mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-11-02 15:31:50 +00:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
affc731e3a | ||
|
|
63b708176e | ||
|
|
dc640e2dcf | ||
|
|
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 | ||
|
|
e0ea9d898a | ||
|
|
867a3a0de2 |
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.0"
|
||||
".": "0.37.0"
|
||||
}
|
||||
|
||||
71
CHANGELOG.md
71
CHANGELOG.md
@@ -1,5 +1,76 @@
|
||||
# Changelog
|
||||
|
||||
## [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)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* Honor availableModels settings allowlist ([#637](https://github.com/agentclientprotocol/claude-agent-acp/issues/637)) ([867a3a0](https://github.com/agentclientprotocol/claude-agent-acp/commit/867a3a0de2a050592d79aa76d7dd3dd5b478162d)), closes [#620](https://github.com/agentclientprotocol/claude-agent-acp/issues/620)
|
||||
|
||||
## [0.33.0](https://github.com/agentclientprotocol/claude-agent-acp/compare/v0.32.0...v0.33.0) (2026-05-07)
|
||||
|
||||
|
||||
|
||||
703
package-lock.json
generated
703
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.0",
|
||||
"version": "0.37.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.146",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.89.0",
|
||||
"@anthropic-ai/sdk": "0.97.1",
|
||||
"@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.59.4",
|
||||
"@typescript-eslint/parser": "8.59.4",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
597
src/acp-agent.ts
597
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,32 @@ 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;
|
||||
/** Assistant message ids whose text/thinking content was already emitted as
|
||||
* `agent_message_chunk` / `agent_thought_chunk` via `stream_event` partial
|
||||
* messages. Used by the top-level `assistant` case to decide whether to
|
||||
* skip text/thinking blocks (the default, when streaming worked) or fall
|
||||
* back to emitting the assembled blocks (when the underlying SDK / model
|
||||
* proxy never produced `content_block_delta` for them). */
|
||||
textStreamedMessageIds: Set<string>;
|
||||
/** Keys of assembled text/thinking blocks the fallback path has already
|
||||
* forwarded this turn, used to dedup re-delivered blocks without dropping
|
||||
* genuinely new ones. A single assistant message id is frequently
|
||||
* delivered as several `assistant` messages, each carrying a *different*
|
||||
* subset of content blocks (e.g. an empty `thinking` block, then the real
|
||||
* `text` block); deduping at message-id granularity would emit the first
|
||||
* block, flag the id, then silently drop the later text. Keying on the
|
||||
* block content instead lets distinct blocks that share an id all through
|
||||
* while still collapsing identical re-deliveries (incremental + consolidated
|
||||
* messages, or the same message delivered twice). */
|
||||
fallbackEmittedBlockKeys: Set<string>;
|
||||
/** Id of the assistant message currently being streamed via `stream_event`
|
||||
* partial messages, captured at `message_start` so subsequent
|
||||
* `content_block_delta` events can record their parent into
|
||||
* `textStreamedMessageIds`. */
|
||||
currentStreamingMessageId?: string;
|
||||
};
|
||||
|
||||
/** Compute a stable fingerprint of the session-defining params so we can
|
||||
@@ -232,6 +268,8 @@ type GatewayAuthMeta = {
|
||||
};
|
||||
};
|
||||
|
||||
type GatewayAuthRequest = AuthenticateRequest & { _meta?: GatewayAuthMeta };
|
||||
|
||||
/**
|
||||
* Extra metadata that the agent provides for each tool_call / tool_update update.
|
||||
*/
|
||||
@@ -476,7 +514,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 +541,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 +651,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
loadSession: true,
|
||||
sessionCapabilities: {
|
||||
additionalDirectories: {},
|
||||
close: {},
|
||||
delete: {},
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
close: {},
|
||||
},
|
||||
},
|
||||
agentInfo: {
|
||||
@@ -613,7 +664,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
title: "Claude Agent",
|
||||
version: packageJson.version,
|
||||
},
|
||||
authMethods: [...terminalAuthMethods, ...(supportsGatewayAuth ? [gatewayAuthMethod] : [])],
|
||||
authMethods: [
|
||||
...terminalAuthMethods,
|
||||
...(supportsGatewayAuth ? [gatewayAuthMethod, gatewayBedrockAuthMethod] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -634,6 +688,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
@@ -690,8 +745,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.");
|
||||
@@ -711,6 +766,14 @@ export class ClaudeAcpAgent implements Agent {
|
||||
cachedWriteTokens: 0,
|
||||
};
|
||||
|
||||
// Assistant message ids and fallback block keys are turn-scoped — clear
|
||||
// before a new turn so the sets don't grow unboundedly over a long-lived
|
||||
// session, and so that an id reused across turns (uncommon but possible)
|
||||
// is re-evaluated.
|
||||
session.textStreamedMessageIds.clear();
|
||||
session.fallbackEmittedBlockKeys.clear();
|
||||
session.currentStreamingMessageId = undefined;
|
||||
|
||||
let lastAssistantTotalUsage: number | null = null;
|
||||
let lastAssistantUsage: UsageSnapshot | null = null;
|
||||
let lastAssistantModel: string | null = null;
|
||||
@@ -749,6 +812,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
session.promptRunning = true;
|
||||
let handedOff = false;
|
||||
let errored = false;
|
||||
let stopReason: StopReason = "end_turn";
|
||||
|
||||
try {
|
||||
@@ -832,10 +896,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 +953,10 @@ 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":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
default:
|
||||
@@ -980,6 +1087,31 @@ export class ClaudeAcpAgent implements Agent {
|
||||
break;
|
||||
}
|
||||
case "stream_event": {
|
||||
// Track which assistant message ids have produced streamed text /
|
||||
// thinking deltas so the `assistant` case below can fall back to
|
||||
// emitting the assembled content when streaming didn't (e.g. when
|
||||
// the SDK is talking to a model proxy that never produces
|
||||
// `content_block_delta` events for the second model call in a
|
||||
// tool-use loop).
|
||||
//
|
||||
// This runs for every stream_event (not gated on
|
||||
// `parent_tool_use_id === null` like the usage block below) because
|
||||
// subagent assistant messages flow through the same content filter.
|
||||
// `currentStreamingMessageId` is a single field, which is safe
|
||||
// because the SDK serializes the event stream and `message_start`
|
||||
// always precedes its own `content_block_delta`s, so the captured
|
||||
// id is always the parent of the deltas that follow.
|
||||
if (message.event.type === "message_start") {
|
||||
session.currentStreamingMessageId = message.event.message.id;
|
||||
} else if (
|
||||
message.event.type === "content_block_delta" &&
|
||||
(message.event.delta.type === "text_delta" ||
|
||||
message.event.delta.type === "thinking_delta") &&
|
||||
session.currentStreamingMessageId
|
||||
) {
|
||||
session.textStreamedMessageIds.add(session.currentStreamingMessageId);
|
||||
}
|
||||
|
||||
if (
|
||||
message.parent_tool_use_id === null &&
|
||||
(message.event.type === "message_start" || message.event.type === "message_delta")
|
||||
@@ -1040,6 +1172,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
{
|
||||
clientCapabilities: this.clientCapabilities,
|
||||
cwd: session.cwd,
|
||||
taskState: session.taskState,
|
||||
},
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
@@ -1088,13 +1221,36 @@ 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 (
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1127,14 +1283,77 @@ export class ClaudeAcpAgent implements Agent {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
// Text and thinking blocks are normally emitted from the
|
||||
// `stream_event` case above as `content_block_delta` arrives,
|
||||
// so the assembled message replays them here. We skip them in
|
||||
// that case to avoid duplication. However, if streaming never
|
||||
// produced any text/thinking delta for this assistant message
|
||||
// (e.g. because the underlying model proxy returned the response
|
||||
// as a single non-streamed block, which is common with OpenAI-
|
||||
// compatible gateways translating to the Anthropic protocol), the
|
||||
// delta path emitted nothing — filtering the assembled blocks out
|
||||
// would drop the assistant's final text on the floor while still
|
||||
// returning `stopReason: "end_turn"`. Detect that case via
|
||||
// `textStreamedMessageIds` and emit the assembled blocks as a
|
||||
// fallback.
|
||||
//
|
||||
// The fallback dedup must be per content block, not per message
|
||||
// id: such gateways frequently deliver one assistant response as
|
||||
// several `assistant` messages that *share a message id* but each
|
||||
// carry a *different* block (e.g. an empty `thinking` block, then
|
||||
// the real `text` block). Flagging the whole id after the first
|
||||
// block would emit that block, then silently drop the real text
|
||||
// arriving under the same id. So we key the dedup on the block
|
||||
// contents, which lets distinct blocks through while still
|
||||
// collapsing identical re-deliveries (an incremental block
|
||||
// followed by a consolidated message, or the same message
|
||||
// delivered twice).
|
||||
const assistantMessageId =
|
||||
message.type === "assistant" ? message.message.id : undefined;
|
||||
const liveStreamed =
|
||||
!!assistantMessageId && session.textStreamedMessageIds.has(assistantMessageId);
|
||||
|
||||
let emittedFallbackBlock = false;
|
||||
const content =
|
||||
message.type === "assistant"
|
||||
? // Handled by stream events above
|
||||
message.message.content.filter(
|
||||
(item) => !["text", "thinking"].includes(item.type),
|
||||
)
|
||||
? message.message.content.filter((item) => {
|
||||
if (item.type !== "text" && item.type !== "thinking") {
|
||||
return true;
|
||||
}
|
||||
// Already delivered live via streaming deltas — drop the
|
||||
// assembled copy to avoid duplication. Note this is
|
||||
// all-or-nothing per message id: a single delta of either
|
||||
// kind flags the id, so if a gateway streamed (say)
|
||||
// thinking deltas but delivered text only in the assembled
|
||||
// message under that same id, the assembled text is
|
||||
// dropped here. That mixed mode hasn't been observed —
|
||||
// gateways stream either everything or nothing — and it is
|
||||
// not a regression (the old code dropped it unconditionally).
|
||||
if (liveStreamed) {
|
||||
return false;
|
||||
}
|
||||
// Fallback path: emit each assembled block once, keyed on
|
||||
// its contents so re-delivered blocks dedup but distinct
|
||||
// blocks sharing a message id are all forwarded.
|
||||
const blockText = item.type === "text" ? item.text : item.thinking;
|
||||
const key = `${assistantMessageId ?? "<unknown>"}\u0000${item.type}\u0000${blockText}`;
|
||||
if (session.fallbackEmittedBlockKeys.has(key)) {
|
||||
return false;
|
||||
}
|
||||
session.fallbackEmittedBlockKeys.add(key);
|
||||
emittedFallbackBlock = true;
|
||||
return true;
|
||||
})
|
||||
: message.message.content;
|
||||
|
||||
if (emittedFallbackBlock) {
|
||||
this.logger.log(
|
||||
`[claude-agent-acp] No streamed text/thinking deltas seen for ` +
|
||||
`assistant message ${assistantMessageId ?? "<unknown>"}; ` +
|
||||
`emitting assembled blocks as fallback.`,
|
||||
);
|
||||
}
|
||||
|
||||
for (const notification of toAcpNotifications(
|
||||
content,
|
||||
message.message.role,
|
||||
@@ -1146,6 +1365,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 +1385,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 +1437,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 +1502,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 +1666,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 +1697,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 +1941,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 +1966,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
);
|
||||
if (configId === "effort") {
|
||||
await session.query.applyFlagSettings({
|
||||
effortLevel: value as Settings["effortLevel"],
|
||||
effortLevel: toSdkEffortLevel(value),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1708,6 +1976,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 +2001,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
additionalDirectories: params.additionalDirectories,
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
@@ -1781,7 +2051,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 +2115,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 +2139,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 +2180,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 +2267,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
if (
|
||||
shouldHideClaudeAuth() &&
|
||||
initializationResult.account.subscriptionType &&
|
||||
!this.gatewayAuthMeta
|
||||
!this.gatewayAuthRequest
|
||||
) {
|
||||
throw RequestError.authRequired(
|
||||
undefined,
|
||||
@@ -1956,8 +2275,19 @@ export class ClaudeAcpAgent implements Agent {
|
||||
);
|
||||
}
|
||||
|
||||
// Apply user's `availableModels` allowlist from settings.json before any
|
||||
// downstream model handling. The SDK only enforces this allowlist in its
|
||||
// own UI, not in `initializationResult.models`, so we filter here to keep
|
||||
// configOptions, the current-model resolver, and the stored modelInfos
|
||||
// consistent with what the user configured.
|
||||
const settingsAvailableModels = settingsManager.getSettings().availableModels;
|
||||
const allowedModels = Array.isArray(settingsAvailableModels)
|
||||
? applyAvailableModelsAllowlist(initializationResult.models, settingsAvailableModels)
|
||||
: initializationResult.models;
|
||||
|
||||
const models = await getAvailableModels(
|
||||
q,
|
||||
allowedModels,
|
||||
initializationResult.models,
|
||||
settingsManager,
|
||||
this.logger,
|
||||
@@ -1965,9 +2295,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
// Gate `auto` (and future model-specific modes) on the resolved model's
|
||||
// `ModelInfo`. See `buildAvailableModes` for the canonical SDK signal.
|
||||
const currentModelInfo = initializationResult.models.find(
|
||||
(m) => m.value === models.currentModelId,
|
||||
);
|
||||
const currentModelInfo = allowedModels.find((m) => m.value === models.currentModelId);
|
||||
const availableModes = buildAvailableModes(currentModelInfo);
|
||||
|
||||
// Clamp `permissionMode` if the resolved session does not offer it. The
|
||||
@@ -2008,13 +2336,17 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const configOptions = buildConfigOptions(
|
||||
modes,
|
||||
models,
|
||||
initializationResult.models,
|
||||
allowedModels,
|
||||
settingsManager.getSettings().effortLevel,
|
||||
);
|
||||
|
||||
// Apply the initial effort level to the SDK so it matches the UI default
|
||||
const initialEffort = configOptions.find((o) => o.id === "effort");
|
||||
if (initialEffort && typeof initialEffort.currentValue === "string") {
|
||||
if (
|
||||
initialEffort &&
|
||||
typeof initialEffort.currentValue === "string" &&
|
||||
initialEffort.currentValue !== "default"
|
||||
) {
|
||||
await q.applyFlagSettings({
|
||||
effortLevel: initialEffort.currentValue as Settings["effortLevel"],
|
||||
});
|
||||
@@ -2035,7 +2367,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
modes,
|
||||
models,
|
||||
modelInfos: initializationResult.models,
|
||||
modelInfos: allowedModels,
|
||||
configOptions,
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
@@ -2044,6 +2376,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
emitRawSDKMessages: sessionMeta?.claudeCode?.emitRawSDKMessages ?? false,
|
||||
contextWindowSize:
|
||||
inferContextWindowFromModel(models.currentModelId) ?? DEFAULT_CONTEXT_WINDOW,
|
||||
taskState,
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -2134,16 +2469,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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2199,6 +2544,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,
|
||||
@@ -2241,25 +2596,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",
|
||||
@@ -2279,6 +2629,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();
|
||||
@@ -2325,6 +2696,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);
|
||||
@@ -2338,6 +2710,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;
|
||||
@@ -2364,15 +2737,63 @@ function resolveSettingsModel(
|
||||
return resolveModelPreference(models, settingsModel);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restrict the SDK's model list to the user's `availableModels` allowlist
|
||||
* (already merged-and-deduped across settings sources by `SettingsManager`).
|
||||
* The user's exact entries become the model IDs surfaced via configOptions
|
||||
* and passed to `setModel`, which prevents Claude Code from silently
|
||||
* substituting a date-pinned variant (e.g. `haiku` →
|
||||
* `claude-haiku-4-5-20251001`) that the user may not have access to.
|
||||
*
|
||||
* Display info and capability flags are copied from the closest SDK match so
|
||||
* the UI still renders sensible names and effort levels.
|
||||
*
|
||||
* Semantics from https://code.claude.com/docs/en/model-config#restrict-model-selection:
|
||||
* - `undefined` is handled by the caller (no allowlist applied).
|
||||
* - The Default option is unaffected by `availableModels` — it always remains
|
||||
* available, even when the allowlist is `[]`.
|
||||
*/
|
||||
function applyAvailableModelsAllowlist(sdkModels: ModelInfo[], allowlist: string[]): ModelInfo[] {
|
||||
// Default is always preserved per the docs. Synthesize one if the SDK
|
||||
// didn't surface it so downstream code (e.g. `getAvailableModels` picking
|
||||
// `models[0]` as a fallback) still has something to work with.
|
||||
const defaultModel = sdkModels.find((m) => m.value === "default") ?? {
|
||||
value: "default",
|
||||
displayName: "Default",
|
||||
description: "",
|
||||
};
|
||||
const result: ModelInfo[] = [defaultModel];
|
||||
const seen = new Set<string>([defaultModel.value]);
|
||||
|
||||
const sdkModelsWithoutDefault = sdkModels.filter((m) => m.value !== "default");
|
||||
|
||||
for (const entry of allowlist) {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed || seen.has(trimmed)) continue;
|
||||
|
||||
const sdkMatch = resolveModelPreference(sdkModelsWithoutDefault, trimmed);
|
||||
if (sdkMatch) {
|
||||
result.push({ ...sdkMatch, value: trimmed });
|
||||
} else {
|
||||
result.push({ value: trimmed, displayName: trimmed, description: "" });
|
||||
}
|
||||
seen.add(trimmed);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
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
|
||||
@@ -2382,15 +2803,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) => ({
|
||||
@@ -2404,6 +2843,7 @@ async function getAvailableModels(
|
||||
|
||||
function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] {
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"clear",
|
||||
"cost",
|
||||
"keybindings-help",
|
||||
"login",
|
||||
@@ -2547,8 +2987,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") {
|
||||
@@ -2622,6 +3064,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) {
|
||||
@@ -2726,7 +3177,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],
|
||||
@@ -2813,6 +3293,7 @@ export function streamEventToAcpNotifications(
|
||||
options?: {
|
||||
clientCapabilities?: ClientCapabilities;
|
||||
cwd?: string;
|
||||
taskState?: TaskState;
|
||||
},
|
||||
): SessionNotification[] {
|
||||
const event = message.event;
|
||||
@@ -2829,6 +3310,7 @@ export function streamEventToAcpNotifications(
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
},
|
||||
);
|
||||
case "content_block_delta":
|
||||
@@ -2843,6 +3325,7 @@ export function streamEventToAcpNotifications(
|
||||
clientCapabilities: options?.clientCapabilities,
|
||||
parentToolUseId: message.parent_tool_use_id,
|
||||
cwd: options?.cwd,
|
||||
taskState: options?.taskState,
|
||||
},
|
||||
);
|
||||
// No content
|
||||
|
||||
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";
|
||||
|
||||
171
src/settings.ts
171
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,51 +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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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:
|
||||
@@ -72,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 };
|
||||
@@ -126,98 +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.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);
|
||||
@@ -268,8 +167,8 @@ export class SettingsManager {
|
||||
/**
|
||||
* Returns the current merged settings
|
||||
*/
|
||||
getSettings(): ClaudeCodeSettings {
|
||||
return this.mergedSettings;
|
||||
getSettings(): Settings {
|
||||
return this.effective;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -362,6 +389,261 @@ describe("ClaudeAcpAgent settings", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("availableModels allowlist from settings", () => {
|
||||
function mockQueryWithModels(models: any[]): {
|
||||
setModelSpy: ReturnType<typeof vi.fn>;
|
||||
} {
|
||||
const setModelSpy = vi.fn();
|
||||
querySpy.mockImplementation(() => {
|
||||
return {
|
||||
initializationResult: async () => ({ models }),
|
||||
setModel: setModelSpy,
|
||||
supportedCommands: async () => [],
|
||||
} as any;
|
||||
});
|
||||
return { setModelSpy };
|
||||
}
|
||||
|
||||
it("restricts configOptions to the user's allowlist using their exact IDs", async () => {
|
||||
// Reproduces the scenario from
|
||||
// https://github.com/agentclientprotocol/claude-agent-acp/issues/620:
|
||||
// user lists `claude-haiku-4-5` (no date pin) in availableModels, but
|
||||
// the SDK still surfaces its `haiku` alias which resolves to a
|
||||
// date-pinned variant the user doesn't have access to.
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: [
|
||||
"claude-sonnet-4-6[1m]",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-haiku-4-5",
|
||||
"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: "sonnet[1m]",
|
||||
displayName: "Sonnet (1M context)",
|
||||
description: "Sonnet 4.6 long context",
|
||||
},
|
||||
{
|
||||
value: "opus[1m]",
|
||||
displayName: "Opus (1M context)",
|
||||
description: "Opus 1M context",
|
||||
},
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
"claude-opus-4-6[1m]",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7[1m]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("unions availableModels across user and project settings", async () => {
|
||||
// https://code.claude.com/docs/en/model-config#merge-behavior
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ availableModels: ["claude-haiku-4-5"] }),
|
||||
);
|
||||
|
||||
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({
|
||||
availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
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");
|
||||
// User and project entries are unioned and deduplicated.
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual([
|
||||
"default",
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7[1m]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns only the default entry when availableModels is an empty array", async () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
JSON.stringify({ availableModels: [] }),
|
||||
);
|
||||
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("does not filter when availableModels is absent from settings", async () => {
|
||||
const projectDir = path.join(tempDir, "project");
|
||||
await fs.promises.mkdir(projectDir, { recursive: true });
|
||||
|
||||
mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
|
||||
|
||||
const response = await (agent as any).createSession({
|
||||
cwd: projectDir,
|
||||
mcpServers: [],
|
||||
_meta: { disableBuiltInTools: true },
|
||||
});
|
||||
|
||||
const modelOption = response.configOptions.find((o: any) => o.id === "model");
|
||||
expect(modelOption.options.map((o: any) => o.value)).toEqual(["default", "haiku"]);
|
||||
});
|
||||
|
||||
it("passes the user's exact ID to setModel when it matches an SDK alias", async () => {
|
||||
// Without the allowlist, the SDK would resolve `haiku` to a
|
||||
// date-pinned variant. Forcing setModel to receive `claude-haiku-4-5`
|
||||
// is exactly what the issue's workaround
|
||||
// (`ANTHROPIC_DEFAULT_HAIKU_MODEL`) achieves manually.
|
||||
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 } = mockQueryWithModels([
|
||||
{ value: "default", displayName: "Default", description: "Default model" },
|
||||
{ value: "haiku", displayName: "Haiku", description: "Fast" },
|
||||
]);
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
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 () => {
|
||||
await fs.promises.writeFile(
|
||||
path.join(tempDir, "settings.json"),
|
||||
@@ -407,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,
|
||||
@@ -35,8 +35,16 @@ import {
|
||||
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,
|
||||
@@ -639,6 +647,8 @@ describe("tool conversions", () => {
|
||||
],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
stop_details: null,
|
||||
diagnostics: null,
|
||||
usage: {
|
||||
input_tokens: 6,
|
||||
cache_creation_input_tokens: 326,
|
||||
@@ -1533,6 +1543,9 @@ describe("stop reason propagation", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1676,6 +1689,9 @@ describe("stop reason propagation", () => {
|
||||
nextPendingOrder: 0,
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
|
||||
const response = await agent.prompt({
|
||||
@@ -1794,6 +1810,409 @@ describe("stop reason propagation", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("assistant text fallback when streaming yields no text deltas", () => {
|
||||
function createCapturingAgent() {
|
||||
const notifications: SessionNotification[] = [];
|
||||
const mockClient = {
|
||||
sessionUpdate: async (n: SessionNotification) => {
|
||||
notifications.push(n);
|
||||
},
|
||||
extNotification: async () => {},
|
||||
} as unknown as AgentSideConnection;
|
||||
const agent = new ClaudeAcpAgent(mockClient, { log: () => {}, error: () => {} });
|
||||
return { agent, notifications };
|
||||
}
|
||||
|
||||
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(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
function makeResult() {
|
||||
return {
|
||||
type: "result" as const,
|
||||
subtype: "success" as const,
|
||||
stop_reason: null,
|
||||
is_error: false,
|
||||
result: "",
|
||||
errors: [],
|
||||
duration_ms: 0,
|
||||
duration_api_ms: 0,
|
||||
num_turns: 1,
|
||||
total_cost_usd: 0,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
modelUsage: {},
|
||||
permission_denials: [],
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
};
|
||||
}
|
||||
|
||||
function makeAssistantMessage(messageId: string, text: string) {
|
||||
return makeAssistantMessageWithBlocks(messageId, [{ type: "text", text }]);
|
||||
}
|
||||
|
||||
// Same as makeAssistantMessage but with arbitrary content blocks, so tests
|
||||
// can reproduce gateways that split a single response (one message id) into
|
||||
// several `assistant` messages each carrying a different block.
|
||||
function makeAssistantMessageWithBlocks(messageId: string, content: any[]) {
|
||||
return {
|
||||
type: "assistant" as const,
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message" as const,
|
||||
role: "assistant" as const,
|
||||
model: "claude-test",
|
||||
content,
|
||||
stop_reason: "end_turn",
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("emits the assembled text when no content_block_delta was streamed", async () => {
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "msg_no_stream";
|
||||
const finalText = "final answer never streamed";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
expect(textChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not re-emit text already streamed via content_block_delta", async () => {
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "msg_streamed";
|
||||
const streamedText = "streamed answer";
|
||||
injectSession(agent, [
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: {
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: messageId,
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-test",
|
||||
content: [],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: {
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "stream_event",
|
||||
parent_tool_use_id: null,
|
||||
uuid: randomUUID(),
|
||||
session_id: "test-session",
|
||||
event: {
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text: streamedText },
|
||||
},
|
||||
},
|
||||
makeAssistantMessage(messageId, streamedText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === streamedText,
|
||||
);
|
||||
// Only the streamed content_block_delta should produce a chunk; the
|
||||
// assembled message must not duplicate it.
|
||||
expect(textChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("fallback emits exactly once when the same assistant message is delivered twice", async () => {
|
||||
// Regression test: the SDK occasionally delivers the same assembled
|
||||
// assistant message twice (observed in production logs). Without
|
||||
// recording the id into `textStreamedMessageIds` after the first
|
||||
// fallback emit, the second delivery would re-fire the fallback and
|
||||
// ACP clients would see duplicate text chunks. After the dedupe fix
|
||||
// the second delivery must be a no-op.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "msg_delivered_twice";
|
||||
const finalText = "final answer delivered twice";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
expect(textChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("textStreamedMessageIds is cleared at the start of each new turn", async () => {
|
||||
// Regression test: assistant message ids are turn-scoped. Without
|
||||
// per-turn clear, the set would accumulate over the session's lifetime
|
||||
// and a (rare) reuse of an id across turns would be misclassified as
|
||||
// "already streamed", silently dropping the fallback emit. The two
|
||||
// turns below use the same messageId; both should fallback-emit
|
||||
// exactly once because the set is reset on each `prompt()` entry.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "msg_reused_across_turns";
|
||||
const finalText = "answer for both turns";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
makeAssistantMessage(messageId, finalText),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const first = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "turn 1" }],
|
||||
});
|
||||
const second = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "turn 2" }],
|
||||
});
|
||||
|
||||
expect(first.stopReason).toBe("end_turn");
|
||||
expect(second.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
// Two turns each fallback-emit once.
|
||||
expect(textChunks.length).toBe(2);
|
||||
});
|
||||
|
||||
it("emits the text block when it arrives in a later same-id message than the thinking block", async () => {
|
||||
// Production regression (OpenAI-compatible gpt-5 gateway): a single
|
||||
// assistant response (one message id) is delivered as *separate*
|
||||
// `assistant` messages — an empty `thinking` block first, then the real
|
||||
// `text` block — both sharing the same id and neither streamed via
|
||||
// content_block_delta. Deduping at message-id granularity flagged the id
|
||||
// on the thinking block and then dropped the text, so the client saw the
|
||||
// fallback fire but received an empty answer. The fallback must key on the
|
||||
// block contents so the later text block is still emitted.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "resp_split_blocks";
|
||||
const finalText = "=== Ticket Review Summary === approved";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessageWithBlocks(messageId, [
|
||||
{ type: "thinking", thinking: "", signature: "sig" },
|
||||
]),
|
||||
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: finalText }]),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const textChunks = notifications
|
||||
.map((n) => n.update)
|
||||
.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
expect(textChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("emits distinct text blocks that share a message id", async () => {
|
||||
// A gateway may split a multi-block answer (thinking + text + a second
|
||||
// text) across same-id messages. Every distinct text block must reach the
|
||||
// client exactly once.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "resp_multi_text";
|
||||
const firstText = "我先直接执行审批并返回工单结果。";
|
||||
const secondText = "Ticket approved.";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessageWithBlocks(messageId, [
|
||||
{ type: "thinking", thinking: "considering options", signature: "sig" },
|
||||
]),
|
||||
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: firstText }]),
|
||||
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: secondText }]),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const updates = notifications.map((n) => n.update);
|
||||
const firstChunks = updates.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === firstText,
|
||||
);
|
||||
const secondChunks = updates.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === secondText,
|
||||
);
|
||||
expect(firstChunks.length).toBe(1);
|
||||
expect(secondChunks.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not duplicate text when an incremental block is followed by a consolidated message", async () => {
|
||||
// Some gateways deliver an incremental per-block `assistant` message and
|
||||
// then a consolidated `assistant` message (same id) that repeats that
|
||||
// block alongside others. The repeated block must not be emitted twice.
|
||||
const { agent, notifications } = createCapturingAgent();
|
||||
const messageId = "resp_consolidated";
|
||||
const thinking = "step-by-step reasoning";
|
||||
const finalText = "the answer";
|
||||
injectSession(agent, [
|
||||
makeAssistantMessageWithBlocks(messageId, [{ type: "text", text: finalText }]),
|
||||
makeAssistantMessageWithBlocks(messageId, [
|
||||
{ type: "thinking", thinking, signature: "sig" },
|
||||
{ type: "text", text: finalText },
|
||||
]),
|
||||
makeResult(),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "go" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
const updates = notifications.map((n) => n.update);
|
||||
const textChunks = updates.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_message_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === finalText,
|
||||
);
|
||||
const thoughtChunks = updates.filter(
|
||||
(u) =>
|
||||
u.sessionUpdate === "agent_thought_chunk" &&
|
||||
u.content.type === "text" &&
|
||||
u.content.text === thinking,
|
||||
);
|
||||
expect(textChunks.length).toBe(1);
|
||||
expect(thoughtChunks.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/close", () => {
|
||||
function createMockAgent() {
|
||||
const mockClient = {
|
||||
@@ -1834,6 +2253,9 @@ describe("session/close", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -1883,6 +2305,96 @@ 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(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
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 +2442,9 @@ describe("getOrCreateSession param change detection", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
@@ -2164,6 +2679,9 @@ describe("usage_update computation", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3063,6 +3581,9 @@ describe("emitRawSDKMessages", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3290,6 +3811,9 @@ describe("result origin handling", () => {
|
||||
abortController: new AbortController(),
|
||||
emitRawSDKMessages: false,
|
||||
contextWindowSize: 200000,
|
||||
taskState: new Map(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3411,3 +3935,363 @@ 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(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
}
|
||||
|
||||
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(),
|
||||
textStreamedMessageIds: new Set<string>(),
|
||||
fallbackEmittedBlockKeys: new Set<string>(),
|
||||
};
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -53,6 +53,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 +68,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 +117,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 +127,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 () => {
|
||||
|
||||
@@ -53,6 +53,57 @@ describe("SettingsManager", () => {
|
||||
expect(settings.model).toBe("claude-3-5-haiku");
|
||||
});
|
||||
|
||||
it("should expose availableModels from settings", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const settings = settingsManager.getSettings();
|
||||
expect(settings.availableModels).toEqual(["claude-haiku-4-5", "claude-opus-4-7[1m]"]);
|
||||
});
|
||||
|
||||
it("should union and dedupe availableModels across sources", async () => {
|
||||
// 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 claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
availableModels: ["claude-haiku-4-5", "claude-opus-4-7[1m]"],
|
||||
}),
|
||||
);
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
// claude-opus-4-7[1m] overlaps with project; should be deduped.
|
||||
availableModels: ["claude-opus-4-7[1m]", "claude-sonnet-4-6[1m]"],
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const settings = settingsManager.getSettings();
|
||||
expect(settings.availableModels).toEqual([
|
||||
"claude-haiku-4-5",
|
||||
"claude-opus-4-7[1m]",
|
||||
"claude-sonnet-4-6[1m]",
|
||||
]);
|
||||
});
|
||||
|
||||
it("should merge permissions.defaultMode with later sources taking precedence", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
@@ -90,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