mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
edde96ebaf | ||
|
|
3fb6db4e5e | ||
|
|
41e3771881 | ||
|
|
61ebda2c60 | ||
|
|
98b54a02c2 | ||
|
|
a172885c80 | ||
|
|
7ed1daf7b4 | ||
|
|
694221a412 | ||
|
|
36822c2b75 | ||
|
|
9e259d128a | ||
|
|
2711f506d5 | ||
|
|
2414a6f98b | ||
|
|
cfce130707 | ||
|
|
b275f6ff7f | ||
|
|
a0bfb98ba7 | ||
|
|
e1e1c69029 | ||
|
|
72875b248c | ||
|
|
f66bc31ae4 | ||
|
|
5b2e147b5d | ||
|
|
db852dcf7c | ||
|
|
505f9c13c0 | ||
|
|
f37e9a0d47 | ||
|
|
2162d5af62 | ||
|
|
798844726f | ||
|
|
f6e12d425e | ||
|
|
3b9b7d5a56 | ||
|
|
eb1259cf88 | ||
|
|
5ff7d50f0e | ||
|
|
47df1f2a0b | ||
|
|
5d7165d961 | ||
|
|
27ca2e5d40 | ||
|
|
0db6e3d6f8 | ||
|
|
8d76be356a | ||
|
|
9258652169 | ||
|
|
002c63a78b |
7
.github/dependabot.yml
vendored
7
.github/dependabot.yml
vendored
@@ -11,13 +11,6 @@ updates:
|
||||
interval: "daily"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
ignore:
|
||||
# Pinned to match the peer range of @anthropic-ai/claude-agent-sdk;
|
||||
# newer minors break type compatibility between the two installed copies.
|
||||
- dependency-name: "@anthropic-ai/sdk"
|
||||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-major"
|
||||
groups:
|
||||
minor:
|
||||
update-types:
|
||||
|
||||
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
# Generating a GitHub token, so that PRs and tags created by
|
||||
# the release-please-action can trigger actions workflows.
|
||||
- name: Generate GitHub token
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
|
||||
id: generate-token
|
||||
with:
|
||||
# GitHub App ID secret name
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.33.1"
|
||||
".": "0.39.0"
|
||||
}
|
||||
|
||||
84
CHANGELOG.md
84
CHANGELOG.md
@@ -1,5 +1,89 @@
|
||||
# Changelog
|
||||
|
||||
## [0.39.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.38.0...v0.39.0) (2026-05-29)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** update Claude Agent SDK to 0.3.156 ([#722](https://github.com/agentclientprotocol/claude-agent-acp/issues/722)) ([3fb6db4](https://github.com/agentclientprotocol/claude-agent-acp/commit/3fb6db4e5ee5bd1f431cf5d2ce5331ce983f9597))
|
||||
|
||||
## [0.38.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.37.0...v0.38.0) (2026-05-28)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump to @anthropic-ai/claude-agent-sdk 0.3.154 (Opus 4.8) ([#716](https://github.com/agentclientprotocol/claude-agent-acp/issues/716)) ([a172885](https://github.com/agentclientprotocol/claude-agent-acp/commit/a172885c80fc32c108a8501268a543641088f543))
|
||||
* Support Opus 4.8 ([#718](https://github.com/agentclientprotocol/claude-agent-acp/issues/718)) ([98b54a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/98b54a02c267cdef619becceccf6ef519fbf8f6f))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Remove hide Claude auth flag handling ([#707](https://github.com/agentclientprotocol/claude-agent-acp/issues/707)) ([7ed1daf](https://github.com/agentclientprotocol/claude-agent-acp/commit/7ed1daf7b42088ad149e9bf742f764752b82d093))
|
||||
|
||||
## [0.37.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.36.1...v0.37.0) (2026-05-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add 'Default' option to effort level config ([#701](https://github.com/agentclientprotocol/claude-agent-acp/issues/701)) ([9e259d1](https://github.com/agentclientprotocol/claude-agent-acp/commit/9e259d128aa58dbb8107e53362781ca4e8ee071e))
|
||||
* **deps:** bump the claude-agent-sdk to 0.3.146 ([#700](https://github.com/agentclientprotocol/claude-agent-acp/issues/700)) ([72875b2](https://github.com/agentclientprotocol/claude-agent-acp/commit/72875b248c7b90a89638de2f1709eec9e2153e7e))
|
||||
* **deps:** bump the minor group with 13 updates ([#689](https://github.com/agentclientprotocol/claude-agent-acp/issues/689)) ([f66bc31](https://github.com/agentclientprotocol/claude-agent-acp/commit/f66bc31ae4bea73b48eeeb7f1db90c7d69cc0dac))
|
||||
* Emit tool calls for memory recall events ([#703](https://github.com/agentclientprotocol/claude-agent-acp/issues/703)) ([a0bfb98](https://github.com/agentclientprotocol/claude-agent-acp/commit/a0bfb98ba73eaf37e9c071e886c054f90a5d5629)), closes [#650](https://github.com/agentclientprotocol/claude-agent-acp/issues/650)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Avoid cross-version model alias matches ([#702](https://github.com/agentclientprotocol/claude-agent-acp/issues/702)) ([e1e1c69](https://github.com/agentclientprotocol/claude-agent-acp/commit/e1e1c69029ef08579c841e6fbdffb32a5b94df06))
|
||||
* Avoid redundant initial model sync ([#704](https://github.com/agentclientprotocol/claude-agent-acp/issues/704)) ([b275f6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/b275f6ff7f0f2e21adedbd8e63a6fc3d63cbbb8d)), closes [#646](https://github.com/agentclientprotocol/claude-agent-acp/issues/646)
|
||||
* Don't expose /clear in commands ([#705](https://github.com/agentclientprotocol/claude-agent-acp/issues/705)) ([cfce130](https://github.com/agentclientprotocol/claude-agent-acp/commit/cfce1307076e93f43a3ed8cc0134f9bb14a0f2d6))
|
||||
* emit "cancelled" instead of "end_turn" when the session was interrupted. ([#694](https://github.com/agentclientprotocol/claude-agent-acp/issues/694)) ([2414a6f](https://github.com/agentclientprotocol/claude-agent-acp/commit/2414a6f98bec5bf50f3a13af528aa38d5a0fc974))
|
||||
* Recover prompt stream after a failed turn ([#706](https://github.com/agentclientprotocol/claude-agent-acp/issues/706)) ([2711f50](https://github.com/agentclientprotocol/claude-agent-acp/commit/2711f506d5799f0ae25160de311a4459ffb46c49)), closes [#654](https://github.com/agentclientprotocol/claude-agent-acp/issues/654)
|
||||
|
||||
## [0.36.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.36.0...v0.36.1) (2026-05-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* flaky authentication bypass for gateway ([#686](https://github.com/agentclientprotocol/claude-agent-acp/issues/686)) ([db852dc](https://github.com/agentclientprotocol/claude-agent-acp/commit/db852dcf7c8e3b461fabb1bdcf0c80a27d1da77d))
|
||||
|
||||
## [0.36.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.35.0...v0.36.0) (2026-05-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* Add experimental session delete support ([#682](https://github.com/agentclientprotocol/claude-agent-acp/issues/682)) ([2162d5a](https://github.com/agentclientprotocol/claude-agent-acp/commit/2162d5af62d493a82381bd88bc8fc67d376e358b))
|
||||
* Support experimental additionalDirectories field ([#684](https://github.com/agentclientprotocol/claude-agent-acp/issues/684)) ([f37e9a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/f37e9a0d47d8d201d98b2177083664a6781895cc))
|
||||
|
||||
## [0.35.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.34.1...v0.35.0) (2026-05-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **deps:** bump actions/create-github-app-token from 3.1.1 to 3.2.0 ([#669](https://github.com/agentclientprotocol/claude-agent-acp/issues/669)) ([47df1f2](https://github.com/agentclientprotocol/claude-agent-acp/commit/47df1f2a0be57ddb8873ae8e341f748471c7bc53))
|
||||
* **deps:** bump hono from 4.12.18 to 4.12.19 in the minor group ([#671](https://github.com/agentclientprotocol/claude-agent-acp/issues/671)) ([5d7165d](https://github.com/agentclientprotocol/claude-agent-acp/commit/5d7165d96158da2ddf3087676cdb0a19d1ff57aa))
|
||||
* **deps:** update to claude-agent-sdk 0.3.143 ([#664](https://github.com/agentclientprotocol/claude-agent-acp/issues/664)) ([27ca2e5](https://github.com/agentclientprotocol/claude-agent-acp/commit/27ca2e5d40917887671cc4c39a854fd8f92c01e6))
|
||||
* Use SDK settings resolution for defaults ([#677](https://github.com/agentclientprotocol/claude-agent-acp/issues/677)) ([eb1259c](https://github.com/agentclientprotocol/claude-agent-acp/commit/eb1259cf88375ca3c20a219ab99f12f5488fb360))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Add task hooks for plan state updates ([#676](https://github.com/agentclientprotocol/claude-agent-acp/issues/676)) ([5ff7d50](https://github.com/agentclientprotocol/claude-agent-acp/commit/5ff7d50f0ec7f34a7d9d901223aed73e2dbcce68))
|
||||
* render local-command-stdout messages instead of dropping them ([#649](https://github.com/agentclientprotocol/claude-agent-acp/issues/649)) ([3b9b7d5](https://github.com/agentclientprotocol/claude-agent-acp/commit/3b9b7d5a56defd925eb2038fa97b6484ad951587))
|
||||
|
||||
## [0.34.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.34.0...v0.34.1) (2026-05-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* load credential error for bedrock gateway ([#667](https://github.com/agentclientprotocol/claude-agent-acp/issues/667)) ([8d76be3](https://github.com/agentclientprotocol/claude-agent-acp/commit/8d76be356a3e74c58a7c93c3c201cca6a317d784))
|
||||
|
||||
## [0.34.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.33.1...v0.34.0) (2026-05-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add bedrock gateway authentication ([#665](https://github.com/agentclientprotocol/claude-agent-acp/issues/665)) ([002c63a](https://github.com/agentclientprotocol/claude-agent-acp/commit/002c63a78b844c168d3dfc744cf408362a6adf97))
|
||||
|
||||
## [0.33.1](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.33.0...v0.33.1) (2026-05-07)
|
||||
|
||||
|
||||
|
||||
775
package-lock.json
generated
775
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.33.1",
|
||||
"version": "0.39.0",
|
||||
"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.21.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.132",
|
||||
"@agentclientprotocol/sdk": "0.22.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.3.156",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.89.0",
|
||||
"@anthropic-ai/sdk": "0.100.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.6.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.59.2",
|
||||
"@typescript-eslint/parser": "8.59.2",
|
||||
"eslint": "10.3.0",
|
||||
"@types/node": "25.9.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.60.0",
|
||||
"@typescript-eslint/parser": "8.60.0",
|
||||
"eslint": "10.4.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.6.0",
|
||||
"prettier": "3.8.3",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "6.0.3",
|
||||
"vitest": "4.1.5"
|
||||
"vitest": "4.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
464
src/acp-agent.ts
464
src/acp-agent.ts
@@ -17,6 +17,7 @@ import {
|
||||
ndJsonStream,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
PermissionOption,
|
||||
PromptRequest,
|
||||
PromptResponse,
|
||||
ReadTextFileRequest,
|
||||
@@ -36,6 +37,8 @@ import {
|
||||
SetSessionModeResponse,
|
||||
CloseSessionRequest,
|
||||
CloseSessionResponse,
|
||||
DeleteSessionRequest,
|
||||
DeleteSessionResponse,
|
||||
TerminalHandle,
|
||||
TerminalOutputResponse,
|
||||
WriteTextFileRequest,
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
CanUseTool,
|
||||
deleteSession,
|
||||
getSessionMessages,
|
||||
listSessions,
|
||||
McpServerConfig,
|
||||
@@ -69,10 +73,16 @@ import * as path from "node:path";
|
||||
import packageJson from "../package.json" with { type: "json" };
|
||||
import { SettingsManager } from "./settings.js";
|
||||
import {
|
||||
applyTaskCreate,
|
||||
applyTaskUpdate,
|
||||
ClaudePlanEntry,
|
||||
createPostToolUseHook,
|
||||
createTaskHook,
|
||||
parseTaskCreateOutput,
|
||||
planEntries,
|
||||
registerHookCallback,
|
||||
TaskState,
|
||||
taskStateToPlanEntries,
|
||||
toolInfoFromToolUse,
|
||||
toolUpdateFromDiffToolResponse,
|
||||
toolUpdateFromToolResult,
|
||||
@@ -152,6 +162,9 @@ type Session = {
|
||||
* DEFAULT_CONTEXT_WINDOW, refreshed from each result's modelUsage, and
|
||||
* invalidated when the user switches the session's model. */
|
||||
contextWindowSize: number;
|
||||
/** Accumulated task list for the session, keyed by task ID. Task IDs are
|
||||
* per-session, so this state must not be shared across sessions. */
|
||||
taskState: TaskState;
|
||||
};
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
@@ -232,6 +245,8 @@ type GatewayAuthMeta = {
|
||||
};
|
||||
};
|
||||
|
||||
type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta };
|
||||
|
||||
/**
|
||||
* Extra metadata that the agent provides for each tool_call / tool_update update.
|
||||
*/
|
||||
@@ -476,7 +491,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
backgroundTerminals: { [key: string]: BackgroundTerminal } = {};
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
logger: Logger;
|
||||
gatewayAuthMeta?: GatewayAuthMeta;
|
||||
gatewayAuthRequest?: GatewayAuthRequest;
|
||||
|
||||
constructor(client: AgentSideConnection, logger?: Logger) {
|
||||
this.sessions = {};
|
||||
@@ -503,6 +518,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
};
|
||||
|
||||
const gatewayBedrockAuthMethod: AuthMethod = {
|
||||
id: "gateway-bedrock",
|
||||
name: "Custom model gateway",
|
||||
description: "Use a custom gateway to authenticate and access models",
|
||||
_meta: {
|
||||
gateway: {
|
||||
protocol: "bedrock",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const supportsTerminalAuth = request.clientCapabilities?.auth?.terminal === true;
|
||||
const supportsMetaTerminalAuth = request.clientCapabilities?._meta?.["terminal-auth"] === true;
|
||||
|
||||
@@ -602,10 +628,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
loadSession: true,
|
||||
sessionCapabilities: {
|
||||
additionalDirectories: {},
|
||||
close: {},
|
||||
delete: {},
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
close: {},
|
||||
},
|
||||
},
|
||||
agentInfo: {
|
||||
@@ -613,7 +641,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
title: "Claude Agent",
|
||||
version: packageJson.version,
|
||||
},
|
||||
authMethods: [...terminalAuthMethods, ...(supportsGatewayAuth ? [gatewayAuthMethod] : [])],
|
||||
authMethods: [
|
||||
...terminalAuthMethods,
|
||||
...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -634,6 +665,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
@@ -690,8 +722,8 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
|
||||
async authenticate(_params: AuthenticateRequest): Promise<void> {
|
||||
if (_params.methodId === "gateway") {
|
||||
this.gatewayAuthMeta = _params._meta as GatewayAuthMeta | undefined;
|
||||
if (_params.methodId === "gateway" || _params.methodId === "gateway-bedrock") {
|
||||
this.gatewayAuthRequest = _params as GatewayAuthRequest;
|
||||
return;
|
||||
}
|
||||
throw new Error("Method not implemented.");
|
||||
@@ -721,6 +753,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// forward it to clients as structured `data`, sparing them from
|
||||
// pattern-matching on the human-readable message text.
|
||||
let lastAssistantError: SDKAssistantMessageError | undefined;
|
||||
// Tracks whether we're inside a compaction. The SDK emits the terminal
|
||||
// `status` (compact_result success/failed) twice for a single failed
|
||||
// compaction, and the two messages are indistinguishable — so we report the
|
||||
// outcome only while a compaction is in progress, then clear this. A fresh
|
||||
// `compacting` status sets it again, so every distinct compaction (e.g.
|
||||
// repeated auto-compactions in a long turn) is still shown.
|
||||
let compactionInProgress = false;
|
||||
|
||||
const userMessage = promptToClaude(params);
|
||||
|
||||
@@ -749,6 +788,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
session.promptRunning = true;
|
||||
let handedOff = false;
|
||||
let errored = false;
|
||||
let stopReason: StopReason = "end_turn";
|
||||
|
||||
try {
|
||||
@@ -779,6 +819,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
case "status": {
|
||||
if (message.status === "compacting") {
|
||||
compactionInProgress = true;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
@@ -786,6 +827,28 @@ export class ClaudeAcpAgent implements Agent {
|
||||
content: { type: "text", text: "Compacting..." },
|
||||
},
|
||||
});
|
||||
} else if (message.compact_result === "success" && compactionInProgress) {
|
||||
// The SDK signals manual `/compact` completion with a status
|
||||
// message carrying `compact_result`, not the `compact_boundary`
|
||||
// message (which only fires when there's content to compact).
|
||||
compactionInProgress = false;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "\n\nCompacting completed." },
|
||||
},
|
||||
});
|
||||
} else if (message.compact_result === "failed" && compactionInProgress) {
|
||||
compactionInProgress = false;
|
||||
const reason = message.compact_error ? `: ${message.compact_error}` : ".";
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: `\n\nCompacting failed${reason}` },
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
@@ -801,6 +864,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
// The alternative (no update) leaves the client showing e.g.
|
||||
// "944k/1m" right after the user sees "Compacting completed",
|
||||
// which is confusing and wrong.
|
||||
//
|
||||
// The "Compacting completed." text is emitted from the `status`
|
||||
// handler (keyed on `compact_result`), not here, so the failure
|
||||
// path gets a message too.
|
||||
lastAssistantTotalUsage = 0;
|
||||
lastAssistantUsage = null;
|
||||
await this.client.sessionUpdate({
|
||||
@@ -811,13 +878,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
size: session.contextWindowSize,
|
||||
},
|
||||
});
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "\n\nCompacting completed." },
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "local_command_output": {
|
||||
@@ -832,10 +892,53 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
case "session_state_changed": {
|
||||
if (message.state === "idle") {
|
||||
if (session.cancelled) {
|
||||
stopReason = "cancelled";
|
||||
}
|
||||
return { stopReason, usage: sessionUsage(session) };
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "memory_recall": {
|
||||
const isSynthesis = message.mode === "synthesize";
|
||||
const locations = isSynthesis
|
||||
? []
|
||||
: message.memories.map((m) => ({ path: m.path }));
|
||||
const content = isSynthesis
|
||||
? message.memories
|
||||
.filter(
|
||||
(m): m is (typeof message.memories)[number] & { content: string } =>
|
||||
typeof m.content === "string",
|
||||
)
|
||||
.map((m) => ({
|
||||
type: "content" as const,
|
||||
content: { type: "text" as const, text: m.content },
|
||||
}))
|
||||
: [];
|
||||
const count = message.memories.length;
|
||||
const title = isSynthesis
|
||||
? "Recalled synthesized memory"
|
||||
: `Recalled ${count} ${count === 1 ? "memory" : "memories"}`;
|
||||
await this.client.sessionUpdate({
|
||||
sessionId: message.session_id,
|
||||
update: {
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: message.uuid,
|
||||
title,
|
||||
kind: "read",
|
||||
status: "completed",
|
||||
...(locations.length > 0 && { locations }),
|
||||
...(content.length > 0 && { content }),
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolName: "memory_recall",
|
||||
toolResponse: { mode: message.mode },
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
},
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "hook_started":
|
||||
case "hook_progress":
|
||||
case "hook_response":
|
||||
@@ -846,10 +949,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "task_updated":
|
||||
case "elicitation_complete":
|
||||
case "plugin_install":
|
||||
case "memory_recall":
|
||||
case "notification":
|
||||
case "api_retry":
|
||||
case "mirror_error":
|
||||
case "permission_denied":
|
||||
case "thinking_tokens":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
default:
|
||||
@@ -1040,6 +1144,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
{
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
@@ -1088,13 +1193,37 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
// Slash commands like /compact can generate invalid output... doesn't match
|
||||
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
|
||||
// Strip <command-*>/<local-command-stdout> markers and render any
|
||||
// remaining prose. Skill bodies and built-in slash commands (e.g.
|
||||
// /usage, /status, /model) arrive wrapped in these tags; pure-marker
|
||||
// payloads (e.g. /compact's malformed output) strip to null and are
|
||||
// skipped. Mirrors the replay path at replaySessionHistory.
|
||||
if (
|
||||
message.message.role !== "system" &&
|
||||
typeof message.message.content === "string" &&
|
||||
message.message.content.includes("<local-command-stdout>")
|
||||
) {
|
||||
this.logger.log(message.message.content);
|
||||
const stripped = stripLocalCommandMetadata(message.message.content);
|
||||
if (typeof stripped === "string") {
|
||||
for (const notification of toAcpNotifications(
|
||||
stripped,
|
||||
message.message.role,
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
} else {
|
||||
this.logger.log(message.message.content);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1115,6 +1244,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (message.message.role === "system") {
|
||||
break;
|
||||
}
|
||||
|
||||
if (
|
||||
message.type === "assistant" &&
|
||||
@@ -1146,6 +1278,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
@@ -1165,6 +1298,34 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
throw new Error("Session did not end in result");
|
||||
} catch (error) {
|
||||
errored = true;
|
||||
// A failed turn typically leaves a trailing `session_state_changed: idle`
|
||||
// (and possibly more) in the query iterator. If we don't drain it here,
|
||||
// the next prompt's first `query.next()` consumes that stale idle and
|
||||
// short-circuits to end_turn with zero usage
|
||||
// Bounded so a misbehaving SDK can't hang the next prompt indefinitely.
|
||||
try {
|
||||
await session.query.interrupt();
|
||||
const MAX_DRAIN = 100;
|
||||
for (let i = 0; i < MAX_DRAIN; i++) {
|
||||
const { value: m, done } = await session.query.next();
|
||||
if (done || !m) break;
|
||||
if (m.type === "system" && m.subtype === "session_state_changed" && m.state === "idle") {
|
||||
break;
|
||||
}
|
||||
if (i === MAX_DRAIN - 1) {
|
||||
this.logger.error(
|
||||
`Session ${params.sessionId}: drained ${MAX_DRAIN} messages after error without observing idle`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (drainErr) {
|
||||
this.logger.error(
|
||||
`Session ${params.sessionId}: failed to drain query after prompt error:`,
|
||||
drainErr,
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof RequestError || !(error instanceof Error)) {
|
||||
throw error;
|
||||
}
|
||||
@@ -1189,10 +1350,19 @@ export class ClaudeAcpAgent implements Agent {
|
||||
} finally {
|
||||
if (!handedOff) {
|
||||
session.promptRunning = false;
|
||||
// This usually should not happen, but in case the loop finishes
|
||||
// without claude sending all message replays, we resolve the
|
||||
// next pending prompt call to ensure no prompts get stuck.
|
||||
if (session.pendingMessages.size > 0) {
|
||||
if (errored) {
|
||||
// The query stream was just drained — handing pending prompts off
|
||||
// onto it would let them race with the recovery. Cancel them so
|
||||
// each waiting prompt() returns stopReason: "cancelled" and the
|
||||
// client can decide whether to retry.
|
||||
for (const pending of session.pendingMessages.values()) {
|
||||
pending.resolve(true);
|
||||
}
|
||||
session.pendingMessages.clear();
|
||||
} else if (session.pendingMessages.size > 0) {
|
||||
// This usually should not happen, but in case the loop finishes
|
||||
// without claude sending all message replays, we resolve the
|
||||
// next pending prompt call to ensure no prompts get stuck.
|
||||
const next = [...session.pendingMessages.entries()].sort(
|
||||
(a, b) => a[1].order - b[1].order,
|
||||
)[0];
|
||||
@@ -1245,6 +1415,16 @@ export class ClaudeAcpAgent implements Agent {
|
||||
return {};
|
||||
}
|
||||
|
||||
async unstable_deleteSession(params: DeleteSessionRequest): Promise<DeleteSessionResponse> {
|
||||
// Tear down any active in-memory state first so the on-disk file isn't
|
||||
// recreated by an outstanding query writing to it.
|
||||
if (this.sessions[params.sessionId]) {
|
||||
await this.teardownSession(params.sessionId);
|
||||
}
|
||||
await deleteSession(params.sessionId);
|
||||
return {};
|
||||
}
|
||||
|
||||
async unstable_setSessionModel(
|
||||
params: SetSessionModelRequest,
|
||||
): Promise<SetSessionModelResponse | void> {
|
||||
@@ -1399,6 +1579,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
registerHooks: false,
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
cwd: this.sessions[sessionId]?.cwd,
|
||||
taskState: this.sessions[sessionId]?.taskState,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
@@ -1429,7 +1610,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
|
||||
if (toolName === "ExitPlanMode") {
|
||||
const optionsAll = [
|
||||
const optionsAll: PermissionOption[] = [
|
||||
{ kind: "allow_always", name: 'Yes, and use "auto" mode', optionId: "auto" },
|
||||
{
|
||||
kind: "allow_always",
|
||||
@@ -1673,7 +1854,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
typeof newEffortOpt?.currentValue === "string" ? newEffortOpt.currentValue : undefined;
|
||||
if (newEffort !== currentEffort) {
|
||||
await session.query.applyFlagSettings({
|
||||
effortLevel: newEffort as Settings["effortLevel"],
|
||||
effortLevel: toSdkEffortLevel(newEffort),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1698,7 +1879,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
);
|
||||
if (configId === "effort") {
|
||||
await session.query.applyFlagSettings({
|
||||
effortLevel: value as Settings["effortLevel"],
|
||||
effortLevel: toSdkEffortLevel(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1708,6 +1889,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
sessionId: string;
|
||||
cwd: string;
|
||||
mcpServers?: NewSessionRequest["mcpServers"];
|
||||
additionalDirectories?: NewSessionRequest["additionalDirectories"];
|
||||
_meta?: NewSessionRequest["_meta"];
|
||||
}): Promise<NewSessionResponse> {
|
||||
const existingSession = this.sessions[params.sessionId];
|
||||
@@ -1732,6 +1914,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
@@ -1781,7 +1964,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
? Object.fromEntries(server.headers.map((e) => [e.name, e.value]))
|
||||
: undefined,
|
||||
};
|
||||
} else {
|
||||
} else if (!("type" in server)) {
|
||||
// Stdio type MCP server (with or without explicit type field)
|
||||
mcpServers[server.name] = {
|
||||
type: "stdio",
|
||||
@@ -1845,6 +2028,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
const abortController = userProvidedOptions?.abortController || new AbortController();
|
||||
|
||||
// Per-session task state. Created here (rather than in the session record
|
||||
// below) so the TaskCreated/TaskCompleted hook callbacks can close over
|
||||
// the same Map that the streaming message handler will read from.
|
||||
const taskState: TaskState = new Map();
|
||||
|
||||
const options: Options = {
|
||||
systemPrompt,
|
||||
settingSources: ["user", "project", "local"],
|
||||
@@ -1864,7 +2052,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
env: {
|
||||
...process.env,
|
||||
...userProvidedOptions?.env,
|
||||
...createEnvForGateway(this.gatewayAuthMeta),
|
||||
...createEnvForGateway(this.gatewayAuthRequest),
|
||||
// Opt-in to session state events like when the agent is idle
|
||||
CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: "1",
|
||||
},
|
||||
@@ -1905,14 +2093,58 @@ export class ClaudeAcpAgent implements Agent {
|
||||
],
|
||||
},
|
||||
],
|
||||
TaskCreated: [
|
||||
...(userProvidedOptions?.hooks?.TaskCreated || []),
|
||||
{
|
||||
hooks: [
|
||||
createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "plan",
|
||||
entries: taskStateToPlanEntries(taskState),
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
TaskCompleted: [
|
||||
...(userProvidedOptions?.hooks?.TaskCompleted || []),
|
||||
{
|
||||
hooks: [
|
||||
createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "plan",
|
||||
entries: taskStateToPlanEntries(taskState),
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...creationOpts,
|
||||
abortController,
|
||||
};
|
||||
|
||||
// Prefer the official ACP `additionalDirectories` field. Fall back to the
|
||||
// legacy `_meta.additionalRoots` extension for clients that haven't been
|
||||
// updated yet. Either source is merged with directories supplied via
|
||||
// `_meta.claudeCode.options.additionalDirectories` (SDK pass-through).
|
||||
const acpAdditionalDirectories =
|
||||
params.additionalDirectories ?? sessionMeta?.additionalRoots ?? [];
|
||||
options.additionalDirectories = [
|
||||
...(userProvidedOptions?.additionalDirectories ?? []),
|
||||
...(sessionMeta?.additionalRoots ?? []),
|
||||
...acpAdditionalDirectories,
|
||||
];
|
||||
|
||||
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
||||
@@ -1948,7 +2180,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (
|
||||
shouldHideClaudeAuth() &&
|
||||
initializationResult.account.subscriptionType &&
|
||||
!this.gatewayAuthMeta
|
||||
!this.gatewayAuthRequest
|
||||
) {
|
||||
throw RequestError.authRequired(
|
||||
undefined,
|
||||
@@ -1966,7 +2198,13 @@ export class ClaudeAcpAgent implements Agent {
|
||||
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels)
|
||||
: initializationResult.models;
|
||||
|
||||
const models = await getAvailableModels(q, allowedModels, settingsManager, this.logger);
|
||||
const models = await getAvailableModels(
|
||||
q,
|
||||
allowedModels,
|
||||
initializationResult.models,
|
||||
settingsManager,
|
||||
this.logger,
|
||||
);
|
||||
|
||||
// Gate `auto` (and future model-specific modes) on the resolved model's
|
||||
// `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
|
||||
@@ -2017,7 +2255,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
// 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") {
|
||||
if (
|
||||
initialEffort &&
|
||||
typeof initialEffort.currentValue === "string" &&
|
||||
initialEffort.currentValue !== "default"
|
||||
) {
|
||||
await q.applyFlagSettings({
|
||||
effortLevel: initialEffort.currentValue as Settings["effortLevel"],
|
||||
});
|
||||
@@ -2047,6 +2289,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
contextWindowSize:
|
||||
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
taskState,
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -2137,16 +2380,26 @@ function snapshotFromUsage(usage: {
|
||||
};
|
||||
}
|
||||
|
||||
function createEnvForGateway(gatewayMeta?: GatewayAuthMeta) {
|
||||
if (!gatewayMeta) {
|
||||
function createEnvForGateway(request?: GatewayAuthRequest) {
|
||||
if (!request?._meta) {
|
||||
return {};
|
||||
}
|
||||
const customHeaders = Object.entries(request._meta.gateway.headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n");
|
||||
|
||||
if (request.methodId === "gateway-bedrock") {
|
||||
return {
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
AWS_BEARER_TOKEN_BEDROCK: " ", // Must be non-empty to bypass pass configuration check
|
||||
ANTHROPIC_BEDROCK_BASE_URL: request._meta.gateway.baseUrl,
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ANTHROPIC_BASE_URL: gatewayMeta.gateway.baseUrl,
|
||||
ANTHROPIC_CUSTOM_HEADERS: Object.entries(gatewayMeta.gateway.headers)
|
||||
.map(([key, value]) => `${key}: ${value}`)
|
||||
.join("\n"),
|
||||
ANTHROPIC_AUTH_TOKEN: "", // Must be specified to bypass claude login requirement
|
||||
ANTHROPIC_BASE_URL: request._meta.gateway.baseUrl,
|
||||
ANTHROPIC_CUSTOM_HEADERS: customHeaders,
|
||||
ANTHROPIC_AUTH_TOKEN: " ", // Must be specified to bypass claude login requirement
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2202,6 +2455,16 @@ function buildAvailableModes(modelInfo: ModelInfo | undefined): SessionModeState
|
||||
return modes;
|
||||
}
|
||||
|
||||
// Translate a UI effort value into the flag-layer payload. The SDK
|
||||
// shallow-merges `applyFlagSettings`, drops `undefined` during JSON transport,
|
||||
// and only clears a key when an explicit `null` is sent — see
|
||||
// `applyFlagSettings` in @anthropic-ai/claude-agent-sdk. Mapping both the
|
||||
// `"default"` sentinel and `undefined` (effort option absent for the model) to
|
||||
// `null` ensures any previously-applied flag is actually cleared.
|
||||
function toSdkEffortLevel(value: string | undefined): Settings["effortLevel"] | null {
|
||||
return value === undefined || value === "default" ? null : (value as Settings["effortLevel"]);
|
||||
}
|
||||
|
||||
function buildConfigOptions(
|
||||
modes: SessionModeState,
|
||||
models: SessionModelState,
|
||||
@@ -2244,25 +2507,20 @@ function buildConfigOptions(
|
||||
: [];
|
||||
|
||||
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(" "),
|
||||
}));
|
||||
const effortOptions = [
|
||||
{ value: "default", name: "Default" },
|
||||
...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 includes = (l: string) => l === "default" || (supportedLevels as string[]).includes(l);
|
||||
const validEffort =
|
||||
currentEffortLevel && includes(currentEffortLevel)
|
||||
? currentEffortLevel
|
||||
: includes("xhigh")
|
||||
? "xhigh"
|
||||
: includes("high")
|
||||
? "high"
|
||||
: supportedLevels[0];
|
||||
currentEffortLevel && includes(currentEffortLevel) ? currentEffortLevel : "default";
|
||||
|
||||
options.push({
|
||||
id: "effort",
|
||||
@@ -2282,6 +2540,27 @@ function buildConfigOptions(
|
||||
// but the SDK model list uses IDs like "claude-opus-4-6-1m".
|
||||
const MODEL_CONTEXT_HINT_PATTERN = /\[(\d+m)\]$/i;
|
||||
|
||||
// Captures a model family version such as `4-6` or `4.7` so we can keep
|
||||
// `claude-opus-4-6` from being copied onto the SDK's `opus` alias when that
|
||||
// alias currently resolves to a different family version (e.g. Opus 4.7).
|
||||
const MODEL_FAMILY_VERSION_PATTERN = /\b(\d+)[-.](\d+)\b/;
|
||||
|
||||
function extractModelFamilyVersion(s: string): string | null {
|
||||
const match = s.match(MODEL_FAMILY_VERSION_PATTERN);
|
||||
return match ? `${match[1]}.${match[2]}` : null;
|
||||
}
|
||||
|
||||
function modelVersionsCompatible(preference: string, candidate: ModelInfo): boolean {
|
||||
const preferred = extractModelFamilyVersion(preference);
|
||||
if (!preferred) return true;
|
||||
const candidateVersion =
|
||||
extractModelFamilyVersion(candidate.value) ??
|
||||
extractModelFamilyVersion(candidate.displayName) ??
|
||||
extractModelFamilyVersion(candidate.description);
|
||||
if (!candidateVersion) return true;
|
||||
return preferred === candidateVersion;
|
||||
}
|
||||
|
||||
function tokenizeModelPreference(model: string): { tokens: string[]; contextHint?: string } {
|
||||
const lower = model.trim().toLowerCase();
|
||||
const contextHint = lower.match(MODEL_CONTEXT_HINT_PATTERN)?.[1]?.toLowerCase();
|
||||
@@ -2328,6 +2607,7 @@ function resolveModelPreference(models: ModelInfo[], preference: string): ModelI
|
||||
|
||||
// Substring match
|
||||
const includesMatch = models.find((model) => {
|
||||
if (!modelVersionsCompatible(trimmed, model)) return false;
|
||||
const value = model.value.toLowerCase();
|
||||
const display = model.displayName.toLowerCase();
|
||||
return value.includes(lower) || display.includes(lower) || lower.includes(value);
|
||||
@@ -2341,6 +2621,7 @@ function resolveModelPreference(models: ModelInfo[], preference: string): ModelI
|
||||
let bestMatch: ModelInfo | null = null;
|
||||
let bestScore = 0;
|
||||
for (const model of models) {
|
||||
if (!modelVersionsCompatible(trimmed, model)) continue;
|
||||
const score = scoreModelMatch(model, tokens, contextHint);
|
||||
if (0 < score && (!bestMatch || bestScore < score)) {
|
||||
bestMatch = model;
|
||||
@@ -2416,12 +2697,14 @@ function applyAvailableModelsAllowlist(sdkModels: ModelInfo[], allowlist: string
|
||||
async function getAvailableModels(
|
||||
query: Query,
|
||||
models: ModelInfo[],
|
||||
sdkModels: ModelInfo[],
|
||||
settingsManager: SettingsManager,
|
||||
logger: Logger,
|
||||
): Promise<SessionModelState> {
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
let currentModel = models[0];
|
||||
let resolvedFromInput: string | undefined;
|
||||
|
||||
// Model priority (highest to lowest):
|
||||
// 1. ANTHROPIC_MODEL environment variable
|
||||
@@ -2431,15 +2714,33 @@ async function getAvailableModels(
|
||||
const match = resolveModelPreference(models, process.env.ANTHROPIC_MODEL);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
resolvedFromInput = process.env.ANTHROPIC_MODEL;
|
||||
}
|
||||
} else {
|
||||
} else if (typeof settings.model === "string") {
|
||||
const match = resolveSettingsModel(models, settings.model, logger);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
resolvedFromInput = settings.model;
|
||||
}
|
||||
}
|
||||
|
||||
await query.setModel(currentModel.value);
|
||||
// Skip the setModel round-trip when we can prove the SDK has already landed
|
||||
// on the same model. Two cases qualify:
|
||||
// (a) No override applied — currentModel stayed at models[0]; the SDK is on
|
||||
// its own default and we have nothing to sync.
|
||||
// (b) The resolver returned the user's input verbatim AND that value exists
|
||||
// in the SDK's original model list — meaning no fuzzy match or
|
||||
// allowlist rewrite was involved, and the SDK (which reads the same
|
||||
// ANTHROPIC_MODEL / settings.json) will have arrived at the same entry.
|
||||
// Anything else (fuzzy match, allowlist-synthesized value, alias) gets a
|
||||
// setModel call so we don't drift from the user's intended pin.
|
||||
const sdkSawSameValue = sdkModels.some((m) => m.value === currentModel.value);
|
||||
const skipSetModel =
|
||||
resolvedFromInput === undefined ||
|
||||
(currentModel.value === resolvedFromInput && sdkSawSameValue);
|
||||
if (!skipSetModel) {
|
||||
await query.setModel(currentModel.value);
|
||||
}
|
||||
|
||||
return {
|
||||
availableModels: models.map((model) => ({
|
||||
@@ -2453,6 +2754,7 @@ async function getAvailableModels(
|
||||
|
||||
function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] {
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"clear",
|
||||
"cost",
|
||||
"keybindings-help",
|
||||
"login",
|
||||
@@ -2596,8 +2898,10 @@ export function toAcpNotifications(
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
parentToolUseId?: string | null;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const taskState = options?.taskState ?? new Map();
|
||||
const registerHooks = options?.registerHooks !== false;
|
||||
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
|
||||
if (typeof content === "string") {
|
||||
@@ -2671,6 +2975,15 @@ export function toAcpNotifications(
|
||||
entries: planEntries(chunk.input as { todos: ClaudePlanEntry[] }),
|
||||
};
|
||||
}
|
||||
} else if (
|
||||
chunk.name === "TaskCreate" ||
|
||||
chunk.name === "TaskUpdate" ||
|
||||
chunk.name === "TaskList" ||
|
||||
chunk.name === "TaskGet"
|
||||
) {
|
||||
// Task* tool_use is suppressed; the plan update is emitted at
|
||||
// tool_result time once we have the task ID (for TaskCreate) and
|
||||
// confirmation that the change took effect.
|
||||
} else {
|
||||
// Only register hooks on first encounter to avoid double-firing
|
||||
if (registerHooks && !alreadyCached) {
|
||||
@@ -2775,7 +3088,36 @@ export function toAcpNotifications(
|
||||
break;
|
||||
}
|
||||
|
||||
if (toolUse.name !== "TodoWrite") {
|
||||
if (
|
||||
toolUse.name === "TaskCreate" ||
|
||||
toolUse.name === "TaskUpdate" ||
|
||||
toolUse.name === "TaskList" ||
|
||||
toolUse.name === "TaskGet"
|
||||
) {
|
||||
// Headless/SDK sessions emit Task* tools instead of TodoWrite.
|
||||
// TaskCreate / TaskUpdate mutate the accumulated task list; TaskList
|
||||
// and TaskGet are read-only so we just suppress their tool_call /
|
||||
// tool_result events. The plan update is emitted as a snapshot of
|
||||
// the accumulated state, mirroring the legacy TodoWrite behavior.
|
||||
const isError = "is_error" in chunk && chunk.is_error;
|
||||
if (!isError) {
|
||||
if (toolUse.name === "TaskCreate") {
|
||||
applyTaskCreate(
|
||||
taskState,
|
||||
toolUse.input as Parameters<typeof applyTaskCreate>[1],
|
||||
parseTaskCreateOutput(chunk.content),
|
||||
);
|
||||
} else if (toolUse.name === "TaskUpdate") {
|
||||
applyTaskUpdate(taskState, toolUse.input as Parameters<typeof applyTaskUpdate>[1]);
|
||||
}
|
||||
}
|
||||
if (!isError && (toolUse.name === "TaskCreate" || toolUse.name === "TaskUpdate")) {
|
||||
update = {
|
||||
sessionUpdate: "plan",
|
||||
entries: taskStateToPlanEntries(taskState),
|
||||
};
|
||||
}
|
||||
} else if (toolUse.name !== "TodoWrite") {
|
||||
const { _meta: toolMeta, ...toolUpdate } = toolUpdateFromToolResult(
|
||||
chunk,
|
||||
toolUseCache[chunk.tool_use_id],
|
||||
@@ -2830,6 +3172,7 @@ export function toAcpNotifications(
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
case "advisor_tool_result":
|
||||
case "mid_conv_system":
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -2862,6 +3205,7 @@ export function streamEventToAcpNotifications(
|
||||
options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
@@ -2878,6 +3222,7 @@ export function streamEventToAcpNotifications(
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
},
|
||||
);
|
||||
case "content_block_delta":
|
||||
@@ -2892,9 +3237,14 @@ export function streamEventToAcpNotifications(
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
},
|
||||
);
|
||||
// No content
|
||||
// No content. `ping` is a Messages-API keep-alive event that the SDK's
|
||||
// `BetaRawMessageStreamEvent` union doesn't include even though the
|
||||
// wire format emits it; the `as never` cast lets us no-op it here
|
||||
// instead of letting it fall through to `unreachable`.
|
||||
case "ping" as never:
|
||||
case "message_start":
|
||||
case "message_delta":
|
||||
case "message_stop":
|
||||
|
||||
13
src/index.ts
13
src/index.ts
@@ -1,7 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
// Load managed settings and apply environment variables
|
||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
||||
import { resolveSettings } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
|
||||
if (process.argv.includes("--cli")) {
|
||||
@@ -35,9 +34,13 @@ if (process.argv.includes("--cli")) {
|
||||
process.exit(1);
|
||||
});
|
||||
} else {
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
// Apply env vars from the managed-policy tier before any SDK call so the
|
||||
// SDK subprocess inherits them. Going through resolveSettings (vs. a raw
|
||||
// read of managed-settings.json) also picks up MDM sources on macOS and
|
||||
// HKLM/HKCU on Windows.
|
||||
const policy = await resolveSettings({ settingSources: [] });
|
||||
for (const [key, value] of Object.entries(policy.effective.env ?? {})) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
// stdout is used to send messages to the client
|
||||
|
||||
15
src/lib.ts
15
src/lib.ts
@@ -10,25 +10,14 @@ export {
|
||||
type NewSessionMeta,
|
||||
type SDKMessageFilter,
|
||||
} from "./acp-agent.js";
|
||||
export {
|
||||
loadManagedSettings,
|
||||
applyEnvironmentSettings,
|
||||
nodeToWebReadable,
|
||||
nodeToWebWritable,
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
type SettingsManagerOptions,
|
||||
} from "./settings.js";
|
||||
export { SettingsManager, type SettingsManagerOptions } from "./settings.js";
|
||||
|
||||
// Export types
|
||||
export type { ClaudePlanEntry } from "./tools.js";
|
||||
|
||||
181
src/settings.ts
181
src/settings.ts
@@ -1,5 +1,12 @@
|
||||
import * as fs from "node:fs";
|
||||
import * as path from "node:path";
|
||||
// resolveSettings and filterEscalatingDefaultMode are marked @alpha in the
|
||||
// SDK; API may shift in a future release.
|
||||
import {
|
||||
filterEscalatingDefaultMode,
|
||||
resolveSettings,
|
||||
type Settings,
|
||||
} from "@anthropic-ai/claude-agent-sdk";
|
||||
import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
@@ -14,52 +21,10 @@ import { CLAUDE_CONFIG_DIR } from "./acp-agent.js";
|
||||
* Docs: https://code.claude.com/docs/en/iam#tool-specific-permission-rules
|
||||
*/
|
||||
|
||||
export interface PermissionSettings {
|
||||
defaultMode?: string;
|
||||
}
|
||||
|
||||
export interface ClaudeCodeSettings {
|
||||
permissions?: PermissionSettings;
|
||||
env?: Record<string, string>;
|
||||
model?: string;
|
||||
effortLevel?: string;
|
||||
availableModels?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and parses a JSON settings file, returning an empty object if not found or invalid.
|
||||
* Silently ignores missing files (ENOENT) but logs warnings for other errors
|
||||
* (malformed JSON, permission errors, etc.) to aid debugging.
|
||||
*/
|
||||
async function loadSettingsFile(
|
||||
filePath: string | null,
|
||||
logger?: { error: (...args: any[]) => void },
|
||||
): Promise<ClaudeCodeSettings> {
|
||||
if (!filePath) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
return JSON.parse(content) as ClaudeCodeSettings;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && "code" in error && error.code === "ENOENT") {
|
||||
return {};
|
||||
}
|
||||
logger?.error(`Failed to load settings from ${filePath}:`, error);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the enterprise settings path based on the current platform
|
||||
*/
|
||||
export function getManagedSettingsPath(): string {
|
||||
function getManagedSettingsPath(): string {
|
||||
switch (process.platform) {
|
||||
case "darwin":
|
||||
return "/Library/Application Support/ClaudeCode/managed-settings.json";
|
||||
case "linux":
|
||||
return "/etc/claude-code/managed-settings.json";
|
||||
case "win32":
|
||||
return "C:\\Program Files\\ClaudeCode\\managed-settings.json";
|
||||
default:
|
||||
@@ -73,23 +38,17 @@ export interface SettingsManagerOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages Claude Code settings from multiple sources with proper precedence.
|
||||
* Manages Claude Code settings using the SDK's `resolveSettings` merge engine
|
||||
* so the values we see match what `query()` would observe.
|
||||
*
|
||||
* Settings are loaded from (in order of increasing precedence):
|
||||
* 1. User settings (~/.claude/settings.json)
|
||||
* 2. Project settings (<cwd>/.claude/settings.json)
|
||||
* 3. Local project settings (<cwd>/.claude/settings.local.json)
|
||||
* 4. Enterprise managed settings (platform-specific path)
|
||||
*
|
||||
* The manager watches all settings files for changes and automatically reloads.
|
||||
* Watches the user/project/local/managed settings files for changes and
|
||||
* re-resolves through the SDK on update. Escalating `permissions.defaultMode`
|
||||
* values from repo-committed sources are filtered out via
|
||||
* `filterEscalatingDefaultMode`, matching the CLI's trust policy.
|
||||
*/
|
||||
export class SettingsManager {
|
||||
private cwd: string;
|
||||
private userSettings: ClaudeCodeSettings = {};
|
||||
private projectSettings: ClaudeCodeSettings = {};
|
||||
private localSettings: ClaudeCodeSettings = {};
|
||||
private enterpriseSettings: ClaudeCodeSettings = {};
|
||||
private mergedSettings: ClaudeCodeSettings = {};
|
||||
private effective: Settings = {};
|
||||
private watchers: fs.FSWatcher[] = [];
|
||||
private onChange?: () => void;
|
||||
private logger: { log: (...args: any[]) => void; error: (...args: any[]) => void };
|
||||
@@ -127,107 +86,37 @@ export class SettingsManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the user settings file
|
||||
* Paths the SDK reads when resolving settings for this cwd. Watching the
|
||||
* containing directories means we pick up file creation as well as edits.
|
||||
*/
|
||||
private getUserSettingsPath(): string {
|
||||
return path.join(CLAUDE_CONFIG_DIR, "settings.json");
|
||||
private getWatchedPaths(): string[] {
|
||||
return [
|
||||
path.join(CLAUDE_CONFIG_DIR, "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.json"),
|
||||
path.join(this.cwd, ".claude", "settings.local.json"),
|
||||
getManagedSettingsPath(),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the project settings file
|
||||
*/
|
||||
private getProjectSettingsPath(): string {
|
||||
return path.join(this.cwd, ".claude", "settings.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the local project settings file
|
||||
*/
|
||||
private getLocalSettingsPath(): string {
|
||||
return path.join(this.cwd, ".claude", "settings.local.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads settings from all sources
|
||||
* Resolves the effective settings via the SDK and applies the CLI's trust
|
||||
* filter for escalating `permissions.defaultMode` values.
|
||||
*/
|
||||
private async loadAllSettings(): Promise<void> {
|
||||
const [userSettings, projectSettings, localSettings, enterpriseSettings] = await Promise.all([
|
||||
loadSettingsFile(this.getUserSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getProjectSettingsPath(), this.logger),
|
||||
loadSettingsFile(this.getLocalSettingsPath(), this.logger),
|
||||
loadSettingsFile(getManagedSettingsPath(), this.logger),
|
||||
]);
|
||||
|
||||
this.userSettings = userSettings;
|
||||
this.projectSettings = projectSettings;
|
||||
this.localSettings = localSettings;
|
||||
this.enterpriseSettings = enterpriseSettings;
|
||||
|
||||
this.mergeSettings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges all settings sources with proper precedence.
|
||||
* For permissions, rules from all sources are combined.
|
||||
* Deny rules always take precedence during permission checks.
|
||||
*/
|
||||
private mergeSettings(): void {
|
||||
const allSettings = [
|
||||
this.userSettings,
|
||||
this.projectSettings,
|
||||
this.localSettings,
|
||||
this.enterpriseSettings,
|
||||
];
|
||||
|
||||
const merged: ClaudeCodeSettings = {};
|
||||
|
||||
for (const settings of allSettings) {
|
||||
if (settings.env) {
|
||||
merged.env = { ...merged.env, ...settings.env };
|
||||
}
|
||||
|
||||
if (settings.model) {
|
||||
merged.model = settings.model;
|
||||
}
|
||||
|
||||
if (settings.effortLevel !== undefined) {
|
||||
merged.effortLevel = settings.effortLevel;
|
||||
}
|
||||
|
||||
if (settings.availableModels !== undefined) {
|
||||
// Per Claude Code docs: "When `availableModels` is set at multiple
|
||||
// levels, such as user settings and project settings, arrays are
|
||||
// merged and deduplicated."
|
||||
// https://code.claude.com/docs/en/model-config#merge-behavior
|
||||
const combined = [...(merged.availableModels ?? []), ...settings.availableModels];
|
||||
merged.availableModels = Array.from(new Set(combined));
|
||||
}
|
||||
|
||||
if (settings.permissions?.defaultMode !== undefined) {
|
||||
merged.permissions = {
|
||||
...merged.permissions,
|
||||
defaultMode: settings.permissions.defaultMode,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const resolved = await resolveSettings({ cwd: this.cwd });
|
||||
this.effective = filterEscalatingDefaultMode(resolved);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to resolve settings:", error);
|
||||
this.effective = {};
|
||||
}
|
||||
|
||||
this.mergedSettings = merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up file watchers for all settings files
|
||||
*/
|
||||
private setupWatchers(): void {
|
||||
const paths = [
|
||||
this.getUserSettingsPath(),
|
||||
this.getProjectSettingsPath(),
|
||||
this.getLocalSettingsPath(),
|
||||
getManagedSettingsPath(),
|
||||
];
|
||||
|
||||
for (const filePath of paths) {
|
||||
if (!filePath) continue;
|
||||
|
||||
for (const filePath of this.getWatchedPaths()) {
|
||||
try {
|
||||
const dir = path.dirname(filePath);
|
||||
const filename = path.basename(filePath);
|
||||
@@ -278,8 +167,8 @@ export class SettingsManager {
|
||||
/**
|
||||
* Returns the current merged settings
|
||||
*/
|
||||
getSettings(): ClaudeCodeSettings {
|
||||
return this.mergedSettings;
|
||||
getSettings(): Settings {
|
||||
return this.effective;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -3,4 +3,4 @@ description: Say hello
|
||||
argument-hint: name
|
||||
---
|
||||
|
||||
Respond with "Hello $1" and nothing else.
|
||||
Respond with "Hello $ARGUMENTS" and nothing else.
|
||||
|
||||
@@ -126,6 +126,31 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(response.modes.currentModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
it("drops escalating defaultMode when it comes from project-tier settings", async () => {
|
||||
// bypassPermissions in .claude/settings.json (a repo-committed tier) is
|
||||
// filtered by the SDK's trust policy and clamps to 'default'.
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(path.join(projectDir, ".claude"), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(projectDir, ".claude", "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "bypassPermissions" } }),
|
||||
);
|
||||
|
||||
const { getCapturedOptions } = 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 },
|
||||
});
|
||||
|
||||
expect(getCapturedOptions().permissionMode).toBe("default");
|
||||
expect(response.modes.currentModeId).toBe("default");
|
||||
});
|
||||
|
||||
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
@@ -197,7 +222,9 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
|
||||
// Bad model is ignored at the usage site; falls back to the first SDK model.
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-6");
|
||||
// No setModel call is needed because no override was applied — the SDK is
|
||||
// already on its own default.
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
expect(response.models.currentModelId).toBe("claude-sonnet-4-6");
|
||||
});
|
||||
|
||||
@@ -553,6 +580,68 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5");
|
||||
expect(response.models.currentModelId).toBe("claude-haiku-4-5");
|
||||
});
|
||||
|
||||
it("does not inherit display info across mismatched model family versions", async () => {
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/639:
|
||||
// when the SDK's `opus` alias resolves to Opus 4.7, an allowlist entry
|
||||
// of `claude-opus-4-6` (or `claude-opus-4-6[1m]`) used to substring-match
|
||||
// `opus` and inherit the "Opus 4.7" display info. With version-aware
|
||||
// matching, these entries fall back to showing their literal ID rather
|
||||
// than a misleading newer name.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: [
|
||||
"claude-opus-4-6",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-opus-4-7",
|
||||
"claude-opus-4-7[1m]",
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{
|
||||
value: "opus",
|
||||
displayName: "Opus 4.7",
|
||||
description: "Claude Opus 4.7 — complex tasks, higher cost",
|
||||
},
|
||||
{
|
||||
value: "opus[1m]",
|
||||
displayName: "Opus 4.7 (1M context)",
|
||||
description: "Opus 4.7 with 1M context",
|
||||
},
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
const byValue: Record<string, { name: string; description?: string }> = {};
|
||||
for (const opt of modelOption.options) {
|
||||
byValue[opt.value] = { name: opt.name, description: opt.description };
|
||||
}
|
||||
|
||||
// 4-6 entries must NOT inherit the 4.7 SDK alias display info.
|
||||
expect(byValue["claude-opus-4-6"].name).toBe("claude-opus-4-6");
|
||||
expect(byValue["claude-opus-4-6"].description).toBe("");
|
||||
expect(byValue["claude-opus-4-6[1m]"].name).toBe("claude-opus-4-6[1m]");
|
||||
expect(byValue["claude-opus-4-6[1m]"].description).toBe("");
|
||||
|
||||
// 4-7 entries continue to inherit display info from a 4.7 SDK alias.
|
||||
expect(byValue["claude-opus-4-7"].name).toBe("Opus 4.7");
|
||||
expect(byValue["claude-opus-4-7[1m]"].name).toMatch(/Opus 4\.7/);
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves model aliases like opus[1m] to the correct model", async () => {
|
||||
@@ -600,4 +689,94 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-6-1m");
|
||||
expect(response.models.currentModelId).toBe("claude-opus-4-6-1m");
|
||||
});
|
||||
|
||||
it("skips the initial setModel when the resolved value matches the SDK's model list verbatim", async () => {
|
||||
// Covers the launcher case from PR #646: the launcher bakes the model into
|
||||
// ANTHROPIC_MODEL, the SDK already starts on that model, and a second
|
||||
// setModel call would be a redundant round-trip (and on some launcher
|
||||
// setups, more fragile than launch-time selection).
|
||||
const originalEnv = process.env.ANTHROPIC_MODEL;
|
||||
process.env.ANTHROPIC_MODEL = "claude-opus-4-6";
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
{ value: "claude-sonnet-4-6", displayName: "Claude Sonnet 4.6", description: "" },
|
||||
{ value: "claude-opus-4-6", displayName: "Claude Opus 4.6", description: "" },
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
|
||||
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 },
|
||||
});
|
||||
|
||||
expect(setModelSpy).not.toHaveBeenCalled();
|
||||
expect(response.models.currentModelId).toBe("claude-opus-4-6");
|
||||
} finally {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env.ANTHROPIC_MODEL;
|
||||
} else {
|
||||
process.env.ANTHROPIC_MODEL = originalEnv;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("still calls setModel when the allowlist synthesizes a value the SDK has not surfaced", async () => {
|
||||
// The allowlist may rewrite a model's `value` to the user's literal ID
|
||||
// (e.g., `claude-haiku-4-5`) even when the SDK only exposed an alias
|
||||
// (`haiku`). In that case the SDK has not independently arrived at the
|
||||
// user's preferred ID, so we must sync via setModel.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-haiku-4-5"],
|
||||
model: "claude-haiku-4-5",
|
||||
}),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "default", displayName: "Default", description: "" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
],
|
||||
}),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
|
||||
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(setModelSpy).toHaveBeenCalledWith("claude-haiku-4-5");
|
||||
expect(response.models.currentModelId).toBe("claude-haiku-4-5");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
@@ -32,11 +32,20 @@ import {
|
||||
ClaudeAcpAgent,
|
||||
claudeCliPath,
|
||||
describeAlwaysAllow,
|
||||
streamEventToAcpNotifications,
|
||||
type SDKMessageFilter,
|
||||
} from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { deleteSession, query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@anthropic-ai/claude-agent-sdk")>();
|
||||
return {
|
||||
...actual,
|
||||
deleteSession: vi.fn(),
|
||||
};
|
||||
});
|
||||
import type {
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
@@ -230,13 +239,15 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
name: "compact",
|
||||
});
|
||||
|
||||
// Send something
|
||||
await connection.prompt({
|
||||
prompt: [{ type: "text", text: "Hi" }],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
// Clear response
|
||||
client.takeReceivedText();
|
||||
// Build up enough conversation that there's something to compact. The SDK
|
||||
// refuses to compact a conversation with too few message groups.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await connection.prompt({
|
||||
prompt: [{ type: "text", text: `Reply with just the number ${i}.` }],
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
client.takeReceivedText();
|
||||
}
|
||||
|
||||
await connection.prompt({
|
||||
prompt: [
|
||||
@@ -249,7 +260,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed.");
|
||||
}, 30000);
|
||||
}, 60000);
|
||||
});
|
||||
|
||||
describe("tool conversions", () => {
|
||||
@@ -639,6 +650,8 @@ describe("tool conversions", () => {
|
||||
],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
stop_details: null,
|
||||
diagnostics: null,
|
||||
usage: {
|
||||
input_tokens: 6,
|
||||
cache_creation_input_tokens: 326,
|
||||
@@ -652,6 +665,7 @@ describe("tool conversions", () => {
|
||||
server_tool_use: null,
|
||||
inference_geo: null,
|
||||
iterations: null,
|
||||
output_tokens_details: null,
|
||||
speed: null,
|
||||
},
|
||||
context_management: null,
|
||||
@@ -1533,6 +1547,7 @@ describe("stop reason propagation", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1676,6 +1691,7 @@ describe("stop reason propagation", () => {
|
||||
nextPendingOrder: 0,
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1834,6 +1850,7 @@ describe("session/close", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -1883,6 +1900,94 @@ describe("session/close", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/delete", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, sessionId: string) {
|
||||
function* empty() {}
|
||||
const gen = Object.assign(empty(), { interrupt: vi.fn(), close: vi.fn() });
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(deleteSession).mockReset();
|
||||
vi.mocked(deleteSession).mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("tears down the active session and deletes it from disk", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "session-1");
|
||||
|
||||
const result = await agent.unstable_deleteSession({ sessionId: "session-1" });
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(agent.sessions["session-1"]).toBeUndefined();
|
||||
expect(session.query.interrupt).toHaveBeenCalled();
|
||||
expect(session.settingsManager.dispose).toHaveBeenCalled();
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
expect(deleteSession).toHaveBeenCalledWith("session-1");
|
||||
});
|
||||
|
||||
it("deletes a session from disk that is not currently active", async () => {
|
||||
const agent = createMockAgent();
|
||||
|
||||
const result = await agent.unstable_deleteSession({ sessionId: "not-active" });
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(deleteSession).toHaveBeenCalledWith("not-active");
|
||||
});
|
||||
|
||||
it("propagates errors from the SDK delete call", async () => {
|
||||
const agent = createMockAgent();
|
||||
vi.mocked(deleteSession).mockRejectedValueOnce(new Error("Session not found on disk"));
|
||||
|
||||
await expect(agent.unstable_deleteSession({ sessionId: "missing" })).rejects.toThrow(
|
||||
"Session not found on disk",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not affect other sessions when deleting one", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, "session-a");
|
||||
injectSession(agent, "session-b");
|
||||
|
||||
await agent.unstable_deleteSession({ sessionId: "session-a" });
|
||||
|
||||
expect(agent.sessions["session-a"]).toBeUndefined();
|
||||
expect(agent.sessions["session-b"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("getOrCreateSession param change detection", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
@@ -1930,6 +2035,7 @@ describe("getOrCreateSession param change detection", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -2164,6 +2270,7 @@ describe("usage_update computation", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3063,6 +3170,7 @@ describe("emitRawSDKMessages", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3290,6 +3398,7 @@ describe("result origin handling", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3411,3 +3520,394 @@ describe("result origin handling", () => {
|
||||
expect(response.stopReason).toBe("max_tokens");
|
||||
});
|
||||
});
|
||||
|
||||
describe("memory_recall handling", () => {
|
||||
function createMockAgentWithCapture() {
|
||||
const updates: any[] = [];
|
||||
const mockClient = {
|
||||
sessionUpdate: async (notification: any) => {
|
||||
updates.push(notification);
|
||||
},
|
||||
} as unknown as AgentSideConnection;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, updates };
|
||||
}
|
||||
|
||||
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
|
||||
const input = new Pushable<any>();
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage, done } = await iter.next();
|
||||
if (!done && userMessage) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
yield* messages;
|
||||
}
|
||||
agent.sessions["test-session"] = {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
}
|
||||
|
||||
function createResult() {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
stop_reason: "end_turn",
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
it("emits a synthetic tool_call for select mode with one location per memory", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
const recallUuid = randomUUID();
|
||||
injectSession(agent, [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "memory_recall",
|
||||
mode: "select",
|
||||
memories: [
|
||||
{ path: "/Users/test/.claude/memory/user_role.md", scope: "personal" },
|
||||
{ path: "/Users/test/.claude/memory/feedback_testing.md", scope: "personal" },
|
||||
{ path: "/Users/test/.claude/team/conventions.md", scope: "team" },
|
||||
],
|
||||
uuid: recallUuid,
|
||||
session_id: "test-session",
|
||||
},
|
||||
createResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const toolCall = updates.find((u: any) => u.update?.sessionUpdate === "tool_call");
|
||||
expect(toolCall).toBeDefined();
|
||||
expect(toolCall.update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: recallUuid,
|
||||
title: "Recalled 3 memories",
|
||||
kind: "read",
|
||||
status: "completed",
|
||||
locations: [
|
||||
{ path: "/Users/test/.claude/memory/user_role.md" },
|
||||
{ path: "/Users/test/.claude/memory/feedback_testing.md" },
|
||||
{ path: "/Users/test/.claude/team/conventions.md" },
|
||||
],
|
||||
_meta: {
|
||||
claudeCode: { toolName: "memory_recall", toolResponse: { mode: "select" } },
|
||||
},
|
||||
});
|
||||
expect(toolCall.update.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("uses singular 'memory' in title when exactly one entry", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "memory_recall",
|
||||
mode: "select",
|
||||
memories: [{ path: "/Users/test/.claude/memory/user_role.md", scope: "personal" }],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
},
|
||||
createResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const toolCall = updates.find((u: any) => u.update?.sessionUpdate === "tool_call");
|
||||
expect(toolCall.update.title).toBe("Recalled 1 memory");
|
||||
});
|
||||
|
||||
it("emits synthesis content and no locations for synthesize mode", async () => {
|
||||
const { agent, updates } = createMockAgentWithCapture();
|
||||
injectSession(agent, [
|
||||
{
|
||||
type: "system",
|
||||
subtype: "memory_recall",
|
||||
mode: "synthesize",
|
||||
memories: [
|
||||
{
|
||||
path: "<synthesis:/Users/test/.claude/memory>",
|
||||
scope: "personal",
|
||||
content: "The user prefers terse responses and writes Go.",
|
||||
},
|
||||
],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
},
|
||||
createResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await agent.prompt({ sessionId: "test-session", prompt: [{ type: "text", text: "hi" }] });
|
||||
|
||||
const toolCall = updates.find((u: any) => u.update?.sessionUpdate === "tool_call");
|
||||
expect(toolCall).toBeDefined();
|
||||
expect(toolCall.update.title).toBe("Recalled synthesized memory");
|
||||
expect(toolCall.update.locations).toBeUndefined();
|
||||
expect(toolCall.update.content).toEqual([
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "The user prefers terse responses and writes Go." },
|
||||
},
|
||||
]);
|
||||
expect(toolCall.update._meta.claudeCode.toolResponse).toEqual({ mode: "synthesize" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("post-error recovery", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
sessionUpdate: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
return new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
}
|
||||
|
||||
function createResultMessage(overrides: {
|
||||
subtype: "success" | "error_during_execution";
|
||||
stop_reason: string | null;
|
||||
is_error: boolean;
|
||||
result?: string;
|
||||
errors?: string[];
|
||||
}) {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: overrides.subtype,
|
||||
stop_reason: overrides.stop_reason,
|
||||
is_error: overrides.is_error,
|
||||
result: overrides.result ?? "",
|
||||
errors: overrides.errors ?? [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
// Two-turn generator: turn 1 yields the caller-supplied `firstTurn`
|
||||
// messages (including a trailing idle that the drain must consume).
|
||||
// Turn 2 yields a clean success + idle, used to verify the next prompt
|
||||
// sees real messages rather than the stale idle.
|
||||
function injectTwoTurnSession(agent: ClaudeAcpAgent, firstTurn: unknown[]) {
|
||||
const input = new Pushable<any>();
|
||||
const interrupt = vi.fn(async () => {});
|
||||
const close = vi.fn();
|
||||
async function* messageGenerator() {
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
|
||||
const first = await iter.next();
|
||||
if (!first.done && first.value) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: first.value.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: first.value.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
yield* firstTurn;
|
||||
|
||||
const second = await iter.next();
|
||||
if (!second.done && second.value) {
|
||||
yield {
|
||||
type: "user",
|
||||
message: second.value.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: second.value.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
}
|
||||
yield createResultMessage({ subtype: "success", stop_reason: null, is_error: false });
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
const gen = Object.assign(messageGenerator(), { interrupt, close });
|
||||
agent.sessions["test-session"] = {
|
||||
query: gen as any,
|
||||
input,
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
sessionFingerprint: JSON.stringify({ cwd: "/test", mcpServers: [] }),
|
||||
modes: { currentModeId: "default", availableModes: [] },
|
||||
models: { currentModelId: "default", availableModels: [] },
|
||||
modelInfos: [],
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
};
|
||||
return { interrupt };
|
||||
}
|
||||
|
||||
it("drains a failed turn's trailing idle so the next prompt is not short-circuited", async () => {
|
||||
const agent = createMockAgent();
|
||||
const { interrupt } = injectTwoTurnSession(agent, [
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "boom",
|
||||
}),
|
||||
// Trailing idle from the failed turn. Without draining, the next
|
||||
// prompt's first query.next() would consume this and short-circuit
|
||||
// to end_turn with zero usage (issue #654).
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
await expect(
|
||||
agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
expect(interrupt).toHaveBeenCalled();
|
||||
|
||||
const second = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "second" }],
|
||||
});
|
||||
expect(second.stopReason).toBe("end_turn");
|
||||
expect(second.usage?.inputTokens).toBe(10);
|
||||
expect(second.usage?.outputTokens).toBe(5);
|
||||
});
|
||||
|
||||
it("cancels all queued pending prompts when a turn errors", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectTwoTurnSession(agent, [
|
||||
createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: "end_turn",
|
||||
is_error: true,
|
||||
result: "boom",
|
||||
}),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
// Simulate two prompts already queued behind the running turn. Both
|
||||
// resolvers should fire with `true` (cancelled) when the running
|
||||
// prompt errors, and the map should be cleared.
|
||||
const session = agent.sessions["test-session"];
|
||||
let resolveA!: (cancelled: boolean) => void;
|
||||
let resolveB!: (cancelled: boolean) => void;
|
||||
const pendingA = new Promise<boolean>((r) => (resolveA = r));
|
||||
const pendingB = new Promise<boolean>((r) => (resolveB = r));
|
||||
session.pendingMessages.set("uuid-a", { resolve: resolveA, order: 0 });
|
||||
session.pendingMessages.set("uuid-b", { resolve: resolveB, order: 1 });
|
||||
|
||||
await expect(
|
||||
agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "first" }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
|
||||
await expect(pendingA).resolves.toBe(true);
|
||||
await expect(pendingB).resolves.toBe(true);
|
||||
expect(session.pendingMessages.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("streamEventToAcpNotifications", () => {
|
||||
it("treats `ping` keep-alive events as no-ops without logging to stderr", () => {
|
||||
const errors: unknown[][] = [];
|
||||
const logger = {
|
||||
log: () => {},
|
||||
error: (...args: unknown[]) => {
|
||||
errors.push(args);
|
||||
},
|
||||
};
|
||||
const pingMessage = {
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
// The SDK's typed `BetaRawMessageStreamEvent` union doesn't include
|
||||
// `ping`, but the API emits it on the wire and the SDK passes it
|
||||
// through. Cast through `unknown` to feed the realistic runtime shape.
|
||||
event: { type: "ping" } as unknown,
|
||||
} as Parameters<typeof streamEventToAcpNotifications>[0];
|
||||
|
||||
const result = streamEventToAcpNotifications(
|
||||
pingMessage,
|
||||
"test-session",
|
||||
{},
|
||||
{ sessionUpdate: async () => {} } as unknown as Parameters<
|
||||
typeof streamEventToAcpNotifications
|
||||
>[3],
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -76,4 +76,33 @@ describe("additionalRoots", () => {
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]);
|
||||
});
|
||||
|
||||
it("prefers the official ACP additionalDirectories field over _meta.additionalRoots", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/from/official"]);
|
||||
});
|
||||
|
||||
it("merges official ACP additionalDirectories with claudeCode SDK additionalDirectories", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
additionalDirectories: ["/from/official"],
|
||||
_meta: { claudeCode: { options: { additionalDirectories: ["/from/sdk"] } } },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/from/sdk", "/from/official"]);
|
||||
});
|
||||
|
||||
it("falls back to _meta.additionalRoots when the official field is omitted", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: { additionalRoots: ["/from/meta"] },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/from/meta"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,10 @@ const mockQuery = vi.hoisted(() =>
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: mockQuery,
|
||||
}));
|
||||
|
||||
@@ -53,6 +56,9 @@ describe("authorization", () => {
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
);
|
||||
expect(initializeResponse.authMethods).not.toContainEqual(
|
||||
expect.objectContaining({ id: "gateway-bedrock" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("gateway auth offered when client advertises auth._meta.gateway capability", async () => {
|
||||
@@ -65,11 +71,20 @@ describe("authorization", () => {
|
||||
} as any,
|
||||
});
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "gateway" }),
|
||||
expect.objectContaining({
|
||||
id: "gateway",
|
||||
_meta: { gateway: { protocol: "anthropic" } },
|
||||
}),
|
||||
);
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "gateway-bedrock",
|
||||
_meta: { gateway: { protocol: "bedrock" } },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses gateway env after gateway auth", async () => {
|
||||
it("uses gateway env after anthropic gateway auth", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
@@ -105,7 +120,7 @@ describe("authorization", () => {
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
ANTHROPIC_AUTH_TOKEN: "",
|
||||
ANTHROPIC_AUTH_TOKEN: " ",
|
||||
ANTHROPIC_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
|
||||
userEnv: "userEnv",
|
||||
@@ -115,6 +130,42 @@ describe("authorization", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses gateway env after gateway auth", async () => {
|
||||
const [agent, mockQuery] = await createAgentMock();
|
||||
|
||||
await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: {
|
||||
auth: { terminal: true, _meta: { gateway: true } },
|
||||
} as any,
|
||||
});
|
||||
|
||||
await agent.authenticate({
|
||||
methodId: "gateway-bedrock",
|
||||
_meta: {
|
||||
gateway: { baseUrl: "https://gateway.example", headers: { "custom-header": "test" } },
|
||||
},
|
||||
});
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "testRoot",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(mockQuery).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
options: expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
CLAUDE_CODE_USE_BEDROCK: "1",
|
||||
AWS_BEARER_TOKEN_BEDROCK: " ",
|
||||
ANTHROPIC_BEDROCK_BASE_URL: "https://gateway.example",
|
||||
ANTHROPIC_CUSTOM_HEADERS: "custom-header: test",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("hide claude authentication without terminal-auth", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
|
||||
|
||||
@@ -65,8 +65,9 @@ const MOCK_CONFIG_OPTIONS = [
|
||||
description: "Available effort levels for this model",
|
||||
type: "select",
|
||||
category: "effort",
|
||||
currentValue: "high",
|
||||
currentValue: "default",
|
||||
options: [
|
||||
{ value: "default", name: "Default" },
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "medium", name: "Medium" },
|
||||
{ value: "high", name: "High" },
|
||||
@@ -445,7 +446,7 @@ describe("session config options", () => {
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
expect(effortOption).toBeUndefined();
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined });
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
});
|
||||
|
||||
it("clamps effort in config_option_update when new model has different supported levels", async () => {
|
||||
@@ -483,8 +484,8 @@ describe("session config options", () => {
|
||||
(o: any) => o.id === "effort",
|
||||
);
|
||||
expect(effortOption).toBeDefined();
|
||||
expect(effortOption.currentValue).toBe("high");
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "high" });
|
||||
expect(effortOption.currentValue).toBe("default");
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
});
|
||||
|
||||
it("preserves effort in config_option_update when new model supports same level", async () => {
|
||||
@@ -557,6 +558,31 @@ describe("session config options", () => {
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: "low" });
|
||||
});
|
||||
|
||||
it("calls applyFlagSettings with null effortLevel for 'default'", async () => {
|
||||
// Set effort to a non-default value 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 = "high";
|
||||
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "effort",
|
||||
value: "default",
|
||||
});
|
||||
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
|
||||
// The SDK's applyFlagSettings travels over a JSON pipe and only clears a
|
||||
// flag-layer key when an explicit `null` is sent — `undefined` is
|
||||
// dropped during JSON.stringify, which would leave the previous effort
|
||||
// override in place. Round-trip the call args through JSON to make sure
|
||||
// the key actually reaches the SDK.
|
||||
const calls = applyFlagSettingsSpy.mock.calls;
|
||||
const lastCallArgs = calls[calls.length - 1]?.[0];
|
||||
const serialized = JSON.parse(JSON.stringify(lastCallArgs));
|
||||
expect(serialized).toHaveProperty("effortLevel", null);
|
||||
});
|
||||
|
||||
it("updates effort currentValue in returned configOptions", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
@@ -663,7 +689,7 @@ describe("session config options", () => {
|
||||
value: "claude-sonnet-4-6",
|
||||
});
|
||||
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: undefined });
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
});
|
||||
|
||||
it("adds effort option when switching to a model that supports effort", async () => {
|
||||
@@ -696,8 +722,8 @@ describe("session config options", () => {
|
||||
|
||||
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");
|
||||
// No previous effort, so defaults to "default" (no effort override)
|
||||
expect(effortOption?.currentValue).toBe("default");
|
||||
});
|
||||
|
||||
it("clamps effort to valid value when new model has different supported levels", async () => {
|
||||
@@ -731,10 +757,10 @@ describe("session config options", () => {
|
||||
|
||||
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" });
|
||||
// "max" is not in sonnet's levels, so should fall back to "default" (no effort override)
|
||||
expect(effortOption?.currentValue).toBe("default");
|
||||
// SDK should be told to clear the effort override
|
||||
expect(applyFlagSettingsSpy).toHaveBeenCalledWith({ effortLevel: null });
|
||||
});
|
||||
|
||||
it("preserves effort value when new model supports the same level", async () => {
|
||||
|
||||
@@ -141,4 +141,66 @@ describe("SettingsManager", () => {
|
||||
expect(settings.permissions?.defaultMode).toBe("plan");
|
||||
});
|
||||
});
|
||||
|
||||
describe("escalating defaultMode trust filter", () => {
|
||||
// The SDK's filterEscalatingDefaultMode drops escalating values
|
||||
// (bypassPermissions / auto / acceptEdits) that came from a repo-committed
|
||||
// tier (.claude/settings.json), preventing a checked-in file from
|
||||
// silently escalating permissions. Local (.claude/settings.local.json)
|
||||
// and user-tier sources are not committed by convention, so escalating
|
||||
// values from those tiers are preserved.
|
||||
|
||||
it.each(["bypassPermissions", "auto", "acceptEdits"] as const)(
|
||||
"drops %s when set in project-tier settings",
|
||||
async (mode) => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: mode } }),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
expect(settingsManager.getSettings().permissions?.defaultMode).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["plan", "dontAsk"] as const)(
|
||||
"preserves non-escalating %s from project-tier settings",
|
||||
async (mode) => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: mode } }),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
expect(settingsManager.getSettings().permissions?.defaultMode).toBe(mode);
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves escalating defaultMode when it comes from local-tier settings", async () => {
|
||||
// settings.local.json is git-ignored by convention, so the trust
|
||||
// filter does not apply.
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({ permissions: { defaultMode: "acceptEdits" } }),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
expect(settingsManager.getSettings().permissions?.defaultMode).toBe("acceptEdits");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,8 +14,14 @@ import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import {
|
||||
toolUpdateFromToolResult,
|
||||
createPostToolUseHook,
|
||||
createTaskHook,
|
||||
toolInfoFromToolUse,
|
||||
planEntries,
|
||||
applyTaskCreate,
|
||||
applyTaskUpdate,
|
||||
parseTaskCreateOutput,
|
||||
taskStateToPlanEntries,
|
||||
TaskState,
|
||||
} from "../tools.js";
|
||||
|
||||
describe("rawOutput in tool call updates", () => {
|
||||
@@ -1541,3 +1547,404 @@ describe("toAcpNotifications - TodoWrite with undefined input regression", () =>
|
||||
expect(planUpdates).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseTaskCreateOutput", () => {
|
||||
it("parses JSON-string content", () => {
|
||||
const parsed = parseTaskCreateOutput(JSON.stringify({ task: { id: "1", subject: "X" } }));
|
||||
expect(parsed).toEqual({ task: { id: "1", subject: "X" } });
|
||||
});
|
||||
|
||||
it("parses array-of-text-block content", () => {
|
||||
const parsed = parseTaskCreateOutput([
|
||||
{ type: "text", text: JSON.stringify({ task: { id: "2", subject: "Y" } }) },
|
||||
]);
|
||||
expect(parsed).toEqual({ task: { id: "2", subject: "Y" } });
|
||||
});
|
||||
|
||||
it("returns undefined for non-JSON content", () => {
|
||||
expect(parseTaskCreateOutput("not json")).toBeUndefined();
|
||||
expect(parseTaskCreateOutput([{ type: "text", text: "not json" }])).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined when task.id is missing", () => {
|
||||
expect(parseTaskCreateOutput(JSON.stringify({ task: { subject: "X" } }))).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyTaskCreate / applyTaskUpdate", () => {
|
||||
it("creates an entry on TaskCreate when both input and output are present", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskCreate(
|
||||
state,
|
||||
{ subject: "Write tests", description: "Cover Task* flow", activeForm: "Writing tests" },
|
||||
{ task: { id: "1", subject: "Write tests" } },
|
||||
);
|
||||
expect(state.get("1")).toEqual({
|
||||
subject: "Write tests",
|
||||
status: "pending",
|
||||
activeForm: "Writing tests",
|
||||
description: "Cover Task* flow",
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when the output has no task ID", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskCreate(state, { subject: "X", description: "Y" }, undefined);
|
||||
expect(state.size).toBe(0);
|
||||
});
|
||||
|
||||
it("updates fields by task ID and keeps insertion order in plan entries", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskCreate(state, { subject: "A", description: "" }, { task: { id: "1", subject: "A" } });
|
||||
applyTaskCreate(state, { subject: "B", description: "" }, { task: { id: "2", subject: "B" } });
|
||||
applyTaskUpdate(state, { taskId: "1", status: "in_progress" });
|
||||
expect(taskStateToPlanEntries(state)).toEqual([
|
||||
{ content: "A", status: "in_progress", priority: "medium" },
|
||||
{ content: "B", status: "pending", priority: "medium" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes entries when status is 'deleted'", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskCreate(state, { subject: "A", description: "" }, { task: { id: "1", subject: "A" } });
|
||||
applyTaskUpdate(state, { taskId: "1", status: "deleted" });
|
||||
expect(state.size).toBe(0);
|
||||
});
|
||||
|
||||
it("creates a placeholder entry when TaskUpdate carries a subject for an unseen task", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskUpdate(state, { taskId: "5", subject: "Late arrival", status: "in_progress" });
|
||||
expect(state.get("5")).toEqual({
|
||||
subject: "Late arrival",
|
||||
status: "in_progress",
|
||||
activeForm: undefined,
|
||||
description: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips TaskUpdate for an unseen task when no subject is available", () => {
|
||||
const state: TaskState = new Map();
|
||||
applyTaskUpdate(state, { taskId: "5", status: "in_progress" });
|
||||
// Without a subject we'd render an empty-content plan entry, so the
|
||||
// update is dropped instead of synthesizing a blank placeholder.
|
||||
expect(state.has("5")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toAcpNotifications - Task* tools", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
const mockLogger: Logger = { log: () => {}, error: () => {} };
|
||||
|
||||
it("suppresses tool_call for TaskCreate/TaskUpdate/TaskList/TaskGet on tool_use", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const taskState: TaskState = new Map();
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{ type: "tool_use", id: "1", name: "TaskCreate", input: { subject: "A", description: "" } },
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "2",
|
||||
name: "TaskUpdate",
|
||||
input: { taskId: "1", status: "in_progress" },
|
||||
},
|
||||
{ type: "tool_use", id: "3", name: "TaskList", input: {} },
|
||||
{ type: "tool_use", id: "4", name: "TaskGet", input: { taskId: "1" } },
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
expect(taskState.size).toBe(0);
|
||||
});
|
||||
|
||||
it("emits a plan snapshot after a TaskCreate tool_result and accumulates state", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const taskState: TaskState = new Map();
|
||||
|
||||
toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "1",
|
||||
name: "TaskCreate",
|
||||
input: { subject: "First", description: "" },
|
||||
},
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
const created = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "1",
|
||||
content: JSON.stringify({ task: { id: "1", subject: "First" } }),
|
||||
is_error: false,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(created).toHaveLength(1);
|
||||
expect(created[0].update).toMatchObject({
|
||||
sessionUpdate: "plan",
|
||||
entries: [{ content: "First", status: "pending", priority: "medium" }],
|
||||
});
|
||||
|
||||
// A second TaskCreate accumulates rather than replacing.
|
||||
toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "2",
|
||||
name: "TaskCreate",
|
||||
input: { subject: "Second", description: "" },
|
||||
},
|
||||
] as any,
|
||||
"assistant",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
const second = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "2",
|
||||
content: JSON.stringify({ task: { id: "2", subject: "Second" } }),
|
||||
is_error: false,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(second[0].update).toMatchObject({
|
||||
sessionUpdate: "plan",
|
||||
entries: [
|
||||
{ content: "First", status: "pending", priority: "medium" },
|
||||
{ content: "Second", status: "pending", priority: "medium" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("emits a plan snapshot reflecting status changes after a TaskUpdate tool_result", () => {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const taskState: TaskState = new Map([["1", { subject: "First", status: "pending" as const }]]);
|
||||
toolUseCache["update-1"] = {
|
||||
type: "tool_use",
|
||||
id: "update-1",
|
||||
name: "TaskUpdate",
|
||||
input: { taskId: "1", status: "completed" },
|
||||
};
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "update-1",
|
||||
content: JSON.stringify({
|
||||
success: true,
|
||||
taskId: "1",
|
||||
updatedFields: ["status"],
|
||||
}),
|
||||
is_error: false,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(1);
|
||||
expect(notifications[0].update).toMatchObject({
|
||||
sessionUpdate: "plan",
|
||||
entries: [{ content: "First", status: "completed", priority: "medium" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses TaskList and TaskGet tool_result without touching task state", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
"list-1": { type: "tool_use", id: "list-1", name: "TaskList", input: {} },
|
||||
"get-1": { type: "tool_use", id: "get-1", name: "TaskGet", input: { taskId: "1" } },
|
||||
};
|
||||
const taskState: TaskState = new Map([
|
||||
["1", { subject: "Existing", status: "in_progress" as const }],
|
||||
]);
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{ type: "tool_result", tool_use_id: "list-1", content: "...", is_error: false },
|
||||
{ type: "tool_result", tool_use_id: "get-1", content: "...", is_error: false },
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
expect(taskState.get("1")).toEqual({ subject: "Existing", status: "in_progress" });
|
||||
});
|
||||
|
||||
it("does not apply TaskCreate/TaskUpdate when the tool_result reports an error", () => {
|
||||
const toolUseCache: ToolUseCache = {
|
||||
"create-1": {
|
||||
type: "tool_use",
|
||||
id: "create-1",
|
||||
name: "TaskCreate",
|
||||
input: { subject: "A", description: "" },
|
||||
},
|
||||
};
|
||||
const taskState: TaskState = new Map();
|
||||
|
||||
const notifications = toAcpNotifications(
|
||||
[
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "create-1",
|
||||
content: "task creation failed",
|
||||
is_error: true,
|
||||
},
|
||||
] as any,
|
||||
"user",
|
||||
"test-session",
|
||||
toolUseCache,
|
||||
mockClient,
|
||||
mockLogger,
|
||||
{ taskState },
|
||||
);
|
||||
|
||||
expect(notifications).toHaveLength(0);
|
||||
expect(taskState.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createTaskHook", () => {
|
||||
it("registers a task on TaskCreated and fires onChange", async () => {
|
||||
const taskState: TaskState = new Map();
|
||||
let changes = 0;
|
||||
const hook = createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
changes++;
|
||||
},
|
||||
});
|
||||
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "TaskCreated",
|
||||
task_id: "t-1",
|
||||
task_subject: "Investigate flaky test",
|
||||
task_description: "Repro and fix",
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
expect(taskState.get("t-1")).toEqual({
|
||||
subject: "Investigate flaky test",
|
||||
status: "pending",
|
||||
description: "Repro and fix",
|
||||
});
|
||||
expect(changes).toBe(1);
|
||||
});
|
||||
|
||||
it("does not clobber an existing entry on TaskCreated", async () => {
|
||||
const taskState: TaskState = new Map([
|
||||
["t-1", { subject: "Investigate flaky test", status: "in_progress" as const }],
|
||||
]);
|
||||
let changes = 0;
|
||||
const hook = createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
changes++;
|
||||
},
|
||||
});
|
||||
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "TaskCreated",
|
||||
task_id: "t-1",
|
||||
task_subject: "Investigate flaky test",
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
expect(taskState.get("t-1")?.status).toBe("in_progress");
|
||||
expect(changes).toBe(0);
|
||||
});
|
||||
|
||||
it("marks a task completed on TaskCompleted", async () => {
|
||||
const taskState: TaskState = new Map([
|
||||
["t-1", { subject: "Investigate flaky test", status: "in_progress" as const }],
|
||||
]);
|
||||
let changes = 0;
|
||||
const hook = createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
changes++;
|
||||
},
|
||||
});
|
||||
|
||||
await hook(
|
||||
{
|
||||
hook_event_name: "TaskCompleted",
|
||||
task_id: "t-1",
|
||||
task_subject: "Investigate flaky test",
|
||||
} as any,
|
||||
undefined,
|
||||
{ signal: new AbortController().signal },
|
||||
);
|
||||
|
||||
expect(taskState.get("t-1")?.status).toBe("completed");
|
||||
expect(changes).toBe(1);
|
||||
});
|
||||
|
||||
it("is a no-op for unrelated hook events", async () => {
|
||||
const taskState: TaskState = new Map();
|
||||
let changes = 0;
|
||||
const hook = createTaskHook({
|
||||
taskState,
|
||||
onChange: async () => {
|
||||
changes++;
|
||||
},
|
||||
});
|
||||
|
||||
await hook({ hook_event_name: "PostToolUse", tool_name: "Read" } as any, undefined, {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
expect(taskState.size).toBe(0);
|
||||
expect(changes).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
169
src/tools.ts
169
src/tools.ts
@@ -14,6 +14,9 @@ import {
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TaskCreateInput,
|
||||
TaskCreateOutput,
|
||||
TaskUpdateInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
@@ -367,6 +370,40 @@ export function toolInfoFromToolUse(
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskCreate": {
|
||||
const input = toolUse.input as TaskCreateInput | undefined;
|
||||
return {
|
||||
title: input?.subject ? `Create task: ${input.subject}` : "Create task",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskUpdate": {
|
||||
const input = toolUse.input as TaskUpdateInput | undefined;
|
||||
return {
|
||||
title: input?.subject ? `Update task: ${input.subject}` : "Update task",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskList": {
|
||||
return {
|
||||
title: "List tasks",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "TaskGet": {
|
||||
return {
|
||||
title: "Get task",
|
||||
kind: "think",
|
||||
content: [],
|
||||
};
|
||||
}
|
||||
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string } | undefined;
|
||||
return {
|
||||
@@ -669,6 +706,103 @@ export function planEntries(input: { todos: ClaudePlanEntry[] } | undefined): Pl
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-session task list accumulated from Task* tool calls (TaskCreate /
|
||||
* TaskUpdate). The headless/SDK session emits these as incremental tool
|
||||
* calls keyed by task ID, replacing the snapshot-style TodoWrite tool.
|
||||
* Iteration order is insertion order (Map semantics), matching the order
|
||||
* tasks are created.
|
||||
*/
|
||||
export type TaskEntry = {
|
||||
subject: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
activeForm?: string;
|
||||
description?: string;
|
||||
};
|
||||
export type TaskState = Map<string, TaskEntry>;
|
||||
|
||||
/**
|
||||
* Best-effort parse of a TaskCreate tool_result content into the structured
|
||||
* TaskCreateOutput. The SDK delivers tool outputs either as a string or as
|
||||
* an array of TextBlockParam-like blocks containing JSON text; try both.
|
||||
*/
|
||||
export function parseTaskCreateOutput(content: unknown): TaskCreateOutput | undefined {
|
||||
const tryParse = (text: string): TaskCreateOutput | undefined => {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (
|
||||
parsed &&
|
||||
typeof parsed === "object" &&
|
||||
parsed.task &&
|
||||
typeof parsed.task.id === "string"
|
||||
) {
|
||||
return parsed as TaskCreateOutput;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (typeof content === "string") {
|
||||
return tryParse(content);
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
for (const block of content) {
|
||||
if (block && typeof block === "object" && "type" in block && block.type === "text") {
|
||||
const text = (block as { text?: unknown }).text;
|
||||
if (typeof text === "string") {
|
||||
const parsed = tryParse(text);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function applyTaskCreate(
|
||||
state: TaskState,
|
||||
input: TaskCreateInput | undefined,
|
||||
output: TaskCreateOutput | undefined,
|
||||
): void {
|
||||
const taskId = output?.task?.id;
|
||||
if (!taskId || !input) return;
|
||||
state.set(taskId, {
|
||||
subject: input.subject,
|
||||
status: "pending",
|
||||
activeForm: input.activeForm,
|
||||
description: input.description,
|
||||
});
|
||||
}
|
||||
|
||||
export function applyTaskUpdate(state: TaskState, input: TaskUpdateInput | undefined): void {
|
||||
if (!input?.taskId) return;
|
||||
if (input.status === "deleted") {
|
||||
state.delete(input.taskId);
|
||||
return;
|
||||
}
|
||||
const existing = state.get(input.taskId);
|
||||
// Without a subject from either the existing entry or the update payload,
|
||||
// we'd produce a plan entry with empty `content` — drop the update.
|
||||
const subject = input.subject ?? existing?.subject;
|
||||
if (!subject) return;
|
||||
state.set(input.taskId, {
|
||||
subject,
|
||||
status: input.status ?? existing?.status ?? "pending",
|
||||
activeForm: input.activeForm ?? existing?.activeForm,
|
||||
description: input.description ?? existing?.description,
|
||||
});
|
||||
}
|
||||
|
||||
export function taskStateToPlanEntries(state: TaskState): PlanEntry[] {
|
||||
return Array.from(state.values()).map((task) => ({
|
||||
content: task.subject,
|
||||
status: task.status,
|
||||
priority: "medium",
|
||||
}));
|
||||
}
|
||||
|
||||
export function markdownEscape(text: string): string {
|
||||
let escape = "```";
|
||||
for (const [m] of text.matchAll(/^```+/gm)) {
|
||||
@@ -797,3 +931,38 @@ export const createPostToolUseHook =
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook callback for `TaskCreated` / `TaskCompleted` events. The SDK fires
|
||||
* these for both user-facing TaskCreate tool calls and subagent task
|
||||
* creation, giving us `task_id` + `task_subject` without having to parse
|
||||
* tool_result payloads.
|
||||
*
|
||||
* Populating `taskState` from the hook means a later `TaskUpdate` (which
|
||||
* typically only carries `taskId` + `status`) finds an existing entry with
|
||||
* a real subject, instead of synthesizing a placeholder with empty content.
|
||||
*/
|
||||
export const createTaskHook =
|
||||
(options: { taskState: TaskState; onChange?: () => Promise<void> }): HookCallback =>
|
||||
async (input): Promise<{ continue: boolean }> => {
|
||||
const taskId =
|
||||
"task_id" in input && typeof input.task_id === "string" ? input.task_id : undefined;
|
||||
if (!taskId) return { continue: true };
|
||||
|
||||
if (input.hook_event_name === "TaskCreated") {
|
||||
if (!input.task_subject) return { continue: true };
|
||||
if (options.taskState.has(taskId)) return { continue: true };
|
||||
options.taskState.set(taskId, {
|
||||
subject: input.task_subject,
|
||||
status: "pending",
|
||||
description: input.task_description,
|
||||
});
|
||||
if (options.onChange) await options.onChange();
|
||||
} else if (input.hook_event_name === "TaskCompleted") {
|
||||
const existing = options.taskState.get(taskId);
|
||||
if (!existing || existing.status === "completed") return { continue: true };
|
||||
options.taskState.set(taskId, { ...existing, status: "completed" });
|
||||
if (options.onChange) await options.onChange();
|
||||
}
|
||||
return { continue: true };
|
||||
};
|
||||
|
||||
18
src/utils.ts
18
src/utils.ts
@@ -2,9 +2,7 @@
|
||||
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { WritableStream, ReadableStream } from "node:stream/web";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import { ClaudeCodeSettings, getManagedSettingsPath } from "./settings.js";
|
||||
|
||||
// Useful for bridging push-based and async-iterator-based code.
|
||||
export class Pushable<T> implements AsyncIterable<T> {
|
||||
@@ -89,19 +87,3 @@ export function unreachable(value: never, logger: Logger = console) {
|
||||
export function sleep(time: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, time));
|
||||
}
|
||||
|
||||
export function loadManagedSettings(): ClaudeCodeSettings | null {
|
||||
try {
|
||||
return JSON.parse(readFileSync(getManagedSettingsPath(), "utf8")) as ClaudeCodeSettings;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
|
||||
if (settings.env) {
|
||||
for (const [key, value] of Object.entries(settings.env)) {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user