diff --git a/src/agent/claude-code/binary.ts b/src/agent/claude-code/binary.ts index 5e57cda..81b4a55 100644 --- a/src/agent/claude-code/binary.ts +++ b/src/agent/claude-code/binary.ts @@ -18,32 +18,51 @@ type SearchSpec = { function getWindowsSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { const paths = path.win32; const appData = env.APPDATA ?? paths.join(homeDir, "AppData", "Roaming"); + return { paths, names: ["claude.exe", "claude.cmd"], - knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), env.npm_config_prefix ?? paths.join(appData, "npm")], + knownDirs: [ + paths.join(homeDir, ".local", "bin"), + paths.join(homeDir, ".claude", "local"), + env.npm_config_prefix ?? paths.join(appData, "npm"), + ], }; } function getPosixSearchSpec(env: BinaryLookup["env"], homeDir: string): SearchSpec { const paths = path.posix; - const npmBin = env.npm_config_prefix ? paths.join(env.npm_config_prefix, "bin") : paths.join(homeDir, ".npm-global", "bin"); + const npmBin = env.npm_config_prefix + ? paths.join(env.npm_config_prefix, "bin") + : paths.join(homeDir, ".npm-global", "bin"); + return { paths, names: ["claude"], - knownDirs: [paths.join(homeDir, ".local", "bin"), paths.join(homeDir, ".claude", "local"), npmBin, "/opt/homebrew/bin", "/usr/local/bin"], + knownDirs: [ + paths.join(homeDir, ".local", "bin"), + paths.join(homeDir, ".claude", "local"), + npmBin, + "/opt/homebrew/bin", + "/usr/local/bin", + ], }; } export function resolveClaudeBinary({ env, platform, homeDir, isExecutable }: BinaryLookup): string | null { - const { paths, names, knownDirs } = platform === "win32" ? getWindowsSearchSpec(env, homeDir) : getPosixSearchSpec(env, homeDir); + const { paths, names, knownDirs } = platform === "win32" + ? getWindowsSearchSpec(env, homeDir) + : getPosixSearchSpec(env, homeDir); + const pathDirs = (env.PATH ?? "").split(paths.delimiter).filter(Boolean); + for (const name of names) { for (const dir of [...pathDirs, ...knownDirs]) { const candidate = paths.join(dir, name); if (isExecutable(candidate)) return candidate; } } + return null; } @@ -57,5 +76,10 @@ function isExecutableFile(filePath: string): boolean { } export function getClaudeBinary(): string | null { - return resolveClaudeBinary({ env: process.env, platform: process.platform, homeDir: os.homedir(), isExecutable: isExecutableFile }); + return resolveClaudeBinary({ + env: process.env, + platform: process.platform, + homeDir: os.homedir(), + isExecutable: isExecutableFile, + }); } diff --git a/src/agent/claude-code/transcript.ts b/src/agent/claude-code/transcript.ts index 67cba3c..ecc5e5d 100644 --- a/src/agent/claude-code/transcript.ts +++ b/src/agent/claude-code/transcript.ts @@ -1,7 +1,9 @@ import type { ImageContent, Message, TextContent, Tool } from "@earendil-works/pi-ai"; import { getCurrentSystemPrompt, getCurrentTools } from "@earendil-works/pi-ai/utils/transcript"; -export type ClaudeContentBlock = { type: "text"; text: string } | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; +export type ClaudeContentBlock = + | { type: "text"; text: string } + | { type: "image"; source: { type: "base64"; media_type: string; data: string } }; export type ClaudeInput = { systemPrompt: string; @@ -17,8 +19,10 @@ function toImageBlock(image: ImageContent): ClaudeContentBlock { function getLabelledParts(label: string, content: string | (TextContent | ImageContent)[]): ReplayPart[] { if (typeof content === "string") return [`${label}\n${content}`]; + const text = content.flatMap((block) => (block.type === "text" ? [block.text] : [])).join("\n"); const images = content.filter((block) => block.type === "image"); + return [text ? `${label}\n${text}` : label, ...images]; } @@ -26,26 +30,33 @@ function getReplayParts(message: Message): ReplayPart[] { switch (message.role) { case "system": return []; + case "user": return getLabelledParts("[user]", message.content); + case "assistant": return message.content.flatMap((block) => { if (block.type === "text") return [`[assistant]\n${block.text}`]; if (block.type === "toolCall") return [`[tool call ${block.name}] ${JSON.stringify(block.arguments)}`]; return []; }); - case "toolResult": - return getLabelledParts(message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`, message.content); + + case "toolResult": { + const label = message.isError ? `[tool result ${message.toolName} error]` : `[tool result ${message.toolName}]`; + return getLabelledParts(label, message.content); + } } } function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] { const blocks: ClaudeContentBlock[] = []; let pendingText: string[] = []; + const flushText = () => { if (pendingText.length > 0) blocks.push({ type: "text", text: pendingText.join("\n\n") }); pendingText = []; }; + for (const part of parts) { if (typeof part === "string") { pendingText.push(part); @@ -54,20 +65,33 @@ function toContentBlocks(parts: ReplayPart[]): ClaudeContentBlock[] { blocks.push(toImageBlock(part)); } } + flushText(); + return blocks; } function toUnlabelledBlocks(content: string | (TextContent | ImageContent)[]): ClaudeContentBlock[] { if (typeof content === "string") return [{ type: "text", text: content }]; + return content.map((block) => (block.type === "text" ? { type: "text", text: block.text } : toImageBlock(block))); } +function toUserContent(conversation: Message[]): ClaudeContentBlock[] { + const [first] = conversation; + if (conversation.length === 1 && first?.role === "user") return toUnlabelledBlocks(first.content); + + return toContentBlocks(conversation.flatMap(getReplayParts)); +} + export function toClaudeInput(messages: Message[]): ClaudeInput { const conversation = messages.filter((message) => message.role !== "system"); - const onlyMessage = conversation.length === 1 ? conversation[0] : null; - const userContent = onlyMessage?.role === "user" ? toUnlabelledBlocks(onlyMessage.content) : toContentBlocks(conversation.flatMap(getReplayParts)); - return { systemPrompt: getCurrentSystemPrompt(messages), tools: getCurrentTools(messages), userContent }; + + return { + systemPrompt: getCurrentSystemPrompt(messages), + tools: getCurrentTools(messages), + userContent: toUserContent(conversation), + }; } export function toStreamJsonLine(content: ClaudeContentBlock[]): string { diff --git a/tests/claude-code-binary.test.ts b/tests/claude-code-binary.test.ts index 00c7d34..66a716a 100644 --- a/tests/claude-code-binary.test.ts +++ b/tests/claude-code-binary.test.ts @@ -45,7 +45,10 @@ test("windows looks for claude.cmd and claude.exe", () => { assert.equal(resolveWith(["C:\\bin\\claude.cmd"], env, "win32", winHome), "C:\\bin\\claude.cmd"); assert.equal(resolveWith(["C:\\bin\\claude.exe"], env, "win32", winHome), "C:\\bin\\claude.exe"); assert.equal(resolveWith([`${winHome}\\.local\\bin\\claude.exe`], env, "win32", winHome), `${winHome}\\.local\\bin\\claude.exe`); - assert.equal(resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`); + assert.equal( + resolveWith([`${winHome}\\AppData\\Roaming\\npm\\claude.cmd`], env, "win32", winHome), + `${winHome}\\AppData\\Roaming\\npm\\claude.cmd`, + ); assert.equal(resolveWith(["C:\\bin\\claude"], env, "win32", winHome), null); }); diff --git a/tests/claude-code-transcript.test.ts b/tests/claude-code-transcript.test.ts index 0fbec27..5e8db33 100644 --- a/tests/claude-code-transcript.test.ts +++ b/tests/claude-code-transcript.test.ts @@ -29,7 +29,14 @@ function assistant(content: AssistantMessage["content"]): AssistantMessage { api: "claude-code-cli", provider: "claude-code", model: "opus", - usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } }, + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, stopReason: "stop", timestamp: 0, }; @@ -96,7 +103,12 @@ test("error tool results are marked as errors in the replay", () => { toolResult("read", [{ type: "text", text: "ENOENT" }], true), user("why?"), ]; - const expected = ["[user]\nopen missing.md", '[tool call read] {"path":"missing.md"}', "[tool result read error]\nENOENT", "[user]\nwhy?"].join("\n\n"); + const expected = [ + "[user]\nopen missing.md", + '[tool call read] {"path":"missing.md"}', + "[tool result read error]\nENOENT", + "[user]\nwhy?", + ].join("\n\n"); assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); }); @@ -142,5 +154,8 @@ test("thinking blocks are omitted from replay", () => { }); test('toStreamJsonLine emits {"type":"user","message":{"role":"user","content":[...]}}', () => { - assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), { type: "user", message: { role: "user", content: [{ type: "text", text: "hi" }] } }); + assert.deepEqual(JSON.parse(toStreamJsonLine([{ type: "text", text: "hi" }])), { + type: "user", + message: { role: "user", content: [{ type: "text", text: "hi" }] }, + }); });