| ... | ... | @@ -16,6 +16,7 @@ import { |
| 16 | 16 | DEFAULT_PROVIDER_INTERACTION_MODE, |
| 17 | 17 | EventId, |
| 18 | 18 | MessageId, |
| 19 | type OrchestrationCommand, |
| 19 | 20 | ProjectId, |
| 20 | 21 | ProviderItemId, |
| 21 | 22 | type ServerSettings, |
| ... | ... | @@ -256,57 +257,52 @@ describe("ProviderRuntimeIngestion", () => { |
| 256 | 257 | scope = await Effect.runPromise(Scope.make("sequential")); |
| 257 | 258 | await Effect.runPromise(ingestion.start().pipe(Scope.provide(scope))); |
| 258 | 259 | const drain = () => Effect.runPromise(ingestion.drain); |
| 260 | const dispatch = (command: OrchestrationCommand) => Effect.runPromise(engine.dispatch(command)); |
| 259 | 261 | |
| 260 | 262 | const createdAt = "2026-01-01T00:00:00.000Z"; |
| 261 | | await Effect.runPromise( |
| 262 | | engine.dispatch({ |
| 263 | | type: "project.create", |
| 264 | | commandId: CommandId.make("cmd-provider-project-create"), |
| 265 | | projectId: asProjectId("project-1"), |
| 266 | | title: "Provider Project", |
| 267 | | workspaceRoot, |
| 268 | | defaultModelSelection: { |
| 269 | | instanceId: ProviderInstanceId.make("codex"), |
| 270 | | model: "gpt-5-codex", |
| 271 | | }, |
| 272 | | createdAt, |
| 273 | | }), |
| 274 | | ); |
| 275 | | await Effect.runPromise( |
| 276 | | engine.dispatch({ |
| 277 | | type: "thread.create", |
| 278 | | commandId: CommandId.make("cmd-thread-create"), |
| 263 | await dispatch({ |
| 264 | type: "project.create", |
| 265 | commandId: CommandId.make("cmd-provider-project-create"), |
| 266 | projectId: asProjectId("project-1"), |
| 267 | title: "Provider Project", |
| 268 | workspaceRoot, |
| 269 | defaultModelSelection: { |
| 270 | instanceId: ProviderInstanceId.make("codex"), |
| 271 | model: "gpt-5-codex", |
| 272 | }, |
| 273 | createdAt, |
| 274 | }); |
| 275 | await dispatch({ |
| 276 | type: "thread.create", |
| 277 | commandId: CommandId.make("cmd-thread-create"), |
| 278 | threadId: ThreadId.make("thread-1"), |
| 279 | projectId: asProjectId("project-1"), |
| 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 | 296 | threadId: ThreadId.make("thread-1"), |
| 280 | | projectId: asProjectId("project-1"), |
| 281 | | title: "Thread", |
| 282 | | modelSelection: { |
| 283 | | instanceId: ProviderInstanceId.make("codex"), |
| 284 | | model: "gpt-5-codex", |
| 285 | | }, |
| 286 | | interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, |
| 297 | status: "ready", |
| 298 | providerName: "codex", |
| 287 | 299 | runtimeMode: "approval-required", |
| 288 | | branch: null, |
| 289 | | worktreePath: null, |
| 290 | | createdAt, |
| 291 | | }), |
| 292 | | ); |
| 293 | | await Effect.runPromise( |
| 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 | | ); |
| 300 | activeTurnId: null, |
| 301 | updatedAt: createdAt, |
| 302 | lastError: null, |
| 303 | }, |
| 304 | createdAt, |
| 305 | }); |
| 310 | 306 | provider.setSession({ |
| 311 | 307 | provider: ProviderDriverKind.make("codex"), |
| 312 | 308 | status: "ready", |
| ... | ... | @@ -318,6 +314,7 @@ describe("ProviderRuntimeIngestion", () => { |
| 318 | 314 | |
| 319 | 315 | return { |
| 320 | 316 | engine, |
| 317 | dispatch, |
| 321 | 318 | readModel: () => Effect.runPromise(snapshotQuery.getSnapshot()), |
| 322 | 319 | emit: provider.emit, |
| 323 | 320 | setProviderSession: provider.setSession, |
| ... | ... | @@ -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 | 919 | it("ignores non-active turn completion when runtime omits thread id", async () => { |
| 847 | 920 | const harness = await createHarness(); |
| 848 | 921 | const now = "2026-01-01T00:00:00.000Z"; |