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 }]; }