Compare commits

...

12 Commits

Author SHA1 Message Date
Aleksandr Suhinin
edf6486c44 Authentication adjustments for JB integration (#350)
Updated authentication methods for IDEA compatibility and SDK compliance

#### New authentication type: gateway (url + custom headers)
 
- Required to support native IDEA authentication flows
 
#### Process argument to hide Claude Code authentication

- Ensures compliance with Anthropic’s third-party authentication
policies.

https://platform.claude.com/docs/en/agent-sdk/overview

> Unless previously approved, Anthropic does not allow third party
developers to offer claude.ai login or rate limits for their products,
including agents built on the Claude Agent SDK. Please use the API key
authentication methods described in this document instead.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-03 20:29:44 +01:00
Ben Brandt
232a8cd8e2 Only store session on successful setup (#372) 2026-03-03 15:10:59 +00:00
Ben Brandt
37e3f389f6 prep release 0.20.0 (#371)
- **Remove unnecessary static-entry file**
- **Prep 0.20 release**
2026-03-03 15:49:37 +01:00
Ben Brandt
f513a14a46 Support more notifications (#370)
- **Give notification of compaction**
- **Also support local command output**
2026-03-03 14:16:43 +00:00
Kunall Banerjee
c0e2cee265 Surface better error message when Claude Code process exits unexpectedly (#366)
As seen in https://github.com/zed-industries/zed/issues/39563, Claude
Agent child processes can die mid-session (crash, signal, OOM, et al).
When that happens, the SDK throws errors with internal messages like
`"ProcessTransport closed"`, `"Failed to write to process stdin"`, or
`"process terminated by signal"`. These would bubble up to the user
as-is, which is not helpful.

At this point, it’s [not possible to continue the
session](https://github.com/zed-industries/zed/issues/39563#issuecomment-3776814727)
either (which I also confirmed myself), but that is not obvious from the
error the user sees (screenshot on the left).

In my testing, I could not get the `Error: ProcessTransport is not ready
for writing` error, but you can easily trigger the `SIGTERM` (exit code
143) by simply killing the `claude` child process spawned by the ACP
adapter while the agent is streaming a response:

<img width="1621" height="54" alt="image"
src="https://github.com/user-attachments/assets/29d989f1-7e56-44b3-992d-065218444958"
/>

In this case, you’d do `kill 74573` to trigger the error.

AFAICT, the child process is managed internally by the Claude Agent SDK
(inside the `query` object returned by `query()`). The ACP agent only
sees it as an `AsyncGenerator`. There’s no `process.pid` or
`ChildProcess` handle exposed to check liveness against. The only way to
discover the process is dead is when the SDK throws one of these
transport-level errors*.

\*I got these error strings by asking Opus 4.6 to de-obfuscate
(`node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs`)

| Before | After |
|--------|--------|
| <img width="466" height="62" alt="image"
src="https://github.com/user-attachments/assets/dc959924-0929-4587-9ddd-4a3873f1fa5d"
/> | <img width="462" height="77" alt="image"
src="https://github.com/user-attachments/assets/77eff880-28da-489a-b9e3-e9f93abf3c7f"
/> |

In other words, I think the best we (the ACP adapter) can do here is to
grep for these transport errors and surface a _better_ error message to
the user, as seen above.

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-03 13:54:51 +01:00
Anurag Akkiraju
b4f56b17fc fix: throw resourceNotFound when loadSession fails to resume (#363)
## Summary

- When `loadSession` calls `createSession({ resume: sessionId })` and
the CLI subprocess can't find persisted session data (no `.jsonl` file),
the subprocess exits immediately and `createSession` rejects with a
generic `-32603` ("Query closed before response received")
- ACP clients like [acpx](https://github.com/openclaw/acpx) check for
`-32001`/`-32002` (resource not found) to fall back to `session/new` —
the generic `-32603` bypasses this, causing the turn to fail entirely
- This commonly happens when `session/new` creates an ACP session but
the subprocess exits before writing session data (no prompt to process),
then `session/prompt` tries `session/load` to resume a session that was
never persisted

## Fix

Wrap the `createSession({ resume })` call in `loadSession()` with
try/catch and re-throw as `RequestError.resourceNotFound()` (code
`-32002`). This lets callers handle it with their existing fallback
logic.

## Verification

Confirmed working in production with the
[OpenClaw](https://github.com/openclaw/openclaw) gateway + acpx
integration. Before the fix, all ACP tasks failed silently. After
patching both this code path and acpx's `shouldFallbackToNewSession()`
([openclaw/acpx#30](https://github.com/openclaw/acpx/pull/30)), sessions
fall back to `session/new` correctly.

Fixes #338
Closes #361
Closes #362

---------

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-03 13:47:25 +01:00
Anurag Akkiraju
c2e45e638d fix: add missing zod dependency (#364)
## Summary

- `@agentclientprotocol/sdk@0.14.1` declares `zod` as a peer dependency
(`"^3.25.0 || ^4.0.0"`) but this package does not include it as a direct
dependency
- On clean installs (e.g. Zed's external agent directory), `zod` is not
hoisted from any other package, causing `ERR_MODULE_NOT_FOUND` on every
agent start
- Users must manually `npm install zod` after every update as a
workaround

## Fix

Add `"zod": "^3.25.0 || ^4.0.0"` to `dependencies` to match the SDK's
peer dependency range.

Fixes #357

Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
2026-03-03 12:01:03 +00:00
Luis
5c1863d56e Fix: propagate max_tokens stop reason instead of throwing internal error (#365)
## Summary

- When the Claude SDK reports `stop_reason: "max_tokens"`, the ACP
adapter now returns `{ stopReason: "max_tokens" }` instead of throwing a
`RequestError.internalError` (JSON-RPC `-32603`)
- Scoped to `success` and `error_during_execution` result subtypes only
— `error_max_turns` / `error_max_budget_usd` still correctly map to
`max_turn_requests`

## Problem

When tokens are exhausted, the SDK sends a `result` message with
`stop_reason: "max_tokens"` and `is_error: true`. The adapter hit the
`is_error` check first and threw an internal error, so ACP clients never
received the `stopReason` field. Downstream tools like
[toad](https://github.com/batrachianai/toad/discussions/176#discussioncomment-15445958)
saw a JSON-RPC exception instead of a clean stop reason.

## Fix

Check `stop_reason === "max_tokens"` before the `is_error` throw in the
`success` and `error_during_execution` branches. Token exhaustion is a
normal stop condition, not an internal error.

## Test plan

- [x] Added 5 unit tests covering:
  - `success` + `max_tokens` + `is_error: false` → returns `max_tokens`
- `success` + `max_tokens` + `is_error: true` → returns `max_tokens`
(main bug)
- `error_during_execution` + `max_tokens` + `is_error: true` → returns
`max_tokens`
- `success` + `null` stop_reason → still returns `end_turn` (no
regression)
- `success` + `is_error: true` + non-max_tokens → still throws (no
regression)
- [x] All 100 tests pass
- [x] Build passes

Fixes #235

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 11:58:40 +00:00
Tim Visher
6253b1611e Respect user settings for permission mode and model selection (#340)
Most user settings (`env`, permission rules like `allow`/`deny`/`ask`)
already work through the SDK's `settingSources` or are handled by the
CLI subprocess. Two settings require explicit handling by the ACP agent
and were not being respected:

**`permissions.defaultMode`**: Must be passed as the `permissionMode`
option to `query()`. The SDK defaults to `"default"` internally and does
not read it from settings files. The CLI handles this by reading the
setting and passing it to the SDK; `createSession` was hardcoding it to
`"default"` instead.

**`model`**: The CLI persists display strings like `"opus[1m]"` in
settings, but the SDK model list uses IDs like `"claude-opus-4-6-1m"`.
The existing substring matching in `getAvailableModels` couldn't resolve
these aliases, so they silently fell back to the first model. Passing
the raw alias to `query.setModel()` doesn't work either — the CLI's
internal `dg()` function strips `[1m]` and loses the context hint.

Changes:
- Fix pre-existing build errors from SDK type changes
(`SDKTaskStartedMessage` removed, `SDKMessageTemp` replaced with SDK's
own `SDKMessage`)
- Add `permissions.defaultMode` to `ClaudeCodeSettings`, merge it
through the settings hierarchy, and resolve it via
`resolvePermissionMode()` with case-insensitive aliases, validation, and
root-user guard
- Add tokenized model matching (`resolveModelPreference`) that handles
CLI display strings like `"opus[1m]"` by scoring against the available
models list
- Add unit and integration tests for both features

Co-authored-by: Codex <codex@openai.com>

---------

Co-authored-by: Tim Visher <194828183+timvisher-dd@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Codex <codex@openai.com>
2026-03-03 12:54:36 +01:00
Ben Brandt
6ff856c94f Update to claude-agent-sdk 0.2.63 (#369) 2026-03-03 12:53:16 +01:00
Steffen Deusch
1b057923e8 support --cli for node (#358)
This allows to also use `--cli` when not using the standalone build.
2026-02-27 21:44:59 +01:00
Steffen Deusch
3b3ce6f287 Ensure concurrent prompts are handled. (#348)
This basically implements the pending prompt queueing RFD. When a prompt
request is received while the agent is already generating, we
immediately forward the message to Claude and then wait until it replays
the message back to us. When it replays it, we respond to the previous
prompt request with "end_turn", which also singifies to the client that
the message is now being processed by the agent.

See
https://github.com/agentclientprotocol/agent-client-protocol/pull/484.
2026-02-27 18:53:00 +01:00
14 changed files with 1283 additions and 355 deletions

View File

@@ -54,7 +54,7 @@ jobs:
- name: Build binary
run: |
bun build src/static-entry.ts \
bun build src/index.ts \
--compile \
--target ${{ matrix.target }} \
--define 'process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN="true"' \

3
.prettierignore Normal file
View File

@@ -0,0 +1,3 @@
node_modules
dist
.agent-shell

View File

@@ -1,5 +1,16 @@
# Changelog
## 0.20.0
- Update to @anthropic-ai/claude-agent-sdk@0.2.63
- Respect user settings for permission mode and model selection
- Better handling of concurrent prompts
- Support --cli for node as well
- Propagate max_tokens stop reason instead of throwing internal error
- fix: throw resourceNotFound when loadSession fails to resume
- fix: add missing zod dependency
- Surface better error message when Claude Code process exits unexpectedly
## 0.19.2
- Fix for broken notifications when reloading session messages

42
package-lock.json generated
View File

@@ -1,16 +1,17 @@
{
"name": "@zed-industries/claude-agent-acp",
"version": "0.19.2",
"version": "0.20.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zed-industries/claude-agent-acp",
"version": "0.19.2",
"version": "0.20.0",
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.62"
"@anthropic-ai/claude-agent-sdk": "0.2.63",
"zod": "^3.25.0 || ^4.0.0"
},
"bin": {
"claude-agent-acp": "dist/index.js"
@@ -18,12 +19,12 @@
"devDependencies": {
"@anthropic-ai/sdk": "0.78.0",
"@eslint/js": "10.0.1",
"@types/node": "25.3.2",
"@types/node": "25.3.3",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"eslint": "10.0.2",
"eslint-config-prettier": "10.1.8",
"globals": "17.3.0",
"globals": "17.4.0",
"prettier": "3.8.1",
"ts-node": "10.9.2",
"typescript": "5.9.3",
@@ -40,9 +41,9 @@
}
},
"node_modules/@anthropic-ai/claude-agent-sdk": {
"version": "0.2.62",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.62.tgz",
"integrity": "sha512-exRJ2djKP6erRpUrtnVf5iXzqeS9dAie9d1ghODZVVXdLqieSqCpaAhZhe0hL1yvP33OL0J5CgT9RAyPzhYY9A==",
"version": "0.2.63",
"resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.2.63.tgz",
"integrity": "sha512-ZNiaQb/v6xkbrGt3dtq5J0DGY+AaOhoehUyposa3msvlAlkTHWNGR+NhbCcTE0ML1U91xhPqMAAwZIUqrlkKyQ==",
"license": "SEE LICENSE IN README.md",
"engines": {
"node": ">=18.0.0"
@@ -1472,9 +1473,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.3.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.2.tgz",
"integrity": "sha512-RpV6r/ij22zRRdyBPcxDeKAzH43phWVKEjL2iksqo1Vz3CuBUrgmPpPhALKiRfU7OMCmeeO9vECBMsV0hMTG8Q==",
"version": "25.3.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
"integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2355,9 +2356,9 @@
}
},
"node_modules/flatted": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz",
"integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==",
"version": "3.3.4",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.4.tgz",
"integrity": "sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==",
"dev": true,
"license": "ISC"
},
@@ -2390,9 +2391,9 @@
}
},
"node_modules/globals": {
"version": "17.3.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.3.0.tgz",
"integrity": "sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==",
"version": "17.4.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz",
"integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2702,9 +2703,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.6",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
"version": "8.5.8",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
"integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
"dev": true,
"funding": [
{
@@ -3261,7 +3262,6 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.19.2",
"version": "0.20.0",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -61,17 +61,18 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.62"
"@anthropic-ai/claude-agent-sdk": "0.2.63",
"zod": "^3.25.0 || ^4.0.0"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.78.0",
"@eslint/js": "10.0.1",
"@types/node": "25.3.2",
"@types/node": "25.3.3",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"eslint": "10.0.2",
"eslint-config-prettier": "10.1.8",
"globals": "17.3.0",
"globals": "17.4.0",
"prettier": "3.8.1",
"ts-node": "10.9.2",
"typescript": "5.9.3",

File diff suppressed because it is too large Load Diff

View File

@@ -2,25 +2,31 @@
// Load managed settings and apply environment variables
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
const managedSettings = loadManagedSettings();
if (managedSettings) {
applyEnvironmentSettings(managedSettings);
}
// stdout is used to send messages to the client
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
console.log = console.error;
console.info = console.error;
console.warn = console.error;
console.debug = console.error;
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
import { runAcp } from "./acp-agent.js";
runAcp();
// Keep process alive
process.stdin.resume();
if (process.argv.includes("--cli")) {
process.argv = process.argv.filter((arg) => arg !== "--cli");
// @ts-expect-error -- no types
await import("@anthropic-ai/claude-agent-sdk/cli.js");
} else {
const managedSettings = loadManagedSettings();
if (managedSettings) {
applyEnvironmentSettings(managedSettings);
}
// stdout is used to send messages to the client
// we redirect everything else to stderr to make sure it doesn't interfere with ACP
console.log = console.error;
console.info = console.error;
console.warn = console.error;
console.debug = console.error;
process.on("unhandledRejection", (reason, promise) => {
console.error("Unhandled Rejection at:", promise, "reason:", reason);
});
runAcp();
// Keep process alive
process.stdin.resume();
}

View File

@@ -14,7 +14,12 @@ 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;
}
@@ -162,6 +167,13 @@ export class SettingsManager {
if (settings.model) {
merged.model = settings.model;
}
if (settings.permissions?.defaultMode !== undefined) {
merged.permissions = {
...merged.permissions,
defaultMode: settings.permissions.defaultMode,
};
}
}
this.mergedSettings = merged;

View File

@@ -1,7 +0,0 @@
if (process.argv.includes("--cli")) {
process.argv = process.argv.filter((arg) => arg !== "--cli");
// @ts-expect-error -- no types
await import("@anthropic-ai/claude-agent-sdk/cli.js");
} else {
await import("./index.js");
}

View File

@@ -0,0 +1,220 @@
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import * as fs from "node:fs";
import * as path from "node:path";
import * as os from "node:os";
import type { AgentSideConnection } from "@agentclientprotocol/sdk";
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
const { querySpy } = vi.hoisted(() => ({
querySpy: vi.fn(),
}));
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
const actual = await vi.importActual<any>("@anthropic-ai/claude-agent-sdk");
return {
...actual,
query: querySpy,
};
});
describe("ClaudeAcpAgent settings", () => {
let tempDir: string;
let originalClaudeConfigDir: string | undefined;
function createMockClient(): AgentSideConnection {
return {
sessionUpdate: async () => {},
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
readTextFile: async () => ({ content: "" }),
writeTextFile: async () => ({}),
} as unknown as AgentSideConnection;
}
function mockQuery() {
let capturedOptions: any;
const setModelSpy = vi.fn();
querySpy.mockImplementation(({ options }: any) => {
capturedOptions = options;
return {
initializationResult: async () => ({
models: [
{
value: "claude-sonnet-4-5",
displayName: "Claude Sonnet 4.5",
description: "Default",
},
],
}),
setModel: setModelSpy,
supportedCommands: async () => [],
} as any;
});
return { getCapturedOptions: () => capturedOptions, setModelSpy };
}
beforeEach(async () => {
tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "acp-agent-settings-"));
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
process.env.CLAUDE_CONFIG_DIR = tempDir;
querySpy.mockReset();
vi.resetModules();
});
afterEach(async () => {
if (originalClaudeConfigDir) {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
} else {
delete process.env.CLAUDE_CONFIG_DIR;
}
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
it("uses permissions.defaultMode for new sessions", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
permissions: {
defaultMode: "dontAsk",
},
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
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("dontAsk");
expect(getCapturedOptions().settingSources).toEqual(["user", "project", "local"]);
expect(response.modes.currentModeId).toBe("dontAsk");
});
it("supports acceptEdits mode defaults", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
permissions: {
defaultMode: "acceptEdits",
},
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
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("acceptEdits");
expect(response.modes.currentModeId).toBe("acceptEdits");
});
it("defaults to 'default' when no permissions.defaultMode is set", async () => {
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
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("throws when permissions.defaultMode is not a string", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
permissions: {
defaultMode: 123,
},
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
mockQuery();
const { ClaudeAcpAgent } = await import("../acp-agent.js");
const agent: ClaudeAcpAgentType = new ClaudeAcpAgent(createMockClient());
await expect(
(agent as any).createSession({
cwd: projectDir,
mcpServers: [],
_meta: { disableBuiltInTools: true },
}),
).rejects.toThrow("Invalid permissions.defaultMode");
});
it("resolves model aliases like opus[1m] to the correct model", async () => {
await fs.promises.writeFile(
path.join(tempDir, "settings.json"),
JSON.stringify({
model: "opus[1m]",
}),
);
const projectDir = path.join(tempDir, "project");
await fs.promises.mkdir(projectDir, { recursive: true });
const setModelSpy = vi.fn();
querySpy.mockImplementation(({ options: _options }: any) => {
return {
initializationResult: async () => ({
models: [
{
value: "claude-opus-4-6",
displayName: "Claude Opus 4.6",
description: "Base",
},
{
value: "claude-opus-4-6-1m",
displayName: "Claude Opus 4.6 (1M)",
description: "Long context",
},
],
}),
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-opus-4-6-1m");
expect(response.models.currentModelId).toBe("claude-opus-4-6-1m");
});
});

View File

@@ -23,7 +23,8 @@ import {
toolUpdateFromToolResult,
toolUpdateFromEditToolResponse,
} from "../tools.js";
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent } from "../acp-agent.js";
import { Pushable } from "../utils.js";
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
import { randomUUID } from "crypto";
import type {
@@ -1228,3 +1229,153 @@ describe("permission requests", () => {
}
});
});
describe("stop reason propagation", () => {
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",
};
}
function* messageGenerator(messages: any[]) {
yield* messages;
}
function injectSession(agent: ClaudeAcpAgent, messages: any[]) {
const gen = messageGenerator(messages);
agent.sessions["test-session"] = {
query: gen as any,
input: new Pushable(),
cancelled: false,
permissionMode: "default",
settingsManager: {} as any,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: [],
promptRunning: false,
pendingMessages: new Map(),
nextPendingOrder: 0,
};
}
it("should return max_tokens when success result has stop_reason max_tokens", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({ subtype: "success", stop_reason: "max_tokens", is_error: false }),
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("max_tokens");
});
it("should return max_tokens when success result has stop_reason max_tokens and is_error true", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({
subtype: "success",
stop_reason: "max_tokens",
is_error: true,
result: "Token limit reached",
}),
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("max_tokens");
});
it("should return max_tokens when error_during_execution has stop_reason max_tokens", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({
subtype: "error_during_execution",
stop_reason: "max_tokens",
is_error: true,
errors: ["some error"],
}),
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("max_tokens");
});
it("should return end_turn for success with null stop_reason", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({ subtype: "success", stop_reason: null, is_error: false }),
]);
const response = await agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
});
expect(response.stopReason).toBe("end_turn");
});
it("should throw internal error for success with is_error true and no max_tokens", async () => {
const agent = createMockAgent();
injectSession(agent, [
createResultMessage({
subtype: "success",
stop_reason: "end_turn",
is_error: true,
result: "Something went wrong",
}),
]);
await expect(
agent.prompt({
sessionId: "test-session",
prompt: [{ type: "text", text: "test" }],
}),
).rejects.toThrow("Internal error");
});
});

View File

@@ -0,0 +1,160 @@
import { describe, expect, it, Mock, vi, afterEach, beforeEach } from "vitest";
import { ClaudeAcpAgent } from "../acp-agent.js";
import { AgentSideConnection } from "@agentclientprotocol/sdk";
describe("authorization", () => {
beforeEach(() => {
vi.useFakeTimers();
});
afterEach(() => {
//await all pending events like
vi.runAllTimers();
vi.useRealTimers();
vi.unstubAllGlobals();
vi.resetAllMocks();
});
async function createAgentMock(): Promise<[ClaudeAcpAgent, Mock]> {
const mockQuery = vi.hoisted(() =>
vi.fn(() => ({
initializationResult: vi.fn().mockResolvedValue({
models: [{ value: "id", displayName: "name", description: "description" }],
}),
setModel: vi.fn(),
supportedCommands: vi.fn().mockResolvedValue([]),
})),
);
vi.mock("@anthropic-ai/claude-agent-sdk", () => ({
query: mockQuery,
}));
const connectionMock = {
sessionUpdate: async (_: any) => {},
} as AgentSideConnection;
const agent = new ClaudeAcpAgent(connectionMock);
return [agent, mockQuery];
}
it("gateway auth not offered without capability", async () => {
const [agent] = await createAgentMock();
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {},
});
expect(initializeResponse.authMethods).not.toContainEqual(
expect.objectContaining({ id: "gateway" }),
);
});
it("gateway auth offered when client advertises auth._meta.gateway capability", async () => {
const [agent] = await createAgentMock();
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {
auth: { _meta: { gateway: true } },
} as any,
});
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "gateway" }),
);
});
it("uses gateway env after gateway auth", async () => {
const [agent, mockQuery] = await createAgentMock();
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {
auth: { _meta: { gateway: true } },
} as any,
});
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "gateway" }),
);
await agent.authenticate({
methodId: "gateway",
_meta: { gateway: { baseUrl: "https://gateway.example", headers: { "x-api-key": "test" } } },
});
await agent.newSession({
cwd: "testRoot",
mcpServers: [],
_meta: {
claudeCode: {
options: {
env: {
userEnv: "userEnv",
},
},
},
},
});
expect(mockQuery).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: {
ANTHROPIC_AUTH_TOKEN: "",
ANTHROPIC_BASE_URL: "https://gateway.example",
ANTHROPIC_CUSTOM_HEADERS: "x-api-key: test",
userEnv: "userEnv",
},
}),
}),
);
});
it("hide claude authentication without terminal-auth", async () => {
const [agent] = await createAgentMock();
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {
auth: { _meta: { gateway: true } },
} as any,
});
expect(initializeResponse.authMethods).not.toContainEqual(
expect.objectContaining({ id: "claude-login" }),
);
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "gateway" }),
);
});
it("terminal auth still offered when --hide-claude-auth is set", async () => {
const [agent] = await createAgentMock();
vi.stubGlobal("process", { ...process, argv: ["--hide-claude-auth"] });
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {
_meta: { "terminal-auth": true },
},
});
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "claude-login" }),
);
});
it("show claude authentication", async () => {
const [agent] = await createAgentMock();
const initializeResponse = await agent.initialize({
protocolVersion: 1,
clientCapabilities: {},
});
expect(initializeResponse.authMethods).toContainEqual(
expect.objectContaining({ id: "claude-login" }),
);
});
});

