From 70b14067c17d23bdfee0307ab4c0d9e9866696f4 Mon Sep 17 00:00:00 2001 From: Xab3r Date: Wed, 11 Nov 2026 18:59:52 +0100 Subject: [PATCH] fix(server): switching the Claude model no longer blocks a later provider switch The Claude CLI answers setModel with a replayed local-command user frame. It arrives before the next turn attaches, so the adapter buffered it as the start of a wake. Nothing drains that buffer on a user turn, so the session reported background work until the process exited and every provider switch was refused. Drop that echo when no turn is active. Co-Authored-By: Claude --- .../Adapters/ClaudeAdapterV2.test.ts | 72 ++++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 11 +++ 2 files changed, 82 insertions(+), 1 deletion(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 155370da1757d..174b84cf05cb9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3365,6 +3365,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly close?: (sdkMessages: Queue.Queue) => Effect.Effect; readonly interrupt?: Effect.Effect; readonly setPermissionMode?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["setPermissionMode"]; + readonly setModel?: ( + model: string, + offerAndWait: (message: SDKMessage) => Effect.Effect, + ) => Effect.Effect; readonly applyFlagSettings?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["applyFlagSettings"]; readonly stopTask?: ( taskId: string, @@ -3526,7 +3530,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { setModel: (model) => Effect.sync(() => { liveSelectionChanges.push({ model }); - }), + }).pipe( + Effect.andThen(() => options?.setModel?.(model, offerAndWait) ?? Effect.void), + ), applyFlagSettings: (settings) => Effect.sync(() => { liveSelectionChanges.push(settings); @@ -15519,6 +15525,70 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("does not count the echo of a live model switch as background work", () => + Effect.gen(function* () { + const idleUserFrame = (uuid: string, content: string, isReplay: boolean) => + claudeSdkFrame({ + type: "user", + message: { role: "user", content }, + parent_tool_use_id: null, + ...(isReplay ? { isReplay } : {}), + uuid, + session_id: WAKE_NATIVE_SESSION, + }); + // As the CLI does, answer setModel with its echo before the next turn attaches. + const harness = yield* makeWakeHarnessWithOptions({ + setModel: (model, offerAndWait) => + offerAndWait( + idleUserFrame( + "00000000-0000-4000-8000-000000000b01", + `Set model to \`${model}\``, + true, + ), + ), + }); + const now = yield* DateTime.now; + const turn = (ordinal: number, model: string) => + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-claude-model-switch-echo:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + modelSelection: { instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, model }, + }); + const settle = Effect.gen(function* () { + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + }); + yield* harness.runtime.startTurn(turn(1, "claude-opus-5-5")); + yield* settle; + yield* harness.runtime.startTurn(turn(2, "claude-sonnet-4-6")); + yield* settle; + + assert.deepInclude(harness.liveSelectionChanges, { model: "claude-sonnet-4-6" }); + assert.lengthOf(harness.continuationRequests, 0); + assert.isFalse(yield* harness.hasPendingBackgroundWork); + + // A root user frame the CLI produced on its own still marks a wake. + yield* harness.offerAndWait( + idleUserFrame("00000000-0000-4000-8000-000000000b02", "A background task finished.", false), + ); + assert.isTrue(yield* harness.hasPendingBackgroundWork); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + it.effect( "emits one interrupted terminal for a positive task-notification result racing interrupt", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b3c1fd1c1fed4..cab5959c47cd3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3520,6 +3520,14 @@ const CLAUDE_SCHEDULED_WAKEUP_TASK: OrchestrationV2PendingBackgroundTask = { // most 60 s before the minute-rounded scheduledFor it reports; the rest is slack. const CLAUDE_SCHEDULED_WAKEUP_EARLY_FIRE_MARGIN_MS = 120_000; +// The CLI answers a control request such as setModel with a replayed local-command user frame. +// It belongs to no turn, so on an idle session it is not the start of a wake. +function isClaudeLocalCommandEcho(message: SDKMessage): boolean { + if (message.type !== "user" || !("isReplay" in message) || !message.isReplay) return false; + const content = message.message.content; + return typeof content === "string" && content.startsWith(""); +} + function claudePendingBackgroundTasksFromRoster( roster: ReadonlyMap, awaitedTaskIds: ReadonlySet, @@ -9977,6 +9985,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( (yield* Ref.get(activeTurn)) ?? (yield* settledTurnForSubagentFrame(liveQuery.nativeThreadId, message)); if (context === null) { + if (isClaudeLocalCommandEcho(message)) { + return; + } yield* applyWorkflowProgressWithoutTurn(message); // Buffer wake evidence before clearing the task from the roster. if (message.type === "system" && message.subtype === "task_notification") {