mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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/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/*
|
||||
|
||||
15
CHANGELOG.md
15
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
746
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -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",
|
||||
|
||||
499
src/acp-agent.ts
499
src/acp-agent.ts
@@ -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
7
src/static-entry.ts
Normal 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");
|
||||
}
|
||||
185
src/tests/create-session-options.test.ts
Normal file
185
src/tests/create-session-options.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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