Compare commits

..

1 Commits

Author SHA1 Message Date
Steffen Deusch
025038e8b5 wait for the first tool_use chunk in canUseTool
Before, if a subagent requested a tool permission, we'd send the
permission request with the tool_call_update before the initial
tool_call.
2026-06-25 16:35:14 +02:00
23 changed files with 1201 additions and 11358 deletions

View File

@@ -50,31 +50,8 @@ jobs:
with:
node-version: "lts/*"
registry-url: "https://registry.npmjs.org"
- name: Update npm
run: npm install -g npm@latest
- run: npm ci
- run: npm run build
- run: npm publish
trigger-registry-update:
needs: publish-npm
runs-on: ubuntu-latest
environment: release
permissions: {}
steps:
- name: Generate token scoped to the registry repo
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
id: registry-token
with:
app-id: ${{ secrets.REGISTRY_UPDATER_APP_ID }}
private-key: ${{ secrets.REGISTRY_UPDATER_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: registry
- name: Dispatch registry version update
env:
GH_TOKEN: ${{ steps.registry-token.outputs.token }}
run: |
gh workflow run update-versions.yml \
--repo ${{ github.repository_owner }}/registry \
--ref main \
-f apply=true \
-f agents=claude-acp

View File

@@ -1,3 +1,3 @@
{
".": "0.60.0"
".": "0.51.0"
}

View File

@@ -1,134 +1,5 @@
# Changelog
## [0.60.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.59.0...v0.60.0) (2026-07-20)
### Features
* **deps:** Update to claude-agent-sdk 0.3.215 ([#890](https://github.com/agentclientprotocol/claude-agent-acp/issues/890)) ([92548f0](https://github.com/agentclientprotocol/claude-agent-acp/commit/92548f043547b0ddac95921ece020e69f7c12c5f))
* implement configurable LLM providers ([#853](https://github.com/agentclientprotocol/claude-agent-acp/issues/853)) ([82cd692](https://github.com/agentclientprotocol/claude-agent-acp/commit/82cd692e500eedec182f817b18cebb005c4b98ce))
### Bug Fixes
* parse Agent/Task trailers without regex ([#879](https://github.com/agentclientprotocol/claude-agent-acp/issues/879)) ([06c3d7b](https://github.com/agentclientprotocol/claude-agent-acp/commit/06c3d7bdbd8cc9415c8cabac060a50e0951c758b))
* remove ~15s stall on session/new and model switch by seeding the context window synchronously ([#894](https://github.com/agentclientprotocol/claude-agent-acp/issues/894)) ([ff9b96d](https://github.com/agentclientprotocol/claude-agent-acp/commit/ff9b96d462831b1c3b96722ea20215ff6e529cb1))
* Silence missing PostToolUse callbacks ([#895](https://github.com/agentclientprotocol/claude-agent-acp/issues/895)) ([1887ada](https://github.com/agentclientprotocol/claude-agent-acp/commit/1887ada215b27bb1025d9b7696a46ae7a4ac0f7a)), closes [#889](https://github.com/agentclientprotocol/claude-agent-acp/issues/889)
## [0.59.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.58.1...v0.59.0) (2026-07-13)
### Features
* **deps-dev:** bump nanoid from 3.3.15 to 3.3.16 ([#875](https://github.com/agentclientprotocol/claude-agent-acp/issues/875)) ([e67dacd](https://github.com/agentclientprotocol/claude-agent-acp/commit/e67dacdcac629b00e5bdcda94b3de57459696b1e))
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.207 ([#874](https://github.com/agentclientprotocol/claude-agent-acp/issues/874)) ([c7f5b8f](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7f5b8fe768507afa28cdffe5cc3c054c56306ad))
### Bug Fixes
* Add subagent parent tool use attribution ([#859](https://github.com/agentclientprotocol/claude-agent-acp/issues/859)) ([9cd48c5](https://github.com/agentclientprotocol/claude-agent-acp/commit/9cd48c597af68f630774ec00bc3adc85f6b0fd4b))
* forward result text when the turn emitted no assistant message ([#858](https://github.com/agentclientprotocol/claude-agent-acp/issues/858)) ([61ae860](https://github.com/agentclientprotocol/claude-agent-acp/commit/61ae8609d4343da6758c2376c6cf684c7fb0956a))
* hold a turn open while its background subagents are still live ([#870](https://github.com/agentclientprotocol/claude-agent-acp/issues/870)) ([7a70f82](https://github.com/agentclientprotocol/claude-agent-acp/commit/7a70f82739e085014cad878f08513cdef7b7fe16))
* Refine tool calls from streamed input ([#867](https://github.com/agentclientprotocol/claude-agent-acp/issues/867)) ([2c19974](https://github.com/agentclientprotocol/claude-agent-acp/commit/2c19974d5b80d3178b35e80a4bb0a1050588aab9))
* Seed context window from SDK usage report ([#868](https://github.com/agentclientprotocol/claude-agent-acp/issues/868)) ([3ba2d36](https://github.com/agentclientprotocol/claude-agent-acp/commit/3ba2d367a282fe3053a73804a416a435cb01ee08)), closes [#596](https://github.com/agentclientprotocol/claude-agent-acp/issues/596)
* Skip synthetic login messages on replay ([#869](https://github.com/agentclientprotocol/claude-agent-acp/issues/869)) ([c7dff3c](https://github.com/agentclientprotocol/claude-agent-acp/commit/c7dff3cf7eeb03cb993493efafa96344f18de81b)), closes [#863](https://github.com/agentclientprotocol/claude-agent-acp/issues/863)
## [0.58.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.58.0...v0.58.1) (2026-07-09)
### Bug Fixes
* Use valid npm version for publish ([#855](https://github.com/agentclientprotocol/claude-agent-acp/issues/855)) ([8b366d8](https://github.com/agentclientprotocol/claude-agent-acp/commit/8b366d8e9ad55e9237bf2d618cfc99b57fac570d))
## [0.58.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.57.0...v0.58.0) (2026-07-09)
### Features
* **deps:** update to @anthropic-ai/claude-agent-sdk 0.3.205 ([#854](https://github.com/agentclientprotocol/claude-agent-acp/issues/854)) ([f664ced](https://github.com/agentclientprotocol/claude-agent-acp/commit/f664ced76df00674d88aedd018a4261da3bc3f3a))
### Bug Fixes
* Preserve live model on resumed sessions ([#848](https://github.com/agentclientprotocol/claude-agent-acp/issues/848)) ([f3d8ae3](https://github.com/agentclientprotocol/claude-agent-acp/commit/f3d8ae3eb389ee9367f48c8762562a55280ade0a)), closes [#845](https://github.com/agentclientprotocol/claude-agent-acp/issues/845)
* Report usage for cancelled active turns ([#846](https://github.com/agentclientprotocol/claude-agent-acp/issues/846)) ([b03318f](https://github.com/agentclientprotocol/claude-agent-acp/commit/b03318f59c6165146c8d8e036227f35c3abd0ff4)), closes [#844](https://github.com/agentclientprotocol/claude-agent-acp/issues/844)
* tolerate missing text in streamed thinking chunks ([#852](https://github.com/agentclientprotocol/claude-agent-acp/issues/852)) ([e944ced](https://github.com/agentclientprotocol/claude-agent-acp/commit/e944ceddea79950d6bfb6339266862507c4b964f))
* Use SDK guards for elicitation validation ([#850](https://github.com/agentclientprotocol/claude-agent-acp/issues/850)) ([32b9350](https://github.com/agentclientprotocol/claude-agent-acp/commit/32b93501d7a35a3b405b5022b4caaad492eed11c))
## [0.57.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.56.0...v0.57.0) (2026-07-07)
### Features
* Update @anthropic-ai/claude-agent-sdk to 0.3.202 ([#843](https://github.com/agentclientprotocol/claude-agent-acp/issues/843)) ([1612c07](https://github.com/agentclientprotocol/claude-agent-acp/commit/1612c078953c134e12368a2d968a9bcf14d31b55))
### Bug Fixes
* don't emit empty agent_message_chunk ([#841](https://github.com/agentclientprotocol/claude-agent-acp/issues/841)) ([d74dd1d](https://github.com/agentclientprotocol/claude-agent-acp/commit/d74dd1d9d356050fc87f9fdab8599f1546b12a2c))
## [0.56.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.55.0...v0.56.0) (2026-07-06)
### Features
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.201 ([#837](https://github.com/agentclientprotocol/claude-agent-acp/issues/837)) ([ff2a8a9](https://github.com/agentclientprotocol/claude-agent-acp/commit/ff2a8a963536a4265700e4f2003a0ffff93c9087))
## [0.55.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.54.1...v0.55.0) (2026-07-02)
### Features
* Add refusal fallback consent dialog support ([#834](https://github.com/agentclientprotocol/claude-agent-acp/issues/834)) ([15dfd83](https://github.com/agentclientprotocol/claude-agent-acp/commit/15dfd83daa84ced98ecbaa7c83fd5a53124030c1))
* **deps-dev:** bump the minor group with 3 updates ([#831](https://github.com/agentclientprotocol/claude-agent-acp/issues/831)) ([8d5febf](https://github.com/agentclientprotocol/claude-agent-acp/commit/8d5febf85485c1ad19b81501fb5c6d4275448a7c))
* Update to claude-agent-sdk 0.3.198 ([#836](https://github.com/agentclientprotocol/claude-agent-acp/issues/836)) ([307ab82](https://github.com/agentclientprotocol/claude-agent-acp/commit/307ab82712213413446466d808e161073e94fc7f))
### Bug Fixes
* Handle model refusal fallback updates ([#833](https://github.com/agentclientprotocol/claude-agent-acp/issues/833)) ([648e3f6](https://github.com/agentclientprotocol/claude-agent-acp/commit/648e3f60bd7e6eadb5f86b0dc475104ab12e7b02))
* Handle SDK idle turns without results ([#835](https://github.com/agentclientprotocol/claude-agent-acp/issues/835)) ([57b00cc](https://github.com/agentclientprotocol/claude-agent-acp/commit/57b00ccac610e2eeaf0a753934a0c3b78d27ccb0))
## [0.54.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.54.0...v0.54.1) (2026-06-30)
### Bug Fixes
* apply modelOverrides when resolving availableModels allowlist ([#827](https://github.com/agentclientprotocol/claude-agent-acp/issues/827)) ([98c284b](https://github.com/agentclientprotocol/claude-agent-acp/commit/98c284bb871a96710596dfdc2b9ea25d2400f6b2))
## [0.54.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.53.0...v0.54.0) (2026-06-30)
### Features
* Add Fast mode session config support ([#828](https://github.com/agentclientprotocol/claude-agent-acp/issues/828)) ([fa949a2](https://github.com/agentclientprotocol/claude-agent-acp/commit/fa949a20db0e33f2d63aebeb37e9fea212d8ee95))
* **deps-dev:** bump prettier from 3.9.1 to 3.9.3 in the minor group ([#821](https://github.com/agentclientprotocol/claude-agent-acp/issues/821)) ([b8df8e0](https://github.com/agentclientprotocol/claude-agent-acp/commit/b8df8e0e5460fd782214f4dde488f7476c80c454))
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.197 for Sonnet 5 ([#826](https://github.com/agentclientprotocol/claude-agent-acp/issues/826)) ([ef42c46](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef42c46e5aea3bb53f433b9f9dd36a62a9f2df6a))
* **deps:** bump fast-uri from 3.1.2 to 3.1.3 ([#822](https://github.com/agentclientprotocol/claude-agent-acp/issues/822)) ([64aa130](https://github.com/agentclientprotocol/claude-agent-acp/commit/64aa130c66c299f9835193796ba372b3ed40e7c1))
## [0.53.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.52.0...v0.53.0) (2026-06-29)
### Features
* Add ACP logout support ([#816](https://github.com/agentclientprotocol/claude-agent-acp/issues/816)) ([0a0468c](https://github.com/agentclientprotocol/claude-agent-acp/commit/0a0468c72092b7ceccc551b4353dd92f9c22cbb8))
* **deps:** bump @anthropic-ai/claude-agent-sdk to 0.3.195 ([#818](https://github.com/agentclientprotocol/claude-agent-acp/issues/818)) ([5dd8746](https://github.com/agentclientprotocol/claude-agent-acp/commit/5dd87462376597eb7b695200f4c716af46e764d6))
### Bug Fixes
* Emit tool_call before permission request ([#820](https://github.com/agentclientprotocol/claude-agent-acp/issues/820)) ([c95fc88](https://github.com/agentclientprotocol/claude-agent-acp/commit/c95fc884e82e5a297d1b17b8965690260867835b))
## [0.52.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.51.0...v0.52.0) (2026-06-25)
### Features
* Add version flag handling ([#813](https://github.com/agentclientprotocol/claude-agent-acp/issues/813)) ([9616bda](https://github.com/agentclientprotocol/claude-agent-acp/commit/9616bdac47505e4a14c36d667fcffc9ae97e1f2a)), closes [#809](https://github.com/agentclientprotocol/claude-agent-acp/issues/809)
* **deps-dev:** bump expect-type from 1.3.0 to 1.4.0 in the minor group ([#814](https://github.com/agentclientprotocol/claude-agent-acp/issues/814)) ([61272ac](https://github.com/agentclientprotocol/claude-agent-acp/commit/61272acb30dcafaa2455d334b11ce2ad97339707))
* **deps:** Update @anthropic-ai/claude-agent-sdk to 0.3.191 ([#810](https://github.com/agentclientprotocol/claude-agent-acp/issues/810)) ([228f02e](https://github.com/agentclientprotocol/claude-agent-acp/commit/228f02ecfb23be16e59e121c6b42c0f2b2f40a4e))
* Push session title updates at turn end ([#812](https://github.com/agentclientprotocol/claude-agent-acp/issues/812)) ([1fe7ec0](https://github.com/agentclientprotocol/claude-agent-acp/commit/1fe7ec09a3a7bcb7501231dae4b0ffe6ef9b70a4))
## [0.51.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.50.0...v0.51.0) (2026-06-24)

649
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.60.0",
"version": "0.51.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -63,23 +63,23 @@
"node": ">=22"
},
"dependencies": {
"@agentclientprotocol/sdk": "1.2.1",
"@anthropic-ai/claude-agent-sdk": "0.3.215",
"@agentclientprotocol/sdk": "1.0.0",
"@anthropic-ai/claude-agent-sdk": "0.3.191",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.112.3",
"@anthropic-ai/sdk": "0.106.0",
"@eslint/js": "10.0.1",
"@tsconfig/node22": "22.0.5",
"@types/node": "26.1.1",
"@typescript-eslint/eslint-plugin": "8.64.0",
"@typescript-eslint/parser": "8.64.0",
"eslint": "10.7.0",
"@types/node": "26.0.1",
"@typescript-eslint/eslint-plugin": "8.62.0",
"@typescript-eslint/parser": "8.62.0",
"eslint": "10.5.0",
"eslint-config-prettier": "10.1.8",
"globals": "17.7.0",
"prettier": "3.9.5",
"prettier": "3.8.4",
"ts-node": "10.9.2",
"typescript": "6.0.3",
"vitest": "4.1.10"
"vitest": "4.1.9"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,7 @@
import { randomUUID } from "node:crypto";
import { CreateElicitationResponse } from "@agentclientprotocol/sdk";
import type {
CreateElicitationRequest,
ElicitationAcceptAction,
CreateElicitationResponse,
ElicitationPropertySchema,
ElicitationSchema,
EnumOption,
@@ -69,20 +68,6 @@ export function mcpElicitationToCreateRequest(
};
}
/**
* Content of an accepted elicitation response.
*
* Uses the SDK's validating guard rather than an `action === "accept"` check:
* the guard both narrows past the union's custom/future variant and validates
* the payload, so a malformed accept (right tag, ill-typed content) yields
* empty content — the same classification the SDK's wire validators apply.
*/
function acceptedElicitationContent(
response: CreateElicitationResponse,
): NonNullable<ElicitationAcceptAction["content"]> {
return CreateElicitationResponse.isAccept(response) ? (response.content ?? {}) : {};
}
/**
* Map an ACP elicitation response back to the MCP `ElicitResult` the SDK expects
* to hand back to the requesting server.
@@ -92,7 +77,7 @@ export function createElicitationResponseToElicitResult(
): ElicitationResult {
switch (response.action) {
case "accept":
return { action: "accept", content: acceptedElicitationContent(response) };
return { action: "accept", content: response.content ?? {} };
case "decline":
return { action: "decline" };
case "cancel":
@@ -141,13 +126,33 @@ function questionCustomFieldKey(index: number): string {
}
/**
* `_meta` key under which a bridged enum option carries its `preview`, the one
* option field ACP's `EnumOption` still has no slot for (descriptions are
* first-class as of schema 1.19). Namespaced like the agent's other `_meta`
* extensions (`_claude/...`).
* `_meta` key under which a bridged enum option carries its structured
* `description`/`preview`, since ACP's `EnumOption` has no field for either.
* Namespaced like the agent's other `_meta` extensions (`_claude/...`).
*/
const OPTION_META_KEY = "_claude/askUserQuestionOption";
/** A single option as supplied by the AskUserQuestion tool. */
type AskUserQuestionOption = AskUserQuestion["options"][number];
/**
* Build the `_meta` payload that carries an option's structured `description`
* and optional `preview` for clients that can render them as distinct fields.
* Returns `undefined` when neither is present, so we don't emit an empty `_meta`.
*/
function askUserQuestionOptionMeta(
option: AskUserQuestionOption,
): NonNullable<EnumOption["_meta"]> | undefined {
const detail: { description?: string; preview?: string } = {};
if (option.description) {
detail.description = option.description;
}
if (option.preview) {
detail.preview = option.preview;
}
return Object.keys(detail).length > 0 ? { [OPTION_META_KEY]: detail } : undefined;
}
/**
* Render the AskUserQuestion tool's questions as an ACP form elicitation.
*
@@ -155,8 +160,7 @@ const OPTION_META_KEY = "_claude/askUserQuestionOption";
* question text, so the question text appears in exactly one place per field.
* Single-select questions use a titled `oneOf` enum; multi-select questions use
* an array with a titled `anyOf` item enum. The enum `const` is always the
* option label, since that is what the tool records as the answer; an option's
* secondary text travels in the enum option's own `description` field.
* option label, since that is what the tool records as the answer.
*
* Each question is followed by its own optional free-text "custom answer" field
* (`question_<n>_custom`), mirroring the CLI's per-question "Other" box: the
@@ -176,16 +180,19 @@ export function askUserQuestionsToCreateRequest(
const options: EnumOption[] = question.options.map((option) => {
const enumOption: EnumOption = {
const: option.label,
title: option.label,
// `title` stays a flattened "label — description" string so clients that
// only read `const`/`title` still surface the description. Clients that
// understand the `_meta` below should prefer its structured fields (and
// treat `const` as the clean label) rather than parsing this title.
title: option.description ? `${option.label} — ${option.description}` : option.label,
};
if (option.description) {
enumOption.description = option.description;
}
// The SDK option's `preview` (mockups, code snippets, comparisons shown
// on focus) still has no structural slot in `EnumOption`, so forward it
// under ACP's reserved `_meta` extension point for clients that render it.
if (option.preview) {
enumOption._meta = { [OPTION_META_KEY]: { preview: option.preview } };
// The SDK option carries a `description` (secondary text) and an optional
// `preview` (mockups, code snippets, comparisons shown on focus). Neither
// has a structural slot in `EnumOption`, so forward them under ACP's
// reserved `_meta` extension point for clients that can render them.
const meta = askUserQuestionOptionMeta(option);
if (meta) {
enumOption._meta = meta;
}
return enumOption;
});
@@ -225,7 +232,8 @@ export function askUserQuestionsToCreateRequest(
/** Outcome of an AskUserQuestion elicitation, decoupled from any transport. */
export type AskUserQuestionOutcome =
{ action: "answered"; updatedInput: Record<string, unknown> } | { action: "cancel" };
| { action: "answered"; updatedInput: Record<string, unknown> }
| { action: "cancel" };
/**
* Fold an ACP elicitation response into the AskUserQuestion tool's input.
@@ -236,23 +244,22 @@ export type AskUserQuestionOutcome =
* custom-answer field (`question_<n>_custom`) takes precedence over that
* question's selection, since the user typed their own answer instead of
* picking one. Decline yields empty answers (the model is told the user skipped
* rather than the turn aborting); cancel — and any custom/future action we
* don't understand — aborts the tool call.
* rather than the turn aborting); cancel aborts the tool call.
*/
export function applyAskElicitationResponse(
response: CreateElicitationResponse,
toolInput: Record<string, unknown>,
questions: AskUserQuestion[],
): AskUserQuestionOutcome {
if (response.action === "cancel") {
return { action: "cancel" };
}
if (response.action === "decline") {
return { action: "answered", updatedInput: { ...toolInput, answers: {} } };
}
if (response.action !== "accept") {
return { action: "cancel" };
}
const content = acceptedElicitationContent(response);
const content = response.content ?? {};
// Typed against the tool's own output schema so the answer/response shapes
// stay in sync with what the built-in tool's call() expects to read back.
const answers: AskUserQuestionOutput["answers"] = {};
@@ -292,113 +299,3 @@ function normalizeElicitationSchema(
}
return { ...(schema as ElicitationSchema), type: "object" };
}
/**
* The `request_user_dialog` kind the CLI emits when a model refusal has a
* fallback available but needs user consent before retrying (e.g. Claude Fable
* declining a request with Opus available as the fallback). Declaring this
* kind in `supportedDialogKinds` is the opt-in: the CLI fails closed and never
* emits an undeclared kind — the flow degrades to the classic refusal error
* ending the turn.
*/
export const REFUSAL_FALLBACK_DIALOG_KIND = "refusal_fallback_prompt";
/**
* Payload of the `refusal_fallback_prompt` dialog. The dialog protocol
* transports payloads opaquely, so this shape is recovered from the CLI's own
* schema (v2.1.177): `originalModel`/`fallbackModel` are required strings;
* `apiRefusalCategory` (nullable), `guidanceText`, and
* `retractedMessageUuids` are optional. We ignore `retractedMessageUuids` —
* ACP has no way to retract already-streamed chunks.
*/
export type RefusalFallbackPrompt = {
originalModel: string;
fallbackModel: string;
apiRefusalCategory: string | null;
guidanceText?: string;
};
/**
* Validate the opaque dialog payload into a {@link RefusalFallbackPrompt}.
* Returns `null` when the required fields are missing or mistyped (a newer CLI
* may reshape the payload), so the caller can cancel the dialog and let the
* CLI apply its default behavior instead of rendering something misleading.
*/
export function extractRefusalFallbackPrompt(
payload: Record<string, unknown>,
): RefusalFallbackPrompt | null {
const { originalModel, fallbackModel, apiRefusalCategory, guidanceText } = payload;
if (typeof originalModel !== "string" || typeof fallbackModel !== "string") {
return null;
}
return {
originalModel,
fallbackModel,
apiRefusalCategory: typeof apiRefusalCategory === "string" ? apiRefusalCategory : null,
...(typeof guidanceText === "string" && guidanceText ? { guidanceText } : {}),
};
}
/** Form-field key carrying the user's choice in the refusal-fallback form. */
const REFUSAL_FALLBACK_CHOICE_KEY = "choice";
/** Wire values of the dialog's result enum (CLI schema). `edit_prompt` is
* deliberately not offered: in the CLI it prefills the composer with the
* refused prompt for edit-and-retry, and ACP has no composer-prefill surface
* — the user can simply edit and resend on their own. */
const RETRY_FALLBACK_RESULT = "retry_fallback";
const KEEP_REFUSAL_RESULT = "cancelled";
/**
* Render the refusal-fallback consent prompt as an ACP form elicitation: a
* single-select between retrying on the fallback model and keeping the
* refusal. The enum `const`s are the dialog's wire result values, so the
* response maps back without a translation table.
*/
export function refusalFallbackToCreateRequest(
prompt: RefusalFallbackPrompt,
sessionId: string,
): CreateElicitationRequest {
const category = prompt.apiRefusalCategory ? ` (${prompt.apiRefusalCategory})` : "";
const guidance = prompt.guidanceText ? `\n\n${prompt.guidanceText}` : "";
return {
mode: "form",
sessionId,
message:
`${prompt.originalModel} declined this request${category}. ` +
`Retry with ${prompt.fallbackModel}?` +
guidance,
requestedSchema: {
type: "object",
properties: {
[REFUSAL_FALLBACK_CHOICE_KEY]: {
type: "string",
oneOf: [
{
const: RETRY_FALLBACK_RESULT,
title: `Retry with ${prompt.fallbackModel}`,
description: `The session continues on ${prompt.fallbackModel}.`,
},
{
const: KEEP_REFUSAL_RESULT,
title: "Keep the refusal",
description: "You can send a new message.",
},
],
},
},
},
};
}
/**
* Map the elicitation response back to the dialog's result enum. Only an
* explicit accept-with-retry resolves to `retry_fallback`; decline, cancel, a
* skipped field, or an unrecognized value all keep the refusal — the dialog's
* own default — so a dismissed or half-filled form can never trigger a model
* switch the user didn't ask for.
*/
export function refusalFallbackResultFromResponse(response: CreateElicitationResponse): string {
const choice = acceptedElicitationContent(response)[REFUSAL_FALLBACK_CHOICE_KEY];
return choice === RETRY_FALLBACK_RESULT ? RETRY_FALLBACK_RESULT : KEEP_REFUSAL_RESULT;
}

View File

@@ -4,10 +4,10 @@ import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
import { claudeCliPath, runAcp } from "./acp-agent.js";
import packageJson from "../package.json" with { type: "json" };
// `--cli` is checked first so that `--version`/`-v` (and any other flags) are
// forwarded to the wrapped native CLI rather than swallowed by our own version
// handler below. Our version flag only applies when not delegating.
if (process.argv.includes("--cli")) {
if (process.argv.includes("--version") || process.argv.includes("-v")) {
console.log(packageJson.version);
process.exit(0);
} else if (process.argv.includes("--cli")) {
const { spawn } = await import("node:child_process");
const args = process.argv.slice(2).filter((arg) => arg !== "--cli");
const child = spawn(await claudeCliPath(), args, { stdio: "inherit" });
@@ -37,9 +37,6 @@ if (process.argv.includes("--cli")) {
console.error(err);
process.exit(1);
});
} else if (process.argv.includes("--version") || process.argv.includes("-v")) {
console.log(packageJson.version);
process.exit(0);
} else {
// Apply env vars from the managed-policy tier before any SDK call so the
// SDK subprocess inherits them. Going through resolveSettings (vs. a raw

View File

@@ -3,7 +3,6 @@ import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { makeMockQuery } from "./helpers.js";
const { querySpy } = vi.hoisted(() => ({
querySpy: vi.fn(),
@@ -35,7 +34,7 @@ describe("ClaudeAcpAgent settings", () => {
const setModelSpy = vi.fn();
querySpy.mockImplementation(({ options }: any) => {
capturedOptions = options;
return makeMockQuery({
return {
initializationResult: async () => ({
models: [
{
@@ -46,7 +45,8 @@ describe("ClaudeAcpAgent settings", () => {
],
}),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
return { getCapturedOptions: () => capturedOptions, setModelSpy };
}
@@ -240,11 +240,12 @@ describe("ClaudeAcpAgent settings", () => {
const setPermissionModeSpy = vi.fn();
querySpy.mockImplementation(({ options }: any) => {
capturedOptions = options;
return makeMockQuery({
return {
initializationResult: async () => ({ models }),
setModel: setModelSpy,
setPermissionMode: setPermissionModeSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
return {
getCapturedOptions: () => capturedOptions,
@@ -395,10 +396,11 @@ describe("ClaudeAcpAgent settings", () => {
} {
const setModelSpy = vi.fn();
querySpy.mockImplementation(() => {
return makeMockQuery({
return {
initializationResult: async () => ({ models }),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
return { setModelSpy };
}
@@ -809,55 +811,6 @@ describe("ClaudeAcpAgent settings", () => {
}
}
});
it("maps allowlist aliases through modelOverrides to provider IDs", async () => {
// Bedrock/Vertex deployments enforce short aliases in availableModels
// but require provider-specific IDs at the API. Without applying
// modelOverrides during allowlist resolution, the alias is surfaced as
// the model value and passed verbatim to setModel, which the provider
// API rejects with a 400 "invalid model identifier".
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
availableModels: ["claude-opus-4-6", "claude-sonnet-4-6"],
model: "claude-opus-4-6",
modelOverrides: {
"claude-opus-4-6": "global.anthropic.claude-opus-4-6-v1[1m]",
"claude-sonnet-4-6": "global.anthropic.claude-sonnet-4-6[1m]",
},
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const { setModelSpy } = mockQueryWithModels([
{ value: "default", displayName: "Default", description: "Default model" },
{ value: "opus", displayName: "Opus", description: "Claude Opus 4.6" },
{ value: "sonnet", displayName: "Sonnet", description: "Claude Sonnet 4.6" },
]);
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
const response = await (agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
});
const modelOption = response.configOptions.find((o: any) => o.id === "model");
expect(modelOption.options.map((o: any) => o.value)).toEqual([
"default",
"global.anthropic.claude-opus-4-6-v1[1m]",
"global.anthropic.claude-sonnet-4-6[1m]",
]);
// The allowlist synthesized the overridden ID (the SDK only surfaced the
// `opus` alias), so the adapter syncs the provider ID via setModel — never
// the raw alias that Bedrock would reject.
expect(setModelSpy).toHaveBeenCalledWith("global.anthropic.claude-opus-4-6-v1[1m]");
expect(modelOption.currentValue).toBe("global.anthropic.claude-opus-4-6-v1[1m]");
});
});
it("resolves model aliases like opus[1m] to the correct model", async () => {
@@ -873,7 +826,7 @@ describe("ClaudeAcpAgent settings", () => {
const setModelSpy = vi.fn();
querySpy.mockImplementation(({ options: _options }: any) => {
return makeMockQuery({
return {
initializationResult: async () => ({
models: [
{
@@ -889,7 +842,8 @@ describe("ClaudeAcpAgent settings", () => {
],
}),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
const { ClaudeAcpAgent } = await import("../acp-agent.js");
@@ -920,7 +874,7 @@ describe("ClaudeAcpAgent settings", () => {
const setModelSpy = vi.fn();
querySpy.mockImplementation(() => {
return makeMockQuery({
return {
initializationResult: async () => ({
models: [
{ value: "default", displayName: "Default", description: "" },
@@ -929,7 +883,8 @@ describe("ClaudeAcpAgent settings", () => {
],
}),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
try {
@@ -973,7 +928,7 @@ describe("ClaudeAcpAgent settings", () => {
const setModelSpy = vi.fn();
querySpy.mockImplementation(() => {
return makeMockQuery({
return {
initializationResult: async () => ({
models: [
{ value: "default", displayName: "Default", description: "" },
@@ -981,7 +936,8 @@ describe("ClaudeAcpAgent settings", () => {
],
}),
setModel: setModelSpy,
}) as any;
supportedCommands: async () => [],
} as any;
});
const { ClaudeAcpAgent } = await import("../acp-agent.js");

File diff suppressed because it is too large Load Diff

View File

@@ -7,30 +7,30 @@ import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-age
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let capturedOptions: Options | undefined;
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
const { makeMockQuery } = await import("./helpers.js");
return {
...actual,
query: ({ options }: { options: Options }) => {
capturedOptions = options;
return makeMockQuery({
initializationResult: async () => ({
models: [
{
value: "claude-sonnet-4-6",
displayName: "Claude Sonnet",
description: "Fast",
supportsAutoMode: true,
},
],
}),
});
},
};
});
)),
query: ({ options }: { options: Options }) => {
capturedOptions = options;
return {
initializationResult: async () => ({
models: [
{
value: "claude-sonnet-4-6",
displayName: "Claude Sonnet",
description: "Fast",
supportsAutoMode: true,
},
],
}),
setModel: async () => {},
setPermissionMode: async () => {},
supportedCommands: async () => [],
[Symbol.asyncIterator]: async function* () {},
};
},
}));
vi.mock("../tools.js", async () => ({
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
registerHookCallback: vi.fn(),

View File

@@ -1,8 +1,18 @@
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { makeMockQuery } from "./helpers.js";
const mockQuery = vi.hoisted(() => vi.fn());
const mockQuery = vi.hoisted(() =>
vi.fn(() => ({
initializationResult: vi.fn().mockResolvedValue({
models: [
{ value: "id", displayName: "name", description: "description", supportsAutoMode: true },
],
}),
setModel: vi.fn(),
setPermissionMode: vi.fn(),
supportedCommands: vi.fn().mockResolvedValue([]),
})),
);
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
@@ -14,22 +24,6 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
describe("authorization", () => {
beforeEach(() => {
vi.useFakeTimers();
// Set here (not in vi.fn(impl) at hoist time) so the helper import is
// available; afterEach's resetAllMocks clears it, beforeEach re-sets it.
mockQuery.mockImplementation(() =>
makeMockQuery({
initializationResult: async () => ({
models: [
{
value: "id",
displayName: "name",
description: "description",
supportsAutoMode: true,
},
],
}),
}),
);
});
afterEach(() => {

View File

@@ -7,17 +7,15 @@ import * as os from "node:os";
import * as path from "node:path";
let capturedOptions: Options | undefined;
let contextUsageResult: (() => Promise<{ rawMaxTokens: number; model?: string }>) | undefined;
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
);
const { makeMockQuery, DEFAULT_CONTEXT_USAGE } = await import("./helpers.js");
return {
...actual,
query: (args: { prompt: unknown; options: Options }) => {
capturedOptions = args.options;
return makeMockQuery({
return {
initializationResult: async () => ({
models: [
{
@@ -28,9 +26,11 @@ vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
},
],
}),
getContextUsage: () =>
contextUsageResult ? contextUsageResult() : Promise.resolve(DEFAULT_CONTEXT_USAGE),
});
setModel: async () => {},
setPermissionMode: async () => {},
supportedCommands: async () => [],
[Symbol.asyncIterator]: async function* () {},
};
},
};
});
@@ -58,7 +58,6 @@ describe("createSession options merging", () => {
beforeEach(async () => {
capturedOptions = undefined;
contextUsageResult = undefined;
vi.resetModules();
const acpAgent = await import("../acp-agent.js");
@@ -515,13 +514,8 @@ describe("createSession options merging", () => {
it("ignores a non-numeric value", async () => {
process.env.MAX_THINKING_TOKENS = "lots";
// The bad value is deliberate — capture the agent's warning instead of
// letting it hit the console.
const errorSpy = vi.fn();
(agent as any).logger = { log: () => {}, error: errorSpy };
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.thinking).toBeUndefined();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("MAX_THINKING_TOKENS"));
});
it("lets a user-provided thinking option override the env default", async () => {
@@ -619,240 +613,4 @@ describe("createSession options merging", () => {
expect(capturedOptions!.disallowedTools).not.toContain("AskUserQuestion");
});
});
describe("context window seeding", () => {
function sessionFor(sessionId: string) {
return (agent as unknown as { sessions: Record<string, any> }).sessions[sessionId];
}
it("does not call getContextUsage during session creation", async () => {
// getContextUsage stalls until the session's first prompt turn has run
// (it is not serviced pre-turn), so session/new must never call it —
// awaiting it inline is what regressed session/new latency in 0.59.0.
const ctxSpy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
contextUsageResult = ctxSpy;
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(ctxSpy).not.toHaveBeenCalled();
});
it("seeds contextWindowSize from text inference, falling back to the default when it misses", async () => {
// The mock model ("claude-sonnet-4-6" / "Claude Sonnet" / "Fast") carries
// no "1m" token anywhere, so inference misses and the window falls back to
// the default; the authoritative value arrives later via result.modelUsage.
contextUsageResult = async () => ({ rawMaxTokens: 967000 });
const response = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(sessionFor(response.sessionId).contextWindowSize).toBe(200000);
expect(sessionFor(response.sessionId).contextWindowAuthoritative).toBe(false);
});
it("session/load seeds the window from the resumed session's getContextUsage report", async () => {
// Resumed sessions get getContextUsage serviced pre-turn (issue #845 uses
// it to restore the live model), and the same response carries the
// authoritative window (`rawMaxTokens`). After a process restart the
// module cache is empty and text inference misses natively-1M aliases, so
// discarding this in-hand value would replay the issue-#596 flicker on
// every reload — the flagship scenario. 888_000 can only come from the
// report: inference on the mock model yields null → 200_000 default.
contextUsageResult = async () => ({ rawMaxTokens: 888_000, model: "claude-sonnet-4-6" });
await (
agent as unknown as {
createSession: (params: object, opts: { resume?: string }) => Promise<unknown>;
}
).createSession({ cwd: process.cwd(), mcpServers: [] }, { resume: "resumed-window-probe" });
const session = sessionFor("resumed-window-probe");
expect(session.contextWindowSize).toBe(888_000);
expect(session.contextWindowAuthoritative).toBe(true);
});
it("scopes providerCacheKey by per-session env routing", async () => {
// The context-window cache key must distinguish backends exactly as the
// CLI will see them: a session routed to a proxy via _meta env shares a
// model id spelling with default-routed sessions but not a context lane,
// so it must land in its own cache bucket (and two default-routed
// sessions must share one).
const r1 = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const r2 = await agent.newSession({
cwd: process.cwd(),
mcpServers: [],
_meta: {
claudeCode: {
options: { env: { ANTHROPIC_BASE_URL: "https://window-probe-proxy.example" } },
},
},
});
const r3 = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(sessionFor(r2.sessionId).providerCacheKey).not.toBe(
sessionFor(r1.sessionId).providerCacheKey,
);
expect(sessionFor(r3.sessionId).providerCacheKey).toBe(
sessionFor(r1.sessionId).providerCacheKey,
);
});
it("scopes providerCacheKey by provider headers: same endpoint, different headers → different buckets", async () => {
// Two providers/set configs sharing apiType+baseUrl but differing in
// headers (e.g. an `anthropic-beta: context-1m-…` routing header) can
// serve different context lanes for the same model id, so they must not
// share a window-cache bucket.
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://gw.example",
headers: {},
});
const plain = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://gw.example",
headers: { "anthropic-beta": "context-1m-2025-08-07" },
});
const beta = await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(sessionFor(beta.sessionId).providerCacheKey).not.toBe(
sessionFor(plain.sessionId).providerCacheKey,
);
});
});
describe("refusal fallback dialog", () => {
it("declares the dialog kind and callback when the client supports form elicitation", async () => {
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { elicitation: { form: {} } },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(capturedOptions!.supportedDialogKinds).toEqual(["refusal_fallback_prompt"]);
expect(typeof capturedOptions!.onUserDialog).toBe("function");
});
it("omits the dialog wiring without form elicitation support (url-only included)", async () => {
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { elicitation: { url: {} } },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
// The CLI fails closed on the undeclared kind, so the flow degrades to
// the classic refusal error instead of parking a dialog we can't render.
expect(capturedOptions!.supportedDialogKinds).toBeUndefined();
expect(capturedOptions!.onUserDialog).toBeUndefined();
});
/** Wire the session up with form support and return the captured dialog
* callback plus a mock the test can point the elicitation response at. */
async function setupDialog() {
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { elicitation: { form: {} } },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
const createElicitation = vi.fn();
(
agent as unknown as { client: { unstable_createElicitation: unknown } }
).client.unstable_createElicitation = createElicitation;
return { onUserDialog: capturedOptions!.onUserDialog!, createElicitation };
}
const signal = () => ({ signal: new AbortController().signal });
it("renders the prompt as a form elicitation and maps the retry choice", async () => {
const { onUserDialog, createElicitation } = await setupDialog();
createElicitation.mockResolvedValue({
action: "accept",
content: { choice: "retry_fallback" },
});
const result = await onUserDialog(
{
dialogKind: "refusal_fallback_prompt",
payload: {
originalModel: "claude-fable-5",
fallbackModel: "claude-opus-4-8",
apiRefusalCategory: "cyber",
},
},
signal(),
);
expect(result).toEqual({ behavior: "completed", result: "retry_fallback" });
const request = createElicitation.mock.calls[0][0];
expect(request.mode).toBe("form");
expect(request.message).toContain("claude-fable-5");
expect(request.message).toContain("claude-opus-4-8");
});
it("completes with cancelled when the user keeps the refusal", async () => {
const { onUserDialog, createElicitation } = await setupDialog();
createElicitation.mockResolvedValue({ action: "cancel" });
const result = await onUserDialog(
{
dialogKind: "refusal_fallback_prompt",
payload: { originalModel: "a", fallbackModel: "b" },
},
signal(),
);
expect(result).toEqual({ behavior: "completed", result: "cancelled" });
});
it("cancels unrecognized dialog kinds without presenting anything", async () => {
const { onUserDialog, createElicitation } = await setupDialog();
const result = await onUserDialog(
{ dialogKind: "some_future_dialog", payload: {} },
signal(),
);
expect(result).toEqual({ behavior: "cancelled" });
expect(createElicitation).not.toHaveBeenCalled();
});
it("cancels on a malformed payload without presenting anything", async () => {
const { onUserDialog, createElicitation } = await setupDialog();
// The malformed payload is deliberate — capture the agent's warning
// instead of letting it hit the console.
const errorSpy = vi.fn();
(agent as any).logger = { log: () => {}, error: errorSpy };
const result = await onUserDialog(
{ dialogKind: "refusal_fallback_prompt", payload: { fallbackModel: 42 } },
signal(),
);
expect(result).toEqual({ behavior: "cancelled" });
expect(createElicitation).not.toHaveBeenCalled();
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("unexpected shape"));
});
it("cancels when the elicitation request fails", async () => {
const { onUserDialog, createElicitation } = await setupDialog();
createElicitation.mockRejectedValue(new Error("client exploded"));
// The client failure is deliberate — capture the agent's warning
// instead of letting it hit the console.
const errorSpy = vi.fn();
(agent as any).logger = { log: () => {}, error: errorSpy };
const result = await onUserDialog(
{
dialogKind: "refusal_fallback_prompt",
payload: { originalModel: "a", fallbackModel: "b" },
},
signal(),
);
expect(result).toEqual({ behavior: "cancelled" });
expect(errorSpy).toHaveBeenCalledWith(expect.stringContaining("client exploded"));
});
});
});

View File

@@ -11,10 +11,7 @@ import {
AskUserQuestion,
createElicitationResponseToElicitResult,
extractAskUserQuestions,
extractRefusalFallbackPrompt,
mcpElicitationToCreateRequest,
refusalFallbackResultFromResponse,
refusalFallbackToCreateRequest,
} from "../elicitation.js";
const SESSION_ID = "session-1";
@@ -22,7 +19,7 @@ const SESSION_ID = "session-1";
/**
* Build a question matching the SDK's (strict) AskUserQuestion schema while
* keeping test sites terse. Option descriptions default to "" (the tool always
* provides one); pass a description to exercise the enum option's description.
* provides one); pass a description to exercise the "label — description" title.
*/
function mkQuestion(
question: string,
@@ -127,14 +124,6 @@ describe("createElicitationResponseToElicitResult", () => {
createElicitationResponseToElicitResult({ action: "cancel" } as CreateElicitationResponse),
).toEqual({ action: "cancel" });
});
it("maps a custom/future action to cancel", () => {
expect(
createElicitationResponseToElicitResult({
action: "_zed/snooze",
} as CreateElicitationResponse),
).toEqual({ action: "cancel" });
});
});
describe("extractAskUserQuestions", () => {
@@ -198,10 +187,14 @@ describe("askUserQuestionsToCreateRequest", () => {
title: "Library",
description: undefined,
oneOf: [
// The description travels in the enum option's first-class field; the
// title stays the clean label (no "label — description" flattening).
{ const: "date-fns", title: "date-fns", description: "Lightweight" },
// No description → the field is omitted entirely.
{
const: "date-fns",
title: "date-fns — Lightweight",
// Structured description forwarded under `_meta` so clients can render
// it as secondary text instead of parsing it out of the title.
_meta: { "_claude/askUserQuestionOption": { description: "Lightweight" } },
},
// No description → no `_meta` emitted.
{ const: "luxon", title: "luxon" },
],
});
@@ -209,7 +202,7 @@ describe("askUserQuestionsToCreateRequest", () => {
expect(schema.properties?.["Which library?"]).toBeUndefined();
});
it("carries descriptions structurally and forwards previews under _meta", () => {
it("forwards option description and preview under _meta for rich clients", () => {
const questions = [
mkQuestion("Which layout?", [
{
@@ -230,22 +223,24 @@ describe("askUserQuestionsToCreateRequest", () => {
).requestedSchema;
const oneOf = (schema.properties?.["question_0"] as { oneOf: EnumOption[] }).oneOf;
// The description uses the enum option's own field; only the preview —
// which still has no structural slot — rides under `_meta`.
// Both description and preview travel structurally; the title still flattens
// the description for clients that only read const/title.
expect(oneOf[0]).toEqual({
const: "Grid",
title: "Grid",
description: "Cards in a responsive grid",
title: "Grid — Cards in a responsive grid",
_meta: {
"_claude/askUserQuestionOption": {
description: "Cards in a responsive grid",
preview: "```\n[ ] [ ] [ ]\n[ ] [ ] [ ]\n```",
},
},
});
// Description only → no _meta needed.
expect(oneOf[1]).toEqual({ const: "List", title: "List", description: "Stacked rows" });
// Neither → just the label.
expect(oneOf[2]).toEqual({ const: "Plain", title: "Plain" });
// Description only → preview omitted from _meta.
expect(oneOf[1]._meta).toEqual({
"_claude/askUserQuestionOption": { description: "Stacked rows" },
});
// Neither → no _meta at all.
expect(oneOf[2]._meta).toBeUndefined();
});
it("includes a per-question optional free-text custom-answer field", () => {
@@ -381,13 +376,6 @@ describe("applyAskElicitationResponse", () => {
});
});
it("returns cancel for a custom/future action it does not understand", () => {
const response = { action: "_zed/snooze" } as CreateElicitationResponse;
expect(applyAskElicitationResponse(response, toolInput, questions)).toEqual({
action: "cancel",
});
});
it("omits answers for questions the user left unanswered", () => {
const response = {
action: "accept",
@@ -401,104 +389,3 @@ describe("applyAskElicitationResponse", () => {
);
});
});
describe("extractRefusalFallbackPrompt", () => {
it("extracts required and optional fields", () => {
expect(
extractRefusalFallbackPrompt({
originalModel: "claude-fable-5",
fallbackModel: "claude-opus-4-8",
apiRefusalCategory: "cyber",
guidanceText: "You can retry with the fallback model.",
retractedMessageUuids: ["u1"],
}),
).toEqual({
originalModel: "claude-fable-5",
fallbackModel: "claude-opus-4-8",
apiRefusalCategory: "cyber",
guidanceText: "You can retry with the fallback model.",
});
});
it("returns null when a required model field is missing or mistyped", () => {
expect(extractRefusalFallbackPrompt({ fallbackModel: "claude-opus-4-8" })).toBeNull();
expect(
extractRefusalFallbackPrompt({ originalModel: 5, fallbackModel: "claude-opus-4-8" }),
).toBeNull();
});
it("normalizes a non-string category to null and drops empty guidance", () => {
expect(
extractRefusalFallbackPrompt({
originalModel: "a",
fallbackModel: "b",
apiRefusalCategory: null,
guidanceText: "",
}),
).toEqual({ originalModel: "a", fallbackModel: "b", apiRefusalCategory: null });
});
});
describe("refusalFallbackToCreateRequest", () => {
const prompt = {
originalModel: "claude-fable-5",
fallbackModel: "claude-opus-4-8",
apiRefusalCategory: "cyber" as string | null,
};
it("builds a single-choice form whose enum consts are the dialog's wire results", () => {
const request = refusalFallbackToCreateRequest(prompt, SESSION_ID) as Extract<
CreateElicitationRequest,
{ mode: "form" }
>;
expect(request.mode).toBe("form");
expect(request).toMatchObject({ sessionId: SESSION_ID });
expect(request.message).toContain("claude-fable-5");
expect(request.message).toContain("(cyber)");
expect(request.message).toContain("claude-opus-4-8");
const choice = request.requestedSchema.properties?.choice as {
type: string;
oneOf: Array<{ const: string; title: string; description?: string }>;
};
expect(choice.type).toBe("string");
expect(choice.oneOf.map((o) => o.const)).toEqual(["retry_fallback", "cancelled"]);
expect(choice.oneOf[0].title).toContain("claude-opus-4-8");
// Each option explains its consequence in the description field.
expect(choice.oneOf[0].description).toContain("claude-opus-4-8");
expect(choice.oneOf[1].description).toBeTruthy();
});
it("omits the category marker and appends guidance when provided", () => {
const request = refusalFallbackToCreateRequest(
{ ...prompt, apiRefusalCategory: null, guidanceText: "Consider rephrasing." },
SESSION_ID,
);
expect(request.message).not.toContain("(");
expect(request.message).toContain("Consider rephrasing.");
});
});
describe("refusalFallbackResultFromResponse", () => {
it("maps an accepted retry choice to retry_fallback", () => {
const response = {
action: "accept",
content: { choice: "retry_fallback" },
} as CreateElicitationResponse;
expect(refusalFallbackResultFromResponse(response)).toBe("retry_fallback");
});
it("keeps the refusal for every other outcome", () => {
const cases = [
{ action: "accept", content: { choice: "cancelled" } },
{ action: "accept", content: {} },
{ action: "accept", content: { choice: "something-new" } },
{ action: "accept" },
{ action: "decline" },
{ action: "cancel" },
] as CreateElicitationResponse[];
for (const response of cases) {
expect(refusalFallbackResultFromResponse(response)).toBe("cancelled");
}
});
});

View File

@@ -1,341 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import type { ClientCapabilities, SessionNotification } from "@agentclientprotocol/sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import {
buildConfigOptions,
clientSupportsBooleanConfigOptions,
createFastModeConfigOption,
fastModeStateEnabled,
resolveFastModeEnabled,
FAST_MODE_CONFIG_ID,
FAST_MODE_ON,
FAST_MODE_OFF,
ClaudeAcpAgent,
type AcpClient,
} from "../acp-agent.js";
const MODES = {
currentModeId: "default",
availableModes: [{ id: "default", name: "Default", description: "Standard behavior" }],
};
const MODELS = {
currentModelId: "claude-opus-4-8",
availableModels: [
{ modelId: "claude-opus-4-8", name: "Claude Opus", description: "Most capable" },
],
};
const MODEL_INFOS: ModelInfo[] = [
{ value: "claude-opus-4-8", displayName: "Claude Opus", description: "Most capable" },
];
describe("createFastModeConfigOption", () => {
it("produces a native boolean toggle when the client opted in", () => {
expect(createFastModeConfigOption(true, true)).toEqual({
id: FAST_MODE_CONFIG_ID,
name: "Fast mode",
description: expect.any(String),
category: "model_config",
type: "boolean",
currentValue: true,
});
});
it("falls back to an on/off select when the client did not opt in", () => {
const option = createFastModeConfigOption(false, false);
expect(option).toMatchObject({
id: FAST_MODE_CONFIG_ID,
type: "select",
category: "model_config",
currentValue: FAST_MODE_OFF,
options: [
{ value: FAST_MODE_ON, name: "On" },
{ value: FAST_MODE_OFF, name: "Off" },
],
});
expect(option).not.toHaveProperty("currentValue", true);
});
});
describe("clientSupportsBooleanConfigOptions", () => {
it("is true only when session.configOptions.boolean is present", () => {
expect(
clientSupportsBooleanConfigOptions({ session: { configOptions: { boolean: {} } } }),
).toBe(true);
});
it("is false when the capability is omitted or null at any level", () => {
expect(clientSupportsBooleanConfigOptions(undefined)).toBe(false);
expect(clientSupportsBooleanConfigOptions(null)).toBe(false);
expect(clientSupportsBooleanConfigOptions({})).toBe(false);
expect(clientSupportsBooleanConfigOptions({ session: { configOptions: {} } })).toBe(false);
expect(
clientSupportsBooleanConfigOptions({ session: { configOptions: { boolean: null } } }),
).toBe(false);
});
});
describe("resolveFastModeEnabled", () => {
const base = { sessionId: "s", configId: FAST_MODE_CONFIG_ID };
it("accepts native boolean values", () => {
expect(resolveFastModeEnabled({ ...base, type: "boolean", value: true })).toBe(true);
expect(resolveFastModeEnabled({ ...base, type: "boolean", value: false })).toBe(false);
});
it("accepts the on/off select fallback", () => {
expect(resolveFastModeEnabled({ ...base, value: FAST_MODE_ON })).toBe(true);
expect(resolveFastModeEnabled({ ...base, value: FAST_MODE_OFF })).toBe(false);
});
it("rejects any other value", () => {
expect(() => resolveFastModeEnabled({ ...base, value: "maybe" })).toThrow(
/Invalid value for config option fast/,
);
});
});
describe("fastModeStateEnabled", () => {
it("treats cooldown as on (the user's intent persists through rate-limit cooldown)", () => {
expect(fastModeStateEnabled("on")).toBe(true);
expect(fastModeStateEnabled("cooldown")).toBe(true);
expect(fastModeStateEnabled("off")).toBe(false);
});
});
describe("buildConfigOptions Fast mode", () => {
it("omits the Fast mode option when the model does not support it", () => {
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
supported: false,
enabled: false,
useBooleanOption: true,
});
expect(options.find((o) => o.id === FAST_MODE_CONFIG_ID)).toBeUndefined();
});
it("omits the Fast mode option when no fast mode state is provided", () => {
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default");
expect(options.find((o) => o.id === FAST_MODE_CONFIG_ID)).toBeUndefined();
});
it("surfaces a boolean toggle when supported and the client opted in", () => {
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
supported: true,
enabled: true,
useBooleanOption: true,
});
expect(options).toContainEqual(createFastModeConfigOption(true, true));
});
it("surfaces a select fallback when supported but the client did not opt in", () => {
const options = buildConfigOptions(MODES, MODELS, MODEL_INFOS, undefined, [], "default", {
supported: true,
enabled: false,
useBooleanOption: false,
});
expect(options).toContainEqual(createFastModeConfigOption(false, false));
});
});
describe("setSessionConfigOption Fast mode toggle", () => {
const SESSION_ID = "fast-session";
function setup(opts: { useBooleanOption: boolean }) {
const sessionUpdates: SessionNotification[] = [];
const client = {
sessionUpdate: async (n: SessionNotification) => {
sessionUpdates.push(n);
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(client);
const clientCapabilities: ClientCapabilities = opts.useBooleanOption
? { session: { configOptions: { boolean: {} } } }
: {};
(agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities =
clientCapabilities;
const applyFlagSettings = vi.fn();
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
query: { applyFlagSettings },
fastModeEnabled: false,
configOptions: [createFastModeConfigOption(false, opts.useBooleanOption)],
};
return { agent, applyFlagSettings, sessionUpdates };
}
it("toggles Fast mode on/off through a boolean value", async () => {
const { agent, applyFlagSettings } = setup({ useBooleanOption: true });
const onResponse = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: FAST_MODE_CONFIG_ID,
type: "boolean",
value: true,
});
expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: true });
expect(onResponse.configOptions).toContainEqual(createFastModeConfigOption(true, true));
expect(
(agent as unknown as { sessions: Record<string, { fastModeEnabled: boolean }> }).sessions[
SESSION_ID
].fastModeEnabled,
).toBe(true);
const offResponse = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: FAST_MODE_CONFIG_ID,
type: "boolean",
value: false,
});
expect(applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
expect(offResponse.configOptions).toContainEqual(createFastModeConfigOption(false, true));
});
it("toggles Fast mode through the on/off select fallback", async () => {
const { agent, applyFlagSettings } = setup({ useBooleanOption: false });
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: FAST_MODE_CONFIG_ID,
value: FAST_MODE_ON,
});
expect(applyFlagSettings).toHaveBeenCalledWith({ fastMode: true });
expect(response.configOptions).toContainEqual(createFastModeConfigOption(true, false));
});
it("does not change session state when the SDK rejects the flag", async () => {
const { agent, applyFlagSettings } = setup({ useBooleanOption: true });
applyFlagSettings.mockRejectedValueOnce(new Error("nope"));
await expect(
agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: FAST_MODE_CONFIG_ID,
type: "boolean",
value: true,
}),
).rejects.toThrow("nope");
const session = (agent as unknown as { sessions: Record<string, { fastModeEnabled: boolean }> })
.sessions[SESSION_ID];
expect(session.fastModeEnabled).toBe(false);
});
});
describe("syncFastModeState (SDK-driven state changes)", () => {
const SESSION_ID = "fast-session";
function setup(opts: { fastModeEnabled: boolean; withOption: boolean }) {
const sessionUpdates: SessionNotification[] = [];
const client = {
sessionUpdate: async (n: SessionNotification) => {
sessionUpdates.push(n);
},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AcpClient;
const agent = new ClaudeAcpAgent(client);
(agent as unknown as { clientCapabilities: ClientCapabilities }).clientCapabilities = {
session: { configOptions: { boolean: {} } },
};
const session = {
query: {},
fastModeEnabled: opts.fastModeEnabled,
configOptions: opts.withOption
? [createFastModeConfigOption(opts.fastModeEnabled, true)]
: [],
};
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = session;
const sync = (
agent as unknown as {
syncFastModeState: (
sessionId: string,
session: unknown,
state: string | undefined,
) => Promise<void>;
}
).syncFastModeState.bind(agent);
return { sync, session, sessionUpdates };
}
it("emits a config_option_update when the SDK reports a new state", async () => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
await sync(SESSION_ID, session, "on");
expect(session.fastModeEnabled).toBe(true);
expect(session.configOptions).toContainEqual(createFastModeConfigOption(true, true));
expect(sessionUpdates).toHaveLength(1);
expect(sessionUpdates[0].update).toMatchObject({
sessionUpdate: "config_option_update",
});
const updated = (
sessionUpdates[0].update as { configOptions: ReturnType<typeof createFastModeConfigOption>[] }
).configOptions;
expect(updated).toContainEqual(createFastModeConfigOption(true, true));
});
it("leaves the toggle on and quiet during a rate-limit cooldown", async () => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
// cooldown is a transient suspension of an already-enabled fast mode.
await sync(SESSION_ID, session, "cooldown");
expect(session.fastModeEnabled).toBe(true);
expect(sessionUpdates).toHaveLength(0);
});
it("never lets a stray cooldown spuriously enable a toggle the user has off", async () => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
await sync(SESSION_ID, session, "cooldown");
expect(session.fastModeEnabled).toBe(false);
expect(sessionUpdates).toHaveLength(0);
});
it("clears the toggle when the SDK reports off", async () => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: true, withOption: true });
await sync(SESSION_ID, session, "off");
expect(session.fastModeEnabled).toBe(false);
expect(session.configOptions).toContainEqual(createFastModeConfigOption(false, true));
expect(sessionUpdates).toHaveLength(1);
});
it("is a no-op when the reported state is undefined or unchanged", async () => {
const { sync, session, sessionUpdates } = setup({ fastModeEnabled: false, withOption: true });
await sync(SESSION_ID, session, undefined);
await sync(SESSION_ID, session, "off");
expect(sessionUpdates).toHaveLength(0);
});
it("preserves the retained setting (no clobber) when the model has no Fast mode option", async () => {
// Model without fast support: the SDK reports a capability-driven state, not
// the user's intent. We must leave session.fastModeEnabled untouched so it's
// correct when a supporting model is reselected — reconciling here was the
// original intent-clobber bug.
const enabledCase = setup({ fastModeEnabled: true, withOption: false });
await enabledCase.sync(SESSION_ID, enabledCase.session, "off");
expect(enabledCase.session.fastModeEnabled).toBe(true);
expect(enabledCase.sessionUpdates).toHaveLength(0);
const disabledCase = setup({ fastModeEnabled: false, withOption: false });
await disabledCase.sync(SESSION_ID, disabledCase.session, "on");
expect(disabledCase.session.fastModeEnabled).toBe(false);
expect(disabledCase.sessionUpdates).toHaveLength(0);
});
});

View File

@@ -1,34 +0,0 @@
/**
* Shared test doubles. Deliberately vitest-free so `vi.mock` async factories
* can `await import("./helpers.js")` without ordering hazards; tests supply
* their own vi.fn spies via `overrides`.
*/
/** The context-usage report the base mock query returns. `rawMaxTokens`
* matches the agent's DEFAULT_CONTEXT_WINDOW so window-related assertions
* don't shift in tests that don't care about context usage. */
export const DEFAULT_CONTEXT_USAGE = { totalTokens: 0, rawMaxTokens: 200000 };
/**
* Base stub for the SDK `query()` return object, covering the surface
* ClaudeAcpAgent touches unconditionally at session creation. Tests pass
* `overrides` for the parts they assert on (spies, custom models, rejecting
* getContextUsage, …).
*
* When the agent starts calling a new SDK method on every session, add it
* here once — the getContextUsage adoption required hand-editing ~10 inline
* mocks across five files, and any missed copy didn't fail: it silently
* rerouted that test through the error-fallback branch and re-polluted test
* output.
*/
export function makeMockQuery(overrides: Record<string, unknown> = {}) {
return {
initializationResult: async () => ({ models: [] }),
setModel: async () => {},
setPermissionMode: async () => {},
supportedCommands: async () => [],
getContextUsage: async () => DEFAULT_CONTEXT_USAGE,
[Symbol.asyncIterator]: async function* () {},
...overrides,
};
}

View File

@@ -1,272 +0,0 @@
import { describe, it, expect } from "vitest";
import { randomUUID } from "crypto";
import { query } from "@anthropic-ai/claude-agent-sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import {
resolveModelPreference,
applyAvailableModelsAllowlist,
matchResumedModel,
} from "../acp-agent.js";
// Mirrors a real `supportedModels()` response: alias rows carry
// `resolvedModel`, and "Sonnet 5" has no `major.minor` dot unlike older
// "claude-opus-4-6"-style ids.
const LIVE_SHAPED_MODELS: ModelInfo[] = [
{
value: "default",
resolvedModel: "claude-opus-4-8[1m]",
displayName: "Default (recommended)",
description: "Use the default model (currently Opus 4.8 (1M context))",
},
{
value: "opus[1m]",
resolvedModel: "claude-opus-4-8[1m]",
displayName: "Opus",
description: "Opus 4.8 with 1M context · Best for everyday, complex tasks",
},
{
value: "sonnet",
resolvedModel: "claude-sonnet-5",
displayName: "Sonnet",
description: "Sonnet 5 · Efficient for routine tasks",
},
{
value: "haiku",
resolvedModel: "claude-haiku-4-5-20251001",
displayName: "Haiku",
description: "Haiku 4.5 · Fastest for quick answers",
},
];
describe("resolveModelPreference - resolvedModel matching", () => {
it("matches a preference that equals an alias's resolvedModel exactly", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-sonnet-5")?.value).toBe("sonnet");
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8[1m]")?.value).toBe(
"opus[1m]",
);
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-haiku-4-5-20251001")?.value).toBe(
"haiku",
);
});
it("is case-insensitive", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "CLAUDE-SONNET-5")?.value).toBe("sonnet");
});
it("still resolves plain aliases via the direct-match tier", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "sonnet")?.value).toBe("sonnet");
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "opus[1m]")?.value).toBe("opus[1m]");
});
it("does not let a retired dated id drift onto a same-family alias of a different generation", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-sonnet-4-6")).toBeNull();
});
// "opus[1m]"'s `value` has no dotted version, just the context-hint digit
// in "[1m]" — unstripped, that reads as version "1" and blocks the match
// before the matcher reaches the description's real "4.8".
it("does not mistake an alias's context-hint suffix for its version", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-opus-4-8")?.value).toBe("opus[1m]");
});
it("returns null for a preference that matches nothing", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "claude-gpt-99")).toBeNull();
});
it("matches a '-1m' id-suffix preference against a '[1m]'-spelled resolvedModel", () => {
// Without hint canonicalization the exact tier misses and the substring
// tier lands on the bare 200k sibling, silently downgrading a 1M pin.
const models: ModelInfo[] = [
{
value: "sonnet",
resolvedModel: "claude-sonnet-5",
displayName: "Sonnet",
description: "Sonnet 5 · Efficient for routine tasks",
},
{
value: "sonnet[1m]",
resolvedModel: "claude-sonnet-5[1m]",
displayName: "Sonnet",
description: "Sonnet 5 with 1M context",
},
];
expect(resolveModelPreference(models, "claude-sonnet-5-1m")?.value).toBe("sonnet[1m]");
});
it("matches a '-1m'-suffix alias directly against a '[1m]'-spelled value", () => {
expect(resolveModelPreference(LIVE_SHAPED_MODELS, "opus-1m")?.value).toBe("opus[1m]");
});
// Rows without resolvedModel (older SDKs, allowlist-synthesized entries,
// option-derived lists) can't use the exact resolved tier, so the hinted
// preference must not be absorbed by the bare sibling in the substring
// tier — the tokenized tier scores the hinted row correctly instead.
it("does not let a bare sibling absorb a hinted preference when resolvedModel is absent", () => {
const models: ModelInfo[] = [
{ value: "sonnet", displayName: "Sonnet", description: "" },
{ value: "sonnet[1m]", displayName: "Sonnet", description: "" },
];
expect(resolveModelPreference(models, "claude-sonnet-5[1m]")?.value).toBe("sonnet[1m]");
expect(resolveModelPreference(models, "claude-sonnet-5-1m")?.value).toBe("sonnet[1m]");
// Bare preferences still land on the bare row via the substring tier.
expect(resolveModelPreference(models, "claude-sonnet-5")?.value).toBe("sonnet");
});
});
describe("matchResumedModel", () => {
// Environment shape from issue #845: the default resolves to Sonnet, the
// session was explicitly switched to Opus before the process restart.
const SONNET_DEFAULT_MODELS: ModelInfo[] = [
{
value: "default",
resolvedModel: "claude-sonnet-5",
displayName: "Default (recommended)",
description: "Use the default model (currently Sonnet 5)",
},
{
value: "opus",
resolvedModel: "claude-opus-4-6[1m]",
displayName: "Opus",
description: "Opus 4.6 · Most capable for complex work",
},
{
value: "sonnet",
resolvedModel: "claude-sonnet-5",
displayName: "Sonnet",
description: "Sonnet 5 · Efficient for routine tasks",
},
{
value: "sonnet[1m]",
resolvedModel: "claude-sonnet-5[1m]",
displayName: "Sonnet",
description: "Sonnet 5 with 1M context",
},
];
it("maps an explicitly-switched session's live model to its picker alias", () => {
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-opus-4-6").value).toBe("opus");
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-opus-4-6[1m]").value).toBe("opus");
});
it("keeps Default selected when the live model is what Default resolves to", () => {
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-sonnet-5").value).toBe("default");
});
it("prefers an exact named-row resolvedModel match over the hint-stripped Default tier", () => {
// "claude-sonnet-5[1m]" differs from Default's "claude-sonnet-5" only by
// the context hint; stripping both would misreport the explicit 1M row as
// Default (and infer the wrong context window downstream). Both hint
// spellings must land on the row regardless of which one it uses.
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-sonnet-5[1m]").value).toBe(
"sonnet[1m]",
);
expect(matchResumedModel(SONNET_DEFAULT_MODELS, "claude-sonnet-5-1m").value).toBe("sonnet[1m]");
});
it("normalizes the '-1m' id-suffix hint form and casing against Default's resolution", () => {
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-opus-4-8-1m").value).toBe("default");
expect(matchResumedModel(LIVE_SHAPED_MODELS, "Claude-Opus-4-8[1M]").value).toBe("default");
});
it("prefers Default over a named row sharing the identical resolvedModel", () => {
// "opus[1m]" carries the same resolvedModel as Default; the live id can't
// tell the two apart, so a never-customized session stays on Default.
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-opus-4-8[1m]").value).toBe("default");
});
it("ignores '[1m]' context hints when comparing against Default's resolution", () => {
// The transcript-restored model drops the "[1m]" context hint, so a
// session that never left the 1M-context default resumes as the bare id.
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-opus-4-8").value).toBe("default");
});
it("still resolves non-default live models against the picker entries", () => {
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-sonnet-5").value).toBe("sonnet");
expect(matchResumedModel(LIVE_SHAPED_MODELS, "claude-haiku-4-5-20251001").value).toBe("haiku");
});
it("tracks a live model with no picker counterpart verbatim", () => {
const result = matchResumedModel(LIVE_SHAPED_MODELS, "claude-gpt-99");
expect(result.value).toBe("claude-gpt-99");
expect(result.displayName).toBe("claude-gpt-99");
});
});
describe("applyAvailableModelsAllowlist - resolvedModel matching", () => {
it("resolves an allowlist entry pinned to a canonical resolvedModel id", () => {
const result = 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");
});
});
describe("applyAvailableModelsAllowlist - modelOverrides", () => {
const SDK_MODELS: ModelInfo[] = [
{ value: "default", displayName: "Default", description: "Default model" },
{
value: "opus",
displayName: "Opus",
description: "Claude Opus 4.6",
supportsEffort: true,
supportedEffortLevels: ["low", "high"],
},
];
it("surfaces the override target as the value while keeping the alias's display metadata", () => {
// 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 entry = result.find((m) => m.value === overrides["claude-opus-4-6"]);
expect(entry).toEqual({
value: overrides["claude-opus-4-6"],
displayName: "Opus",
description: "Claude Opus 4.6",
supportsEffort: true,
supportedEffortLevels: ["low", "high"],
});
});
});
// Runs against the real SDK (no mocks) so the fixture above is flagged the
// moment the SDK's actual model list shape drifts from it. Requires a usable
// ANTHROPIC_API_KEY, hence opt-in via RUN_INTEGRATION_TESTS.
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("model resolution (real SDK)", () => {
it("every model's resolvedModel (when present) resolves back to that same row", async () => {
const q = query({ prompt: "hi", options: { sessionId: randomUUID() } });
try {
const models = await q.supportedModels();
expect(models.length).toBeGreaterThan(0);
for (const model of models) {
// "default" shares a resolvedModel with the CLI's recommended named
// alias, and resolution intentionally prefers that alias on a tie.
if (!model.resolvedModel || model.value === "default") continue;
const resolved = resolveModelPreference(models, model.resolvedModel);
expect(resolved?.value).toBe(model.value);
}
} finally {
q.return(undefined);
}
}, 30000);
it("resolves the well-known 'sonnet' and 'opus' aliases to a model bearing their family name", async () => {
const q = query({ prompt: "hi", options: { sessionId: randomUUID() } });
try {
const models = await q.supportedModels();
const sonnet = resolveModelPreference(models, "sonnet");
expect(sonnet?.resolvedModel?.toLowerCase()).toContain("sonnet");
const opus = resolveModelPreference(models, "opus");
expect(opus?.resolvedModel?.toLowerCase()).toContain("opus");
} finally {
q.return(undefined);
}
}, 30000);
});

View File

@@ -1,303 +0,0 @@
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
const mockQuery = vi.hoisted(() =>
vi.fn(() => ({
initializationResult: vi.fn().mockResolvedValue({
models: [
{ value: "id", displayName: "name", description: "description", supportsAutoMode: true },
],
}),
setModel: vi.fn(),
setPermissionMode: vi.fn(),
supportedCommands: vi.fn().mockResolvedValue([]),
})),
);
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
"@anthropic-ai/claude-agent-sdk",
)),
query: mockQuery,
}));
// `logout` shells out to `claude auth logout`; make execFile succeed so the
// real logout body runs (and clears provider config) without a live CLI.
vi.mock("node:child_process", async () => {
const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
return {
...actual,
execFile: vi.fn((_file: string, _args: string[], cb: (...a: unknown[]) => void) =>
cb(null, { stdout: "", stderr: "" }),
),
};
});
describe("providers", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
vi.runAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.resetAllMocks();
});
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
const connectionMock = {
sessionUpdate: async (_: any) => {},
} as AcpClient;
const agent = new ClaudeAcpAgent(connectionMock);
return [agent, mockQuery];
}
it("advertises the providers capability in initialize", async () => {
const [agent] = await createAgentMock();
const response = await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
expect(response.agentCapabilities?.providers).toEqual({});
});
it("lists a single optional 'main' provider, unconfigured by default", async () => {
const [agent] = await createAgentMock();
const response = await agent.unstable_listProviders({});
expect(response.providers).toEqual([
{
providerId: "main",
supported: ["anthropic", "bedrock", "vertex"],
required: false,
current: null,
},
]);
});
it("reflects apiType/baseUrl after set, and never echoes headers", async () => {
const [agent] = await createAgentMock();
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://gateway.example/v1",
headers: { authorization: "Bearer secret" },
});
const response = await agent.unstable_listProviders({});
expect(response.providers[0].current).toEqual({
apiType: "anthropic",
baseUrl: "https://gateway.example/v1",
});
// headers must not leak through list
expect(JSON.stringify(response.providers)).not.toContain("secret");
});
it("rejects set for an unknown providerId", async () => {
const [agent] = await createAgentMock();
await expect(
agent.unstable_setProvider({
providerId: "openai",
apiType: "anthropic",
baseUrl: "https://gateway.example",
}),
).rejects.toMatchObject({ code: -32602 });
});
it("rejects set for an unsupported apiType", async () => {
const [agent] = await createAgentMock();
await expect(
agent.unstable_setProvider({
providerId: "main",
apiType: "openai",
baseUrl: "https://gateway.example",
}),
).rejects.toMatchObject({ code: -32602 });
});
it("rejects set for an empty or non-http baseUrl", async () => {
const [agent] = await createAgentMock();
await expect(
agent.unstable_setProvider({ providerId: "main", apiType: "anthropic", baseUrl: "" }),
).rejects.toMatchObject({ code: -32602 });
await expect(
agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "ftp://nope.example",
}),
).rejects.toMatchObject({ code: -32602 });
});
it("disables the 'main' provider by clearing config and reporting current: null", async () => {
const [agent] = await createAgentMock();
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://gateway.example/v1",
});
expect((await agent.unstable_listProviders({})).providers[0].current).not.toBeNull();
await expect(agent.unstable_disableProvider({ providerId: "main" })).resolves.toEqual({});
expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull();
});
it("treats disabling an unknown provider as an idempotent no-op", async () => {
const [agent] = await createAgentMock();
await expect(agent.unstable_disableProvider({ providerId: "openai" })).resolves.toEqual({});
});
it("routes anthropic provider config into session env", async () => {
const [agent, mockQuery] = await createAgentMock();
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://gateway.example",
headers: { "x-api-key": "test" },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_AUTH_TOKEN: " ",
ANTHROPIC_BASE_URL: "https://gateway.example",
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
}),
}),
}),
);
});
it("routes bedrock provider config into session env", async () => {
const [agent, mockQuery] = await createAgentMock();
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await agent.unstable_setProvider({
providerId: "main",
apiType: "bedrock",
baseUrl: "https://gateway.example",
headers: { "custom-header": "test" },
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
CLAUDE_CODE_USE_BEDROCK: "1",
AWS_BEARER_TOKEN_BEDROCK: " ",
ANTHROPIC_BEDROCK_BASE_URL: "https://gateway.example",
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
}),
}),
}),
);
});
it("accepts vertex config via _meta and routes it into session env", async () => {
const [agent, mockQuery] = await createAgentMock();
await agent.initialize({ protocolVersion: 1, clientCapabilities: {} });
await agent.unstable_setProvider({
providerId: "main",
apiType: "vertex",
baseUrl: "https://vertex.example",
headers: { "custom-header": "test" },
_meta: { claudeCode: { vertex: { projectId: "my-project", region: "us-east5" } } },
});
// list surfaces apiType/baseUrl but not the _meta extras
const listed = await agent.unstable_listProviders({});
expect(listed.providers[0].current).toEqual({
apiType: "vertex",
baseUrl: "https://vertex.example",
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
CLAUDE_CODE_USE_VERTEX: "1",
ANTHROPIC_VERTEX_BASE_URL: "https://vertex.example",
ANTHROPIC_VERTEX_PROJECT_ID: "my-project",
CLOUD_ML_REGION: "us-east5",
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
}),
}),
}),
);
});
it("rejects vertex set without _meta project/region", async () => {
const [agent] = await createAgentMock();
await expect(
agent.unstable_setProvider({
providerId: "main",
apiType: "vertex",
baseUrl: "https://vertex.example",
}),
).rejects.toMatchObject({ code: -32602 });
// partial _meta (missing region) is also rejected
await expect(
agent.unstable_setProvider({
providerId: "main",
apiType: "vertex",
baseUrl: "https://vertex.example",
_meta: { claudeCode: { vertex: { projectId: "my-project" } } } as any,
}),
).rejects.toMatchObject({ code: -32602 });
});
it("providers/set takes precedence over gateway auth", async () => {
const [agent, mockQuery] = await createAgentMock();
await agent.initialize({
protocolVersion: 1,
clientCapabilities: { auth: { terminal: true, _meta: { gateway: true } } } as any,
});
// Gateway auth first...
await agent.authenticate({
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gateway.example", headers: {} } },
});
// ...then providers/set wins.
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://provider.example",
});
await agent.newSession({ cwd: process.cwd(), mcpServers: [] });
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
ANTHROPIC_BASE_URL: "https://provider.example",
}),
}),
}),
);
});
it("clears provider config on logout", async () => {
const [agent] = await createAgentMock();
// Avoids resolving the native CLI binary in claudeCliPath().
vi.stubEnv("CLAUDE_CODE_EXECUTABLE", "/bin/true");
await agent.unstable_setProvider({
providerId: "main",
apiType: "anthropic",
baseUrl: "https://provider.example",
});
expect((await agent.unstable_listProviders({})).providers[0].current).not.toBeNull();
await agent.logout({});
expect((await agent.unstable_listProviders({})).providers[0].current).toBeNull();
});
});

View File

@@ -29,13 +29,6 @@ describe("resolvePermissionMode", () => {
expect(resolvePermissionMode("bypass")).toBe("bypassPermissions");
});
it("resolves 'manual' as an alias for 'default'", () => {
const { logger, error } = mockLogger();
expect(resolvePermissionMode("manual", logger)).toBe("default");
expect(resolvePermissionMode("Manual", logger)).toBe("default");
expect(error).not.toHaveBeenCalled();
});
it("trims whitespace", () => {
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
});

View File

@@ -2,7 +2,6 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { SessionNotification } from "@agentclientprotocol/sdk";
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
import type { AcpClient, ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
import { makeMockQuery } from "./helpers.js";
const { registerHookCallbackSpy } = vi.hoisted(() => ({
registerHookCallbackSpy: vi.fn(),
@@ -102,28 +101,31 @@ describe("session config options", () => {
applyFlagSettingsSpy = vi.fn();
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
query: makeMockQuery({
query: {
setPermissionMode: setPermissionModeSpy,
setModel: setModelSpy,
applyFlagSettings: applyFlagSettingsSpy,
}),
supportedCommands: async () => [],
},
input: null,
cancelled: false,
permissionMode: "default",
settingsManager: {},
modes: structuredClone(MOCK_MODES),
models: structuredClone(MOCK_MODELS),
modelInfos: MOCK_MODELS.availableModels.map((m): ModelInfo => ({
value: m.modelId,
displayName: m.name,
description: m.description,
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
})),
modelInfos: MOCK_MODELS.availableModels.map(
(m): ModelInfo => ({
value: m.modelId,
displayName: m.name,
description: m.description,
supportsEffort: true,
supportedEffortLevels: ["low", "medium", "high"],
}),
),
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
contextWindowSize: 200000,
toolUseCache: {},
emittedToolCalls: new Set(),
waitingForToolCall: new Map(),
};
}
@@ -819,85 +821,6 @@ describe("session config options", () => {
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
});
// Option entries carry no resolvedModel, so alias resolution must consult
// session.modelInfos — otherwise a full model id in either hint spelling
// ("[1m]"/"-1m") falls to the substring tier and lands on the bare 200k
// sibling, silently downgrading the session's context lane.
it("resolves a full model id onto its hinted row via session.modelInfos", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.models = {
currentModelId: "sonnet",
availableModels: [
{ modelId: "sonnet", name: "Sonnet", description: "" },
{ modelId: "sonnet[1m]", name: "Sonnet", description: "" },
],
};
session.modelInfos = [
{
value: "sonnet",
resolvedModel: "claude-sonnet-5",
displayName: "Sonnet",
description: "",
},
{
value: "sonnet[1m]",
resolvedModel: "claude-sonnet-5[1m]",
displayName: "Sonnet",
description: "",
},
];
session.configOptions = session.configOptions.map((o: { id: string }) =>
o.id === "model"
? {
...o,
currentValue: "sonnet",
options: [
{ value: "sonnet", name: "Sonnet" },
{ value: "sonnet[1m]", name: "Sonnet" },
],
}
: o,
);
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-5[1m]",
});
expect(setModelSpy).toHaveBeenCalledWith("sonnet[1m]");
setModelSpy.mockClear();
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-5-1m",
});
expect(setModelSpy).toHaveBeenCalledWith("sonnet[1m]");
});
// A session can be running a model with no picker entry (resumed onto a
// model excluded by the availableModels allowlist, or a refusal
// fallback); its verbatim id is then the option's currentValue. A client
// round-tripping that reported value must not get "Invalid value".
it("accepts the reported currentValue even when it has no options entry", async () => {
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
session.models = { ...session.models, currentModelId: "claude-offlist-9" };
session.configOptions = session.configOptions.map((o: { id: string }) =>
o.id === "model" ? { ...o, currentValue: "claude-offlist-9" } : o,
);
const response = await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-offlist-9",
});
expect(setModelSpy).toHaveBeenCalledWith("claude-offlist-9");
expect(response.configOptions?.find((o) => o.id === "model")?.currentValue).toBe(
"claude-offlist-9",
);
});
it("setSessionMode also syncs configOptions", async () => {
await agent.setSessionMode({ sessionId: SESSION_ID, modeId: "plan" });
@@ -927,84 +850,6 @@ describe("session config options", () => {
});
});
describe("context window on model change", () => {
beforeEach(() => {
populateSession();
});
function getSession() {
return (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
}
it("sets the window from text inference on model switch, without any getContextUsage IPC", async () => {
// getContextUsage stalls until the session's first prompt turn, so the
// switch path must never call it; the window is seeded from the text
// heuristic (here via the new model's resolvedModel) and later confirmed
// by result.modelUsage.
const session = getSession();
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
session.modelInfos = session.modelInfos.map((m: ModelInfo) =>
m.value === "claude-sonnet-4-6" ? { ...m, resolvedModel: "claude-sonnet-5[1m]" } : m,
);
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(session.query.getContextUsage).not.toHaveBeenCalled();
expect(session.contextWindowSize).toBe(1_000_000);
});
it("falls back to the default window when inference misses, without any getContextUsage IPC", async () => {
const session = getSession();
session.contextWindowSize = 1_000_000;
// Present but must NOT be called; the switch never consults it.
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 967000 }));
// claude-sonnet-4-6 carries no "1m" token in its id, resolvedModel,
// displayName, or description, so inference misses → default window.
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(session.query.getContextUsage).not.toHaveBeenCalled();
expect(session.contextWindowSize).toBe(200000);
});
it("does not call getContextUsage even when switching to a fresh model", async () => {
const session = getSession();
const spy = vi.fn(async () => ({ rawMaxTokens: 967000 }));
session.query.getContextUsage = spy;
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-sonnet-4-6",
});
expect(spy).not.toHaveBeenCalled();
});
it("keeps the learned window when re-asserting the current model", async () => {
const session = getSession();
session.contextWindowSize = 1_000_000;
session.query.getContextUsage = vi.fn(async () => ({ rawMaxTokens: 200000 }));
await agent.setSessionConfigOption({
sessionId: SESSION_ID,
configId: "model",
value: "claude-opus-4-5",
});
expect(session.query.getContextUsage).not.toHaveBeenCalled();
expect(session.contextWindowSize).toBe(1_000_000);
});
});
describe("auto mode availability per model", () => {
/**
* Augment the session populated by `populateSession()` with a Haiku entry
@@ -1269,10 +1114,12 @@ describe("session config options", () => {
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
],
};
// The tool_call was already surfaced (by the streamed tool_use chunk), so
// the permission request won't re-emit one — keep this focused on options.
session.emittedToolCalls.add("toolu_1");
session.toolUseCache["toolu_1"] = {
type: "tool_use",
id: "toolu_1",
name: "ExitPlanMode",
input: { plan: "do stuff" },
};
const canUseTool = (agent as any).canUseTool(SESSION_ID);
const signal = new AbortController().signal;
@@ -1305,10 +1152,12 @@ describe("session config options", () => {
],
};
permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } };
// The tool_call was already surfaced (by the streamed tool_use chunk), so
// the permission request won't re-emit one — the deny path below should
// produce no session updates at all.
session.emittedToolCalls.add("toolu_2");
session.toolUseCache["toolu_2"] = {
type: "tool_use",
id: "toolu_2",
name: "ExitPlanMode",
input: { plan: "do stuff" },
};
const canUseTool = (agent as any).canUseTool(SESSION_ID);
const result = await canUseTool(
@@ -1336,10 +1185,12 @@ describe("session config options", () => {
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
],
};
// The tool_call was already surfaced (by the streamed tool_use chunk), so
// the permission request won't re-emit one — keep this focused on options.
session.emittedToolCalls.add("toolu_3");
session.toolUseCache["toolu_3"] = {
type: "tool_use",
id: "toolu_3",
name: "ExitPlanMode",
input: { plan: "do stuff" },
};
const canUseTool = (agent as any).canUseTool(SESSION_ID);
const signal = new AbortController().signal;

View File

@@ -6,11 +6,9 @@ import { AcpClient, ClaudeAcpAgent } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import type { SDKUserMessage } from "@anthropic-ai/claude-agent-sdk";
function createMockClient(
sessionUpdate: (notification: SessionNotification) => Promise<void> = async () => {},
): AcpClient {
function createMockClient(): AcpClient {
return {
sessionUpdate,
sessionUpdate: async (_notification: SessionNotification) => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
@@ -332,52 +330,4 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
}
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
}, 60000);
// Regression test for https://github.com/agentclientprotocol/claude-agent-acp/issues/845
// On resume the CLI restores the model the session's transcript was running,
// so loadSession must report that live model instead of recomputing the
// env/settings/default choice from scratch. Haiku is used because no account
// resolves its default there, so the restored model provably differs from
// the freshly-computed default. Requires no ANTHROPIC_MODEL env var and no
// "model" key in settings.json (either would pin the model on resume and
// mask the reconciliation this test covers).
it("ACP: loadSession reports the model the resumed session is actually running", async () => {
const agentChunks: string[] = [];
const client = createMockClient(async (notification) => {
if (notification.update.sessionUpdate === "agent_message_chunk") {
const content = notification.update.content;
if (content.type === "text") agentChunks.push(content.text);
}
});
const modelOf = (opts: { id: string; currentValue?: unknown }[] | null | undefined) =>
opts?.find((o) => o.id === "model")?.currentValue;
const agent = new ClaudeAcpAgent(client);
try {
const cwd = process.cwd();
const { sessionId, configOptions } = await agent.newSession({ cwd, mcpServers: [] });
expect(modelOf(configOptions)).not.toBe("haiku");
await agent.setSessionConfigOption({ sessionId, configId: "model", value: "haiku" });
// A real turn on Haiku, so the transcript's last assistant message —
// which is what the CLI restores the model from — records it.
await agent.prompt({
sessionId,
prompt: [{ type: "text", text: "Reply with only the word OK" }],
});
await agent.closeSession({ sessionId });
const loadResp = await agent.loadSession({ sessionId, cwd, mcpServers: [] });
expect(modelOf(loadResp.configOptions)).toBe("haiku");
// And the reported option agrees with the live session (`/context`).
agentChunks.length = 0;
await agent.prompt({ sessionId, prompt: [{ type: "text", text: "/context" }] });
const liveModel = agentChunks.join("\n").match(/\*\*Model:\*\*\s*(\S+)/)?.[1];
expect(liveModel).toContain("haiku");
} finally {
await agent.dispose();
}
}, 120000);
});

File diff suppressed because it is too large Load Diff

View File

@@ -8,24 +8,19 @@ import {
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
import {
AgentInput,
AgentOutput,
AskUserQuestionInput,
BashInput,
BashOutput,
FileEditInput,
FileReadInput,
FileReadOutput,
FileWriteInput,
GlobInput,
GrepInput,
ReportFindingsInput,
TaskCreateInput,
TaskCreateOutput,
TaskUpdateInput,
TodoWriteInput,
WebFetchInput,
WebSearchInput,
WebSearchOutput,
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
import {
ImageBlockParam,
@@ -60,6 +55,7 @@ import {
BetaWebSearchToolResultBlockParam,
} from "@anthropic-ai/sdk/resources/beta.mjs";
import path from "node:path";
import { Logger } from "./acp-agent.js";
/**
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
@@ -375,25 +371,6 @@ export function toolInfoFromToolUse(
};
}
case "ReportFindings": {
const input = toolUse.input as ReportFindingsInput | undefined;
const findings = input?.findings ?? [];
return {
title:
findings.length === 0
? "Report findings: none found"
: `Report ${findings.length} finding${findings.length === 1 ? "" : "s"}`,
kind: "think",
content: findings.map((finding) => ({
type: "content" as const,
content: {
type: "text" as const,
text: `**${finding.file}${finding.line ? `:${finding.line}` : ""}** — ${finding.summary}`,
},
})),
};
}
case "TaskCreate": {
const input = toolUse.input as TaskCreateInput | undefined;
return {
@@ -489,88 +466,6 @@ export function toolInfoFromToolUse(
}
}
/**
* Narrow the untyped message-level `tool_use_result` toward a per-tool Output
* shape: rejects everything but a plain non-null object (arrays pass a bare
* `typeof === "object"` check, so they're excluded here). The returned value
* is only *nominally* typed — it arrives over the wire from arbitrary CLI
* versions, so each caller must still guard the specific fields it reads
* before trusting them.
*/
function structuredResult<T extends object>(toolUseResult: unknown): T | undefined {
return toolUseResult !== null &&
typeof toolUseResult === "object" &&
!Array.isArray(toolUseResult)
? (toolUseResult as T)
: undefined;
}
/**
* Strip the model-directed trailer from a raw Agent/Task tool_result text:
* a `<usage>…</usage>` totals block and/or an
* `agentId: <id> (use SendMessage …)` continuation line at the end of the
* text. Both patterns are tail-anchored and independent (older CLIs emit
* variants with only one of them), so a format change makes them stop
* matching rather than mangle the report.
*/
function stripAgentTrailer(text: string): string {
return stripAgentIdLine(stripUsageBlock(text));
}
const USAGE_OPEN = "<usage>";
const USAGE_CLOSE = "</usage>";
/** Remove a trailing `<usage>…</usage>` block, plus trailing whitespace and
* one preceding newline. Matches from the *last* `<usage>` so a report that
* merely mentions the marker earlier isn't truncated at the mention. */
function stripUsageBlock(text: string): string {
const body = text.trimEnd();
if (!body.endsWith(USAGE_CLOSE)) {
return text;
}
const open = body.lastIndexOf(USAGE_OPEN, body.length - USAGE_CLOSE.length - USAGE_OPEN.length);
if (open === -1) {
return text;
}
return body.slice(0, open > 0 && body[open - 1] === "\n" ? open - 1 : open);
}
/** The continuation line, anchored to a whole line so the regex has a single
* start position and no ambiguous repetition (`[\w-]+` can't consume the
* following space, `[^)]*` can't consume the closing paren) — it runs in
* linear time on any input. */
const AGENT_ID_LINE = /^agentId: [\w-]+ \([^)]*\)$/;
/** Remove a final `agentId: <id> (…)` line, plus trailing whitespace and the
* newline that preceded the line. */
function stripAgentIdLine(text: string): string {
const body = text.trimEnd();
const lineStart = body.lastIndexOf("\n") + 1;
if (!AGENT_ID_LINE.test(body.slice(lineStart))) {
return text;
}
return body.slice(0, Math.max(lineStart - 1, 0));
}
/** Apply {@link stripAgentTrailer} across a raw tool_result `content` (plain
* string or block array), leaving non-text blocks untouched. */
function stripAgentTrailerFromContent(content: unknown): unknown {
if (typeof content === "string") {
return stripAgentTrailer(content);
}
if (Array.isArray(content)) {
return content.map((block) =>
block !== null &&
typeof block === "object" &&
block.type === "text" &&
typeof block.text === "string"
? { ...block, text: stripAgentTrailer(block.text) }
: block,
);
}
return content;
}
export function toolUpdateFromToolResult(
toolResult:
| ToolResultBlockParam
@@ -585,7 +480,6 @@ export function toolUpdateFromToolResult(
| BetaToolSearchToolResultBlockParam,
toolUse: any | undefined,
supportsTerminalOutput: boolean = false,
toolUseResult?: unknown,
): ToolUpdate {
if (
"is_error" in toolResult &&
@@ -598,63 +492,8 @@ export function toolUpdateFromToolResult(
return toAcpContentUpdate(toolResult.content, true);
}
// Shared raw-text fallback: renders the tool_result content the model saw.
// The structured cases below fall back to this when `tool_use_result` is
// absent or fails its shape guard (older CLIs, replayed sessions).
const rawContentUpdate = () =>
toAcpContentUpdate(toolResult.content, "is_error" in toolResult ? toolResult.is_error : false);
switch (toolUse?.name) {
case "Read": {
// The raw tool_result text is the model-facing view: line-numbered
// content plus any appended <system-reminder> blocks (malicious-code
// checks, memory staleness notes, …) that clients shouldn't see. The
// structured FileReadOutput carries the clean content — rebuild the
// line-numbered view from it. Non-text variants (image/notebook/pdf)
// fall back to the raw content blocks, which already render fine.
const structuredRead = structuredResult<FileReadOutput>(toolUseResult);
if (
structuredRead?.type === "text" &&
typeof structuredRead.file?.content === "string" &&
// An empty file has nothing to line-number; keep the raw view (the
// model-facing "file is empty" note) rather than a phantom blank line.
structuredRead.file.content.length > 0
) {
// startLine is typed non-optional but defended anyway; a Read's
// `offset` input is the same 1-based starting line, so it beats a
// blind 1 when an emitter omits the field.
const startLine =
structuredRead.file.startLine ??
(toolUse.input as FileReadInput | undefined)?.offset ??
1;
// A trailing newline is a line terminator, not an extra line — don't
// number a phantom empty line after it.
let numbered = structuredRead.file.content
.replace(/\n$/, "")
.split("\n")
.map((line, i) => `${startLine + i}\t${line}`)
.join("\n");
// The model-facing truncation banner doesn't survive reconstruction
// from file.content (the SDK flag exists for exactly this case) —
// re-establish it so a partial first page doesn't read as the whole
// file.
if (structuredRead.file.truncatedByTokenCap) {
const { numLines, totalLines } = structuredRead.file;
const detail =
typeof numLines === "number" && typeof totalLines === "number"
? `: showing ${numLines} of ${totalLines} lines`
: "";
numbered += `\n[File truncated${detail}]`;
}
return {
content: [
{
type: "content",
content: { type: "text", text: markdownEscape(numbered) },
},
],
};
}
case "Read":
if (Array.isArray(toolResult.content) && toolResult.content.length > 0) {
return {
content: toolResult.content.map((content: any) => ({
@@ -682,7 +521,6 @@ export function toolUpdateFromToolResult(
};
}
return {};
}
case "Bash": {
const result = toolResult.content;
@@ -690,57 +528,15 @@ export function toolUpdateFromToolResult(
const isError = "is_error" in toolResult && toolResult.is_error;
// Extract output and exit code from either format:
// 1. The structured BashOutput (message-level tool_use_result): its
// stdout/stderr exclude the model-directed suffixes the raw text
// carries (stale-read hints, gh rate-limit hints, the
// persisted-output wrapper for too-large outputs — the interruption
// and truncation facts those carried are re-established from the
// structured flags below). Skipped for image output (the raw content
// array carries the actual image blocks) and backgrounded commands
// (the raw text carries the background-task notice; structured
// stdout may be empty).
// 2. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
// 3. Plain string content from a regular tool_result
// 4. Array content (e.g. [{ type: "text", text: "..." }] for stdout,
// 1. BetaBashCodeExecutionResultBlock: { type: "bash_code_execution_result", stdout, stderr, return_code }
// 2. Plain string content from a regular tool_result
// 3. Array content (e.g. [{ type: "text", text: "..." }] for stdout,
// or [{ type: "image", source: {...} }] when the local Bash tool
// produces an image, e.g. piping a base64 data URI)
let output = "";
let exitCode = isError ? 1 : 0;
const structuredBash = structuredResult<BashOutput>(toolUseResult);
if (
structuredBash &&
typeof structuredBash.stdout === "string" &&
typeof structuredBash.stderr === "string" &&
!structuredBash.isImage &&
structuredBash.backgroundTaskId === undefined
) {
output = [structuredBash.stdout, structuredBash.stderr].filter(Boolean).join("\n");
// Two raw-text notices don't survive the structured stdout/stderr —
// re-establish them so the client isn't shown a clean-looking result:
// the CLI appends its abort marker only to the model-facing text, and
// an aborted command isn't a success, so synthesize a failing exit
// code when the result wasn't already an error.
if (structuredBash.interrupted) {
output = [output, "[Command was aborted before completion]"].filter(Boolean).join("\n");
exitCode = 1;
}
// Structured stdout is clipped (~30k chars) when the full output was
// persisted to disk; without this note the clip is silent and the
// path to the full output is lost.
if (typeof structuredBash.persistedOutputPath === "string") {
const size =
typeof structuredBash.persistedOutputSize === "number"
? ` (${structuredBash.persistedOutputSize} bytes total)`
: "";
output = [
output,
`[Output truncated${size}: full output saved to ${structuredBash.persistedOutputPath}]`,
]
.filter(Boolean)
.join("\n");
}
} else if (
result &&
typeof result === "object" &&
"type" in result &&
@@ -803,45 +599,6 @@ export function toolUpdateFromToolResult(
return {};
}
case "Agent":
case "Task": {
// The raw tool_result text ends with a model-directed trailer (an
// `agentId: … (use SendMessage …)` line plus a `<usage>` totals block)
// that ACP clients shouldn't see. The message-level `tool_use_result`
// carries the structured AgentOutput whose `content` is the subagent's
// report without the trailer — render from it when present (per the SDK
// 0.3.207 guidance) and fall back to the raw text otherwise (older CLIs,
// replayed sessions).
// Narrowed to the full union, not the completed variant — the status
// check below is what discriminates it, and pre-narrowing would let
// future field reads typecheck against a variant the runtime value may
// not be.
const structured = structuredResult<AgentOutput>(toolUseResult);
if (
structured?.status === "completed" &&
Array.isArray(structured.content) &&
// A completed subagent can end with zero text blocks; an empty
// structured render would beat the raw fallback for no benefit.
structured.content.length > 0
) {
return toAcpContentUpdate(
structured.content,
"is_error" in toolResult ? toolResult.is_error : false,
);
}
// No structured report to render from (replayed sessions —
// getSessionMessages doesn't expose the transcript's toolUseResult —
// and older CLIs). The SDK advises rendering from tool_use_result
// instead of parsing the text, but with no structured value the
// tail-anchored strip is the only cleanup available; if the trailer
// format changes it simply stops matching and the full raw text
// renders, no worse than before.
return toAcpContentUpdate(
stripAgentTrailerFromContent(toolResult.content),
"is_error" in toolResult ? toolResult.is_error : false,
);
}
case "Edit": // Edit is handled in hooks
case "Write": {
return {};
@@ -851,54 +608,15 @@ export function toolUpdateFromToolResult(
return { title: "Exited Plan Mode" };
}
case "WebSearch": {
// The raw tool_result text is a model-directed dump ("Web search
// results for query: …\n\nLinks: [{…json…}]"). The structured
// WebSearchOutput carries the hits — render them the way server-side
// web_search_result blocks render ("Title (url)").
const structuredSearch = structuredResult<WebSearchOutput>(toolUseResult);
if (structuredSearch && Array.isArray(structuredSearch.results)) {
const lines = structuredSearch.results.flatMap((entry) =>
typeof entry === "string"
? [entry]
: Array.isArray(entry?.content)
? // tool_use_result arrives untyped across CLI version skew —
// skip off-spec hits rather than rendering
// "undefined (undefined)" lines.
entry.content.flatMap((hit) =>
typeof hit?.title === "string" && typeof hit?.url === "string"
? [formatWebSearchHit(hit)]
: [],
)
: [],
);
if (lines.length > 0) {
return {
content: [
{
type: "content",
content: { type: "text", text: lines.join("\n") },
},
],
};
}
}
return rawContentUpdate();
}
default: {
return rawContentUpdate();
return toAcpContentUpdate(
toolResult.content,
"is_error" in toolResult ? toolResult.is_error : false,
);
}
}
}
/** One display format for a web-search hit, shared by the structured
* WebSearchOutput render and the server-side `web_search_result` block so
* the two paths can't drift. */
function formatWebSearchHit(hit: { title: string; url: string }): string {
return `${hit.title} (${hit.url})`;
}
function toAcpContentUpdate(
content: any,
isError: boolean = false,
@@ -973,7 +691,7 @@ function toAcpContentBlock(content: ToolResultContent, isError: boolean): Conten
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
);
case "web_search_result":
return wrapText(formatWebSearchHit(content));
return wrapText(`${content.title} (${content.url})`);
case "web_search_tool_result_error":
return wrapText(`Error: ${content.error_code}`);
case "web_fetch_result":
@@ -1216,7 +934,12 @@ export const registerHookCallback = (
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
export const createPostToolUseHook =
(options?: { onEnterPlanMode?: () => Promise<void> }): HookCallback =>
(
logger: Logger = console,
options?: {
onEnterPlanMode?: () => Promise<void>;
},
): HookCallback =>
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
if (input.hook_event_name === "PostToolUse") {
// Handle EnterPlanMode tool - notify client of mode change after successful execution
@@ -1228,8 +951,11 @@ export const createPostToolUseHook =
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
if (onPostToolUseHook) {
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
} else {
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
delete toolUseCallbacks[toolUseID];
}
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
}
}
return { continue: true };