mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 12:40:32 +00:00
Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a1ac9b9d | ||
|
|
673b0507d7 | ||
|
|
ea00a4014a | ||
|
|
ebdd115b76 | ||
|
|
7c87fe7a8c | ||
|
|
8420b50523 | ||
|
|
24339faf2b | ||
|
|
8360a21358 | ||
|
|
a9985d49e9 | ||
|
|
9dfd0a0c4f | ||
|
|
fd4fd247bf | ||
|
|
67926a9ac2 | ||
|
|
4d0cb9cbdd | ||
|
|
e0cac12239 | ||
|
|
6b68ddf00e | ||
|
|
34a617e20f | ||
|
|
9016cba978 | ||
|
|
325c513f1b | ||
|
|
87fa0a23c0 | ||
|
|
c5a49e7481 | ||
|
|
c597cfae85 | ||
|
|
a31f4534dd | ||
|
|
2e9bb8e82b | ||
|
|
cfdb6463d6 | ||
|
|
adb495d5d3 | ||
|
|
11ca660dec |
2
.github/workflows/publish.yml
vendored
2
.github/workflows/publish.yml
vendored
@@ -32,3 +32,5 @@ jobs:
|
||||
- name: Create Release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@6da8fa9354ddfdc4aeace5fc48d7f679b5214090
|
||||
with:
|
||||
generate_release_notes: true
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,5 +1,33 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.1
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.38
|
||||
- Fix incorrect paths for session/list
|
||||
- Fix available commands after loading a session
|
||||
- Make loading session more permissive for finding events
|
||||
- Fix overriding user-provided disallowedTools
|
||||
|
||||
## 0.16.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.34
|
||||
- Experimental support for session loading
|
||||
|
||||
## 0.15.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.32 (adds support for Opus 4.6 and 1M context Opus)
|
||||
|
||||
## 0.14.0
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.29
|
||||
- Update to using the recommended `CLAUDE_CONFIG_DIR` env variable for setting where config files are kept
|
||||
- Support /context command
|
||||
- Fix incorrect context type mapping for tool calls
|
||||
- Fix glob metching for file permissions on Windows
|
||||
- Support the `IS_SANDBOX` env var for supporting bypass permissions in root mode
|
||||
- Fix missing notification for entering plan mode
|
||||
- Experimental unstable support for listing sessions
|
||||
|
||||
## 0.13.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.22
|
||||
|
||||
648
package-lock.json
generated
648
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
20
package.json
20
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.13.2",
|
||||
"version": "0.16.1",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -60,20 +60,20 @@
|
||||
"author": "Zed Industries",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.13.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.22",
|
||||
"@modelcontextprotocol/sdk": "1.25.3",
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.38",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"diff": "8.0.3",
|
||||
"minimatch": "10.1.1"
|
||||
"minimatch": "10.1.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.71.2",
|
||||
"@types/node": "25.0.10",
|
||||
"@typescript-eslint/eslint-plugin": "8.54.0",
|
||||
"@typescript-eslint/parser": "8.54.0",
|
||||
"@anthropic-ai/sdk": "0.74.0",
|
||||
"@types/node": "25.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.55.0",
|
||||
"@typescript-eslint/parser": "8.55.0",
|
||||
"eslint": "9.39.2",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.2.0",
|
||||
"globals": "17.3.0",
|
||||
"prettier": "3.8.1",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.9.3",
|
||||
|
||||
544
src/acp-agent.ts
544
src/acp-agent.ts
@@ -9,6 +9,10 @@ import {
|
||||
ForkSessionResponse,
|
||||
InitializeRequest,
|
||||
InitializeResponse,
|
||||
LoadSessionRequest,
|
||||
LoadSessionResponse,
|
||||
ListSessionsRequest,
|
||||
ListSessionsResponse,
|
||||
ndJsonStream,
|
||||
NewSessionRequest,
|
||||
NewSessionResponse,
|
||||
@@ -19,6 +23,7 @@ import {
|
||||
RequestError,
|
||||
ResumeSessionRequest,
|
||||
ResumeSessionResponse,
|
||||
SessionInfo,
|
||||
SessionModelState,
|
||||
SessionNotification,
|
||||
SetSessionModelRequest,
|
||||
@@ -34,17 +39,26 @@ import { SettingsManager } from "./settings.js";
|
||||
import {
|
||||
CanUseTool,
|
||||
McpServerConfig,
|
||||
ModelInfo,
|
||||
Options,
|
||||
PermissionMode,
|
||||
Query,
|
||||
query,
|
||||
SDKPartialAssistantMessage,
|
||||
SDKUserMessage,
|
||||
SlashCommand,
|
||||
} 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 { nodeToWebReadable, nodeToWebWritable, Pushable, unreachable } from "./utils.js";
|
||||
import {
|
||||
encodeProjectPath,
|
||||
nodeToWebReadable,
|
||||
nodeToWebWritable,
|
||||
Pushable,
|
||||
unreachable,
|
||||
} from "./utils.js";
|
||||
import { createMcpServer } from "./mcp-server.js";
|
||||
import { EDIT_TOOL_NAMES, acpToolNames } from "./tools.js";
|
||||
import {
|
||||
@@ -62,7 +76,26 @@ import packageJson from "../package.json" with { type: "json" };
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const CLAUDE_CONFIG_DIR = process.env.CLAUDE ?? path.join(os.homedir(), ".claude");
|
||||
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;
|
||||
|
||||
function sanitizeTitle(text: string): string {
|
||||
// Replace newlines and collapse whitespace
|
||||
const sanitized = text
|
||||
.replace(/[\r\n]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
if (sanitized.length <= MAX_TITLE_LENGTH) {
|
||||
return sanitized;
|
||||
}
|
||||
return sanitized.slice(0, MAX_TITLE_LENGTH - 1) + "…";
|
||||
}
|
||||
|
||||
/**
|
||||
* Logger interface for customizing logging output
|
||||
@@ -80,6 +113,17 @@ type Session = {
|
||||
settingsManager: SettingsManager;
|
||||
};
|
||||
|
||||
type SessionHistoryEntry = {
|
||||
type?: string;
|
||||
isSidechain?: boolean;
|
||||
sessionId?: string;
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
model?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type BackgroundTerminal =
|
||||
| {
|
||||
handle: TerminalHandle;
|
||||
@@ -108,6 +152,7 @@ export type NewSessionMeta = {
|
||||
* Those parameters will be used and updated to work with ACP:
|
||||
* - hooks (merged with ACP's hooks)
|
||||
* - mcpServers (merged with ACP's mcpServers)
|
||||
* - disallowedTools (merged with ACP's disallowedTools)
|
||||
*/
|
||||
options?: Options;
|
||||
};
|
||||
@@ -136,6 +181,7 @@ export type ToolUseCache = {
|
||||
|
||||
// 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;
|
||||
|
||||
// Implement the ACP Agent interface
|
||||
export class ClaudeAcpAgent implements Agent {
|
||||
@@ -189,8 +235,10 @@ export class ClaudeAcpAgent implements Agent {
|
||||
http: true,
|
||||
sse: true,
|
||||
},
|
||||
loadSession: true,
|
||||
sessionCapabilities: {
|
||||
fork: {},
|
||||
list: {},
|
||||
resume: {},
|
||||
},
|
||||
},
|
||||
@@ -211,14 +259,19 @@ export class ClaudeAcpAgent implements Agent {
|
||||
throw RequestError.authRequired();
|
||||
}
|
||||
|
||||
return await this.createSession(params, {
|
||||
const response = await this.createSession(params, {
|
||||
// Revisit these meta values once we support resume
|
||||
resume: (params._meta as NewSessionMeta | undefined)?.claudeCode?.options?.resume,
|
||||
});
|
||||
// Needs to happen after we return the session
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(response.sessionId);
|
||||
}, 0);
|
||||
return response;
|
||||
}
|
||||
|
||||
async unstable_forkSession(params: ForkSessionRequest): Promise<ForkSessionResponse> {
|
||||
return await this.createSession(
|
||||
const response = await this.createSession(
|
||||
{
|
||||
cwd: params.cwd,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
@@ -229,6 +282,11 @@ export class ClaudeAcpAgent implements Agent {
|
||||
forkSession: true,
|
||||
},
|
||||
);
|
||||
// Needs to happen after we return the session
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(response.sessionId);
|
||||
}, 0);
|
||||
return response;
|
||||
}
|
||||
|
||||
async unstable_resumeSession(params: ResumeSessionRequest): Promise<ResumeSessionResponse> {
|
||||
@@ -242,6 +300,242 @@ export class ClaudeAcpAgent implements Agent {
|
||||
resume: params.sessionId,
|
||||
},
|
||||
);
|
||||
// Needs to happen after we return the session
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(response.sessionId);
|
||||
}, 0);
|
||||
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,
|
||||
mcpServers: params.mcpServers ?? [],
|
||||
_meta: params._meta,
|
||||
},
|
||||
{
|
||||
resume: params.sessionId,
|
||||
},
|
||||
);
|
||||
|
||||
await this.replaySessionHistory(params.sessionId, filePath);
|
||||
|
||||
// Send available commands after replay so it doesn't interleave with history
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(params.sessionId);
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
modes: response.modes,
|
||||
models: response.models,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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");
|
||||
|
||||
try {
|
||||
await fs.promises.access(claudeDir);
|
||||
} catch {
|
||||
return { sessions: [] };
|
||||
}
|
||||
|
||||
// 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,
|
||||
};
|
||||
|
||||
if (hasMore) {
|
||||
const nextCursor = Buffer.from(JSON.stringify({ offset: startIndex + PAGE_SIZE })).toString(
|
||||
"base64",
|
||||
);
|
||||
response.nextCursor = nextCursor;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
@@ -280,6 +574,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
case "hook_progress":
|
||||
case "hook_response":
|
||||
case "status":
|
||||
case "files_persisted":
|
||||
// Todo: process via status api: https://docs.claude.com/en/docs/claude-code/hooks#hook-output
|
||||
break;
|
||||
default:
|
||||
@@ -350,6 +645,21 @@ export class ClaudeAcpAgent implements Agent {
|
||||
typeof message.message.content === "string" &&
|
||||
message.message.content.includes("<local-command-stdout>")
|
||||
) {
|
||||
// Handle /context by sending its reply as regular agent message.
|
||||
if (message.message.content.includes("Context Usage")) {
|
||||
for (const notification of toAcpNotifications(
|
||||
message.message.content
|
||||
.replace("<local-command-stdout>", "")
|
||||
.replace("</local-command-stdout>", ""),
|
||||
"assistant",
|
||||
params.sessionId,
|
||||
this.toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
}
|
||||
this.logger.log(message.message.content);
|
||||
break;
|
||||
}
|
||||
@@ -457,6 +767,70 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
private async replaySessionHistory(sessionId: string, filePath: string): Promise<void> {
|
||||
const toolUseCache: ToolUseCache = {};
|
||||
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
|
||||
const reader = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
||||
|
||||
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 },
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
async readTextFile(params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
const response = await this.client.readTextFile(params);
|
||||
return response;
|
||||
@@ -595,6 +969,19 @@ export class ClaudeAcpAgent implements Agent {
|
||||
};
|
||||
}
|
||||
|
||||
private async sendAvailableCommandsUpdate(sessionId: string): Promise<void> {
|
||||
const session = this.sessions[sessionId];
|
||||
if (!session) return;
|
||||
const commands = await session.query.supportedCommands();
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: getAvailableSlashCommands(commands),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
params: NewSessionRequest,
|
||||
creationOpts: { resume?: string; forkSession?: boolean } = {},
|
||||
@@ -669,11 +1056,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
// Extract options from _meta if provided
|
||||
const userProvidedOptions = (params._meta as NewSessionMeta | undefined)?.claudeCode?.options;
|
||||
const extraArgs = { ...userProvidedOptions?.extraArgs };
|
||||
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
||||
// Set our own session id if not resuming an existing session.
|
||||
extraArgs["session-id"] = sessionId;
|
||||
}
|
||||
|
||||
// Configure thinking tokens from environment variable
|
||||
const maxThinkingTokens = process.env.MAX_THINKING_TOKENS
|
||||
@@ -690,10 +1072,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
cwd: params.cwd,
|
||||
includePartialMessages: true,
|
||||
mcpServers: { ...(userProvidedOptions?.mcpServers || {}), ...mcpServers },
|
||||
extraArgs,
|
||||
// If we want bypassPermissions to be an option, we have to allow it here.
|
||||
// But it doesn't work in root mode, so we only activate it if it will work.
|
||||
allowDangerouslySkipPermissions: !IS_ROOT,
|
||||
allowDangerouslySkipPermissions: ALLOW_BYPASS,
|
||||
permissionMode,
|
||||
canUseTool: this.canUseTool(sessionId),
|
||||
// note: although not documented by the types, passing an absolute path
|
||||
@@ -714,13 +1095,34 @@ export class ClaudeAcpAgent implements Agent {
|
||||
PostToolUse: [
|
||||
...(userProvidedOptions?.hooks?.PostToolUse || []),
|
||||
{
|
||||
hooks: [createPostToolUseHook(this.logger)],
|
||||
hooks: [
|
||||
createPostToolUseHook(this.logger, {
|
||||
onEnterPlanMode: async () => {
|
||||
const session = this.sessions[sessionId];
|
||||
if (session) {
|
||||
session.permissionMode = "plan";
|
||||
}
|
||||
await this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "current_mode_update",
|
||||
currentModeId: "plan",
|
||||
},
|
||||
});
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
...creationOpts,
|
||||
};
|
||||
|
||||
if (creationOpts?.resume === undefined || creationOpts?.forkSession) {
|
||||
// Set our own session id if not resuming an existing session.
|
||||
options.sessionId = sessionId;
|
||||
}
|
||||
|
||||
const allowedTools = [];
|
||||
// Disable this for now, not a great way to expose this over ACP at the moment (in progress work so we can revisit)
|
||||
const disallowedTools = ["AskUserQuestion"];
|
||||
@@ -773,7 +1175,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
options.allowedTools = allowedTools;
|
||||
}
|
||||
if (disallowedTools.length > 0) {
|
||||
options.disallowedTools = disallowedTools;
|
||||
options.disallowedTools = [...(options.disallowedTools || []), ...disallowedTools];
|
||||
}
|
||||
|
||||
// Handle abort controller from meta options
|
||||
@@ -795,19 +1197,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
settingsManager,
|
||||
};
|
||||
|
||||
const availableCommands = await getAvailableSlashCommands(q);
|
||||
const models = await getAvailableModels(q);
|
||||
const initializationResult = await q.initializationResult();
|
||||
|
||||
// Needs to happen after we return the session
|
||||
setTimeout(() => {
|
||||
this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands,
|
||||
},
|
||||
});
|
||||
}, 0);
|
||||
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
|
||||
|
||||
const availableModes = [
|
||||
{
|
||||
@@ -832,7 +1224,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
];
|
||||
// Only works in non-root mode
|
||||
if (!IS_ROOT) {
|
||||
if (ALLOW_BYPASS) {
|
||||
availableModes.push({
|
||||
id: "bypassPermissions",
|
||||
name: "Bypass Permissions",
|
||||
@@ -851,36 +1243,51 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
async function getAvailableModels(query: Query): Promise<SessionModelState> {
|
||||
const models = await query.supportedModels();
|
||||
async function getAvailableModels(
|
||||
query: Query,
|
||||
models: ModelInfo[],
|
||||
settingsManager: SettingsManager,
|
||||
): Promise<SessionModelState> {
|
||||
const settings = settingsManager.getSettings();
|
||||
|
||||
let currentModel = models[0];
|
||||
|
||||
if (settings.model) {
|
||||
const match = models.find(
|
||||
(m) =>
|
||||
m.value === settings.model ||
|
||||
m.value.includes(settings.model!) ||
|
||||
settings.model!.includes(m.value) ||
|
||||
m.displayName.toLowerCase() === settings.model!.toLowerCase() ||
|
||||
m.displayName.toLowerCase().includes(settings.model!.toLowerCase()),
|
||||
);
|
||||
if (match) {
|
||||
currentModel = match;
|
||||
}
|
||||
}
|
||||
|
||||
// Query doesn't give us access to the currently selected model, so we just choose the first model in the list.
|
||||
const currentModel = models[0];
|
||||
await query.setModel(currentModel.value);
|
||||
|
||||
const availableModels = models.map((model) => ({
|
||||
modelId: model.value,
|
||||
name: model.displayName,
|
||||
description: model.description,
|
||||
}));
|
||||
|
||||
return {
|
||||
availableModels,
|
||||
availableModels: models.map((model) => ({
|
||||
modelId: model.value,
|
||||
name: model.displayName,
|
||||
description: model.description,
|
||||
})),
|
||||
currentModelId: currentModel.value,
|
||||
};
|
||||
}
|
||||
|
||||
async function getAvailableSlashCommands(query: Query): Promise<AvailableCommand[]> {
|
||||
function getAvailableSlashCommands(commands: SlashCommand[]): AvailableCommand[] {
|
||||
const UNSUPPORTED_COMMANDS = [
|
||||
"context",
|
||||
"cost",
|
||||
"keybindings-help",
|
||||
"login",
|
||||
"logout",
|
||||
"output-style:new",
|
||||
"release-notes",
|
||||
"todos",
|
||||
];
|
||||
const commands = await query.supportedCommands();
|
||||
|
||||
return commands
|
||||
.map((command) => {
|
||||
@@ -1011,7 +1418,9 @@ export function toAcpNotifications(
|
||||
toolUseCache: ToolUseCache,
|
||||
client: AgentSideConnection,
|
||||
logger: Logger,
|
||||
options?: { registerHooks?: boolean },
|
||||
): SessionNotification[] {
|
||||
const registerHooks = options?.registerHooks !== false;
|
||||
if (typeof content === "string") {
|
||||
return [
|
||||
{
|
||||
@@ -1076,32 +1485,33 @@ export function toAcpNotifications(
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Register hook callback to receive the structured output from the hook
|
||||
registerHookCallback(chunk.id, {
|
||||
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
||||
const toolUse = toolUseCache[toolUseId];
|
||||
if (toolUse) {
|
||||
const update: SessionNotification["update"] = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolResponse,
|
||||
toolName: toolUse.name,
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: "tool_call_update",
|
||||
};
|
||||
await client.sessionUpdate({
|
||||
sessionId,
|
||||
update,
|
||||
});
|
||||
} else {
|
||||
logger.error(
|
||||
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (registerHooks) {
|
||||
registerHookCallback(chunk.id, {
|
||||
onPostToolUseHook: async (toolUseId, toolInput, toolResponse) => {
|
||||
const toolUse = toolUseCache[toolUseId];
|
||||
if (toolUse) {
|
||||
const update: SessionNotification["update"] = {
|
||||
_meta: {
|
||||
claudeCode: {
|
||||
toolResponse,
|
||||
toolName: toolUse.name,
|
||||
},
|
||||
} satisfies ToolUpdateMeta,
|
||||
toolCallId: toolUseId,
|
||||
sessionUpdate: "tool_call_update",
|
||||
};
|
||||
await client.sessionUpdate({
|
||||
sessionId,
|
||||
update,
|
||||
});
|
||||
} else {
|
||||
logger.error(
|
||||
`[claude-code-acp] Got a tool response for tool use that wasn't tracked: ${toolUseId}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
let rawInput;
|
||||
try {
|
||||
@@ -1165,6 +1575,8 @@ export function toAcpNotifications(
|
||||
case "citations_delta":
|
||||
case "signature_delta":
|
||||
case "container_upload":
|
||||
case "compaction":
|
||||
case "compaction_delta":
|
||||
break;
|
||||
|
||||
default:
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface PermissionSettings {
|
||||
export interface ClaudeCodeSettings {
|
||||
permissions?: PermissionSettings;
|
||||
env?: Record<string, string>;
|
||||
model?: string;
|
||||
}
|
||||
|
||||
export type PermissionDecision = "allow" | "deny" | "ask";
|
||||
@@ -128,7 +129,8 @@ function normalizePath(filePath: string, cwd: string): string {
|
||||
} else if (!path.isAbsolute(filePath)) {
|
||||
filePath = path.join(cwd, filePath);
|
||||
}
|
||||
return path.normalize(filePath);
|
||||
// Convert backslashes to forward slashes for minimatch compatibility on Windows
|
||||
return path.normalize(filePath).replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -154,7 +156,7 @@ function matchesRule(rule: ParsedRule, toolName: string, toolInput: unknown, cwd
|
||||
// - "Claude will make a best-effort attempt to apply Read rules to all built-in tools
|
||||
// that read files like Grep, Glob, and LS."
|
||||
const ruleAppliesToTool =
|
||||
rule.toolName === "Bash" ||
|
||||
(rule.toolName === "Bash" && toolName === acpToolNames.bash) ||
|
||||
(rule.toolName === "Edit" && FILE_EDITING_TOOLS.includes(toolName)) ||
|
||||
(rule.toolName === "Read" && FILE_READING_TOOLS.includes(toolName));
|
||||
|
||||
@@ -369,6 +371,10 @@ export class SettingsManager {
|
||||
if (settings.env) {
|
||||
merged.env = { ...merged.env, ...settings.env };
|
||||
}
|
||||
|
||||
if (settings.model) {
|
||||
merged.model = settings.model;
|
||||
}
|
||||
}
|
||||
|
||||
this.mergedSettings = merged;
|
||||
|
||||
@@ -21,6 +21,13 @@ import { markdownEscape, toolInfoFromToolUse, toolUpdateFromToolResult } from ".
|
||||
import { toAcpNotifications, promptToClaude } from "../acp-agent.js";
|
||||
import { query, SDKAssistantMessage } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { randomUUID } from "crypto";
|
||||
import type {
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
|
||||
describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("ACP subprocess integration", () => {
|
||||
let child: ReturnType<typeof spawn>;
|
||||
@@ -600,6 +607,9 @@ describe("tool conversions", () => {
|
||||
output_tokens: 1,
|
||||
service_tier: "standard",
|
||||
server_tool_use: null,
|
||||
inference_geo: null,
|
||||
iterations: null,
|
||||
speed: null,
|
||||
},
|
||||
context_management: null,
|
||||
},
|
||||
@@ -734,6 +744,200 @@ describe("tool conversions", () => {
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform tool_reference content to valid ACP content", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "ToolSearch",
|
||||
input: { query: "test" },
|
||||
};
|
||||
|
||||
const toolResult: BetaToolResultBlockParam = {
|
||||
content: [
|
||||
{
|
||||
type: "tool_reference",
|
||||
tool_name: "some_discovered_tool",
|
||||
},
|
||||
],
|
||||
tool_use_id: "toolu_01MNO345",
|
||||
is_error: false,
|
||||
type: "tool_result",
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Tool: some_discovered_tool" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform web_search_result content to valid ACP content", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "WebSearch",
|
||||
input: { query: "test" },
|
||||
};
|
||||
|
||||
const toolResult: BetaWebSearchToolResultBlockParam = {
|
||||
content: [
|
||||
{
|
||||
type: "web_search_result",
|
||||
title: "Test Result",
|
||||
url: "https://example.com",
|
||||
encrypted_content: "...",
|
||||
page_age: null,
|
||||
},
|
||||
],
|
||||
tool_use_id: "toolu_01MNO345",
|
||||
type: "web_search_tool_result",
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Test Result (https://example.com)" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform web_search_tool_result_error to valid ACP content", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "WebSearch",
|
||||
input: { query: "test" },
|
||||
};
|
||||
|
||||
const toolResult: BetaWebSearchToolResultBlockParam = {
|
||||
content: {
|
||||
type: "web_search_tool_result_error",
|
||||
error_code: "unavailable",
|
||||
},
|
||||
tool_use_id: "toolu_01MNO345",
|
||||
type: "web_search_tool_result",
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Error: unavailable" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform code_execution_result content to valid ACP content", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "CodeExecution",
|
||||
input: {},
|
||||
};
|
||||
|
||||
const toolResult: BetaCodeExecutionToolResultBlockParam = {
|
||||
content: {
|
||||
type: "code_execution_result",
|
||||
stdout: "Hello World",
|
||||
stderr: "",
|
||||
return_code: 0,
|
||||
content: [],
|
||||
},
|
||||
tool_use_id: "toolu_01MNO345",
|
||||
type: "code_execution_tool_result",
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Output: Hello World" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform web_fetch_result content to valid ACP content", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "WebFetch",
|
||||
input: { url: "https://example.com" },
|
||||
};
|
||||
|
||||
const toolResult: BetaWebFetchToolResultBlockParam = {
|
||||
content: {
|
||||
type: "web_fetch_result",
|
||||
url: "https://example.com",
|
||||
content: {
|
||||
type: "document",
|
||||
citations: null,
|
||||
title: null,
|
||||
source: { type: "text", media_type: "text/plain", data: "Page content here" },
|
||||
},
|
||||
},
|
||||
tool_use_id: "toolu_01MNO345",
|
||||
type: "web_fetch_tool_result",
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Fetched: https://example.com" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should transform tool_search_tool_search_result to valid ACP content", () => {
|
||||
const toolUse = {
|
||||
type: "tool_use",
|
||||
id: "toolu_01MNO345",
|
||||
name: "ToolSearch",
|
||||
input: { query: "test" },
|
||||
};
|
||||
|
||||
const toolResult: BetaToolSearchToolResultBlockParam = {
|
||||
content: {
|
||||
type: "tool_search_tool_search_result",
|
||||
tool_references: [
|
||||
{ type: "tool_reference", tool_name: "tool_a" },
|
||||
{ type: "tool_reference", tool_name: "tool_b" },
|
||||
],
|
||||
},
|
||||
tool_use_id: "toolu_01MNO345",
|
||||
type: "tool_search_tool_result",
|
||||
};
|
||||
|
||||
const update = toolUpdateFromToolResult(toolResult, toolUse);
|
||||
|
||||
expect(update).toEqual({
|
||||
content: [
|
||||
{
|
||||
type: "content",
|
||||
content: { type: "text", text: "Tools found: tool_a, tool_b" },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("escape markdown", () => {
|
||||
@@ -800,7 +1004,7 @@ describe.skipIf(!process.env.RUN_INTEGRATION_TESTS)("SDK behavior", () => {
|
||||
prompt: "hi",
|
||||
options: {
|
||||
systemPrompt: { type: "preset", preset: "claude_code" },
|
||||
extraArgs: { "session-id": sessionId },
|
||||
sessionId,
|
||||
settingSources: ["user", "project", "local"],
|
||||
includePartialMessages: true,
|
||||
},
|
||||
|
||||
596
src/tests/list-sessions.test.ts
Normal file
596
src/tests/list-sessions.test.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
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-code-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-code-acp", "hyphen-a", { userMessage: "A" });
|
||||
writeSessionFile("/Users/test/other-project", "hyphen-b", { userMessage: "B" });
|
||||
|
||||
const result = await agent.unstable_listSessions({ cwd: "/Users/test/claude-code-acp" });
|
||||
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0]!.sessionId).toBe("hyphen-a");
|
||||
expect(result.sessions[0]!.cwd).toBe("/Users/test/claude-code-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");
|
||||
});
|
||||
});
|
||||
});
|
||||
320
src/tests/load-session.test.ts
Normal file
320
src/tests/load-session.test.ts
Normal file
@@ -0,0 +1,320 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -465,6 +465,41 @@ describe("SettingsManager", () => {
|
||||
});
|
||||
expect(envResult.decision).toBe("deny");
|
||||
});
|
||||
|
||||
it("should merge model setting with later sources taking precedence", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
|
||||
// Project settings with one model
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
model: "claude-3-5-sonnet",
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
let settings = settingsManager.getSettings();
|
||||
expect(settings.model).toBe("claude-3-5-sonnet");
|
||||
|
||||
// Add local settings that override the model
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.local.json"),
|
||||
JSON.stringify({
|
||||
model: "claude-3-5-haiku",
|
||||
}),
|
||||
);
|
||||
|
||||
// Re-initialize to pick up local settings
|
||||
settingsManager.dispose();
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
settings = settingsManager.getSettings();
|
||||
expect(settings.model).toBe("claude-3-5-haiku");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ask rules", () => {
|
||||
@@ -614,4 +649,119 @@ describe("SettingsManager", () => {
|
||||
await fs.promises.rm(tempDir2, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("cross-tool rule isolation", () => {
|
||||
// These tests verify the fix in commit d471ccf:
|
||||
// Permission rules should only match their intended tool type.
|
||||
// A Bash rule should not match Edit/Write/Read operations, and vice versa.
|
||||
|
||||
it("should not apply Bash rules to Edit tool calls", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
// This Bash rule has an argument that looks like a glob pattern.
|
||||
// Before the fix, it would incorrectly match Edit operations
|
||||
// because the argument would be interpreted as a file glob.
|
||||
allow: ["Bash(./**:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
// Bash rule should NOT allow Edit operations
|
||||
const result = settingsManager.checkPermission("mcp__acp__Edit", {
|
||||
file_path: path.join(tempDir, "any-file.txt"),
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not apply Bash rules to Write tool calls", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Bash(./**:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Write", {
|
||||
file_path: path.join(tempDir, "any-file.txt"),
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not apply Bash rules to Read tool calls", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Bash(./**:*)"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Read", {
|
||||
file_path: path.join(tempDir, "any-file.txt"),
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not apply Edit rules to Bash tool calls", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Edit"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "echo hello",
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
|
||||
it("should not apply Read rules to Bash tool calls", async () => {
|
||||
const claudeDir = path.join(tempDir, ".claude");
|
||||
await fs.promises.mkdir(claudeDir, { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(claudeDir, "settings.json"),
|
||||
JSON.stringify({
|
||||
permissions: {
|
||||
allow: ["Read"],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
settingsManager = new SettingsManager(tempDir);
|
||||
await settingsManager.initialize();
|
||||
|
||||
const result = settingsManager.checkPermission("mcp__acp__Bash", {
|
||||
command: "ls -la",
|
||||
});
|
||||
expect(result.decision).toBe("ask");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
193
src/tools.ts
193
src/tools.ts
@@ -1,7 +1,44 @@
|
||||
import { PlanEntry, ToolCallContent, ToolCallLocation, ToolKind } from "@agentclientprotocol/sdk";
|
||||
import {
|
||||
ContentBlock,
|
||||
PlanEntry,
|
||||
ToolCallContent,
|
||||
ToolCallLocation,
|
||||
ToolKind,
|
||||
} from "@agentclientprotocol/sdk";
|
||||
import { SYSTEM_REMINDER } from "./mcp-server.js";
|
||||
import * as diff from "diff";
|
||||
import { ToolResultBlockParam, WebSearchToolResultBlockParam } from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
ImageBlockParam,
|
||||
TextBlockParam,
|
||||
ToolResultBlockParam,
|
||||
WebSearchResultBlock,
|
||||
WebSearchToolResultBlockParam,
|
||||
WebSearchToolResultError,
|
||||
} from "@anthropic-ai/sdk/resources";
|
||||
import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaBashCodeExecutionResultBlock,
|
||||
BetaBashCodeExecutionToolResultError,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionResultBlock,
|
||||
BetaCodeExecutionToolResultError,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionViewResultBlock,
|
||||
BetaTextEditorCodeExecutionCreateResultBlock,
|
||||
BetaTextEditorCodeExecutionStrReplaceResultBlock,
|
||||
BetaTextEditorCodeExecutionToolResultError,
|
||||
BetaToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaToolReferenceBlock,
|
||||
BetaToolSearchToolSearchResultBlock,
|
||||
BetaToolSearchToolResultError,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebFetchBlock,
|
||||
BetaWebFetchToolResultErrorBlock,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
BetaImageBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
|
||||
const acpUnqualifiedToolNames = {
|
||||
read: "Read",
|
||||
@@ -24,15 +61,29 @@ export const acpToolNames = {
|
||||
|
||||
export const EDIT_TOOL_NAMES = [acpToolNames.edit, acpToolNames.write];
|
||||
|
||||
import {
|
||||
BetaBashCodeExecutionToolResultBlockParam,
|
||||
BetaCodeExecutionToolResultBlockParam,
|
||||
BetaRequestMCPToolResultBlockParam,
|
||||
BetaTextEditorCodeExecutionToolResultBlockParam,
|
||||
BetaToolSearchToolResultBlockParam,
|
||||
BetaWebFetchToolResultBlockParam,
|
||||
BetaWebSearchToolResultBlockParam,
|
||||
} from "@anthropic-ai/sdk/resources/beta.mjs";
|
||||
/**
|
||||
* Union of all possible content types that can appear in tool results from the Anthropic SDK.
|
||||
* These are transformed to valid ACP ContentBlock types by toValidAcpContent().
|
||||
*/
|
||||
type ToolResultContent =
|
||||
| TextBlockParam
|
||||
| ImageBlockParam
|
||||
| BetaImageBlockParam
|
||||
| BetaToolReferenceBlock
|
||||
| BetaToolSearchToolSearchResultBlock
|
||||
| BetaToolSearchToolResultError
|
||||
| WebSearchResultBlock
|
||||
| WebSearchToolResultError
|
||||
| BetaWebFetchBlock
|
||||
| BetaWebFetchToolResultErrorBlock
|
||||
| BetaCodeExecutionResultBlock
|
||||
| BetaCodeExecutionToolResultError
|
||||
| BetaBashCodeExecutionResultBlock
|
||||
| BetaBashCodeExecutionToolResultError
|
||||
| BetaTextEditorCodeExecutionViewResultBlock
|
||||
| BetaTextEditorCodeExecutionCreateResultBlock
|
||||
| BetaTextEditorCodeExecutionStrReplaceResultBlock
|
||||
| BetaTextEditorCodeExecutionToolResultError;
|
||||
import { HookCallback } from "@anthropic-ai/claude-agent-sdk";
|
||||
import { Logger } from "./acp-agent.js";
|
||||
import { SettingsManager } from "./settings.js";
|
||||
@@ -406,6 +457,7 @@ export function toolInfoFromToolUse(toolUse: any): ToolInfo {
|
||||
export function toolUpdateFromToolResult(
|
||||
toolResult:
|
||||
| ToolResultBlockParam
|
||||
| BetaToolResultBlockParam
|
||||
| BetaWebSearchToolResultBlockParam
|
||||
| BetaWebFetchToolResultBlockParam
|
||||
| WebSearchToolResultBlockParam
|
||||
@@ -547,17 +599,20 @@ function toAcpContentUpdate(
|
||||
): { content?: ToolCallContent[] } {
|
||||
if (Array.isArray(content) && content.length > 0) {
|
||||
return {
|
||||
content: content.map((content: any) => ({
|
||||
type: "content",
|
||||
content:
|
||||
isError && content.type === "text"
|
||||
? {
|
||||
...content,
|
||||
text: `\`\`\`\n${content.text}\n\`\`\``,
|
||||
}
|
||||
: content,
|
||||
content: content.map((c: any) => ({
|
||||
type: "content" as const,
|
||||
content: toAcpContentBlock(c, isError),
|
||||
})),
|
||||
};
|
||||
} else if (typeof content === "object" && content !== null && "type" in content) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "content" as const,
|
||||
content: toAcpContentBlock(content, isError),
|
||||
},
|
||||
],
|
||||
};
|
||||
} else if (typeof content === "string" && content.length > 0) {
|
||||
return {
|
||||
content: [
|
||||
@@ -574,6 +629,74 @@ function toAcpContentUpdate(
|
||||
return {};
|
||||
}
|
||||
|
||||
function toAcpContentBlock(content: ToolResultContent, isError: boolean): ContentBlock {
|
||||
const wrapText = (text: string): ContentBlock => ({
|
||||
type: "text" as const,
|
||||
text: isError ? `\`\`\`\n${text}\n\`\`\`` : text,
|
||||
});
|
||||
|
||||
switch (content.type) {
|
||||
case "text":
|
||||
return {
|
||||
type: "text" as const,
|
||||
text: isError ? `\`\`\`\n${content.text}\n\`\`\`` : content.text,
|
||||
};
|
||||
case "image":
|
||||
if (content.source.type === "base64") {
|
||||
return {
|
||||
type: "image" as const,
|
||||
data: content.source.data,
|
||||
mimeType: content.source.media_type,
|
||||
};
|
||||
}
|
||||
// URL and file-based images can't be converted to ACP format (requires data)
|
||||
return wrapText(
|
||||
content.source.type === "url"
|
||||
? `[image: ${content.source.url}]`
|
||||
: "[image: file reference]",
|
||||
);
|
||||
|
||||
case "tool_reference":
|
||||
return wrapText(`Tool: ${content.tool_name}`);
|
||||
case "tool_search_tool_search_result":
|
||||
return wrapText(
|
||||
`Tools found: ${content.tool_references.map((r) => r.tool_name).join(", ") || "none"}`,
|
||||
);
|
||||
case "tool_search_tool_result_error":
|
||||
return wrapText(
|
||||
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
|
||||
);
|
||||
case "web_search_result":
|
||||
return wrapText(`${content.title} (${content.url})`);
|
||||
case "web_search_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "web_fetch_result":
|
||||
return wrapText(`Fetched: ${content.url}`);
|
||||
case "web_fetch_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "code_execution_result":
|
||||
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
|
||||
case "bash_code_execution_result":
|
||||
return wrapText(`Output: ${content.stdout || content.stderr || ""}`);
|
||||
case "code_execution_tool_result_error":
|
||||
case "bash_code_execution_tool_result_error":
|
||||
return wrapText(`Error: ${content.error_code}`);
|
||||
case "text_editor_code_execution_view_result":
|
||||
return wrapText(content.content);
|
||||
case "text_editor_code_execution_create_result":
|
||||
return wrapText(content.is_file_update ? "File updated" : "File created");
|
||||
case "text_editor_code_execution_str_replace_result":
|
||||
return wrapText(content.lines?.join("\n") || "");
|
||||
case "text_editor_code_execution_tool_result_error":
|
||||
return wrapText(
|
||||
`Error: ${content.error_code}${content.error_message ? ` - ${content.error_message}` : ""}`,
|
||||
);
|
||||
|
||||
default:
|
||||
return wrapText(JSON.stringify(content));
|
||||
}
|
||||
}
|
||||
|
||||
export type ClaudePlanEntry = {
|
||||
content: string;
|
||||
status: "pending" | "in_progress" | "completed";
|
||||
@@ -629,16 +752,28 @@ export const registerHookCallback = (
|
||||
|
||||
/* A callback for Claude Code that is called when receiving a PostToolUse hook */
|
||||
export const createPostToolUseHook =
|
||||
(logger: Logger = console): HookCallback =>
|
||||
(
|
||||
logger: Logger = console,
|
||||
options?: {
|
||||
onEnterPlanMode?: () => Promise<void>;
|
||||
},
|
||||
): HookCallback =>
|
||||
async (input: any, toolUseID: string | undefined): Promise<{ continue: boolean }> => {
|
||||
if (input.hook_event_name === "PostToolUse" && toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
if (input.hook_event_name === "PostToolUse") {
|
||||
// Handle EnterPlanMode tool - notify client of mode change after successful execution
|
||||
if (input.tool_name === "EnterPlanMode" && options?.onEnterPlanMode) {
|
||||
await options.onEnterPlanMode();
|
||||
}
|
||||
|
||||
if (toolUseID) {
|
||||
const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook;
|
||||
if (onPostToolUseHook) {
|
||||
await onPostToolUseHook(toolUseID, input.tool_input, input.tool_response);
|
||||
delete toolUseCallbacks[toolUseID]; // Cleanup after execution
|
||||
} else {
|
||||
logger.error(`No onPostToolUseHook found for tool use ID: ${toolUseID}`);
|
||||
delete toolUseCallbacks[toolUseID];
|
||||
}
|
||||
}
|
||||
}
|
||||
return { continue: true };
|
||||
|
||||
15
src/utils.ts
15
src/utils.ts
@@ -169,3 +169,18 @@ export function extractLinesWithByteLimit(
|
||||
linesRead: linesSeen,
|
||||
};
|
||||
}
|
||||
|
||||
// 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 {
|
||||
const windowsPathMatch = cwd.match(/^([A-Za-z]):[\\/]/);
|
||||
if (windowsPathMatch) {
|
||||
const driveLetter = windowsPathMatch[1];
|
||||
const rest = cwd.slice(2);
|
||||
return `${driveLetter}${rest.replace(/[\\/]/g, "-")}`;
|
||||
}
|
||||
|
||||
// Unix paths
|
||||
return cwd.replace(/\//g, "-");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user