mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
feat: Add Fast mode session config support (#828)
This commit is contained in:
258
src/acp-agent.ts
258
src/acp-agent.ts
@@ -51,6 +51,7 @@ import {
|
|||||||
AgentInfo,
|
AgentInfo,
|
||||||
CanUseTool,
|
CanUseTool,
|
||||||
deleteSession,
|
deleteSession,
|
||||||
|
FastModeState,
|
||||||
getSessionInfo,
|
getSessionInfo,
|
||||||
getSessionMessages,
|
getSessionMessages,
|
||||||
listSessions,
|
listSessions,
|
||||||
@@ -246,6 +247,10 @@ type Session = {
|
|||||||
/** The currently selected main-thread agent name, or "default" for the
|
/** The currently selected main-thread agent name, or "default" for the
|
||||||
* standard Claude Code agent (no `agent` flag applied). */
|
* standard Claude Code agent (no `agent` flag applied). */
|
||||||
currentAgent: string;
|
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;
|
abortController: AbortController;
|
||||||
/** Signal the consumer races `query.next()` against. Aborted by cancel()
|
/** Signal the consumer races `query.next()` against. Aborted by cancel()
|
||||||
* (after a grace period) to force the active turn to settle "cancelled" when
|
* (after a grace period) to force the active turn to settle "cancelled" when
|
||||||
@@ -1348,6 +1353,10 @@ export class ClaudeAcpAgent {
|
|||||||
case "system":
|
case "system":
|
||||||
switch (message.subtype) {
|
switch (message.subtype) {
|
||||||
case "init":
|
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;
|
break;
|
||||||
case "status": {
|
case "status": {
|
||||||
if (message.status === "compacting") {
|
if (message.status === "compacting") {
|
||||||
@@ -1620,6 +1629,14 @@ export class ClaudeAcpAgent {
|
|||||||
// slash-command output forwarding) but their cost is real.
|
// slash-command output forwarding) but their cost is real.
|
||||||
const isTaskNotification = message.origin?.kind === "task-notification";
|
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
|
// A user-turn result needs an active turn so its stop reason is
|
||||||
// attributed and the turn settles at idle. Local-only commands carry
|
// 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.
|
// 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.applySessionMode(params.sessionId, params.modeId);
|
||||||
await this.updateConfigOption(params.sessionId, "mode", params.modeId);
|
await this.updateConfigOption(params.sessionId, MODE_CONFIG_ID, params.modeId);
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2401,15 +2418,24 @@ export class ClaudeAcpAgent {
|
|||||||
if (session.queryClosed) {
|
if (session.queryClosed) {
|
||||||
throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE);
|
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);
|
const option = session.configOptions.find((o) => o.id === params.configId);
|
||||||
if (!option) {
|
if (!option) {
|
||||||
throw new Error(`Unknown config option: ${params.configId}`);
|
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 =
|
const allValues =
|
||||||
"options" in option && Array.isArray(option.options)
|
"options" in option && Array.isArray(option.options)
|
||||||
? option.options.flatMap((o) => ("options" in o ? o.options : [o]))
|
? 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
|
// For model options, fall back to resolveModelPreference when the exact
|
||||||
// value doesn't match. This lets callers use human-friendly aliases like
|
// 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".
|
// "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) => ({
|
const modelInfos: ModelInfo[] = allValues.map((o) => ({
|
||||||
value: o.value,
|
value: o.value,
|
||||||
displayName: o.name,
|
displayName: o.name,
|
||||||
@@ -2439,7 +2465,7 @@ export class ClaudeAcpAgent {
|
|||||||
// model ID rather than the caller-supplied alias.
|
// model ID rather than the caller-supplied alias.
|
||||||
const resolvedValue = validValue.value;
|
const resolvedValue = validValue.value;
|
||||||
|
|
||||||
if (params.configId === "mode") {
|
if (params.configId === MODE_CONFIG_ID) {
|
||||||
await this.applySessionMode(params.sessionId, resolvedValue);
|
await this.applySessionMode(params.sessionId, resolvedValue);
|
||||||
await this.client.sessionUpdate({
|
await this.client.sessionUpdate({
|
||||||
sessionId: params.sessionId,
|
sessionId: params.sessionId,
|
||||||
@@ -2448,7 +2474,7 @@ export class ClaudeAcpAgent {
|
|||||||
currentModeId: resolvedValue,
|
currentModeId: resolvedValue,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else if (params.configId === "model") {
|
} else if (params.configId === MODEL_CONFIG_ID) {
|
||||||
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
||||||
}
|
}
|
||||||
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
|
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
|
||||||
@@ -2706,7 +2732,7 @@ export class ClaudeAcpAgent {
|
|||||||
currentModeId: selectedMode,
|
currentModeId: selectedMode,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.updateConfigOption(sessionId, "mode", selectedMode);
|
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, selectedMode);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
behavior: "allow",
|
behavior: "allow",
|
||||||
@@ -2906,12 +2932,12 @@ export class ClaudeAcpAgent {
|
|||||||
configId: string,
|
configId: string,
|
||||||
value: string,
|
value: string,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (configId === "mode") {
|
if (configId === MODE_CONFIG_ID) {
|
||||||
session.modes = { ...session.modes, currentModeId: value };
|
session.modes = { ...session.modes, currentModeId: value };
|
||||||
session.configOptions = session.configOptions.map((o) =>
|
session.configOptions = session.configOptions.map((o) =>
|
||||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : 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
|
// `ModelInfo.supportsAutoMode` is the canonical SDK signal for clamping
|
||||||
// modes below; its `displayName`/`description` also let us infer the
|
// modes below; its `displayName`/`description` also let us infer the
|
||||||
// context window for semantic aliases (e.g. `default`) whose ID alone
|
// 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
|
// 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 =
|
const currentEffort =
|
||||||
typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
||||||
session.configOptions = buildConfigOptions(
|
session.configOptions = buildConfigOptions(
|
||||||
@@ -2971,10 +2997,18 @@ export class ClaudeAcpAgent {
|
|||||||
currentEffort,
|
currentEffort,
|
||||||
session.agents,
|
session.agents,
|
||||||
session.currentAgent,
|
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
|
// 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 =
|
const newEffort =
|
||||||
typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
||||||
if (newEffort !== currentEffort) {
|
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
|
// Live agent switch — no subprocess restart needed. Apply the SDK flag
|
||||||
// first so a rejected control request leaves both `currentAgent` and the
|
// first so a rejected control request leaves both `currentAgent` and the
|
||||||
// config option untouched (no UI/SDK desync). Passing `null` clears 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) =>
|
session.configOptions = session.configOptions.map((o) =>
|
||||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||||
);
|
);
|
||||||
if (configId === "effort") {
|
if (configId === EFFORT_CONFIG_ID) {
|
||||||
await session.query.applyFlagSettings({
|
await session.query.applyFlagSettings({
|
||||||
effortLevel: toSdkEffortLevel(value),
|
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: {
|
private async getOrCreateSession(params: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
cwd: string;
|
cwd: string;
|
||||||
@@ -3274,7 +3379,7 @@ export class ClaudeAcpAgent {
|
|||||||
currentModeId: "plan",
|
currentModeId: "plan",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await this.updateConfigOption(sessionId, "mode", "plan");
|
await this.updateConfigOption(sessionId, MODE_CONFIG_ID, "plan");
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
@@ -3445,6 +3550,19 @@ export class ClaudeAcpAgent {
|
|||||||
? requestedAgent
|
? requestedAgent
|
||||||
: DEFAULT_AGENT_ID;
|
: 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(
|
const configOptions = buildConfigOptions(
|
||||||
modes,
|
modes,
|
||||||
models,
|
models,
|
||||||
@@ -3452,10 +3570,11 @@ export class ClaudeAcpAgent {
|
|||||||
settingsManager.getSettings().effortLevel,
|
settingsManager.getSettings().effortLevel,
|
||||||
agents,
|
agents,
|
||||||
currentAgent,
|
currentAgent,
|
||||||
|
fastMode,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Apply the initial effort level to the SDK so it matches the UI default
|
// 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 (
|
if (
|
||||||
initialEffort &&
|
initialEffort &&
|
||||||
typeof initialEffort.currentValue === "string" &&
|
typeof initialEffort.currentValue === "string" &&
|
||||||
@@ -3484,6 +3603,7 @@ export class ClaudeAcpAgent {
|
|||||||
configOptions,
|
configOptions,
|
||||||
agents,
|
agents,
|
||||||
currentAgent,
|
currentAgent,
|
||||||
|
fastModeEnabled,
|
||||||
abortController,
|
abortController,
|
||||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||||
contextWindowSize:
|
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(
|
export function buildConfigOptions(
|
||||||
modes: SessionModeState,
|
modes: SessionModeState,
|
||||||
models: SessionModelState,
|
models: SessionModelState,
|
||||||
@@ -3711,10 +3921,11 @@ export function buildConfigOptions(
|
|||||||
currentEffortLevel?: string,
|
currentEffortLevel?: string,
|
||||||
agents: AgentInfo[] = [],
|
agents: AgentInfo[] = [],
|
||||||
currentAgent: string = DEFAULT_AGENT_ID,
|
currentAgent: string = DEFAULT_AGENT_ID,
|
||||||
|
fastMode?: FastModeOptionState,
|
||||||
): SessionConfigOption[] {
|
): SessionConfigOption[] {
|
||||||
const options: SessionConfigOption[] = [
|
const options: SessionConfigOption[] = [
|
||||||
{
|
{
|
||||||
id: "mode",
|
id: MODE_CONFIG_ID,
|
||||||
name: "Mode",
|
name: "Mode",
|
||||||
description: "Session permission mode",
|
description: "Session permission mode",
|
||||||
category: "mode",
|
category: "mode",
|
||||||
@@ -3727,7 +3938,7 @@ export function buildConfigOptions(
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "model",
|
id: MODEL_CONFIG_ID,
|
||||||
name: "Model",
|
name: "Model",
|
||||||
description: "AI model to use",
|
description: "AI model to use",
|
||||||
category: "model",
|
category: "model",
|
||||||
@@ -3764,7 +3975,7 @@ export function buildConfigOptions(
|
|||||||
currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
|
currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
|
||||||
|
|
||||||
options.push({
|
options.push({
|
||||||
id: "effort",
|
id: EFFORT_CONFIG_ID,
|
||||||
name: "Effort",
|
name: "Effort",
|
||||||
description: "Available effort levels for this model",
|
description: "Available effort levels for this model",
|
||||||
category: "thought_level",
|
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
|
// 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
|
// has configured at least one custom agent (built-ins are filtered out in
|
||||||
// discoverCustomAgents). With none configured, "Default" would be the only
|
// discoverCustomAgents). With none configured, "Default" would be the only
|
||||||
// entry, so we omit the option entirely.
|
// entry, so we omit the option entirely.
|
||||||
if (agents.length > 0) {
|
if (agents.length > 0) {
|
||||||
options.push({
|
options.push({
|
||||||
id: "agent",
|
id: AGENT_CONFIG_ID,
|
||||||
name: "Agent",
|
name: "Agent",
|
||||||
description: "Main-thread agent persona",
|
description: "Main-thread agent persona",
|
||||||
type: "select",
|
type: "select",
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
streamEventToAcpNotifications,
|
streamEventToAcpNotifications,
|
||||||
messageIdForGrouping,
|
messageIdForGrouping,
|
||||||
buildConfigOptions,
|
buildConfigOptions,
|
||||||
|
createFastModeConfigOption,
|
||||||
discoverCustomAgents,
|
discoverCustomAgents,
|
||||||
runPromptWithCancellation,
|
runPromptWithCancellation,
|
||||||
type AcpClient,
|
type AcpClient,
|
||||||
@@ -1810,6 +1811,7 @@ describe("permission request cancellation", () => {
|
|||||||
configOptions: [],
|
configOptions: [],
|
||||||
agents: [],
|
agents: [],
|
||||||
currentAgent: "default",
|
currentAgent: "default",
|
||||||
|
fastModeEnabled: false,
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
emitRawSDKMessages: false,
|
emitRawSDKMessages: false,
|
||||||
contextWindowSize: 200000,
|
contextWindowSize: 200000,
|
||||||
@@ -2267,6 +2269,77 @@ describe("stop reason propagation", () => {
|
|||||||
expect(response.usage?.outputTokens).toBe(promptResult.usage.output_tokens);
|
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 () => {
|
it("does not fold a task-notification result's tokens into an already-active turn's usage", async () => {
|
||||||
const agent = createMockAgent();
|
const agent = createMockAgent();
|
||||||
|
|
||||||
@@ -2713,6 +2786,7 @@ describe("session/close", () => {
|
|||||||
configOptions: [],
|
configOptions: [],
|
||||||
agents: [],
|
agents: [],
|
||||||
currentAgent: "default",
|
currentAgent: "default",
|
||||||
|
fastModeEnabled: false,
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
emitRawSDKMessages: false,
|
emitRawSDKMessages: false,
|
||||||
contextWindowSize: 200000,
|
contextWindowSize: 200000,
|
||||||
@@ -2799,6 +2873,7 @@ describe("session/delete", () => {
|
|||||||
configOptions: [],
|
configOptions: [],
|
||||||
agents: [],
|
agents: [],
|
||||||
currentAgent: "default",
|
currentAgent: "default",
|
||||||
|
fastModeEnabled: false,
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
emitRawSDKMessages: false,
|
emitRawSDKMessages: false,
|
||||||
contextWindowSize: 200000,
|
contextWindowSize: 200000,
|
||||||
@@ -2902,6 +2977,7 @@ describe("getOrCreateSession param change detection", () => {
|
|||||||
configOptions: [],
|
configOptions: [],
|
||||||
agents: [],
|
agents: [],
|
||||||
currentAgent: "default",
|
currentAgent: "default",
|
||||||
|
fastModeEnabled: false,
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
emitRawSDKMessages: false,
|
emitRawSDKMessages: false,
|
||||||
contextWindowSize: 200000,
|
contextWindowSize: 200000,
|
||||||
@@ -5117,6 +5193,7 @@ describe("post-error recovery", () => {
|
|||||||
configOptions: [],
|
configOptions: [],
|
||||||
agents: [],
|
agents: [],
|
||||||
currentAgent: "default",
|
currentAgent: "default",
|
||||||
|
fastModeEnabled: false,
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
emitRawSDKMessages: false,
|
emitRawSDKMessages: false,
|
||||||
contextWindowSize: 200000,
|
contextWindowSize: 200000,
|
||||||
@@ -5688,6 +5765,7 @@ describe("session/cancel wedge recovery (issue #680)", () => {
|
|||||||
configOptions: [],
|
configOptions: [],
|
||||||
agents: [],
|
agents: [],
|
||||||
currentAgent: "default",
|
currentAgent: "default",
|
||||||
|
fastModeEnabled: false,
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
emitRawSDKMessages: false,
|
emitRawSDKMessages: false,
|
||||||
contextWindowSize: 200000,
|
contextWindowSize: 200000,
|
||||||
@@ -6142,6 +6220,7 @@ describe("agent selection config option", () => {
|
|||||||
configOptions: buildConfigOptions(baseModes, baseModels, [], undefined, agents, "default"),
|
configOptions: buildConfigOptions(baseModes, baseModels, [], undefined, agents, "default"),
|
||||||
agents,
|
agents,
|
||||||
currentAgent: "default",
|
currentAgent: "default",
|
||||||
|
fastModeEnabled: false,
|
||||||
abortController: new AbortController(),
|
abortController: new AbortController(),
|
||||||
emitRawSDKMessages: false,
|
emitRawSDKMessages: false,
|
||||||
contextWindowSize: 200000,
|
contextWindowSize: 200000,
|
||||||
|
|||||||
341
src/tests/fast-mode-config.test.ts
Normal file
341
src/tests/fast-mode-config.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user