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:
+1
-1
@@ -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
@@ -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
@@ -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 [];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user