diff --git a/src/acp-agent.ts b/src/acp-agent.ts index 9ab81860..71337d35 100644 --- a/src/acp-agent.ts +++ b/src/acp-agent.ts @@ -51,6 +51,7 @@ import { AgentInfo, CanUseTool, deleteSession, + FastModeState, getSessionInfo, getSessionMessages, listSessions, @@ -246,6 +247,10 @@ type Session = { /** The currently selected main-thread agent name, or "default" for the * standard Claude Code agent (no `agent` flag applied). */ currentAgent: string; + /** Whether Fast mode is currently enabled for this session. Tracked as the + * user's intent so it persists across model switches; the Fast mode config + * option is only surfaced while the selected model supports it. */ + fastModeEnabled: boolean; abortController: AbortController; /** Signal the consumer races `query.next()` against. Aborted by cancel() * (after a grace period) to force the active turn to settle "cancelled" when @@ -1348,6 +1353,10 @@ export class ClaudeAcpAgent { case "system": switch (message.subtype) { case "init": + // A fresh `system`/init (e.g. after reinitialize) can carry an + // updated Fast mode state; reconcile it with what we seeded at + // session creation. + await this.syncFastModeState(message.session_id, session, message.fast_mode_state); break; case "status": { if (message.status === "compacting") { @@ -1620,6 +1629,14 @@ export class ClaudeAcpAgent { // slash-command output forwarding) but their cost is real. const isTaskNotification = message.origin?.kind === "task-notification"; + // Reconcile the Fast mode toggle with the SDK's reported state. + // Gated to user-driven turns like every other side effect below; a + // background followup's state lands on the next user turn's result. + // Runs even when the turn errors or was cancelled. + if (!isTaskNotification) { + await this.syncFastModeState(params.sessionId, session, message.fast_mode_state); + } + // A user-turn result needs an active turn so its stop reason is // attributed and the turn settles at idle. Local-only commands carry // no user-message echo to promote them, so do it here from the head. @@ -2383,7 +2400,7 @@ export class ClaudeAcpAgent { } await this.applySessionMode(params.sessionId, params.modeId); - await this.updateConfigOption(params.sessionId, "mode", params.modeId); + await this.updateConfigOption(params.sessionId, MODE_CONFIG_ID, params.modeId); return {}; } @@ -2401,15 +2418,24 @@ export class ClaudeAcpAgent { if (session.queryClosed) { throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE); } - if (typeof params.value !== "string") { - throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`); - } const option = session.configOptions.find((o) => o.id === params.configId); if (!option) { throw new Error(`Unknown config option: ${params.configId}`); } + // Fast mode carries a boolean value (for Clients that opted into boolean + // config options) or the "on"/"off" select fallback, so it bypasses the + // string-only validation the select-style options below rely on. + if (params.configId === FAST_MODE_CONFIG_ID) { + await this.applyFastMode(session, resolveFastModeEnabled(params)); + return { configOptions: session.configOptions }; + } + + if (typeof params.value !== "string") { + throw new Error(`Invalid value for config option ${params.configId}: ${params.value}`); + } + const allValues = "options" in option && Array.isArray(option.options) ? option.options.flatMap((o) => ("options" in o ? o.options : [o])) @@ -2419,7 +2445,7 @@ export class ClaudeAcpAgent { // For model options, fall back to resolveModelPreference when the exact // value doesn't match. This lets callers use human-friendly aliases like // "opus" or "sonnet" instead of full model IDs like "claude-opus-4-6". - if (!validValue && params.configId === "model") { + if (!validValue && params.configId === MODEL_CONFIG_ID) { const modelInfos: ModelInfo[] = allValues.map((o) => ({ value: o.value, displayName: o.name, @@ -2439,7 +2465,7 @@ export class ClaudeAcpAgent { // model ID rather than the caller-supplied alias. const resolvedValue = validValue.value; - if (params.configId === "mode") { + if (params.configId === MODE_CONFIG_ID) { await this.applySessionMode(params.sessionId, resolvedValue); await this.client.sessionUpdate({ sessionId: params.sessionId, @@ -2448,7 +2474,7 @@ export class ClaudeAcpAgent { currentModeId: resolvedValue, }, }); - } else if (params.configId === "model") { + } else if (params.configId === MODEL_CONFIG_ID) { await this.sessions[params.sessionId].query.setModel(resolvedValue); } // Effort SDK sync is handled inside applyConfigOptionValue so that direct @@ -2706,7 +2732,7 @@ export class ClaudeAcpAgent { currentModeId: selectedMode, }, }); - await this.updateConfigOption(sessionId, "mode", selectedMode); + await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode); return { behavior: "allow", @@ -2906,12 +2932,12 @@ export class ClaudeAcpAgent { configId: string, value: string, ): Promise { - if (configId === "mode") { + if (configId === MODE_CONFIG_ID) { session.modes = { ...session.modes, currentModeId: value }; session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, ); - } else if (configId === "model") { + } else if (configId === MODEL_CONFIG_ID) { // `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping // modes below; its `displayName`/`description` also let us infer the // context window for semantic aliases (e.g. `default`) whose ID alone @@ -2961,7 +2987,7 @@ export class ClaudeAcpAgent { } // Rebuild config options since effort levels depend on the selected model - const effortOpt = session.configOptions.find((o) => o.id === "effort"); + const effortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID); const currentEffort = typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined; session.configOptions = buildConfigOptions( @@ -2971,10 +2997,18 @@ export class ClaudeAcpAgent { currentEffort, session.agents, session.currentAgent, + { + // The toggle follows the newly selected model: it disappears when the + // model lacks fast support and reappears (with the retained user + // intent) when a supporting model is selected again. + supported: newModelInfo?.supportsFastMode ?? false, + enabled: session.fastModeEnabled, + useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities), + }, ); // Sync effort with the SDK if it changed after the model switch - const newEffortOpt = session.configOptions.find((o) => o.id === "effort"); + const newEffortOpt = session.configOptions.find((o) => o.id === EFFORT_CONFIG_ID); const newEffort = typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined; if (newEffort !== currentEffort) { @@ -2998,7 +3032,7 @@ export class ClaudeAcpAgent { }, }); } - } else if (configId === "agent") { + } else if (configId === AGENT_CONFIG_ID) { // Live agent switch — no subprocess restart needed. Apply the SDK flag // first so a rejected control request leaves both `currentAgent` and the // config option untouched (no UI/SDK desync). Passing `null` clears the @@ -3015,7 +3049,7 @@ export class ClaudeAcpAgent { session.configOptions = session.configOptions.map((o) => o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o, ); - if (configId === "effort") { + if (configId === EFFORT_CONFIG_ID) { await session.query.applyFlagSettings({ effortLevel: toSdkEffortLevel(value), }); @@ -3023,6 +3057,77 @@ export class ClaudeAcpAgent { } } + /** Replace the Fast mode option in `session.configOptions` so it reflects + * `enabled` (and the client's current boolean-capability). A no-op when the + * option isn't present, so callers must confirm the current model surfaces + * it first. */ + private refreshFastModeOption(session: Session, enabled: boolean): void { + const refreshed = createFastModeConfigOption( + enabled, + clientSupportsBooleanConfigOptions(this.clientCapabilities), + ); + session.configOptions = session.configOptions.map((o) => + o.id === FAST_MODE_CONFIG_ID ? refreshed : o, + ); + } + + /** Toggle Fast mode for a session: push the SDK flag, record the user's + * intent, and refresh the Fast mode config option in place. Only reached + * once the option exists (i.e. the current model supports fast mode), so the + * option is guaranteed to be present in `configOptions`. */ + private async applyFastMode(session: Session, enabled: boolean): Promise { + // Apply the SDK flag first so a rejected control request leaves both the + // session state and the config option untouched (no UI/SDK desync). + await session.query.applyFlagSettings({ fastMode: enabled }); + session.fastModeEnabled = enabled; + this.refreshFastModeOption(session, enabled); + } + + /** Reconcile the session's Fast mode toggle with an SDK-reported + * `fast_mode_state` (delivered on `system`/init and on user-turn `result`s). + * The SDK can flip fast mode independently of the user — e.g. back to `on` + * once a rate-limit `cooldown` clears — so we mirror definitive on/off + * changes into the config option and notify the client. + * + * Guards, in order: + * - absent state: nothing to reconcile. + * - no Fast mode option: the current model doesn't support fast mode, so the + * reported state reflects capability, not the user's intent. Leave the + * retained setting untouched so it's correct when a supporting model is + * reselected (the source of the earlier intent-clobber bug was mutating it + * here). + * - `cooldown`: a transient suspension of an already-enabled fast mode. + * Leave the toggle as-is rather than flapping it — and never let a stray + * cooldown spuriously enable a toggle the user has off. */ + private async syncFastModeState( + sessionId: string, + session: Session, + state: FastModeState | undefined, + ): Promise { + if (state === undefined) { + return; + } + if (!session.configOptions.some((o) => o.id === FAST_MODE_CONFIG_ID)) { + return; + } + if (state === "cooldown") { + return; + } + const enabled = state === "on"; + if (enabled === session.fastModeEnabled) { + return; + } + session.fastModeEnabled = enabled; + this.refreshFastModeOption(session, enabled); + await this.client.sessionUpdate({ + sessionId, + update: { + sessionUpdate: "config_option_update", + configOptions: session.configOptions, + }, + }); + } + private async getOrCreateSession(params: { sessionId: string; cwd: string; @@ -3274,7 +3379,7 @@ export class ClaudeAcpAgent { currentModeId: "plan", }, }); - await this.updateConfigOption(sessionId, "mode", "plan"); + await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan"); }, }), ], @@ -3445,6 +3550,19 @@ export class ClaudeAcpAgent { ? requestedAgent : DEFAULT_AGENT_ID; + // Seed Fast mode from the SDK's reported state so the UI reflects reality + // (the CLI may start a session with fast mode already on, or force it off + // when `fastModePerSessionOptIn` is set). The toggle is only surfaced while + // the resolved model advertises `supportsFastMode`. + const fastModeEnabled = + initializationResult.fast_mode_state !== undefined && + fastModeStateEnabled(initializationResult.fast_mode_state); + const fastMode: FastModeOptionState = { + supported: currentModelInfo?.supportsFastMode ?? false, + enabled: fastModeEnabled, + useBooleanOption: clientSupportsBooleanConfigOptions(this.clientCapabilities), + }; + const configOptions = buildConfigOptions( modes, models, @@ -3452,10 +3570,11 @@ export class ClaudeAcpAgent { settingsManager.getSettings().effortLevel, agents, currentAgent, + fastMode, ); // Apply the initial effort level to the SDK so it matches the UI default - const initialEffort = configOptions.find((o) => o.id === "effort"); + const initialEffort = configOptions.find((o) => o.id === EFFORT_CONFIG_ID); if ( initialEffort && typeof initialEffort.currentValue === "string" && @@ -3484,6 +3603,7 @@ export class ClaudeAcpAgent { configOptions, agents, currentAgent, + fastModeEnabled, abortController, emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false, contextWindowSize: @@ -3704,6 +3824,96 @@ export async function discoverCustomAgents(q: Query): Promise { } } +/** Stable ids for the session config options surfaced via `configOptions`. + * Centralized so the option declarations in `buildConfigOptions` and the + * handlers in `setSessionConfigOption`/`applyConfigOptionValue` reference the + * same identifiers and can't drift apart. */ +export const MODE_CONFIG_ID = "mode"; +export const MODEL_CONFIG_ID = "model"; +export const EFFORT_CONFIG_ID = "effort"; +export const AGENT_CONFIG_ID = "agent"; +export const FAST_MODE_CONFIG_ID = "fast"; + +/** Select-fallback values used when the client has not opted into boolean + * config options (see {@link createFastModeConfigOption}). */ +export const FAST_MODE_ON = "on"; +export const FAST_MODE_OFF = "off"; +const FAST_MODE_DESCRIPTION = "Faster responses on supported models"; + +/** Map the SDK's tri-state `fast_mode_state` onto the boolean config toggle. + * `cooldown` (fast mode temporarily suspended after a rate limit, per the SDK + * docs) keeps the toggle on so it reflects the user's intent — only an + * explicit `off` clears it. */ +export function fastModeStateEnabled(state: FastModeState): boolean { + return state !== "off"; +} + +/** Whether the Client advertised support for boolean session config options + * (`session.configOptions.boolean`). Agents MUST only send `type: "boolean"` + * config options to Clients that opt in; otherwise we fall back to a `select`. + * See https://agentclientprotocol.com/rfds/boolean-config-option. */ +export function clientSupportsBooleanConfigOptions( + clientCapabilities?: ClientCapabilities | null, +): boolean { + return clientCapabilities?.session?.configOptions?.boolean != null; +} + +/** Build the Fast mode config option. When the Client supports boolean config + * options we expose a native `type: "boolean"` toggle; otherwise we degrade to + * a two-value `select` ("on"/"off") so older Clients still get a usable + * control. */ +export function createFastModeConfigOption( + enabled: boolean, + useBooleanOption: boolean, +): SessionConfigOption { + const base = { + id: FAST_MODE_CONFIG_ID, + name: "Fast mode", + description: FAST_MODE_DESCRIPTION, + category: "model_config", + } as const; + + if (useBooleanOption) { + return { ...base, type: "boolean", currentValue: enabled }; + } + + return { + ...base, + type: "select", + currentValue: enabled ? FAST_MODE_ON : FAST_MODE_OFF, + options: [ + { value: FAST_MODE_ON, name: "On" }, + { value: FAST_MODE_OFF, name: "Off" }, + ], + }; +} + +/** Resolve the requested Fast mode value from a `session/set_config_option` + * request. Accepts a native boolean (boolean-capable Clients) or the + * "on"/"off" select-fallback strings. */ +export function resolveFastModeEnabled(params: SetSessionConfigOptionRequest): boolean { + const value = params.value; + if (typeof value === "boolean") { + return value; + } + if (value === FAST_MODE_ON) { + return true; + } + if (value === FAST_MODE_OFF) { + return false; + } + throw new Error(`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`); +} + +/** Per-model Fast mode state threaded into {@link buildConfigOptions}. The + * option is only surfaced when the current model `supported`s fast mode. */ +export type FastModeOptionState = { + supported: boolean; + enabled: boolean; + /** Whether the Client opted into boolean config options. */ + useBooleanOption: boolean; +}; + export function buildConfigOptions( modes: SessionModeState, models: SessionModelState, @@ -3711,10 +3921,11 @@ export function buildConfigOptions( currentEffortLevel?: string, agents: AgentInfo[] = [], currentAgent: string = DEFAULT_AGENT_ID, + fastMode?: FastModeOptionState, ): SessionConfigOption[] { const options: SessionConfigOption[] = [ { - id: "mode", + id: MODE_CONFIG_ID, name: "Mode", description: "Session permission mode", category: "mode", @@ -3727,7 +3938,7 @@ export function buildConfigOptions( })), }, { - id: "model", + id: MODEL_CONFIG_ID, name: "Model", description: "AI model to use", category: "model", @@ -3764,7 +3975,7 @@ export function buildConfigOptions( currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default"; options.push({ - id: "effort", + id: EFFORT_CONFIG_ID, name: "Effort", description: "Available effort levels for this model", category: "thought_level", @@ -3774,13 +3985,20 @@ export function buildConfigOptions( }); } + // Surface the Fast mode toggle only when the current model supports it. The + // option renders as a native boolean toggle for Clients that opted in, and a + // two-value select otherwise. + if (fastMode?.supported) { + options.push(createFastModeConfigOption(fastMode.enabled, fastMode.useBooleanOption)); + } + // Only surface the Agent picker when there's a real choice — i.e. the user // has configured at least one custom agent (built-ins are filtered out in // discoverCustomAgents). With none configured, "Default" would be the only // entry, so we omit the option entirely. if (agents.length > 0) { options.push({ - id: "agent", + id: AGENT_CONFIG_ID, name: "Agent", description: "Main-thread agent persona", type: "select", diff --git a/src/tests/acp-agent.test.ts b/src/tests/acp-agent.test.ts index 2ed851a1..8a9ae1ea 100644 --- a/src/tests/acp-agent.test.ts +++ b/src/tests/acp-agent.test.ts @@ -37,6 +37,7 @@ import { streamEventToAcpNotifications, messageIdForGrouping, buildConfigOptions, + createFastModeConfigOption, discoverCustomAgents, runPromptWithCancellation, type AcpClient, @@ -1810,6 +1811,7 @@ describe("permission request cancellation", () => { configOptions: [], agents: [], currentAgent: "default", + fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, @@ -2267,6 +2269,77 @@ describe("stop reason propagation", () => { expect(response.usage?.outputTokens).toBe(promptResult.usage.output_tokens); }); + it("only reconciles Fast mode from user-driven results, not task-notification followups", async () => { + const updates: any[] = []; + const mockClient = { + sessionUpdate: async (n: any) => { + updates.push(n); + }, + } as unknown as AcpClient; + const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} }); + (agent as any).clientCapabilities = { session: { configOptions: { boolean: {} } } }; + + const input = new Pushable(); + + // A background followup reports fast_mode_state="on". It must NOT flip the + // user's toggle or emit a config_option_update (every other side effect in + // the result handler is likewise gated behind !isTaskNotification). + const backgroundTaskResult = { + ...createResultMessage({ subtype: "success", stop_reason: null, is_error: false }), + origin: { kind: "task-notification" }, + fast_mode_state: "on", + }; + + // The user prompt's own result reports the same state — this one IS a user + // turn, so it reconciles and notifies. + const promptResult = { + ...createResultMessage({ subtype: "success", stop_reason: null, is_error: false }), + fast_mode_state: "on", + }; + + async function* messageGenerator() { + yield { type: "system", subtype: "init", session_id: "test-session" }; + yield backgroundTaskResult; + + const iter = input[Symbol.asyncIterator](); + const { value: userMessage } = await iter.next(); + yield { + type: "user", + message: userMessage.message, + parent_tool_use_id: null, + uuid: userMessage.uuid, + session_id: "test-session", + isReplay: true, + }; + + yield promptResult; + yield { type: "system", subtype: "session_state_changed", state: "idle" }; + } + + agent.sessions["test-session"] = mockSessionState({ + query: wrapQuery(messageGenerator()), + input, + fastModeEnabled: false, + configOptions: [createFastModeConfigOption(false, true)], + }); + + await agent.prompt({ + sessionId: "test-session", + prompt: [{ type: "text", text: "test" }], + }); + + // The user-turn result flipped the toggle and emitted exactly one + // config_option_update; the background result contributed none. + expect((agent.sessions["test-session"] as any).fastModeEnabled).toBe(true); + const configUpdates = updates.filter( + (n: any) => n.update?.sessionUpdate === "config_option_update", + ); + expect(configUpdates).toHaveLength(1); + expect(configUpdates[0].update.configOptions).toContainEqual( + createFastModeConfigOption(true, true), + ); + }); + it("does not fold a task-notification result's tokens into an already-active turn's usage", async () => { const agent = createMockAgent(); @@ -2713,6 +2786,7 @@ describe("session/close", () => { configOptions: [], agents: [], currentAgent: "default", + fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, @@ -2799,6 +2873,7 @@ describe("session/delete", () => { configOptions: [], agents: [], currentAgent: "default", + fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, @@ -2902,6 +2977,7 @@ describe("getOrCreateSession param change detection", () => { configOptions: [], agents: [], currentAgent: "default", + fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, @@ -5117,6 +5193,7 @@ describe("post-error recovery", () => { configOptions: [], agents: [], currentAgent: "default", + fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, @@ -5688,6 +5765,7 @@ describe("session/cancel wedge recovery (issue #680)", () => { configOptions: [], agents: [], currentAgent: "default", + fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, @@ -6142,6 +6220,7 @@ describe("agent selection config option", () => { configOptions: buildConfigOptions(baseModes, baseModels, [], undefined, agents, "default"), agents, currentAgent: "default", + fastModeEnabled: false, abortController: new AbortController(), emitRawSDKMessages: false, contextWindowSize: 200000, diff --git a/src/tests/fast-mode-config.test.ts b/src/tests/fast-mode-config.test.ts new file mode 100644 index 00000000..2843076f --- /dev/null +++ b/src/tests/fast-mode-config.test.ts @@ -0,0 +1,341 @@ +import { describe, it, expect, vi } from "vitest"; +import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk"; +import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk"; +import { + buildConfigOptions, + clientSupportsBooleanConfigOptions, + createFastModeConfigOption, + fastModeStateEnabled, + resolveFastModeEnabled, + FAST_MODE_CONFIG_ID, + FAST_MODE_ON, + FAST_MODE_OFF, + ClaudeAcpAgent, + type AcpClient, +} from "../acp-agent.js"; + +const MODES = { + currentModeId: "default", + availableModes: [{ id: "default", name: "Default", description: "Standard behavior" }], +}; + +const MODELS = { + currentModelId: "claude-opus-4-8", + availableModels: [ + { modelId: "claude-opus-4-8", name: "Claude Opus", description: "Most capable" }, + ], +}; + +const MODEL_INFOS: ModelInfo[] = [ + { value: "claude-opus-4-8", displayName: "Claude Opus", description: "Most capable" }, +]; + +describe("createFastModeConfigOption", () => { + it("produces a native boolean toggle when the client opted in", () => { + expect(createFastModeConfigOption(true, true)).toEqual({ + id: FAST_MODE_CONFIG_ID, + name: "Fast mode", + description: expect.any(String), + category: "model_config", + type: "boolean", + currentValue: true, + }); + }); + + it("falls back to an on/off select when the client did not opt in", () => { + const option = createFastModeConfigOption(false, false); + expect(option).toMatchObject({ + id: FAST_MODE_CONFIG_ID, + type: "select", + category: "model_config", + currentValue: FAST_MODE_OFF, + options: [ + { value: FAST_MODE_ON, name: "On" }, + { value: FAST_MODE_OFF, name: "Off" }, + ], + }); + expect(option).not.toHaveProperty("currentValue", true); + }); +}); + +describe("clientSupportsBooleanConfigOptions", () => { + it("is true only when session.configOptions.boolean is present", () => { + expect( + clientSupportsBooleanConfigOptions({ session: { configOptions: { boolean: {} } } }), + ).toBe(true); + }); + + it("is false when the capability is omitted or null at any level", () => { + expect(clientSupportsBooleanConfigOptions(undefined)).toBe(false); + expect(clientSupportsBooleanConfigOptions(null)).toBe(false); + expect(clientSupportsBooleanConfigOptions({})).toBe(false); + expect(clientSupportsBooleanConfigOptions({ session: { configOptions: {} } })).toBe(false); + expect( + clientSupportsBooleanConfigOptions({ session: { configOptions: { boolean: null } } }), + ).toBe(false); + }); +}); + +describe("resolveFastModeEnabled", () => { + const base = { sessionId: "s", configId: FAST_MODE_CONFIG_ID }; + + it("accepts native boolean values", () => { + expect(resolveFastModeEnabled({ ...base, type: "boolean", value: true })).toBe(true); + expect(resolveFastModeEnabled({ ...base, type: "boolean", value: false })).toBe(false); + }); + + it("accepts the on/off select fallback", () => { + expect(resolveFastModeEnabled({ ...base, value: FAST_MODE_ON })).toBe(true); + expect(resolveFastModeEnabled({ ...base, value: FAST_MODE_OFF })).toBe(false); + }); + + it("rejects any other value", () => { + expect(() => resolveFastModeEnabled({ ...base, value: "maybe" })).toThrow( + /Invalid value for config option fast/, + ); + }); +}); + +describe("fastModeStateEnabled", () => { + it("treats cooldown as on (the user's intent persists through rate-limit cooldown)", () => { + expect(fastModeStateEnabled("on")).toBe(true); + expect(fastModeStateEnabled("cooldown")).toBe(true); + expect(fastModeStateEnabled("off")).toBe(false); + }); +}); + +describe("buildConfigOptions Fast mode", () => { + it("omits the Fast mode option when the model does not support it", () => { + const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", { + supported: false, + enabled: false, + useBooleanOption: true, + }); + expect(options.find((o) => o.id === FAST_MODE_CONFIG_ID)).toBeUndefined(); + }); + + it("omits the Fast mode option when no fast mode state is provided", () => { + const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default"); + expect(options.find((o) => o.id === FAST_MODE_CONFIG_ID)).toBeUndefined(); + }); + + it("surfaces a boolean toggle when supported and the client opted in", () => { + const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", { + supported: true, + enabled: true, + useBooleanOption: true, + }); + expect(options).toContainEqual(createFastModeConfigOption(true, true)); + }); + + it("surfaces a select fallback when supported but the client did not opt in", () => { + const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", { + supported: true, + enabled: false, + useBooleanOption: false, + }); + expect(options).toContainEqual(createFastModeConfigOption(false, false)); + }); +}); + +describe("setSessionConfigOption Fast mode toggle", () => { + const SESSION_ID = "fast-session"; + + function setup(opts: { useBooleanOption: boolean }) { + const sessionUpdates: SessionNotification[] = []; + const client = { + sessionUpdate: async (n: SessionNotification) => { + sessionUpdates.push(n); + }, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AcpClient; + + const agent = new ClaudeAcpAgent(client); + const clientCapabilities: ClientCapabilities = opts.useBooleanOption + ? { session: { configOptions: { boolean: {} } } } + : {}; + (agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities = + clientCapabilities; + + const applyFlagSettings = vi.fn(); + (agent as unknown as { sessions: Record }).sessions[SESSION_ID] = { + query: { applyFlagSettings }, + fastModeEnabled: false, + configOptions: [createFastModeConfigOption(false, opts.useBooleanOption)], + }; + + return { agent, applyFlagSettings, sessionUpdates }; + } + + it("toggles Fast mode on/off through a boolean value", async () => { + const { agent, applyFlagSettings } = setup({ useBooleanOption: true }); + + const onResponse = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: FAST_MODE_CONFIG_ID, + type: "boolean", + value: true, + }); + expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: true }); + expect(onResponse.configOptions).toContainEqual(createFastModeConfigOption(true, true)); + expect( + (agent as unknown as { sessions: Record }).sessions[ + SESSION_ID + ].fastModeEnabled, + ).toBe(true); + + const offResponse = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: FAST_MODE_CONFIG_ID, + type: "boolean", + value: false, + }); + expect(applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false }); + expect(offResponse.configOptions).toContainEqual(createFastModeConfigOption(false, true)); + }); + + it("toggles Fast mode through the on/off select fallback", async () => { + const { agent, applyFlagSettings } = setup({ useBooleanOption: false }); + + const response = await agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: FAST_MODE_CONFIG_ID, + value: FAST_MODE_ON, + }); + expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: true }); + expect(response.configOptions).toContainEqual(createFastModeConfigOption(true, false)); + }); + + it("does not change session state when the SDK rejects the flag", async () => { + const { agent, applyFlagSettings } = setup({ useBooleanOption: true }); + applyFlagSettings.mockRejectedValueOnce(new Error("nope")); + + await expect( + agent.setSessionConfigOption({ + sessionId: SESSION_ID, + configId: FAST_MODE_CONFIG_ID, + type: "boolean", + value: true, + }), + ).rejects.toThrow("nope"); + + const session = (agent as unknown as { sessions: Record }) + .sessions[SESSION_ID]; + expect(session.fastModeEnabled).toBe(false); + }); +}); + +describe("syncFastModeState (SDK-driven state changes)", () => { + const SESSION_ID = "fast-session"; + + function setup(opts: { fastModeEnabled: boolean; withOption: boolean }) { + const sessionUpdates: SessionNotification[] = []; + const client = { + sessionUpdate: async (n: SessionNotification) => { + sessionUpdates.push(n); + }, + requestPermission: async () => ({ outcome: { outcome: "cancelled" } }), + readTextFile: async () => ({ content: "" }), + writeTextFile: async () => ({}), + } as unknown as AcpClient; + + const agent = new ClaudeAcpAgent(client); + (agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities = { + session: { configOptions: { boolean: {} } }, + }; + + const session = { + query: {}, + fastModeEnabled: opts.fastModeEnabled, + configOptions: opts.withOption + ? [createFastModeConfigOption(opts.fastModeEnabled, true)] + : [], + }; + (agent as unknown as { sessions: Record }).sessions[SESSION_ID] = session; + + const sync = ( + agent as unknown as { + syncFastModeState: ( + sessionId: string, + session: unknown, + state: string | undefined, + ) => Promise; + } + ).syncFastModeState.bind(agent); + + return { sync, session, sessionUpdates }; + } + + it("emits a config_option_update when the SDK reports a new state", async () => { + const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true }); + + await sync(SESSION_ID, session, "on"); + + expect(session.fastModeEnabled).toBe(true); + expect(session.configOptions).toContainEqual(createFastModeConfigOption(true, true)); + expect(sessionUpdates).toHaveLength(1); + expect(sessionUpdates[0].update).toMatchObject({ + sessionUpdate: "config_option_update", + }); + const updated = ( + sessionUpdates[0].update as { configOptions: ReturnType[] } + ).configOptions; + expect(updated).toContainEqual(createFastModeConfigOption(true, true)); + }); + + it("leaves the toggle on and quiet during a rate-limit cooldown", async () => { + const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true }); + + // cooldown is a transient suspension of an already-enabled fast mode. + await sync(SESSION_ID, session, "cooldown"); + + expect(session.fastModeEnabled).toBe(true); + expect(sessionUpdates).toHaveLength(0); + }); + + it("never lets a stray cooldown spuriously enable a toggle the user has off", async () => { + const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true }); + + await sync(SESSION_ID, session, "cooldown"); + + expect(session.fastModeEnabled).toBe(false); + expect(sessionUpdates).toHaveLength(0); + }); + + it("clears the toggle when the SDK reports off", async () => { + const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true }); + + await sync(SESSION_ID, session, "off"); + + expect(session.fastModeEnabled).toBe(false); + expect(session.configOptions).toContainEqual(createFastModeConfigOption(false, true)); + expect(sessionUpdates).toHaveLength(1); + }); + + it("is a no-op when the reported state is undefined or unchanged", async () => { + const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true }); + + await sync(SESSION_ID, session, undefined); + await sync(SESSION_ID, session, "off"); + + expect(sessionUpdates).toHaveLength(0); + }); + + it("preserves the retained setting (no clobber) when the model has no Fast mode option", async () => { + // Model without fast support: the SDK reports a capability-driven state, not + // the user's intent. We must leave session.fastModeEnabled untouched so it's + // correct when a supporting model is reselected — reconciling here was the + // original intent-clobber bug. + const enabledCase = setup({ fastModeEnabled: true, withOption: false }); + await enabledCase.sync(SESSION_ID, enabledCase.session, "off"); + expect(enabledCase.session.fastModeEnabled).toBe(true); + expect(enabledCase.sessionUpdates).toHaveLength(0); + + const disabledCase = setup({ fastModeEnabled: false, withOption: false }); + await disabledCase.sync(SESSION_ID, disabledCase.session, "on"); + expect(disabledCase.session.fastModeEnabled).toBe(false); + expect(disabledCase.sessionUpdates).toHaveLength(0); + }); +});