mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-13 21:02:08 +00:00
Compare commits
6 Commits
v0.106.1-p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e857d59ec | ||
|
|
c5c120dba9 | ||
|
|
4f474d655b | ||
|
|
21f3e0430b | ||
|
|
c78bcc7f41 | ||
|
|
ca46b412f5 |
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.106.0"
|
||||
".": "0.107.0"
|
||||
}
|
||||
|
||||
21
CHANGELOG.md
21
CHANGELOG.md
@@ -1,5 +1,26 @@
|
||||
# Changelog
|
||||
|
||||
## [0.107.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.106.0...v0.107.0) (2026-11-13)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* report the turn's rate limit in rate-limit failure errors ([#1610](https://github.com/agentclientprotocol/claude-agent-acp/issues/1610)) ([4f474d6](https://github.com/agentclientprotocol/claude-agent-acp/commit/4f474d655b88a075ec7d0124d45cafa0f5565af7))
|
||||
* report unknown subagent state at a conversation reset and reference children from tool calls ([#1608](https://github.com/agentclientprotocol/claude-agent-acp/issues/1608)) ([4804917](https://github.com/agentclientprotocol/claude-agent-acp/commit/4804917a6fa7050490d1d62c6689e78c231c752a))
|
||||
* say the cause of an api retry in the AIR warning details ([#1594](https://github.com/agentclientprotocol/claude-agent-acp/issues/1594)) ([4f690e1](https://github.com/agentclientprotocol/claude-agent-acp/commit/4f690e129c01f22e2c2a87e0e0a3c5b503241f15))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* assume a 1M context window until Claude Code reports it ([#1613](https://github.com/agentclientprotocol/claude-agent-acp/issues/1613)) ([9619805](https://github.com/agentclientprotocol/claude-agent-acp/commit/96198053067cf4d7b7440c52036174ad4f54183e))
|
||||
* end the tool calls of a response that Claude Code took back ([#1616](https://github.com/agentclientprotocol/claude-agent-acp/issues/1616)) ([21f3e04](https://github.com/agentclientprotocol/claude-agent-acp/commit/21f3e0430b693e6b04750ab3018e1eaf726af173))
|
||||
* explain why Claude Code turns Fast mode off ([#1602](https://github.com/agentclientprotocol/claude-agent-acp/issues/1602)) ([c331e17](https://github.com/agentclientprotocol/claude-agent-acp/commit/c331e178a5df47448be1a3ed2f71270c3aad2433))
|
||||
* follow Claude Code's rule for managed deniedModels in the model picker ([#1615](https://github.com/agentclientprotocol/claude-agent-acp/issues/1615)) ([c78bcc7](https://github.com/agentclientprotocol/claude-agent-acp/commit/c78bcc7f417fc8d3da66f51021b43f4528826ec0))
|
||||
* log a managed policy source that did not load ([#1614](https://github.com/agentclientprotocol/claude-agent-acp/issues/1614)) ([ca46b41](https://github.com/agentclientprotocol/claude-agent-acp/commit/ca46b412f51cdb7b5a303c751f5d48c216700887))
|
||||
* report a mid-turn API error notice where the response broke off ([#1599](https://github.com/agentclientprotocol/claude-agent-acp/issues/1599)) ([4df6999](https://github.com/agentclientprotocol/claude-agent-acp/commit/4df69996ca74adff6c42fc60e4b9b742f2d3fdf5))
|
||||
* say why the login failed in the v2 authRequired error ([#1598](https://github.com/agentclientprotocol/claude-agent-acp/issues/1598)) ([ecd066c](https://github.com/agentclientprotocol/claude-agent-acp/commit/ecd066c8700955e3456dd9387e6b8317f024e26c))
|
||||
* update to @anthropic-ai/claude-agent-sdk 0.3.323 ([#1612](https://github.com/agentclientprotocol/claude-agent-acp/issues/1612)) ([ddee24e](https://github.com/agentclientprotocol/claude-agent-acp/commit/ddee24ef8fd0abc0401fb32d567914030fbf6944))
|
||||
|
||||
## [0.106.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.105.0...v0.106.0) (2026-11-10)
|
||||
|
||||
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.106.0",
|
||||
"version": "0.107.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@agentclientprotocol/claude-agent-acp",
|
||||
"version": "0.106.0",
|
||||
"version": "0.107.0",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "2.6.0",
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.106.0",
|
||||
"version": "0.107.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
|
||||
@@ -1,5 +1,28 @@
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** The machine policy sources that `machinePolicy.didNotLoad` names. */
|
||||
const POLICY_SOURCES: Record<string, string> = {
|
||||
profile: "the macOS MDM profile",
|
||||
registry: "the Windows registry policy",
|
||||
file: "the managed-settings file",
|
||||
};
|
||||
|
||||
/**
|
||||
* The machine policy sources that may be there and did not load
|
||||
* (`machinePolicy.didNotLoad`, SDK 0.3.323), named for a log line: "the macOS
|
||||
* MDM profile, the managed-settings file". The resolved settings show such a
|
||||
* source as absent, so the adapter applies nothing from it, and Claude Code
|
||||
* reads the policy itself. Undefined when every source loaded, and when the
|
||||
* SDK does not report it.
|
||||
*/
|
||||
export function unloadedPolicySources(
|
||||
resolved: Pick<Awaited<ReturnType<typeof resolveSettings>>, "machinePolicy">,
|
||||
): string | undefined {
|
||||
const unloaded = resolved.machinePolicy?.didNotLoad;
|
||||
if (!unloaded || unloaded.length === 0) return undefined;
|
||||
return unloaded.map((kind) => POLICY_SOURCES[kind] ?? kind).join(", ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the managed-policy tier's env vars to `process.env` before any SDK
|
||||
* call, so the SDK subprocess inherits them. Going through `resolveSettings`
|
||||
@@ -28,6 +51,13 @@ export async function applyManagedPolicyEnv(
|
||||
console.error("Failed to resolve managed policy settings:", error);
|
||||
return;
|
||||
}
|
||||
const unloaded = unloadedPolicySources(policy);
|
||||
if (unloaded) {
|
||||
console.error(
|
||||
`Managed settings did not load from ${unloaded}: its env vars are not applied. ` +
|
||||
"Restart the agent to read it again.",
|
||||
);
|
||||
}
|
||||
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
|
||||
env[key] = value;
|
||||
}
|
||||
|
||||
@@ -8,9 +8,26 @@
|
||||
import type {
|
||||
SDKMessage,
|
||||
SDKMessageOrigin,
|
||||
SDKRateLimitInfo,
|
||||
SDKResultMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/**
|
||||
* The `data` of the error that fails a turn, for clients to dispatch on
|
||||
* without matching its text: the assistant message's `errorKind`, and for a
|
||||
* `rate_limit` failure the turn's latest `rate_limit_event` info as
|
||||
* `rateLimit`, whose `resetsAt` says when the account can run again. A turn
|
||||
* refused before its first usage sends no `usage_update` to carry that info.
|
||||
* `undefined` without an error kind.
|
||||
*/
|
||||
export function turnErrorData<K extends string>(
|
||||
errorKind: K | undefined,
|
||||
rateLimit: SDKRateLimitInfo | undefined,
|
||||
): { errorKind: K; rateLimit?: SDKRateLimitInfo } | undefined {
|
||||
if (!errorKind) return undefined;
|
||||
return errorKind === "rate_limit" && rateLimit ? { errorKind, rateLimit } : { errorKind };
|
||||
}
|
||||
|
||||
export type ResultEnd =
|
||||
| { stopReason: "refusal" | "max_tokens" | "end_turn" | "max_turn_requests" }
|
||||
/**
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { SessionConfigOption } from "@agentclientprotocol/sdk/unstable";
|
||||
import type { ModelInfo, Query, Settings } from "@anthropic-ai/claude-agent-sdk";
|
||||
// matchDeniedModels is marked @alpha in the SDK; API may shift in a future release.
|
||||
import {
|
||||
matchDeniedModels,
|
||||
type ModelInfo,
|
||||
type Query,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AIR_RECOMMENDED_CONFIG_VALUE_CAPABILITY, withAirMeta } from "./air-extension.js";
|
||||
import { bedrockRegionPrefix } from "./gateway-models.js";
|
||||
|
||||
@@ -105,7 +111,7 @@ function containsWords(haystack: string, needle: string): boolean {
|
||||
}
|
||||
|
||||
/** `id` without the gateway spellings that `normalizeModelId` keeps for
|
||||
* `deniedModels` and gateway listings: path segments before `claude-`
|
||||
* gateway listings: path segments before `claude-`
|
||||
* (`vertex_ai/`, `openrouter/anthropic/`, `publishers/anthropic/models/`)
|
||||
* and the Anthropic API's `-latest` suffix (`claude-3-5-haiku-latest`). */
|
||||
function withoutGatewaySpelling(id: string): string {
|
||||
@@ -376,8 +382,8 @@ export function aliasResumedModelRow(
|
||||
|
||||
/** A model id without the spellings that name the same model: provider
|
||||
* prefixes, dates, version suffixes, `:variant` suffixes, dotted versions,
|
||||
* `-fast` and context hints. `deniedModels` and gateway listings both match on
|
||||
* it. OpenRouter lists `anthropic/claude-opus-5.5:batch[1m]`, which normalizes
|
||||
* `-fast` and context hints. Gateway listings match on it. OpenRouter lists
|
||||
* `anthropic/claude-opus-5.5:batch[1m]`, which normalizes
|
||||
* to `claude-opus-5-5`; `anthropic/openai/gpt-5` keeps its `openai/` prefix and
|
||||
* matches no Claude id. */
|
||||
function normalizeModelId(value: string): string {
|
||||
@@ -401,24 +407,56 @@ function normalizeModelId(value: string): string {
|
||||
);
|
||||
}
|
||||
|
||||
/** A path that no model id starts with. Nothing enforces that no
|
||||
* `deniedModels` entry names it (see {@link deniedModelSpellings}). */
|
||||
const VERSION_SPELLING_PATH = "version-of/";
|
||||
|
||||
/**
|
||||
* Whether managed `deniedModels` blocks a model id, following the CLI's rules
|
||||
* closely enough for the picker: a family alias blocks the whole family, and a
|
||||
* model id blocks that version in every spelling plus the later minor versions
|
||||
* it prefixes (`claude-opus-5` also blocks `claude-opus-5-5`). Entries share
|
||||
* `normalizeModelId`, so `claude-opus-4.5` or `anthropic/claude-opus-4.5` also
|
||||
* blocks `claude-opus-4-5`, which the CLI may still allow. The CLI stays the
|
||||
* enforcer; this only keeps the picker from offering a refused row.
|
||||
* The spellings of `id` to ask the SDK's `matchDeniedModels` about. That
|
||||
* function compares an id with the entries alone. The CLI blocks more
|
||||
* (`setModel` against CLI 2.1.323):
|
||||
* - a last path segment that an exact-name entry names: `my-model` blocks
|
||||
* `gateway/my-model`;
|
||||
* - a version behind a `:variant` suffix or written with a dot:
|
||||
* `claude-opus-5-5` blocks `claude-opus-5-5:free` and
|
||||
* `anthropic/claude-opus-5.5`.
|
||||
*
|
||||
* An exact-name entry does not block a `:variant`: `my-model` leaves
|
||||
* `my-model:free` allowed. So the version spelling goes behind a path, which
|
||||
* the SDK ignores for a versioned id and which keeps the spelling from
|
||||
* equalling an exact name.
|
||||
*/
|
||||
export function isDeniedModel(modelId: string, deniedModels: readonly string[]): boolean {
|
||||
const id = normalizeModelId(modelId);
|
||||
if (!id) return false;
|
||||
return deniedModels.some((entry) => {
|
||||
const denied = normalizeModelId(entry);
|
||||
if (!denied) return false;
|
||||
if (MODEL_FAMILY_ALIASES.has(denied)) return id === denied || id.includes(`-${denied}`);
|
||||
return id === denied || id.startsWith(`${denied}-`);
|
||||
});
|
||||
function deniedModelSpellings(id: string): string[] {
|
||||
const lastSegment = id.slice(id.lastIndexOf("/") + 1);
|
||||
const version = lastSegment.replace(/:.*$/, "").replace(/(?<=\d)\.(?=\d)/g, "-");
|
||||
return [id, lastSegment, `${VERSION_SPELLING_PATH}${version}`];
|
||||
}
|
||||
|
||||
/**
|
||||
* Which of `ids` a managed `deniedModels` list blocks, by the CLI's own entry
|
||||
* rules: the SDK's `matchDeniedModels`, asked about each of the
|
||||
* {@link deniedModelSpellings} of an id.
|
||||
* - A family alias (`opus`) blocks the family.
|
||||
* - A model id blocks that version in every spelling, and the later minor
|
||||
* versions when it names none (`claude-opus-5` also blocks `claude-opus-5-5`).
|
||||
* - Any other entry (`claude-opus-4.5`, `my-model`) blocks only that name,
|
||||
* alone or as the last path segment (`gateway/my-model`).
|
||||
* - `default`, `best` and `opusplan` block nothing.
|
||||
*
|
||||
* The CLI stays the enforcer; this only keeps the picker from offering a row
|
||||
* that `setModel` would refuse.
|
||||
*/
|
||||
export async function deniedModelIds(
|
||||
ids: readonly string[],
|
||||
deniedModels: readonly string[],
|
||||
): Promise<Set<string>> {
|
||||
if (ids.length === 0 || deniedModels.length === 0) return new Set();
|
||||
const asked = ids.flatMap((id) => deniedModelSpellings(id).map((spelling) => ({ id, spelling })));
|
||||
const { denied } = await matchDeniedModels(
|
||||
asked.map(({ spelling }) => spelling),
|
||||
deniedModels,
|
||||
);
|
||||
return new Set(asked.filter((_, index) => denied[index]).map(({ id }) => id));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -436,15 +474,16 @@ export function isDeniedModel(modelId: string, deniedModels: readonly string[]):
|
||||
* - `undefined` is handled by the caller (no allowlist applied).
|
||||
* - The Default option is unaffected by `availableModels` — it always remains
|
||||
* available, even when the allowlist is `[]`.
|
||||
* - Managed `deniedModels` wins over the allowlist, so a denied entry is
|
||||
* dropped even though the CLI would otherwise leave it unmatched.
|
||||
* - Managed `deniedModels` wins over the allowlist: an entry is dropped when
|
||||
* the list blocks its name, its `modelOverrides` target, or the model of the
|
||||
* row it resolves to (see {@link deniedModelIds}).
|
||||
*/
|
||||
export function applyAvailableModelsAllowlist(
|
||||
export async function applyAvailableModelsAllowlist(
|
||||
sdkModels: ModelInfo[],
|
||||
allowlist: string[],
|
||||
settingsModelOverrides?: Record<string, string>,
|
||||
deniedModels: readonly string[] = [],
|
||||
): ModelInfo[] {
|
||||
): Promise<ModelInfo[]> {
|
||||
// Default is always preserved per the docs. Synthesize one if the SDK
|
||||
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
|
||||
// `models[0]` as a fallback) still has something to work with.
|
||||
@@ -465,22 +504,21 @@ export function applyAvailableModelsAllowlist(
|
||||
// opaque provider IDs) often won't textually resemble anything in
|
||||
// `sdkModelsWithoutDefault`. Only the entry's surfaced `value` becomes the
|
||||
// override target, so it's what `setModel` ends up passing to the API.
|
||||
for (const entry of allowlist) {
|
||||
const entries = allowlist.flatMap((entry) => {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
|
||||
const overridden = settingsModelOverrides?.[trimmed];
|
||||
const effective = overridden ?? trimmed;
|
||||
if (seen.has(effective)) continue;
|
||||
|
||||
if (!trimmed) return [];
|
||||
const effective = settingsModelOverrides?.[trimmed] ?? trimmed;
|
||||
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
|
||||
if (
|
||||
[trimmed, effective, sdkMatch?.resolvedModel].some(
|
||||
(id) => id != null && isDeniedModel(id, deniedModels),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const ids = [trimmed, effective, ...(sdkMatch?.resolvedModel ? [sdkMatch.resolvedModel] : [])];
|
||||
return [{ trimmed, effective, sdkMatch, ids }];
|
||||
});
|
||||
const denied = await deniedModelIds(
|
||||
entries.flatMap(({ ids }) => ids),
|
||||
deniedModels,
|
||||
);
|
||||
for (const { trimmed, effective, sdkMatch, ids } of entries) {
|
||||
if (seen.has(trimmed) || seen.has(effective)) continue;
|
||||
if (ids.some((id) => denied.has(id))) continue;
|
||||
if (sdkMatch) {
|
||||
result.push({ ...sdkMatch, value: effective });
|
||||
} else {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
resolveSettings,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { unloadedPolicySources } from "./managed-policy.js";
|
||||
import { claudeConfigDir } from "./paths.js";
|
||||
|
||||
/**
|
||||
@@ -62,6 +63,8 @@ export class SettingsManager {
|
||||
private managedModelSettings: ModelSelectionSettings = {};
|
||||
private managedApiKeyHelper?: string;
|
||||
private managedCompactionSettings: CompactionSettings = {};
|
||||
/** The policy sources last logged as not loaded (see {@link unloadedPolicySources}). */
|
||||
private unloadedPolicy?: string;
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
@@ -118,6 +121,17 @@ export class SettingsManager {
|
||||
private async loadAllSettings(): Promise<void> {
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: this.cwd });
|
||||
// Once per change: a reload after each settings file edit would repeat it.
|
||||
const unloaded = unloadedPolicySources(resolved);
|
||||
if (unloaded !== this.unloadedPolicy) {
|
||||
this.unloadedPolicy = unloaded;
|
||||
if (unloaded) {
|
||||
this.logger.error(
|
||||
`Managed settings did not load from ${unloaded}: the session's settings ` +
|
||||
"and model list do not reflect it. Claude Code reads the policy itself.",
|
||||
);
|
||||
}
|
||||
}
|
||||
this.effective = filterEscalatingDefaultMode(resolved);
|
||||
// The CLI reads `deniedModels` from managed settings only.
|
||||
this.managedDeniedModels = resolved.sources
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { applyManagedPolicyEnv } from "../managed-policy.js";
|
||||
import { applyManagedPolicyEnv, unloadedPolicySources } from "../managed-policy.js";
|
||||
|
||||
type ResolveSettings = typeof resolveSettings;
|
||||
|
||||
@@ -57,6 +57,25 @@ describe("applyManagedPolicyEnv", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reports a policy source that did not load on stderr, and still applies the rest", async () => {
|
||||
const env: Record<string, string | undefined> = {};
|
||||
const stderr = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
await applyManagedPolicyEnv(
|
||||
resolverReturning({
|
||||
effective: { env: { FROM_FILE: "1" } },
|
||||
machinePolicy: { didNotLoad: ["profile"] },
|
||||
}),
|
||||
env,
|
||||
);
|
||||
|
||||
expect(env).toEqual({ FROM_FILE: "1" });
|
||||
expect(stderr).toHaveBeenCalledWith(
|
||||
"Managed settings did not load from the macOS MDM profile: its env vars are not applied. " +
|
||||
"Restart the agent to read it again.",
|
||||
);
|
||||
});
|
||||
|
||||
it("asks the SDK for the managed tier only", async () => {
|
||||
const seen: unknown[] = [];
|
||||
const resolve = (async (options: unknown) => {
|
||||
@@ -69,3 +88,18 @@ describe("applyManagedPolicyEnv", () => {
|
||||
expect(seen).toEqual([{ settingSources: [] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unloadedPolicySources", () => {
|
||||
it.each([
|
||||
// What the SDK reports when every source loaded.
|
||||
[{ machinePolicy: { didNotLoad: false as const } }, undefined],
|
||||
// An SDK before 0.3.323 leaves the member out.
|
||||
[{}, undefined],
|
||||
[
|
||||
{ machinePolicy: { didNotLoad: ["registry" as const, "file" as const] } },
|
||||
"the Windows registry policy, the managed-settings file",
|
||||
],
|
||||
])("names the sources of %j", (resolved, expected) => {
|
||||
expect(unloadedPolicySources(resolved)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@ describe("versioned model display names", () => {
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
];
|
||||
const ids = ["claude-opus-5-5[1m]", "claude-opus-5-5", "opus[1m]", "opus", "haiku"];
|
||||
const models = applyAvailableModelsAllowlist(sdkModels, ids);
|
||||
const models = await applyAvailableModelsAllowlist(sdkModels, ids);
|
||||
const setModel = vi.fn();
|
||||
const state = await getAvailableModels(
|
||||
{ setModel } as unknown as Query,
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
aliasResumedModelRow,
|
||||
resolveModelPreference,
|
||||
applyAvailableModelsAllowlist,
|
||||
isDeniedModel,
|
||||
deniedModelIds,
|
||||
matchResumedModel,
|
||||
modelEntryFor,
|
||||
} from "../session-model.js";
|
||||
@@ -304,8 +304,8 @@ describe("aliasResumedModelRow", () => {
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - resolvedModel matching", () => {
|
||||
it("resolves an allowlist entry pinned to a canonical resolvedModel id", () => {
|
||||
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);
|
||||
it("resolves an allowlist entry pinned to a canonical resolvedModel id", async () => {
|
||||
const result = await applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["claude-sonnet-5"]);
|
||||
const entry = result.find((m) => m.value === "claude-sonnet-5");
|
||||
expect(entry).toBeDefined();
|
||||
expect(entry?.displayName).toBe("Sonnet");
|
||||
@@ -324,14 +324,14 @@ describe("applyAvailableModelsAllowlist - modelOverrides", () => {
|
||||
},
|
||||
];
|
||||
|
||||
it("surfaces the override target as the value while keeping the alias's display metadata", () => {
|
||||
it("surfaces the override target as the value while keeping the alias's display metadata", async () => {
|
||||
// The override target is an opaque Bedrock ARN with no "opus" substring,
|
||||
// so matching against it (instead of the "claude-opus-4-6" alias) would
|
||||
// find nothing and silently drop displayName/description/supportsEffort.
|
||||
const overrides = {
|
||||
"claude-opus-4-6": "arn:aws:bedrock:us-west-2:111122223333:inference-profile/custom-7f3a",
|
||||
};
|
||||
const result = applyAvailableModelsAllowlist(SDK_MODELS, ["claude-opus-4-6"], overrides);
|
||||
const result = await applyAvailableModelsAllowlist(SDK_MODELS, ["claude-opus-4-6"], overrides);
|
||||
|
||||
const entry = result.find((m) => m.value === overrides["claude-opus-4-6"]);
|
||||
expect(entry).toEqual({
|
||||
@@ -345,8 +345,8 @@ describe("applyAvailableModelsAllowlist - modelOverrides", () => {
|
||||
});
|
||||
|
||||
describe("applyAvailableModelsAllowlist - managed deniedModels", () => {
|
||||
it("drops an allowlisted model that deniedModels blocks, keeping Default", () => {
|
||||
const result = applyAvailableModelsAllowlist(
|
||||
it("drops an allowlisted model that deniedModels blocks, keeping Default", async () => {
|
||||
const result = await applyAvailableModelsAllowlist(
|
||||
LIVE_SHAPED_MODELS,
|
||||
["claude-sonnet-5", "claude-opus-5-5"],
|
||||
undefined,
|
||||
@@ -355,43 +355,87 @@ describe("applyAvailableModelsAllowlist - managed deniedModels", () => {
|
||||
expect(result.map((m) => m.value)).toEqual(["default", "claude-sonnet-5"]);
|
||||
});
|
||||
|
||||
it("checks an alias entry against the row it resolves to", () => {
|
||||
const result = applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["sonnet"], undefined, [
|
||||
it("checks an alias entry against the row it resolves to", async () => {
|
||||
const result = await applyAvailableModelsAllowlist(LIVE_SHAPED_MODELS, ["sonnet"], undefined, [
|
||||
"claude-sonnet-5",
|
||||
]);
|
||||
expect(result.map((m) => m.value)).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("checks an entry against its modelOverrides target", async () => {
|
||||
const result = await applyAvailableModelsAllowlist(
|
||||
LIVE_SHAPED_MODELS,
|
||||
["sonnet", "haiku"],
|
||||
{
|
||||
haiku:
|
||||
"arn:aws:bedrock:us-east-1:123456789012:inference-profile/us.anthropic.claude-opus-5-5-v1:0",
|
||||
},
|
||||
["claude-opus-5-5"],
|
||||
);
|
||||
expect(result.map((m) => m.value)).toEqual(["default", "sonnet"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDeniedModel", () => {
|
||||
it("blocks every spelling of a denied version and its later minors", () => {
|
||||
const denied = ["claude-opus-5"];
|
||||
expect(isDeniedModel("claude-opus-5", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5-20260101", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5[1m]", denied)).toBe(true);
|
||||
expect(isDeniedModel("us.anthropic.claude-opus-5-v1:0", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5-5", denied)).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-4-8", denied)).toBe(false);
|
||||
expect(isDeniedModel("anthropic/claude-opus-5.5:batch[1m]", denied)).toBe(true);
|
||||
expect(isDeniedModel("anthropic/us.anthropic.claude-opus-5-v1:0", denied)).toBe(true);
|
||||
});
|
||||
|
||||
it("reads an entry in OpenRouter or dotted spelling as the dashed SDK id", () => {
|
||||
expect(isDeniedModel("claude-opus-4-5", ["claude-opus-4.5"])).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5-5", ["anthropic/claude-opus-5.5"])).toBe(true);
|
||||
expect(isDeniedModel("claude-opus-5", ["anthropic/claude-opus-5.5"])).toBe(false);
|
||||
});
|
||||
|
||||
it("blocks only that version when the entry names a minor", () => {
|
||||
expect(isDeniedModel("claude-opus-5", ["claude-opus-5-5"])).toBe(false);
|
||||
expect(isDeniedModel("claude-opus-5-5-fast", ["claude-opus-5-5"])).toBe(true);
|
||||
});
|
||||
|
||||
it("blocks a whole family for a family alias and ignores settings-dependent aliases", () => {
|
||||
expect(isDeniedModel("claude-haiku-4-5-20251001", ["haiku"])).toBe(true);
|
||||
expect(isDeniedModel("haiku", ["haiku"])).toBe(true);
|
||||
expect(isDeniedModel("claude-sonnet-5", ["haiku"])).toBe(false);
|
||||
expect(isDeniedModel("claude-opus-5", ["default", "best", "opusplan"])).toBe(false);
|
||||
describe("deniedModelIds", () => {
|
||||
// What CLI 2.1.323 answered `setModel(id)` under each managed `deniedModels`
|
||||
// list: it refused the `blocked` ids as restricted by the organization, and
|
||||
// not the `allowed` ones.
|
||||
it.each<{ name: string; denied: string[]; blocked: string[]; allowed: string[] }>([
|
||||
{
|
||||
name: "a family alias blocks the family",
|
||||
denied: ["opus"],
|
||||
blocked: ["opus[1m]", "vertex_ai/claude-opus-5-5", "claude-opus-5.5", "my-opus-proxy"],
|
||||
allowed: ["opusplan", "anthropic/openai/gpt-5", "claude-sonnet-5.5"],
|
||||
},
|
||||
{
|
||||
name: "a version without a minor blocks its later minors",
|
||||
denied: ["claude-opus-5"],
|
||||
blocked: ["claude-opus-5", "claude-opus-5-5", "us.anthropic.claude-opus-5-5-v1:0"],
|
||||
allowed: ["opus", "claude-opus-4-5"],
|
||||
},
|
||||
{
|
||||
name: "a version with a minor blocks that version in every spelling",
|
||||
denied: ["claude-opus-5-5"],
|
||||
blocked: [
|
||||
"claude-opus-5-5-fast",
|
||||
"claude-opus-5-5[1m]",
|
||||
"claude-opus-5-5@20260101",
|
||||
"openrouter/anthropic/claude-opus-5-5",
|
||||
],
|
||||
allowed: ["claude-opus-5", "opus", "claude-opus-5-50"],
|
||||
},
|
||||
{
|
||||
name: "a version is blocked behind a :variant suffix and written with a dot",
|
||||
denied: ["claude-opus-5-5"],
|
||||
blocked: [
|
||||
"claude-opus-5-5:free",
|
||||
"claude-opus-5-5:batch[1m]",
|
||||
"anthropic/claude-opus-5.5",
|
||||
"x/claude-opus-5.5:free",
|
||||
"claude-opus-5.5-20260101",
|
||||
],
|
||||
allowed: ["claude-opus-5.50", "anthropic/claude-opus-5"],
|
||||
},
|
||||
{
|
||||
name: "an entry that is no version blocks that exact name, also as the last path segment",
|
||||
denied: ["my-model"],
|
||||
blocked: ["My-Model", "gateway/my-model", "a/b/my-model", "gateway/my-model[1m]"],
|
||||
allowed: ["my-model:free", "gateway/my-model:free", "my-model-2", "my-model/x"],
|
||||
},
|
||||
{
|
||||
name: "a dotted entry is an exact name, not a version",
|
||||
denied: ["claude-opus-4.5"],
|
||||
blocked: ["claude-opus-4.5", "anthropic/claude-opus-4.5"],
|
||||
allowed: ["claude-opus-4-5", "claude-opus-4-5-20251101", "claude-opus-4.5:batch"],
|
||||
},
|
||||
{
|
||||
name: "an alias whose model depends on the release blocks nothing",
|
||||
denied: ["default", "best", "opusplan"],
|
||||
blocked: [],
|
||||
allowed: ["default", "best", "opusplan", "opus"],
|
||||
},
|
||||
])("$name", async ({ denied, blocked, allowed }) => {
|
||||
expect([...(await deniedModelIds([...allowed, ...blocked], denied))]).toEqual(blocked);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, it, expect, afterEach, vi } from "vitest";
|
||||
import * as os from "node:os";
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { SettingsManager } from "../settings.js";
|
||||
|
||||
@@ -90,3 +91,30 @@ describe("model selection settings policy", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("a managed policy source that did not load", () => {
|
||||
let manager: SettingsManager;
|
||||
afterEach(() => manager?.dispose());
|
||||
|
||||
it("is logged once while the same source stays unloaded", async () => {
|
||||
vi.mocked(resolveSettings).mockResolvedValue({
|
||||
effective: {},
|
||||
provenance: {},
|
||||
sources: [],
|
||||
machinePolicy: { didNotLoad: ["profile"] },
|
||||
});
|
||||
const error = vi.fn();
|
||||
manager = new SettingsManager(process.cwd(), { logger: { log: () => {}, error } });
|
||||
await manager.initialize();
|
||||
// Another cwd resolves the settings again.
|
||||
await manager.setCwd(os.tmpdir());
|
||||
|
||||
expect(resolveSettings).toHaveBeenCalledTimes(2);
|
||||
expect(error.mock.calls).toEqual([
|
||||
[
|
||||
"Managed settings did not load from the macOS MDM profile: the session's settings " +
|
||||
"and model list do not reflect it. Claude Code reads the policy itself.",
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
63
src/tests/retracted-tool-calls.test.ts
Normal file
63
src/tests/retracted-tool-calls.test.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { RetractedToolCalls } from "../tool-calls/retracted.js";
|
||||
|
||||
/** An assistant message that calls the tools `toolUseIds`, as the SDK sends it. */
|
||||
function assistant(toolUseIds: string[], supersedes?: string[]) {
|
||||
return {
|
||||
type: "assistant",
|
||||
message: {
|
||||
id: "msg_1",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-fable-5",
|
||||
content: toolUseIds.map((id) => ({ type: "tool_use", id, name: "Read", input: {} })),
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "session",
|
||||
...(supersedes && { supersedes }),
|
||||
} as unknown as SDKMessage & { uuid: string };
|
||||
}
|
||||
|
||||
/** The notice of a refusal fallback that took back the messages `retracted`. */
|
||||
const notice = (retracted: string[]) =>
|
||||
({
|
||||
type: "system",
|
||||
subtype: "model_refusal_fallback",
|
||||
retracted_message_uuids: retracted,
|
||||
}) as unknown as SDKMessage;
|
||||
|
||||
describe("RetractedToolCalls", () => {
|
||||
it("gives the tool calls of a message once, though the notice and the retry both name it", () => {
|
||||
const calls = new RetractedToolCalls();
|
||||
const refused = assistant(["toolu_a", "toolu_b"]);
|
||||
calls.sent(refused);
|
||||
|
||||
expect(calls.retractedBy(notice([refused.uuid]))).toEqual(["toolu_a", "toolu_b"]);
|
||||
expect(calls.retractedBy(assistant([], [refused.uuid]))).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves out a tool call that the replacing message makes itself", () => {
|
||||
const calls = new RetractedToolCalls();
|
||||
const refused = assistant(["toolu_gone", "toolu_again"]);
|
||||
calls.sent(refused);
|
||||
|
||||
expect(calls.retractedBy(assistant(["toolu_again"], [refused.uuid]))).toEqual(["toolu_gone"]);
|
||||
});
|
||||
|
||||
it("forgets the oldest of more than 1000 messages", () => {
|
||||
const calls = new RetractedToolCalls();
|
||||
const oldest = assistant(["toolu_oldest"]);
|
||||
calls.sent(oldest);
|
||||
const others = Array.from({ length: 1000 }, (_, i) => assistant([`toolu_${i}`]));
|
||||
for (const message of others) calls.sent(message);
|
||||
|
||||
expect(calls.retractedBy(notice([oldest.uuid]))).toEqual([]);
|
||||
expect(calls.retractedBy(notice([others[0]!.uuid]))).toEqual(["toolu_0"]);
|
||||
});
|
||||
});
|
||||
@@ -13406,6 +13406,30 @@ describe("stop reason propagation", () => {
|
||||
expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" });
|
||||
});
|
||||
|
||||
it("adds the turn's rate limit to a rate-limit failure's data", async () => {
|
||||
const agent = createMockAgent();
|
||||
const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" };
|
||||
injectSession(agent, [
|
||||
{
|
||||
type: "rate_limit_event",
|
||||
rate_limit_info: rateLimit,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
},
|
||||
createAssistantError("rate_limit"),
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "You've hit your 5-hour limit · resets 5:30pm (America/Los_Angeles)",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(
|
||||
agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] }),
|
||||
).rejects.toMatchObject({ data: { errorKind: "rate_limit", rateLimit } });
|
||||
});
|
||||
|
||||
it.each(
|
||||
[false, true].flatMap((typedFailures) =>
|
||||
[undefined, "authentication_failed" as const].map((staleError) => ({
|
||||
@@ -14033,6 +14057,74 @@ describe("model refusal fallback handling", () => {
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// Claude Code takes the response of a model that refuses back, stops its
|
||||
// tool call without a result, and retries on the fallback model. Its notice,
|
||||
// or the first message of the retry, names the message that it took back.
|
||||
// Nothing else ends the call: the prompt used to fail with
|
||||
// `incomplete_tool_call` at the turn's end, although the retry answered.
|
||||
it.each<[string, { notice: boolean; retry: boolean }]>([
|
||||
["the fallback notice", { notice: true, retry: false }],
|
||||
["the first message of the retry", { notice: false, retry: true }],
|
||||
])(
|
||||
"ends the tool call of a response that Claude Code took back, named by %s",
|
||||
async (_name, names) => {
|
||||
const { agent, sessionUpdate } = createCapturingAgent();
|
||||
const assistant = (id: string, block: Record<string, unknown>) => ({
|
||||
type: "assistant",
|
||||
message: {
|
||||
id,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-fable-5",
|
||||
content: [block],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
});
|
||||
const refused = assistant("msg_refused", {
|
||||
type: "tool_use",
|
||||
id: "toolu_refused",
|
||||
name: "Read",
|
||||
input: { file_path: "/a.log" },
|
||||
});
|
||||
injectGeneratorSession(
|
||||
agent,
|
||||
makeGenerator([
|
||||
refused,
|
||||
refusalFallbackMessage(names.notice ? { retracted_message_uuids: [refused.uuid] } : {}),
|
||||
{
|
||||
...assistant("msg_retry", { type: "text", text: "The logs are clean." }),
|
||||
...(names.retry && { supersedes: [refused.uuid] }),
|
||||
},
|
||||
successResult(),
|
||||
]),
|
||||
modelStateOverrides,
|
||||
);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
|
||||
const updates = sessionUpdate.mock.calls.map((c: any[]) => (c[0] as { update: any }).update);
|
||||
const closed = updates.findIndex(
|
||||
(u) => u.toolCallId === "toolu_refused" && u.status === "failed",
|
||||
);
|
||||
expect(JSON.stringify(updates[closed])).toContain(
|
||||
"Claude Code replaced the response that made this tool call",
|
||||
);
|
||||
// Before the retry's answer.
|
||||
expect(closed).toBeLessThan(
|
||||
updates.findIndex((u) => u.content?.text === "The logs are clean."),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("MCP server startup failures", () => {
|
||||
|
||||
@@ -1493,20 +1493,7 @@ describe("ACP v2 sessions", () => {
|
||||
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) {
|
||||
yield {
|
||||
type: "system",
|
||||
subtype: "model_refusal_fallback",
|
||||
trigger: "refusal",
|
||||
direction: "retry",
|
||||
scope: "session",
|
||||
original_model: "claude-opus-4-8",
|
||||
fallback_model: "claude-haiku-4-5",
|
||||
request_id: "req_1",
|
||||
api_refusal_category: "cyber",
|
||||
content: "banner",
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId,
|
||||
};
|
||||
yield refusalFallback(options);
|
||||
yield assistantText(options, "Done.");
|
||||
yield result(options);
|
||||
yield idleState(options);
|
||||
@@ -1763,6 +1750,28 @@ function idleState(options: Options) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code's notice that it retried a refused response on the fallback
|
||||
* model. `retracted` names the messages of that response, which it took back.
|
||||
*/
|
||||
function refusalFallback(options: Options, retracted?: string[]) {
|
||||
return {
|
||||
type: "system",
|
||||
subtype: "model_refusal_fallback",
|
||||
trigger: "refusal",
|
||||
direction: "retry",
|
||||
scope: "session",
|
||||
original_model: "claude-opus-4-8",
|
||||
fallback_model: "claude-haiku-4-5",
|
||||
request_id: "req_1",
|
||||
api_refusal_category: "cyber",
|
||||
...(retracted && { retracted_message_uuids: retracted }),
|
||||
content: "banner",
|
||||
uuid: randomUUID(),
|
||||
session_id: options.sessionId,
|
||||
};
|
||||
}
|
||||
|
||||
function assistantText(options: Options, text: string, id = "msg_answer") {
|
||||
return {
|
||||
type: "assistant",
|
||||
@@ -3831,6 +3840,57 @@ describe("ACP v2 prompts", () => {
|
||||
expect(turnTrace(client.sessionUpdates).at(-1)).toBe("idle end_turn");
|
||||
});
|
||||
|
||||
/**
|
||||
* Scenario:
|
||||
* 1. Claude's response calls Read, and the model then refuses.
|
||||
* 2. Claude Code takes the response back, stops the call without a result,
|
||||
* and retries on the fallback model. Its notice, or the first message of
|
||||
* the retry, names the message that it took back.
|
||||
* Expected:
|
||||
* - The call ends `cancelled` when it is named, before the retry's answer.
|
||||
* Nothing else ends a call that ran: it would stay open past the idle.
|
||||
*/
|
||||
it.each<[string, { notice: boolean; retry: boolean }]>([
|
||||
["the fallback notice", { notice: true, retry: false }],
|
||||
["the first message of the retry", { notice: false, retry: true }],
|
||||
])(
|
||||
"ends the tool call of a response that Claude Code took back, named by %s",
|
||||
async (_name, names) => {
|
||||
scriptTurns(async function* (options) {
|
||||
const refused = toolUseMessage(options, "toolu_refused", "Read", { file_path: "/a.log" });
|
||||
yield refused;
|
||||
yield refusalFallback(options, names.notice ? [refused.uuid] : undefined);
|
||||
yield {
|
||||
...assistantText(options, "The logs are clean."),
|
||||
...(names.retry && { supersedes: [refused.uuid] }),
|
||||
};
|
||||
yield result(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.prompt, { sessionId, prompt: text("hi") });
|
||||
await vi.waitFor(() => expect(turnTrace(client.sessionUpdates)).toContain("idle end_turn"));
|
||||
await client.authUpdate(1);
|
||||
});
|
||||
|
||||
const updates = client.sessionUpdates.map(({ update }) => update);
|
||||
const closed = updates.findIndex(
|
||||
(update) =>
|
||||
update.sessionUpdate === "tool_call_update" &&
|
||||
update.toolCallId === "toolu_refused" &&
|
||||
update.status === "cancelled",
|
||||
);
|
||||
expect(JSON.stringify(updates[closed])).toContain(
|
||||
"Claude Code replaced the response that made this tool call",
|
||||
);
|
||||
expect(closed).toBeLessThan(
|
||||
updates.findIndex((update) => update.sessionUpdate === "agent_message_chunk"),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("reports a turn that fails after it was taken in as idle with the error stop reason", async () => {
|
||||
scriptTurns(async function* (options) {
|
||||
yield result(options, { is_error: true, result: "API Error: 529 Overloaded" });
|
||||
|
||||
@@ -143,6 +143,32 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("v2 core with Claude Code",
|
||||
]);
|
||||
}, 180_000);
|
||||
|
||||
it("ends the work once when Claude Code folds a prompt into it", async () => {
|
||||
const sessionId = await newSession("bypassPermissions");
|
||||
await prompt(
|
||||
sessionId,
|
||||
"Use the Bash tool in the foreground (not run_in_background) to run exactly: ping -c 8 127.0.0.1 > /dev/null . Then reply with just: woke",
|
||||
);
|
||||
await until(() =>
|
||||
updates.some(
|
||||
({ update }) =>
|
||||
update.sessionUpdate === "terminal_update" &&
|
||||
/ping/.test((update as v2.TerminalUpdate).command ?? ""),
|
||||
),
|
||||
);
|
||||
// Claude Code takes this prompt in after the command, into the same work.
|
||||
// It sends the prompt's `completed` frame before the work's result.
|
||||
await prompt(sessionId, "Reply with exactly the single word: pong");
|
||||
await until(() => states(sessionId).some((update) => update.state === "idle"));
|
||||
// A turn that the result opened would follow the idle at once.
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
expect(states(sessionId).map((update) => update.state)).toEqual(["running", "idle"]);
|
||||
// The idle is the result's: it carries what the work spent.
|
||||
const idle = states(sessionId).at(-1) as { stopReason?: string; usage?: v2.Usage };
|
||||
expect(idle.stopReason).toBe("end_turn");
|
||||
expect(idle.usage?.totalTokens).toBeGreaterThan(0);
|
||||
}, 240_000);
|
||||
|
||||
it("fails a queued prompt with -32800 at a cancel and goes idle with cancelled", async () => {
|
||||
const sessionId = await newSession("bypassPermissions");
|
||||
await prompt(
|
||||
|
||||
@@ -1706,6 +1706,62 @@ describe("ACP v2 core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["the turn of another prompt", true],
|
||||
["a cycle that Claude Code started on its own", false],
|
||||
])("ends the work once, at its result, when a prompt is folded into %s", async (_, prompted) => {
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
const { response, claude } = await openSession(agent);
|
||||
const ask = () =>
|
||||
agent.request(v2.methods.agent.session.prompt, {
|
||||
sessionId: response.sessionId,
|
||||
prompt: PROMPT,
|
||||
});
|
||||
let opener: SDKUserMessage | undefined;
|
||||
if (prompted) {
|
||||
const first = ask();
|
||||
opener = await claude.prompt(0);
|
||||
claude.send(lifecycle(opener, "started"));
|
||||
await first;
|
||||
}
|
||||
claude.send(cycleStart());
|
||||
await vi.waitFor(() => expect(client.states()).toEqual(["running"]));
|
||||
// As the bundled CLI sends it: a folded prompt's echo comes before its
|
||||
// `started`, and its `completed` before the result of the work.
|
||||
const second = ask();
|
||||
const folded = await claude.prompt(prompted ? 1 : 0);
|
||||
claude.send(echo(folded));
|
||||
claude.send(lifecycle(folded, "started"));
|
||||
await second;
|
||||
for (const event of textDelta("msg_pong", "pong")) claude.send(event);
|
||||
claude.send(assistant("msg_pong", [{ type: "text", text: "pong" }]));
|
||||
claude.send(lifecycle(folded, "completed"));
|
||||
claude.send(
|
||||
result({
|
||||
result: "pong",
|
||||
origin: { kind: prompted ? "human" : "task-notification" },
|
||||
user_message_uuids: [...(opener ? [opener.uuid] : []), folded.uuid],
|
||||
}),
|
||||
);
|
||||
if (opener) claude.send(lifecycle(opener, "completed"));
|
||||
claude.send(sessionState("idle"));
|
||||
await vi.waitFor(() => expect(client.states()).toContain("idle"));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
});
|
||||
|
||||
expect(client.states()).toEqual(["running", "idle"]);
|
||||
expect(
|
||||
client.sessionUpdates.filter((update) => update.sessionUpdate === "agent_message_chunk"),
|
||||
).toMatchObject([{ messageId: "msg_pong" }]);
|
||||
// The idle carries the result's usage: the result ended the work.
|
||||
expect(client.sessionUpdates.at(-1)).toMatchObject({
|
||||
state: "idle",
|
||||
stopReason: "end_turn",
|
||||
usage: { totalTokens: 6 },
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a tool call that Claude dropped before it ran as cancelled at the turn's end", async () => {
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
@@ -2467,6 +2523,29 @@ describe("ACP v2 core", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("adds the turn's rate limit to a rate-limit failure's error data", async () => {
|
||||
const rateLimit = { status: "rejected", resetsAt: 1700000000, rateLimitType: "five_hour" };
|
||||
const client = v2Client();
|
||||
await client.connect(async (agent) => {
|
||||
const { claude } = await openSession(agent);
|
||||
claude.send(cycleStart());
|
||||
claude.send({
|
||||
type: "rate_limit_event",
|
||||
rate_limit_info: rateLimit,
|
||||
uuid: "00000000-0000-4000-8000-00000000e002",
|
||||
session_id: "session",
|
||||
});
|
||||
claude.send({ ...assistant("msg_limited", []), error: "rate_limit" });
|
||||
claude.send(result({ is_error: true, result: "You've hit your 5-hour limit" }));
|
||||
await vi.waitFor(() => expect(client.states()).toEqual(["running", "idle"]));
|
||||
});
|
||||
|
||||
expect(client.sessionUpdates.at(-1)).toMatchObject({
|
||||
stopReason: "error",
|
||||
error: { data: { errorKind: "rate_limit", rateLimit } },
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a notice when the turn goes on to end", { result: "" }, "end_turn", 1],
|
||||
["a notice once when the result repeats its text", {}, "end_turn", 1],
|
||||
|
||||
@@ -686,21 +686,31 @@ export class AcpToolCallRenderer {
|
||||
* client `failed`, since v1 has no `cancelled`. If Claude Code ran it but
|
||||
* returned no result, it failed, unless a `session/cancel` ended the turn
|
||||
* (`cancelled`): the cancel stopped the run, so a v2 client gets `cancelled`
|
||||
* for it too. The report does not end the call's terminal: see {@link terminalExit}.
|
||||
* for it too. The same goes for a call that Claude Code stopped when it took
|
||||
* back the message that made it (`retracted`, see `RetractedToolCalls`),
|
||||
* which closes when that is known, before the turn ends. The report does not
|
||||
* end the call's terminal: see {@link terminalExit}.
|
||||
*/
|
||||
unfinished(call: { toolCallId: string; ran: boolean; cancelled?: boolean }): ToolCallUpdate {
|
||||
const stopped = !call.ran || call.cancelled === true;
|
||||
unfinished(call: {
|
||||
toolCallId: string;
|
||||
ran: boolean;
|
||||
cancelled?: boolean;
|
||||
retracted?: boolean;
|
||||
}): ToolCallUpdate {
|
||||
const stopped = !call.ran || call.cancelled === true || call.retracted === true;
|
||||
return {
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: call.toolCallId,
|
||||
status: stopped && this.capabilities.v2 ? V2_CANCELLED : "failed",
|
||||
content: [
|
||||
textContent(
|
||||
!call.ran
|
||||
? "Claude stopped this tool call before it ran."
|
||||
: call.cancelled
|
||||
? "The turn was cancelled before this tool call returned a result."
|
||||
: "Claude ended the turn without returning a result for this tool.",
|
||||
call.retracted
|
||||
? "Claude Code replaced the response that made this tool call, and stopped the call. If it had started, some of its effects may have happened."
|
||||
: !call.ran
|
||||
? "Claude stopped this tool call before it ran."
|
||||
: call.cancelled
|
||||
? "The turn was cancelled before this tool call returned a result."
|
||||
: "Claude ended the turn without returning a result for this tool.",
|
||||
),
|
||||
],
|
||||
};
|
||||
|
||||
68
src/tool-calls/retracted.ts
Normal file
68
src/tool-calls/retracted.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
|
||||
/** The most assistant messages whose tool uses are kept. The oldest are dropped. */
|
||||
const MAX_MESSAGES = 1000;
|
||||
|
||||
/**
|
||||
* The tool uses of the assistant messages that Claude Code sent, by message
|
||||
* `uuid`, to find the tool calls of a message that Claude Code takes back.
|
||||
*
|
||||
* Claude Code takes back the response of a model that refuses, and retries on
|
||||
* the fallback model. It stops the tool calls of that response that have no
|
||||
* result yet, and sends no result for them. It names the messages it took
|
||||
* back, by the `uuid` they were sent with:
|
||||
* - in `retracted_message_uuids` of the `model_refusal_fallback` notice;
|
||||
* - in `supersedes` of the first assistant message of the retry.
|
||||
*
|
||||
* Both name the same messages, so a tool call is given once. Since Claude
|
||||
* Code 2.1.323 `supersedes` also names a response whose tool call could not
|
||||
* be parsed, which Claude Code retries too. Such a response holds no tool
|
||||
* use: its stop reason is `tool_use` and it has no `tool_use` block.
|
||||
*/
|
||||
export class RetractedToolCalls {
|
||||
private readonly toolUses = new Map<string, string[]>();
|
||||
|
||||
/** Records the tool uses of a complete assistant message. */
|
||||
sent(message: SDKMessage): void {
|
||||
if (message.type !== "assistant" || typeof message.uuid !== "string") return;
|
||||
const ids = toolUseIds(message);
|
||||
if (ids.length === 0) return;
|
||||
this.toolUses.delete(message.uuid);
|
||||
this.toolUses.set(message.uuid, ids);
|
||||
if (this.toolUses.size > MAX_MESSAGES) {
|
||||
this.toolUses.delete(this.toolUses.keys().next().value!);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The tool calls of the messages that `message` takes back, each given
|
||||
* once. A tool call that `message` makes itself is not among them: the
|
||||
* message that replaces another can hold the same tool use.
|
||||
*/
|
||||
retractedBy(message: SDKMessage): string[] {
|
||||
const own = new Set(toolUseIds(message));
|
||||
const retracted: string[] = [];
|
||||
for (const uuid of retractedMessageUuids(message)) {
|
||||
for (const id of this.toolUses.get(uuid) ?? []) {
|
||||
if (!own.has(id)) retracted.push(id);
|
||||
}
|
||||
this.toolUses.delete(uuid);
|
||||
}
|
||||
return retracted;
|
||||
}
|
||||
}
|
||||
|
||||
/** The `uuid`s of the messages that `message` says Claude Code took back. */
|
||||
function retractedMessageUuids(message: SDKMessage): readonly string[] {
|
||||
if (message.type === "assistant") return message.supersedes ?? [];
|
||||
if (message.type === "system" && message.subtype === "model_refusal_fallback") {
|
||||
return message.retracted_message_uuids ?? [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/** The ids of the tool uses of an assistant message. */
|
||||
function toolUseIds(message: SDKMessage): string[] {
|
||||
if (message.type !== "assistant" || !Array.isArray(message.message.content)) return [];
|
||||
return message.message.content.flatMap((block) => (block.type === "tool_use" ? [block.id] : []));
|
||||
}
|
||||
@@ -76,6 +76,7 @@ import {
|
||||
Query,
|
||||
query,
|
||||
SDKAssistantMessageError,
|
||||
SDKRateLimitInfo,
|
||||
SDKActiveGoalMessage,
|
||||
SDKMessage,
|
||||
SDKMessageOrigin,
|
||||
@@ -330,6 +331,7 @@ import { previewPatchContent } from "../diff.js";
|
||||
import { backgroundedBashToolCall, withRunningTerminal } from "../tool-calls/background.js";
|
||||
import { ChangedMetaClient, ClientConnection } from "./client-connection.js";
|
||||
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import { RetractedToolCalls } from "../tool-calls/retracted.js";
|
||||
import { ClientCapabilities as ToolCallClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import {
|
||||
AcpToolCallRenderer,
|
||||
@@ -420,7 +422,7 @@ import {
|
||||
providerCacheKeyFor,
|
||||
readContextWindow,
|
||||
} from "../context-window.js";
|
||||
import { isLoginRequiredText, resultEnd } from "../result-end.js";
|
||||
import { isLoginRequiredText, resultEnd, turnErrorData } from "../result-end.js";
|
||||
import { STARTUP_FAILURE_RESULTS_ENV, startupFailureError } from "../startup-failure.js";
|
||||
import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js";
|
||||
import { subagentHookOptions } from "../subagent-configuration.js";
|
||||
@@ -1172,6 +1174,10 @@ export type Session = {
|
||||
* `tool_call_update` resends only the fields that changed. Created lazily
|
||||
* by {@link toolCallFieldsOf}. */
|
||||
toolCallFields?: ToolCallFieldTracker;
|
||||
/** The tool uses of the assistant messages, to end the tool calls of a
|
||||
* message that Claude Code takes back. Created lazily by
|
||||
* {@link retractedToolCallsOf}. */
|
||||
retractedToolCalls?: RetractedToolCalls;
|
||||
/** ACP session affinity for calls emitted eagerly by permission handling. */
|
||||
eagerToolCallSessions?: Map<string, string>;
|
||||
/** ExitPlanMode denial that intentionally interrupts the current Claude
|
||||
@@ -3089,6 +3095,8 @@ export class ClaudeAcpAgent {
|
||||
let lastAssistantModel: string | null = null;
|
||||
// A rate_limit_event that came before the turn had any usage to report.
|
||||
let pendingRateLimit: unknown = null;
|
||||
// The turn's latest rate_limit_event info, for its failure (see `turnErrorData`).
|
||||
let turnRateLimit: SDKRateLimitInfo | undefined;
|
||||
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
|
||||
// problem, billing), it sets a categorical `error` field on the
|
||||
// `SDKAssistantMessage` that precedes the final `result` message. We capture
|
||||
@@ -3456,6 +3464,7 @@ export class ClaudeAcpAgent {
|
||||
lastAssistantUsage = null;
|
||||
lastAssistantModel = null;
|
||||
lastAssistantError = undefined;
|
||||
turnRateLimit = undefined;
|
||||
lastAssistantWasUsageLimit = false;
|
||||
lastAssistantFailureTitle = undefined;
|
||||
lastRefusalExplanation = null;
|
||||
@@ -3939,19 +3948,23 @@ export class ClaudeAcpAgent {
|
||||
parentToolUseId !== null && runsInBackground(parentToolUseId);
|
||||
|
||||
/** Ends the open tool calls that `include` accepts, with the report of a call
|
||||
* that a cancel stopped and sent no result for. `include` gets the Agent/Task
|
||||
* call of the subagent that made the tool call, if a subagent did. */
|
||||
* that was stopped and sent no result: by a cancel, or (`stoppedBy`
|
||||
* `retracted`) by Claude Code when it took back the message that made the
|
||||
* call. `include` gets the Agent/Task call of the subagent that made the
|
||||
* tool call, if a subagent did, and the tool call. */
|
||||
const closeOpenToolCalls = async (
|
||||
include: (parentToolUseId: string | undefined) => boolean,
|
||||
include: (parentToolUseId: string | undefined, toolCallId: string) => boolean,
|
||||
stoppedBy: "cancelled" | "retracted" = "cancelled",
|
||||
): Promise<void> => {
|
||||
const renderer = new AcpToolCallRenderer(this.toolCallCapabilities);
|
||||
for (const toolCallId of [...session.emittedToolCalls]) {
|
||||
const parentToolUseId = session.toolCallParents?.get(toolCallId);
|
||||
if (!include(parentToolUseId)) continue;
|
||||
if (!include(parentToolUseId, toolCallId)) continue;
|
||||
const report = renderer.unfinished({
|
||||
toolCallId,
|
||||
ran: session.dispatchedToolCalls?.has(toolCallId) === true,
|
||||
cancelled: true,
|
||||
cancelled: stoppedBy === "cancelled",
|
||||
retracted: stoppedBy === "retracted",
|
||||
});
|
||||
// Named as the call's other updates are: the subagent runtime routes the
|
||||
// report with them, to the child session, or to none that has ended.
|
||||
@@ -3960,6 +3973,14 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
};
|
||||
|
||||
/** Ends the open tool calls of the messages that `message` says Claude
|
||||
* Code took back (see `RetractedToolCalls`). No result comes for them. */
|
||||
const closeRetractedToolCalls = async (message: SDKMessage): Promise<void> => {
|
||||
const retracted = new Set(retractedToolCallsOf(session).retractedBy(message));
|
||||
if (retracted.size === 0) return;
|
||||
await closeOpenToolCalls((_parent, toolCallId) => retracted.has(toolCallId), "retracted");
|
||||
};
|
||||
|
||||
/** Whether the closing at the end of the work a cancel stopped takes a tool
|
||||
* call that the subagent started by the Agent call `parentToolUseId` made
|
||||
* (undefined for a call of the main conversation): not one of a running
|
||||
@@ -5384,6 +5405,7 @@ export class ClaudeAcpAgent {
|
||||
// state sync the client's model picker (and the model-dependent
|
||||
// options rebuilt from it) keeps advertising a model the
|
||||
// session is no longer running (see `modelRefusalFallback`).
|
||||
await closeRetractedToolCalls(message);
|
||||
const fallback = modelRefusalFallback(message);
|
||||
const { summary: fallbackSummary, explanation } = fallback;
|
||||
// A silent model swap is a session-level advisory, not something the model said.
|
||||
@@ -6061,7 +6083,10 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
await failActiveWithSessionFailure(
|
||||
providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit),
|
||||
internalErrorForClient(errorKindData(lastAssistantError), ended.error),
|
||||
internalErrorForClient(
|
||||
turnErrorData(lastAssistantError, turnRateLimit),
|
||||
ended.error,
|
||||
),
|
||||
lastAssistantFailureTitle ?? ended.error,
|
||||
);
|
||||
break;
|
||||
@@ -6121,7 +6146,10 @@ export class ClaudeAcpAgent {
|
||||
if ("error" in ended) {
|
||||
await failActiveWithSessionFailure(
|
||||
providerFailureCategory(lastAssistantError, lastAssistantWasUsageLimit),
|
||||
internalErrorForClient(errorKindData(lastAssistantError), ended.error),
|
||||
internalErrorForClient(
|
||||
turnErrorData(lastAssistantError, turnRateLimit),
|
||||
ended.error,
|
||||
),
|
||||
lastAssistantFailureTitle ?? ended.error,
|
||||
);
|
||||
break;
|
||||
@@ -6134,7 +6162,10 @@ export class ClaudeAcpAgent {
|
||||
if ("error" in ended) {
|
||||
await failActiveWithSessionFailure(
|
||||
LIMIT_FAILURE_CATEGORY[message.subtype],
|
||||
internalErrorForClient(errorKindData(lastAssistantError), ended.error),
|
||||
internalErrorForClient(
|
||||
turnErrorData(lastAssistantError, turnRateLimit),
|
||||
ended.error,
|
||||
),
|
||||
ended.error,
|
||||
);
|
||||
break;
|
||||
@@ -6377,6 +6408,8 @@ export class ClaudeAcpAgent {
|
||||
case "user":
|
||||
case "assistant": {
|
||||
if (message.type === "assistant") {
|
||||
await closeRetractedToolCalls(message);
|
||||
retractedToolCallsOf(session).sent(message);
|
||||
recordDispatchedToolUses(session, message.message.content);
|
||||
// A tool use that did not stream is reached here first.
|
||||
const content = message.message.content;
|
||||
@@ -6921,6 +6954,7 @@ export class ClaudeAcpAgent {
|
||||
}
|
||||
case "rate_limit_event": {
|
||||
session.idleCompaction?.noteRateLimit(message.rate_limit_info);
|
||||
turnRateLimit = message.rate_limit_info;
|
||||
// With partial messages on, the SDK sends this before the turn's
|
||||
// first assistant frame, while there is no usage yet to send it with.
|
||||
if (lastAssistantTotalUsage === null) {
|
||||
@@ -10221,7 +10255,7 @@ export class ClaudeAcpAgent {
|
||||
creationOpts.resume !== undefined ? resumedModel : undefined,
|
||||
);
|
||||
const settingsAllowedModels = Array.isArray(settingsAvailableModels)
|
||||
? applyAvailableModelsAllowlist(
|
||||
? await applyAvailableModelsAllowlist(
|
||||
sdkModels,
|
||||
settingsAvailableModels,
|
||||
settingsModelOverrides,
|
||||
@@ -10943,6 +10977,13 @@ function toolCallFieldsOf(session: {
|
||||
return (session.toolCallFields ??= new ToolCallFieldTracker());
|
||||
}
|
||||
|
||||
/** The record of a session's assistant tool uses, created on first use. */
|
||||
function retractedToolCallsOf(session: {
|
||||
retractedToolCalls?: RetractedToolCalls;
|
||||
}): RetractedToolCalls {
|
||||
return (session.retractedToolCalls ??= new RetractedToolCalls());
|
||||
}
|
||||
|
||||
const MAX_RESOLVED_TOOL_NAMES = 1000;
|
||||
|
||||
/** Keeps the tool names of the tool results in `content` before the tool use cache drops them. */
|
||||
|
||||
@@ -12,11 +12,12 @@ import type * as v2 from "@agentclientprotocol/sdk/experimental/v2/unstable";
|
||||
import type {
|
||||
SDKAssistantMessage,
|
||||
SDKAssistantMessageError,
|
||||
SDKRateLimitInfo,
|
||||
SDKResultMessage,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { AUTONOMOUS_RESULT_ORIGINS } from "../result-end.js";
|
||||
import { isSyntheticLoginMessage } from "../message-filters.js";
|
||||
import { resultEnd } from "../result-end.js";
|
||||
import { resultEnd, turnErrorData } from "../result-end.js";
|
||||
import { assistantMessageText, providerFailureCategory } from "../session-failure-extension.js";
|
||||
import {
|
||||
getMatchingModelUsage,
|
||||
@@ -96,7 +97,7 @@ export function idleOutcome(
|
||||
// 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),
|
||||
: RequestError.internalError(turnErrorData(kind, report?.rateLimit), ended.error),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,6 +119,8 @@ function failed(error: RequestError): IdleOutcome {
|
||||
*/
|
||||
export class TurnReport {
|
||||
errorKind: SDKAssistantMessageError | undefined;
|
||||
/** The turn's latest `rate_limit_event` info (see `turnErrorData`). */
|
||||
rateLimit: SDKRateLimitInfo | undefined;
|
||||
/** Claude Code showed its sign-in message ("Not logged in · Please run /login"). */
|
||||
loginRequired = false;
|
||||
/** The explanation of the turn's refusal. */
|
||||
@@ -181,6 +184,7 @@ export class TurnReport {
|
||||
|
||||
reset(): void {
|
||||
this.errorKind = undefined;
|
||||
this.rateLimit = undefined;
|
||||
this.loginRequired = false;
|
||||
this.refusalExplanation = undefined;
|
||||
this.apiError = undefined;
|
||||
|
||||
@@ -141,6 +141,14 @@ export class V2Session implements RequestTarget {
|
||||
private foreground = false;
|
||||
/** The prompt whose `started` frame came last in the open turn. */
|
||||
private turnCommand: string | undefined;
|
||||
/**
|
||||
* Whether a cycle of Claude Code runs in the open turn: one started (see
|
||||
* {@link openTurn}) and its result has not come. Claude Code folds a prompt
|
||||
* that starts meanwhile into that cycle.
|
||||
*/
|
||||
private cycleOpen = false;
|
||||
/** The prompt that started the running cycle, if one did. */
|
||||
private cycleCommand: string | undefined;
|
||||
/** The state the client was told last. */
|
||||
private reportedState: ForegroundState = "idle";
|
||||
/** The open permission requests and elicitations of the session. */
|
||||
@@ -1008,6 +1016,7 @@ export class V2Session implements RequestTarget {
|
||||
return;
|
||||
case "model_refusal_fallback": {
|
||||
// Claude Code retried a refused turn on the fallback model.
|
||||
this.output.closeRetracted(message);
|
||||
const fallback = modelRefusalFallback(message);
|
||||
this.output.emitV1([
|
||||
{ sessionId: this.sessionId, update: noticeUpdate(fallback.notice) },
|
||||
@@ -1043,6 +1052,7 @@ export class V2Session implements RequestTarget {
|
||||
* output (see {@link SessionReports}).
|
||||
*/
|
||||
private report(message: SDKMessage): void {
|
||||
if (message.type === "rate_limit_event") this.turnReport.rateLimit = message.rate_limit_info;
|
||||
if (message.type === "tool_progress") {
|
||||
this.output.toolProgress(message);
|
||||
return;
|
||||
@@ -1086,9 +1096,13 @@ export class V2Session implements RequestTarget {
|
||||
);
|
||||
return;
|
||||
case "completed":
|
||||
// Comes after the turn's result. A turn that ended without one ends here.
|
||||
// Claude Code sends it after the result for the prompt that started
|
||||
// the cycle, so a cycle that ended without a result ends here. For a
|
||||
// prompt folded into the cycle it sends it before the result, which
|
||||
// then ends the turn. Nothing but Claude Code's order tells the two
|
||||
// apart.
|
||||
this.stopped.promptEnded(uuid);
|
||||
if (uuid === this.turnCommand) this.closeTurn();
|
||||
if (uuid === this.cycleCommand) this.closeTurn();
|
||||
return;
|
||||
default:
|
||||
// `queued`: the prompt waits in Claude Code's queue.
|
||||
@@ -1151,6 +1165,10 @@ export class V2Session implements RequestTarget {
|
||||
if (interrupted) this.cancelledWork = true;
|
||||
// A result with no open turn: a turn that sent nothing before it.
|
||||
this.openTurn(undefined);
|
||||
// The result ends the cycle. A prompt that starts next in the open turn,
|
||||
// such as the `/clear` of a fresh context, starts its own.
|
||||
this.cycleOpen = false;
|
||||
this.cycleCommand = undefined;
|
||||
// A cycle that Claude Code started on its own leaves its Fast mode state
|
||||
// to the next result of a prompt.
|
||||
if (!(message.origin && AUTONOMOUS_RESULT_ORIGINS.has(message.origin.kind))) {
|
||||
@@ -1307,8 +1325,8 @@ export class V2Session implements RequestTarget {
|
||||
// --- State ---------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Opens a turn, if none is open. `command` is the prompt that started it,
|
||||
* if one did.
|
||||
* Opens a turn, if none is open, and starts a cycle in it, if none runs
|
||||
* there. `command` is the prompt that started it, if one did.
|
||||
*/
|
||||
private openTurn(command: string | undefined): void {
|
||||
if (command !== undefined) {
|
||||
@@ -1317,6 +1335,10 @@ export class V2Session implements RequestTarget {
|
||||
this.localCommands.delete(command);
|
||||
if (local) this.turnLocalCommand = { command: local, abort: new AbortController() };
|
||||
}
|
||||
if (!this.cycleOpen) {
|
||||
this.cycleOpen = true;
|
||||
this.cycleCommand = command;
|
||||
}
|
||||
if (this.foreground) return;
|
||||
this.foreground = true;
|
||||
this.lastEnd = undefined;
|
||||
@@ -1354,6 +1376,8 @@ export class V2Session implements RequestTarget {
|
||||
this.output.cancelled = false;
|
||||
this.lastEnd = undefined;
|
||||
this.turnCommand = undefined;
|
||||
this.cycleOpen = false;
|
||||
this.cycleCommand = undefined;
|
||||
this.turnReport.reset();
|
||||
const ownCommandDone = this.ownCommandDone;
|
||||
this.ownCommandDone = undefined;
|
||||
|
||||
@@ -80,7 +80,7 @@ export async function sessionModels(
|
||||
// A resumed Fable 5.2 session lists Fable 5.2 as `fable`, as a new session does.
|
||||
const listed = aliasResumedModelRow(sdkModels, resumed?.model);
|
||||
const allowed = Array.isArray(availableModels)
|
||||
? applyAvailableModelsAllowlist(
|
||||
? await applyAvailableModelsAllowlist(
|
||||
listed,
|
||||
availableModels,
|
||||
modelOverrides,
|
||||
|
||||
@@ -16,6 +16,7 @@ import type { ContentBlock } from "@agentclientprotocol/sdk/unstable";
|
||||
import type {
|
||||
SDKAssistantMessage,
|
||||
SDKMemoryRecallMessage,
|
||||
SDKMessage,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKPermissionDeniedMessage,
|
||||
SDKToolProgressMessage,
|
||||
@@ -40,6 +41,7 @@ import type { AcpSessionNotification } from "../acp-subagents.js";
|
||||
import { isCompactSummaryMessage, replayCompactSummary } from "../context-compaction.js";
|
||||
import { ClientCapabilities } from "../tool-calls/client-capabilities.js";
|
||||
import { ToolCallFieldTracker } from "../tool-calls/field-tracker.js";
|
||||
import { RetractedToolCalls } from "../tool-calls/retracted.js";
|
||||
import { AcpToolCallRenderer, progressToolCallId, V2_CANCELLED } from "../tool-calls/renderer.js";
|
||||
import { changedTaskPlanEntries, unregisterHookCallback, type TaskState } from "../tools.js";
|
||||
import { recordStreamedDelta, unstreamedRemainder, type StreamedBlock } from "../streamed-text.js";
|
||||
@@ -100,6 +102,8 @@ export class SessionOutput {
|
||||
private replayCleared: Set<string> | undefined;
|
||||
/** The open tool calls whose tool use reached a complete assistant message: Claude Code runs them. */
|
||||
private readonly dispatched = new Set<string>();
|
||||
/** The tool uses of the assistant messages, for {@link closeRetracted}. */
|
||||
private readonly retracted = new RetractedToolCalls();
|
||||
/**
|
||||
* Whether the output comes from work that a `session/cancel` stopped, until
|
||||
* its idle. The session's streamed deltas still go out,
|
||||
@@ -185,6 +189,8 @@ export class SessionOutput {
|
||||
* cancel, the stopped work's message is dropped (see {@link cancelled}).
|
||||
*/
|
||||
assistant(message: SDKAssistantMessage): void {
|
||||
this.closeRetracted(message);
|
||||
this.retracted.sent(message);
|
||||
const messageId = messageIdForGrouping(message);
|
||||
for (const block of message.message.content) {
|
||||
if (block.type === "tool_use") this.dispatched.add(block.id);
|
||||
@@ -564,22 +570,44 @@ export class SessionOutput {
|
||||
* The calls in `keep` stay open, such as one whose task runs in the background.
|
||||
*/
|
||||
closeUnfinished(stopped: boolean, keep: ReadonlySet<string> = new Set()): void {
|
||||
const renderer = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES);
|
||||
for (const toolCallId of [...this.unfinished]) {
|
||||
if (keep.has(toolCallId)) continue;
|
||||
const ran = this.dispatched.has(toolCallId);
|
||||
if (ran && !stopped) continue;
|
||||
const report = renderer.unfinished({ toolCallId, ran, cancelled: stopped });
|
||||
const toolUse = this.toolUseCache[toolCallId];
|
||||
const terminalExit = toolUse && renderer.terminalExit(toolUse);
|
||||
if (terminalExit) report._meta = { ...report._meta, ...terminalExit };
|
||||
this.emittedToolCalls.delete(toolCallId);
|
||||
delete this.toolUseCache[toolCallId];
|
||||
this.toolCallFields.delete(toolCallId);
|
||||
this.emit([{ sessionId: this.sessionId, update: report }]);
|
||||
this.close({ toolCallId, ran, cancelled: stopped });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the open tool calls of the messages that `message` says Claude Code
|
||||
* took back (see `RetractedToolCalls`), `cancelled`: no result comes for
|
||||
* them, and a call that ran stays open at the end of the work otherwise.
|
||||
*/
|
||||
closeRetracted(message: SDKMessage): void {
|
||||
for (const toolCallId of this.retracted.retractedBy(message)) {
|
||||
if (this.unfinished.has(toolCallId)) {
|
||||
this.close({ toolCallId, ran: this.dispatched.has(toolCallId), retracted: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the open tool call of `call` without its result, with the report of
|
||||
* `AcpToolCallRenderer.unfinished`. A command's report also ends its
|
||||
* terminal. The call is forgotten, so a late result changes nothing.
|
||||
*/
|
||||
private close(call: Parameters<AcpToolCallRenderer["unfinished"]>[0]): void {
|
||||
const renderer = new AcpToolCallRenderer(V2_TOOL_CALL_CAPABILITIES);
|
||||
const report = renderer.unfinished(call);
|
||||
const toolUse = this.toolUseCache[call.toolCallId];
|
||||
const terminalExit = toolUse && renderer.terminalExit(toolUse);
|
||||
if (terminalExit) report._meta = { ...report._meta, ...terminalExit };
|
||||
this.emittedToolCalls.delete(call.toolCallId);
|
||||
delete this.toolUseCache[call.toolCallId];
|
||||
this.toolCallFields.delete(call.toolCallId);
|
||||
this.emit([{ sessionId: this.sessionId, update: report }]);
|
||||
}
|
||||
|
||||
/** The tool calls that the client holds without a final status. */
|
||||
private readonly unfinished = new Set<string>();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user