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",
|
"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",
|
"description": "Paperclip adapter plugin that runs OpenCode agents as Kubernetes Jobs",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
+66
-10
@@ -4,6 +4,9 @@ type JsonEvent = {
|
|||||||
type: string;
|
type: string;
|
||||||
part?: Record<string, unknown>;
|
part?: Record<string, unknown>;
|
||||||
sessionID?: string;
|
sessionID?: string;
|
||||||
|
error?: unknown;
|
||||||
|
message?: unknown;
|
||||||
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
function safeJsonParse(text: string): JsonEvent | null {
|
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.
|
* 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[] {
|
export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
@@ -168,7 +172,6 @@ export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|||||||
|
|
||||||
const event = safeJsonParse(trimmed);
|
const event = safeJsonParse(trimmed);
|
||||||
if (!event) {
|
if (!event) {
|
||||||
// Non-JSON — treat as raw text
|
|
||||||
return [{ kind: "stdout", ts, text: trimmed }];
|
return [{ kind: "stdout", ts, text: trimmed }];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,24 +179,77 @@ export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|||||||
const part = asRecord(event.part ?? {});
|
const part = asRecord(event.part ?? {});
|
||||||
|
|
||||||
if (type === "text") {
|
if (type === "text") {
|
||||||
const text = asString(part.text, "").trim();
|
const text = asString(part.text, "");
|
||||||
if (text) return [{ kind: "stdout", ts, text }];
|
if (text) return [{ kind: "assistant", ts, text, delta: true }];
|
||||||
return [];
|
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") {
|
if (type === "step_finish") {
|
||||||
const text = asString(part.message, "").trim();
|
const message = asString(part.message, "").trim();
|
||||||
if (text) return [{ kind: "stdout", ts, text }];
|
const reason = asString(part.reason, "");
|
||||||
return [];
|
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") {
|
||||||
if (type === "step_start" || type === "tool_use") {
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "error") {
|
if (type === "error") {
|
||||||
const text = errorText(event).trim();
|
const text = errorText(event.error ?? event.message ?? event).trim();
|
||||||
if (text) return [{ kind: "stderr", ts, text }];
|
if (text) return [{ kind: "stderr", ts, text }];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-13
@@ -1,11 +1,20 @@
|
|||||||
/**
|
/**
|
||||||
* Self-contained stdout parser for OpenCode JSONL output.
|
* Self-contained stdout parser for OpenCode JSONL output.
|
||||||
* Zero external imports — required by the Paperclip adapter plugin UI parser contract.
|
* 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 =
|
type TranscriptEntry =
|
||||||
| { kind: "stdout"; ts: string; text: string }
|
| { kind: "assistant"; ts: string; text: string; delta?: boolean }
|
||||||
| { kind: "stderr"; ts: string; text: string };
|
| { 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> {
|
function asRecord(value: unknown): Record<string, unknown> {
|
||||||
if (value && typeof value === "object") {
|
if (value && typeof value === "object") {
|
||||||
@@ -19,6 +28,11 @@ function asString(value: unknown, fallback: string): string {
|
|||||||
return fallback;
|
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 {
|
function safeJsonParse(text: string): Record<string, unknown> | null {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(text);
|
const parsed = JSON.parse(text);
|
||||||
@@ -60,32 +74,109 @@ export function parseStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
|||||||
|
|
||||||
const event = safeJsonParse(trimmed);
|
const event = safeJsonParse(trimmed);
|
||||||
if (!event) {
|
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 }];
|
return [{ kind: "stdout", ts, text: trimmed }];
|
||||||
}
|
}
|
||||||
|
|
||||||
const type = asString(event.type, "");
|
const type = asString(event.type, "");
|
||||||
const part = asRecord(event.part ?? {});
|
const part = asRecord(event.part ?? {});
|
||||||
|
|
||||||
|
// Assistant text fragments — render as assistant chat bubbles
|
||||||
if (type === "text") {
|
if (type === "text") {
|
||||||
const text = asString(part.text, "").trim();
|
const text = asString(part.text, "");
|
||||||
if (text) return [{ kind: "stdout", ts, text }];
|
if (text) return [{ kind: "assistant", ts, text, delta: true }];
|
||||||
return [];
|
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") {
|
if (type === "step_finish") {
|
||||||
const text = asString(part.message, "").trim();
|
const message = asString(part.message, "").trim();
|
||||||
if (text) return [{ kind: "stdout", ts, text }];
|
const reason = asString(part.reason, "");
|
||||||
return [];
|
const tokens = asRecord(part.tokens ?? {});
|
||||||
}
|
const cache = asRecord(tokens.cache ?? {});
|
||||||
|
const inputTokens = asNumber(tokens.input, 0);
|
||||||
// Skip non-display events (step_start, tool_use in normal mode)
|
const outputTokens = asNumber(tokens.output, 0) + asNumber(tokens.reasoning, 0);
|
||||||
if (type === "step_start" || type === "tool_use") {
|
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 [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Error events
|
||||||
if (type === "error") {
|
if (type === "error") {
|
||||||
const text = errorText(event).trim();
|
const text = errorText(event.error ?? event.message ?? event).trim();
|
||||||
if (text) return [{ kind: "stderr", ts, text }];
|
if (text) return [{ kind: "stderr", ts, text }];
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user