diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index bc70c5bf7a16..581e062edead 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -8051,6 +8051,188 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("tells the user once when Claude Code runs a Fast turn at standard speed", () => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + const notices = () => + harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem.message] + : [], + ); + const runTurn = (ordinal: number, fastMode: boolean) => + Effect.gen(function* () { + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`claude-fast-mode-off-attempt:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + modelSelection: { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + options: [{ id: "fastMode", value: fastMode }], + }, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "init", + fast_mode_state: "off", + fast_mode_disabled_reason: "extra_usage_disabled", + session_id: WAKE_NATIVE_SESSION, + uuid: `00000000-0000-4000-8000-00000000041${ordinal}`, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + claudeSdkFrame({ + type: "system", + subtype: "notification", + key: "fast-mode-overage-rejected", + text: "Fast mode disabled · Out of usage credits", + priority: "immediate", + session_id: WAKE_NATIVE_SESSION, + uuid: `00000000-0000-4000-8000-00000000042${ordinal}`, + }), + ); + yield* Queue.offer( + harness.sdkMessages, + makeResultFrame({ + uuid: `00000000-0000-4000-8000-00000000043${ordinal}`, + result: `Done ${ordinal}`, + }), + ); + yield* Queue.take(harness.terminalReceipts); + }); + + yield* runTurn(1, false); + assert.deepEqual(notices(), []); + yield* runTurn(2, true); + yield* runTurn(3, true); + assert.deepEqual(notices(), [ + "Fast mode is off (extra usage disabled), using standard speed", + ]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + IdAllocator.layer, + McpProviderSessions.layer, + ), + ), + ), + ); + + const runFastClaudeTurn = (frames: ReadonlyArray) => + Effect.gen(function* () { + const harness = yield* makeWakeHarness; + const now = yield* DateTime.now; + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make("claude-fast-mode-frames-attempt"), + text: "Request", + attachments: [], + modelSelection: { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-opus-5-5", + options: [{ id: "fastMode", value: true }], + }, + }), + ); + for (const frame of frames) { + yield* Queue.offer(harness.sdkMessages, claudeSdkFrame(frame)); + } + yield* Queue.take(harness.terminalReceipts); + return harness.events.flatMap((event) => + event.type === "turn_item.updated" && event.turnItem.type === "system_notice" + ? [event.turnItem.message] + : [], + ); + }); + + it.effect( + "shows Claude Code's notice when the API rejects Fast while the CLI reports it on", + () => + Effect.gen(function* () { + const notices = yield* runFastClaudeTurn([ + { + type: "system", + subtype: "init", + fast_mode_state: "on", + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000000451", + }, + { + type: "system", + subtype: "notification", + key: "fast-mode-overage-rejected", + text: "Fast mode disabled · Out of usage credits", + priority: "immediate", + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000000452", + }, + { + ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000453", result: "Done" }), + fast_mode_state: "on", + }, + ]); + assert.deepEqual(notices, ["Fast mode disabled · Out of usage credits"]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + IdAllocator.layer, + McpProviderSessions.layer, + ), + ), + ), + ); + + it.effect("waits past a pending Fast decision and reads the state from the result", () => + Effect.gen(function* () { + const notices = yield* runFastClaudeTurn([ + { + type: "system", + subtype: "init", + fast_mode_state: "off", + fast_mode_disabled_reason: "pending", + session_id: WAKE_NATIVE_SESSION, + uuid: "00000000-0000-4000-8000-000000000461", + }, + { + ...makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000462", result: "Done" }), + fast_mode_state: "off", + fast_mode_disabled_reason: "extra_usage_disabled", + }, + ]); + assert.deepEqual(notices, ["Fast mode is off (extra usage disabled), using standard speed"]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + NodeCrypto.layer, + IdAllocator.layer, + McpProviderSessions.layer, + ), + ), + ), + ); + it.effect( "runs native compaction and keeps its context watermark separate from billed usage", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3c6295494ec8..91f839a3dfd6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3222,6 +3222,24 @@ function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean { return message.type === "system" && (message.subtype as string) === "background_tasks_changed"; } +// Claude Code drops a requested Fast mode to standard speed without failing the turn. +function claudeFastModeNotice(message: SDKMessage): string | null { + if (message.type === "system" && message.subtype === "notification") { + return message.key.startsWith("fast-mode") ? message.text : null; + } + // Result frames carry the state too, for Fast turned on in a process whose init predates it. + const stateFrame = + message.type === "result" || (message.type === "system" && message.subtype === "init") + ? message + : null; + if (stateFrame?.fast_mode_state !== "off") return null; + const reason = stateFrame.fast_mode_disabled_reason; + // pending: Claude Code has not decided yet, so a later frame may still turn Fast on. + if (reason === "pending") return null; + const label = reason?.replaceAll("_", " "); + return `Fast mode is off${label === undefined ? "" : ` (${label})`}, using standard speed`; +} + function isClaudeTurnStartMessage(message: SDKMessage): boolean { return message.type === "system" && message.subtype === "init"; } @@ -4424,6 +4442,7 @@ interface ClaudeLiveQueryContext { // The CLI may fall back from the requested mode on its first init frame. openedPermissionMode: PermissionMode; receivedInit: boolean; + announcedFastModeNotice: boolean; // From system/init; feature-detects control requests such as MCP Apps. capabilities: ReadonlySet; permissionMode: PermissionMode; @@ -9211,14 +9230,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } - if ( + const cliNotice = message.type === "system" && (message.subtype === "model_refusal_fallback" || (message.subtype === "informational" && message.level !== "info")) - ) { + ? message.content + : null; + const fastModeNotice = + liveQuery.selection.settings.fastMode === true && !liveQuery.announcedFastModeNotice + ? claudeFastModeNotice(message) + : null; + if (fastModeNotice !== null) liveQuery.announcedFastModeNotice = true; + for (const notice of [cliNotice, fastModeNotice]) { + if (notice === null || !("uuid" in message)) continue; const now = yield* DateTime.now; const nativeItemId = message.uuid; - context.assistant.noticeTexts.add(message.content); + context.assistant.noticeTexts.add(notice); yield* emitProviderEvent({ type: "turn_item.updated", driver: CLAUDE_PROVIDER, @@ -9241,15 +9268,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ordinal: yield* resolveItemOrdinal(context, nativeItemId), type: "system_notice", status: "completed", - title: message.content, - message: message.content, + title: notice, + message: notice, startedAt: now, completedAt: now, updatedAt: now, }, }); - return; } + // Fast-mode frames (init, result) still need their normal handling below. + if (cliNotice !== null) return; if (message.type === "system" && message.subtype === "api_retry") { const updatedAt = yield* DateTime.now; @@ -10935,6 +10963,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, receivedInit: false, + announcedFastModeNotice: false, capabilities: new Set(), permissionMode: queryOptions.permissionMode, stopping: false,