From cdacb6b5bdca3f313cbced05c893750b797961ee Mon Sep 17 00:00:00 2001 From: Jannes Stubbemann Date: Thu, 2 Jul 2026 10:26:08 +0200 Subject: [PATCH] feat(ui): render OpenCode process-adapter runs with the rich transcript Runs executed through the generic process adapter (adapterType "process") whose command ultimately execs an OpenCode harness emit OpenCode JSONL on stdout. The process transcript parser was a pass-through that wrapped every line as raw stdout, so the Run page nice view rendered identically to the raw view: both dumped raw JSONL. The process parser now content-detects OpenCode-shaped JSONL (a JSON object whose type is one of text|reasoning|tool_use|step_start|step_finish|error, carrying a sessionID string or a part payload) and, once detected, locks on and delegates every subsequent line to the existing OpenCode parser. This gives such runs the rich transcript (messages, tool cards, thinking lane) that already exists, while leading plain wrapper lines and genuinely non-JSONL process output stay raw stdout. Co-Authored-By: Claude Fable 5 --- ui/src/adapters/process/index.ts | 3 +- ui/src/adapters/process/parse-stdout.test.ts | 72 ++++++++++++++++++++ ui/src/adapters/process/parse-stdout.ts | 64 +++++++++++++++++ 3 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 ui/src/adapters/process/parse-stdout.test.ts diff --git a/ui/src/adapters/process/index.ts b/ui/src/adapters/process/index.ts index 74e6cb38e1b..e336ef4c347 100644 --- a/ui/src/adapters/process/index.ts +++ b/ui/src/adapters/process/index.ts @@ -1,5 +1,5 @@ import type { UIAdapterModule } from "../types"; -import { parseProcessStdoutLine } from "./parse-stdout"; +import { parseProcessStdoutLine, createProcessStdoutParser } from "./parse-stdout"; import { ProcessConfigFields } from "./config-fields"; import { buildProcessConfig } from "./build-config"; @@ -7,6 +7,7 @@ export const processUIAdapter: UIAdapterModule = { type: "process", label: "Shell Process", parseStdoutLine: parseProcessStdoutLine, + createStdoutParser: createProcessStdoutParser, ConfigFields: ProcessConfigFields, buildAdapterConfig: buildProcessConfig, }; diff --git a/ui/src/adapters/process/parse-stdout.test.ts b/ui/src/adapters/process/parse-stdout.test.ts new file mode 100644 index 00000000000..eed3ab45b05 --- /dev/null +++ b/ui/src/adapters/process/parse-stdout.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { processUIAdapter } from "./index"; +import { buildTranscript, type RunLogChunk } from "../transcript"; + +const ts = "2026-06-23T12:00:00.000Z"; + +// OpenCode runs executed through the generic process adapter (adapterType +// "process", e.g. a wrapper script that ultimately execs an OpenCode harness) +// emit OpenCode JSONL on stdout, usually after a couple of plain bootstrap +// lines. The parser must keep those plain lines as raw stdout, then detect the +// OpenCode JSONL and render the rich "nice view" (messages, tool cards, +// thinking) rather than dumping raw JSON. +function mixedRun(): RunLogChunk[] { + const lines = [ + "Syncing skills ...", + "Starting agent ...", + JSON.stringify({ type: "step_start", sessionID: "ses_x", part: { type: "step-start" } }), + JSON.stringify({ type: "text", sessionID: "ses_x", part: { type: "text", text: "I need to hire a CTO." } }), + JSON.stringify({ + type: "tool_use", + part: { + type: "tool", + tool: "bash", + callID: "tool-1", + state: { status: "completed", input: { command: "ls -la" }, output: "total 0" }, + }, + }), + JSON.stringify({ + type: "step_finish", + part: { reason: "tool-calls", tokens: { input: 9338, output: 59, reasoning: 559 } }, + }), + ]; + return [{ ts, stream: "stdout", chunk: lines.join("\n") + "\n" }]; +} + +describe("process adapter transcript parsing (OpenCode JSONL detection)", () => { + it("keeps leading plain wrapper lines as raw stdout", () => { + const entries = buildTranscript(mixedRun(), processUIAdapter); + const stdout = entries.filter((e) => e.kind === "stdout").map((e) => (e as { text: string }).text); + expect(stdout).toEqual(["Syncing skills ...", "Starting agent ..."]); + }); + + it("renders OpenCode JSONL as rich transcript entries once the stream begins", () => { + const entries = buildTranscript(mixedRun(), processUIAdapter); + + const assistantTexts = entries.filter((e) => e.kind === "assistant").map((e) => (e as { text: string }).text); + expect(assistantTexts).toEqual(["I need to hire a CTO."]); + + const toolCalls = entries.filter((e) => e.kind === "tool_call"); + expect(toolCalls).toHaveLength(1); + expect((toolCalls[0] as { name: string }).name).toBe("bash"); + + const toolResults = entries.filter((e) => e.kind === "tool_result"); + expect(toolResults).toHaveLength(1); + expect((toolResults[0] as { content: string }).content).toContain("total 0"); + + expect(entries.some((e) => e.kind === "result")).toBe(true); + + // The raw JSONL must NOT leak through as stdout blocks anymore. + const stdoutTexts = entries.filter((e) => e.kind === "stdout").map((e) => (e as { text: string }).text); + expect(stdoutTexts.some((t) => t.includes('"type"'))).toBe(false); + }); + + it("leaves genuinely non-JSONL process output as raw stdout", () => { + const chunks: RunLogChunk[] = [ + { ts, stream: "stdout", chunk: "plain line one\nplain line two\n" }, + ]; + const entries = buildTranscript(chunks, processUIAdapter); + expect(entries.every((e) => e.kind === "stdout")).toBe(true); + expect(entries.map((e) => (e as { text: string }).text)).toEqual(["plain line one", "plain line two"]); + }); +}); diff --git a/ui/src/adapters/process/parse-stdout.ts b/ui/src/adapters/process/parse-stdout.ts index 22e564c1187..8b5f3282bad 100644 --- a/ui/src/adapters/process/parse-stdout.ts +++ b/ui/src/adapters/process/parse-stdout.ts @@ -1,5 +1,69 @@ +import { parseOpenCodeStdoutLine } from "@paperclipai/adapter-opencode-local/ui"; import type { TranscriptEntry } from "../types"; +// Event `type` values emitted by the OpenCode JSONL stream. OpenCode runs +// executed through the generic process adapter (adapterType "process", e.g. a +// wrapper script that ultimately execs an OpenCode harness) still emit this +// exact shape on stdout, so we detect it and reuse the existing rich renderer +// instead of dumping raw JSON. +const OPENCODE_EVENT_TYPES = new Set([ + "text", + "reasoning", + "tool_use", + "step_start", + "step_finish", + "error", +]); + +function isOpenCodeJsonlLine(line: string): boolean { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return false; + } + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return false; + const record = parsed as Record; + if (typeof record.type !== "string" || !OPENCODE_EVENT_TYPES.has(record.type)) return false; + // Guard against arbitrary JSON that merely happens to carry a matching `type`: + // real OpenCode events always carry a `sessionID` string or a `part` payload. + return typeof record.sessionID === "string" || record.part !== undefined; +} + +/** + * Stateful process-stdout parser. + * + * A process command typically prints a few plain bootstrap lines (e.g. + * "Syncing skills ...") before the harness's structured JSONL stream + * begins. Once we detect an OpenCode-shaped JSONL line we lock on and + * delegate every subsequent line to the OpenCode parser — this avoids + * flip-flopping on later events (e.g. `error`) that lack the detection markers, + * while genuinely non-JSONL runs fall back to raw stdout for the whole run. + */ +export function createProcessStdoutParser() { + let lockedOnOpenCode = false; + return { + parseLine(line: string, ts: string): TranscriptEntry[] { + if (!lockedOnOpenCode && isOpenCodeJsonlLine(line)) { + lockedOnOpenCode = true; + } + if (lockedOnOpenCode) { + return parseOpenCodeStdoutLine(line, ts); + } + return [{ kind: "stdout", ts, text: line }]; + }, + reset() { + lockedOnOpenCode = false; + }, + }; +} + +// Stateless fallback for callers that haven't migrated to the stateful factory. +// Without lock-on state, only lines that individually look like OpenCode JSONL +// are parsed richly; prefer createProcessStdoutParser for live transcripts. export function parseProcessStdoutLine(line: string, ts: string): TranscriptEntry[] { + if (isOpenCodeJsonlLine(line)) { + return parseOpenCodeStdoutLine(line, ts); + } return [{ kind: "stdout", ts, text: line }]; }