241da1d4f9
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>
186 lines
5.8 KiB
TypeScript
186 lines
5.8 KiB
TypeScript
/**
|
|
* 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: "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") {
|
|
return value as Record<string, unknown>;
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function asString(value: unknown, fallback: string): string {
|
|
if (typeof value === "string") return value;
|
|
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);
|
|
if (parsed && typeof parsed === "object") {
|
|
return parsed as Record<string, unknown>;
|
|
}
|
|
return null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function errorText(value: unknown): string {
|
|
if (typeof value === "string") return value;
|
|
const rec = asRecord(value);
|
|
const message = asString(rec.message, "").trim();
|
|
if (message) return message;
|
|
const data = asRecord(rec.data);
|
|
const nestedMessage = asString(data.message, "").trim();
|
|
if (nestedMessage) return nestedMessage;
|
|
const name = asString(rec.name, "").trim();
|
|
if (name) return name;
|
|
const code = asString(rec.code, "").trim();
|
|
if (code) return code;
|
|
try {
|
|
return JSON.stringify(rec);
|
|
} catch {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse a single stdout line into transcript entries for UI display.
|
|
* This is the Paperclip UI parser contract.
|
|
*/
|
|
export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|
const trimmed = line.trim();
|
|
if (!trimmed) return [];
|
|
|
|
const event = safeJsonParse(trimmed);
|
|
if (!event) {
|
|
// 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, "");
|
|
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 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.error ?? event.message ?? event).trim();
|
|
if (text) return [{ kind: "stderr", ts, text }];
|
|
return [];
|
|
}
|
|
|
|
return [];
|
|
}
|