From 7cbad6d7da305a8b7323cc616955f419bc0c1abd Mon Sep 17 00:00:00 2001 From: Julius Marminge <51714798+juliusmarminge@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:53:20 -0700 Subject: [PATCH] test(server): make the provider-switch resume-failure test reach the fallback "uses portable fallback when native resume fails after a provider switch" never called resumeThread: the shared Codex session kept its native thread loaded across the Claude turn, so returning to Codex reused it warm and failResume had no effect. Stop the Codex session before returning, as the client's stop-session action does, so the return must resume, fail, and take the portable fallback. Assert the fallback's effects: a fresh native thread on the same provider-thread row, a resolved portable transfer, and a full_thread_summary handoff that re-sends the earlier Codex turn. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../ProviderSwitch.integration.test.ts | 41 +++++++++++++++++-- 1 file changed, 38 insertions(+), 3 deletions(-) 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); }), ),