View File

@@ -0,0 +1,43 @@
import { describe, it, expect } from "vitest";
import { resolvePermissionMode } from "../acp-agent.js";
describe("resolvePermissionMode", () => {
it("returns 'default' when no mode is provided", () => {
expect(resolvePermissionMode()).toBe("default");
expect(resolvePermissionMode(undefined)).toBe("default");
});
it("resolves exact canonical modes", () => {
expect(resolvePermissionMode("default")).toBe("default");
expect(resolvePermissionMode("acceptEdits")).toBe("acceptEdits");
expect(resolvePermissionMode("dontAsk")).toBe("dontAsk");
expect(resolvePermissionMode("plan")).toBe("plan");
expect(resolvePermissionMode("bypassPermissions")).toBe("bypassPermissions");
});
it("resolves case-insensitive aliases", () => {
expect(resolvePermissionMode("DontAsk")).toBe("dontAsk");
expect(resolvePermissionMode("DONTASK")).toBe("dontAsk");
expect(resolvePermissionMode("AcceptEdits")).toBe("acceptEdits");
expect(resolvePermissionMode("bypass")).toBe("bypassPermissions");
});
it("trims whitespace", () => {
expect(resolvePermissionMode(" dontAsk ")).toBe("dontAsk");
});
it("throws on non-string values", () => {
expect(() => resolvePermissionMode(123)).toThrow("expected a string");
expect(() => resolvePermissionMode(true)).toThrow("expected a string");
expect(() => resolvePermissionMode({})).toThrow("expected a string");
});
it("throws on empty string", () => {
expect(() => resolvePermissionMode("")).toThrow("expected a non-empty string");
expect(() => resolvePermissionMode(" ")).toThrow("expected a non-empty string");
});
it("throws on unknown mode", () => {
expect(() => resolvePermissionMode("yolo")).toThrow("Invalid permissions.defaultMode: yolo");
});
});

View File

@@ -52,5 +52,42 @@ describe("SettingsManager", () => {
settings = settingsManager.getSettings();
expect(settings.model).toBe("claude-3-5-haiku");
});
it("should merge permissions.defaultMode with later sources taking precedence", 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({
permissions: {
defaultMode: "dontAsk",
},
}),
);
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
let settings = settingsManager.getSettings();
expect(settings.permissions?.defaultMode).toBe("dontAsk");
// Local settings override the mode
await fs.promises.writeFile(
path.join(claudeDir, "settings.local.json"),
JSON.stringify({
permissions: {
defaultMode: "plan",
},
}),
);
settingsManager.dispose();
settingsManager = new SettingsManager(tempDir);
await settingsManager.initialize();
settings = settingsManager.getSettings();
expect(settings.permissions?.defaultMode).toBe("plan");
});
});
});