Use rich TranscriptEntry kinds in UI parser for pretty output

The UI parser was mapping all OpenCode JSONL events to plain stdout/stderr
transcript entries, causing the Paperclip UI to render everything as raw
terminal output. Updated parseStdoutLine to use structured kinds:

- text events → "assistant" (renders as chat bubbles)
- tool_use events → "tool_call" / "tool_result" (shows tool invocations)
- step_finish events → "result" (shows token/cost metrics)
- step_start events → "system" (shows step transitions)
- assistant events → "assistant" (nested content blocks)

Fixes FAR-43.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
2026-04-12 15:15:19 +00:00
parent faa5f09984
commit 241da1d4f9
3 changed files with 171 additions and 24 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@farhoodliquor/paperclip-adapter-opencode-k8s",
"version": "0.1.11",
"version": "0.1.12",
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
"license": "MIT",
"type": "module",
+66 -10
View File
@@ -4,6 +4,9 @@ type JsonEvent = {
type: string;
part?: Record<string, unknown>;
sessionID?: string;
error?: unknown;
message?: unknown;
[key: string]: unknown;
};
function safeJsonParse(text: string): JsonEvent | null {
@@ -160,7 +163,8 @@ export function formatEvent(line: string, debug: boolean): string {
/**
* Parse a single stdout line into transcript entries for UI display.
* This is the Paperclip UI parser contract.
* This is the Paperclip UI parser contract — uses rich TranscriptEntry kinds
* so the UI renders structured assistant messages, tool calls, and results.
*/
export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
const trimmed = line.trim();
@@ -168,7 +172,6 @@ export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
const event = safeJsonParse(trimmed);
if (!event) {
// Non-JSON — treat as raw text
return [{ kind: "stdout", ts, text: trimmed }];
}
@@ -176,24 +179,77 @@ export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
const part = asRecord(event.part ?? {});
if (type === "text") {
const text = asString(part.text, "").trim();
if (text) return [{ kind: "stdout", ts, text }];
const text = asString(part.text, "");
if (text) return [{ kind: "assistant", ts, text, delta: true }];
return [];
}
if (type === "tool_use") {
const toolName = asString(part.tool, "") || asString(part.type, "tool");
const state = asRecord(part.state ?? {});
const status = asString(state.status, "");
const toolUseId = asString(part.id ?? part.toolUseId ?? "", "") || toolName;
const description = asString(state.description, "").trim();
if (status === "error") {
const err = asString(state.error, "").trim();
return [{ kind: "tool_result", ts, toolUseId, toolName, content: err || "Tool error", isError: true }];
}
if (status === "completed" || status === "done") {
const output = asString(state.output, "").trim();
return [{ kind: "tool_result", ts, toolUseId, toolName, content: output || description || "Done", isError: false }];
}
return [{ kind: "tool_call", ts, name: toolName, input: description || undefined, toolUseId }];
}
if (type === "step_finish") {
const text = asString(part.message, "").trim();
if (text) return [{ kind: "stdout", ts, text }];
return [];
const message = asString(part.message, "").trim();
const reason = asString(part.reason, "");
const tokens = asRecord(part.tokens ?? {});
const cache = asRecord(tokens.cache ?? {});
const inputTokens = asNumber(tokens.input, 0);
const outputTokens = asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
const cachedTokens = asNumber(cache.read, 0);
const costUsd = asNumber(part.cost, 0);
return [{
kind: "result",
ts,
text: message || `Step finished: ${reason || "done"}`,
inputTokens,
outputTokens,
cachedTokens,
costUsd,
subtype: reason || "step_finish",
isError: false,
errors: [],
}];
}
// Skip non-display events (step_start, tool_use in normal mode)
if (type === "step_start" || type === "tool_use") {
if (type === "step_start") {
return [{ kind: "system", ts, text: "Starting step…" }];
}
if (type === "assistant") {
const content = part.message ?? part;
const contentRecord = asRecord(content);
if (contentRecord.content) {
const contentArr = Array.isArray(contentRecord.content)
? contentRecord.content
: [contentRecord.content];
for (const item of contentArr) {
const itemRecord = asRecord(item);
if (itemRecord.type === "text" && typeof itemRecord.text === "string") {
const text = (itemRecord.text as string).trim();
if (text) return [{ kind: "assistant", ts, text }];
}
}
}
return [];
}
if (type === "error") {
const text = errorText(event).trim();
const text = errorText(event.error ?? event.message ?? event).trim();
if (text) return [{ kind: "stderr", ts, text }];
return [];
}
+104 -13
View File
@@ -1,11 +1,20 @@
/**
* Self-contained stdout parser for OpenCode JSONL output.
* Zero external imports — required by the Paperclip adapter plugin UI parser contract.
*
* Maps OpenCode event types to rich TranscriptEntry kinds so the Paperclip UI
* renders structured assistant messages, tool calls, results, etc. instead of
* plain stdout text.
*/
type TranscriptEntry =
| { kind: "stdout"; ts: string; text: string }
| { kind: "stderr"; ts: string; text: string };
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
| { kind: "tool_call"; ts: string; name: string; input: unknown; toolUseId?: string }
| { kind: "tool_result"; ts: string; toolUseId: string; toolName?: string; content: string; isError: boolean }
| { kind: "result"; ts: string; text: string; inputTokens: number; outputTokens: number; cachedTokens: number; costUsd: number; subtype: string; isError: boolean; errors: string[] }
| { kind: "system"; ts: string; text: string }
| { kind: "stderr"; ts: string; text: string }
| { kind: "stdout"; ts: string; text: string };
function asRecord(value: unknown): Record<string, unknown> {
if (value && typeof value === "object") {
@@ -19,6 +28,11 @@ function asString(value: unknown, fallback: string): string {
return fallback;
}
function asNumber(value: unknown, fallback: number): number {
if (typeof value === "number") return value;
return fallback;
}
function safeJsonParse(text: string): Record<string, unknown> | null {
try {
const parsed = JSON.parse(text);
@@ -60,32 +74,109 @@ export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
const event = safeJsonParse(trimmed);
if (!event) {
// Non-JSON — treat as raw text
// Non-JSON — treat as raw stdout (e.g. K8s pod lifecycle messages)
return [{ kind: "stdout", ts, text: trimmed }];
}
const type = asString(event.type, "");
const part = asRecord(event.part ?? {});
// Assistant text fragments — render as assistant chat bubbles
if (type === "text") {
const text = asString(part.text, "").trim();
if (text) return [{ kind: "stdout", ts, text }];
const text = asString(part.text, "");
if (text) return [{ kind: "assistant", ts, text, delta: true }];
return [];
}
// Tool use events — map to tool_call / tool_result depending on status
if (type === "tool_use") {
const toolName = asString(part.tool, "") || asString(part.type, "tool");
const state = asRecord(part.state ?? {});
const status = asString(state.status, "");
const toolUseId = asString(part.id ?? part.toolUseId ?? "", "") || toolName;
const description = asString(state.description, "").trim();
if (status === "error") {
const err = asString(state.error, "").trim();
return [{
kind: "tool_result",
ts,
toolUseId,
toolName,
content: err || "Tool error",
isError: true,
}];
}
if (status === "completed" || status === "done") {
const output = asString(state.output, "").trim();
return [{
kind: "tool_result",
ts,
toolUseId,
toolName,
content: output || description || "Done",
isError: false,
}];
}
// pending / running / other — show as a tool call invocation
const input = description || undefined;
return [{ kind: "tool_call", ts, name: toolName, input, toolUseId }];
}
// Step finish — render as a structured result with token/cost metrics
if (type === "step_finish") {
const text = asString(part.message, "").trim();
if (text) return [{ kind: "stdout", ts, text }];
return [];
}
// Skip non-display events (step_start, tool_use in normal mode)
if (type === "step_start" || type === "tool_use") {
const message = asString(part.message, "").trim();
const reason = asString(part.reason, "");
const tokens = asRecord(part.tokens ?? {});
const cache = asRecord(tokens.cache ?? {});
const inputTokens = asNumber(tokens.input, 0);
const outputTokens = asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
const cachedTokens = asNumber(cache.read, 0);
const costUsd = asNumber(part.cost, 0);
return [{
kind: "result",
ts,
text: message || `Step finished: ${reason || "done"}`,
inputTokens,
outputTokens,
cachedTokens,
costUsd,
subtype: reason || "step_finish",
isError: false,
errors: [],
}];
}
// Step start — render as system message
if (type === "step_start") {
return [{ kind: "system", ts, text: "Starting step…" }];
}
// Assistant message (nested content blocks)
if (type === "assistant") {
const content = part.message ?? part;
const contentRecord = asRecord(content);
if (contentRecord.content) {
const contentArr = Array.isArray(contentRecord.content)
? contentRecord.content
: [contentRecord.content];
for (const item of contentArr) {
const itemRecord = asRecord(item);
if (itemRecord.type === "text" && typeof itemRecord.text === "string") {
const text = (itemRecord.text as string).trim();
if (text) return [{ kind: "assistant", ts, text }];
}
}
}
return [];
}
// Error events
if (type === "error") {
const text = errorText(event).trim();
const text = errorText(event.error ?? event.message ?? event).trim();
if (text) return [{ kind: "stderr", ts, text }];
return [];
}