mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9957b54882 | ||
|
|
ec47d3446f | ||
|
|
546040ffe7 | ||
|
|
a52ab4ab64 | ||
|
|
76b4e96508 | ||
|
|
32abd34bc0 | ||
|
|
67af018d02 | ||
|
|
a55f31a6b0 | ||
|
|
f0801d50b2 | ||
|
|
1a9d3cebcd | ||
|
|
84e05eecc5 | ||
|
|
5faefab8ab | ||
|
|
b3ddfc3b86 | ||
|
|
e09f1486f1 | ||
|
|
1e15f3d8b0 | ||
|
|
92adcbd1ac | ||
|
|
e474a0c127 | ||
|
|
ef1dbd1cb4 | ||
|
|
e30f0e5ba0 | ||
|
|
9e1185bfd5 |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -8,7 +8,7 @@ updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
interval: "daily"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
|
||||
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -24,7 +24,7 @@ jobs:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "npm"
|
||||
|
||||
4
.github/workflows/publish.yml
vendored
4
.github/workflows/publish.yml
vendored
@@ -23,7 +23,7 @@ jobs:
|
||||
app-id: ${{ secrets.RELEASE_PLZ_APP_ID }}
|
||||
# GitHub App private key secret name
|
||||
private-key: ${{ secrets.RELEASE_PLZ_APP_PRIVATE_KEY }}
|
||||
- uses: googleapis/release-please-action@5c625bfb5d1ff62eadeeb3772007f7f66fdcf071
|
||||
- uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7
|
||||
id: release
|
||||
with:
|
||||
token: ${{ steps.generate-token.outputs.token }}
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
|
||||
# Setup .npmrc file to publish to npm
|
||||
- uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
registry-url: "https://registry.npmjs.org"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.30.0"
|
||||
".": "0.31.4"
|
||||
}
|
||||
|
||||
44
CHANGELOG.md
44
CHANGELOG.md
@@ -1,5 +1,49 @@
|
||||
# Changelog
|
||||
|
||||
## [0.31.4](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.3...v0.31.4) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* gate auto mode on model support ([#604](https://github.com/agentclientprotocol/claude-agent-acp/issues/604)) ([ec47d34](https://github.com/agentclientprotocol/claude-agent-acp/commit/ec47d3446fa22e5895621f57c0a4497dbe044505))
|
||||
|
||||
## [0.31.3](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.2...v0.31.3) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Rename effort config category to thought_level ([#609](https://github.com/agentclientprotocol/claude-agent-acp/issues/609)) ([76b4e96](https://github.com/agentclientprotocol/claude-agent-acp/commit/76b4e9650832e59a6b4915ad898fc66a065022fa))
|
||||
|
||||
## [0.31.2](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.1...v0.31.2) (2026-04-28)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* tolerate invalid values in settings.json ([#601](https://github.com/agentclientprotocol/claude-agent-acp/issues/601)) ([67af018](https://github.com/agentclientprotocol/claude-agent-acp/commit/67af018d0216238d8f3cd5bad04a55200518239b))
|
||||
|
||||
## [0.31.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.31.0...v0.31.1) (2026-04-27)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* detect glibc and use correct binary ([#599](https://github.com/agentclientprotocol/claude-agent-acp/issues/599)) ([f0801d5](https://github.com/agentclientprotocol/claude-agent-acp/commit/f0801d50b2390a3c48150b9b8507d30a70c18b84))
|
||||
|
||||
## [0.31.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.30.0...v0.31.0) (2026-04-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* allow configuring models through env vars ([#586](https://github.com/agentclientprotocol/claude-agent-acp/issues/586)) ([1e15f3d](https://github.com/agentclientprotocol/claude-agent-acp/commit/1e15f3d8b0a06076ee62764ab576ac29fe056a38))
|
||||
* Support effort levels ([#464](https://github.com/agentclientprotocol/claude-agent-acp/issues/464)) ([9e1185b](https://github.com/agentclientprotocol/claude-agent-acp/commit/9e1185bfd55cbf83a653a0ff8c194f986cbd1574))
|
||||
* Update to acp sdk v0.20 ([#589](https://github.com/agentclientprotocol/claude-agent-acp/issues/589)) ([92adcbd](https://github.com/agentclientprotocol/claude-agent-acp/commit/92adcbd1ace2f2103742ffcf5c589eb1ca41b9b8))
|
||||
* Update to claude-agent-sdk 0.2.119 ([#587](https://github.com/agentclientprotocol/claude-agent-acp/issues/587)) ([ef1dbd1](https://github.com/agentclientprotocol/claude-agent-acp/commit/ef1dbd1cb457f0524d221ea07235abf49dc941a0))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Forward full systemPrompt preset options from _meta ([#591](https://github.com/agentclientprotocol/claude-agent-acp/issues/591)) ([b3ddfc3](https://github.com/agentclientprotocol/claude-agent-acp/commit/b3ddfc3b86703c23fe99d93611885c12b1e4812c))
|
||||
* Session Replay and Permission rendering ([#593](https://github.com/agentclientprotocol/claude-agent-acp/issues/593)) ([5faefab](https://github.com/agentclientprotocol/claude-agent-acp/commit/5faefab8ab9b725f7a3374808bf5579e411884bc)), closes [#579](https://github.com/agentclientprotocol/claude-agent-acp/issues/579)
|
||||
|
||||
## [0.30.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.29.2...v0.30.0) (2026-04-20)
|
||||
|
||||
|
||||
|
||||
56
docs/model-configuration.md
Normal file
56
docs/model-configuration.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# Model Configuration
|
||||
|
||||
When using claude-agent-acp with alternative providers (e.g. AWS Bedrock), model IDs differ from the direct Anthropic API. The `CLAUDE_MODEL_CONFIG` environment variable lets you configure model overrides and availability at the deployment level.
|
||||
|
||||
## `CLAUDE_MODEL_CONFIG`
|
||||
|
||||
A JSON string with two optional fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
| ----------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------- |
|
||||
| `modelOverrides` | `Record<string, string>` | Maps Anthropic model IDs to provider-specific model IDs (e.g. Bedrock model IDs or ARNs) |
|
||||
| `availableModels` | `string[]` | Restricts which models are offered to users. Accepts aliases (`"opus"`), prefixes (`"opus-4-5"`), or full IDs |
|
||||
|
||||
### Examples
|
||||
|
||||
**Bedrock model overrides:**
|
||||
|
||||
```bash
|
||||
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"}}'
|
||||
```
|
||||
|
||||
**Restrict available models:**
|
||||
|
||||
```bash
|
||||
CLAUDE_MODEL_CONFIG='{"availableModels":["opus","sonnet"]}'
|
||||
```
|
||||
|
||||
**Both together:**
|
||||
|
||||
```bash
|
||||
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1","claude-sonnet-4-5":"us.anthropic.claude-sonnet-4-5-v1"},"availableModels":["opus","sonnet"]}'
|
||||
```
|
||||
|
||||
**Full Bedrock example:**
|
||||
|
||||
```bash
|
||||
CLAUDE_CODE_USE_BEDROCK=1 \
|
||||
AWS_REGION=us-west-2 \
|
||||
CLAUDE_MODEL_CONFIG='{"modelOverrides":{"claude-opus-4-6":"us.anthropic.claude-opus-4-6-v1"}}' \
|
||||
node dist/index.js
|
||||
```
|
||||
|
||||
## Precedence
|
||||
|
||||
When an ACP caller provides `settings` via `_meta.claudeCode.options.settings` in the `sessions/create` request, `CLAUDE_MODEL_CONFIG` is ignored entirely. The env var is a deployment-level fallback for cases where the caller does not configure model settings itself.
|
||||
|
||||
| Source | Priority |
|
||||
| -------------------------------------------- | ----------------------------------------------------- |
|
||||
| `_meta.claudeCode.options.settings` (caller) | Highest — used if present |
|
||||
| `CLAUDE_MODEL_CONFIG` (env var) | Fallback — used only when caller provides no settings |
|
||||
|
||||
## Format details
|
||||
|
||||
- The value must be valid JSON. Invalid JSON will cause session creation to fail with a parse error.
|
||||
- Only `modelOverrides` and `availableModels` keys are read; other keys in the JSON are ignored.
|
||||
- Both fields map directly to the Claude Agent SDK's `Settings` type.
|
||||
530
package-lock.json
generated
530
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
12
package.json
12
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.30.0",
|
||||
"version": "0.31.4",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -60,22 +60,22 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.19.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.114",
|
||||
"@agentclientprotocol/sdk": "0.21.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.121",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.89.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.6.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.58.2",
|
||||
"@typescript-eslint/parser": "8.58.2",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.1",
|
||||
"@typescript-eslint/parser": "8.59.1",
|
||||
"eslint": "10.2.1",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.5.0",
|
||||
"prettier": "3.8.3",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.4"
|
||||
"vitest": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
608
src/acp-agent.ts
608
src/acp-agent.ts
@@ -51,8 +51,11 @@ import {
|
||||
ModelUsage,
|
||||
Options,
|
||||
PermissionMode,
|
||||
PermissionUpdate,
|
||||
Query,
|
||||
query,
|
||||
Settings,
|
||||
SDKAssistantMessageError,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKUserMessage,
|
||||
SlashCommand,
|
||||
@@ -135,6 +138,7 @@ type Session = {
|
||||
accumulatedUsage: AccumulatedUsage;
|
||||
modes: SessionModeState;
|
||||
models: SessionModelState;
|
||||
modelInfos: ModelInfo[];
|
||||
configOptions: SessionConfigOption[];
|
||||
promptRunning: boolean;
|
||||
pendingMessages: Map<string, { resolve: (cancelled: boolean) => void; order: number }>;
|
||||
@@ -270,12 +274,22 @@ export async function claudeCliPath(): Promise<string> {
|
||||
const { createRequire } = await import("node:module");
|
||||
const req = createRequire(import.meta.resolve("@anthropic-ai/claude-agent-sdk"));
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
// On linux, both glibc and musl variants may be installed side-by-side
|
||||
// (e.g. bunx hydrates every optional dep), so picking one by trial is
|
||||
// unreliable: the wrong binary segfaults at runtime instead of failing to
|
||||
// spawn. Detect the runtime libc and prefer the matching variant, falling
|
||||
// back to the other only if the preferred one isn't installed.
|
||||
const candidates =
|
||||
process.platform === "linux"
|
||||
? [
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
|
||||
]
|
||||
? isMuslLibc()
|
||||
? [
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
|
||||
]
|
||||
: [
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}/claude${ext}`,
|
||||
`@anthropic-ai/claude-agent-sdk-linux-${process.arch}-musl/claude${ext}`,
|
||||
]
|
||||
: [`@anthropic-ai/claude-agent-sdk-${process.platform}-${process.arch}/claude${ext}`];
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
@@ -290,6 +304,15 @@ export async function claudeCliPath(): Promise<string> {
|
||||
);
|
||||
}
|
||||
|
||||
function isMuslLibc(): boolean {
|
||||
// process.report.getReport().header.glibcVersionRuntime is populated when
|
||||
// Node is dynamically linked against glibc, and absent on musl.
|
||||
const report = process.report?.getReport() as
|
||||
| { header?: { glibcVersionRuntime?: string } }
|
||||
| undefined;
|
||||
return !report?.header?.glibcVersionRuntime;
|
||||
}
|
||||
|
||||
function shouldHideClaudeAuth(): boolean {
|
||||
return process.argv.includes("--hide-claude-auth");
|
||||
}
|
||||
@@ -302,6 +325,56 @@ const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
|
||||
// message and without invoking the model.
|
||||
const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]);
|
||||
|
||||
// The Claude SDK persists local slash command invocations (e.g. `/model`) and
|
||||
// their output as user messages in the session transcript, wrapping the
|
||||
// payload in these XML-like markers that the CLI uses for its own display.
|
||||
// The live prompt loop drops them; replay must strip them too or they leak
|
||||
// into the UI on session/load.
|
||||
const LOCAL_COMMAND_TAG_PATTERN =
|
||||
/<(command-name|command-message|command-args|local-command-stdout|local-command-stderr)>[\s\S]*?<\/\1>/g;
|
||||
|
||||
function stripMarkerTags(text: string): string {
|
||||
return text.replace(LOCAL_COMMAND_TAG_PATTERN, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Return user-message content with local-command marker tags removed, or
|
||||
* `null` if nothing meaningful remains (caller should skip the message).
|
||||
* Preserves real prose that's mixed in alongside the markers — e.g. a
|
||||
* message like `<command-name>…</command-name>hi` becomes `hi`.
|
||||
*/
|
||||
export function stripLocalCommandMetadata(content: unknown): unknown | null {
|
||||
if (typeof content === "string") {
|
||||
const stripped = stripMarkerTags(content);
|
||||
return stripped.trim() === "" ? null : stripped;
|
||||
}
|
||||
if (!Array.isArray(content)) return content;
|
||||
|
||||
const kept: unknown[] = [];
|
||||
for (const block of content) {
|
||||
if (
|
||||
block &&
|
||||
typeof block === "object" &&
|
||||
"type" in block &&
|
||||
(block as { type: unknown }).type === "text" &&
|
||||
"text" in block &&
|
||||
typeof (block as { text: unknown }).text === "string"
|
||||
) {
|
||||
const stripped = stripMarkerTags((block as { text: string }).text);
|
||||
if (stripped.trim() === "") continue;
|
||||
kept.push({ ...(block as object), text: stripped });
|
||||
} else {
|
||||
kept.push(block);
|
||||
}
|
||||
}
|
||||
if (kept.length === 0) return null;
|
||||
return kept;
|
||||
}
|
||||
|
||||
export function isLocalCommandMetadata(content: unknown): boolean {
|
||||
return stripLocalCommandMetadata(content) === null;
|
||||
}
|
||||
|
||||
const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
auto: "auto",
|
||||
default: "default",
|
||||
@@ -312,34 +385,85 @@ const PERMISSION_MODE_ALIASES: Record<string, PermissionMode> = {
|
||||
bypass: "bypassPermissions",
|
||||
};
|
||||
|
||||
export function resolvePermissionMode(defaultMode?: unknown): PermissionMode {
|
||||
export function resolvePermissionMode(
|
||||
defaultMode?: unknown,
|
||||
logger: Logger = console,
|
||||
): PermissionMode {
|
||||
if (defaultMode === undefined) {
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (typeof defaultMode !== "string") {
|
||||
throw new Error("Invalid permissions.defaultMode: expected a string.");
|
||||
logger.error("Ignoring permissions.defaultMode from settings: expected a string.");
|
||||
return "default";
|
||||
}
|
||||
|
||||
const normalized = defaultMode.trim().toLowerCase();
|
||||
if (normalized === "") {
|
||||
throw new Error("Invalid permissions.defaultMode: expected a non-empty string.");
|
||||
logger.error("Ignoring permissions.defaultMode from settings: expected a non-empty string.");
|
||||
return "default";
|
||||
}
|
||||
|
||||
const mapped = PERMISSION_MODE_ALIASES[normalized];
|
||||
if (!mapped) {
|
||||
throw new Error(`Invalid permissions.defaultMode: ${defaultMode}.`);
|
||||
logger.error(`Ignoring permissions.defaultMode from settings: unknown value '${defaultMode}'.`);
|
||||
return "default";
|
||||
}
|
||||
|
||||
if (mapped === "bypassPermissions" && !ALLOW_BYPASS) {
|
||||
throw new Error(
|
||||
"Invalid permissions.defaultMode: bypassPermissions is not available when running as root.",
|
||||
logger.error(
|
||||
"Ignoring permissions.defaultMode from settings: bypassPermissions is not available when running as root.",
|
||||
);
|
||||
return "default";
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the label for the "Always Allow" permission option so the user can see
|
||||
* the exact scope they are committing to. Uses the SDK-provided suggestions
|
||||
* when available (e.g. `Bash(npm test:*)`) and falls back to naming the whole
|
||||
* tool so "Always Allow" is never a blank check without disclosure.
|
||||
*/
|
||||
export function describeAlwaysAllow(
|
||||
suggestions: PermissionUpdate[] | undefined,
|
||||
toolName: string,
|
||||
): string {
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
return `Always Allow all ${toolName}`;
|
||||
}
|
||||
|
||||
const ruleLabels: string[] = [];
|
||||
const directories: string[] = [];
|
||||
|
||||
for (const update of suggestions) {
|
||||
if (update.type === "addRules" && update.behavior === "allow") {
|
||||
for (const rule of update.rules) {
|
||||
ruleLabels.push(
|
||||
rule.ruleContent ? `${rule.toolName}(${rule.ruleContent})` : `all ${rule.toolName}`,
|
||||
);
|
||||
}
|
||||
} else if (update.type === "addDirectories") {
|
||||
directories.push(...update.directories);
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (ruleLabels.length > 0) {
|
||||
parts.push(ruleLabels.join(", "));
|
||||
}
|
||||
if (directories.length > 0) {
|
||||
parts.push(`access to ${directories.join(", ")}`);
|
||||
}
|
||||
|
||||
if (parts.length === 0) {
|
||||
return `Always Allow all ${toolName}`;
|
||||
}
|
||||
|
||||
return `Always Allow ${parts.join(" and ")}`;
|
||||
}
|
||||
|
||||
// Implement the ACP Agent interface
|
||||
export class ClaudeAcpAgent implements Agent {
|
||||
sessions: {
|
||||
@@ -522,7 +646,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return response;
|
||||
}
|
||||
|
||||
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
async resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
const result = await this.getOrCreateSession(params);
|
||||
|
||||
// Needs to happen after we return the session
|
||||
@@ -588,6 +712,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
let lastAssistantTotalUsage: number | null = null;
|
||||
let lastAssistantUsage: UsageSnapshot | null = null;
|
||||
let lastAssistantModel: string | null = null;
|
||||
// When the Claude SDK classifies a turn as failed (e.g. rate limit, auth
|
||||
// problem, billing), it sets a categorical `error` field on the
|
||||
// `SDKAssistantMessage` that precedes the final `result` message. We
|
||||
// capture it here so the subsequent `RequestError.internalError` can
|
||||
// forward it to clients as structured `data`, sparing them from
|
||||
// pattern-matching on the human-readable message text.
|
||||
let lastAssistantError: SDKAssistantMessageError | undefined;
|
||||
|
||||
const userMessage = promptToClaude(params);
|
||||
|
||||
@@ -773,7 +904,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(undefined, message.result);
|
||||
throw RequestError.internalError(
|
||||
errorKindData(lastAssistantError),
|
||||
message.result,
|
||||
);
|
||||
}
|
||||
// For local-only commands (no model invocation), the result
|
||||
// text is the command output — forward it to the client.
|
||||
@@ -798,7 +932,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(
|
||||
undefined,
|
||||
errorKindData(lastAssistantError),
|
||||
message.errors.join(", ") || message.subtype,
|
||||
);
|
||||
}
|
||||
@@ -810,7 +944,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "error_max_structured_output_retries":
|
||||
if (message.is_error) {
|
||||
throw RequestError.internalError(
|
||||
undefined,
|
||||
errorKindData(lastAssistantError),
|
||||
message.errors.join(", ") || message.subtype,
|
||||
);
|
||||
}
|
||||
@@ -926,6 +1060,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (message.message.model && message.message.model !== "<synthetic>") {
|
||||
lastAssistantModel = message.message.model;
|
||||
}
|
||||
if (message.error) {
|
||||
lastAssistantError = message.error;
|
||||
}
|
||||
}
|
||||
|
||||
// Slash commands like /compact can generate invalid output... doesn't match
|
||||
@@ -1077,7 +1214,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
await Promise.all(Object.keys(this.sessions).map((id) => this.teardownSession(id)));
|
||||
}
|
||||
|
||||
async unstable_closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
@@ -1088,11 +1225,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
if (!this.sessions[params.sessionId]) {
|
||||
const session = this.sessions[params.sessionId];
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
await this.sessions[params.sessionId].query.setModel(params.modelId);
|
||||
await this.updateConfigOption(params.sessionId, "model", params.modelId);
|
||||
// Resolve aliases (e.g. "opus", "opus[1m]") to canonical model IDs so
|
||||
// downstream lookups in modelInfos succeed and the effort option isn't
|
||||
// silently dropped.
|
||||
const resolved = resolveModelPreference(session.modelInfos, params.modelId);
|
||||
const modelId = resolved?.value ?? params.modelId;
|
||||
await session.query.setModel(modelId);
|
||||
await this.updateConfigOption(params.sessionId, "model", modelId);
|
||||
}
|
||||
|
||||
async setSessionMode(params: SetSessionModeRequest): Promise<SetSessionModeResponse> {
|
||||
@@ -1162,14 +1305,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
} else if (params.configId === "model") {
|
||||
await this.sessions[params.sessionId].query.setModel(resolvedValue);
|
||||
}
|
||||
// Effort SDK sync is handled inside applyConfigOptionValue so that direct
|
||||
// effort changes and effort changes induced by a model switch go through
|
||||
// the same path.
|
||||
|
||||
this.syncSessionConfigState(session, params.configId, params.value);
|
||||
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === params.configId && typeof o.currentValue === "string"
|
||||
? { ...o, currentValue: resolvedValue }
|
||||
: o,
|
||||
);
|
||||
await this.applyConfigOptionValue(params.sessionId, session, params.configId, resolvedValue);
|
||||
|
||||
return { configOptions: session.configOptions };
|
||||
}
|
||||
@@ -1186,8 +1326,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
default:
|
||||
throw new Error("Invalid Mode");
|
||||
}
|
||||
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
throw new Error("Session not found");
|
||||
}
|
||||
if (!session.modes.availableModes.some((mode) => mode.id === modeId)) {
|
||||
throw new Error(`Mode ${modeId} is not available in this session`);
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sessions[sessionId].query.setPermissionMode(modeId);
|
||||
await session.query.setPermissionMode(modeId);
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (!error.message) {
|
||||
@@ -1206,9 +1355,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const messages = await getSessionMessages(sessionId);
|
||||
|
||||
for (const message of messages) {
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
let content: unknown = message.message.content;
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
if (message.message.role === "user") {
|
||||
content = stripLocalCommandMetadata(content);
|
||||
if (content === null) continue;
|
||||
}
|
||||
|
||||
for (const notification of toAcpNotifications(
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
message.message.content,
|
||||
content,
|
||||
// @ts-expect-error - untyped in SDK but we handle all of these
|
||||
message.message.role,
|
||||
sessionId,
|
||||
@@ -1238,6 +1395,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
canUseTool(sessionId: string): CanUseTool {
|
||||
return async (toolName, toolInput, { signal, suggestions, toolUseID }) => {
|
||||
const alwaysAllowLabel = describeAlwaysAllow(suggestions, toolName);
|
||||
const supportsTerminalOutput = this.clientCapabilities?._meta?.["terminal_output"] === true;
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) {
|
||||
@@ -1248,7 +1406,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const options = [
|
||||
const optionsAll = [
|
||||
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
||||
{
|
||||
kind: "allow_always",
|
||||
@@ -1259,12 +1417,20 @@ export class ClaudeAcpAgent implements Agent {
|
||||
{ kind: "reject_once", name: "No, keep planning", optionId: "plan" },
|
||||
];
|
||||
if (ALLOW_BYPASS) {
|
||||
options.unshift({
|
||||
optionsAll.unshift({
|
||||
kind: "allow_always",
|
||||
name: "Yes, and bypass permissions",
|
||||
optionId: "bypassPermissions",
|
||||
});
|
||||
}
|
||||
// Filter against the session's currently-advertised modes so we never
|
||||
// present options the active model can't honor (e.g. `auto` on Haiku).
|
||||
// `bypassPermissions` is already covered by `availableModes` via
|
||||
// `buildAvailableModes`/`ALLOW_BYPASS`. The `plan` option is a
|
||||
// "keep planning" reject path; it's always present in `availableModes`.
|
||||
const options = optionsAll.filter((o) =>
|
||||
session.modes.availableModes.some((m) => m.id === o.optionId),
|
||||
);
|
||||
|
||||
const response = await this.client.requestPermission({
|
||||
options,
|
||||
@@ -1283,27 +1449,30 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (signal.aborted || response.outcome?.outcome === "cancelled") {
|
||||
throw new Error("Tool use aborted");
|
||||
}
|
||||
const selectedMode =
|
||||
response.outcome?.outcome === "selected" ? response.outcome.optionId : undefined;
|
||||
const selectedModeWasOffered = options.some((option) => option.optionId === selectedMode);
|
||||
if (
|
||||
response.outcome?.outcome === "selected" &&
|
||||
(response.outcome.optionId === "default" ||
|
||||
response.outcome.optionId === "acceptEdits" ||
|
||||
response.outcome.optionId === "auto" ||
|
||||
response.outcome.optionId === "bypassPermissions")
|
||||
selectedModeWasOffered &&
|
||||
(selectedMode === "default" ||
|
||||
selectedMode === "acceptEdits" ||
|
||||
selectedMode === "auto" ||
|
||||
selectedMode === "bypassPermissions")
|
||||
) {
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: response.outcome.optionId,
|
||||
currentModeId: selectedMode,
|
||||
},
|
||||
});
|
||||
await this.updateConfigOption(sessionId, "mode", response.outcome.optionId);
|
||||
await this.updateConfigOption(sessionId, "mode", selectedMode);
|
||||
|
||||
return {
|
||||
behavior: "allow",
|
||||
updatedInput: toolInput,
|
||||
updatedPermissions: suggestions ?? [
|
||||
{ type: "setMode", mode: response.outcome.optionId, destination: "session" },
|
||||
{ type: "setMode", mode: selectedMode, destination: "session" },
|
||||
],
|
||||
};
|
||||
} else {
|
||||
@@ -1328,7 +1497,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
options: [
|
||||
{
|
||||
kind: "allow_always",
|
||||
name: "Always Allow",
|
||||
name: alwaysAllowLabel,
|
||||
optionId: "allow_always",
|
||||
},
|
||||
{ kind: "allow_once", name: "Allow", optionId: "allow" },
|
||||
@@ -1401,11 +1570,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return;
|
||||
|
||||
this.syncSessionConfigState(session, configId, value);
|
||||
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
await this.applyConfigOptionValue(sessionId, session, configId, value);
|
||||
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
@@ -1416,9 +1581,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
});
|
||||
}
|
||||
|
||||
private syncSessionConfigState(session: Session, configId: string, value: string): void {
|
||||
private async applyConfigOptionValue(
|
||||
sessionId: string,
|
||||
session: Session,
|
||||
configId: string,
|
||||
value: string,
|
||||
): Promise<void> {
|
||||
if (configId === "mode") {
|
||||
session.modes = { ...session.modes, currentModeId: value };
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
} else if (configId === "model") {
|
||||
if (session.models.currentModelId !== value) {
|
||||
// The cached context window was learned for the previous model; reset
|
||||
@@ -1428,6 +1601,83 @@ export class ClaudeAcpAgent implements Agent {
|
||||
session.contextWindowSize = inferContextWindowFromModel(value) ?? DEFAULT_CONTEXT_WINDOW;
|
||||
}
|
||||
session.models = { ...session.models, currentModelId: value };
|
||||
|
||||
// Recompute availableModes for the new model and clamp the current
|
||||
// mode if the SDK no longer offers it (today: "auto" on Haiku).
|
||||
// `ModelInfo.supportsAutoMode` is the canonical SDK signal.
|
||||
const newModelInfo = session.modelInfos.find((m) => m.value === value);
|
||||
const newAvailableModes = buildAvailableModes(newModelInfo);
|
||||
// Capture BEFORE mutating session.modes so the log message reflects
|
||||
// the invalidated mode rather than "default".
|
||||
const previousModeId = session.modes.currentModeId;
|
||||
let modeDowngraded = false;
|
||||
if (!newAvailableModes.some((m) => m.id === previousModeId)) {
|
||||
session.modes = {
|
||||
availableModes: newAvailableModes,
|
||||
currentModeId: "default",
|
||||
};
|
||||
try {
|
||||
await session.query.setPermissionMode("default");
|
||||
} catch (err) {
|
||||
// Failing the entire model switch over a bookkeeping sync error is
|
||||
// worse UX than logging and continuing; the user explicitly asked
|
||||
// to change models. The next setPermissionMode from the user will
|
||||
// either succeed or surface a fresh error.
|
||||
this.logger.error(
|
||||
`Failed to sync permissionMode to "default" after model switch invalidated "${previousModeId}":`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
modeDowngraded = true;
|
||||
} else {
|
||||
session.modes = { ...session.modes, availableModes: newAvailableModes };
|
||||
}
|
||||
|
||||
// Rebuild config options since effort levels depend on the selected model
|
||||
const effortOpt = session.configOptions.find((o) => o.id === "effort");
|
||||
const currentEffort =
|
||||
typeof effortOpt?.currentValue === "string" ? effortOpt.currentValue : undefined;
|
||||
session.configOptions = buildConfigOptions(
|
||||
session.modes,
|
||||
session.models,
|
||||
session.modelInfos,
|
||||
currentEffort,
|
||||
);
|
||||
|
||||
// Sync effort with the SDK if it changed after the model switch
|
||||
const newEffortOpt = session.configOptions.find((o) => o.id === "effort");
|
||||
const newEffort =
|
||||
typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
||||
if (newEffort !== currentEffort) {
|
||||
await session.query.applyFlagSettings({
|
||||
effortLevel: newEffort as Settings["effortLevel"],
|
||||
});
|
||||
}
|
||||
|
||||
// Emit current_mode_update only after session.modes AND
|
||||
// session.configOptions have been fully reconciled. This way, a failure
|
||||
// in the configOptions/effort rebuild above can't leave the client with
|
||||
// a clamped currentModeId but stale configOptions, and the notification
|
||||
// still precedes the caller's config_option_update so order-sensitive
|
||||
// clients update currentModeId before re-rendering the option list.
|
||||
if (modeDowngraded) {
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "default",
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
session.configOptions = session.configOptions.map((o) =>
|
||||
o.id === configId && typeof o.currentValue === "string" ? { ...o, currentValue: value } : o,
|
||||
);
|
||||
if (configId === "effort") {
|
||||
await session.query.applyFlagSettings({
|
||||
effortLevel: value as Settings["effortLevel"],
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1529,15 +1779,22 @@ export class ClaudeAcpAgent implements Agent {
|
||||
systemPrompt = customPrompt;
|
||||
} else if (
|
||||
typeof customPrompt === "object" &&
|
||||
"append" in customPrompt &&
|
||||
typeof customPrompt.append === "string"
|
||||
customPrompt !== null &&
|
||||
!Array.isArray(customPrompt)
|
||||
) {
|
||||
systemPrompt.append = customPrompt.append;
|
||||
// Forward all preset options (append, excludeDynamicSections, and
|
||||
// anything the SDK adds later) while locking type/preset.
|
||||
systemPrompt = {
|
||||
...(customPrompt as object),
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
} as Options["systemPrompt"];
|
||||
}
|
||||
}
|
||||
|
||||
const permissionMode = resolvePermissionMode(
|
||||
settingsManager.getSettings().permissions?.defaultMode,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
// Extract options from _meta if provided
|
||||
@@ -1549,6 +1806,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
? parseInt(process.env.MAX_THINKING_TOKENS, 10)
|
||||
: undefined;
|
||||
|
||||
// Parse model configuration from environment (e.g. Bedrock model overrides)
|
||||
const modelConfig = parseModelConfig(process.env.CLAUDE_MODEL_CONFIG);
|
||||
|
||||
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
|
||||
const disallowedTools = ["AskUserQuestion"];
|
||||
|
||||
@@ -1567,6 +1827,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
settingSources: ["user", "project", "local"],
|
||||
...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
|
||||
...userProvidedOptions,
|
||||
// CLAUDE_MODEL_CONFIG env var is a fallback for model
|
||||
// configuration (e.g. Bedrock model ID overrides). When the caller
|
||||
// provides settings via _meta, we intentionally ignore the env var —
|
||||
// the caller is assumed to have full control over model configuration.
|
||||
...(!userProvidedOptions?.settings &&
|
||||
modelConfig && {
|
||||
settings: {
|
||||
...(modelConfig.modelOverrides && { modelOverrides: modelConfig.modelOverrides }),
|
||||
...(modelConfig.availableModels && { availableModels: modelConfig.availableModels }),
|
||||
},
|
||||
}),
|
||||
env: {
|
||||
...process.env,
|
||||
...userProvidedOptions?.env,
|
||||
@@ -1583,7 +1854,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
||||
permissionMode,
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
|
||||
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE ?? (await claudeCliPath()),
|
||||
extraArgs: {
|
||||
...userProvidedOptions?.extraArgs,
|
||||
"replay-user-messages": "",
|
||||
@@ -1662,50 +1933,69 @@ export class ClaudeAcpAgent implements Agent {
|
||||
);
|
||||
}
|
||||
|
||||
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
|
||||
const models = await getAvailableModels(
|
||||
q,
|
||||
initializationResult.models,
|
||||
settingsManager,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
const availableModes = [
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Use a model classifier to approve/deny permission prompts",
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
description: "Standard behavior, prompts for dangerous operations",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Auto-accept file edit operations",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan Mode",
|
||||
description: "Planning mode, no actual tool execution",
|
||||
},
|
||||
{
|
||||
id: "dontAsk",
|
||||
name: "Don't Ask",
|
||||
description: "Don't prompt for permissions, deny if not pre-approved",
|
||||
},
|
||||
];
|
||||
// Only works in non-root mode
|
||||
if (ALLOW_BYPASS) {
|
||||
availableModes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass Permissions",
|
||||
description: "Bypass all permission checks",
|
||||
});
|
||||
// Gate `auto` (and future model-specific modes) on the resolved model's
|
||||
// `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
|
||||
const currentModelInfo = initializationResult.models.find(
|
||||
(m) => m.value === models.currentModelId,
|
||||
);
|
||||
const availableModes = buildAvailableModes(currentModelInfo);
|
||||
|
||||
// Clamp `permissionMode` if the resolved session does not offer it. The
|
||||
// common case is `permissions.defaultMode: "auto"` resolving to a model
|
||||
// that does not support auto mode (e.g. Haiku); without this clamp the
|
||||
// SDK would later throw `"auto mode unavailable for this model"` from
|
||||
// `setPermissionMode`. Keep `permissionMode` as the resolved user intent
|
||||
// (matches what was passed into `options.permissionMode` above) and use
|
||||
// `effectiveMode` for the post-clamp value the session actually runs in.
|
||||
let effectiveMode: PermissionMode = permissionMode;
|
||||
if (!availableModes.some((m) => m.id === effectiveMode)) {
|
||||
if (effectiveMode === "auto") {
|
||||
this.logger.error(
|
||||
`permissions.defaultMode "auto" is not available for model ` +
|
||||
`"${models.currentModelId}"; falling back to "default".`,
|
||||
);
|
||||
} else {
|
||||
this.logger.error(
|
||||
`permissions.defaultMode "${effectiveMode}" is not available in ` +
|
||||
`this session; falling back to "default".`,
|
||||
);
|
||||
}
|
||||
effectiveMode = "default";
|
||||
// Sync the SDK so it doesn't keep "auto" cached internally. Wrapped in
|
||||
// try/catch since failing here would abort session creation entirely.
|
||||
try {
|
||||
await q.setPermissionMode("default");
|
||||
} catch (err) {
|
||||
this.logger.error("Failed to sync clamped permissionMode to SDK:", err);
|
||||
}
|
||||
}
|
||||
|
||||
const modes = {
|
||||
currentModeId: permissionMode,
|
||||
currentModeId: effectiveMode,
|
||||
availableModes,
|
||||
};
|
||||
|
||||
const configOptions = buildConfigOptions(modes, models);
|
||||
const configOptions = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
initializationResult.models,
|
||||
settingsManager.getSettings().effortLevel,
|
||||
);
|
||||
|
||||
// Apply the initial effort level to the SDK so it matches the UI default
|
||||
const initialEffort = configOptions.find((o) => o.id === "effort");
|
||||
if (initialEffort && typeof initialEffort.currentValue === "string") {
|
||||
await q.applyFlagSettings({
|
||||
effortLevel: initialEffort.currentValue as Settings["effortLevel"],
|
||||
});
|
||||
}
|
||||
|
||||
this.sessions[sessionId] = {
|
||||
query: q,
|
||||
@@ -1722,6 +2012,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
modes,
|
||||
models,
|
||||
modelInfos: initializationResult.models,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
@@ -1779,6 +2070,22 @@ function totalTokens(usage: UsageSnapshot): number {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `data` payload attached to a `RequestError.internalError` when we
|
||||
* have a categorical error from the Claude SDK. Returns `undefined` when no
|
||||
* categorical error is available, matching the previous behavior of passing
|
||||
* `undefined` to `RequestError.internalError`.
|
||||
*
|
||||
* The `errorKind` field is a convention for ACP clients to dispatch on
|
||||
* without having to pattern-match the human-readable message text. Clients
|
||||
* that don't understand it fall back to the existing message-based rendering.
|
||||
*/
|
||||
function errorKindData(
|
||||
errorKind: SDKAssistantMessageError | undefined,
|
||||
): { errorKind: SDKAssistantMessageError } | undefined {
|
||||
return errorKind ? { errorKind } : undefined;
|
||||
}
|
||||
|
||||
/** Project a nullable API usage object into our non-null snapshot shape.
|
||||
* Both SDK message_start and assistant message `usage` have `number | null`
|
||||
* cache fields; we coerce absent values to 0 so `totalTokens` never hits
|
||||
@@ -1814,11 +2121,65 @@ function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the list of permission modes the agent will advertise for the given
|
||||
* model. `auto` is gated by `ModelInfo.supportsAutoMode === true`, which is
|
||||
* the SDK's model-level availability signal. `undefined`/`false` both exclude
|
||||
* `auto`. `bypassPermissions` is still gated by `ALLOW_BYPASS`.
|
||||
*/
|
||||
function buildAvailableModes(modelInfo: ModelInfo | undefined): SessionModeState["availableModes"] {
|
||||
const modes: SessionModeState["availableModes"] = [];
|
||||
|
||||
// Only advertise "auto" when the SDK reports the model supports it.
|
||||
if (modelInfo?.supportsAutoMode === true) {
|
||||
modes.push({
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Use a model classifier to approve/deny permission prompts",
|
||||
});
|
||||
}
|
||||
|
||||
modes.push(
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
description: "Standard behavior, prompts for dangerous operations",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Auto-accept file edit operations",
|
||||
},
|
||||
{
|
||||
id: "plan",
|
||||
name: "Plan Mode",
|
||||
description: "Planning mode, no actual tool execution",
|
||||
},
|
||||
{
|
||||
id: "dontAsk",
|
||||
name: "Don't Ask",
|
||||
description: "Don't prompt for permissions, deny if not pre-approved",
|
||||
},
|
||||
);
|
||||
|
||||
if (ALLOW_BYPASS) {
|
||||
modes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass Permissions",
|
||||
description: "Bypass all permission checks",
|
||||
});
|
||||
}
|
||||
|
||||
return modes;
|
||||
}
|
||||
|
||||
function buildConfigOptions(
|
||||
modes: SessionModeState,
|
||||
models: SessionModelState,
|
||||
modelInfos: ModelInfo[],
|
||||
currentEffortLevel?: string,
|
||||
): SessionConfigOption[] {
|
||||
return [
|
||||
const options: SessionConfigOption[] = [
|
||||
{
|
||||
id: "mode",
|
||||
name: "Mode",
|
||||
@@ -1846,6 +2207,46 @@ function buildConfigOptions(
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
// Add effort level option based on the currently selected model
|
||||
const currentModelInfo = modelInfos.find((m) => m.value === models.currentModelId);
|
||||
const supportedLevels = currentModelInfo?.supportsEffort
|
||||
? (currentModelInfo.supportedEffortLevels ?? [])
|
||||
: [];
|
||||
|
||||
if (supportedLevels.length > 0) {
|
||||
const effortOptions = supportedLevels.map((level) => ({
|
||||
value: level,
|
||||
name: level
|
||||
.split(/[_-]/)
|
||||
.map((part) => (part ? part.charAt(0).toUpperCase() + part.slice(1) : part))
|
||||
.join(" "),
|
||||
}));
|
||||
|
||||
// Keep the current level if valid, otherwise prefer xhigh (Claude Code's
|
||||
// recommended default for capable models), then high (the API default).
|
||||
const includes = (l: string) => (supportedLevels as string[]).includes(l);
|
||||
const validEffort =
|
||||
currentEffortLevel && includes(currentEffortLevel)
|
||||
? currentEffortLevel
|
||||
: includes("xhigh")
|
||||
? "xhigh"
|
||||
: includes("high")
|
||||
? "high"
|
||||
: supportedLevels[0];
|
||||
|
||||
options.push({
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue: validEffort,
|
||||
options: effortOptions,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
// Claude Code CLI persists display strings like "opus[1m]" in settings,
|
||||
@@ -1921,10 +2322,27 @@ function resolveModelPreference(models: ModelInfo[], preference: string): ModelI
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
function resolveSettingsModel(
|
||||
models: ModelInfo[],
|
||||
settingsModel: unknown,
|
||||
logger: Logger,
|
||||
): ModelInfo | null {
|
||||
if (settingsModel === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof settingsModel !== "string") {
|
||||
const typeLabel = settingsModel === null ? "null" : typeof settingsModel;
|
||||
logger.error(`Ignoring model from settings: expected a string, got ${typeLabel}.`);
|
||||
return null;
|
||||
}
|
||||
return resolveModelPreference(models, settingsModel);
|
||||
}
|
||||
|
||||
async function getAvailableModels(
|
||||
query: Query,
|
||||
models: ModelInfo[],
|
||||
settingsManager: SettingsManager,
|
||||
logger: Logger,
|
||||
): Promise<SessionModelState> {
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
@@ -1939,8 +2357,8 @@ async function getAvailableModels(
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
}
|
||||
} else if (settings.model) {
|
||||
const match = resolveModelPreference(models, settings.model);
|
||||
} else {
|
||||
const match = resolveSettingsModel(models, settings.model, logger);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
}
|
||||
@@ -2435,6 +2853,20 @@ function inferContextWindowFromModel(model: string): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseModelConfig(
|
||||
raw: string | undefined,
|
||||
): { modelOverrides?: Record<string, string>; availableModels?: string[] } | undefined {
|
||||
if (!raw) return undefined;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
||||
throw new Error("CLAUDE_MODEL_CONFIG must be a JSON object");
|
||||
}
|
||||
const result: { modelOverrides?: Record<string, string>; availableModels?: string[] } = {};
|
||||
if (parsed.modelOverrides !== undefined) result.modelOverrides = parsed.modelOverrides;
|
||||
if (parsed.availableModels !== undefined) result.availableModels = parsed.availableModels;
|
||||
return Object.keys(result).length > 0 ? result : undefined;
|
||||
}
|
||||
|
||||
function getMatchingModelUsage(modelUsage: Record<string, ModelUsage>, currentModel: string) {
|
||||
let bestKey: string | null = null;
|
||||
let bestLen = 0;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
// Export the main agent class and utilities for library usage
|
||||
export {
|
||||
ClaudeAcpAgent,
|
||||
isLocalCommandMetadata,
|
||||
stripLocalCommandMetadata,
|
||||
runAcp,
|
||||
toAcpNotifications,
|
||||
streamEventToAcpNotifications,
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface ClaudeCodeSettings {
|
||||
permissions?: PermissionSettings;
|
||||
env?: Record<string, string>;
|
||||
model?: string;
|
||||
effortLevel?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -188,6 +189,10 @@ export class SettingsManager {
|
||||
merged.model = settings.model;
|
||||
}
|
||||
|
||||
if (settings.effortLevel !== undefined) {
|
||||
merged.effortLevel = settings.effortLevel;
|
||||
}
|
||||
|
||||
if (settings.permissions?.defaultMode !== undefined) {
|
||||
merged.permissions = {
|
||||
...merged.permissions,
|
||||
|
||||
@@ -145,12 +145,12 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("throws when permissions.defaultMode is not a string", async () => {
|
||||
it("falls back to 'default' when permissions.defaultMode is invalid", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
defaultMode: 123,
|
||||
defaultMode: "not-a-real-mode",
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -158,18 +158,208 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQuery();
|
||||
const { getCapturedOptions } = mockQuery();
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
await expect(
|
||||
(agent as any).createSession({
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
// Bad mode is ignored at the usage site; session creation must not throw.
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("ignores model from settings when it is not a string", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
model: 123,
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { setModelSpy } = mockQuery();
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
// Bad model is ignored at the usage site; falls back to the first SDK model.
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
expect(response.models.currentModelId).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
describe("auto mode availability per model", () => {
|
||||
function mockQueryWithModels(models: any[]): {
|
||||
getCapturedOptions: () => any;
|
||||
setModelSpy: ReturnType<typeof vi.fn>;
|
||||
setPermissionModeSpy: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
let capturedOptions: any;
|
||||
const setModelSpy = vi.fn();
|
||||
const setPermissionModeSpy = vi.fn();
|
||||
querySpy.mockImplementation(({ options }: any) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
setPermissionMode: setPermissionModeSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
return {
|
||||
getCapturedOptions: () => capturedOptions,
|
||||
setModelSpy,
|
||||
setPermissionModeSpy,
|
||||
};
|
||||
}
|
||||
|
||||
it("omits `auto` from availableModes when the resolved model lacks supportsAutoMode", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{
|
||||
value: "claude-haiku-4-5",
|
||||
displayName: "Claude Haiku",
|
||||
description: "Fast",
|
||||
// supportsAutoMode intentionally omitted
|
||||
},
|
||||
]);
|
||||
|
||||
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 },
|
||||
}),
|
||||
).rejects.toThrow("Invalid permissions.defaultMode");
|
||||
});
|
||||
|
||||
const modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
|
||||
expect(modeIds).not.toContain("auto");
|
||||
expect(modeIds).toEqual(
|
||||
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes `auto` when the resolved model has supportsAutoMode: true", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
]);
|
||||
|
||||
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 modeIds: string[] = response.modes.availableModes.map((m: any) => m.id);
|
||||
expect(modeIds).toContain("auto");
|
||||
});
|
||||
|
||||
it("clamps permissions.defaultMode='auto' to 'default' on a model that lacks supportsAutoMode", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "auto" } }),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([
|
||||
{
|
||||
value: "claude-haiku-4-5",
|
||||
displayName: "Claude Haiku",
|
||||
description: "Fast",
|
||||
// supportsAutoMode intentionally omitted
|
||||
},
|
||||
]);
|
||||
|
||||
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
try {
|
||||
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 },
|
||||
});
|
||||
|
||||
// Options.permissionMode is built before init resolves, so it still
|
||||
// carries the user-typed value; the SDK was synced via
|
||||
// setPermissionMode after we discovered the model can't honor it.
|
||||
expect(getCapturedOptions().permissionMode).toBe("auto");
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
expect(response.modes.availableModes.map((m: any) => m.id)).not.toContain("auto");
|
||||
|
||||
// A descriptive warning was logged so operators see the clamp.
|
||||
const messages = errorSpy.mock.calls.map((c) => c.join(" "));
|
||||
expect(messages.some((m) => m.includes("auto") && m.includes("claude-haiku-4-5"))).toBe(
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
errorSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not clamp permissions.defaultMode='auto' on a model that supports auto", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "auto" } }),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const { getCapturedOptions, setPermissionModeSpy } = mockQueryWithModels([
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
]);
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("auto");
|
||||
expect(setPermissionModeSpy).not.toHaveBeenCalled();
|
||||
expect(response.modes.currentModeId).toBe("auto");
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
|
||||
@@ -24,7 +24,15 @@ import {
|
||||
toolUpdateFromToolResult,
|
||||
toolUpdateFromEditToolResponse,
|
||||
} from "../tools.js";
|
||||
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent, claudeCliPath } from "../acp-agent.js";
|
||||
import {
|
||||
toAcpNotifications,
|
||||
promptToClaude,
|
||||
isLocalCommandMetadata,
|
||||
stripLocalCommandMetadata,
|
||||
ClaudeAcpAgent,
|
||||
claudeCliPath,
|
||||
describeAlwaysAllow,
|
||||
} from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
@@ -221,14 +229,6 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
name: "compact",
|
||||
});
|
||||
|
||||
// Error case (no previous message)
|
||||
await connection.prompt({
|
||||
prompt: [{ type: "text", text: "/compact" }],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toBe("Compacting...\n\nCompacting completed.");
|
||||
|
||||
// Send something
|
||||
await connection.prompt({
|
||||
prompt: [{ type: "text", text: "Hi" }],
|
||||
@@ -237,12 +237,11 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
// Clear response
|
||||
client.takeReceivedText();
|
||||
|
||||
// Test with instruction
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
{
|
||||
type: "text",
|
||||
text: "/compact greeting",
|
||||
text: "/compact",
|
||||
},
|
||||
],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
@@ -1119,6 +1118,113 @@ describe("toolUpdateFromEditToolResponse", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripLocalCommandMetadata", () => {
|
||||
it("returns null for strings that are pure marker metadata", () => {
|
||||
expect(stripLocalCommandMetadata("<command-name>/model</command-name>")).toBeNull();
|
||||
expect(
|
||||
stripLocalCommandMetadata("<local-command-stdout>out</local-command-stdout>"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
stripLocalCommandMetadata("<local-command-stderr>err</local-command-stderr>"),
|
||||
).toBeNull();
|
||||
expect(
|
||||
stripLocalCommandMetadata(
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("returns the string unchanged for real content", () => {
|
||||
expect(stripLocalCommandMetadata("hi")).toBe("hi");
|
||||
expect(stripLocalCommandMetadata("please run /model with args")).toBe(
|
||||
"please run /model with args",
|
||||
);
|
||||
});
|
||||
|
||||
// Regression: in the original bug report the entire /model preamble and
|
||||
// the user's real "hi" prompt were concatenated into a single message.
|
||||
// We want to strip the marker tags and preserve the real prose, not drop
|
||||
// the whole message.
|
||||
it("strips marker tags from mixed-content strings, preserving real prose", () => {
|
||||
const mixed =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>" +
|
||||
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>" +
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus[1m]</command-args>" +
|
||||
"<local-command-stdout>Set model to opus[1m] (claude-opus-4-7[1m])</local-command-stdout>" +
|
||||
"hi";
|
||||
const stripped = stripLocalCommandMetadata(mixed);
|
||||
expect(typeof stripped).toBe("string");
|
||||
expect(stripped as string).not.toContain("<command-name>");
|
||||
expect(stripped as string).not.toContain("<command-message>");
|
||||
expect(stripped as string).not.toContain("<command-args>");
|
||||
expect(stripped as string).not.toContain("<local-command-stdout>");
|
||||
expect((stripped as string).trimEnd()).toMatch(/hi$/);
|
||||
});
|
||||
|
||||
it("drops marker-only blocks from mixed arrays, keeping real blocks", () => {
|
||||
const result = stripLocalCommandMetadata([
|
||||
{ type: "text", text: "<command-name>/model</command-name>" },
|
||||
{ type: "text", text: "<local-command-stdout>ok</local-command-stdout>" },
|
||||
{ type: "text", text: "hi" },
|
||||
]);
|
||||
expect(result).toEqual([{ type: "text", text: "hi" }]);
|
||||
});
|
||||
|
||||
it("returns null when every block is a marker", () => {
|
||||
expect(
|
||||
stripLocalCommandMetadata([
|
||||
{ type: "text", text: "<command-name>/model</command-name>" },
|
||||
{ type: "text", text: "<local-command-stdout>ok</local-command-stdout>" },
|
||||
]),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("strips tags inside a text block while keeping the trailing prose", () => {
|
||||
const result = stripLocalCommandMetadata([
|
||||
{
|
||||
type: "text",
|
||||
text: "<command-name>/model</command-name><local-command-stdout>ok</local-command-stdout>hi",
|
||||
},
|
||||
]);
|
||||
expect(result).toEqual([{ type: "text", text: "hi" }]);
|
||||
});
|
||||
|
||||
it("leaves non-text blocks alone", () => {
|
||||
const image = { type: "image", source: { type: "base64", data: "", media_type: "image/png" } };
|
||||
const result = stripLocalCommandMetadata([
|
||||
{ type: "text", text: "<command-name>/model</command-name>" },
|
||||
image,
|
||||
]);
|
||||
expect(result).toEqual([image]);
|
||||
});
|
||||
|
||||
it("handles null/undefined/non-container shapes", () => {
|
||||
expect(stripLocalCommandMetadata(null)).toBeNull();
|
||||
expect(stripLocalCommandMetadata(undefined)).toBeUndefined();
|
||||
expect(stripLocalCommandMetadata({ arbitrary: "object" })).toEqual({ arbitrary: "object" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLocalCommandMetadata", () => {
|
||||
it("is true when stripping leaves nothing", () => {
|
||||
expect(isLocalCommandMetadata("<command-name>/model</command-name>")).toBe(true);
|
||||
expect(
|
||||
isLocalCommandMetadata([{ type: "text", text: "<command-name>/model</command-name>" }]),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false when real content survives stripping", () => {
|
||||
expect(isLocalCommandMetadata("hi")).toBe(false);
|
||||
expect(isLocalCommandMetadata("<command-name>/model</command-name>hi")).toBe(false);
|
||||
expect(
|
||||
isLocalCommandMetadata([
|
||||
{ type: "text", text: "<command-name>/model</command-name>" },
|
||||
{ type: "text", text: "hi" },
|
||||
]),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape markdown", () => {
|
||||
it("should escape markdown characters", () => {
|
||||
let text = "Hello *world*!";
|
||||
@@ -1195,7 +1301,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
||||
});
|
||||
|
||||
const { value } = await q.next();
|
||||
expect(value).toMatchObject({ type: "system", subtype: "init", session_id: sessionId });
|
||||
expect(value).toMatchObject({ type: "system", session_id: sessionId });
|
||||
}, 10000);
|
||||
});
|
||||
|
||||
@@ -1263,6 +1369,81 @@ describe("permission requests", () => {
|
||||
expect(Array.isArray(requestStructure.toolCall.content)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
describe("describeAlwaysAllow", () => {
|
||||
it("falls back to naming the whole tool when no suggestions are provided", () => {
|
||||
expect(describeAlwaysAllow(undefined, "Bash")).toBe("Always Allow all Bash");
|
||||
expect(describeAlwaysAllow([], "Read")).toBe("Always Allow all Read");
|
||||
});
|
||||
|
||||
it("includes the scoped rule content from a suggestion", () => {
|
||||
const label = describeAlwaysAllow(
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Bash", ruleContent: "npm test:*" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Bash",
|
||||
);
|
||||
expect(label).toBe("Always Allow Bash(npm test:*)");
|
||||
});
|
||||
|
||||
it("indicates a tool-wide rule when the suggestion has no ruleContent", () => {
|
||||
const label = describeAlwaysAllow(
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Read" }],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Read",
|
||||
);
|
||||
expect(label).toBe("Always Allow all Read");
|
||||
});
|
||||
|
||||
it("joins multiple rules and directory suggestions", () => {
|
||||
const label = describeAlwaysAllow(
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [
|
||||
{ toolName: "Bash", ruleContent: "git status" },
|
||||
{ toolName: "Bash", ruleContent: "git diff:*" },
|
||||
],
|
||||
behavior: "allow",
|
||||
destination: "session",
|
||||
},
|
||||
{
|
||||
type: "addDirectories",
|
||||
directories: ["/tmp/work"],
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Bash",
|
||||
);
|
||||
expect(label).toBe("Always Allow Bash(git status), Bash(git diff:*) and access to /tmp/work");
|
||||
});
|
||||
|
||||
it("ignores non-allow rules and falls back when nothing is left", () => {
|
||||
const label = describeAlwaysAllow(
|
||||
[
|
||||
{
|
||||
type: "addRules",
|
||||
rules: [{ toolName: "Bash", ruleContent: "rm -rf:*" }],
|
||||
behavior: "deny",
|
||||
destination: "session",
|
||||
},
|
||||
],
|
||||
"Bash",
|
||||
);
|
||||
expect(label).toBe("Always Allow all Bash");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("stop reason propagation", () => {
|
||||
@@ -1336,6 +1517,7 @@ describe("stop reason propagation", () => {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -1478,6 +1660,7 @@ describe("stop reason propagation", () => {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -1527,6 +1710,87 @@ describe("stop reason propagation", () => {
|
||||
}),
|
||||
).rejects.toThrow("Internal error");
|
||||
});
|
||||
|
||||
it("forwards SDKAssistantMessage.error as structured data on internal errors", async () => {
|
||||
const agent = createMockAgent();
|
||||
const assistantMessage: SDKAssistantMessage = {
|
||||
type: "assistant",
|
||||
parent_tool_use_id: null,
|
||||
error: "rate_limit",
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
id: "msg-1",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
container: null,
|
||||
model: "claude-sonnet-4-20250514",
|
||||
content: [],
|
||||
stop_reason: "stop_sequence",
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
server_tool_use: { web_search_requests: 0, web_fetch_requests: 0 },
|
||||
service_tier: null,
|
||||
cache_creation: {
|
||||
ephemeral_1h_input_tokens: 0,
|
||||
ephemeral_5m_input_tokens: 0,
|
||||
},
|
||||
} as any,
|
||||
} as any,
|
||||
};
|
||||
|
||||
injectSession(agent, [
|
||||
assistantMessage,
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "You've hit your limit · resets 8pm",
|
||||
}),
|
||||
]);
|
||||
|
||||
const err = await agent
|
||||
.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
})
|
||||
.then(
|
||||
() => null,
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).not.toBeNull();
|
||||
expect((err as { data: unknown }).data).toEqual({ errorKind: "rate_limit" });
|
||||
});
|
||||
|
||||
it("omits errorKind data when no SDKAssistantMessage.error was observed", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, [
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "Something went wrong",
|
||||
}),
|
||||
]);
|
||||
|
||||
const err = await agent
|
||||
.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
})
|
||||
.then(
|
||||
() => null,
|
||||
(e) => e,
|
||||
);
|
||||
|
||||
expect(err).not.toBeNull();
|
||||
expect((err as { data: unknown }).data).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/close", () => {
|
||||
@@ -1554,6 +1818,7 @@ describe("session/close", () => {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -1578,7 +1843,7 @@ describe("session/close", () => {
|
||||
|
||||
expect(agent.sessions["session-1"]).toBeDefined();
|
||||
|
||||
const result = await agent.unstable_closeSession({ sessionId: "session-1" });
|
||||
const result = await agent.closeSession({ sessionId: "session-1" });
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(agent.sessions["session-1"]).toBeUndefined();
|
||||
@@ -1592,7 +1857,7 @@ describe("session/close", () => {
|
||||
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
|
||||
await agent.unstable_closeSession({ sessionId: "session-2" });
|
||||
await agent.closeSession({ sessionId: "session-2" });
|
||||
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
});
|
||||
@@ -1600,7 +1865,7 @@ describe("session/close", () => {
|
||||
it("should throw when closing a non-existent session", async () => {
|
||||
const agent = createMockAgent();
|
||||
|
||||
await expect(agent.unstable_closeSession({ sessionId: "non-existent" })).rejects.toThrow(
|
||||
await expect(agent.closeSession({ sessionId: "non-existent" })).rejects.toThrow(
|
||||
"Session not found",
|
||||
);
|
||||
});
|
||||
@@ -1610,7 +1875,7 @@ describe("session/close", () => {
|
||||
injectSession(agent, "session-a");
|
||||
injectSession(agent, "session-b");
|
||||
|
||||
await agent.unstable_closeSession({ sessionId: "session-a" });
|
||||
await agent.closeSession({ sessionId: "session-a" });
|
||||
|
||||
expect(agent.sessions["session-a"]).toBeUndefined();
|
||||
expect(agent.sessions["session-b"]).toBeDefined();
|
||||
@@ -1649,6 +1914,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
}),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -1671,7 +1937,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "s1", { cwd: "/project" });
|
||||
|
||||
await agent.unstable_resumeSession({
|
||||
await agent.resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [],
|
||||
@@ -1694,7 +1960,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
.mockRejectedValue(new Error("mock"));
|
||||
|
||||
await expect(
|
||||
agent.unstable_resumeSession({ sessionId: "s1", cwd: "/new", mcpServers: [] }),
|
||||
agent.resumeSession({ sessionId: "s1", cwd: "/new", mcpServers: [] }),
|
||||
).rejects.toThrow("mock");
|
||||
|
||||
// Old session should have been fully torn down
|
||||
@@ -1719,7 +1985,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
.mockRejectedValue(new Error("mock"));
|
||||
|
||||
await expect(
|
||||
agent.unstable_resumeSession({
|
||||
agent.resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [{ name: "new-server", command: "node", args: ["server.js"], env: [] }],
|
||||
@@ -1744,7 +2010,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
});
|
||||
|
||||
// Same servers but reversed order — should NOT trigger teardown
|
||||
await agent.unstable_resumeSession({
|
||||
await agent.resumeSession({
|
||||
sessionId: "s1",
|
||||
cwd: "/project",
|
||||
mcpServers: [...servers].reverse() as any,
|
||||
@@ -1882,6 +2148,7 @@ describe("usage_update computation", () => {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
modelInfos: [],
|
||||
settingsManager: {} as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
@@ -2235,7 +2502,12 @@ describe("usage_update computation", () => {
|
||||
const session = agent.sessions["test-session"];
|
||||
expect(session.contextWindowSize).toBe(200000);
|
||||
|
||||
(agent as any).syncSessionConfigState(session, "model", "claude-opus-4-6-1m");
|
||||
await (agent as any).applyConfigOptionValue(
|
||||
"test-session",
|
||||
session,
|
||||
"model",
|
||||
"claude-opus-4-6-1m",
|
||||
);
|
||||
expect(session.contextWindowSize).toBe(1000000);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
@@ -2286,7 +2558,7 @@ describe("usage_update computation", () => {
|
||||
it("switching the session's model invalidates the learned context window", async () => {
|
||||
// When the user switches models mid-session, the window learned for the
|
||||
// previous model would otherwise persist into the next prompt's first
|
||||
// mid-stream update. syncSessionConfigState should reset it so the next
|
||||
// mid-stream update. applyConfigOptionValue should reset it so the next
|
||||
// turn's first update falls back to the heuristic (here: 200k default).
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
@@ -2320,7 +2592,12 @@ describe("usage_update computation", () => {
|
||||
session.models = { ...session.models, currentModelId: "claude-opus-4-6-1m" };
|
||||
|
||||
// User flips the selector to a 200k model.
|
||||
(agent as any).syncSessionConfigState(session, "model", "claude-sonnet-4-6");
|
||||
await (agent as any).applyConfigOptionValue(
|
||||
"test-session",
|
||||
session,
|
||||
"model",
|
||||
"claude-sonnet-4-6",
|
||||
);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "test" }] });
|
||||
|
||||
@@ -2770,6 +3047,7 @@ describe("emitRawSDKMessages", () => {
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
@@ -253,6 +253,189 @@ describe("createSession options merging", () => {
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
});
|
||||
|
||||
describe("systemPrompt via _meta", () => {
|
||||
it("defaults to the claude_code preset when not provided", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces the preset when a string is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: "custom prompt" },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toBe("custom prompt");
|
||||
});
|
||||
|
||||
it("forwards append", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { append: "extra instructions" } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: "extra instructions",
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards excludeDynamicSections", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: { systemPrompt: { excludeDynamicSections: true } },
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
excludeDynamicSections: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards append and excludeDynamicSections together", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
append: "extra instructions",
|
||||
excludeDynamicSections: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: "extra instructions",
|
||||
excludeDynamicSections: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores caller-provided type/preset overrides", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
systemPrompt: {
|
||||
type: "something-else",
|
||||
preset: "other-preset",
|
||||
append: "extra",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.systemPrompt).toEqual({
|
||||
type: "preset",
|
||||
preset: "claude_code",
|
||||
append: "extra",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("CLAUDE_MODEL_CONFIG", () => {
|
||||
let originalModelConfig: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
originalModelConfig = process.env.CLAUDE_MODEL_CONFIG;
|
||||
delete process.env.CLAUDE_MODEL_CONFIG;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalModelConfig !== undefined) {
|
||||
process.env.CLAUDE_MODEL_CONFIG = originalModelConfig;
|
||||
} else {
|
||||
delete process.env.CLAUDE_MODEL_CONFIG;
|
||||
}
|
||||
});
|
||||
|
||||
it("passes modelOverrides as settings", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
});
|
||||
});
|
||||
|
||||
it("passes availableModels as settings", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
availableModels: ["opus", "sonnet"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
availableModels: ["opus", "sonnet"],
|
||||
});
|
||||
});
|
||||
|
||||
it("passes both modelOverrides and availableModels", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
availableModels: ["opus"],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not add settings when env var is not set", async () => {
|
||||
await agent.newSession({ cwd: "/test", mcpServers: [] });
|
||||
|
||||
expect(capturedOptions!.settings).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores env var when _meta provides settings", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = JSON.stringify({
|
||||
modelOverrides: { "claude-opus-4-6": "us.anthropic.claude-opus-4-6-v1" },
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
settings: {
|
||||
model: "claude-sonnet-4-5",
|
||||
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// _meta settings take precedence; env var is ignored entirely
|
||||
expect(capturedOptions!.settings).toEqual({
|
||||
model: "claude-sonnet-4-5",
|
||||
modelOverrides: { "claude-opus-4-6": "meta-value" },
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on invalid JSON", async () => {
|
||||
process.env.CLAUDE_MODEL_CONFIG = "not-json";
|
||||
|
||||
await expect(agent.newSession({ cwd: "/test", mcpServers: [] })).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { resolvePermissionMode } from "../acp-agent.js";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { resolvePermissionMode, type Logger } from "../acp-agent.js";
|
||||
|
||||
function mockLogger() {
|
||||
const error = vi.fn<(...args: any[]) => void>();
|
||||
const log = vi.fn<(...args: any[]) => void>();
|
||||
const logger: Logger = { log, error };
|
||||
return { logger, error, log };
|
||||
}
|
||||
|
||||
describe("resolvePermissionMode", () => {
|
||||
it("returns 'default' when no mode is provided", () => {
|
||||
@@ -26,18 +33,25 @@ describe("resolvePermissionMode", () => {
|
||||
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
|
||||
});
|
||||
|
||||
it("throws on non-string values", () => {
|
||||
expect(() => resolvePermissionMode(123)).toThrow("expected a string");
|
||||
expect(() => resolvePermissionMode(true)).toThrow("expected a string");
|
||||
expect(() => resolvePermissionMode({})).toThrow("expected a string");
|
||||
it("falls back to 'default' and logs on non-string values", () => {
|
||||
for (const value of [123, true, {}]) {
|
||||
const { logger, error } = mockLogger();
|
||||
expect(resolvePermissionMode(value, logger)).toBe("default");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a string"));
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on empty string", () => {
|
||||
expect(() => resolvePermissionMode("")).toThrow("expected a non-empty string");
|
||||
expect(() => resolvePermissionMode(" ")).toThrow("expected a non-empty string");
|
||||
it("falls back to 'default' and logs on empty string", () => {
|
||||
for (const value of ["", " "]) {
|
||||
const { logger, error } = mockLogger();
|
||||
expect(resolvePermissionMode(value, logger)).toBe("default");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("expected a non-empty string"));
|
||||
}
|
||||
});
|
||||
|
||||
it("throws on unknown mode", () => {
|
||||
expect(() => resolvePermissionMode("yolo")).toThrow("Invalid permissions.defaultMode: yolo");
|
||||
it("falls back to 'default' and logs on unknown mode", () => {
|
||||
const { logger, error } = mockLogger();
|
||||
expect(resolvePermissionMode("yolo", logger)).toBe("default");
|
||||
expect(error).toHaveBeenCalledWith(expect.stringContaining("yolo"));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { ModelInfo } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
const { registerHookCallbackSpy } = vi.hoisted(() => ({
|
||||
@@ -58,6 +59,19 @@ const MOCK_CONFIG_OPTIONS = [
|
||||
description: m.description,
|
||||
})),
|
||||
},
|
||||
{
|
||||
id: "effort",
|
||||
name: "Effort",
|
||||
description: "Available effort levels for this model",
|
||||
type: "select",
|
||||
category: "effort",
|
||||
currentValue: "high",
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "medium", name: "Medium" },
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
describe("session config options", () => {
|
||||
@@ -67,6 +81,7 @@ describe("session config options", () => {
|
||||
let createSessionSpy: ReturnType<typeof vi.fn>;
|
||||
let setPermissionModeSpy: ReturnType<typeof vi.fn>;
|
||||
let setModelSpy: ReturnType<typeof vi.fn>;
|
||||
let applyFlagSettingsSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
@@ -82,11 +97,13 @@ describe("session config options", () => {
|
||||
function populateSession() {
|
||||
setPermissionModeSpy = vi.fn();
|
||||
setModelSpy = vi.fn();
|
||||
applyFlagSettingsSpy = vi.fn();
|
||||
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[SESSION_ID] = {
|
||||
query: {
|
||||
setPermissionMode: setPermissionModeSpy,
|
||||
setModel: setModelSpy,
|
||||
applyFlagSettings: applyFlagSettingsSpy,
|
||||
supportedCommands: async () => [],
|
||||
},
|
||||
input: null,
|
||||
@@ -95,6 +112,15 @@ describe("session config options", () => {
|
||||
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"],
|
||||
}),
|
||||
),
|
||||
configOptions: structuredClone(MOCK_CONFIG_OPTIONS),
|
||||
contextWindowSize: 200000,
|
||||
};
|
||||
@@ -387,6 +413,101 @@ describe("session config options", () => {
|
||||
const modelOption = session.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("includes updated effort in config_option_update when model drops effort support", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
const effortOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
expect(effortOption).toBeUndefined();
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined });
|
||||
});
|
||||
|
||||
it("clamps effort in config_option_update when new model has different supported levels", async () => {
|
||||
// Set current effort to "max" which the new model won't support
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
|
||||
if (effortOpt) effortOpt.currentValue = "max";
|
||||
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
];
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
const effortOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
expect(effortOption).toBeDefined();
|
||||
expect(effortOption.currentValue).toBe("high");
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
|
||||
});
|
||||
|
||||
it("preserves effort in config_option_update when new model supports same level", async () => {
|
||||
// Set effort to "low" first
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
|
||||
if (effortOpt) effortOpt.currentValue = "low";
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
const effortOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
expect(effortOption?.currentValue).toBe("low");
|
||||
// Effort didn't change, so applyFlagSettings should NOT be called
|
||||
expect(applyFlagSettingsSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("no config_option_update notification when using setSessionConfigOption", () => {
|
||||
@@ -421,6 +542,223 @@ describe("session config options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("setSessionConfigOption for effort", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("calls applyFlagSettings with effortLevel", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
});
|
||||
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "low" });
|
||||
});
|
||||
|
||||
it("updates effort currentValue in returned configOptions", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "medium",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption?.currentValue).toBe("medium");
|
||||
});
|
||||
|
||||
it("throws for invalid effort value", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "turbo",
|
||||
}),
|
||||
).rejects.toThrow("Invalid value for config option effort: turbo");
|
||||
});
|
||||
|
||||
it("does not send config_option_update notification", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
});
|
||||
|
||||
const configUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("other options are unchanged when effort is updated", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
});
|
||||
|
||||
const modeOption = response.configOptions.find((o) => o.id === "mode");
|
||||
expect(modeOption?.currentValue).toBe("default");
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
describe("effort level and model switch interactions", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("drops effort option when switching to a model without effort support", async () => {
|
||||
// Make sonnet not support effort
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clears effort via applyFlagSettings when switching to a model without effort", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined });
|
||||
});
|
||||
|
||||
it("adds effort option when switching to a model that supports effort", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
// Start with sonnet (no effort) as current
|
||||
session.models = { ...session.models, currentModelId: "claude-sonnet-4-5" };
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: false,
|
||||
},
|
||||
];
|
||||
// Remove effort from current config options
|
||||
session.configOptions = session.configOptions.filter((o: any) => o.id !== "effort");
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption).toBeDefined();
|
||||
// No previous effort, so defaults to "high" (the API default)
|
||||
expect(effortOption?.currentValue).toBe("high");
|
||||
});
|
||||
|
||||
it("clamps effort to valid value when new model has different supported levels", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
// Set current effort to "max" (not supported by sonnet in our mock)
|
||||
const effortOpt = session.configOptions.find((o: any) => o.id === "effort");
|
||||
if (effortOpt) effortOpt.currentValue = "max";
|
||||
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high", "max"],
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
},
|
||||
];
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption).toBeDefined();
|
||||
// "max" is not in sonnet's levels, so should fall back to "high" (the API default)
|
||||
expect(effortOption?.currentValue).toBe("high");
|
||||
// SDK should be told about the clamped value
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
|
||||
});
|
||||
|
||||
it("preserves effort value when new model supports the same level", async () => {
|
||||
// Set effort to "low"
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "low",
|
||||
});
|
||||
|
||||
// Switch model — both support "low"
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
const effortOption = response.configOptions.find((o) => o.id === "effort");
|
||||
expect(effortOption?.currentValue).toBe("low");
|
||||
// applyFlagSettings was called once for the effort change, but not again for the model switch
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("bidirectional consistency", () => {
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
@@ -473,4 +811,391 @@ describe("session config options", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("auto mode availability per model", () => {
|
||||
/**
|
||||
* Augment the session populated by `populateSession()` with a Haiku entry
|
||||
* (no `supportsAutoMode`), Opus + Sonnet entries with `supportsAutoMode:
|
||||
* true`, and seed `availableModes` so it currently includes `auto`. This
|
||||
* exercises the per-model recomputation done by `applyConfigOptionValue`
|
||||
* on a model switch.
|
||||
*/
|
||||
function setupHaikuOpusSession(currentModeId: string = "default") {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modelInfos = [
|
||||
{
|
||||
value: "claude-opus-4-5",
|
||||
displayName: "Claude Opus",
|
||||
description: "Most capable",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
{
|
||||
value: "claude-sonnet-4-5",
|
||||
displayName: "Claude Sonnet",
|
||||
description: "Balanced",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
supportsAutoMode: true,
|
||||
},
|
||||
{
|
||||
value: "claude-haiku-4-5",
|
||||
displayName: "Claude Haiku",
|
||||
description: "Fast",
|
||||
supportsEffort: true,
|
||||
supportedEffortLevels: ["low", "medium", "high"],
|
||||
// supportsAutoMode intentionally omitted
|
||||
},
|
||||
];
|
||||
session.models = {
|
||||
currentModelId: "claude-opus-4-5",
|
||||
availableModels: [
|
||||
{ modelId: "claude-opus-4-5", name: "Claude Opus", description: "Most capable" },
|
||||
{ modelId: "claude-sonnet-4-5", name: "Claude Sonnet", description: "Balanced" },
|
||||
{ modelId: "claude-haiku-4-5", name: "Claude Haiku", description: "Fast" },
|
||||
],
|
||||
};
|
||||
session.modes = {
|
||||
currentModeId,
|
||||
availableModes: [
|
||||
{
|
||||
id: "auto",
|
||||
name: "Auto",
|
||||
description: "Use a model classifier to approve/deny permission prompts",
|
||||
},
|
||||
{
|
||||
id: "default",
|
||||
name: "Default",
|
||||
description: "Standard behavior, prompts for dangerous operations",
|
||||
},
|
||||
{
|
||||
id: "acceptEdits",
|
||||
name: "Accept Edits",
|
||||
description: "Auto-accept file edit operations",
|
||||
},
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{
|
||||
id: "dontAsk",
|
||||
name: "Don't Ask",
|
||||
description: "Don't prompt for permissions, deny if not pre-approved",
|
||||
},
|
||||
],
|
||||
};
|
||||
// Reflect the seeded availableModes/availableModels in configOptions so
|
||||
// the pre-state matches what `createSession` would have produced for
|
||||
// Opus, and `setSessionConfigOption` validation can accept the seeded
|
||||
// model ids (notably the new Haiku entry).
|
||||
session.configOptions = session.configOptions.map((o: any) => {
|
||||
if (o.id === "mode") {
|
||||
return {
|
||||
...o,
|
||||
currentValue: currentModeId,
|
||||
options: session.modes.availableModes.map((m: any) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (o.id === "model") {
|
||||
return {
|
||||
...o,
|
||||
currentValue: session.models.currentModelId,
|
||||
options: session.models.availableModels.map((m: any) => ({
|
||||
value: m.modelId,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
return o;
|
||||
});
|
||||
return session;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("drops `auto` from available modes when switching to Haiku", async () => {
|
||||
setupHaikuOpusSession("default");
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
const modeOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "mode",
|
||||
);
|
||||
expect(modeOption).toBeDefined();
|
||||
const modeValues = modeOption.options.map((o: any) => o.value);
|
||||
expect(modeValues).not.toContain("auto");
|
||||
expect(modeValues).toEqual(
|
||||
expect.arrayContaining(["default", "acceptEdits", "plan", "dontAsk"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("clamps to `default` and emits current_mode_update when Opus(auto) → Haiku", async () => {
|
||||
setupHaikuOpusSession("auto");
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
// SDK was synced to "default".
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
|
||||
|
||||
// current_mode_update was emitted before config_option_update so a
|
||||
// client applying notifications in order observes the mode change
|
||||
// before re-rendering the config-option list.
|
||||
const modeUpdateIdx = sessionUpdates.findIndex(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
const configUpdateIdx = sessionUpdates.findIndex(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(modeUpdateIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(configUpdateIdx).toBeGreaterThanOrEqual(0);
|
||||
expect(modeUpdateIdx).toBeLessThan(configUpdateIdx);
|
||||
expect((sessionUpdates[modeUpdateIdx].update as any).currentModeId).toBe("default");
|
||||
|
||||
// configOptions reflect the clamped mode.
|
||||
const modeOption = (sessionUpdates[configUpdateIdx].update as any).configOptions.find(
|
||||
(o: any) => o.id === "mode",
|
||||
);
|
||||
expect(modeOption.currentValue).toBe("default");
|
||||
});
|
||||
|
||||
it("re-adds `auto` when switching from Haiku back to Opus", async () => {
|
||||
const session = setupHaikuOpusSession("default");
|
||||
// Pretend Haiku is the current model with no `auto`.
|
||||
session.models.currentModelId = "claude-haiku-4-5";
|
||||
session.modes.availableModes = session.modes.availableModes.filter(
|
||||
(m: any) => m.id !== "auto",
|
||||
);
|
||||
const modeOpt = session.configOptions.find((o: any) => o.id === "mode");
|
||||
modeOpt.options = session.modes.availableModes.map((m: any) => ({
|
||||
value: m.id,
|
||||
name: m.name,
|
||||
description: m.description,
|
||||
}));
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-opus-4-5",
|
||||
});
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdate).toBeDefined();
|
||||
const modeOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "mode",
|
||||
);
|
||||
const modeValues = modeOption.options.map((o: any) => o.value);
|
||||
expect(modeValues).toContain("auto");
|
||||
|
||||
// The current mode ("default") is still valid on Opus, so no
|
||||
// current_mode_update should have been emitted by the model switch.
|
||||
const modeUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
expect(modeUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("preserves the current mode when it remains valid after a model switch", async () => {
|
||||
setupHaikuOpusSession("plan");
|
||||
|
||||
await agent.unstable_setSessionModel({
|
||||
sessionId: SESSION_ID,
|
||||
modelId: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
// `plan` is in availableModes for both Opus and Haiku, so no clamp.
|
||||
expect(setPermissionModeSpy).not.toHaveBeenCalledWith("default");
|
||||
|
||||
const modeUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
expect(modeUpdates).toHaveLength(0);
|
||||
|
||||
const configUpdate = sessionUpdates.find(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
const modeOption = (configUpdate?.update as any).configOptions.find(
|
||||
(o: any) => o.id === "mode",
|
||||
);
|
||||
expect(modeOption.currentValue).toBe("plan");
|
||||
});
|
||||
|
||||
it("clamps mode and emits current_mode_update via setSessionConfigOption(model)", async () => {
|
||||
// Mirrors the unstable_setSessionModel(auto → Haiku) test, but goes
|
||||
// through the request/response API. The `current_mode_update` side
|
||||
// effect must still fire so clients learn about the clamp regardless of
|
||||
// which entry point triggered the model switch.
|
||||
setupHaikuOpusSession("auto");
|
||||
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-haiku-4-5",
|
||||
});
|
||||
|
||||
expect(setPermissionModeSpy).toHaveBeenCalledWith("default");
|
||||
|
||||
const modeUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "current_mode_update",
|
||||
);
|
||||
expect(modeUpdates).toHaveLength(1);
|
||||
expect((modeUpdates[0].update as any).currentModeId).toBe("default");
|
||||
|
||||
// setSessionConfigOption is a request/response API: it returns the new
|
||||
// configOptions in the response rather than emitting a
|
||||
// config_option_update notification.
|
||||
const configUpdates = sessionUpdates.filter(
|
||||
(n) => n.update.sessionUpdate === "config_option_update",
|
||||
);
|
||||
expect(configUpdates).toHaveLength(0);
|
||||
|
||||
const modeOption = response.configOptions.find((o: any) => o.id === "mode");
|
||||
expect(modeOption).toBeDefined();
|
||||
expect((modeOption as any).currentValue).toBe("default");
|
||||
expect((modeOption as any).options.map((o: any) => o.value)).not.toContain("auto");
|
||||
});
|
||||
|
||||
it("rejects direct setSessionMode to `auto` when the active model does not offer it", async () => {
|
||||
const session = setupHaikuOpusSession("default");
|
||||
session.models.currentModelId = "claude-haiku-4-5";
|
||||
session.modes.availableModes = session.modes.availableModes.filter(
|
||||
(mode: any) => mode.id !== "auto",
|
||||
);
|
||||
|
||||
await expect(agent.setSessionMode({ sessionId: SESSION_ID, modeId: "auto" })).rejects.toThrow(
|
||||
"Mode auto is not available in this session",
|
||||
);
|
||||
|
||||
expect(setPermissionModeSpy).not.toHaveBeenCalledWith("auto");
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ExitPlanMode permission options filtered by availableModes", () => {
|
||||
let capturedPermissionRequest: any;
|
||||
let permissionResponse: any;
|
||||
|
||||
beforeEach(() => {
|
||||
capturedPermissionRequest = null;
|
||||
permissionResponse = { outcome: { outcome: "cancelled" } };
|
||||
// Replace the default mock client with one that captures the
|
||||
// requestPermission call so we can assert on the offered options.
|
||||
(agent as any).client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
sessionUpdates.push(notification);
|
||||
},
|
||||
requestPermission: async (params: any) => {
|
||||
capturedPermissionRequest = params;
|
||||
return permissionResponse;
|
||||
},
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
};
|
||||
populateSession();
|
||||
});
|
||||
|
||||
it("omits the `auto` option on a model without supportsAutoMode", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
// Haiku-shaped session: availableModes does NOT include `auto`.
|
||||
session.modes = {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Default", description: "Standard" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
|
||||
],
|
||||
};
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const signal = new AbortController().signal;
|
||||
try {
|
||||
await canUseTool(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal, suggestions: undefined, toolUseID: "toolu_1" },
|
||||
);
|
||||
} catch {
|
||||
// The mock client returns `cancelled`, which makes canUseTool throw.
|
||||
// We only care about the captured requestPermission options.
|
||||
}
|
||||
|
||||
expect(capturedPermissionRequest).not.toBeNull();
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).not.toContain("auto");
|
||||
expect(optionIds).toEqual(expect.arrayContaining(["default", "acceptEdits", "plan"]));
|
||||
});
|
||||
|
||||
it("denies a selected `auto` option if the client did not receive that option", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modes = {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "default", name: "Default", description: "Standard" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
|
||||
],
|
||||
};
|
||||
permissionResponse = { outcome: { outcome: "selected", optionId: "auto" } };
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const result = await canUseTool(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal: new AbortController().signal, suggestions: undefined, toolUseID: "toolu_2" },
|
||||
);
|
||||
|
||||
expect(capturedPermissionRequest).not.toBeNull();
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).not.toContain("auto");
|
||||
expect(result.behavior).toBe("deny");
|
||||
expect(sessionUpdates).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("includes the `auto` option on a model with supportsAutoMode", async () => {
|
||||
const session = (agent as unknown as { sessions: Record<string, any> }).sessions[SESSION_ID];
|
||||
session.modes = {
|
||||
currentModeId: "plan",
|
||||
availableModes: [
|
||||
{ id: "auto", name: "Auto", description: "Use a model classifier" },
|
||||
{ id: "default", name: "Default", description: "Standard" },
|
||||
{ id: "acceptEdits", name: "Accept Edits", description: "Auto-accept edits" },
|
||||
{ id: "plan", name: "Plan Mode", description: "Planning mode" },
|
||||
{ id: "dontAsk", name: "Don't Ask", description: "Deny if not pre-approved" },
|
||||
],
|
||||
};
|
||||
|
||||
const canUseTool = (agent as any).canUseTool(SESSION_ID);
|
||||
const signal = new AbortController().signal;
|
||||
try {
|
||||
await canUseTool(
|
||||
"ExitPlanMode",
|
||||
{ plan: "do stuff" },
|
||||
{ signal, suggestions: undefined, toolUseID: "toolu_3" },
|
||||
);
|
||||
} catch {
|
||||
// mock returns cancelled
|
||||
}
|
||||
|
||||
expect(capturedPermissionRequest).not.toBeNull();
|
||||
const optionIds = capturedPermissionRequest.options.map((o: any) => o.optionId);
|
||||
expect(optionIds).toContain("auto");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -86,7 +86,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agent.unstable_resumeSession({
|
||||
agent.resumeSession({
|
||||
sessionId: bogusSessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
@@ -135,7 +135,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
|
||||
try {
|
||||
await expect(
|
||||
agentB.unstable_resumeSession({
|
||||
agentB.resumeSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
@@ -145,4 +145,189 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("session load/resume lifecyc
|
||||
await agentB.dispose();
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Regression test for https://github.com/zed-industries/claude-code-acp/issues/579
|
||||
// The client (Zed) renders its own local slash commands — e.g. `/model` —
|
||||
// by injecting user-message prompts whose text is wrapped in
|
||||
// `<command-name>` / `<local-command-stdout>` markers. The Claude SDK
|
||||
// persists those messages verbatim in the session transcript. Without
|
||||
// filtering, `loadSession` replays the markers as user_message_chunks
|
||||
// ahead of the real prompt, leaking CLI internals into the transcript.
|
||||
it("ACP: loadSession does not replay local-command metadata user messages", async () => {
|
||||
const recordedUserChunks: string[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
if (notification.update.sessionUpdate === "user_message_chunk") {
|
||||
const content = notification.update.content;
|
||||
if (content.type === "text") recordedUserChunks.push(content.text);
|
||||
}
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
const commandName =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>";
|
||||
const commandStdout =
|
||||
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>";
|
||||
|
||||
// Step 1: create a session via the SDK, push the marker user messages
|
||||
// followed by a real "hi" prompt. We bypass the ACP agent's prompt loop
|
||||
// so we're only testing what the loadSession replay path does, not how
|
||||
// prompts get routed.
|
||||
const sessionId = randomUUID();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
await q.initializationResult();
|
||||
|
||||
// Zed renders its local slash commands by mixing the marker blocks into
|
||||
// the user's prompt content array alongside the real text. The Claude
|
||||
// SDK persists the message verbatim — including the marker blocks —
|
||||
// which is what loadSession must filter when it replays.
|
||||
input.push({
|
||||
type: "user",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: commandName },
|
||||
{ type: "text", text: commandStdout },
|
||||
{ type: "text", text: "hi" },
|
||||
],
|
||||
},
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
|
||||
for await (const msg of q) {
|
||||
if (msg.type === "result") break;
|
||||
}
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
|
||||
// Sanity check: the SDK transcript contains the metadata we're filtering.
|
||||
const sdkMessages = await getSessionMessages(sessionId);
|
||||
const sdkTexts = sdkMessages
|
||||
.map((m) => {
|
||||
const c = (m as { message?: { content?: unknown } }).message?.content;
|
||||
if (typeof c === "string") return c;
|
||||
if (Array.isArray(c))
|
||||
return c
|
||||
.map((b) =>
|
||||
b && typeof b === "object" && "text" in b
|
||||
? String((b as { text: unknown }).text)
|
||||
: "",
|
||||
)
|
||||
.join("");
|
||||
return "";
|
||||
})
|
||||
.join("\n");
|
||||
expect(sdkTexts).toContain("<command-name>");
|
||||
expect(sdkTexts).toContain("<local-command-stdout>");
|
||||
expect(sdkTexts).toContain("hi");
|
||||
|
||||
// Step 2: load the session through the ACP agent and confirm the markers
|
||||
// never reach the client as user_message_chunks, while the real "hi"
|
||||
// prompt does.
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
try {
|
||||
await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
|
||||
for (const chunk of recordedUserChunks) {
|
||||
expect(chunk).not.toContain("<command-name>");
|
||||
expect(chunk).not.toContain("<command-message>");
|
||||
expect(chunk).not.toContain("<command-args>");
|
||||
expect(chunk).not.toContain("<local-command-stdout>");
|
||||
expect(chunk).not.toContain("<local-command-stderr>");
|
||||
}
|
||||
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
|
||||
}, 60000);
|
||||
|
||||
// Second regression: the original issue showed the markers and the real
|
||||
// "hi" prompt all concatenated into a single string ending with "hi".
|
||||
// loadSession must strip the marker tags in-place rather than dropping the
|
||||
// whole message.
|
||||
it("ACP: loadSession strips marker tags from a concatenated single-string prompt", async () => {
|
||||
const recordedUserChunks: string[] = [];
|
||||
const client = {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
if (notification.update.sessionUpdate === "user_message_chunk") {
|
||||
const content = notification.update.content;
|
||||
if (content.type === "text") recordedUserChunks.push(content.text);
|
||||
}
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
|
||||
// Exact shape from https://github.com/zed-industries/claude-code-acp/issues/579.
|
||||
const concatenated =
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus</command-args>" +
|
||||
"<local-command-stdout>Set model to opus (claude-opus-4-7)</local-command-stdout>\n" +
|
||||
"<command-name>/model</command-name>\n <command-message>model</command-message>\n <command-args>opus[1m]</command-args>" +
|
||||
"<local-command-stdout>Set model to opus[1m] (claude-opus-4-7[1m])</local-command-stdout>" +
|
||||
"hi";
|
||||
|
||||
const sessionId = randomUUID();
|
||||
const input = new Pushable<SDKUserMessage>();
|
||||
const q = query({
|
||||
prompt: input,
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
});
|
||||
await q.initializationResult();
|
||||
|
||||
input.push({
|
||||
type: "user",
|
||||
message: { role: "user", content: concatenated },
|
||||
session_id: sessionId,
|
||||
parent_tool_use_id: null,
|
||||
});
|
||||
for await (const msg of q) {
|
||||
if (msg.type === "result") break;
|
||||
}
|
||||
input.end();
|
||||
q.return(undefined);
|
||||
|
||||
const agent = new ClaudeAcpAgent(client);
|
||||
try {
|
||||
await agent.loadSession({
|
||||
sessionId,
|
||||
cwd: process.cwd(),
|
||||
mcpServers: [],
|
||||
});
|
||||
} finally {
|
||||
await agent.dispose();
|
||||
}
|
||||
|
||||
expect(recordedUserChunks.length).toBeGreaterThan(0);
|
||||
for (const chunk of recordedUserChunks) {
|
||||
expect(chunk).not.toContain("<command-name>");
|
||||
expect(chunk).not.toContain("<command-message>");
|
||||
expect(chunk).not.toContain("<command-args>");
|
||||
expect(chunk).not.toContain("<local-command-stdout>");
|
||||
expect(chunk).not.toContain("<local-command-stderr>");
|
||||
}
|
||||
expect(recordedUserChunks.some((c) => c.includes("hi"))).toBe(true);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user