| ... | @@ -16,6 +16,7 @@ import { | ... | @@ -16,6 +16,7 @@ import { |
| 16 | DEFAULT_PROVIDER_INTERACTION_MODE, | 16 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 17 | EventId, | 17 | EventId, |
| 18 | MessageId, | 18 | MessageId, |
| | 19 | type OrchestrationCommand, |
| 19 | ProjectId, | 20 | ProjectId, |
| 20 | ProviderItemId, | 21 | ProviderItemId, |
| 21 | type ServerSettings, | 22 | type ServerSettings, |
| ... | @@ -256,57 +257,52 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -256,57 +257,52 @@ describe("ProviderRuntimeIngestion", () => { |
| 256 | scope = await Effect.runPromise(Scope.make("sequential")); | 257 | scope = await Effect.runPromise(Scope.make("sequential")); |
| 257 | await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); | 258 | await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); |
| 258 | const drain = () => Effect.runPromise(ingestion.drain); | 259 | const drain = () => Effect.runPromise(ingestion.drain); |
| | 260 | const dispatch = (command: OrchestrationCommand) => Effect.runPromise(engine.dispatch(command)); |
| 259 | | 261 | |
| 260 | const createdAt = "2026-01-01T00:00:00.000Z"; | 262 | const createdAt = "2026-01-01T00:00:00.000Z"; |
| 261 | await Effect.runPromise( | 263 | await dispatch({ |
| 262 | engine.dispatch({ | 264 | type: "project.create", |
| 263 | type: "project.create", | 265 | commandId: CommandId.make("cmd-provider-project-create"), |
| 264 | commandId: CommandId.make("cmd-provider-project-create"), | 266 | projectId: asProjectId("project-1"), |
| 265 | projectId: asProjectId("project-1"), | 267 | title: "Provider Project", |
| 266 | title: "Provider Project", | 268 | workspaceRoot, |
| 267 | workspaceRoot, | 269 | defaultModelSelection: { |
| 268 | defaultModelSelection: { | 270 | instanceId: ProviderInstanceId.make("codex"), |
| 269 | instanceId: ProviderInstanceId.make("codex"), | 271 | model: "gpt-5-codex", |
| 270 | model: "gpt-5-codex", | 272 | }, |
| 271 | }, | 273 | createdAt, |
| 272 | createdAt, | 274 | }); |
| 273 | }), | 275 | await dispatch({ |
| 274 | ); | 276 | type: "thread.create", |
| 275 | await Effect.runPromise( | 277 | commandId: CommandId.make("cmd-thread-create"), |
| 276 | engine.dispatch({ | 278 | threadId: ThreadId.make("thread-1"), |
| 277 | type: "thread.create", | 279 | projectId: asProjectId("project-1"), |
| 278 | commandId: CommandId.make("cmd-thread-create"), | 280 | title: "Thread", |
| | 281 | modelSelection: { |
| | 282 | instanceId: ProviderInstanceId.make("codex"), |
| | 283 | model: "gpt-5-codex", |
| | 284 | }, |
| | 285 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| | 286 | runtimeMode: "approval-required", |
| | 287 | branch: null, |
| | 288 | worktreePath: null, |
| | 289 | createdAt, |
| | 290 | }); |
| | 291 | await dispatch({ |
| | 292 | type: "thread.session.set", |
| | 293 | commandId: CommandId.make("cmd-session-seed"), |
| | 294 | threadId: ThreadId.make("thread-1"), |
| | 295 | session: { |
| 279 | threadId: ThreadId.make("thread-1"), | 296 | threadId: ThreadId.make("thread-1"), |
| 280 | projectId: asProjectId("project-1"), | 297 | status: "ready", |
| 281 | title: "Thread", | 298 | providerName: "codex", |
| 282 | modelSelection: { | | |
| 283 | instanceId: ProviderInstanceId.make("codex"), | | |
| 284 | model: "gpt-5-codex", | | |
| 285 | }, | | |
| 286 | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, | | |
| 287 | runtimeMode: "approval-required", | 299 | runtimeMode: "approval-required", |
| 288 | branch: null, | 300 | activeTurnId: null, |
| 289 | worktreePath: null, | 301 | updatedAt: createdAt, |
| 290 | createdAt, | 302 | lastError: null, |
| 291 | }), | 303 | }, |
| 292 | ); | 304 | createdAt, |
| 293 | await Effect.runPromise( | 305 | }); |
| 294 | engine.dispatch({ | | |
| 295 | type: "thread.session.set", | | |
| 296 | commandId: CommandId.make("cmd-session-seed"), | | |
| 297 | threadId: ThreadId.make("thread-1"), | | |
| 298 | session: { | | |
| 299 | threadId: ThreadId.make("thread-1"), | | |
| 300 | status: "ready", | | |
| 301 | providerName: "codex", | | |
| 302 | runtimeMode: "approval-required", | | |
| 303 | activeTurnId: null, | | |
| 304 | updatedAt: createdAt, | | |
| 305 | lastError: null, | | |
| 306 | }, | | |
| 307 | createdAt, | | |
| 308 | }), | | |
| 309 | ); | | |
| 310 | provider.setSession({ | 306 | provider.setSession({ |
| 311 | provider: ProviderDriverKind.make("codex"), | 307 | provider: ProviderDriverKind.make("codex"), |
| 312 | status: "ready", | 308 | status: "ready", |
| ... | @@ -318,6 +314,7 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -318,6 +314,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 318 | | 314 | |
| 319 | return { | 315 | return { |
| 320 | engine, | 316 | engine, |
| | 317 | dispatch, |
| 321 | readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), | 318 | readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), |
| 322 | emit: provider.emit, | 319 | emit: provider.emit, |
| 323 | setProviderSession: provider.setSession, | 320 | setProviderSession: provider.setSession, |
| ... | @@ -843,6 +840,82 @@ describe("ProviderRuntimeIngestion", () => { | ... | @@ -843,6 +840,82 @@ describe("ProviderRuntimeIngestion", () => { |
| 843 | ); | 840 | ); |
| 844 | }); | 841 | }); |
| 845 | | 842 | |
| | 843 | it("rejects an untargeted turn.completed when no turn is active", async () => { |
| | 844 | const harness = await createHarness(); |
| | 845 | const seededAt = "2026-01-01T00:00:00.000Z"; |
| | 846 | |
| | 847 | // A turn start is pending: the session reads "starting" with no active |
| | 848 | // turn tracked yet. This is the window the Claude resume handshake's |
| | 849 | // phantom (turn.completed with no turnId) used to slip through, stomping |
| | 850 | // "starting" back to "ready" for a turn that never existed. |
| | 851 | await harness.dispatch({ |
| | 852 | type: "thread.session.set", |
| | 853 | commandId: CommandId.make("cmd-session-seed-untargeted-completion"), |
| | 854 | threadId: ThreadId.make("thread-1"), |
| | 855 | session: { |
| | 856 | threadId: ThreadId.make("thread-1"), |
| | 857 | status: "starting", |
| | 858 | providerName: "claudeAgent", |
| | 859 | runtimeMode: "approval-required", |
| | 860 | activeTurnId: null, |
| | 861 | updatedAt: seededAt, |
| | 862 | lastError: null, |
| | 863 | }, |
| | 864 | createdAt: seededAt, |
| | 865 | }); |
| | 866 | |
| | 867 | harness.emit({ |
| | 868 | type: "turn.completed", |
| | 869 | eventId: asEventId("evt-turn-completed-untargeted"), |
| | 870 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 871 | createdAt: seededAt, |
| | 872 | threadId: asThreadId("thread-1"), |
| | 873 | status: "completed", |
| | 874 | }); |
| | 875 | |
| | 876 | await harness.drain(); |
| | 877 | const readModel = await harness.readModel(); |
| | 878 | const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); |
| | 879 | expect(thread?.session?.status).toBe("starting"); |
| | 880 | expect(thread?.session?.activeTurnId).toBeNull(); |
| | 881 | }); |
| | 882 | |
| | 883 | it("accepts a targeted turn.completed when no turn is active", async () => { |
| | 884 | const harness = await createHarness(); |
| | 885 | const seededAt = "2026-01-01T00:00:00.000Z"; |
| | 886 | |
| | 887 | // A completion that names its turn still lands even when no active turn |
| | 888 | // is tracked (e.g. its turn.started was lost). Only untargeted |
| | 889 | // completions are rejected. |
| | 890 | await harness.dispatch({ |
| | 891 | type: "thread.session.set", |
| | 892 | commandId: CommandId.make("cmd-session-seed-targeted-completion"), |
| | 893 | threadId: ThreadId.make("thread-1"), |
| | 894 | session: { |
| | 895 | threadId: ThreadId.make("thread-1"), |
| | 896 | status: "starting", |
| | 897 | providerName: "claudeAgent", |
| | 898 | runtimeMode: "approval-required", |
| | 899 | activeTurnId: null, |
| | 900 | updatedAt: seededAt, |
| | 901 | lastError: null, |
| | 902 | }, |
| | 903 | createdAt: seededAt, |
| | 904 | }); |
| | 905 | |
| | 906 | harness.emit({ |
| | 907 | type: "turn.completed", |
| | 908 | eventId: asEventId("evt-turn-completed-targeted-late"), |
| | 909 | provider: ProviderDriverKind.make("claudeAgent"), |
| | 910 | createdAt: seededAt, |
| | 911 | threadId: asThreadId("thread-1"), |
| | 912 | turnId: asTurnId("turn-late"), |
| | 913 | status: "completed", |
| | 914 | }); |
| | 915 | |
| | 916 | await waitForThread(harness.readModel, (thread) => thread.session?.status === "ready"); |
| | 917 | }); |
| | 918 | |
| 846 | it("ignores non-active turn completion when runtime omits thread id", async () => { | 919 | it("ignores non-active turn completion when runtime omits thread id", async () => { |
| 847 | const harness = await createHarness(); | 920 | const harness = await createHarness(); |
| 848 | const now = "2026-01-01T00:00:00.000Z"; | 921 | const now = "2026-01-01T00:00:00.000Z"; |