diff --git a/packages/adapters/claude-local/src/cli/format-event.ts b/packages/adapters/claude-local/src/cli/format-event.ts index 08423d6e..13263be6 100644 --- a/packages/adapters/claude-local/src/cli/format-event.ts +++ b/packages/adapters/claude-local/src/cli/format-event.ts @@ -17,6 +17,27 @@ function asErrorText(value: unknown): string { } } +function printToolResult(block: Record): void { + const isError = block.is_error === true; + let text = ""; + if (typeof block.content === "string") { + text = block.content; + } else if (Array.isArray(block.content)) { + const parts: string[] = []; + for (const part of block.content) { + if (typeof part !== "object" || part === null || Array.isArray(part)) continue; + const record = part as Record; + if (typeof record.text === "string") parts.push(record.text); + } + text = parts.join("\n"); + } + + console.log((isError ? pc.red : pc.cyan)(`tool_result${isError ? " (error)" : ""}`)); + if (text) { + console.log((isError ? pc.red : pc.gray)(text)); + } +} + export function printClaudeStreamEvent(raw: string, debug: boolean): void { const line = raw.trim(); if (!line) return; @@ -51,6 +72,9 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void { if (blockType === "text") { const text = typeof block.text === "string" ? block.text : ""; if (text) console.log(pc.green(`assistant: ${text}`)); + } else if (blockType === "thinking") { + const text = typeof block.thinking === "string" ? block.thinking : ""; + if (text) console.log(pc.gray(`thinking: ${text}`)); } else if (blockType === "tool_use") { const name = typeof block.name === "string" ? block.name : "unknown"; console.log(pc.yellow(`tool_call: ${name}`)); @@ -62,6 +86,22 @@ export function printClaudeStreamEvent(raw: string, debug: boolean): void { return; } + if (type === "user") { + const message = + typeof parsed.message === "object" && parsed.message !== null && !Array.isArray(parsed.message) + ? (parsed.message as Record) + : {}; + const content = Array.isArray(message.content) ? message.content : []; + for (const blockRaw of content) { + if (typeof blockRaw !== "object" || blockRaw === null || Array.isArray(blockRaw)) continue; + const block = blockRaw as Record; + if (typeof block.type === "string" && block.type === "tool_result") { + printToolResult(block); + } + } + return; + } + if (type === "result") { const usage = typeof parsed.usage === "object" && parsed.usage !== null && !Array.isArray(parsed.usage) diff --git a/server/src/__tests__/claude-local-adapter.test.ts b/server/src/__tests__/claude-local-adapter.test.ts index 26fc4531..d2cb8665 100644 --- a/server/src/__tests__/claude-local-adapter.test.ts +++ b/server/src/__tests__/claude-local-adapter.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { isClaudeMaxTurnsResult } from "@paperclipai/adapter-claude-local/server"; +import { parseClaudeStdoutLine } from "@paperclipai/adapter-claude-local/ui"; +import { printClaudeStreamEvent } from "@paperclipai/adapter-claude-local/cli"; describe("claude_local max-turn detection", () => { it("detects max-turn exhaustion by subtype", () => { @@ -28,3 +30,158 @@ describe("claude_local max-turn detection", () => { ).toBe(false); }); }); + +describe("claude_local ui stdout parser", () => { + it("maps assistant text, thinking, tool calls, and tool results into transcript entries", () => { + const ts = "2026-03-29T00:00:00.000Z"; + + expect( + parseClaudeStdoutLine( + JSON.stringify({ + type: "system", + subtype: "init", + model: "claude-sonnet-4-6", + session_id: "claude-session-1", + }), + ts, + ), + ).toEqual([ + { + kind: "init", + ts, + model: "claude-sonnet-4-6", + sessionId: "claude-session-1", + }, + ]); + + expect( + parseClaudeStdoutLine( + JSON.stringify({ + type: "assistant", + session_id: "claude-session-1", + message: { + content: [ + { type: "text", text: "I will inspect the repo." }, + { type: "thinking", thinking: "Checking the adapter wiring" }, + { type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } }, + ], + }, + }), + ts, + ), + ).toEqual([ + { kind: "assistant", ts, text: "I will inspect the repo." }, + { kind: "thinking", ts, text: "Checking the adapter wiring" }, + { kind: "tool_call", ts, name: "bash", toolUseId: "tool_1", input: { command: "ls -1" } }, + ]); + + expect( + parseClaudeStdoutLine( + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: [{ type: "text", text: "AGENTS.md\nREADME.md" }], + is_error: false, + }, + ], + }, + }), + ts, + ), + ).toEqual([ + { + kind: "tool_result", + ts, + toolUseId: "tool_1", + content: "AGENTS.md\nREADME.md", + isError: false, + }, + ]); + }); +}); + +function stripAnsi(value: string) { + return value.replace(/\x1b\[[0-9;]*m/g, ""); +} + +describe("claude_local cli formatter", () => { + it("prints the user-visible and background transcript events from stream-json output", () => { + const spy = vi.spyOn(console, "log").mockImplementation(() => {}); + + try { + printClaudeStreamEvent( + JSON.stringify({ + type: "system", + subtype: "init", + model: "claude-sonnet-4-6", + session_id: "claude-session-1", + }), + false, + ); + printClaudeStreamEvent( + JSON.stringify({ + type: "assistant", + message: { + content: [ + { type: "text", text: "I will inspect the repo." }, + { type: "thinking", thinking: "Checking the adapter wiring" }, + { type: "tool_use", id: "tool_1", name: "bash", input: { command: "ls -1" } }, + ], + }, + }), + false, + ); + printClaudeStreamEvent( + JSON.stringify({ + type: "user", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "tool_1", + content: [{ type: "text", text: "AGENTS.md\nREADME.md" }], + is_error: false, + }, + ], + }, + }), + false, + ); + printClaudeStreamEvent( + JSON.stringify({ + type: "result", + subtype: "success", + result: "Done", + usage: { input_tokens: 10, output_tokens: 5, cache_read_input_tokens: 2 }, + total_cost_usd: 0.00042, + }), + false, + ); + + const lines = spy.mock.calls + .map((call) => call.map((value) => String(value)).join(" ")) + .map(stripAnsi); + + expect(lines).toEqual( + expect.arrayContaining([ + "Claude initialized (model: claude-sonnet-4-6, session: claude-session-1)", + "assistant: I will inspect the repo.", + "thinking: Checking the adapter wiring", + "tool_call: bash", + '{\n "command": "ls -1"\n}', + "tool_result", + "AGENTS.md\nREADME.md", + "result:", + "Done", + "tokens: in=10 out=5 cached=2 cost=$0.000420", + ]), + ); + } finally { + spy.mockRestore(); + } + }); +});