b60765785b
All output sent to Paperclip via onLog now passes through formatClaudeStreamLine, converting raw stream-json blobs into human-readable text consistent with how the CLI and claude_local adapter format events. Changes: - format-event.ts: add formatClaudeStreamLine(raw) -> string | null Plain-text equivalent of printClaudeStreamEvent — no ANSI colours, returns null for lines to suppress (assistant with no content, unknown events). Handles: system/init, assistant (text/thinking/tool_use), user (tool_result), result (summary + tokens), rate_limit_event. Non-JSON lines pass through. - execute.ts: wire formatClaudeStreamLine into streamPodLogsOnce write handler. raw chunks still stored in 'chunks[]' for parseClaudeStreamJson; only the onLog path receives formatted text. - 12 new tests for formatClaudeStreamLine covering all event types. - 352/352 tests pass. Co-Authored-By: Paperclip <noreply@paperclip.ing>
284 lines
9.1 KiB
TypeScript
284 lines
9.1 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { printClaudeStreamEvent, formatClaudeStreamLine } from "./format-event.js";
|
|
|
|
// Mock console methods to capture output
|
|
const consoleMock = {
|
|
log: vi.fn(),
|
|
};
|
|
|
|
vi.stubGlobal("console", {
|
|
...console,
|
|
log: consoleMock.log,
|
|
});
|
|
|
|
beforeEach(() => {
|
|
consoleMock.log.mockClear();
|
|
});
|
|
|
|
function output() {
|
|
return consoleMock.log.mock.calls.map((c) => c[0]).join("\n");
|
|
}
|
|
|
|
describe("printClaudeStreamEvent", () => {
|
|
it("prints raw line if not JSON", () => {
|
|
printClaudeStreamEvent("hello world", false);
|
|
expect(output()).toBe("hello world");
|
|
});
|
|
|
|
it("skips empty lines", () => {
|
|
printClaudeStreamEvent(" ", false);
|
|
expect(output()).toBe("");
|
|
});
|
|
|
|
it("prints init event with model and session", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "system",
|
|
subtype: "init",
|
|
model: "claude-opus-4-6",
|
|
session_id: "sess_abc123",
|
|
}), false);
|
|
expect(output()).toContain("Claude initialized");
|
|
expect(output()).toContain("claude-opus-4-6");
|
|
expect(output()).toContain("sess_abc123");
|
|
});
|
|
|
|
it("prints assistant text block", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "assistant",
|
|
message: { content: [{ type: "text", text: "Hello world" }] },
|
|
}), false);
|
|
expect(output()).toContain("assistant: Hello world");
|
|
});
|
|
|
|
it("prints thinking block", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "assistant",
|
|
message: { content: [{ type: "thinking", thinking: "Let me think..." }] },
|
|
}), false);
|
|
expect(output()).toContain("thinking: Let me think...");
|
|
});
|
|
|
|
it("prints tool_use block with name and input", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "assistant",
|
|
message: {
|
|
content: [{
|
|
type: "tool_use",
|
|
name: "Bash",
|
|
input: { command: "ls -la" },
|
|
}],
|
|
},
|
|
}), false);
|
|
expect(output()).toContain("tool_call: Bash");
|
|
expect(output()).toContain("ls -la");
|
|
});
|
|
|
|
it("prints tool_result for user message", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "user",
|
|
message: {
|
|
content: [{
|
|
type: "tool_result",
|
|
tool_use_id: "tool_1",
|
|
content: "file1.txt\nfile2.txt",
|
|
}],
|
|
},
|
|
}), false);
|
|
expect(output()).toContain("tool_result");
|
|
});
|
|
|
|
it("marks tool_result as error when is_error is true", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "user",
|
|
message: {
|
|
content: [{
|
|
type: "tool_result",
|
|
tool_use_id: "tool_1",
|
|
is_error: true,
|
|
content: "Permission denied",
|
|
}],
|
|
},
|
|
}), false);
|
|
expect(output()).toContain("tool_result (error)");
|
|
});
|
|
|
|
it("prints result with tokens and cost", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "result",
|
|
result: "Done",
|
|
subtype: "stop",
|
|
total_cost_usd: 0.005,
|
|
usage: {
|
|
input_tokens: 100,
|
|
output_tokens: 200,
|
|
cache_read_input_tokens: 50,
|
|
},
|
|
}), false);
|
|
expect(output()).toContain("tokens:");
|
|
expect(output()).toContain("in=100");
|
|
expect(output()).toContain("out=200");
|
|
expect(output()).toContain("cached=50");
|
|
expect(output()).toContain("cost=");
|
|
});
|
|
|
|
it("prints error subtype in result", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "result",
|
|
subtype: "error_rate_limit",
|
|
is_error: true,
|
|
errors: ["rate limited"],
|
|
}), false);
|
|
expect(output()).toContain("claude_result");
|
|
expect(output()).toContain("error_rate_limit");
|
|
expect(output()).toContain("rate limited");
|
|
});
|
|
|
|
it("prints non-JSON lines directly", () => {
|
|
printClaudeStreamEvent("some output text", false);
|
|
expect(output()).toBe("some output text");
|
|
});
|
|
|
|
it("prints rate_limit_event with type, status, and reset time", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "rate_limit_event",
|
|
rate_limit_info: {
|
|
status: "allowed",
|
|
resetsAt: 1777056000,
|
|
rateLimitType: "five_hour",
|
|
overageStatus: "allowed",
|
|
isUsingOverage: false,
|
|
},
|
|
uuid: "3ab8f9eb-b9d6-4bf6-9c39-4608427717fc",
|
|
session_id: "ad5f3e11-3c0c-4144-b53d-d4b959e57cee",
|
|
}), false);
|
|
expect(output()).toContain("rate_limit:");
|
|
expect(output()).toContain("five_hour");
|
|
expect(output()).toContain("allowed");
|
|
expect(output()).toContain("resets=");
|
|
// Raw JSON must not be surfaced verbatim
|
|
expect(output()).not.toContain("3ab8f9eb-b9d6-4bf6-9c39-4608427717fc");
|
|
});
|
|
|
|
it("prints rate_limit_event with unknown fields gracefully", () => {
|
|
printClaudeStreamEvent(JSON.stringify({
|
|
type: "rate_limit_event",
|
|
rate_limit_info: {},
|
|
}), false);
|
|
expect(output()).toContain("rate_limit:");
|
|
expect(output()).toContain("type=unknown");
|
|
expect(output()).toContain("status=unknown");
|
|
// No resetsAt present — reset clause omitted
|
|
expect(output()).not.toContain("resets=");
|
|
});
|
|
|
|
it("does not print unknown types in non-debug mode", () => {
|
|
printClaudeStreamEvent(JSON.stringify({ type: "unknown", data: "stuff" }), false);
|
|
expect(output()).toBe("");
|
|
});
|
|
|
|
it("prints unknown types in debug mode", () => {
|
|
printClaudeStreamEvent(JSON.stringify({ type: "unknown", data: "stuff" }), true);
|
|
expect(output()).toContain("stuff");
|
|
});
|
|
});
|
|
|
|
describe("formatClaudeStreamLine", () => {
|
|
it("returns null for empty/blank lines", () => {
|
|
expect(formatClaudeStreamLine("")).toBeNull();
|
|
expect(formatClaudeStreamLine(" ")).toBeNull();
|
|
});
|
|
|
|
it("returns raw text for non-JSON lines (adapter status messages pass through)", () => {
|
|
expect(formatClaudeStreamLine("[paperclip] Pod running: pod-abc")).toBe("[paperclip] Pod running: pod-abc");
|
|
expect(formatClaudeStreamLine("Error: disk full")).toBe("Error: disk full");
|
|
});
|
|
|
|
it("formats system/init event", () => {
|
|
const result = formatClaudeStreamLine(JSON.stringify({
|
|
type: "system", subtype: "init", model: "claude-opus-4-7", session_id: "sess_abc",
|
|
}));
|
|
expect(result).toContain("Claude initialized");
|
|
expect(result).toContain("claude-opus-4-7");
|
|
expect(result).toContain("sess_abc");
|
|
expect(result).not.toContain("{");
|
|
});
|
|
|
|
it("formats assistant text block", () => {
|
|
const result = formatClaudeStreamLine(JSON.stringify({
|
|
type: "assistant",
|
|
message: { content: [{ type: "text", text: "Hello world" }] },
|
|
}));
|
|
expect(result).toBe("assistant: Hello world");
|
|
});
|
|
|
|
it("formats assistant thinking block", () => {
|
|
const result = formatClaudeStreamLine(JSON.stringify({
|
|
type: "assistant",
|
|
message: { content: [{ type: "thinking", thinking: "Let me think..." }] },
|
|
}));
|
|
expect(result).toBe("thinking: Let me think...");
|
|
});
|
|
|
|
it("formats assistant tool_use block", () => {
|
|
const result = formatClaudeStreamLine(JSON.stringify({
|
|
type: "assistant",
|
|
message: { content: [{ type: "tool_use", name: "Bash", input: { command: "ls" } }] },
|
|
}));
|
|
expect(result).toContain("tool_call: Bash");
|
|
expect(result).toContain("ls");
|
|
});
|
|
|
|
it("returns null for assistant with no printable content (thinking-only with empty text)", () => {
|
|
const result = formatClaudeStreamLine(JSON.stringify({
|
|
type: "assistant",
|
|
message: { content: [{ type: "thinking", thinking: "" }] },
|
|
}));
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("formats user tool_result", () => {
|
|
const result = formatClaudeStreamLine(JSON.stringify({
|
|
type: "user",
|
|
message: { content: [{ type: "tool_result", content: "file1.txt\nfile2.txt" }] },
|
|
}));
|
|
expect(result).toContain("tool_result");
|
|
expect(result).toContain("file1.txt");
|
|
});
|
|
|
|
it("formats user tool_result error", () => {
|
|
const result = formatClaudeStreamLine(JSON.stringify({
|
|
type: "user",
|
|
message: { content: [{ type: "tool_result", is_error: true, content: "Permission denied" }] },
|
|
}));
|
|
expect(result).toContain("tool_result (error)");
|
|
expect(result).toContain("Permission denied");
|
|
});
|
|
|
|
it("formats result event with tokens and cost", () => {
|
|
const result = formatClaudeStreamLine(JSON.stringify({
|
|
type: "result", result: "Done", subtype: "stop", total_cost_usd: 0.005,
|
|
usage: { input_tokens: 100, output_tokens: 200, cache_read_input_tokens: 50 },
|
|
}));
|
|
expect(result).toContain("result:");
|
|
expect(result).toContain("Done");
|
|
expect(result).toContain("in=100");
|
|
expect(result).toContain("out=200");
|
|
expect(result).toContain("cached=50");
|
|
});
|
|
|
|
it("formats rate_limit_event (FAR-32 repro)", () => {
|
|
const result = formatClaudeStreamLine(JSON.stringify({
|
|
type: "rate_limit_event",
|
|
rate_limit_info: { status: "allowed", resetsAt: 1777056000, rateLimitType: "five_hour" },
|
|
}));
|
|
expect(result).toContain("rate_limit:");
|
|
expect(result).toContain("five_hour");
|
|
expect(result).toContain("allowed");
|
|
expect(result).not.toContain("{");
|
|
});
|
|
|
|
it("returns null for unknown event types", () => {
|
|
expect(formatClaudeStreamLine(JSON.stringify({ type: "unknown_event", data: "x" }))).toBeNull();
|
|
});
|
|
});
|