From 5c0425901c06213b38e82dc3789a3c4f26716ac8 Mon Sep 17 00:00:00 2001 From: Ivanko1608 <29154527+Ivanko1608@users.noreply.github.com> Date: Sat, 24 Oct 2026 20:35:20 +0400 Subject: [PATCH] feat: replay pi transcript as CLI input --- src/agent/claude-code/transcript.ts | 75 ++++++++++++++ tests/claude-code-transcript.test.ts | 146 +++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 src/agent/claude-code/transcript.ts create mode 100644 tests/claude-code-transcript.test.ts diff --git a/src/agent/claude-code/transcript.ts b/src/agent/claude-code/transcript.ts new file mode 100644 index 0000000..67cba3c --- /dev/null +++ b/src/agent/claude-code/transcript.ts @@ -0,0 +1,75 @@ +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 ClaudeInput = { + systemPrompt: string; + tools: Tool[]; + userContent: ClaudeContentBlock[]; +}; + +type ReplayPart = string | ImageContent; + +function toImageBlock(image: ImageContent): ClaudeContentBlock { + return { type: "image", source: { type: "base64", media_type: image.mimeType, data: image.data } }; +} + +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]; +} + +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); + } +} + +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); + } else { + flushText(); + 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))); +} + +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 }; +} + +export function toStreamJsonLine(content: ClaudeContentBlock[]): string { + return JSON.stringify({ type: "user", message: { role: "user", content } }); +} diff --git a/tests/claude-code-transcript.test.ts b/tests/claude-code-transcript.test.ts new file mode 100644 index 0000000..0fbec27 --- /dev/null +++ b/tests/claude-code-transcript.test.ts @@ -0,0 +1,146 @@ +/** + * Claude Code transcript replay (src/agent/claude-code/transcript.ts). + * + * The CLI makes one model call per input user message, so earlier turns are + * rendered as labelled text inside the single user message it receives. + */ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { AssistantMessage, Message, SystemMessage, Tool, ToolResultMessage, UserMessage } from "@earendil-works/pi-ai"; + +import { toClaudeInput, toStreamJsonLine } from "../src/agent/claude-code/transcript.ts"; + +const READ_TOOL: Tool = { name: "read", description: "Read a file", parameters: { type: "object", properties: {} } }; +const WRITE_TOOL: Tool = { name: "write", description: "Write a file", parameters: { type: "object", properties: {} } }; + +function system(fields: Partial): SystemMessage { + return { role: "system", content: "", timestamp: 0, ...fields }; +} + +function user(content: UserMessage["content"]): UserMessage { + return { role: "user", content, timestamp: 0 }; +} + +function assistant(content: AssistantMessage["content"]): AssistantMessage { + return { + role: "assistant", + content, + 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 } }, + stopReason: "stop", + timestamp: 0, + }; +} + +function toolResult(toolName: string, content: ToolResultMessage["content"], isError = false): ToolResultMessage { + return { role: "toolResult", toolCallId: `${toolName}-1`, toolName, content, isError, timestamp: 0 }; +} + +test("system prompt joins all system content, then the live sections", () => { + const messages: Message[] = [ + system({ content: "You are a tutor.", sections: { goals: "Teach TCP.", style: "Be brief." } }), + user("hi"), + system({ content: "The learner switched topics.", sections: { goals: "Teach TLS.", style: null } }), + user("next"), + ]; + assert.equal(toClaudeInput(messages).systemPrompt, "You are a tutor.\n\nThe learner switched topics.\n\nTeach TLS."); +}); + +test("tools reflect toolsAdded minus toolsRemoved", () => { + const messages: Message[] = [ + system({ content: "p", toolsAdded: [READ_TOOL, WRITE_TOOL] }), + user("hi"), + system({ toolsRemoved: [{ name: "read" }] }), + user("next"), + ]; + assert.deepEqual( + toClaudeInput(messages).tools.map((t) => t.name), + ["write"], + ); +}); + +test("single user message passes through as text", () => { + const input = toClaudeInput([system({ content: "p" }), user("what is a socket?")]); + assert.deepEqual(input.userContent, [{ type: "text", text: "what is a socket?" }]); +}); + +test("history renders user, assistant text, tool call name+JSON args and tool result text in order before the latest user message", () => { + const messages: Message[] = [ + user([{ type: "text", text: "open notes.md" }]), + assistant([ + { type: "text", text: "Reading it." }, + { type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } }, + ]), + toolResult("read", [{ type: "text", text: "# Notes" }]), + assistant([{ type: "text", text: "It has a heading." }]), + user("summarise it"), + ]; + const expected = [ + "[user]\nopen notes.md", + "[assistant]\nReading it.", + '[tool call read] {"path":"notes.md"}', + "[tool result read]\n# Notes", + "[assistant]\nIt has a heading.", + "[user]\nsummarise it", + ].join("\n\n"); + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); +}); + +test("error tool results are marked as errors in the replay", () => { + const messages: Message[] = [ + user("open missing.md"), + assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "missing.md" } }]), + 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"); + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); +}); + +test("images in user and tool-result content become base64 image blocks", () => { + const messages: Message[] = [ + user([ + { type: "text", text: "look" }, + { type: "image", data: "AAAA", mimeType: "image/png" }, + ]), + assistant([{ type: "toolCall", id: "shot-1", name: "screenshot", arguments: {} }]), + toolResult("screenshot", [{ type: "image", data: "BBBB", mimeType: "image/jpeg" }]), + user("compare"), + ]; + assert.deepEqual(toClaudeInput(messages).userContent, [ + { type: "text", text: "[user]\nlook" }, + { type: "image", source: { type: "base64", media_type: "image/png", data: "AAAA" } }, + { type: "text", text: "[tool call screenshot] {}\n\n[tool result screenshot]" }, + { type: "image", source: { type: "base64", media_type: "image/jpeg", data: "BBBB" } }, + { type: "text", text: "[user]\ncompare" }, + ]); +}); + +test("transcript ending on a tool result renders as history with no trailing user turn", () => { + const messages: Message[] = [ + user("open notes.md"), + assistant([{ type: "toolCall", id: "read-1", name: "read", arguments: { path: "notes.md" } }]), + toolResult("read", [{ type: "text", text: "# Notes" }]), + ]; + const expected = ["[user]\nopen notes.md", '[tool call read] {"path":"notes.md"}', "[tool result read]\n# Notes"].join("\n\n"); + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: expected }]); +}); + +test("thinking blocks are omitted from replay", () => { + const messages: Message[] = [ + user("hi"), + assistant([ + { type: "thinking", thinking: "secret plan" }, + { type: "text", text: "Hello." }, + ]), + user("bye"), + ]; + assert.deepEqual(toClaudeInput(messages).userContent, [{ type: "text", text: "[user]\nhi\n\n[assistant]\nHello.\n\n[user]\nbye" }]); +}); + +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" }] } }); +});