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 <noreply@anthropic.com>
This commit is contained in:
Jannes StubbemannandClaude Fable 5 committed 2026-07-04 17:57:02 +02:00
1 parent a328ec953a
commit cdacb6b5bd
3 files changed
+138 -1

No files matched your search

+2 -1
View File
@@ -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,
};
@@ -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"]);
});
});
+64
View File
@@ -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<string, unknown>;
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 }];
}