mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-27 04:30:59 +00:00
Use history methods from the SDK (#352)
This commit is contained in:
317
src/acp-agent.ts
317
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,
|
||||
@@ -70,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,
|
||||
@@ -118,11 +112,7 @@ type SDKMessageTemp =
|
||||
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
|
||||
@@ -153,17 +143,6 @@ type Session = {
|
||||
configOptions: SessionConfigOption[];
|
||||
};
|
||||
|
||||
type SessionHistoryEntry = {
|
||||
type?: string;
|
||||
isSidechain?: boolean;
|
||||
sessionId?: string;
|
||||
message?: {
|
||||
role?: string;
|
||||
content?: unknown;
|
||||
model?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type BackgroundTerminal =
|
||||
| {
|
||||
handle: TerminalHandle;
|
||||
@@ -374,52 +353,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,
|
||||
@@ -431,7 +365,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(() => {
|
||||
@@ -445,167 +379,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> {
|
||||
@@ -895,67 +684,23 @@ 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,
|
||||
message.type,
|
||||
sessionId,
|
||||
toolUseCache,
|
||||
this.client,
|
||||
this.logger,
|
||||
{ registerHooks: false, clientCapabilities: this.clientCapabilities },
|
||||
)) {
|
||||
await this.client.sessionUpdate(notification);
|
||||
}
|
||||
} finally {
|
||||
reader.close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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