Files
claude-agent-acp/src/session-fast-mode.ts
Mark Tkachenko c331e178a5 fix: explain why Claude Code turns Fast mode off (#1602)
When the user turns Fast mode on, Claude Code can report
`fast_mode_state: "off"` on the next turn's `system`/init. The adapter
mirrored that into the option but explained it only for reasons listed
in `FAST_MODE_UNAVAILABLE_EXPLANATIONS`. `preference`, `unknown` and any
newer code were dropped, so the toggle snapped off with no reason. This
happens on every turn behind an API gateway that authenticates with
`ANTHROPIC_AUTH_TOKEN` only: Claude Code treats Fast mode as disabled by
the organization (`preference`) without asking the server.

Changes:
- `preference` ("not enabled for your organization or API gateway") and
`unknown` ("currently unavailable") are explained. A reason code the SDK
adds later gets a generic `not available (<code>)`.
- Only `sdk_opt_in_required` (every SDK session starts there; the toggle
is the opt-in) and `pending` (eligibility still resolving) stay
unexplained.
- The notice rule is unchanged: Fast mode was on, then off with an
explained reason. Off without a reason stays quiet, because a `result`
can carry the state from the start of the turn.
- One shared function in `session-fast-mode.ts` serves v1 and v2. In
`src/v1/acp-agent.ts` only comments change.

Limitation: the adapter still offers the toggle up front. Until a
session opts in, Claude Code reports `sdk_opt_in_required` and hides the
account or organization blocker, so the adapter can't tell in advance
that Fast mode will be refused.

Tests: unit tests for each reason, plus v1 and v2 flow tests (set `fast`
on, prompt, init reports off/`preference`). The flow tests fail without
the fix.
2026-11-11 17:28:40 +01:00

204 lines
8.8 KiB
TypeScript

/**
* The Fast mode toggle of a session: its config option, the value a
* `session/set_config_option` request sets, and the state Claude Code reports
* (`fast_mode_state` and `fast_mode_disabled_reason`, on `system`/init and on
* results). Shared by the v1 core and the v2 core.
*/
import { RequestError } from "@agentclientprotocol/sdk/unstable";
import type {
SessionConfigOption,
SetSessionConfigOptionRequest,
} from "@agentclientprotocol/sdk/unstable";
import type { FastModeDisabledReason, FastModeState } from "@anthropic-ai/claude-agent-sdk";
import { sentenceCase } from "./session-notices.js";
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";
}
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values.
* Every reason is explained except the two in {@link FAST_MODE_UNEXPLAINED_REASONS};
* a code missing here gets the generic text from {@link explainFastModeDisabledReason}. */
const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason, string>> = {
free: "not available on the free plan",
extra_usage_disabled: "requires extra usage to be enabled for this account",
model_not_allowed: "not available for the selected model",
not_first_party: "not available on this API provider",
disabled_by_env: "disabled by environment configuration",
network_error: "eligibility could not be verified (network error)",
preference: "not enabled for your organization or API gateway",
unknown: "currently unavailable",
};
/** Reasons that are never explained:
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
* opt-in), so it describes the default, not a problem.
* - `pending`: eligibility is still resolving; the next report supersedes it. */
const FAST_MODE_UNEXPLAINED_REASONS: ReadonlySet<FastModeDisabledReason> = new Set([
"sdk_opt_in_required",
"pending",
]);
/** The user-facing explanation of a `fast_mode_disabled_reason`: `undefined`
* for no reason, `sdk_opt_in_required` or `pending`; the table text when there
* is one; else a generic text naming the code (a reason a newer SDK added). */
export function explainFastModeDisabledReason(
reason: FastModeDisabledReason | undefined,
): string | undefined {
if (reason === undefined || FAST_MODE_UNEXPLAINED_REASONS.has(reason)) return undefined;
return FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ?? `not available (${reason})`;
}
/** Normalize an SDK-reported `fast_mode_disabled_reason` to the one we retain:
* a reason {@link explainFastModeDisabledReason} explains (including codes a
* newer SDK adds), else `undefined`. State comparisons (see
* `fastModeStateChange`) then track exactly what the user can see, so
* `sdk_opt_in_required` and `pending` never churn the config option. */
export function normalizeFastModeDisabledReason(
reason: FastModeDisabledReason | undefined,
): FastModeDisabledReason | undefined {
return explainFastModeDisabledReason(reason) !== undefined ? reason : undefined;
}
/** 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.
*
* `disabledReason` (the SDK's `fast_mode_disabled_reason`) is folded into the
* description while the toggle reads off, so a user whose account or provider
* can't serve Fast mode sees why instead of a switch that silently refuses to
* stay on. Ignored while enabled: a reason reported alongside an `on`/`cooldown`
* state isn't blocking anything right now. */
export function createFastModeConfigOption(
enabled: boolean,
useBooleanOption: boolean,
disabledReason?: FastModeDisabledReason,
): SessionConfigOption {
const explanation = enabled ? undefined : explainFastModeDisabledReason(disabledReason);
const base = {
id: FAST_MODE_CONFIG_ID,
name: "Fast mode",
description: explanation ? `${FAST_MODE_DESCRIPTION} — ${explanation}` : 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 RequestError.invalidParams(
undefined,
`Invalid value for config option ${FAST_MODE_CONFIG_ID}: ${value}`,
);
}
/** Per-model Fast mode state threaded into `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;
/** Latest explainable `fast_mode_disabled_reason`, folded into the option's
* description while the toggle reads off. */
disabledReason?: FastModeDisabledReason;
};
/** What a reported `fast_mode_state` changes in a Fast mode toggle that the
* current model shows. The SDK can flip fast mode independently of the
* user — e.g. back to `on` once a rate-limit `cooldown` clears — so
* definitive on/off changes are mirrored into the option.
*
* Undefined when nothing changes: an absent state, a `cooldown` (a transient
* suspension of an already-enabled fast mode: the toggle stays as-is rather
* than flapping, and a stray cooldown never enables a toggle the user has
* off), or the same state with the same explainable reason.
*
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
* state. Every reason except `sdk_opt_in_required` and `pending` is retained
* (see {@link normalizeFastModeDisabledReason}; unknown future codes get the
* generic text), so `sdk_opt_in_required` on every turn's result can't churn
* the option, while a real blocker updates the description even when the
* toggle's own value is unchanged.
*
* `notice` is set when the user had Fast mode on and the SDK turned it off
* with a retained reason: the description carries the same explanation, but a
* toggle silently snapping back is worth saying out loud once. Off with no
* reason, `sdk_opt_in_required` or `pending` stays quiet: a `result` can carry
* the turn-start snapshot, so a notice there would falsely say "turned off". */
export function fastModeStateChange(
current: { enabled: boolean; disabledReason?: FastModeDisabledReason },
state: FastModeState | undefined,
reason: FastModeDisabledReason | undefined,
):
| { enabled: boolean; disabledReason?: FastModeDisabledReason; notice?: FastModeOffNotice }
| undefined {
if (state === undefined || state === "cooldown") return undefined;
const enabled = state === "on";
// A reason only describes an off state; drop any that rides an `on` report
// so it can't decorate the option the next time fast mode goes off.
const disabledReason = enabled ? undefined : normalizeFastModeDisabledReason(reason);
if (enabled === current.enabled && disabledReason === current.disabledReason) return undefined;
const explanation = explainFastModeDisabledReason(disabledReason);
if (!current.enabled || enabled || explanation === undefined) {
return { enabled, disabledReason };
}
return {
enabled,
disabledReason,
notice: {
notice: {
severity: "warning",
title: "Fast mode turned off",
description: `${sentenceCase(explanation)}.`,
},
transcript: `**Fast mode turned off:** ${explanation}.`,
},
};
}
/** The notice of Fast mode that the SDK turned off, and its transcript line
* for a client without `notice` updates. */
export type FastModeOffNotice = {
notice: { severity: "warning"; title: string; description: string };
transcript: string;
};