Compare commits

...

4 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
Ben Brandt
4df69996ca fix: report a mid-turn API error notice where the response broke off (#1599)
## Problem

When a response stream drops after some blocks arrived, Claude Code
keeps the partial response and writes its own message into the
conversation, e.g. "API Error: Connection lost mid-response. The
response above may be incomplete."

If the partial response contained tool calls, Claude Code runs them and
the turn goes on. Since #1582 the v2 agent sends this text as a
`notice`, but only at the turn's result. In a turn that goes on, the
notice arrived after all the later work, so "the response above" pointed
at the wrong output.

## Change

The v2 agent sends the pending notice as soon as the main conversation
goes on past the failed request: at the next tool result, stream event
or assistant message.

A turn that ends at the failed request behaves as before: the idle's
`error` carries it when the turn fails, and a notice goes out at the
turn's end otherwise.

## Not changed

- v1 keeps its current behaviour, as in #1582.
- `session/load` replay still shows these messages as text.

## Testing

- New v2 case: a kept tool call, the API error message, the tool result,
then more output. The notice comes before the later text. It fails
without the change.
- The test double follows the message shape and order read from the
bundled CLI's code. Not run against a real dropped connection.
- `npm run check`, `npm run build` and `npm run test:run` pass.
2026-11-11 13:45:06 +00:00
Ben Brandt
ecd066c870 fix: say why the login failed in the v2 authRequired error (#1598)
## Problem

When an API request fails authentication, the v2 agent hides the message
Claude Code writes for it (#1582) and fails the turn with a bare
`Authentication required`. The reason is lost, for example:

> Failed to authenticate: this login reached its time limit and cannot
be renewed. Run `claude auth login` to sign in again, or use `claude
setup-token` and set CLAUDE_CODE_OAUTH_TOKEN for unattended jobs

A client can then only say that the agent is signed out, not why.

## Change

The turn's `authRequired` error now carries the result's error text in
its message: `Authentication required: <text>`. The code and `data` are
unchanged, so a client that starts its own sign-in from the code behaves
as before.

Text that tells the user to run `/login` is still left out, as that
instruction is for Claude Code's terminal (#863). Only the v2 agent
changes.

## Testing

`npm run check`, `npm run build` and `npm run test:run` pass. The v2
tests now pin both messages: the plain one for the `/login` text, and
the one with the reason for an authentication failure reported on the
assistant message.

Not checked against a real expired login.
2026-11-11 13:34:58 +00:00
9 changed files with 540 additions and 234 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

@@ -2433,7 +2433,7 @@ describe("ACP v2 core", () => {
expect(client.sessionUpdates.at(-1)).toMatchObject({
state: "idle",
stopReason: "error",
error: { code: -32000 },
error: { code: -32000, message: "Authentication required" },
});
});
@@ -2442,7 +2442,7 @@ describe("ACP v2 core", () => {
"an authentication failure as authRequired",
"authentication_failed",
"Failed to authenticate. API Error: 401",
{ code: -32000 },
{ code: -32000, message: "Authentication required: Failed to authenticate. API Error: 401" },
],
[
"another failure with its error kind",
@@ -2505,6 +2505,53 @@ describe("ACP v2 core", () => {
},
);
it("reports a failed API request that the turn goes on past where it failed", async () => {
const text = "API Error: Connection lost mid-response. The response above may be incomplete.";
const client = v2Client();
await client.connect(async (agent) => {
const { claude } = await openSession(agent);
claude.send(cycleStart());
// Claude Code keeps the tool call of the response that broke off, and runs it.
claude.send(
assistant("msg_partial", [
{ type: "tool_use", id: "toolu_kept", name: "Bash", input: { command: "ls" } },
]),
);
claude.send({
...assistant("msg_api_error", [{ type: "text", text }]),
message: {
id: "msg_api_error",
type: "message",
role: "assistant",
model: "<synthetic>",
content: [{ type: "text", text }],
usage: {},
},
error: "server_error",
});
claude.send({
type: "user",
message: {
role: "user",
content: [{ type: "tool_result", tool_use_id: "toolu_kept", content: "src" }],
},
parent_tool_use_id: null,
uuid: randomUUID(),
});
claude.send(assistant("msg_after", [{ type: "text", text: "Done." }]));
claude.send(result({ result: "Done." }));
await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"]));
});
const notice = { sessionUpdate: "notice", severity: "error", title: text };
const shown = client.sessionUpdates.filter(
(update) =>
update.sessionUpdate === "notice" || update.sessionUpdate === "agent_message_chunk",
);
expect(shown).toMatchObject([notice, { sessionUpdate: "agent_message_chunk" }]);
expect(shown[0]).toEqual(notice);
});
it.each([
[
"an error at the turn limit",

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,
}),
);
}

View File

@@ -91,7 +91,11 @@ export function idleOutcome(
providerFailureCategory(kind) === "auth_required");
return failed(
authFailure
? RequestError.authRequired()
? // Claude Code's `/login` instruction is for its terminal, so that text
// is left out. Any other says why the login failed, e.g. "Failed to
// authenticate: this login reached its time limit and cannot be
// renewed."
RequestError.authRequired(undefined, ended.loginRequired ? undefined : ended.error)
: RequestError.internalError(kind ? { errorKind: kind } : undefined, ended.error),
);
}
@@ -132,8 +136,9 @@ export class TurnReport {
* - Claude Code's sign-in message. Its `/login` instruction is for Claude
* Code's terminal, and the turn's idle fails with `authRequired` instead.
* - The message that Claude Code wrote for a failed API request (`error` on
* a `<synthetic>` message). The turn's idle carries it when the turn
* fails, and a notice otherwise (see {@link takeApiError}).
* a `<synthetic>` message). A notice carries it where the turn goes on
* past it. At the turn's end, the idle carries it when the turn fails,
* and a notice otherwise (see {@link takeApiError}).
*/
assistant(message: SDKAssistantMessage): boolean {
if (message.error) this.errorKind = message.error;

View File

@@ -903,6 +903,7 @@ export class V2Session implements RequestTarget {
this.tasks.toolResults(this.output, message);
return;
}
this.wentOnPastApiError();
// The rewrite applies to the updates of this message only.
this.output.rewrite = this.clearContext.toolResults(message);
try {
@@ -935,6 +936,7 @@ export class V2Session implements RequestTarget {
if (this.stopped.drops(message)) return;
if (message.parent_tool_use_id === null) {
this.outputStarts();
this.wentOnPastApiError();
this.reports.streamEvent(message);
}
this.output.streamEvent(message);
@@ -947,6 +949,7 @@ export class V2Session implements RequestTarget {
// request are not shown as Claude's text (see `TurnReport`).
if (this.turnReport.assistant(message)) return;
this.outputStarts();
this.wentOnPastApiError();
this.reports.assistant(message);
}
this.output.assistant(message);
@@ -1208,10 +1211,21 @@ export class V2Session implements RequestTarget {
}
/**
* Sends the text of a failed API request that Claude Code wrote into the
* conversation (see `TurnReport.assistant`) as a notice. A turn whose idle
* fails sends none: its error carries the failure. `end` is the turn's
* result, if it has one.
* The turn went on past a failed API request: Claude Code kept the tool
* calls of a response that broke off ("Connection lost mid-response. The
* response above may be incomplete.") and ran them. Its notice goes out
* here, after the output it is about, and not at the turn's end.
*/
private wentOnPastApiError(): void {
const text = this.turnReport.takeApiError();
if (text) this.reports.apiError(text);
}
/**
* At the turn's end, sends the text of a failed API request that Claude
* Code wrote into the conversation (see `TurnReport.assistant`) as a
* notice. A turn whose idle fails sends none: its error carries the
* failure. `end` is the turn's result, if it has one.
*/
private reportApiError(end: TurnEnd | undefined): void {
const text = this.turnReport.takeApiError();