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 =