From 4db40b730f79b8ef4530f5a3d801af88a55ea5da Mon Sep 17 00:00:00 2001 From: jalay-mantys Date: Sat, 14 Nov 2026 15:36:04 +0530 Subject: [PATCH] fix(server): a Claude model change no longer blocks the next provider switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude Code replays set_model's "Set model to …" as a user frame before the next turn attaches. The adapter buffered it as wake evidence, so hasPendingBackgroundWork stayed true and switching provider was refused after Stop. Made with Claude Opus 5.5 in Claude Code. --- .../Adapters/ClaudeAdapterV2.test.ts | 46 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 13 ++++++ 2 files changed, 59 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f0c4d00c7bdf3..3bb0aaf7fe549 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6555,6 +6555,52 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("does not count a live model change's stdout echo as background work", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-model-change-echo"), + text: "Hello", + attachments: [], + }), + ); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + // Claude Code answers set_model with this replayed user frame before the next turn attaches. + yield* harness.offerAndWait( + claudeSdkFrame({ + type: "user", + message: { + role: "user", + content: + "Set model to `claude-fable-5-2`", + }, + parent_tool_use_id: null, + isReplay: true, + uuid: "00000000-0000-4000-8000-000000000e01", + session_id: WAKE_NATIVE_SESSION, + }), + ); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect.each([ { content: { type: "advisor_tool_result_error", error_code: "overloaded" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 74cdde264e7e1..4342149a8ace2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3618,6 +3618,17 @@ function isClaudeTurnStartMessage(message: SDKMessage): boolean { return message.type === "system" && message.subtype === "init"; } +// Claude Code replays a local command's stdout, such as set_model's "Set model to", as a user frame. +function isClaudeLocalCommandEcho(message: SDKMessage): boolean { + return ( + message.type === "user" && + "isReplay" in message && + message.isReplay === true && + typeof message.message.content === "string" && + message.message.content.startsWith("") + ); +} + function isClaudeRootTurnFrame(message: SDKMessage): boolean { return ( isClaudeTurnStartMessage(message) || @@ -9374,6 +9385,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( readonly message: SDKMessage; }) { const message = wakeInput.message; + // An idle echo is no wake: buffered, it reads as background work and blocks switches. + if (isClaudeLocalCommandEcho(message)) return; const isNotification = message.type === "system" && message.subtype === "task_notification"; const isPendingTaskNotification =