mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
59 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
95721e4e39 | ||
|
|
7f1c6c2078 | ||
|
|
3500b6c16e | ||
|
|
07db59ef0d | ||
|
|
34c847a9e3 | ||
|
|
d2428a49ef | ||
|
|
91d629d6c6 | ||
|
|
5fd7873c7c | ||
|
|
83496544b2 | ||
|
|
c5d5c11f40 | ||
|
|
521d1f766d | ||
|
|
f11435df13 | ||
|
|
e8545266fe | ||
|
|
ff3b97aece | ||
|
|
c40d7bc30f | ||
|
|
152fe91152 | ||
|
|
45de3d2847 | ||
|
|
c7ae07da6b | ||
|
|
0af467c400 | ||
|
|
f4ce899223 | ||
|
|
31539b4ee9 | ||
|
|
61a39ea801 | ||
|
|
c0b7752b45 | ||
|
|
3002539e4a | ||
|
|
87a0886a34 | ||
|
|
2193a13a3b | ||
|
|
c13edf3b02 | ||
|
|
a217891e6c | ||
|
|
867de85f0b | ||
|
|
af11cdbba5 | ||
|
|
f7af07758c | ||
|
|
3524606555 | ||
|
|
9ecb222807 | ||
|
|
dd9fe3a98e | ||
|
|
1c641cd6a7 | ||
|
|
a02ef184d3 | ||
|
|
3beddee766 | ||
|
|
52d5b74aa7 | ||
|
|
edf6486c44 | ||
|
|
232a8cd8e2 | ||
|
|
37e3f389f6 | ||
|
|
f513a14a46 | ||
|
|
c0e2cee265 | ||
|
|
b4f56b17fc | ||
|
|
c2e45e638d | ||
|
|
5c1863d56e | ||
|
|
6253b1611e | ||
|
|
6ff856c94f | ||
|
|
1b057923e8 | ||
|
|
3b3ce6f287 | ||
|
|
b1d09f2366 | ||
|
|
d24066bd9d | ||
|
|
5ca63abc47 | ||
|
|
001614b459 | ||
|
|
218661f97b | ||
|
|
5ede2b7c4c | ||
|
|
988a741d59 | ||
|
|
b57a429867 | ||
|
|
101f09dbf8 |
92
.github/workflows/publish.yml
vendored
92
.github/workflows/publish.yml
vendored
@@ -1,11 +1,84 @@
|
||||
name: Publish Package to npmjs
|
||||
name: Publish and Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
publish-npm:
|
||||
build-binaries:
|
||||
name: Build (${{ matrix.target }})
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 15
|
||||
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
include:
|
||||
- target: bun-linux-x64
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-linux-x64.tar.gz
|
||||
- target: bun-linux-arm64
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-linux-arm64.tar.gz
|
||||
- target: bun-linux-x64-musl
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-linux-x64-musl.tar.gz
|
||||
- target: bun-linux-arm64-musl
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-linux-arm64-musl.tar.gz
|
||||
- target: bun-darwin-x64
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-darwin-x64.zip
|
||||
- target: bun-darwin-arm64
|
||||
artifact: claude-agent-acp
|
||||
archive: claude-agent-acp-darwin-arm64.zip
|
||||
- target: bun-windows-x64
|
||||
artifact: claude-agent-acp.exe
|
||||
archive: claude-agent-acp-windows-x64.zip
|
||||
- target: bun-windows-arm64
|
||||
artifact: claude-agent-acp.exe
|
||||
archive: claude-agent-acp-windows-arm64.zip
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- uses: oven-sh/setup-bun@3d267786b128fe76c2f16a390aa2448b815359f3
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install
|
||||
|
||||
- name: Build binary
|
||||
run: |
|
||||
bun build src/index.ts \
|
||||
--compile \
|
||||
--target ${{ matrix.target }} \
|
||||
--define 'process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN="true"' \
|
||||
--outfile ${{ matrix.artifact }}
|
||||
|
||||
- name: Compress binary
|
||||
run: |
|
||||
if [[ "${{ matrix.archive }}" == *.zip ]]; then
|
||||
zip ${{ matrix.archive }} ${{ matrix.artifact }}
|
||||
else
|
||||
tar czf ${{ matrix.archive }} ${{ matrix.artifact }}
|
||||
fi
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f
|
||||
with:
|
||||
name: ${{ matrix.archive }}
|
||||
path: ${{ matrix.archive }}
|
||||
|
||||
publish-npm:
|
||||
name: Publish to npm
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-binaries]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
environment: release # Optional: for enhanced security
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -22,15 +95,26 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: npm run build
|
||||
- run: npm publish
|
||||
|
||||
release:
|
||||
name: Create Release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [publish-npm]
|
||||
needs: [build-binaries, publish-npm]
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131
|
||||
with:
|
||||
path: artifacts
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
with:
|
||||
generate_release_notes: true
|
||||
files: artifacts/*
|
||||
|
||||
3
.prettierignore
Normal file
3
.prettierignore
Normal file
@@ -0,0 +1,3 @@
|
||||
node_modules
|
||||
dist
|
||||
.agent-shell
|
||||
73
CHANGELOG.md
73
CHANGELOG.md
@@ -1,5 +1,78 @@
|
||||
# Changelog
|
||||
|
||||
## 0.23.0
|
||||
|
||||
- Use idle session state as end of turn (#463)
|
||||
- Update claude-agent-sdk to 0.2.83 (#462)
|
||||
- Fix handling of local-only slash commands (#432)
|
||||
- fix: correct null check for gatewayAuthMeta in subscription validation (#455)
|
||||
- fix: include both stdout and stderr in Bash tool output (#456)
|
||||
- fix: prevent prompt loop hang when cancel races with first result (#458)
|
||||
- fix: dispose SettingsManager on session close to prevent resource leaks (#454)
|
||||
- fix: restore plan content in ExitPlanMode tool call (#451)
|
||||
|
||||
## 0.22.2
|
||||
|
||||
- Add experimental meta param for testing additional directories
|
||||
|
||||
## 0.22.1
|
||||
|
||||
- Fix: invalid auth required state in gateway mode
|
||||
|
||||
## 0.22.0
|
||||
|
||||
- Use stable list sessions method (#429)
|
||||
- Use correct Claude CLI path for static binaries (#428)
|
||||
- Update claude-agent-sdk to 0.2.76 (#427)
|
||||
- fix: resolve model aliases in setSessionConfigOption (#401) (#403)
|
||||
- Reuse existing sessions for load/resume if possible (#426)
|
||||
- Remove interrupt flag from deny responses (#425)
|
||||
- Allow Bypass permissions mode after Exiting plan (#410)
|
||||
- Don't get out of sync when background task creates new init/result (try 2) (#400)
|
||||
- Add session/close support (#409)
|
||||
|
||||
## 0.21.0
|
||||
|
||||
- Update to claude-agent-sdk 0.2.71
|
||||
- show project-relative paths in tool call titles
|
||||
- Lib: pass through tools array to control built-in tool availability
|
||||
- fix: skip user replay
|
||||
- fix: handle renamed Agent tool in toolInfoFromToolUse
|
||||
|
||||
## 0.20.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.68
|
||||
|
||||
## 0.20.1
|
||||
|
||||
- fix: inherit process.env when spawning agent subprocess
|
||||
|
||||
## 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
|
||||
|
||||
## 0.19.1
|
||||
|
||||
- Support windows arm builds and clean up artifact files
|
||||
|
||||
## 0.19.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.62
|
||||
- Use SDK functions for listing and loading session history
|
||||
- Build single-file executables using bun.
|
||||
- Fix for overwritten disallowed tools.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
- Switch over to built-in Claude tools. We no longer replicate specific ACP tools and just rely on sending updates based on Claude's internal tools. This means it won't use client capabilities for files or terminals, but also means there will be less difference and hopefully issues arising from the differences in behavior.
|
||||
|
||||
@@ -48,6 +48,11 @@ You can then use `claude-agent-acp` as a regular ACP agent:
|
||||
ANTHROPIC_API_KEY=sk-... claude-agent-acp
|
||||
```
|
||||
|
||||
#### Single-file executable
|
||||
|
||||
Pre-built single-file binaries are available on the [Releases](https://github.com/zed-industries/claude-agent-acp/releases) page for Linux, macOS, and Windows.
|
||||
These binaries bundle everything needed and don't require Node.js.
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0
|
||||
|
||||
1972
package-lock.json
generated
1972
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
21
package.json
21
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.18.0",
|
||||
"version": "0.23.0",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -60,21 +60,22 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.45"
|
||||
"@agentclientprotocol/sdk": "0.17.0",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.83",
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.76.0",
|
||||
"@anthropic-ai/sdk": "0.80.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.0",
|
||||
"@typescript-eslint/parser": "8.56.0",
|
||||
"eslint": "10.0.0",
|
||||
"@types/node": "25.5.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.57.2",
|
||||
"@typescript-eslint/parser": "8.57.2",
|
||||
"eslint": "10.1.0",
|
||||
"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",
|
||||
"vitest": "4.0.18"
|
||||
"vitest": "4.1.1"
|
||||
}
|
||||
}
|
||||
|
||||
1550
src/acp-agent.ts
1550
src/acp-agent.ts
File diff suppressed because it is too large
Load Diff
45
src/index.ts
45
src/index.ts
@@ -2,25 +2,30 @@
|
||||
|
||||
// Load managed settings and apply environment variables
|
||||
import { loadManagedSettings, applyEnvironmentSettings } from "./utils.js";
|
||||
import { claudeCliPath, runAcp } from "./acp-agent.js";
|
||||
|
||||
const managedSettings = loadManagedSettings();
|
||||
if (managedSettings) {
|
||||
applyEnvironmentSettings(managedSettings);
|
||||
if (process.argv.includes("--cli")) {
|
||||
process.argv = process.argv.filter((arg) => arg !== "--cli");
|
||||
await import(await claudeCliPath());
|
||||
} 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();
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
@@ -15,7 +15,12 @@ export {
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
export { toolInfoFromToolUse, planEntries, toolUpdateFromToolResult } from "./tools.js";
|
||||
export {
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
planEntries,
|
||||
toolUpdateFromToolResult,
|
||||
} from "./tools.js";
|
||||
export {
|
||||
SettingsManager,
|
||||
type ClaudeCodeSettings,
|
||||
|
||||
@@ -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;
|
||||
|
||||
220
src/tests/acp-agent-settings.test.ts
Normal file
220
src/tests/acp-agent-settings.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from "vitest";
|
||||
import { spawn, spawnSync } from "child_process";
|
||||
import {
|
||||
Agent,
|
||||
@@ -20,10 +20,12 @@ import { nodeToWebWritable, nodeToWebReadable } from "../utils.js";
|
||||
import {
|
||||
markdownEscape,
|
||||
toolInfoFromToolUse,
|
||||
toDisplayPath,
|
||||
toolUpdateFromToolResult,
|
||||
toolUpdateFromEditToolResponse,
|
||||
} from "../tools.js";
|
||||
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
|
||||
import { toAcpNotifications, promptToClaude, ClaudeAcpAgent, claudeCliPath } from "../acp-agent.js";
|
||||
import { Pushable } from "../utils.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
import type {
|
||||
@@ -226,7 +228,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toBe("");
|
||||
expect(client.takeReceivedText()).toBe("Compacting...\n\nCompacting completed.");
|
||||
|
||||
// Send something
|
||||
await connection.prompt({
|
||||
@@ -247,7 +249,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration"
|
||||
sessionId: newSessionResponse.sessionId,
|
||||
});
|
||||
|
||||
expect(client.takeReceivedText()).toContain("");
|
||||
expect(client.takeReceivedText()).toContain("Compacting...\n\nCompacting completed.");
|
||||
}, 30000);
|
||||
});
|
||||
|
||||
@@ -562,6 +564,20 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("should use relative path in title when cwd is provided", () => {
|
||||
const tool_use = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01READ_CWD",
|
||||
name: "Read",
|
||||
input: { file_path: "/Users/test/project/src/main.ts" },
|
||||
};
|
||||
|
||||
const result = toolInfoFromToolUse(tool_use, false, "/Users/test/project");
|
||||
expect(result.title).toBe("Read src/main.ts");
|
||||
// locations.path stays absolute for navigation
|
||||
expect(result.locations).toStrictEqual([{ path: "/Users/test/project/src/main.ts", line: 1 }]);
|
||||
});
|
||||
|
||||
it("should handle plan entries", () => {
|
||||
const received: SDKAssistantMessage = {
|
||||
type: "assistant",
|
||||
@@ -968,6 +984,22 @@ describe("tool conversions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("toDisplayPath", () => {
|
||||
it("should relativize paths inside cwd and keep absolute paths outside", () => {
|
||||
expect(toDisplayPath("/Users/test/project/src/main.ts", "/Users/test/project")).toBe(
|
||||
"src/main.ts",
|
||||
);
|
||||
expect(toDisplayPath("/etc/hosts", "/Users/test/project")).toBe("/etc/hosts");
|
||||
expect(toDisplayPath("/Users/test/project/src/main.ts")).toBe(
|
||||
"/Users/test/project/src/main.ts",
|
||||
);
|
||||
// Partial directory name match should not be treated as inside cwd
|
||||
expect(toDisplayPath("/Users/test/project-other/file.ts", "/Users/test/project")).toBe(
|
||||
"/Users/test/project-other/file.ts",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolUpdateFromEditToolResponse", () => {
|
||||
it("should return empty for non-object input", () => {
|
||||
expect(toolUpdateFromEditToolResponse(null)).toEqual({});
|
||||
@@ -1139,6 +1171,11 @@ describe("prompt conversion", () => {
|
||||
});
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
||||
it("finds vendored cli path", async () => {
|
||||
const path = await claudeCliPath();
|
||||
expect(path).toContain("@anthropic-ai/claude-agent-sdk/cli.js");
|
||||
});
|
||||
|
||||
it("query has a 'default' model", async () => {
|
||||
const q = query({ prompt: "hi" });
|
||||
const models = await q.supportedModels();
|
||||
@@ -1228,3 +1265,343 @@ 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 injectSession(agent: ClaudeAcpAgent, messages: any[]) {
|
||||
const input = new Pushable<any>();
|
||||
async function* messageGenerator() {
|
||||
// Wait for the prompt to push its user message so we can replay it
|
||||
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",
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
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 }),
|
||||
{ type: "system", subtype: "session_state_changed", state: "idle" },
|
||||
]);
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
});
|
||||
|
||||
it("should consume background task results and return the prompt's own result", async () => {
|
||||
const agent = createMockAgent();
|
||||
const input = new Pushable<any>();
|
||||
|
||||
const backgroundTaskResult = createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: null,
|
||||
is_error: false,
|
||||
});
|
||||
// Background task used some tokens
|
||||
backgroundTaskResult.usage.input_tokens = 100;
|
||||
backgroundTaskResult.usage.output_tokens = 50;
|
||||
|
||||
const promptResult = createResultMessage({
|
||||
subtype: "success",
|
||||
stop_reason: null,
|
||||
is_error: false,
|
||||
});
|
||||
|
||||
async function* messageGenerator() {
|
||||
// Background task init + result arrive before our prompt's replay
|
||||
yield { type: "system", subtype: "init", session_id: "test-session" };
|
||||
yield backgroundTaskResult;
|
||||
|
||||
// Now the prompt's user message replay arrives
|
||||
const iter = input[Symbol.asyncIterator]();
|
||||
const { value: userMessage } = await iter.next();
|
||||
yield {
|
||||
type: "user",
|
||||
message: userMessage.message,
|
||||
parent_tool_use_id: null,
|
||||
uuid: userMessage.uuid,
|
||||
session_id: "test-session",
|
||||
isReplay: true,
|
||||
};
|
||||
|
||||
// Then the prompt's own result
|
||||
yield promptResult;
|
||||
yield { type: "system", subtype: "session_state_changed", state: "idle" };
|
||||
}
|
||||
|
||||
agent.sessions["test-session"] = {
|
||||
query: messageGenerator() as any,
|
||||
input,
|
||||
cwd: "/tmp/test",
|
||||
cancelled: false,
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
settingsManager: { dispose: vi.fn() } as any,
|
||||
accumulatedUsage: {
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
cachedReadTokens: 0,
|
||||
cachedWriteTokens: 0,
|
||||
},
|
||||
abortController: new AbortController(),
|
||||
configOptions: [],
|
||||
promptRunning: false,
|
||||
pendingMessages: new Map(),
|
||||
nextPendingOrder: 0,
|
||||
};
|
||||
|
||||
const response = await agent.prompt({
|
||||
sessionId: "test-session",
|
||||
prompt: [{ type: "text", text: "test" }],
|
||||
});
|
||||
|
||||
expect(response.stopReason).toBe("end_turn");
|
||||
// Usage should include both background task and prompt result tokens
|
||||
expect(response.usage?.inputTokens).toBe(
|
||||
backgroundTaskResult.usage.input_tokens + promptResult.usage.input_tokens,
|
||||
);
|
||||
expect(response.usage?.outputTokens).toBe(
|
||||
backgroundTaskResult.usage.output_tokens + promptResult.usage.output_tokens,
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("session/close", () => {
|
||||
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() });
|
||||
agent.sessions[sessionId] = {
|
||||
query: gen as any,
|
||||
input: new Pushable(),
|
||||
cancelled: false,
|
||||
cwd: "/test",
|
||||
modes: {
|
||||
currentModeId: "default",
|
||||
availableModes: [],
|
||||
},
|
||||
models: {
|
||||
currentModelId: "default",
|
||||
availableModels: [],
|
||||
},
|
||||
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(),
|
||||
};
|
||||
return agent.sessions[sessionId]!;
|
||||
}
|
||||
|
||||
it("should close an existing session and remove it", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "session-1");
|
||||
|
||||
expect(agent.sessions["session-1"]).toBeDefined();
|
||||
|
||||
const result = await agent.unstable_closeSession({ sessionId: "session-1" });
|
||||
|
||||
expect(result).toEqual({});
|
||||
expect(agent.sessions["session-1"]).toBeUndefined();
|
||||
expect(session.query.interrupt).toHaveBeenCalled();
|
||||
expect(session.settingsManager.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should abort the session's abort controller", async () => {
|
||||
const agent = createMockAgent();
|
||||
const session = injectSession(agent, "session-2");
|
||||
|
||||
expect(session.abortController.signal.aborted).toBe(false);
|
||||
|
||||
await agent.unstable_closeSession({ sessionId: "session-2" });
|
||||
|
||||
expect(session.abortController.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("should throw when closing a non-existent session", async () => {
|
||||
const agent = createMockAgent();
|
||||
|
||||
await expect(agent.unstable_closeSession({ sessionId: "non-existent" })).rejects.toThrow(
|
||||
"Session not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not affect other sessions when closing one", async () => {
|
||||
const agent = createMockAgent();
|
||||
injectSession(agent, "session-a");
|
||||
injectSession(agent, "session-b");
|
||||
|
||||
await agent.unstable_closeSession({ sessionId: "session-a" });
|
||||
|
||||
expect(agent.sessions["session-a"]).toBeUndefined();
|
||||
expect(agent.sessions["session-b"]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
71
src/tests/additional-roots.test.ts
Normal file
71
src/tests/additional-roots.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import * as path from "node:path";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => ({
|
||||
...(await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
)),
|
||||
query: ({ options }: { options: Options }) => {
|
||||
capturedOptions = options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" }],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
},
|
||||
}));
|
||||
vi.mock("../tools.js", async () => ({
|
||||
...(await vi.importActual<typeof import("../tools.js")>("../tools.js")),
|
||||
registerHookCallback: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("additionalRoots", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
const tempDirs: string[] = [];
|
||||
const newSession = (meta: Record<string, unknown>, cwd = "/test") =>
|
||||
agent.newSession({ cwd, mcpServers: [], _meta: meta });
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedOptions = undefined;
|
||||
tempDirs.length = 0;
|
||||
vi.resetModules();
|
||||
const { ClaudeAcpAgent } = await import("../acp-agent.js");
|
||||
agent = new ClaudeAcpAgent({
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection);
|
||||
});
|
||||
|
||||
afterEach(
|
||||
async () =>
|
||||
void (await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true })))),
|
||||
);
|
||||
|
||||
it("passes through relative roots as provided", async () => {
|
||||
const projectRoot = await mkdtemp(path.join(os.tmpdir(), "claude-project-"));
|
||||
tempDirs.push(projectRoot);
|
||||
await newSession({ additionalRoots: ["."] }, projectRoot);
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["."]);
|
||||
});
|
||||
|
||||
it("merges additionalRoots with user additionalDirectories without normalization", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "claude-root-"));
|
||||
tempDirs.push(root);
|
||||
await newSession({
|
||||
additionalRoots: ["", root],
|
||||
claudeCode: { options: { additionalDirectories: ["/workspace/shared"] } },
|
||||
});
|
||||
expect(capturedOptions!.additionalDirectories).toEqual(["/workspace/shared", "", root]);
|
||||
});
|
||||
});
|
||||
158
src/tests/authorization.test.ts
Normal file
158
src/tests/authorization.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
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: { terminal: true, _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: expect.objectContaining({
|
||||
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("hide terminal auth even when terminal-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).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("show claude authentication", async () => {
|
||||
const [agent] = await createAgentMock();
|
||||
|
||||
const initializeResponse = await agent.initialize({
|
||||
protocolVersion: 1,
|
||||
clientCapabilities: { auth: { terminal: true } },
|
||||
});
|
||||
|
||||
expect(initializeResponse.authMethods).toContainEqual(
|
||||
expect.objectContaining({ id: "claude-login" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
287
src/tests/create-session-options.test.ts
Normal file
287
src/tests/create-session-options.test.ts
Normal file
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||
import { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { Options } from "@anthropic-ai/claude-agent-sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
|
||||
let capturedOptions: Options | undefined;
|
||||
vi.mock("@anthropic-ai/claude-agent-sdk", async () => {
|
||||
const actual = await vi.importActual<typeof import("@anthropic-ai/claude-agent-sdk")>(
|
||||
"@anthropic-ai/claude-agent-sdk",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
query: (args: { prompt: unknown; options: Options }) => {
|
||||
capturedOptions = args.options;
|
||||
return {
|
||||
initializationResult: async () => ({
|
||||
models: [
|
||||
{ value: "claude-sonnet-4-5", displayName: "Claude Sonnet", description: "Fast" },
|
||||
],
|
||||
}),
|
||||
setModel: async () => {},
|
||||
supportedCommands: async () => [],
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
};
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../tools.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
|
||||
return {
|
||||
...actual,
|
||||
registerHookCallback: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe("createSession options merging", () => {
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
||||
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async (_notification: SessionNotification) => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
capturedOptions = undefined;
|
||||
|
||||
vi.resetModules();
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
});
|
||||
|
||||
it("merges user-provided disallowedTools with ACP internal list", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
disallowedTools: ["WebSearch", "WebFetch"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User-provided tools should be present
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebSearch");
|
||||
expect(capturedOptions!.disallowedTools).toContain("WebFetch");
|
||||
// ACP's internal disallowed tool should also be present
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("works when user provides no disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("works when user provides empty disallowedTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
disallowedTools: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("sets tools to empty array when disableBuiltInTools is true", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
claudeCode: {
|
||||
options: {
|
||||
disallowedTools: ["CustomTool"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// disableBuiltInTools removes all built-in tools from context
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
// User-provided and ACP disallowedTools still apply
|
||||
expect(capturedOptions!.disallowedTools).toContain("CustomTool");
|
||||
expect(capturedOptions!.disallowedTools).toContain("AskUserQuestion");
|
||||
});
|
||||
|
||||
it("merges user-provided hooks with ACP hooks", async () => {
|
||||
const userPreToolUseHook = { hooks: [{ command: "echo pre" }] };
|
||||
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
hooks: {
|
||||
PreToolUse: [userPreToolUseHook],
|
||||
PostToolUse: [{ hooks: [{ command: "echo user-post" }] }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User's PreToolUse hooks should be preserved
|
||||
expect(capturedOptions!.hooks?.PreToolUse).toEqual([userPreToolUseHook]);
|
||||
// PostToolUse should contain both user and ACP hooks
|
||||
expect(capturedOptions!.hooks?.PostToolUse).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("inherits HOME and PATH from process.env when no env is provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
|
||||
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
|
||||
});
|
||||
|
||||
it("merges user-provided env vars on top of process.env", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
CUSTOM_VAR: "custom-value",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe(process.env.HOME);
|
||||
expect(capturedOptions?.env?.PATH).toBe(process.env.PATH);
|
||||
expect(capturedOptions?.env?.CUSTOM_VAR).toBe("custom-value");
|
||||
});
|
||||
|
||||
it("allows user-provided env vars to override process.env entries", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
env: {
|
||||
HOME: "/custom/home",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions?.env?.HOME).toBe("/custom/home");
|
||||
});
|
||||
|
||||
it("defaults tools to claude_code preset when not provided", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual({ type: "preset", preset: "claude_code" });
|
||||
});
|
||||
|
||||
it("passes through user-provided tools string array", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: ["Read", "Glob"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
|
||||
});
|
||||
|
||||
it("explicit tools array takes precedence over disableBuiltInTools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
disableBuiltInTools: true,
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: ["Read", "Glob"],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual(["Read", "Glob"]);
|
||||
});
|
||||
|
||||
it("passes through empty tools array to disable all built-in tools", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
tools: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedOptions!.tools).toEqual([]);
|
||||
});
|
||||
|
||||
it("merges user-provided mcpServers with ACP mcpServers", async () => {
|
||||
await agent.newSession({
|
||||
cwd: "/test",
|
||||
mcpServers: [
|
||||
{
|
||||
name: "acp-server",
|
||||
command: "node",
|
||||
args: ["acp-server.js"],
|
||||
env: [],
|
||||
},
|
||||
],
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
options: {
|
||||
mcpServers: {
|
||||
"user-server": {
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
args: ["server.js"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// User-provided MCP server should be present
|
||||
expect(capturedOptions!.mcpServers).toHaveProperty("user-server");
|
||||
// ACP-provided MCP server should also be present
|
||||
expect(capturedOptions!.mcpServers).toHaveProperty("acp-server");
|
||||
});
|
||||
});
|
||||
@@ -1,596 +0,0 @@
|
||||
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 { AgentSideConnection } from "@agentclientprotocol/sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { encodeProjectPath } from "../utils.js";
|
||||
|
||||
describe("unstable_listSessions", () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
||||
|
||||
// Helper to create a mock AgentSideConnection
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async () => {},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
// Helper to write a session file
|
||||
function writeSessionFile(
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
options: {
|
||||
userMessage?: string;
|
||||
userMessageArray?: boolean;
|
||||
mtime?: Date;
|
||||
malformed?: boolean;
|
||||
isAgentFile?: boolean;
|
||||
omitCwdFromEntries?: boolean;
|
||||
} = {},
|
||||
): void {
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const filename = options.isAgentFile ? `agent-${sessionId}.jsonl` : `${sessionId}.jsonl`;
|
||||
const filePath = path.join(projectDir, filename);
|
||||
|
||||
if (options.malformed) {
|
||||
fs.writeFileSync(filePath, "not valid json\n{also bad");
|
||||
return;
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
|
||||
// First line with sessionId
|
||||
lines.push(JSON.stringify({ sessionId, type: "init" }));
|
||||
|
||||
// User message if provided
|
||||
if (options.userMessage) {
|
||||
const content = options.userMessageArray
|
||||
? [{ type: "text", text: options.userMessage }]
|
||||
: options.userMessage;
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId,
|
||||
...(options.omitCwdFromEntries ? {} : { cwd }),
|
||||
message: { content },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant response
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
sessionId,
|
||||
...(options.omitCwdFromEntries ? {} : { cwd }),
|
||||
message: { content: "Hello!" },
|
||||
}),
|
||||
);
|
||||
|
||||
fs.writeFileSync(filePath, lines.join("\n"));
|
||||
|
||||
// Set modification time if specified
|
||||
if (options.mtime) {
|
||||
fs.utimesSync(filePath, options.mtime, options.mtime);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create temp directory to isolate tests from real filesystem.
|
||||
// We set process.env.CLAUDE_CONFIG_DIR before importing the module so that
|
||||
// CLAUDE_CONFIG_DIR is evaluated with our temp directory.
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-test-"));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
||||
|
||||
// Reset modules to pick up the new CLAUDE_CONFIG_DIR env var
|
||||
vi.resetModules();
|
||||
|
||||
// Dynamic import after setting env var
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
// Create agent instance
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env and cleanup
|
||||
if (originalClaudeConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("returns empty array when projects directory does not exist", async () => {
|
||||
const result = await agent.unstable_listSessions({});
|
||||
expect(result.sessions).toEqual([]);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns empty array when projects directory is empty", async () => {
|
||||
fs.mkdirSync(path.join(tempDir, "projects"), { recursive: true });
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
expect(result.sessions).toEqual([]);
|
||||
});
|
||||
|
||||
it("parses session files and returns correct metadata", async () => {
|
||||
const cwd = "/Users/test/myproject";
|
||||
const sessionId = "sess-123";
|
||||
const mtime = new Date("2025-01-15T10:30:00Z");
|
||||
|
||||
writeSessionFile(cwd, sessionId, {
|
||||
userMessage: "Hello, can you help me?",
|
||||
mtime,
|
||||
});
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]).toMatchObject({
|
||||
sessionId: "sess-123",
|
||||
cwd: "/Users/test/myproject",
|
||||
title: "Hello, can you help me?",
|
||||
});
|
||||
expect(result.sessions[0]!.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("extracts title from array-style user message content", async () => {
|
||||
writeSessionFile("/Users/test/project", "sess-456", {
|
||||
userMessage: "Fix the bug in auth",
|
||||
userMessageArray: true,
|
||||
});
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions[0]!.title).toBe("Fix the bug in auth");
|
||||
});
|
||||
|
||||
it("truncates long titles to 128 characters with ellipsis", async () => {
|
||||
const longMessage = "A".repeat(150);
|
||||
writeSessionFile("/Users/test/project", "sess-789", {
|
||||
userMessage: longMessage,
|
||||
});
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions[0]!.title).toBe("A".repeat(127) + "…");
|
||||
});
|
||||
|
||||
it("replaces newlines with spaces in titles", async () => {
|
||||
const messageWithNewlines = "First line\nSecond line\r\nThird line";
|
||||
writeSessionFile("/Users/test/project", "sess-newlines", {
|
||||
userMessage: messageWithNewlines,
|
||||
});
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions[0]!.title).toBe("First line Second line Third line");
|
||||
});
|
||||
|
||||
it("collapses multiple whitespace characters in titles", async () => {
|
||||
const messageWithWhitespace = "Hello world \n\n test";
|
||||
writeSessionFile("/Users/test/project", "sess-whitespace", {
|
||||
userMessage: messageWithWhitespace,
|
||||
});
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions[0]!.title).toBe("Hello world test");
|
||||
});
|
||||
|
||||
it("filters sessions by cwd parameter", async () => {
|
||||
writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "Project A" });
|
||||
writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "Project B" });
|
||||
|
||||
const result = await agent.unstable_listSessions({ cwd: "/Users/test/projectone" });
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("sess-a");
|
||||
});
|
||||
|
||||
it("uses per-entry cwd when filtering even if encoded folder collides", async () => {
|
||||
const encodedPath = encodeProjectPath("/Users/test/a-b");
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, "sess-a-b.jsonl"),
|
||||
[
|
||||
JSON.stringify({ sessionId: "sess-a-b", type: "init" }),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "sess-a-b",
|
||||
cwd: "/Users/test/a-b",
|
||||
message: { content: "A-B" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(projectDir, "sess-a-slash-b.jsonl"),
|
||||
[
|
||||
JSON.stringify({ sessionId: "sess-a-slash-b", type: "init" }),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "sess-a-slash-b",
|
||||
cwd: "/Users/test/a/b",
|
||||
message: { content: "A/B" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await agent.unstable_listSessions({ cwd: "/Users/test/a-b" });
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("sess-a-b");
|
||||
expect(result.sessions[0]!.cwd).toBe("/Users/test/a-b");
|
||||
});
|
||||
|
||||
it("returns the true cwd for paths containing hyphens", async () => {
|
||||
const cwd = "/Users/test/claude-agent-acp";
|
||||
writeSessionFile(cwd, "hyphen-session", { userMessage: "Hyphen test" });
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.cwd).toBe(cwd);
|
||||
expect(result.sessions[0]!.sessionId).toBe("hyphen-session");
|
||||
});
|
||||
|
||||
it("filters hyphenated paths by cwd correctly", async () => {
|
||||
writeSessionFile("/Users/test/claude-agent-acp", "hyphen-a", { userMessage: "A" });
|
||||
writeSessionFile("/Users/test/other-project", "hyphen-b", { userMessage: "B" });
|
||||
|
||||
const result = await agent.unstable_listSessions({ cwd: "/Users/test/claude-agent-acp" });
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("hyphen-a");
|
||||
expect(result.sessions[0]!.cwd).toBe("/Users/test/claude-agent-acp");
|
||||
});
|
||||
|
||||
it("returns all sessions when cwd is not specified", async () => {
|
||||
writeSessionFile("/Users/test/projectone", "sess-a", { userMessage: "A" });
|
||||
writeSessionFile("/Users/test/projecttwo", "sess-b", { userMessage: "B" });
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sorts sessions by updatedAt descending (most recent first)", async () => {
|
||||
writeSessionFile("/Users/test/project", "old-session", {
|
||||
userMessage: "Old",
|
||||
mtime: new Date("2025-01-01T00:00:00Z"),
|
||||
});
|
||||
writeSessionFile("/Users/test/project", "new-session", {
|
||||
userMessage: "New",
|
||||
mtime: new Date("2025-01-15T00:00:00Z"),
|
||||
});
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions[0]!.sessionId).toBe("new-session");
|
||||
expect(result.sessions[1]!.sessionId).toBe("old-session");
|
||||
});
|
||||
|
||||
it("skips files starting with agent-", async () => {
|
||||
writeSessionFile("/Users/test/project", "regular-session", { userMessage: "Regular" });
|
||||
writeSessionFile("/Users/test/project", "hidden-session", {
|
||||
userMessage: "Hidden",
|
||||
isAgentFile: true,
|
||||
});
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("regular-session");
|
||||
});
|
||||
|
||||
it("skips malformed session files gracefully", async () => {
|
||||
writeSessionFile("/Users/test/project", "good-session", { userMessage: "Good" });
|
||||
writeSessionFile("/Users/test/project", "bad-session", { malformed: true });
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("good-session");
|
||||
});
|
||||
|
||||
it("uses filename as sessionId when not in file content", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
// Write file without sessionId in content (uses filename fallback), but with cwd.
|
||||
const filePath = path.join(projectDir, "fallback-id.jsonl");
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({ type: "init" }),
|
||||
JSON.stringify({ type: "assistant", cwd, message: { content: "Hi" } }),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions[0]!.sessionId).toBe("fallback-id");
|
||||
});
|
||||
|
||||
it("keeps sessionId stable from filename when later entries have different sessionId", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(projectDir, "stable-id.jsonl");
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({ type: "init", sessionId: "stable-id" }),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "stable-id",
|
||||
cwd,
|
||||
message: { content: "Hello" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
sessionId: "other-id",
|
||||
cwd,
|
||||
message: { content: "Wrong session id from sidechain" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("stable-id");
|
||||
});
|
||||
|
||||
it("derives cwd from entries for the filename sessionId only", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(projectDir, "target-session.jsonl");
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "target-session",
|
||||
cwd,
|
||||
message: { content: "Correct cwd entry" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("target-session");
|
||||
expect(result.sessions[0]!.cwd).toBe(cwd);
|
||||
});
|
||||
|
||||
it("keeps scanning after title to find later cwd in same session", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(projectDir, "late-cwd.jsonl");
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({ type: "init", sessionId: "late-cwd" }),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "late-cwd",
|
||||
message: { content: "Title from first user message" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
sessionId: "late-cwd",
|
||||
cwd,
|
||||
message: { content: "Assistant entry carries cwd" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("late-cwd");
|
||||
expect(result.sessions[0]!.cwd).toBe(cwd);
|
||||
expect(result.sessions[0]!.title).toBe("Title from first user message");
|
||||
});
|
||||
|
||||
it("ignores sidechain entries when deriving cwd and title", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(projectDir, "sidechain-metadata.jsonl");
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({ type: "init", sessionId: "sidechain-metadata" }),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "sidechain-metadata",
|
||||
isSidechain: true,
|
||||
cwd: "/Users/test/wrong-project",
|
||||
message: { content: "Wrong sidechain title" },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId: "sidechain-metadata",
|
||||
cwd,
|
||||
message: { content: "Correct primary title" },
|
||||
}),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await agent.unstable_listSessions({ cwd });
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("sidechain-metadata");
|
||||
expect(result.sessions[0]!.cwd).toBe(cwd);
|
||||
expect(result.sessions[0]!.title).toBe("Correct primary title");
|
||||
});
|
||||
|
||||
it("skips entries without cwd when cwd filter is not provided", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(projectDir, "no-cwd.jsonl");
|
||||
fs.writeFileSync(filePath, JSON.stringify({ sessionId: "no-cwd", type: "summary" }) + "\n");
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("returns null title when no user message exists", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
// Write file with only init and assistant message
|
||||
const filePath = path.join(projectDir, "no-user.jsonl");
|
||||
fs.writeFileSync(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({ sessionId: "no-user", type: "init" }),
|
||||
JSON.stringify({ type: "assistant", cwd, message: { content: "Hi" } }),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions[0]!.title).toBeNull();
|
||||
});
|
||||
|
||||
describe("pagination", () => {
|
||||
it("returns nextCursor when there are more results", async () => {
|
||||
// Create 60 sessions (more than PAGE_SIZE of 50)
|
||||
for (let i = 0; i < 60; i++) {
|
||||
writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, {
|
||||
userMessage: `Session ${i}`,
|
||||
mtime: new Date(Date.now() - i * 1000), // Each older by 1 second
|
||||
});
|
||||
}
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(50);
|
||||
expect(result.nextCursor).toBeDefined();
|
||||
});
|
||||
|
||||
it("does not return nextCursor when all results fit in one page", async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
writeSessionFile("/Users/test/project", `sess-${i}`, { userMessage: `Session ${i}` });
|
||||
}
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(10);
|
||||
expect(result.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns next page when cursor is provided", async () => {
|
||||
// Create 60 sessions
|
||||
for (let i = 0; i < 60; i++) {
|
||||
writeSessionFile("/Users/test/project", `sess-${i.toString().padStart(3, "0")}`, {
|
||||
userMessage: `Session ${i}`,
|
||||
mtime: new Date(Date.now() - i * 1000),
|
||||
});
|
||||
}
|
||||
|
||||
// Get first page
|
||||
const firstPage = await agent.unstable_listSessions({});
|
||||
expect(firstPage.sessions).toHaveLength(50);
|
||||
expect(firstPage.nextCursor).toBeDefined();
|
||||
|
||||
// Get second page
|
||||
const secondPage = await agent.unstable_listSessions({ cursor: firstPage.nextCursor });
|
||||
expect(secondPage.sessions).toHaveLength(10);
|
||||
expect(secondPage.nextCursor).toBeUndefined();
|
||||
});
|
||||
|
||||
it("handles invalid cursor gracefully (starts from beginning)", async () => {
|
||||
writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" });
|
||||
|
||||
const result = await agent.unstable_listSessions({ cursor: "invalid-base64!" });
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles malformed cursor JSON gracefully", async () => {
|
||||
writeSessionFile("/Users/test/project", "sess-1", { userMessage: "Test" });
|
||||
|
||||
// Valid base64 but not valid JSON
|
||||
const badCursor = Buffer.from("not json").toString("base64");
|
||||
const result = await agent.unstable_listSessions({ cursor: badCursor });
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Windows path support", () => {
|
||||
it("decodes Windows-style paths correctly", async () => {
|
||||
// Simulate a Windows path encoded as "C-Users-test-project"
|
||||
const windowsCwd = "C:\\Users\\test\\project";
|
||||
writeSessionFile(windowsCwd, "win-session", { userMessage: "Windows test" });
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.cwd).toBe("C:\\Users\\test\\project");
|
||||
expect(result.sessions[0]!.sessionId).toBe("win-session");
|
||||
});
|
||||
|
||||
it("filters Windows paths by cwd correctly", async () => {
|
||||
writeSessionFile("C:\\Users\\test\\projectone", "win-a", { userMessage: "A" });
|
||||
writeSessionFile("C:\\Users\\test\\projecttwo", "win-b", { userMessage: "B" });
|
||||
|
||||
const result = await agent.unstable_listSessions({ cwd: "C:\\Users\\test\\projectone" });
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("win-a");
|
||||
});
|
||||
|
||||
it("handles mixed Unix and Windows paths", async () => {
|
||||
writeSessionFile("/Users/test/unixproject", "unix-session", { userMessage: "Unix" });
|
||||
writeSessionFile("C:\\Users\\test\\winproject", "win-session", { userMessage: "Windows" });
|
||||
|
||||
const result = await agent.unstable_listSessions({});
|
||||
|
||||
expect(result.sessions).toHaveLength(2);
|
||||
const cwds = result.sessions.map((s) => s.cwd);
|
||||
expect(cwds).toContain("/Users/test/unixproject");
|
||||
expect(cwds).toContain("C:\\Users\\test\\winproject");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,320 +0,0 @@
|
||||
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 { AgentSideConnection, SessionNotification } from "@agentclientprotocol/sdk";
|
||||
import type { ClaudeAcpAgent as ClaudeAcpAgentType } from "../acp-agent.js";
|
||||
import { encodeProjectPath } from "../utils.js";
|
||||
|
||||
const { registerHookCallbackSpy } = vi.hoisted(() => ({
|
||||
registerHookCallbackSpy: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../tools.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../tools.js")>("../tools.js");
|
||||
return {
|
||||
...actual,
|
||||
registerHookCallback: registerHookCallbackSpy,
|
||||
};
|
||||
});
|
||||
|
||||
describe("loadSession", () => {
|
||||
let tempDir: string;
|
||||
let originalClaudeConfigDir: string | undefined;
|
||||
let agent: ClaudeAcpAgentType;
|
||||
let ClaudeAcpAgent: typeof ClaudeAcpAgentType;
|
||||
let sessionUpdates: SessionNotification[];
|
||||
let createSessionSpy: ReturnType<typeof vi.fn>;
|
||||
|
||||
// Helper to create a mock AgentSideConnection
|
||||
function createMockClient(): AgentSideConnection {
|
||||
return {
|
||||
sessionUpdate: async (notification: SessionNotification) => {
|
||||
sessionUpdates.push(notification);
|
||||
},
|
||||
requestPermission: async () => ({ outcome: { outcome: "cancelled" } }),
|
||||
readTextFile: async () => ({ content: "" }),
|
||||
writeTextFile: async () => ({}),
|
||||
} as unknown as AgentSideConnection;
|
||||
}
|
||||
|
||||
function writeSessionFile(
|
||||
cwd: string,
|
||||
sessionId: string,
|
||||
entries: Array<Record<string, unknown> | string>,
|
||||
): void {
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
const filePath = path.join(projectDir, `${sessionId}.jsonl`);
|
||||
const lines = entries.map((entry) =>
|
||||
typeof entry === "string" ? entry : JSON.stringify(entry),
|
||||
);
|
||||
fs.writeFileSync(filePath, lines.join("\n"));
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "claude-load-session-"));
|
||||
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir;
|
||||
sessionUpdates = [];
|
||||
registerHookCallbackSpy.mockClear();
|
||||
|
||||
vi.resetModules();
|
||||
|
||||
const acpAgent = await import("../acp-agent.js");
|
||||
ClaudeAcpAgent = acpAgent.ClaudeAcpAgent;
|
||||
|
||||
agent = new ClaudeAcpAgent(createMockClient());
|
||||
createSessionSpy = vi.fn(async () => ({ modes: null, models: null }));
|
||||
(agent as unknown as { createSession: typeof createSessionSpy }).createSession =
|
||||
createSessionSpy;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalClaudeConfigDir !== undefined) {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
|
||||
} else {
|
||||
delete process.env.CLAUDE_CONFIG_DIR;
|
||||
}
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("throws when the session file does not exist", async () => {
|
||||
await expect(
|
||||
agent.loadSession({
|
||||
cwd: "/Users/test/project",
|
||||
sessionId: "missing-session",
|
||||
mcpServers: [],
|
||||
}),
|
||||
).rejects.toThrow("Session not found");
|
||||
|
||||
expect(createSessionSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("replays history and returns modes/models from createSession", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const sessionId = "session-123";
|
||||
|
||||
writeSessionFile(cwd, sessionId, [
|
||||
{ type: "summary", summary: "ignored" },
|
||||
{
|
||||
type: "user",
|
||||
sessionId,
|
||||
message: { role: "user", content: "Hello" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
sessionId,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "Thinking" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
sessionId,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hi" }],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
sessionId,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool-1",
|
||||
name: "Read",
|
||||
input: { file_path: "/tmp/example.txt" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
sessionId,
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: "tool-1", content: "ok" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await agent.loadSession({ cwd, sessionId, mcpServers: [] });
|
||||
|
||||
expect(result).toEqual({ modes: null, models: null });
|
||||
expect(createSessionSpy).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd, mcpServers: [] }),
|
||||
{ resume: sessionId },
|
||||
);
|
||||
expect(registerHookCallbackSpy).not.toHaveBeenCalled();
|
||||
|
||||
expect(sessionUpdates.map((notification) => notification.update.sessionUpdate)).toEqual([
|
||||
"user_message_chunk",
|
||||
"agent_thought_chunk",
|
||||
"agent_message_chunk",
|
||||
"tool_call",
|
||||
"tool_call_update",
|
||||
]);
|
||||
|
||||
expect(sessionUpdates[0]?.update).toMatchObject({
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "Hello" },
|
||||
});
|
||||
expect(sessionUpdates[1]?.update).toMatchObject({
|
||||
sessionUpdate: "agent_thought_chunk",
|
||||
content: { type: "text", text: "Thinking" },
|
||||
});
|
||||
expect(sessionUpdates[2]?.update).toMatchObject({
|
||||
sessionUpdate: "agent_message_chunk",
|
||||
content: { type: "text", text: "Hi" },
|
||||
});
|
||||
expect(sessionUpdates[3]?.update).toMatchObject({
|
||||
sessionUpdate: "tool_call",
|
||||
toolCallId: "tool-1",
|
||||
status: "pending",
|
||||
});
|
||||
expect(sessionUpdates[4]?.update).toMatchObject({
|
||||
sessionUpdate: "tool_call_update",
|
||||
toolCallId: "tool-1",
|
||||
status: "completed",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips sidechain, mismatched sessionId, invalid, and unsupported entries", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const sessionId = "session-456";
|
||||
|
||||
writeSessionFile(cwd, sessionId, [
|
||||
"{not valid json}",
|
||||
{ type: "summary", summary: "ignore" },
|
||||
{
|
||||
type: "user",
|
||||
sessionId,
|
||||
isSidechain: true,
|
||||
message: { role: "user", content: "Sidechain" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
sessionId: "other-session",
|
||||
message: { role: "assistant", content: "Wrong session" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
sessionId,
|
||||
message: { role: "system", content: "Unsupported role" },
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
sessionId,
|
||||
message: { role: "user", content: 123 },
|
||||
},
|
||||
{
|
||||
type: "user",
|
||||
sessionId,
|
||||
message: { role: "user", content: "Keep me" },
|
||||
},
|
||||
]);
|
||||
|
||||
await agent.loadSession({ cwd, sessionId, mcpServers: [] });
|
||||
|
||||
expect(sessionUpdates).toHaveLength(1);
|
||||
expect(sessionUpdates[0]?.update).toMatchObject({
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "Keep me" },
|
||||
});
|
||||
});
|
||||
|
||||
it("finds session file when cwd differs from the original session cwd", async () => {
|
||||
const originalCwd = "/Users/test/original-project";
|
||||
const differentCwd = "/Users/test/different-project";
|
||||
const sessionId = "cross-cwd-session";
|
||||
|
||||
// Session was created in originalCwd
|
||||
writeSessionFile(originalCwd, sessionId, [
|
||||
{
|
||||
type: "user",
|
||||
sessionId,
|
||||
cwd: originalCwd,
|
||||
message: { role: "user", content: "Hello from original" },
|
||||
},
|
||||
]);
|
||||
|
||||
// Load with a different cwd
|
||||
await agent.loadSession({ cwd: differentCwd, sessionId, mcpServers: [] });
|
||||
|
||||
expect(sessionUpdates).toHaveLength(1);
|
||||
expect(sessionUpdates[0]?.update).toMatchObject({
|
||||
sessionUpdate: "user_message_chunk",
|
||||
content: { type: "text", text: "Hello from original" },
|
||||
});
|
||||
expect(createSessionSpy).toHaveBeenCalledWith(expect.objectContaining({ cwd: differentCwd }), {
|
||||
resume: sessionId,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends available_commands_update after history replay, not during", async () => {
|
||||
const cwd = "/Users/test/project";
|
||||
const sessionId = "session-commands";
|
||||
|
||||
// Mock createSession to populate the sessions map with a mock query
|
||||
createSessionSpy.mockImplementationOnce(async () => {
|
||||
(agent as unknown as { sessions: Record<string, unknown> }).sessions[sessionId] = {
|
||||
query: {
|
||||
supportedCommands: async () => [{ name: "help", description: "Get help" }],
|
||||
},
|
||||
};
|
||||
return { modes: null, models: null };
|
||||
});
|
||||
|
||||
writeSessionFile(cwd, sessionId, [
|
||||
{
|
||||
type: "user",
|
||||
sessionId,
|
||||
message: { role: "user", content: "Hello" },
|
||||
},
|
||||
{
|
||||
type: "assistant",
|
||||
sessionId,
|
||||
message: { role: "assistant", content: [{ type: "text", text: "Hi" }] },
|
||||
},
|
||||
]);
|
||||
|
||||
await agent.loadSession({ cwd, sessionId, mcpServers: [] });
|
||||
|
||||
// Flush the setTimeout + async work inside sendAvailableCommandsUpdate
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const updateTypes = sessionUpdates.map((n) => n.update.sessionUpdate);
|
||||
|
||||
// available_commands_update should come AFTER all replay notifications
|
||||
expect(updateTypes).toEqual([
|
||||
"user_message_chunk",
|
||||
"agent_message_chunk",
|
||||
"available_commands_update",
|
||||
]);
|
||||
});
|
||||
|
||||
it("handles Windows-style paths", async () => {
|
||||
const cwd = "C:\\Users\\test\\project";
|
||||
const sessionId = "win-session";
|
||||
|
||||
writeSessionFile(cwd, sessionId, [
|
||||
{
|
||||
type: "user",
|
||||
sessionId,
|
||||
message: { role: "user", content: "Hello" },
|
||||
},
|
||||
]);
|
||||
|
||||
await agent.loadSession({ cwd, sessionId, mcpServers: [] });
|
||||
|
||||
expect(sessionUpdates).toHaveLength(1);
|
||||
expect(sessionUpdates[0]?.sessionId).toBe(sessionId);
|
||||
});
|
||||
});
|
||||
43
src/tests/resolve-permission-mode.test.ts
Normal file
43
src/tests/resolve-permission-mode.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -224,6 +224,61 @@ describe("session config options", () => {
|
||||
expect(configUpdate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves model alias 'opus' to full model ID", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "opus",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-opus-4-5");
|
||||
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-opus-4-5");
|
||||
});
|
||||
|
||||
it("resolves model alias 'sonnet' to full model ID", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("resolves display name to model ID", async () => {
|
||||
await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "Claude Sonnet",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("still works with exact model ID", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "claude-sonnet-4-5",
|
||||
});
|
||||
|
||||
expect(setModelSpy).toHaveBeenCalledWith("claude-sonnet-4-5");
|
||||
const modelOption = response.configOptions.find((o) => o.id === "model");
|
||||
expect(modelOption?.currentValue).toBe("claude-sonnet-4-5");
|
||||
});
|
||||
|
||||
it("throws for completely invalid model value", async () => {
|
||||
await expect(
|
||||
agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
configId: "model",
|
||||
value: "gpt-4",
|
||||
}),
|
||||
).rejects.toThrow("Invalid value for config option model: gpt-4");
|
||||
});
|
||||
|
||||
it("returns full configOptions in the response", async () => {
|
||||
const response = await agent.setSessionConfigOption({
|
||||
sessionId: SESSION_ID,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import { toAcpNotifications, ToolUseCache, Logger } from "../acp-agent.js";
|
||||
import { toolUpdateFromToolResult, createPostToolUseHook } from "../tools.js";
|
||||
import { toolUpdateFromToolResult, createPostToolUseHook, toolInfoFromToolUse } from "../tools.js";
|
||||
|
||||
describe("rawOutput in tool call updates", () => {
|
||||
const mockClient = {} as AgentSideConnection;
|
||||
@@ -1235,3 +1235,39 @@ describe("Bash terminal output", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("toolInfoFromToolUse - ExitPlanMode", () => {
|
||||
it("should include plan text in content when input.plan is provided", () => {
|
||||
const toolUse = {
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_1",
|
||||
input: {
|
||||
plan: "# My Plan\n\n## Step 1\nDo something",
|
||||
planFilePath: "/tmp/plan.md",
|
||||
},
|
||||
};
|
||||
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
|
||||
expect(info.kind).toBe("switch_mode");
|
||||
expect(info.title).toBe("Ready to code?");
|
||||
expect(info.content).toHaveLength(1);
|
||||
expect(info.content![0]).toEqual({
|
||||
type: "content",
|
||||
content: { type: "text", text: "# My Plan\n\n## Step 1\nDo something" },
|
||||
});
|
||||
});
|
||||
|
||||
it("should return empty content when input.plan is not provided", () => {
|
||||
const toolUse = {
|
||||
name: "ExitPlanMode",
|
||||
id: "toolu_plan_2",
|
||||
input: {},
|
||||
};
|
||||
|
||||
const info = toolInfoFromToolUse(toolUse, false);
|
||||
|
||||
expect(info.kind).toBe("switch_mode");
|
||||
expect(info.content).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
77
src/tools.ts
77
src/tools.ts
@@ -5,6 +5,19 @@ import {
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import {
|
||||
AgentInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
import {
|
||||
ImageBlockParam,
|
||||
TextBlockParam,
|
||||
@@ -14,29 +27,31 @@ import {
|
||||
WebSearchToolResultError,
|
||||
} from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaBashCodeExecutionToolResultError,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionResultBlock,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultError,
|
||||
BetaImageBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionViewResultBlock,
|
||||
BetaTextEditorCodeExecutionCreateResultBlock,
|
||||
BetaTextEditorCodeExecutionStrReplaceResultBlock,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultError,
|
||||
BetaTextEditorCodeExecutionViewResultBlock,
|
||||
BetaToolReferenceBlock,
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaToolReferenceBlock,
|
||||
BetaToolSearchToolSearchResultBlock,
|
||||
BetaToolSearchToolResultError,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaToolSearchToolSearchResultBlock,
|
||||
BetaWebFetchBlock,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebFetchToolResultErrorBlock,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
BetaImageBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
import path from "node:path";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
|
||||
/**
|
||||
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
|
||||
@@ -61,20 +76,6 @@ type ToolResultContent =
|
||||
| BetaTextEditorCodeExecutionCreateResultBlock
|
||||
| BetaTextEditorCodeExecutionStrReplaceResultBlock
|
||||
| BetaTextEditorCodeExecutionToolResultError;
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import {
|
||||
AgentInput,
|
||||
BashInput,
|
||||
FileEditInput,
|
||||
FileReadInput,
|
||||
FileWriteInput,
|
||||
GlobInput,
|
||||
GrepInput,
|
||||
TodoWriteInput,
|
||||
WebFetchInput,
|
||||
WebSearchInput,
|
||||
} from "@anthropic-ai/claude-agent-sdk/sdk-tools.js";
|
||||
|
||||
interface ToolInfo {
|
||||
title: string;
|
||||
@@ -103,13 +104,29 @@ interface ToolUpdate {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an absolute file path to a project-relative path for display.
|
||||
* Returns the original path if it's outside the project directory or if no cwd is provided.
|
||||
*/
|
||||
export function toDisplayPath(filePath: string, cwd?: string): string {
|
||||
if (!cwd) return filePath;
|
||||
const resolvedCwd = path.resolve(cwd);
|
||||
const resolvedFile = path.resolve(filePath);
|
||||
if (resolvedFile.startsWith(resolvedCwd + path.sep) || resolvedFile === resolvedCwd) {
|
||||
return path.relative(resolvedCwd, resolvedFile);
|
||||
}
|
||||
return filePath;
|
||||
}
|
||||
|
||||
export function toolInfoFromToolUse(
|
||||
toolUse: any,
|
||||
supportsTerminalOutput: boolean = false,
|
||||
cwd?: string,
|
||||
): ToolInfo {
|
||||
const name = toolUse.name;
|
||||
|
||||
switch (name) {
|
||||
case "Agent":
|
||||
case "Task": {
|
||||
const input = toolUse.input as AgentInput | BashInput;
|
||||
return {
|
||||
@@ -153,8 +170,9 @@ export function toolInfoFromToolUse(
|
||||
} else if (input.offset) {
|
||||
limit = " (from line " + input.offset + ")";
|
||||
}
|
||||
const displayPath = input.file_path ? toDisplayPath(input.file_path, cwd) : "File";
|
||||
return {
|
||||
title: "Read " + (input.file_path ?? "File") + limit,
|
||||
title: "Read " + displayPath + limit,
|
||||
kind: "read",
|
||||
locations: input.file_path
|
||||
? [
|
||||
@@ -188,8 +206,9 @@ export function toolInfoFromToolUse(
|
||||
},
|
||||
];
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: input?.file_path ? `Write ${input.file_path}` : "Write",
|
||||
title: displayPath ? `Write ${displayPath}` : "Write",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
@@ -209,8 +228,9 @@ export function toolInfoFromToolUse(
|
||||
},
|
||||
];
|
||||
}
|
||||
const displayPath = input?.file_path ? toDisplayPath(input.file_path, cwd) : undefined;
|
||||
return {
|
||||
title: input?.file_path ? `Edit ${input.file_path}` : "Edit",
|
||||
title: displayPath ? `Edit ${displayPath}` : "Edit",
|
||||
kind: "edit",
|
||||
content,
|
||||
locations: input?.file_path ? [{ path: input.file_path }] : [],
|
||||
@@ -348,10 +368,13 @@ export function toolInfoFromToolUse(
|
||||
}
|
||||
|
||||
case "ExitPlanMode": {
|
||||
const planInput = toolUse.input as { plan?: string };
|
||||
return {
|
||||
title: "Ready to code?",
|
||||
kind: "switch_mode",
|
||||
content: [],
|
||||
content: planInput?.plan
|
||||
? [{ type: "content" as const, content: { type: "text" as const, text: planInput.plan } }]
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -461,7 +484,7 @@ export function toolUpdateFromToolResult(
|
||||
result.type === "bash_code_execution_result"
|
||||
) {
|
||||
const bashResult = result as BetaBashCodeExecutionResultBlock;
|
||||
output = bashResult.stdout || bashResult.stderr || "";
|
||||
output = [bashResult.stdout, bashResult.stderr].filter(Boolean).join("\n");
|
||||
exitCode = bashResult.return_code;
|
||||
} else if (typeof result === "string") {
|
||||
output = result;
|
||||
|
||||
@@ -105,10 +105,3 @@ export function applyEnvironmentSettings(settings: ClaudeCodeSettings): void {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to encode a path like Claude does:
|
||||
// - Unix: "/Users/test" -> "-Users-test"
|
||||
// - Windows: "C:\Users\test" -> "C--Users-test"
|
||||
export function encodeProjectPath(cwd: string): string {
|
||||
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user