From 8a5281093bdfa57229c002a9d88d213a84be18c4 Mon Sep 17 00:00:00 2001 From: ECuteri <155808593+ECuteri@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:01:58 +0000 Subject: [PATCH 1/2] fix(claude): say when Claude Code runs a Fast request at standard speed Claude Code drops a requested Fast mode to standard speed without failing the turn: it reports fast_mode_state off on init when the account cannot use it, or keeps reporting on and only sends a fast-mode-overage-rejected notification when the API refuses the overage. Surface either signal once per Claude process as a system notice. Fixes #30106 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 81 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 30 +++++-- 2 files changed, 106 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 45d21b5944e24..f9737c17f6933 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7343,6 +7343,87 @@ 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, + ), + ), + ), + ); + 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 674f55c524d75..ca940a271881e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2996,6 +2996,17 @@ 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") return null; + if (message.subtype === "notification") { + return message.key.startsWith("fast-mode") ? message.text : null; + } + if (message.subtype !== "init" || message.fast_mode_state !== "off") return null; + const reason = message.fast_mode_disabled_reason?.replaceAll("_", " "); + return `Fast mode is off${reason === undefined ? "" : ` (${reason})`}, using standard speed`; +} + function isClaudeTurnStartMessage(message: SDKMessage): boolean { return message.type === "system" && message.subtype === "init"; } @@ -4190,6 +4201,7 @@ interface ClaudeLiveQueryContext { // The CLI may fall back from the requested mode on its first init frame. openedPermissionMode: PermissionMode; receivedInit: boolean; + announcedFastModeNotice: boolean; permissionMode: PermissionMode; stopping: boolean; readonly subagentsFromEarlierProcesses: ReadonlySet; @@ -8878,14 +8890,21 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } - if ( + const fastModeNotice = + liveQuery.selection.settings.fastMode === true && !liveQuery.announcedFastModeNotice + ? claudeFastModeNotice(message) + : null; + const notice = message.type === "system" && (message.subtype === "model_refusal_fallback" || (message.subtype === "informational" && message.level !== "info")) - ) { + ? message.content + : fastModeNotice; + if (notice !== null && message.type === "system") { + if (fastModeNotice !== null) liveQuery.announcedFastModeNotice = true; 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, @@ -8908,8 +8927,8 @@ 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, @@ -10590,6 +10609,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( promptEchoMode: "unknown", openedPermissionMode: queryOptions.permissionMode, receivedInit: false, + announcedFastModeNotice: false, permissionMode: queryOptions.permissionMode, stopping: false, handedOffSubagents: new Set(), From a938c505a4ca1b2299b8e75fb3ceca1be6ceec69 Mon Sep 17 00:00:00 2001 From: ECuteri <155808593+ECuteri@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:33:26 +0000 Subject: [PATCH 2/2] fix(claude): read Fast state from results and wait past pending Read fast_mode_state from result frames too, so Fast turned on in a process whose init predates it is still reported, and do not treat a pending decision as a downgrade. Cover the overage-rejected notification in its own test. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 101 ++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 37 ++++--- 2 files changed, 124 insertions(+), 14 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index f9737c17f6933..c22d5d2b4d92d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -7424,6 +7424,107 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + 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 ca940a271881e..0161c65ea38f1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2998,13 +2998,20 @@ function isClaudeBackgroundTasksChangedMessage(message: SDKMessage): boolean { // Claude Code drops a requested Fast mode to standard speed without failing the turn. function claudeFastModeNotice(message: SDKMessage): string | null { - if (message.type !== "system") return null; - if (message.subtype === "notification") { + if (message.type === "system" && message.subtype === "notification") { return message.key.startsWith("fast-mode") ? message.text : null; } - if (message.subtype !== "init" || message.fast_mode_state !== "off") return null; - const reason = message.fast_mode_disabled_reason?.replaceAll("_", " "); - return `Fast mode is off${reason === undefined ? "" : ` (${reason})`}, using standard speed`; + // 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 { @@ -8890,18 +8897,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return; } - const fastModeNotice = - liveQuery.selection.settings.fastMode === true && !liveQuery.announcedFastModeNotice - ? claudeFastModeNotice(message) - : null; - const notice = + const cliNotice = message.type === "system" && (message.subtype === "model_refusal_fallback" || (message.subtype === "informational" && message.level !== "info")) ? message.content - : fastModeNotice; - if (notice !== null && message.type === "system") { - if (fastModeNotice !== null) liveQuery.announcedFastModeNotice = true; + : 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(notice); @@ -8934,8 +8942,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( 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;