diff --git a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts index 6ec386582170..6e363df5f1dd 100644 --- a/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts +++ b/apps/server/src/orchestration-v2/testkit/ProviderSwitch.integration.test.ts @@ -2543,6 +2543,7 @@ describe("orchestration v2 provider switching", () => { Effect.gen(function* () { const cwd = yield* checkpointWorkspace("provider-switch"); const capturedTurns = yield* Ref.make>([]); + const codexNativeThreadGeneration = yield* Ref.make(0); const registryLayer = makeProviderAdapterRegistryLayer([ makeTestAdapter({ instanceId: ProviderInstanceId.make("codex"), @@ -2555,6 +2556,7 @@ describe("orchestration v2 provider switching", () => { }, capturedTurns, failResume: true, + nativeThreadGeneration: codexNativeThreadGeneration, }), makeTestAdapter({ instanceId: ProviderInstanceId.make("claudeAgent"), @@ -2629,6 +2631,20 @@ describe("orchestration v2 provider switching", () => { CLAUDE_MODEL_SELECTION, ); yield* waitForIdle(threadId); + // Stopping the shared Codex process drops its loaded native thread, so + // returning to Codex has to resume it (and fall back when that fails). + const codexSession = (yield* orchestrator.getThreadProjection( + threadId, + )).providerSessions.find( + (session) => session.providerInstanceId === CODEX_MODEL_SELECTION.instanceId, + )!; + yield* orchestrator.dispatch({ + type: "provider-session.detach", + commandId: CommandId.make("command:provider-switch:stop-codex"), + threadId, + providerSessionId: codexSession.id, + }); + yield* (yield* OrchestrationEffectWorkerV2).drain(); yield* orchestrator.dispatch(commands[3]!); assert.deepEqual( (yield* orchestrator.getThreadProjection(threadId)).thread.modelSelection, @@ -2667,9 +2683,24 @@ describe("orchestration v2 provider switching", () => { assert.lengthOf(projection.providerThreads, 2); assert.equal(projection.runs[0]?.providerThreadId, projection.runs[2]?.providerThreadId); assert.notEqual(projection.runs[0]?.providerThreadId, projection.runs[1]?.providerThreadId); + // The failed resume bound a fresh native Codex thread to the same row. + assert.equal(yield* Ref.get(codexNativeThreadGeneration), 2); + const codexThread = projection.providerThreads.find( + (providerThread) => providerThread.id === projection.runs[2]?.providerThreadId, + ); + assert.equal(codexThread?.nativeThreadRef?.nativeId, `codex:${threadId}:1`); assert.deepEqual( - projection.contextHandoffs.map((handoff) => handoff.strategy), - ["full_thread_summary", "delta_since_target_last_seen"], + projection.contextHandoffs.map((handoff) => [ + handoff.strategy, + handoff.coveredRunOrdinals, + handoff.delivery?.status, + handoff.delivery?.nativeThreadId, + ]), + [ + ["full_thread_summary", { from: 1, to: 1 }, "inline", `claudeAgent:${threadId}`], + ["delta_since_target_last_seen", { from: 2, to: 2 }, "inline", `codex:${threadId}:1`], + ["full_thread_summary", { from: 1, to: 2 }, "inline", `codex:${threadId}:1`], + ], ); assert.deepEqual( projection.contextTransfers.map((transfer) => [ @@ -2680,6 +2711,7 @@ describe("orchestration v2 provider switching", () => { [ ["provider_handoff", "consumed", "portable_context"], ["provider_handoff", "consumed", "delta_context"], + ["provider_handoff", "resolved_portable", "portable_context"], ], ); assert.deepEqual( @@ -2703,10 +2735,13 @@ describe("orchestration v2 provider switching", () => { assert.include(turns[1]?.text ?? "", "Context handoff (full_thread_summary):"); assert.include(turns[1]?.text ?? "", "codex before switch"); assert.include(turns[1]?.text ?? "", claudePrompt); + // The fresh native thread has none of the earlier Codex turn, so the + // portable fallback re-sends it alongside the Claude delta. + assert.include(turns[2]?.text ?? "", "Context handoff (full_thread_summary):"); assert.include(turns[2]?.text ?? "", "Context handoff (delta_since_target_last_seen):"); + assert.include(turns[2]?.text ?? "", "codex before switch"); assert.include(turns[2]?.text ?? "", "claude switched response"); assert.include(turns[2]?.text ?? "", returnPrompt); - assert.notInclude(turns[2]?.text ?? "", "codex before switch"); assert.equal(turns[0]?.providerThreadId, turns[2]?.providerThreadId); }), ),