Compare commits

...

9 Commits

Author SHA1 Message Date
Ben Brandt
b1d09f2366 Fix history support (#356) 2026-02-27 12:06:47 +01:00
Ben Brandt
d24066bd9d Support windows arm builds and clean up artifact names (#355) 2026-02-27 10:46:23 +00:00
Ben Brandt
5ca63abc47 Update to claude-agent-sdk 0.2.62 (#354) 2026-02-27 10:14:30 +00:00
Steffen Deusch
001614b459 expose parent_tool_use_id for subagents (#341)
I'm not sure yet how a good protocol for subagents could look like, so
instead of opening an RFD, I opted to expose Claude's
`parent_tool_use_id` in `_meta.claudeCode.parentToolUseId`. This will
allow Tidewave to render tools from subagents in a better way.
2026-02-26 22:45:12 +01:00
Steffen Deusch
218661f97b ACP Session Usage (#345)
The AI in https://github.com/zed-industries/claude-agent-acp/pull/344
had one good idea: only use the usage from the last assistant message
for context size. Of course, `usage` on the AssistantMessage is not
documented in the Agent SDK (we need to do an ugly cast), but this seems
to work alright.

We don't get the exact new usage after compaction, so I set it to zero.
2026-02-26 22:44:20 +01:00
Ben Brandt
5ede2b7c4c Use history methods from the SDK (#352) 2026-02-26 21:37:15 +00:00
Ben Brandt
988a741d59 Update to claude-agent-sdk 0.2.59 (#351) 2026-02-26 22:29:45 +01:00
Ben Limmer
b57a429867 fix: merge user-provided disallowedTools instead of overwriting (#335)
The disallowedTools merge fix from #295 was regressed by #316 when the
options object was restructured. Restore the merge behavior and add
regression tests for all three documented merge fields (disallowedTools,
hooks, mcpServers).

Fixes #334
2026-02-20 18:55:16 +01:00
Steffen Deusch
101f09dbf8 Add single-file executable using bun (#332)
This adds a single-file executable using bun. The trick is to use a
separate flag for ACP `--acp` and spawn the regular `cli.js` bundled by
the Agent SDK if no flags are passed. This was, we can pass ourselves as
pathToClaudeCodeExecutable.
2026-02-20 12:23:04 +01:00
11 changed files with 825 additions and 1661 deletions

View File

@@ -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/static-entry.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/*

View File

@@ -1,5 +1,20 @@
# Changelog
## 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.

View File

@@ -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

746
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,7 @@
"publishConfig": {
"access": "public"
},
"version": "0.18.0",
"version": "0.19.2",
"description": "An ACP-compatible coding agent powered by the Claude Agent SDK (TypeScript)",
"main": "dist/lib.js",
"types": "dist/lib.d.ts",
@@ -61,15 +61,15 @@
"license": "Apache-2.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.14.1",
"@anthropic-ai/claude-agent-sdk": "0.2.45"
"@anthropic-ai/claude-agent-sdk": "0.2.62"
},
"devDependencies": {
"@anthropic-ai/sdk": "0.76.0",
"@anthropic-ai/sdk": "0.78.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.3.2",
"@typescript-eslint/eslint-plugin": "8.56.1",
"@typescript-eslint/parser": "8.56.1",
"eslint": "10.0.2",
"eslint-config-prettier": "10.1.8",
"globals": "17.3.0",
"prettier": "3.8.1",

View File

@@ -24,7 +24,6 @@ import {
ResumeSessionRequest,
ResumeSessionResponse,
SessionConfigOption,
SessionInfo,
SessionModelState,
SessionNotification,
SetSessionConfigOptionRequest,
@@ -42,6 +41,8 @@ import {
import { SettingsManager } from "./settings.js";
import {
CanUseTool,
getSessionMessages,
listSessions,
McpServerConfig,
ModelInfo,
Options,
@@ -60,6 +61,7 @@ import {
SDKStatusMessage,
SDKSystemMessage,
SDKTaskNotificationMessage,
SDKTaskProgressMessage,
SDKTaskStartedMessage,
SDKToolProgressMessage,
SDKToolUseSummaryMessage,
@@ -69,15 +71,8 @@ import {
} from "@anthropic-ai/claude-agent-sdk";
import * as fs from "node:fs";
import * as path from "node:path";
import * as readline from "node:readline";
import * as os from "node:os";
import {
encodeProjectPath,
nodeToWebReadable,
nodeToWebWritable,
Pushable,
unreachable,
} from "./utils.js";
import { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
import {
toolInfoFromToolUse,
planEntries,
@@ -110,17 +105,14 @@ type SDKMessageTemp =
| SDKAuthStatusMessage
| SDKTaskNotificationMessage
| SDKTaskStartedMessage
| SDKTaskProgressMessage
| SDKFilesPersistedEvent
| SDKToolUseSummaryMessage;
export const CLAUDE_CONFIG_DIR =
process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
function sessionFilePath(cwd: string, sessionId: string): string {
return path.join(CLAUDE_CONFIG_DIR, "projects", encodeProjectPath(cwd), `${sessionId}.jsonl`);
}
const MAX_TITLE_LENGTH = 128;
const MAX_TITLE_LENGTH = 256;
function sanitizeTitle(text: string): string {
// Replace newlines and collapse whitespace
@@ -142,26 +134,23 @@ export interface Logger {
error: (...args: any[]) => void;
}
type AccumulatedUsage = {
inputTokens: number;
outputTokens: number;
cachedReadTokens: number;
cachedWriteTokens: number;
};
type Session = {
query: Query;
input: Pushable<SDKUserMessage>;
cancelled: boolean;
permissionMode: PermissionMode;
settingsManager: SettingsManager;
accumulatedUsage: AccumulatedUsage;
configOptions: SessionConfigOption[];
};
type SessionHistoryEntry = {
type?: string;
isSidechain?: boolean;
sessionId?: string;
message?: {
role?: string;
content?: unknown;
model?: string;
};
};
type BackgroundTerminal =
| {
handle: TerminalHandle;
@@ -230,6 +219,10 @@ export type ToolUseCache = {
};
};
function isStaticBinary(): boolean {
return process.env.CLAUDE_AGENT_ACP_IS_SINGLE_FILE_BUN !== undefined;
}
// Bypass Permissions doesn't work if we are a root/sudo user
const IS_ROOT = (process.geteuid?.() ?? process.getuid?.()) === 0;
const ALLOW_BYPASS = !IS_ROOT || !!process.env.IS_SANDBOX;
@@ -264,12 +257,22 @@ export class ClaudeAcpAgent implements Agent {
// If client supports terminal-auth capability, use that instead.
if (request.clientCapabilities?._meta?.["terminal-auth"] === true) {
const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
let command: string;
let args: string[];
if (isStaticBinary()) {
command = process.execPath;
args = ["--cli"];
} else {
const cliPath = fileURLToPath(import.meta.resolve("@anthropic-ai/claude-agent-sdk/cli.js"));
command = "node";
args = [cliPath];
}
authMethod._meta = {
"terminal-auth": {
command: "node",
args: [cliPath],
command,
args,
label: "Claude Login",
},
};
@@ -358,52 +361,7 @@ export class ClaudeAcpAgent implements Agent {
return response;
}
/**
* Find a session file by ID, first checking the given cwd's project directory,
* then falling back to scanning all project directories.
* Returns the absolute file path if found, or null if not found.
*/
private async findSessionFile(sessionId: string, cwd: string): Promise<string | null> {
const fileName = `${sessionId}.jsonl`;
// Fast path: check the expected location based on cwd
const expectedPath = sessionFilePath(cwd, sessionId);
try {
await fs.promises.access(expectedPath);
return expectedPath;
} catch {
// Not found at expected path, scan all project directories
}
const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects");
try {
const projectDirs = await fs.promises.readdir(claudeDir);
for (const encodedPath of projectDirs) {
const projectDir = path.join(claudeDir, encodedPath);
const stat = await fs.promises.stat(projectDir);
if (!stat.isDirectory()) continue;
const candidatePath = path.join(projectDir, fileName);
try {
await fs.promises.access(candidatePath);
return candidatePath;
} catch {
continue;
}
}
} catch {
// projects directory doesn't exist or isn't readable
}
return null;
}
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
const filePath = await this.findSessionFile(params.sessionId, params.cwd);
if (!filePath) {
throw new Error("Session not found");
}
const response = await this.createSession(
{
cwd: params.cwd,
@@ -415,7 +373,7 @@ export class ClaudeAcpAgent implements Agent {
},
);
await this.replaySessionHistory(params.sessionId, filePath);
await this.replaySessionHistory(params.sessionId);
// Send available commands after replay so it doesn't interleave with history
setTimeout(() => {
@@ -429,167 +387,22 @@ export class ClaudeAcpAgent implements Agent {
};
}
/**
* List Claude Code sessions by parsing JSONL files
* Sessions are stored in ~/.claude/projects/<path-encoded>/
* Implements the draft session/list RFD spec
*/
async unstable_listSessions(params: ListSessionsRequest): Promise<ListSessionsResponse> {
// Note: We load all sessions into memory for sorting, so pagination here is for
// API response size limits rather than memory efficiency. This matches the RFD spec.
const PAGE_SIZE = 50;
const claudeDir = path.join(CLAUDE_CONFIG_DIR, "projects");
const sdk_sessions = await listSessions({ dir: params.cwd ?? undefined });
const sessions = [];
try {
await fs.promises.access(claudeDir);
} catch {
return { sessions: [] };
for (const session of sdk_sessions) {
if (!session.cwd) continue;
sessions.push({
sessionId: session.sessionId,
cwd: session.cwd,
title: sanitizeTitle(session.summary),
updatedAt: new Date(session.lastModified).toISOString(),
});
}
// Collect all sessions across all project directories
const allSessions: SessionInfo[] = [];
const encodedCwdFilter = params.cwd ? encodeProjectPath(params.cwd) : null;
try {
const projectDirs = await fs.promises.readdir(claudeDir);
for (const encodedPath of projectDirs) {
const projectDir = path.join(claudeDir, encodedPath);
const stat = await fs.promises.stat(projectDir);
if (!stat.isDirectory()) continue;
// Path encoding is not always reversible (hyphens can be separators or literals),
// so only use encoded value as a coarse pre-filter.
if (encodedCwdFilter && encodedPath !== encodedCwdFilter) continue;
const files = await fs.promises.readdir(projectDir);
// Filter to user session files only. Skip agent-*.jsonl files which contain
// internal agent metadata and system logs, not user-visible conversation sessions.
const jsonlFiles = files.filter((f) => f.endsWith(".jsonl") && !f.startsWith("agent-"));
for (const file of jsonlFiles) {
const filePath = path.join(projectDir, file);
try {
const content = await fs.promises.readFile(filePath, "utf-8");
const lines = content.trim().split("\n").filter(Boolean);
const sessionId = file.replace(".jsonl", "");
let parsedAnyEntry = false;
let sessionCwd: string | undefined;
// Find first user message for title
let title: string | undefined;
for (const line of lines) {
try {
const entry = JSON.parse(line);
parsedAnyEntry = true;
if (entry.isSidechain === true) {
continue;
}
const entrySessionId =
typeof entry.sessionId === "string" ? entry.sessionId : undefined;
if (typeof entry.sessionId === "string" && entry.sessionId !== entrySessionId) {
continue;
}
if (typeof entry.cwd === "string") {
sessionCwd = entry.cwd;
}
if (!title && entry.type === "user" && entry.message?.content) {
const msgContent = entry.message.content;
if (typeof msgContent === "string") {
title = sanitizeTitle(msgContent);
}
if (Array.isArray(msgContent) && msgContent.length > 0) {
const first = msgContent[0];
const text =
typeof first === "string"
? first
: first && typeof first === "object" && typeof first.text === "string"
? first.text
: undefined;
if (text) {
title = sanitizeTitle(text);
}
}
}
// Continue scanning until we have both fields, since cwd can appear
// in later entries even after the first user title-bearing message.
if (title && sessionCwd) {
break;
}
} catch {
// Skip malformed lines
}
}
if (!parsedAnyEntry) continue;
// SessionInfo.cwd is currently required. For entries that do not
// include an explicit cwd in the session JSONL (typically metadata-only files),
// we skip them instead of decoding folder names because path encoding is lossy.
if (!sessionCwd) continue;
// Even after encoded-path pre-filtering, verify per-entry cwd to disambiguate
// collisions such as "/a-b" and "/a/b" that map to the same encoded folder name.
if (params.cwd && sessionCwd !== params.cwd) continue;
// Get file modification time as updatedAt
const fileStat = await fs.promises.stat(filePath);
const updatedAt = fileStat.mtime.toISOString();
allSessions.push({
sessionId,
cwd: sessionCwd,
title: title ?? null,
updatedAt,
});
} catch (err) {
this.logger.error(
`[unstable_listSessions] Failed to parse session file: ${filePath}`,
err,
);
}
}
}
} catch (err) {
this.logger.error("[unstable_listSessions] Failed to list sessions", err);
return { sessions: [] };
}
// Sort by updatedAt descending (most recent first)
allSessions.sort((a, b) => {
const timeA = a.updatedAt ? new Date(a.updatedAt).getTime() : 0;
const timeB = b.updatedAt ? new Date(b.updatedAt).getTime() : 0;
return timeB - timeA;
});
// Handle pagination with cursor
let startIndex = 0;
if (params.cursor) {
try {
const decoded = Buffer.from(params.cursor, "base64").toString("utf-8");
const cursorData = JSON.parse(decoded);
startIndex = cursorData.offset ?? 0;
} catch {
// Invalid cursor, start from beginning
}
}
const pageOfSessions = allSessions.slice(startIndex, startIndex + PAGE_SIZE);
const hasMore = startIndex + PAGE_SIZE < allSessions.length;
const response: ListSessionsResponse = {
sessions: pageOfSessions,
return {
sessions,
};
if (hasMore) {
const nextCursor = Buffer.from(JSON.stringify({ offset: startIndex + PAGE_SIZE })).toString(
"base64",
);
response.nextCursor = nextCursor;
}
return response;
}
async authenticate(_params: AuthenticateRequest): Promise<void> {
@@ -602,6 +415,14 @@ export class ClaudeAcpAgent implements Agent {
}
this.sessions[params.sessionId].cancelled = false;
this.sessions[params.sessionId].accumulatedUsage = {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
};
let lastAssistantTotalUsage: number | null = null;
const { query, input } = this.sessions[params.sessionId];
@@ -618,6 +439,11 @@ export class ClaudeAcpAgent implements Agent {
switch (message.type) {
case "system":
if (message.subtype === "compact_boundary") {
// We don't know the exact size, but since we compacted,
// we set it to zero. The client gets the exact size on the next message.
lastAssistantTotalUsage = 0;
}
switch (message.subtype) {
case "init":
break;
@@ -629,6 +455,7 @@ export class ClaudeAcpAgent implements Agent {
case "status":
case "files_persisted":
case "task_started":
case "task_progress":
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
break;
default:
@@ -641,6 +468,47 @@ export class ClaudeAcpAgent implements Agent {
return { stopReason: "cancelled" };
}
// Accumulate usage from this result
const session = this.sessions[params.sessionId];
session.accumulatedUsage.inputTokens += message.usage.input_tokens;
session.accumulatedUsage.outputTokens += message.usage.output_tokens;
session.accumulatedUsage.cachedReadTokens += message.usage.cache_read_input_tokens;
session.accumulatedUsage.cachedWriteTokens += message.usage.cache_creation_input_tokens;
// Calculate context window size from modelUsage (minimum across all models used)
const contextWindows = Object.values(message.modelUsage).map((m) => m.contextWindow);
const contextWindowSize =
contextWindows.length > 0 ? Math.min(...contextWindows) : 200000;
// Send usage_update notification
if (lastAssistantTotalUsage !== null) {
await this.client.sessionUpdate({
sessionId: params.sessionId,
update: {
sessionUpdate: "usage_update",
used: lastAssistantTotalUsage,
size: contextWindowSize,
cost: {
amount: message.total_cost_usd,
currency: "USD",
},
},
});
}
// Build the usage response
const usage: PromptResponse["usage"] = {
inputTokens: session.accumulatedUsage.inputTokens,
outputTokens: session.accumulatedUsage.outputTokens,
cachedReadTokens: session.accumulatedUsage.cachedReadTokens,
cachedWriteTokens: session.accumulatedUsage.cachedWriteTokens,
totalTokens:
session.accumulatedUsage.inputTokens +
session.accumulatedUsage.outputTokens +
session.accumulatedUsage.cachedReadTokens +
session.accumulatedUsage.cachedWriteTokens,
};
switch (message.subtype) {
case "success": {
if (message.result.includes("Please run /login")) {
@@ -649,7 +517,7 @@ export class ClaudeAcpAgent implements Agent {
if (message.is_error) {
throw RequestError.internalError(undefined, message.result);
}
return { stopReason: "end_turn" };
return { stopReason: "end_turn", usage };
}
case "error_during_execution":
if (message.is_error) {
@@ -658,7 +526,7 @@ export class ClaudeAcpAgent implements Agent {
message.errors.join(", ") || message.subtype,
);
}
return { stopReason: "end_turn" };
return { stopReason: "end_turn", usage };
case "error_max_budget_usd":
case "error_max_turns":
case "error_max_structured_output_retries":
@@ -668,7 +536,7 @@ export class ClaudeAcpAgent implements Agent {
message.errors.join(", ") || message.subtype,
);
}
return { stopReason: "max_turn_requests" };
return { stopReason: "max_turn_requests", usage };
default:
unreachable(message, this.logger);
break;
@@ -694,6 +562,16 @@ export class ClaudeAcpAgent implements Agent {
break;
}
// Store latest assistant usage (excluding subagents)
if ((message.message as any).usage && message.parent_tool_use_id === null) {
const messageWithUsage = message.message as unknown as SDKResultMessage;
lastAssistantTotalUsage =
messageWithUsage.usage.input_tokens +
messageWithUsage.usage.output_tokens +
messageWithUsage.usage.cache_read_input_tokens +
messageWithUsage.usage.cache_creation_input_tokens;
}
// Slash commands like /compact can generate invalid output... doesn't match
// their own docs: https://docs.anthropic.com/en/docs/claude-code/sdk/sdk-slash-commands#%2Fcompact-compact-conversation-history
if (
@@ -762,7 +640,10 @@ export class ClaudeAcpAgent implements Agent {
this.toolUseCache,
this.client,
this.logger,
{ clientCapabilities: this.clientCapabilities },
{
clientCapabilities: this.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
},
)) {
await this.client.sessionUpdate(notification);
}
@@ -878,67 +759,24 @@ export class ClaudeAcpAgent implements Agent {
}
}
private async replaySessionHistory(sessionId: string, filePath: string): Promise<void> {
private async replaySessionHistory(sessionId: string): Promise<void> {
const toolUseCache: ToolUseCache = {};
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
const messages = await getSessionMessages(sessionId);
try {
for await (const line of reader) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
let entry: SessionHistoryEntry;
try {
entry = JSON.parse(trimmed) as SessionHistoryEntry;
} catch {
continue;
}
if (entry.type !== "user" && entry.type !== "assistant") {
continue;
}
if (entry.isSidechain) {
continue;
}
if (entry.sessionId && entry.sessionId !== sessionId) {
continue;
}
const message = entry.message;
if (!message) {
continue;
}
const role =
message.role === "assistant" ? "assistant" : message.role === "user" ? "user" : null;
if (!role) {
continue;
}
const content = message.content;
if (typeof content !== "string" && !Array.isArray(content)) {
continue;
}
for (const notification of toAcpNotifications(
content,
role,
sessionId,
toolUseCache,
this.client,
this.logger,
{ registerHooks: false, clientCapabilities: this.clientCapabilities },
)) {
await this.client.sessionUpdate(notification);
}
for (const message of messages) {
for (const notification of toAcpNotifications(
// @ts-expect-error - untyped in SDK but we handle all of these
message.message.content,
// @ts-expect-error - untyped in SDK but we handle all of these
message.message.role,
sessionId,
toolUseCache,
this.client,
this.logger,
{ registerHooks: false, clientCapabilities: this.clientCapabilities },
)) {
await this.client.sessionUpdate(notification);
}
} finally {
reader.close();
}
}
@@ -1216,7 +1054,6 @@ export class ClaudeAcpAgent implements Agent {
const options: Options = {
systemPrompt,
settingSources: ["user", "project", "local"],
stderr: (err) => this.logger.error(err),
...(maxThinkingTokens !== undefined && { maxThinkingTokens }),
...userProvidedOptions,
// Override certain fields that must be controlled by ACP
@@ -1231,10 +1068,13 @@ export class ClaudeAcpAgent implements Agent {
// note: although not documented by the types, passing an absolute path
// here works to find zed's managed node version.
executable: process.execPath as any,
...(process.env.CLAUDE_CODE_EXECUTABLE && {
pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE,
}),
disallowedTools,
executableArgs: isStaticBinary() ? ["--cli"] : undefined,
...(process.env.CLAUDE_CODE_EXECUTABLE
? { pathToClaudeCodeExecutable: process.env.CLAUDE_CODE_EXECUTABLE }
: isStaticBinary()
? { pathToClaudeCodeExecutable: process.execPath }
: {}),
disallowedTools: [...(userProvidedOptions?.disallowedTools || []), ...disallowedTools],
tools: { type: "preset", preset: "claude_code" },
hooks: {
...userProvidedOptions?.hooks,
@@ -1287,6 +1127,12 @@ export class ClaudeAcpAgent implements Agent {
cancelled: false,
permissionMode,
settingsManager,
accumulatedUsage: {
inputTokens: 0,
outputTokens: 0,
cachedReadTokens: 0,
cachedWriteTokens: 0,
},
configOptions: [],
};
@@ -1551,23 +1397,34 @@ export function toAcpNotifications(
toolUseCache: ToolUseCache,
client: AgentSideConnection,
logger: Logger,
options?: { registerHooks?: boolean; clientCapabilities?: ClientCapabilities },
options?: {
registerHooks?: boolean;
clientCapabilities?: ClientCapabilities;
parentToolUseId?: string | null;
},
): SessionNotification[] {
const registerHooks = options?.registerHooks !== false;
const supportsTerminalOutput = options?.clientCapabilities?._meta?.["terminal_output"] === true;
if (typeof content === "string") {
return [
{
sessionId,
update: {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: content,
},
},
const update: SessionNotification["update"] = {
sessionUpdate: role === "assistant" ? "agent_message_chunk" : "user_message_chunk",
content: {
type: "text",
text: content,
},
];
};
if (options?.parentToolUseId) {
update._meta = {
...update._meta,
claudeCode: {
...(update._meta?.claudeCode || {}),
parentToolUseId: options.parentToolUseId,
},
};
}
return [{ sessionId, update }];
}
const output = [];
@@ -1731,6 +1588,9 @@ export function toAcpNotifications(
update: {
_meta: {
terminal_output: toolMeta.terminal_output,
...(options?.parentToolUseId
? { claudeCode: { parentToolUseId: options.parentToolUseId } }
: {}),
},
toolCallId: chunk.tool_use_id,
sessionUpdate: "tool_call_update" as const,
@@ -1771,6 +1631,15 @@ export function toAcpNotifications(
break;
}
if (update) {
if (options?.parentToolUseId) {
update._meta = {
...update._meta,
claudeCode: {
...(update._meta?.claudeCode || {}),
parentToolUseId: options.parentToolUseId,
},
};
}
output.push({ sessionId, update });
}
}
@@ -1796,7 +1665,10 @@ export function streamEventToAcpNotifications(
toolUseCache,
client,
logger,
{ clientCapabilities: options?.clientCapabilities },
{
clientCapabilities: options?.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
},
);
case "content_block_delta":
return toAcpNotifications(
@@ -1806,7 +1678,10 @@ export function streamEventToAcpNotifications(
toolUseCache,
client,
logger,
{ clientCapabilities: options?.clientCapabilities },
{
clientCapabilities: options?.clientCapabilities,
parentToolUseId: message.parent_tool_use_id,
},
);
// No content
case "message_start":

7
src/static-entry.ts Normal file
View File

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

View File

@@ -0,0 +1,185 @@
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("includes both user and built-in disallowed tools when disableBuiltInTools is true", async () => {
await agent.newSession({
cwd: "/test",
mcpServers: [],
_meta: {
disableBuiltInTools: true,
claudeCode: {
options: {
disallowedTools: ["CustomTool"],
},
},
},
});
const disallowed = capturedOptions!.disallowedTools!;
// User-provided
expect(disallowed).toContain("CustomTool");
// ACP internal
expect(disallowed).toContain("AskUserQuestion");
// Built-in tools disabled by disableBuiltInTools
expect(disallowed).toContain("Read");
expect(disallowed).toContain("Write");
expect(disallowed).toContain("Bash");
});
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("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");
});
});

View File

@@ -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");
});
});
});

View File

@@ -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);
});
});

View File

@@ -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, "-");
}