feat: Add Fast mode session config support (#828)

This commit is contained in:
Ben Brandt
2026-06-30 20:13:43 +02:00
committed by GitHub
parent b8df8e0e54
commit fa949a20db
3 changed files with 658 additions and 20 deletions

View File

@@ -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<void> {
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<void> {
// 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<void> {
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<AgentInfo[]> {
}
}
/** 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",

View File

@@ -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<any>();
// 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,

View File

@@ -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<string, unknown> }).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<string, { fastModeEnabled: boolean }> }).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<string, { fastModeEnabled: boolean }> })
.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<string, unknown> }).sessions[SESSION_ID] = session;
const sync = (
agent as unknown as {
syncFastModeState: (
sessionId: string,
session: unknown,
state: string | undefined,
) => Promise<void>;
}
).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<typeof createFastModeConfigOption>[] }
).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);
});
});