diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index c44993035afa2..61fe46085dfc0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3453,6 +3453,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 renameSession?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["renameSession"]; readonly applyFlagSettings?: ClaudeAdapterV2.ClaudeAgentSdkQuerySession["applyFlagSettings"]; readonly stopTask?: ( @@ -3622,7 +3626,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); @@ -16715,6 +16721,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 8080c2a5fbc6c..3775ddd738b86 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3675,6 +3675,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, @@ -10360,6 +10368,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") {