Compare commits

..

2 Commits

Author SHA1 Message Date
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
Mark Tkachenko
830f05a0ac refactor: move the v1 session fingerprint out of acp-agent.ts (#1601)
Moves `computeSessionFingerprint` and its helpers (`normalizeSkills`,
`OPTION_REBUILDS_SESSION`, `canonicalJson`) verbatim from
`src/v1/acp-agent.ts` into `src/v1/session-fingerprint.ts`. No behaviour
change.

- Only `computeSessionFingerprint` is exported, as before; the helpers
stay private to the new module.
- `session-fingerprint.ts` imports nothing from `acp-agent.ts`. The test
that used `computeSessionFingerprint` now imports it from the new
module.
- Emitted JS differs from `main` only in `dist/v1/acp-agent.js` (one
import, the moved code removed) plus the new module.

Review with `git diff --color-moved` to see the move.
2026-11-11 15:31:40 +01:00
6 changed files with 465 additions and 225 deletions

View File

@@ -28,16 +28,9 @@ export function fastModeStateEnabled(state: FastModeState): boolean {
return state !== "off";
}
/** User-facing explanations for the SDK's `fast_mode_disabled_reason` values
* that a user can act on (or at least wants to know about). Deliberately
* partial — the omitted reasons are not worth surfacing:
* - `sdk_opt_in_required`: every SDK session starts here (the toggle IS the
* opt-in), so it describes the default, not a problem.
* - `preference`: the user turned Fast mode off themselves.
* - `pending`: eligibility is still resolving; the next report supersedes it.
* - `unknown`: nothing meaningful to say.
* Unknown future reasons fall through the same way (open set — the SDK's docs
* say to ignore values you don't handle). */
/** 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",
@@ -45,17 +38,38 @@ const FAST_MODE_UNAVAILABLE_EXPLANATIONS: Partial<Record<FastModeDisabledReason,
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 we have an explanation for, else `undefined`. Keeping only
* explainable reasons means state comparisons (see `fastModeStateChange`) track
* exactly what the user can see, so routine reports like
* `sdk_opt_in_required` never churn the config option. */
* 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 reason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[reason] ? reason : undefined;
return explainFastModeDisabledReason(reason) !== undefined ? reason : undefined;
}
/** Build the Fast mode config option. When the Client supports boolean config
@@ -73,9 +87,7 @@ export function createFastModeConfigOption(
useBooleanOption: boolean,
disabledReason?: FastModeDisabledReason,
): SessionConfigOption {
const explanation = enabled
? undefined
: disabledReason && FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason];
const explanation = enabled ? undefined : explainFastModeDisabledReason(disabledReason);
const base = {
id: FAST_MODE_CONFIG_ID,
name: "Fast mode",
@@ -141,15 +153,17 @@ export type FastModeOptionState = {
* off), or the same state with the same explainable reason.
*
* `reason` is the SDK's `fast_mode_disabled_reason`, reported alongside the
* state. Only explainable reasons are retained (see
* {@link normalizeFastModeDisabledReason}), so a routine
* `sdk_opt_in_required` report on every turn's result can't churn the
* option, while a real blocker updates the description even when 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
* for an explainable reason: the description carries the same explanation,
* but a toggle silently snapping back is worth saying out loud once. */
* 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,
@@ -163,8 +177,7 @@ export function fastModeStateChange(
// 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 =
disabledReason !== undefined ? FAST_MODE_UNAVAILABLE_EXPLANATIONS[disabledReason] : undefined;
const explanation = explainFastModeDisabledReason(disabledReason);
if (!current.enabled || enabled || explanation === undefined) {
return { enabled, disabledReason };
}

View File

@@ -28,11 +28,8 @@ import { nodeToWebWritable, nodeToWebReadable } from "../../utils.js";
import { createPostToolUseHook, markdownEscape, toDisplayPath } from "../../tools.js";
import { toolUpdateFromDiffToolResponse } from "../../diff.js";
import { ToolCallFieldTracker } from "../../tool-calls/field-tracker.js";
import {
ClaudeAcpAgent,
computeSessionFingerprint,
runPromptWithCancellation,
} from "../../v1/acp-agent.js";
import { ClaudeAcpAgent, runPromptWithCancellation } from "../../v1/acp-agent.js";
import { computeSessionFingerprint } from "../../v1/session-fingerprint.js";
import type { SteerRequest } from "../../v1/steering.js";
import { buildConfigOptions, discoverCustomAgents } from "../../session-config-options.js";
import { createFastModeConfigOption } from "../../session-fast-mode.js";

View File

@@ -16,6 +16,14 @@ import {
FAST_MODE_OFF,
} from "../../session-fast-mode.js";
import type { AcpClient } from "../../acp-client.js";
import { Pushable } from "../../utils.js";
import { initializeClient } from "../helpers.js";
import {
mockSessionState,
successfulResultMessage,
userEcho,
wrapQuery,
} from "./session-doubles.js";
const MODES = {
currentModeId: "default",
@@ -33,6 +41,8 @@ const MODEL_INFOS: ModelInfo[] = [
{ value: "claude-opus-4-8", displayName: "Claude Opus", description: "Most capable" },
];
const FUTURE_REASON = "some_future_reason" as FastModeDisabledReason;
describe("createFastModeConfigOption", () => {
it("produces a native boolean toggle when the client opted in", () => {
expect(createFastModeConfigOption(true, true)).toEqual({
@@ -45,20 +55,40 @@ describe("createFastModeConfigOption", () => {
});
});
it("explains a blocking disabled reason while the toggle reads off", () => {
const option = createFastModeConfigOption(false, true, "free");
expect(option.description).toBe(
"Faster responses on supported models — not available on the free plan",
);
});
/**
* Scenario:
* 1. Build the option with the toggle off and a disabled reason.
* Expected:
* - The description is the base text plus the reason's explanation.
* - A code the table lacks gets the generic text naming the code.
*/
it.each([
["free", "not available on the free plan"],
["preference", "not enabled for your organization or API gateway"],
["unknown", "currently unavailable"],
[FUTURE_REASON, "not available (some_future_reason)"],
] as [FastModeDisabledReason, string][])(
"explains the disabled reason %s while the toggle reads off",
(reason, explanation) => {
const option = createFastModeConfigOption(false, true, reason);
expect(option.description).toBe(`Faster responses on supported models — ${explanation}`);
},
);
it("ignores routine and on-state reasons", () => {
/**
* Scenario:
* 1. Build the option, toggle off, with `sdk_opt_in_required` or `pending`.
* 2. Build the option, toggle on, with `free`.
* Expected:
* - The description stays the base text in all three cases.
*/
it("ignores unexplained and on-state reasons", () => {
// Every SDK session starts at `sdk_opt_in_required` — the toggle IS the
// opt-in, so it must not read as a blocker.
expect(createFastModeConfigOption(false, true, "sdk_opt_in_required").description).toBe(
"Faster responses on supported models",
);
expect(createFastModeConfigOption(false, true, "preference").description).toBe(
expect(createFastModeConfigOption(false, true, "pending").description).toBe(
"Faster responses on supported models",
);
// A reason riding an `on` state isn't blocking anything right now.
@@ -133,25 +163,38 @@ describe("fastModeStateEnabled", () => {
});
describe("normalizeFastModeDisabledReason", () => {
it("retains only reasons we have an explanation for", () => {
expect(normalizeFastModeDisabledReason("free")).toBe("free");
expect(normalizeFastModeDisabledReason("extra_usage_disabled")).toBe("extra_usage_disabled");
expect(normalizeFastModeDisabledReason("model_not_allowed")).toBe("model_not_allowed");
expect(normalizeFastModeDisabledReason("not_first_party")).toBe("not_first_party");
expect(normalizeFastModeDisabledReason("disabled_by_env")).toBe("disabled_by_env");
expect(normalizeFastModeDisabledReason("network_error")).toBe("network_error");
/**
* Scenario:
* 1. Normalize each explained reason, including `preference`, `unknown` and a future code.
* Expected:
* - Each reason is returned unchanged.
*/
it.each([
"free",
"extra_usage_disabled",
"model_not_allowed",
"not_first_party",
"disabled_by_env",
"network_error",
"preference",
"unknown",
FUTURE_REASON,
] as FastModeDisabledReason[])("retains the explained reason %s", (reason) => {
expect(normalizeFastModeDisabledReason(reason)).toBe(reason);
});
it("drops routine states and unknown values", () => {
expect(normalizeFastModeDisabledReason("sdk_opt_in_required")).toBeUndefined();
expect(normalizeFastModeDisabledReason("preference")).toBeUndefined();
expect(normalizeFastModeDisabledReason("pending")).toBeUndefined();
expect(normalizeFastModeDisabledReason("unknown")).toBeUndefined();
expect(normalizeFastModeDisabledReason(undefined)).toBeUndefined();
expect(
normalizeFastModeDisabledReason("some_future_reason" as FastModeDisabledReason),
).toBeUndefined();
});
/**
* Scenario:
* 1. Normalize `sdk_opt_in_required`, `pending` and `undefined`.
* Expected:
* - Each normalizes to `undefined`.
*/
it.each(["sdk_opt_in_required", "pending", undefined] as (FastModeDisabledReason | undefined)[])(
"drops the unexplained reason %s",
(reason) => {
expect(normalizeFastModeDisabledReason(reason)).toBeUndefined();
},
);
});
describe("buildConfigOptions Fast mode", () => {
@@ -403,29 +446,61 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
expect(sessionUpdates).toHaveLength(0);
});
it("explains a blocking reason once when the SDK refuses a toggle the user turned on", async () => {
/**
* Scenario:
* 1. The toggle is on and the SDK reports `off` with a reason.
* 2. The SDK reports the same `off` and reason again.
* Expected:
* - The toggle is cleared and the reason retained.
* - One transcript line carries the explanation, then one `config_option_update`.
* - The repeat report sends nothing.
*/
it.each([
["extra_usage_disabled", "requires extra usage to be enabled for this account"],
["preference", "not enabled for your organization or API gateway"],
["unknown", "currently unavailable"],
[FUTURE_REASON, "not available (some_future_reason)"],
] as [FastModeDisabledReason, string][])(
"explains the blocking reason %s once when the SDK refuses a toggle the user turned on",
async (reason, explanation) => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
await sync(SESSION_ID, session, "off", reason);
expect(session.fastModeEnabled).toBe(false);
expect(session.fastModeDisabledReason).toBe(reason);
expect(sessionUpdates).toHaveLength(2);
expect(sessionUpdates[0].update).toEqual({
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: `\n\n> **Fast mode turned off:** ${explanation}.\n\n`,
},
});
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
expect(session.configOptions).toContainEqual(createFastModeConfigOption(false, true, reason));
// The same report again changes nothing the user can see: no repeat notice.
await sync(SESSION_ID, session, "off", reason);
expect(sessionUpdates).toHaveLength(2);
},
);
/**
* Scenario:
* 1. The toggle is on and the SDK reports `off` with `sdk_opt_in_required`.
* Expected:
* - The toggle is cleared and no reason is retained.
* - Only a `config_option_update` is sent: no notice or transcript line.
*/
it("clears the toggle quietly when the SDK reports off with sdk_opt_in_required", async () => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
await sync(SESSION_ID, session, "off", "sdk_opt_in_required");
expect(session.fastModeEnabled).toBe(false);
expect(session.fastModeDisabledReason).toBe("extra_usage_disabled");
expect(sessionUpdates).toHaveLength(2);
expect(sessionUpdates[0].update).toEqual({
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "\n\n> **Fast mode turned off:** requires extra usage to be enabled for this account.\n\n",
},
});
expect(sessionUpdates[1].update).toMatchObject({ sessionUpdate: "config_option_update" });
expect(session.configOptions).toContainEqual(
createFastModeConfigOption(false, true, "extra_usage_disabled"),
);
// The same report again changes nothing the user can see: no repeat notice.
await sync(SESSION_ID, session, "off", "extra_usage_disabled");
expect(sessionUpdates).toHaveLength(2);
expect(session.fastModeDisabledReason).toBeUndefined();
expect(sessionUpdates.map((n) => n.update.sessionUpdate)).toEqual(["config_option_update"]);
});
it("explains the flip as a live notice to a client on the notice contract", async () => {
@@ -493,3 +568,89 @@ describe("syncFastModeState (SDK-driven state changes)", () => {
expect(disabledCase.sessionUpdates).toHaveLength(0);
});
});
describe("Fast mode that Claude Code refuses during a turn", () => {
const SESSION_ID = "test-session";
/** A session whose Claude Code answers the first prompt with a `system`/init
* that reports Fast mode off with `reason`, as it does on every turn. */
async function setupTurn(reason: FastModeDisabledReason) {
const updates: SessionNotification[] = [];
const client = {
sessionUpdate: async (n: SessionNotification) => {
updates.push(n);
},
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(client, { log: () => {}, error: () => {} });
await initializeClient(agent, { session: { configOptions: { boolean: {} } } });
const input = new Pushable<any>();
async function* messages() {
const { value: prompt } = await input[Symbol.asyncIterator]().next();
yield userEcho(prompt);
yield {
type: "system",
subtype: "init",
fast_mode_state: "off",
fast_mode_disabled_reason: reason,
session_id: SESSION_ID,
};
yield successfulResultMessage();
yield { type: "system", subtype: "session_state_changed", state: "idle" };
}
const query = Object.assign(wrapQuery(messages()), { applyFlagSettings: async () => {} });
agent.sessions[SESSION_ID] = mockSessionState({
query,
input,
fastModeEnabled: false,
configOptions: [createFastModeConfigOption(false, true)],
});
return { agent, updates };
}
/**
* Scenario:
* 1. The client, without the notices capability, turns Fast mode on.
* 2. It sends a prompt, and Claude Code's init reports Fast mode off, reason `preference`.
* Expected:
* - The transcript gets one line that explains the organization or gateway has not enabled it.
* - Then one `config_option_update` where `fast` reads off, with the explanation in its description.
*/
it("explains a Fast mode that the organization or gateway has not enabled", async () => {
const { agent, updates } = await setupTurn("preference");
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "fast",
type: "boolean",
value: true,
});
updates.length = 0;
await agent.prompt({ sessionId: SESSION_ID, prompt: [{ type: "text", text: "hi" }] });
const explained = updates
.map((n) => n.update)
.filter((u) => u.sessionUpdate !== "usage_update");
expect(explained).toEqual([
{
sessionUpdate: "agent_message_chunk",
content: {
type: "text",
text: "\n\n> **Fast mode turned off:** not enabled for your organization or API gateway.\n\n",
},
},
{
sessionUpdate: "config_option_update",
configOptions: [
expect.objectContaining({
id: "fast",
currentValue: false,
description: expect.stringMatching(
/ — not enabled for your organization or API gateway$/,
),
}),
],
},
]);
});
});

View File

@@ -1445,6 +1445,51 @@ describe("ACP v2 sessions", () => {
});
});
/**
* Scenario:
* 1. The client turns Fast mode on and sends a prompt.
* 2. Claude Code's `system`/init of that turn reports Fast mode off, reason `preference`.
* Expected:
* - One warning notice explains that the organization or gateway has not enabled it.
* - The `fast` option reads off, and its description ends with the explanation.
*/
it("explains a Fast mode that the organization or gateway has not enabled", async () => {
scriptTurns(async function* (options) {
yield fastModeInit(options, "off", "preference");
yield assistantText(options, "Done.");
yield result(options);
yield idleState(options);
});
const client = v2Client();
await client.app.connectWith(connectRouter(), async (agent) => {
await initializeV2(agent);
const { sessionId } = await agent.request(v2.methods.agent.session.new, { cwd });
await agent.request(v2.methods.agent.session.setConfigOption, {
sessionId,
configId: "fast",
type: "boolean",
value: true,
});
await agent.request(v2.methods.agent.session.prompt, { sessionId, prompt: text("hi") });
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
await client.authUpdate(1);
});
expect(client.updates("notice")).toEqual([
{
sessionUpdate: "notice",
severity: "warning",
title: "Fast mode turned off",
description: "Not enabled for your organization or API gateway.",
},
]);
expect(fastOption(client.updates("config_option_update").at(-1))).toMatchObject({
currentValue: false,
description:
"Faster responses on supported models — not enabled for your organization or API gateway",
});
});
it("reports a refusal fallback with a notice and follows the model it swapped to", async () => {
sdk.initializationResult = async () => ({ models: [OPUS, HAIKU] });
scriptTurns(async function* (options) {
@@ -1691,6 +1736,23 @@ function stream(options: Options, event: Record<string, unknown>) {
};
}
/** Claude Code's per-turn `system`/init, reporting the Fast mode state. */
function fastModeInit(options: Options, state: string, reason: string) {
return {
type: "system",
subtype: "init",
fast_mode_state: state,
fast_mode_disabled_reason: reason,
session_id: options.sessionId,
uuid: randomUUID(),
};
}
/** The `fast` option of a `config_option_update`. */
function fastOption(update: { configOptions: { configId: string }[] } | undefined) {
return update?.configOptions.find((option) => option.configId === "fast");
}
function idleState(options: Options) {
return {
type: "system",

View File

@@ -435,6 +435,7 @@ import {
stripLocalCommandMetadata,
} from "../message-filters.js";
import type { NewSessionMeta, SDKMessageFilter } from "../new-session-meta.js";
import { computeSessionFingerprint } from "./session-fingerprint.js";
import { promptToClaude, SUPPORTED_PROMPT_CONTENT_TYPES } from "../prompt-to-claude.js";
import { AUTONOMOUS_RESULT_ORIGINS, isAbortedResult } from "../result-end.js";
import {
@@ -1065,12 +1066,13 @@ export type Session = {
* independent of user ownership: opted-in clients also apply automatic
* recommendations and settings-derived values. */
appliedEffortLevel?: string;
/** Why the SDK currently can't serve Fast mode, when the reason is one worth
* telling the user about (see {@link FAST_MODE_UNAVAILABLE_EXPLANATIONS} —
* routine states like the SDK's own opt-in requirement normalize to
* `undefined`). Refreshed from every `fast_mode_disabled_reason` the SDK
* reports on `system`/init and user-turn `result`s; surfaced in the Fast mode
* option's description so a toggle that snaps back off explains itself. */
/** Why the SDK currently can't serve Fast mode: the last
* `fast_mode_disabled_reason` that `explainFastModeDisabledReason` explains.
* {@link normalizeFastModeDisabledReason} drops the two it doesn't,
* `sdk_opt_in_required` and `pending`, to `undefined`. Refreshed from every
* `fast_mode_disabled_reason` the SDK reports on `system`/init and user-turn
* `result`s; surfaced in the Fast mode option's description so a toggle that
* snaps back off explains itself. */
fastModeDisabledReason?: FastModeDisabledReason;
abortController: AbortController;
/** Signal the consumer races `query.next()` against. Aborted by cancel()
@@ -1462,149 +1464,6 @@ function disarmForceCancel(session: Session): void {
}
}
/** Normalize skills without changing their SDK semantics. Array order and
* duplicates do not affect the selected skill set, so neither should rebuild
* the underlying Query process. */
function normalizeSkills(skills: Options["skills"]): Options["skills"] {
return Array.isArray(skills) ? [...new Set(skills)].sort() : skills;
}
/** Whether a changed `_meta.claudeCode.options` key on session/load or
* session/resume must rebuild the Query process. Almost every option is read
* only when the process starts, so the default is `true`. Every key of the
* SDK's `Options` must be classified: an SDK bump that adds or removes an
* option fails to compile until it is decided here. */
const OPTION_REBUILDS_SESSION = {
additionalDirectories: true,
agentProgressSummaries: true,
agents: true,
allowDangerouslySkipPermissions: true,
allowedTools: true,
betas: true,
debug: true,
debugFile: true,
disallowedTools: true,
effort: true,
enableFileCheckpointing: true,
env: true,
executableArgs: true,
extraArgs: true,
fallbackModel: true,
forwardSubagentText: true,
includeHookEvents: true,
managedSettings: true,
maxBudgetUsd: true,
maxThinkingTokens: true,
maxTurns: true,
mcpServers: true,
model: true,
outputFormat: true,
pathToClaudeCodeExecutable: true,
permissionPrompts: true,
permissionPromptToolName: true,
persistSession: true,
perTaskStopAffordance: true,
planModeInstructions: true,
pluginDelivery: true,
plugins: true,
projectConfigRoot: true,
promptSuggestions: true,
sandbox: true,
sessionStoreFlush: true,
settings: true,
settingSources: true,
skills: true,
strictMcpConfig: true,
supportedDialogKinds: true,
systemPrompt: true,
taskBudget: true,
thinking: true,
toolAliases: true,
toolConfig: true,
tools: true,
verbatimPrompts: true,
// Managed or ignored by the adapter.
agent: false,
cwd: false,
executable: false,
includePartialMessages: false,
permissionMode: false,
// Per-call controls for attaching to the stored conversation.
continue: false,
forkSession: false,
resume: false,
resumeDropsTurn: false,
resumeSessionAt: false,
sessionId: false,
// Callbacks and live objects: they cannot cross JSON-RPC and have no stable serialized form.
abortController: false,
canUseTool: false,
hooks: false,
onElicitation: false,
onUserDialog: false,
sessionStore: false,
spawnClaudeCodeProcess: false,
stderr: false,
// No effect on an already-created session.
loadTimeoutMs: false,
title: false,
} as const satisfies Record<keyof Options, boolean>;
/** A JSON value with object keys sorted, so key order never changes a
* fingerprint. Functions and class instances (hook callbacks, abort
* controllers, in-process MCP servers) cannot cross JSON-RPC and have no
* stable serialized form, so they are left out. */
function canonicalJson(value: unknown): unknown {
if (typeof value === "function") return undefined;
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(canonicalJson);
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return undefined;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalJson((value as Record<string, unknown>)[key])])
.filter(([, entry]) => entry !== undefined),
);
}
/** Compute a stable fingerprint of the session-defining params so we can
* detect when a loadSession/resumeSession call requires tearing down and
* recreating the underlying Query process. Every option classified in
* {@link OPTION_REBUILDS_SESSION} is covered, so a warm resume never silently
* keeps stale values. MCP servers are sorted by name, and skills are
* normalized as a set, so ordering differences don't trigger unnecessary
* recreations. */
export function computeSessionFingerprint(params: {
cwd: string;
mcpServers?: NewSessionRequest["mcpServers"];
additionalDirectories?: NewSessionRequest["additionalDirectories"];
systemPrompt?: NewSessionRequest["systemPrompt"];
_meta?: NewSessionRequest["_meta"];
}): string {
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
const meta = params._meta as (NewSessionMeta & Record<string, unknown>) | undefined;
const options: Record<string, unknown> = Object.fromEntries(
Object.entries(meta?.claudeCode?.options ?? {}).filter(
([key]) => (OPTION_REBUILDS_SESSION as Record<string, boolean>)[key] === true,
),
);
options.skills = normalizeSkills(options.skills as Options["skills"]);
return JSON.stringify(
canonicalJson({
cwd: params.cwd,
mcpServers: servers,
additionalDirectories: params.additionalDirectories,
additionalRoots: meta?.additionalRoots,
systemPrompt: meta?.systemPrompt,
clientSystemPrompt: params.systemPrompt ?? undefined,
disableBuiltInTools: meta?.disableBuiltInTools,
emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages,
options,
}),
);
}
/** The JSON-RPC code ACP assigns to `authRequired`. Read from the SDK so it
* cannot drift from the errors this agent throws. */
const AUTH_REQUIRED_CODE = RequestError.authRequired().code;
@@ -10452,7 +10311,8 @@ export class ClaudeAcpAgent {
// `fast_mode_disabled_reason` reflects the post-switch model since SDK
// 0.3.219 (the initialize response used to answer from the spawn-time
// model). A fresh SDK session reports `sdk_opt_in_required` — the toggle IS
// the opt-in — which normalizes away, so only real blockers are retained.
// the opt-in — which normalizes to `undefined`, as does `pending`; every
// other reason is retained.
const fastModeDisabledReason = fastModeEnabled
? undefined
: normalizeFastModeDisabledReason(initializationResult.fast_mode_disabled_reason);

View File

@@ -0,0 +1,147 @@
import type { Options } from "@anthropic-ai/claude-agent-sdk";
import type { NewSessionRequest } from "@agentclientprotocol/sdk/unstable";
import type { NewSessionMeta } from "../new-session-meta.js";
/** Normalize skills without changing their SDK semantics. Array order and
* duplicates do not affect the selected skill set, so neither should rebuild
* the underlying Query process. */
function normalizeSkills(skills: Options["skills"]): Options["skills"] {
return Array.isArray(skills) ? [...new Set(skills)].sort() : skills;
}
/** Whether a changed `_meta.claudeCode.options` key on session/load or
* session/resume must rebuild the Query process. Almost every option is read
* only when the process starts, so the default is `true`. Every key of the
* SDK's `Options` must be classified: an SDK bump that adds or removes an
* option fails to compile until it is decided here. */
const OPTION_REBUILDS_SESSION = {
additionalDirectories: true,
agentProgressSummaries: true,
agents: true,
allowDangerouslySkipPermissions: true,
allowedTools: true,
betas: true,
debug: true,
debugFile: true,
disallowedTools: true,
effort: true,
enableFileCheckpointing: true,
env: true,
executableArgs: true,
extraArgs: true,
fallbackModel: true,
forwardSubagentText: true,
includeHookEvents: true,
managedSettings: true,
maxBudgetUsd: true,
maxThinkingTokens: true,
maxTurns: true,
mcpServers: true,
model: true,
outputFormat: true,
pathToClaudeCodeExecutable: true,
permissionPrompts: true,
permissionPromptToolName: true,
persistSession: true,
perTaskStopAffordance: true,
planModeInstructions: true,
pluginDelivery: true,
plugins: true,
projectConfigRoot: true,
promptSuggestions: true,
sandbox: true,
sessionStoreFlush: true,
settings: true,
settingSources: true,
skills: true,
strictMcpConfig: true,
supportedDialogKinds: true,
systemPrompt: true,
taskBudget: true,
thinking: true,
toolAliases: true,
toolConfig: true,
tools: true,
verbatimPrompts: true,
// Managed or ignored by the adapter.
agent: false,
cwd: false,
executable: false,
includePartialMessages: false,
permissionMode: false,
// Per-call controls for attaching to the stored conversation.
continue: false,
forkSession: false,
resume: false,
resumeDropsTurn: false,
resumeSessionAt: false,
sessionId: false,
// Callbacks and live objects: they cannot cross JSON-RPC and have no stable serialized form.
abortController: false,
canUseTool: false,
hooks: false,
onElicitation: false,
onUserDialog: false,
sessionStore: false,
spawnClaudeCodeProcess: false,
stderr: false,
// No effect on an already-created session.
loadTimeoutMs: false,
title: false,
} as const satisfies Record<keyof Options, boolean>;
/** A JSON value with object keys sorted, so key order never changes a
* fingerprint. Functions and class instances (hook callbacks, abort
* controllers, in-process MCP servers) cannot cross JSON-RPC and have no
* stable serialized form, so they are left out. */
function canonicalJson(value: unknown): unknown {
if (typeof value === "function") return undefined;
if (value === null || typeof value !== "object") return value;
if (Array.isArray(value)) return value.map(canonicalJson);
const proto = Object.getPrototypeOf(value);
if (proto !== Object.prototype && proto !== null) return undefined;
return Object.fromEntries(
Object.keys(value)
.sort()
.map((key) => [key, canonicalJson((value as Record<string, unknown>)[key])])
.filter(([, entry]) => entry !== undefined),
);
}
/** Compute a stable fingerprint of the session-defining params so we can
* detect when a loadSession/resumeSession call requires tearing down and
* recreating the underlying Query process. Every option classified in
* {@link OPTION_REBUILDS_SESSION} is covered, so a warm resume never silently
* keeps stale values. MCP servers are sorted by name, and skills are
* normalized as a set, so ordering differences don't trigger unnecessary
* recreations. */
export function computeSessionFingerprint(params: {
cwd: string;
mcpServers?: NewSessionRequest["mcpServers"];
additionalDirectories?: NewSessionRequest["additionalDirectories"];
systemPrompt?: NewSessionRequest["systemPrompt"];
_meta?: NewSessionRequest["_meta"];
}): string {
const servers = [...(params.mcpServers ?? [])].sort((a, b) => a.name.localeCompare(b.name));
const meta = params._meta as (NewSessionMeta & Record<string, unknown>) | undefined;
const options: Record<string, unknown> = Object.fromEntries(
Object.entries(meta?.claudeCode?.options ?? {}).filter(
([key]) => (OPTION_REBUILDS_SESSION as Record<string, boolean>)[key] === true,
),
);
options.skills = normalizeSkills(options.skills as Options["skills"]);
return JSON.stringify(
canonicalJson({
cwd: params.cwd,
mcpServers: servers,
additionalDirectories: params.additionalDirectories,
additionalRoots: meta?.additionalRoots,
systemPrompt: meta?.systemPrompt,
clientSystemPrompt: params.systemPrompt ?? undefined,
disableBuiltInTools: meta?.disableBuiltInTools,
emitRawSDKMessages: meta?.claudeCode?.emitRawSDKMessages,
options,
}),
);
}