mirror of
https://github.com/agentclientprotocol/claude-agent-acp.git
synced 2026-10-28 13:01:20 +00:00
Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5b836820b | ||
|
|
c3ffcfa98f | ||
|
|
4d232dafad | ||
|
|
4f0ffa2735 | ||
|
|
f069df5857 | ||
|
|
e2a1ac9b9d | ||
|
|
673b0507d7 | ||
|
|
ea00a4014a | ||
|
|
ebdd115b76 | ||
|
|
7c87fe7a8c |
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
|
||||
|
||||
14
CHANGELOG.md
14
CHANGELOG.md
@@ -1,5 +1,19 @@
|
||||
# Changelog
|
||||
|
||||
## 0.16.2
|
||||
|
||||
- Update to @anthropic-ai/claude-agent-sdk@0.2.44
|
||||
- fix: Replace all non-alphanumeric characters for session loading in encodeProjectPath (#307)
|
||||
- don't include /login slash command in login command (#315)
|
||||
|
||||
## 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
|
||||
|
||||
695
package-lock.json
generated
695
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
17
package.json
17
package.json
@@ -3,7 +3,7 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"version": "0.16.0",
|
||||
"version": "0.16.2",
|
||||
"description": "An ACP-compatible coding agent powered by the Claude Code SDK (TypeScript)",
|
||||
"main": "dist/lib.js",
|
||||
"types": "dist/lib.d.ts",
|
||||
@@ -61,17 +61,18 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.14.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.34",
|
||||
"@anthropic-ai/claude-agent-sdk": "0.2.44",
|
||||
"@modelcontextprotocol/sdk": "1.26.0",
|
||||
"diff": "8.0.3",
|
||||
"minimatch": "10.1.2"
|
||||
"minimatch": "10.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@anthropic-ai/sdk": "0.73.0",
|
||||
"@types/node": "25.2.1",
|
||||
"@typescript-eslint/eslint-plugin": "8.54.0",
|
||||
"@typescript-eslint/parser": "8.54.0",
|
||||
"eslint": "9.39.2",
|
||||
"@anthropic-ai/sdk": "0.74.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@types/node": "25.2.3",
|
||||
"@typescript-eslint/eslint-plugin": "8.56.0",
|
||||
"@typescript-eslint/parser": "8.56.0",
|
||||
"eslint": "10.0.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"globals": "17.3.0",
|
||||
"prettier": "3.8.1",
|
||||
|
||||
188
src/acp-agent.ts
188
src/acp-agent.ts
@@ -79,26 +79,6 @@ import { fileURLToPath } from "node:url";
|
||||
export const CLAUDE_CONFIG_DIR =
|
||||
process.env.CLAUDE_CONFIG_DIR ?? path.join(os.homedir(), ".claude");
|
||||
|
||||
/**
|
||||
* Decode a Claude project path encoding back to the original filesystem path.
|
||||
* Claude encodes paths by replacing path separators with dashes:
|
||||
* - Unix: "/Users/morse/project" -> "-Users-morse-project"
|
||||
* - Windows: "C:\Users\morse\project" -> "C-Users-morse-project"
|
||||
*/
|
||||
function decodeProjectPath(encodedPath: string): string {
|
||||
// Check if this looks like a Windows path (starts with drive letter pattern like "C-")
|
||||
const windowsDriveMatch = encodedPath.match(/^([A-Za-z])-/);
|
||||
if (windowsDriveMatch) {
|
||||
// Windows path: "C-Users-morse-project" -> "C:\Users\morse\project"
|
||||
const driveLetter = windowsDriveMatch[1];
|
||||
const rest = encodedPath.slice(2); // Skip "C-"
|
||||
return `${driveLetter}:\\${rest.replace(/-/g, "\\")}`;
|
||||
}
|
||||
|
||||
// Unix path: "-Users-morse-project" -> "/Users/morse/project"
|
||||
return encodedPath.replace(/-/g, "/");
|
||||
}
|
||||
|
||||
function sessionFilePath(cwd: string, sessionId: string): string {
|
||||
return path.join(CLAUDE_CONFIG_DIR, "projects", encodeProjectPath(cwd), `${sessionId}.jsonl`);
|
||||
}
|
||||
@@ -172,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;
|
||||
};
|
||||
@@ -237,7 +218,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
authMethod._meta = {
|
||||
"terminal-auth": {
|
||||
command: "node",
|
||||
args: [cliPath, "/login"],
|
||||
args: [cliPath],
|
||||
label: "Claude Code Login",
|
||||
},
|
||||
};
|
||||
@@ -278,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 ?? [],
|
||||
@@ -296,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> {
|
||||
@@ -309,14 +300,56 @@ export class ClaudeAcpAgent implements Agent {
|
||||
resume: params.sessionId,
|
||||
},
|
||||
);
|
||||
|
||||
// Needs to happen after we return the session
|
||||
setTimeout(() => {
|
||||
this.sendAvailableCommandsUpdate(response.sessionId);
|
||||
}, 0);
|
||||
return response;
|
||||
}
|
||||
|
||||
async loadSession(params: LoadSessionRequest): Promise<LoadSessionResponse> {
|
||||
/**
|
||||
* 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(sessionFilePath(params.cwd, params.sessionId));
|
||||
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");
|
||||
}
|
||||
|
||||
@@ -331,7 +364,12 @@ export class ClaudeAcpAgent implements Agent {
|
||||
},
|
||||
);
|
||||
|
||||
await this.replaySessionHistory(params.sessionId, params.cwd);
|
||||
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,
|
||||
@@ -358,6 +396,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
// 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);
|
||||
@@ -367,13 +406,9 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const stat = await fs.promises.stat(projectDir);
|
||||
if (!stat.isDirectory()) continue;
|
||||
|
||||
// Decode the path based on platform:
|
||||
// - Unix: "-Users-morse-project" -> "/Users/morse/project"
|
||||
// - Windows: "C-Users-morse-project" -> "C:\Users\morse\project"
|
||||
const decodedCwd = decodeProjectPath(encodedPath);
|
||||
|
||||
// Skip if filtering by cwd and this doesn't match
|
||||
if (params.cwd && decodedCwd !== params.cwd) 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
|
||||
@@ -386,23 +421,31 @@ export class ClaudeAcpAgent implements Agent {
|
||||
const content = await fs.promises.readFile(filePath, "utf-8");
|
||||
const lines = content.trim().split("\n").filter(Boolean);
|
||||
|
||||
const firstLine = lines[0];
|
||||
if (!firstLine) continue;
|
||||
|
||||
// Parse first line to get session info
|
||||
const firstEntry = JSON.parse(firstLine);
|
||||
const sessionId = firstEntry.sessionId || file.replace(".jsonl", "");
|
||||
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);
|
||||
if (entry.type === "user" && entry.message?.content) {
|
||||
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);
|
||||
break;
|
||||
}
|
||||
if (Array.isArray(msgContent) && msgContent.length > 0) {
|
||||
const first = msgContent[0];
|
||||
@@ -414,14 +457,29 @@ export class ClaudeAcpAgent implements Agent {
|
||||
: undefined;
|
||||
if (text) {
|
||||
title = sanitizeTitle(text);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
@@ -429,7 +487,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
allSessions.push({
|
||||
sessionId,
|
||||
cwd: decodedCwd,
|
||||
cwd: sessionCwd,
|
||||
title: title ?? null,
|
||||
updatedAt,
|
||||
});
|
||||
@@ -698,10 +756,15 @@ export class ClaudeAcpAgent implements Agent {
|
||||
try {
|
||||
await this.sessions[params.sessionId].query.setPermissionMode(params.modeId);
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error && error.message ? error.message : "Invalid Mode";
|
||||
|
||||
throw new Error(errorMessage);
|
||||
if (error instanceof Error) {
|
||||
if (!error.message) {
|
||||
error.message = "Invalid Mode";
|
||||
}
|
||||
throw error;
|
||||
} else {
|
||||
// eslint-disable-next-line preserve-caught-error
|
||||
throw new Error("Invalid Mode");
|
||||
}
|
||||
}
|
||||
return {};
|
||||
default:
|
||||
@@ -709,8 +772,7 @@ export class ClaudeAcpAgent implements Agent {
|
||||
}
|
||||
}
|
||||
|
||||
private async replaySessionHistory(sessionId: string, cwd: string): Promise<void> {
|
||||
const filePath = sessionFilePath(cwd, sessionId);
|
||||
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 });
|
||||
@@ -912,6 +974,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 } = {},
|
||||
@@ -1105,7 +1180,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
|
||||
@@ -1131,17 +1206,6 @@ export class ClaudeAcpAgent implements Agent {
|
||||
|
||||
const models = await getAvailableModels(q, initializationResult.models, settingsManager);
|
||||
|
||||
// Needs to happen after we return the session
|
||||
setTimeout(() => {
|
||||
this.client.sessionUpdate({
|
||||
sessionId,
|
||||
update: {
|
||||
sessionUpdate: "available_commands_update",
|
||||
availableCommands: getAvailableSlashCommands(initializationResult.commands),
|
||||
},
|
||||
});
|
||||
}, 0);
|
||||
|
||||
const availableModes = [
|
||||
{
|
||||
id: "default",
|
||||
|
||||
@@ -609,6 +609,7 @@ describe("tool conversions", () => {
|
||||
server_tool_use: null,
|
||||
inference_geo: null,
|
||||
iterations: null,
|
||||
speed: null,
|
||||
},
|
||||
context_management: null,
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ describe("unstable_listSessions", () => {
|
||||
mtime?: Date;
|
||||
malformed?: boolean;
|
||||
isAgentFile?: boolean;
|
||||
omitCwdFromEntries?: boolean;
|
||||
} = {},
|
||||
): void {
|
||||
const encodedPath = encodeProjectPath(cwd);
|
||||
@@ -56,11 +57,25 @@ describe("unstable_listSessions", () => {
|
||||
const content = options.userMessageArray
|
||||
? [{ type: "text", text: options.userMessage }]
|
||||
: options.userMessage;
|
||||
lines.push(JSON.stringify({ type: "user", message: { content } }));
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: "user",
|
||||
sessionId,
|
||||
...(options.omitCwdFromEntries ? {} : { cwd }),
|
||||
message: { content },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Assistant response
|
||||
lines.push(JSON.stringify({ type: "assistant", message: { content: "Hello!" } }));
|
||||
lines.push(
|
||||
JSON.stringify({
|
||||
type: "assistant",
|
||||
sessionId,
|
||||
...(options.omitCwdFromEntries ? {} : { cwd }),
|
||||
message: { content: "Hello!" },
|
||||
}),
|
||||
);
|
||||
|
||||
fs.writeFileSync(filePath, lines.join("\n"));
|
||||
|
||||
@@ -187,6 +202,66 @@ describe("unstable_listSessions", () => {
|
||||
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" });
|
||||
@@ -241,15 +316,161 @@ describe("unstable_listSessions", () => {
|
||||
const projectDir = path.join(tempDir, "projects", encodedPath);
|
||||
fs.mkdirSync(projectDir, { recursive: true });
|
||||
|
||||
// Write file without sessionId in content
|
||||
// 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" }) + "\n");
|
||||
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);
|
||||
@@ -262,7 +483,7 @@ describe("unstable_listSessions", () => {
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({ sessionId: "no-user", type: "init" }),
|
||||
JSON.stringify({ type: "assistant", message: { content: "Hi" } }),
|
||||
JSON.stringify({ type: "assistant", cwd, message: { content: "Hi" } }),
|
||||
].join("\n"),
|
||||
);
|
||||
|
||||
|
||||
@@ -230,6 +230,76 @@ describe("loadSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
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";
|
||||
|
||||
13
src/utils.ts
13
src/utils.ts
@@ -133,7 +133,6 @@ export function extractLinesWithByteLimit(
|
||||
|
||||
let linesSeen = 0;
|
||||
let index = 0;
|
||||
linesSeen = 0;
|
||||
|
||||
let contentLength = 0;
|
||||
let wasLimited = false;
|
||||
@@ -172,15 +171,7 @@ export function extractLinesWithByteLimit(
|
||||
|
||||
// Helper to encode a path like Claude does:
|
||||
// - Unix: "/Users/test" -> "-Users-test"
|
||||
// - Windows: "C:\Users\test" -> "C-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, "-");
|
||||
return cwd.replace(/[^a-zA-Z0-9]/g, "-");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user