mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-11 20:01:11 +00:00
Compare commits
4 Commits
v0.106.1-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c331e178a5 | ||
|
|
830f05a0ac | ||
|
|
4df69996ca | ||
|
|
ecd066c870 |
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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$/,
|
||||
),
|
||||
}),
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
147
src/v1/session-fingerprint.ts
Normal file
147
src/v1/session-fingerprint.ts
Normal 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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user