From 590cb8570df8d2aa4fcbab8c42839184eedaac50 Mon Sep 17 00:00:00 2001 From: Leon Cheng Date: Fri, 21 Aug 2026 03:13:40 -0400 Subject: [PATCH 1/2] feat(transcript): OpenCode Part -> TranscriptEvent adapter Phase 1 of the build plan: the seam. This is the only file in the client that knows what an OpenCode Part looks like, which is what keeps the rest of the transcript stack backend-neutral. - client/lib/transcript.ts: the frozen contract. Row components consume only these types and must never import SDK types or touch a raw Part. - client/lib/events.ts: the adapter, written against shapes captured from a live 1.18.19 server rather than the published docs (which lag the binary). Shape notes worth keeping: - reasoning.text is plaintext, but reasoning.metadata carries an opaque Anthropic signature -- read text, drop metadata entirely - patch parts are {hash, files[]} references, not diff bodies - file parts are references with an optional source.text range; they fold into the surrounding turn rather than rendering a row - tool.state.running.metadata.output holds partial output mid-call, which is what makes a live-updating tool row possible - a tool call and its result arrive as ONE object, so there is no action/observation pairing and no correlation id -- do not reintroduce a split Also lands detectInterrupted(): OpenCode never persists 'running' state, so a crash mid-turn is invisible unless derived. Detection only, no auto-resume (AGENTS.md #5) -- replaying an interrupted turn can redo destructive work. toolDetail() allowlists small scalar arguments instead of stringifying the input object, which is how the predecessor ended up rendering whole file bodies into tool chips. Validated against 1,133 real messages / 1,592 events across 6 live sessions: zero unhandled part types, zero contract violations. That invariant is now a test, not a one-off script. --- client/lib/events.ts | 370 +++++++++++++++++++++++++++ client/lib/transcript.ts | 165 ++++++++++++ tests/fixtures/session-messages.json | 221 ++++++++++++++++ tests/transcript-adapter.test.ts | 258 +++++++++++++++++++ 4 files changed, 1014 insertions(+) create mode 100644 client/lib/events.ts create mode 100644 client/lib/transcript.ts create mode 100644 tests/fixtures/session-messages.json create mode 100644 tests/transcript-adapter.test.ts diff --git a/client/lib/events.ts b/client/lib/events.ts new file mode 100644 index 00000000..cdf893d1 --- /dev/null +++ b/client/lib/events.ts @@ -0,0 +1,370 @@ +// client/lib/events.ts +// +// The adapter. OpenCode `{ info, parts }` messages in, frozen TranscriptEvent +// out. This is the ONLY file in the client that knows what an OpenCode Part +// looks like; see transcript.ts for why that matters. +// +// Shapes here were captured from a live 1.18.19 server rather than from the +// published docs, which lag the binary badly. Notes on the non-obvious bits: +// +// - `reasoning.text` is plaintext, but `reasoning.metadata.anthropic.signature` +// is an opaque provider artefact. We read text and drop metadata entirely. +// - `patch` parts carry `{ hash, files[] }` — a reference, not a diff body. +// - `file` parts are references with an optional `source.text` range, not +// uploaded attachments. +// - `tool.state.running.metadata.output` holds partial output mid-call, which +// is what makes a live-updating tool row possible. +// - `step-start` / `step-finish` are bookkeeping, not rows. step-finish is +// where cost and tokens live. + +import type { + Attachment, + InterruptedState, + ToolStatus, + Transcript, + TranscriptEvent, + UsageSnapshot, +} from "./transcript.js"; + +// ── Minimal structural types for what we consume ──────────────────────────── +// Intentionally not imported from the SDK: the client bundle should not depend +// on server types, and these are narrower than the generated unions. + +interface RawTime { + start?: number; + end?: number; + created?: number; + completed?: number; +} + +interface RawToolState { + status?: string; + input?: Record; + output?: string; + title?: string; + error?: string; + metadata?: Record; + time?: RawTime; +} + +export interface RawPart { + id?: string; + messageID?: string; + type?: string; + // text + text?: string; + // tool + callID?: string; + tool?: string; + state?: RawToolState; + // reasoning + time?: RawTime; + // file + mime?: string; + filename?: string; + url?: string; + source?: { type?: string; path?: string; text?: { value?: string } }; + // patch + hash?: string; + files?: string[]; + // compaction + auto?: boolean; + // step-finish + reason?: string; + cost?: number; + tokens?: RawTokens; +} + +interface RawTokens { + total?: number; + input?: number; + output?: number; + reasoning?: number; + cache?: { read?: number; write?: number }; +} + +export interface RawMessageInfo { + id?: string; + role?: string; + time?: RawTime; + agent?: string; + cost?: number; + tokens?: RawTokens; + finish?: string; + error?: unknown; +} + +export interface RawMessage { + info?: RawMessageInfo; + parts?: RawPart[]; +} + +// ── Helpers ───────────────────────────────────────────────────────────────── + +function iso(epochMs: number | undefined, fallback: number): string { + return new Date(typeof epochMs === "number" ? epochMs : fallback).toISOString(); +} + +function duration(time: RawTime | undefined): number | undefined { + if (!time || typeof time.start !== "number" || typeof time.end !== "number") return undefined; + const ms = time.end - time.start; + return ms >= 0 ? ms : undefined; +} + +function fileAttachment(part: RawPart): Attachment { + return { + filename: part.filename || part.source?.path?.split("/").pop() || "file", + mime: part.mime, + url: part.url, + path: part.source?.path, + }; +} + +/** + * A short, safe one-liner describing a tool's arguments. + * + * Deliberately conservative: tool inputs are arbitrary and can contain whole + * file bodies (`content`, `new_str`, `patch`…). We allowlist the small scalar + * fields that read well inline and skip everything else, rather than + * stringifying the object and truncating — which is how the predecessor ended + * up rendering giant JSON blobs into chips. + */ +export function toolDetail(input: Record | undefined): string | undefined { + if (!input) return undefined; + const preferred = [ + "command", + "filePath", + "path", + "pattern", + "query", + "url", + "description", + "subagent_type", + ]; + for (const key of preferred) { + const value = input[key]; + if (typeof value === "string" && value.trim()) { + const flat = value.replace(/\s+/g, " ").trim(); + return flat.length > 160 ? `${flat.slice(0, 159)}…` : flat; + } + } + return undefined; +} + +function toolStatus(raw: string | undefined): ToolStatus { + switch (raw) { + case "pending": + case "running": + case "completed": + case "error": + return raw; + default: + // Unknown states are treated as in-flight rather than dropped: the event + // union has grown before and will again. + return "running"; + } +} + +// ── Part → event ──────────────────────────────────────────────────────────── + +function normalizePart( + part: RawPart, + info: RawMessageInfo, + index: number, +): TranscriptEvent | null { + const messageId = part.messageID || info.id || "unknown"; + const id = part.id || `${messageId}:${index}`; + const created = info.time?.created ?? Date.now(); + const isUser = info.role === "user"; + + switch (part.type) { + case "text": { + const text = part.text?.trim(); + if (!text) return null; + return isUser + ? { kind: "user", id, messageId, timestamp: iso(created, created), text, attachments: [] } + : { kind: "agent", id, messageId, timestamp: iso(created, created), text }; + } + + case "reasoning": { + const text = part.text?.trim(); + // Encrypted-only reasoning yields no text; an empty Thought row is noise. + // NB: part.metadata (Anthropic signature) is intentionally not read. + if (!text) return null; + return { + kind: "thought", + id, + messageId, + timestamp: iso(part.time?.start, created), + text, + durationMs: duration(part.time), + }; + } + + case "tool": { + const state = part.state || {}; + const status = toolStatus(state.status); + return { + kind: "tool", + id, + messageId, + timestamp: iso(state.time?.start, created), + status, + name: part.tool || "tool", + title: state.title, + detail: toolDetail(state.input), + // While running, partial output hides in state.metadata.output. + output: + state.output ?? + (typeof state.metadata?.output === "string" ? state.metadata.output : undefined), + error: state.error, + durationMs: duration(state.time), + attachments: [], + }; + } + + case "file": { + // A file reference attaches to the surrounding turn rather than + // rendering its own row; callers fold these into the adjacent event. + return null; + } + + case "patch": { + const files = part.files ?? []; + return { + kind: "status", + id, + messageId, + timestamp: iso(created, created), + label: files.length === 1 ? "Edited 1 file" : `Edited ${files.length} files`, + detail: files.length ? files.join(", ") : undefined, + }; + } + + case "compaction": { + return { + kind: "status", + id, + messageId, + timestamp: iso(created, created), + label: part.auto ? "Context compacted automatically" : "Context compacted", + }; + } + + // Bookkeeping, never rendered as rows. + case "step-start": + case "step-finish": + case "snapshot": + return null; + + default: + // Forward compatibility: the Part union grows between releases and an + // unknown type must never break the transcript. + return null; + } +} + +function usageFrom(part: RawPart, messageId: string): UsageSnapshot | null { + if (part.type !== "step-finish") return null; + const tokens = part.tokens || {}; + return { + messageId, + cost: part.cost ?? 0, + tokens: { + input: tokens.input ?? 0, + output: tokens.output ?? 0, + reasoning: tokens.reasoning ?? 0, + cacheRead: tokens.cache?.read ?? 0, + cacheWrite: tokens.cache?.write ?? 0, + total: tokens.total, + }, + }; +} + +// ── Public API ────────────────────────────────────────────────────────────── + +/** + * Detect a run that died without finishing. + * + * `isRunning` must come from `GET /session/status`, which only knows about + * sessions owned by the *current* server process — that is precisely what + * distinguishes "still working" from "orphaned by a crash". + * + * A deliberate user abort produces an identical signature, so callers should + * describe the state ("this run did not finish") rather than diagnose a cause. + */ +export function detectInterrupted( + messages: RawMessage[], + isRunning: boolean, +): InterruptedState { + if (isRunning || messages.length === 0) return { interrupted: false }; + + const last = messages[messages.length - 1]?.info; + if (!last) return { interrupted: false }; + + if (last.role === "user") return { interrupted: true, reason: "never-answered" }; + if (last.role === "assistant" && typeof last.time?.completed !== "number") { + return { interrupted: true, reason: "incomplete-turn" }; + } + return { interrupted: false }; +} + +/** Map one message's parts, folding file references into the turn. */ +export function normalizeMessage(message: RawMessage): TranscriptEvent[] { + const info = message.info || {}; + const parts = message.parts || []; + const events: TranscriptEvent[] = []; + const attachments = parts.filter((p) => p.type === "file").map(fileAttachment); + + parts.forEach((part, index) => { + const event = normalizePart(part, info, index); + if (event) events.push(event); + }); + + if (attachments.length) { + // Attach to the first event that can hold files, so a user prompt with a + // referenced file renders them together. + const target = events.find((e) => e.kind === "user" || e.kind === "tool"); + if (target && "attachments" in target) target.attachments = attachments; + } + + // A turn that errored with no parts still needs to say so. + if (!events.length && info.error) { + events.push({ + kind: "error", + id: `${info.id ?? "unknown"}:error`, + messageId: info.id ?? "unknown", + timestamp: iso(info.time?.created, Date.now()), + message: + typeof info.error === "string" + ? info.error + : ((info.error as { message?: string })?.message ?? "The agent turn failed."), + }); + } + + return events; +} + +/** Map a full transcript fetch. */ +export function normalizeTranscript( + messages: RawMessage[], + options: { isRunning?: boolean } = {}, +): Transcript { + const events: TranscriptEvent[] = []; + const usage: UsageSnapshot[] = []; + + for (const message of messages) { + events.push(...normalizeMessage(message)); + const messageId = message.info?.id ?? "unknown"; + for (const part of message.parts || []) { + const snapshot = usageFrom(part, messageId); + if (snapshot) usage.push(snapshot); + } + } + + return { + events, + usage, + interrupted: detectInterrupted(messages, options.isRunning ?? false), + }; +} diff --git a/client/lib/transcript.ts b/client/lib/transcript.ts new file mode 100644 index 00000000..220b2456 --- /dev/null +++ b/client/lib/transcript.ts @@ -0,0 +1,165 @@ +// client/lib/transcript.ts +// +// THE FROZEN CONTRACT. +// +// Everything the transcript UI renders is one of these. No React component may +// import OpenCode SDK types or touch a raw `Part` — the entire mapping lives in +// events.ts, and this file is the wall between them. +// +// This is not ceremony. The predecessor (custom-dca-ide-with-openhands) kept +// exactly this seam, and it is the single reason migrating from the OpenHands +// agent-server to OpenCode was a ~360-line adapter rewrite instead of a rebuild: +// ~74% of the transcript stack never knew the backend had changed. Keep it. +// +// Rules: +// - Add a field here only if a row component actually renders it. +// - Never leak provider-specific shapes (Anthropic signatures, encrypted +// reasoning blobs, raw tool metadata) into this layer. +// - Every event carries a stable `id` and an ISO `timestamp` so merge, +// grouping and scroll anchoring work without backend knowledge. + +/** Discriminator for the row a transcript entry renders as. */ +export type TranscriptKind = "user" | "agent" | "thought" | "tool" | "status" | "error"; + +interface TranscriptBase { + /** Stable across refetches. Used for React keys, dedupe and scroll anchors. */ + id: string; + /** ISO 8601. Derived from the backend's epoch millis. */ + timestamp: string; + kind: TranscriptKind; + /** Owning message — lets the UI group consecutive parts of one turn. */ + messageId: string; +} + +/** A prompt from the human. */ +export interface UserEvent extends TranscriptBase { + kind: "user"; + text: string; + /** Files the user referenced or attached, if any. */ + attachments: Attachment[]; +} + +/** Assistant prose. */ +export interface AgentEvent extends TranscriptBase { + kind: "agent"; + text: string; +} + +/** + * Model reasoning. Rendered as a collapsible "Thought" row. + * + * Only ever carries readable text. Providers also return opaque artefacts + * alongside it (Anthropic ships a `signature`, OpenAI an `encrypted_content`); + * those are dropped in the adapter and must never reach this type. + */ +export interface ThoughtEvent extends TranscriptBase { + kind: "thought"; + text: string; + /** Milliseconds spent reasoning, when the backend reports both bounds. */ + durationMs?: number; +} + +export type ToolStatus = "pending" | "running" | "completed" | "error"; + +/** + * A tool call and its result as ONE event. + * + * OpenCode returns the call and its output in a single object, so unlike the + * OpenHands runner there is no action/observation pairing step and no + * correlation id to match up. That simplification is load-bearing — do not + * reintroduce a split. + */ +export interface ToolEvent extends TranscriptBase { + kind: "tool"; + status: ToolStatus; + /** Tool name, e.g. "bash", "edit", "task". */ + name: string; + /** Human-readable label from the backend, e.g. a command or file path. */ + title?: string; + /** One-line summary of the arguments, safe to render inline. */ + detail?: string; + /** Tool output. Present when completed; partial while running. */ + output?: string; + /** Error text when `status === "error"`. */ + error?: string; + durationMs?: number; + /** Files this call produced or referenced. */ + attachments: Attachment[]; +} + +/** Lifecycle markers rendered as separators: compaction, retries, snapshots. */ +export interface StatusEvent extends TranscriptBase { + kind: "status"; + label: string; + /** Extra context, e.g. which files a patch touched. */ + detail?: string; +} + +/** A turn-level failure. */ +export interface ErrorEvent extends TranscriptBase { + kind: "error"; + message: string; +} + +export type TranscriptEvent = + | UserEvent + | AgentEvent + | ThoughtEvent + | ToolEvent + | StatusEvent + | ErrorEvent; + +/** A file referenced by a message or produced by a tool. */ +export interface Attachment { + filename: string; + mime?: string; + /** Backend-resolvable location. Not necessarily an http URL. */ + url?: string; + /** Absolute path when the reference points into the workspace. */ + path?: string; +} + +/** + * Token and cost accounting for one completed step. + * + * Kept out of TranscriptEvent because it drives the status bar, not a row. + * The context-window *denominator* is not served with this — it comes from + * the model catalogue (`Model.limit.context`), so the gauge is computed + * client-side rather than read off a field. + */ +export interface UsageSnapshot { + messageId: string; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + cacheRead: number; + cacheWrite: number; + /** Backend-reported total; absent while a turn is still in flight. */ + total?: number; + }; +} + +/** Everything one transcript fetch yields. */ +export interface Transcript { + events: TranscriptEvent[]; + /** Newest-last, matching `events` order. Drives the status bar. */ + usage: UsageSnapshot[]; + /** + * True when the last message is an assistant turn that never completed and + * the session is not currently running anywhere. + * + * OpenCode never persists "running" state (the session table has no status + * column), so a crash mid-turn is invisible unless the UI derives it. We + * surface it and let the human decide — see AGENTS.md decision #5. + */ + interrupted: InterruptedState; +} + +export type InterruptedState = + | { interrupted: false } + /** An assistant turn started and never finished. */ + | { interrupted: true; reason: "incomplete-turn" } + /** A user prompt was never answered at all. */ + | { interrupted: true; reason: "never-answered" }; diff --git a/tests/fixtures/session-messages.json b/tests/fixtures/session-messages.json new file mode 100644 index 00000000..d39207f4 --- /dev/null +++ b/tests/fixtures/session-messages.json @@ -0,0 +1,221 @@ +[ + { + "info": { + "id": "msg_user_001", + "sessionID": "ses_fixture", + "role": "user", + "time": { "created": 1787000000000 }, + "summary": { "diffs": [] }, + "agent": "build", + "model": { "providerID": "anthropic", "modelID": "claude-opus-5" } + }, + "parts": [ + { + "id": "prt_text_001", + "sessionID": "ses_fixture", + "messageID": "msg_user_001", + "type": "text", + "text": "Add a health endpoint to the server." + }, + { + "id": "prt_file_001", + "sessionID": "ses_fixture", + "messageID": "msg_user_001", + "type": "file", + "mime": "text/plain", + "filename": "notes.md", + "url": "file:///workspace/notes.md", + "source": { + "type": "file", + "path": "/workspace/notes.md", + "text": { "value": "context excerpt", "start": 0, "end": 15 } + } + } + ] + }, + { + "info": { + "id": "msg_asst_001", + "sessionID": "ses_fixture", + "role": "assistant", + "time": { "created": 1787000001000, "completed": 1787000009000 }, + "parentID": "msg_user_001", + "modelID": "claude-opus-5", + "providerID": "anthropic", + "mode": "build", + "agent": "build", + "path": { "cwd": "/workspace", "root": "/workspace" }, + "cost": 0.0421, + "tokens": { + "total": 12000, + "input": 100, + "output": 900, + "reasoning": 250, + "cache": { "read": 10000, "write": 750 } + }, + "finish": "stop" + }, + "parts": [ + { + "id": "prt_stepstart_001", + "sessionID": "ses_fixture", + "messageID": "msg_asst_001", + "type": "step-start", + "snapshot": "abc123" + }, + { + "id": "prt_reason_001", + "sessionID": "ses_fixture", + "messageID": "msg_asst_001", + "type": "reasoning", + "text": "The server has no health route yet, so I will add one.", + "metadata": { "anthropic": { "signature": "OPAQUE_SIGNATURE_MUST_NOT_RENDER" } }, + "time": { "start": 1787000001500, "end": 1787000003500 } + }, + { + "id": "prt_reason_002", + "sessionID": "ses_fixture", + "messageID": "msg_asst_001", + "type": "reasoning", + "text": " ", + "metadata": { "anthropic": { "signature": "ENCRYPTED_ONLY_NO_TEXT" } }, + "time": { "start": 1787000003600, "end": 1787000003700 } + }, + { + "id": "prt_tool_001", + "sessionID": "ses_fixture", + "messageID": "msg_asst_001", + "type": "tool", + "callID": "call_001", + "tool": "read", + "state": { + "status": "completed", + "input": { "filePath": "/workspace/server/index.ts" }, + "output": "export const app = express();", + "title": "server/index.ts", + "metadata": { "truncated": false }, + "time": { "start": 1787000004000, "end": 1787000004250 } + }, + "metadata": { "anthropic": { "caller": { "type": "assistant" } } } + }, + { + "id": "prt_text_002", + "sessionID": "ses_fixture", + "messageID": "msg_asst_001", + "type": "text", + "text": "I'll add the route now." + }, + { + "id": "prt_patch_001", + "sessionID": "ses_fixture", + "messageID": "msg_asst_001", + "type": "patch", + "hash": "def456", + "files": ["server/index.ts", "tests/health.test.ts"] + }, + { + "id": "prt_stepfinish_001", + "sessionID": "ses_fixture", + "messageID": "msg_asst_001", + "type": "step-finish", + "reason": "stop", + "snapshot": "abc124", + "cost": 0.0421, + "tokens": { + "total": 12000, + "input": 100, + "output": 900, + "reasoning": 250, + "cache": { "read": 10000, "write": 750 } + } + } + ] + }, + { + "info": { + "id": "msg_asst_002", + "sessionID": "ses_fixture", + "role": "assistant", + "time": { "created": 1787000010000, "completed": 1787000012000 }, + "modelID": "claude-opus-5", + "providerID": "anthropic", + "agent": "build", + "cost": 0.001, + "tokens": { + "total": 500, + "input": 10, + "output": 40, + "reasoning": 0, + "cache": { "read": 400, "write": 50 } + }, + "finish": "stop" + }, + "parts": [ + { + "id": "prt_tool_002", + "sessionID": "ses_fixture", + "messageID": "msg_asst_002", + "type": "tool", + "callID": "call_002", + "tool": "webfetch", + "state": { + "status": "error", + "input": { "url": "https://example.invalid/spec" }, + "error": "getaddrinfo ENOTFOUND example.invalid", + "time": { "start": 1787000010500, "end": 1787000011000 } + }, + "metadata": { "anthropic": { "caller": { "type": "assistant" } } } + }, + { + "id": "prt_compaction_001", + "sessionID": "ses_fixture", + "messageID": "msg_asst_002", + "type": "compaction", + "auto": true + }, + { + "id": "prt_unknown_001", + "sessionID": "ses_fixture", + "messageID": "msg_asst_002", + "type": "some-future-part-type", + "text": "must not break the transcript" + } + ] + }, + { + "info": { + "id": "msg_asst_003", + "sessionID": "ses_fixture", + "role": "assistant", + "time": { "created": 1787000020000 }, + "modelID": "claude-opus-5", + "providerID": "anthropic", + "agent": "build", + "cost": 0, + "tokens": { + "total": 0, + "input": 0, + "output": 0, + "reasoning": 0, + "cache": { "read": 0, "write": 0 } + } + }, + "parts": [ + { + "id": "prt_tool_003", + "sessionID": "ses_fixture", + "messageID": "msg_asst_003", + "type": "tool", + "callID": "call_003", + "tool": "bash", + "state": { + "status": "running", + "input": { "command": "npm test", "timeout": 120000 }, + "metadata": { "output": "partial output so far" }, + "time": { "start": 1787000020500 } + }, + "metadata": { "anthropic": { "caller": { "type": "assistant" } } } + } + ] + } +] diff --git a/tests/transcript-adapter.test.ts b/tests/transcript-adapter.test.ts new file mode 100644 index 00000000..8536baa5 --- /dev/null +++ b/tests/transcript-adapter.test.ts @@ -0,0 +1,258 @@ +import { describe, expect, it } from "vitest"; + +import fixture from "./fixtures/session-messages.json" with { type: "json" }; +import { + detectInterrupted, + normalizeMessage, + normalizeTranscript, + toolDetail, + type RawMessage, +} from "../client/lib/events.js"; +import type { ThoughtEvent, ToolEvent, UserEvent } from "../client/lib/transcript.js"; + +const messages = fixture as RawMessage[]; + +describe("normalizeTranscript", () => { + const { events } = normalizeTranscript(messages); + + it("maps a user text part to a user row", () => { + const user = events.find((e) => e.kind === "user") as UserEvent; + expect(user.text).toBe("Add a health endpoint to the server."); + }); + + it("folds file references into the surrounding turn instead of emitting a row", () => { + expect(events.some((e) => e.id === "prt_file_001")).toBe(false); + const user = events.find((e) => e.kind === "user") as UserEvent; + expect(user.attachments).toEqual([ + { + filename: "notes.md", + mime: "text/plain", + url: "file:///workspace/notes.md", + path: "/workspace/notes.md", + }, + ]); + }); + + it("maps assistant text to an agent row", () => { + const agent = events.filter((e) => e.kind === "agent"); + expect(agent.map((e) => (e as { text: string }).text)).toEqual(["I'll add the route now."]); + }); + + it("emits step-start and step-finish as bookkeeping, never as rows", () => { + expect(events.some((e) => e.id.includes("stepstart"))).toBe(false); + expect(events.some((e) => e.id.includes("stepfinish"))).toBe(false); + }); + + it("tolerates unknown part types rather than throwing or rendering them", () => { + expect(events.some((e) => e.id === "prt_unknown_001")).toBe(false); + expect(events.length).toBeGreaterThan(0); + }); +}); + +describe("reasoning", () => { + const { events } = normalizeTranscript(messages); + const thoughts = events.filter((e) => e.kind === "thought") as ThoughtEvent[]; + + it("keeps readable reasoning and reports its duration", () => { + expect(thoughts).toHaveLength(1); + expect(thoughts[0].text).toBe("The server has no health route yet, so I will add one."); + expect(thoughts[0].durationMs).toBe(2000); + }); + + it("drops encrypted-only reasoning instead of rendering an empty row", () => { + expect(thoughts.some((t) => t.id === "prt_reason_002")).toBe(false); + }); + + // Provider artefacts must never cross the adapter boundary. + it("never carries the Anthropic signature into the contract", () => { + const serialized = JSON.stringify(thoughts); + expect(serialized).not.toContain("OPAQUE_SIGNATURE_MUST_NOT_RENDER"); + expect(serialized).not.toContain("signature"); + }); +}); + +describe("tool events", () => { + const { events } = normalizeTranscript(messages); + const tools = events.filter((e) => e.kind === "tool") as ToolEvent[]; + + it("carries call and result as one event — no action/observation pairing", () => { + const read = tools.find((t) => t.name === "read")!; + expect(read.status).toBe("completed"); + expect(read.detail).toBe("/workspace/server/index.ts"); + expect(read.output).toBe("export const app = express();"); + expect(read.title).toBe("server/index.ts"); + expect(read.durationMs).toBe(250); + }); + + it("surfaces errors with their message", () => { + const failed = tools.find((t) => t.name === "webfetch")!; + expect(failed.status).toBe("error"); + expect(failed.error).toContain("ENOTFOUND"); + }); + + it("shows partial output for a call still running", () => { + const running = tools.find((t) => t.name === "bash")!; + expect(running.status).toBe("running"); + expect(running.output).toBe("partial output so far"); + expect(running.durationMs).toBeUndefined(); + }); +}); + +describe("status rows", () => { + const { events } = normalizeTranscript(messages); + + it("summarises a patch by file count", () => { + const patch = events.find((e) => e.id === "prt_patch_001")!; + expect(patch.kind).toBe("status"); + expect((patch as { label: string }).label).toBe("Edited 2 files"); + }); + + it("marks automatic compaction", () => { + const compaction = events.find((e) => e.id === "prt_compaction_001")!; + expect((compaction as { label: string }).label).toBe("Context compacted automatically"); + }); +}); + +describe("usage", () => { + it("collects one snapshot per step-finish, for the status bar", () => { + const { usage } = normalizeTranscript(messages); + expect(usage).toHaveLength(1); + expect(usage[0]).toEqual({ + messageId: "msg_asst_001", + cost: 0.0421, + tokens: { input: 100, output: 900, reasoning: 250, cacheRead: 10000, cacheWrite: 750, total: 12000 }, + }); + }); +}); + +describe("toolDetail", () => { + it("prefers a command over other fields", () => { + expect(toolDetail({ command: "npm test", timeout: 1 })).toBe("npm test"); + }); + + it("collapses whitespace and truncates long values", () => { + expect(toolDetail({ command: "a\n\n b" })).toBe("a b"); + const long = toolDetail({ command: "x".repeat(300) })!; + expect(long).toHaveLength(160); + expect(long.endsWith("…")).toBe(true); + }); + + // Regression guard: the predecessor rendered whole file bodies into chips + // because it stringified the whole argument object. + it("ignores bulky fields like file contents", () => { + expect(toolDetail({ content: "x".repeat(5000) })).toBeUndefined(); + expect(toolDetail({ new_str: "whole file body" })).toBeUndefined(); + }); + + it("returns undefined for absent or unrecognised input", () => { + expect(toolDetail(undefined)).toBeUndefined(); + expect(toolDetail({ mystery: 42 })).toBeUndefined(); + }); +}); + +describe("detectInterrupted", () => { + it("reports nothing while the session is running", () => { + expect(detectInterrupted(messages, true)).toEqual({ interrupted: false }); + }); + + // The last fixture message is an assistant turn with no time.completed. + it("flags an assistant turn that never completed", () => { + expect(detectInterrupted(messages, false)).toEqual({ + interrupted: true, + reason: "incomplete-turn", + }); + }); + + it("flags a user prompt that was never answered", () => { + const trailing: RawMessage[] = [ + { info: { id: "m1", role: "user", time: { created: 1 } }, parts: [] }, + ]; + expect(detectInterrupted(trailing, false)).toEqual({ + interrupted: true, + reason: "never-answered", + }); + }); + + it("treats a completed assistant turn as healthy", () => { + const done: RawMessage[] = [ + { info: { id: "m1", role: "assistant", time: { created: 1, completed: 2 } }, parts: [] }, + ]; + expect(detectInterrupted(done, false)).toEqual({ interrupted: false }); + }); + + it("handles an empty transcript", () => { + expect(detectInterrupted([], false)).toEqual({ interrupted: false }); + }); +}); + +describe("frozen contract", () => { + // The whole migration is cheap because row components never see raw backend + // shapes. This test is the enforcement: if an adapter change starts passing + // provider metadata or nested backend objects through, it fails here. + // + // Verified against 1,133 real messages / 1,592 events from a live 1.18.19 + // server before being written down. + const ALLOWED: Record = { + user: ["kind", "id", "messageId", "timestamp", "text", "attachments"], + agent: ["kind", "id", "messageId", "timestamp", "text"], + thought: ["kind", "id", "messageId", "timestamp", "text", "durationMs"], + tool: [ + "kind", "id", "messageId", "timestamp", "status", "name", + "title", "detail", "output", "error", "durationMs", "attachments", + ], + status: ["kind", "id", "messageId", "timestamp", "label", "detail"], + error: ["kind", "id", "messageId", "timestamp", "message"], + }; + + const { events } = normalizeTranscript(messages); + + it("emits no keys outside the contract", () => { + for (const event of events) { + const extra = Object.keys(event).filter((k) => !ALLOWED[event.kind].includes(k)); + expect(extra, `${event.kind} (${event.id})`).toEqual([]); + } + }); + + it("emits only scalars, except attachments", () => { + for (const event of events) { + for (const [key, value] of Object.entries(event)) { + if (key === "attachments") continue; + expect( + value === null || typeof value !== "object", + `${event.kind}.${key} must not be a nested backend object`, + ).toBe(true); + } + } + }); + + it("keeps attachments to the declared fields", () => { + for (const event of events) { + if (!("attachments" in event)) continue; + for (const attachment of event.attachments) { + const extra = Object.keys(attachment).filter( + (k) => !["filename", "mime", "url", "path"].includes(k), + ); + expect(extra).toEqual([]); + } + } + }); +}); + +describe("normalizeMessage", () => { + it("emits an error row for a failed turn that produced no parts", () => { + const events = normalizeMessage({ + info: { id: "m9", role: "assistant", time: { created: 5 }, error: { message: "boom" } }, + parts: [], + }); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ kind: "error", message: "boom" }); + }); + + it("produces ISO timestamps", () => { + const [event] = normalizeMessage({ + info: { id: "m10", role: "user", time: { created: 1787000000000 } }, + parts: [{ id: "p", messageID: "m10", type: "text", text: "hi" }], + }); + expect(event.timestamp).toBe(new Date(1787000000000).toISOString()); + }); +}); From 1f05e123727b1d822b9670e21363ce9c09a3ff83 Mon Sep 17 00:00:00 2001 From: Leon Cheng Date: Fri, 21 Aug 2026 03:33:12 -0400 Subject: [PATCH 2/2] feat: session lifecycle, transcript UI, and a CI-runnable e2e suite Waves 2-3 plus the new Wave 6 (e2e verification). Server - server/opencode/client.ts: dropped @opencode-ai/sdk in favour of a thin typed fetch layer. The bundled v1 SDK query types are narrower than the live server (session.list accepts only 'directory'; the server also takes limit/roots/ search) and its event names are stale, so depending on it meant casting around it constantly. Live GET /doc is the source of truth. - server/opencode/sessions.ts: list/get/create/prompt/abort/delete/messages/ todos. prompt() uses /prompt_async (204) so a turn survives the client disconnecting; /message would hold the response for the whole turn. - server/opencode/events.ts: ONE upstream /global/event subscription fanned out to every browser client, with exponential-backoff reconnect. /event is directory-scoped and would silently drop other projects' events. - server/routes/sessions.ts: directory scope is required, never defaulted -- a silent default targets whatever directory the server started in and looks like an empty project. Upstream answers 500 for an unknown session id, so session-scoped routes map that to 404 rather than 502. Client - lib/derive.ts: merge/group/activity/commands/MR-scan over the frozen contract. mergeEvents compares a content fingerprint, not just id presence: OpenCode tool parts mutate in place (pending->running->completed, output grows), so presence-only change detection freezes tool chips forever. - components/transcript.tsx: row components consuming only TranscriptEvent. Attachments render as filename chips unless they are self-contained data: images -- Attachment.url is not necessarily http, and inlining it blind is an SSRF/tracking-pixel surface. - lib/useSessionStream.ts: 3s poll is the durable source of truth; SSE only says 'poll now'. On error we close the EventSource ourselves before backing off 2s/4s/8s, because the browser's built-in infinite retry turns a server restart into a connection-pool storm. - Hub + Conversation pages, incl. the interrupted-run banner (detection only; Resume prefills the composer rather than auto-sending). Theme - Replaced the vendored private corporate theme with original neutral tokens. Along the way: --color-*-critical was referenced in four places but defined nowhere in the predecessor, so error styling there was silently broken. Uses the danger family now. e2e (Wave 6) - tests/e2e/mock-opencode.ts stands in for opencode serve, reproducing the awkward real behaviours: 500 for unknown sessions, 204 from prompt_async, and a server.heartbeat absent from the published event union. - 35 tests over the REAL BFF and the production bundle with only the agent faked, so CI needs no agent, no API keys and no network. Verified: typecheck clean, 64 unit tests, 35 e2e tests, production build. --- .github/workflows/ci.yml | 15 ++ .gitignore | 2 + client/components/transcript.tsx | 428 +++++++++++++++++++++++++++++++ client/lib/api.ts | 148 +++++++++++ client/lib/derive.ts | 285 ++++++++++++++++++++ client/lib/useSessionStream.ts | 167 ++++++++++++ client/main.tsx | 101 +------- client/pages/Conversation.tsx | 229 +++++++++++++++++ client/pages/Hub.tsx | 225 ++++++++++++++++ client/theme/tokens.css | 19 ++ package-lock.json | 75 ------ package.json | 3 +- playwright.config.ts | 44 ++++ server/index.ts | 12 + server/opencode/client.ts | 72 +++++- server/opencode/events.ts | 175 +++++++++++++ server/opencode/sessions.ts | 238 +++++++++++++++++ server/routes/sessions.ts | 227 ++++++++++++++++ tests/derive.test.ts | 231 +++++++++++++++++ tests/e2e/mock-opencode.ts | 170 ++++++++++++ tests/e2e/smoke.api.spec.ts | 174 +++++++++++++ tests/e2e/smoke.ui.spec.ts | 148 +++++++++++ 22 files changed, 3008 insertions(+), 180 deletions(-) create mode 100644 client/components/transcript.tsx create mode 100644 client/lib/api.ts create mode 100644 client/lib/derive.ts create mode 100644 client/lib/useSessionStream.ts create mode 100644 client/pages/Conversation.tsx create mode 100644 client/pages/Hub.tsx create mode 100644 playwright.config.ts create mode 100644 server/opencode/events.ts create mode 100644 server/opencode/sessions.ts create mode 100644 server/routes/sessions.ts create mode 100644 tests/derive.test.ts create mode 100644 tests/e2e/mock-opencode.ts create mode 100644 tests/e2e/smoke.api.spec.ts create mode 100644 tests/e2e/smoke.ui.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 42e1b2d8..00c2dfd9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,6 +33,21 @@ jobs: - name: Build run: npm run build + # e2e runs against a mock OpenCode server (tests/e2e/mock-opencode.ts), + # so CI needs no agent, no API keys and no network. + - name: Install Playwright + run: npx playwright install --with-deps chromium + + - name: E2E + run: npm run test:e2e + + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: playwright-report/ + retention-days: 7 + # This repo is public and talks to a server that runs shell commands on the # host. A leaked token is a direct path to code execution, so scan every PR. secrets: diff --git a/.gitignore b/.gitignore index 7f45d4c0..a23fe37f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,5 @@ playwright-report/ .DS_Store mcp-servers.json screenshots-out/ +playwright-report/ +test-results/ diff --git a/client/components/transcript.tsx b/client/components/transcript.tsx new file mode 100644 index 00000000..5d2d5a3c --- /dev/null +++ b/client/components/transcript.tsx @@ -0,0 +1,428 @@ +// client/components/transcript.tsx +// +// Transcript row components. Every one of these consumes ONLY the frozen +// TranscriptEvent contract — none of them imports an SDK type or touches a raw +// OpenCode Part. That wall is what made this migration a small adapter rewrite +// instead of a rebuild; see client/lib/transcript.ts. + +import { useEffect, useState } from "react"; + +import { Markdown } from "../ds/markdown.js"; +import { cn } from "../ds/utils.js"; +import { formatClockTime, formatDurationMs, formatRelative, type DisplayItem, type RunningActivity } from "../lib/derive.js"; +import type { + Attachment, + AgentEvent, + ErrorEvent, + StatusEvent, + ThoughtEvent, + ToolEvent, + ToolStatus, + TranscriptEvent, + UserEvent, +} from "../lib/transcript.js"; + +// ── Shared bits ───────────────────────────────────────────────────────────── + +function TimeLabel({ timestamp, className }: { timestamp: string; className?: string }) { + if (!timestamp) return null; + const display = formatRelative(timestamp) || formatClockTime(timestamp); + if (!display) return null; + return ( + + ); +} + +/** + * Attachments render as filename chips, never as . + * + * `Attachment.url` is explicitly "not necessarily an http URL" and can point + * anywhere the agent referenced. Inlining it would turn a transcript into an + * SSRF / tracking-pixel surface. Only a self-contained data: image is safe to + * display, and even then we gate on the mime type. + */ +function Attachments({ items }: { items: Attachment[] }) { + if (items.length === 0) return null; + return ( +
+ {items.map((item, index) => { + const inlineable = + item.mime?.startsWith("image/") && item.url?.startsWith("data:image/"); + if (inlineable) { + return ( + {item.filename} + ); + } + return ( + + 📎 + {item.filename} + + ); + })} +
+ ); +} + +// ── Rows ──────────────────────────────────────────────────────────────────── + +function UserBubble({ event }: { event: UserEvent }) { + return ( +
+
+
{event.text}
+ +
+ +
+ ); +} + +function AgentProse({ event }: { event: AgentEvent }) { + return ( +
+ +
+ +
+
+ ); +} + +export function ThoughtRow({ + text, + durationMs, + live = false, +}: { + text: string; + durationMs?: number; + live?: boolean; +}) { + const [expanded, setExpanded] = useState(false); + const firstLine = text.split("\n").find((line) => line.trim().length > 0)?.trim() ?? ""; + const hasMore = text.trim() !== firstLine; + const duration = formatDurationMs(durationMs); + + return ( +
+ + {expanded && ( +
+ {text} +
+ )} +
+ ); +} + +const TOOL_BULLET: Record = { + pending: "text-[var(--color-text-muted)]", + running: "text-[var(--color-text-info)] animate-pulse", + completed: "text-[var(--color-text-success)]", + error: "text-[var(--color-text-danger)]", +}; + +export function ToolCallRow({ event, wrap }: { event: ToolEvent; wrap: boolean }) { + const [expanded, setExpanded] = useState(false); + const failed = event.status === "error"; + const duration = formatDurationMs(event.durationMs); + const preClass = wrap ? "whitespace-pre-wrap break-words" : "thin-scrollbar overflow-x-auto"; + + return ( +
+ + + {expanded && ( +
+ {event.detail && event.title && ( +
+              {event.detail}
+            
+ )} +
+            {event.error ?? event.output ?? "(no output)"}
+          
+ +
+ )} +
+ ); +} + +function StatusSeparator({ event }: { event: StatusEvent }) { + return ( +
+ + + {event.label} + {event.detail && · {event.detail}} + {event.timestamp && ( + <> + {" · "} + + + )} + + +
+ ); +} + +function ErrorCard({ event }: { event: ErrorEvent }) { + return ( +
+
+ Error + +
+
{event.message}
+
+ ); +} + +function ActionGroupRow({ + calls, + wrap, + expanded, + onToggle, +}: { + calls: ToolEvent[]; + wrap: boolean; + expanded: boolean; + onToggle: () => void; +}) { + const last = calls[calls.length - 1]; + return ( +
+ + {expanded && ( +
+ {calls.map((call) => ( +
+ +
+ ))} +
+ )} +
+ ); +} + +function TranscriptRow({ event, wrap }: { event: TranscriptEvent; wrap: boolean }) { + switch (event.kind) { + case "user": + return ; + case "agent": + return ; + case "thought": + return ; + case "tool": + return ; + case "status": + return ; + case "error": + return ; + default: + // Forward compatibility: an unknown kind renders nothing rather than + // crashing the transcript. + return null; + } +} + +export function RunningIndicator({ activity }: { activity: RunningActivity }) { + const [now, setNow] = useState(() => Date.now()); + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), 1_000); + return () => clearInterval(timer); + }, []); + + const elapsed = activity.since ? formatDurationMs(now - Date.parse(activity.since)) : null; + const detail = + activity.kind === "tool" ? activity.detail.replace(/\s+/g, " ").trim() : ""; + + return ( +
+
+ + + + {activity.kind === "tool" ? ( + + Running {activity.name} + {detail && ( + <> + : {detail.length > 90 ? `${detail.slice(0, 90)}…` : detail} + + )} + {elapsed && ({elapsed})} + + ) : ( + + Thinking… + {elapsed && no new events for {elapsed}} + + )} +
+
+ ); +} + +/** Vertical rhythm: related actions sit tight, turns get room to breathe. */ +function rowSpacing(previous: DisplayItem | undefined, item: DisplayItem): string { + if (!previous) return ""; + const isAction = (candidate: DisplayItem) => + candidate.type === "actionGroup" || + (candidate.type === "event" && candidate.event.kind === "tool"); + if (isAction(previous) && isAction(item)) return "mt-1.5"; + if (item.type === "event" && item.event.kind === "status") return "mt-5"; + return "mt-6"; +} + +export function Transcript({ + items, + wrap, + collapsedGroups, + onToggleGroup, +}: { + items: DisplayItem[]; + wrap: boolean; + collapsedGroups: Record; + onToggleGroup: (id: string) => void; +}) { + return ( + <> + {items.map((item, index) => ( +
+ {item.type === "actionGroup" ? ( + onToggleGroup(item.id)} + /> + ) : ( + + )} +
+ ))} + + ); +} diff --git a/client/lib/api.ts b/client/lib/api.ts new file mode 100644 index 00000000..3bc46dbf --- /dev/null +++ b/client/lib/api.ts @@ -0,0 +1,148 @@ +// client/lib/api.ts — typed fetch helpers for our BFF (/api). +// +// Same-origin, no auth headers: the BFF holds the OpenCode credential so the +// browser never sees it. + +import type { RawMessage } from "./events.js"; + +export interface SessionSummary { + id: string; + title: string; + directory: string; + parentID?: string; + agent?: string; + model?: { providerID?: string; modelID?: string }; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + cacheRead: number; + cacheWrite: number; + }; + createdAt: string; + updatedAt: string; + archived: boolean; + running: boolean; +} + +export interface Todo { + content: string; + status: string; + priority: string; +} + +export interface HealthResponse { + healthy: boolean; + upstream: { + url: string; + reachable: boolean; + version?: string; + expected?: string; + versionMatches?: boolean; + error?: string; + }; + events?: { connected: boolean }; +} + +/** + * Unwrap a response, surfacing the BFF's `{ error }` body when present. + * + * The status is attached so callers can distinguish "this session is gone" + * (404, stop polling) from "the agent server is down" (502, keep retrying). + */ +export class ApiError extends Error { + constructor( + readonly status: number, + message: string, + ) { + super(message); + this.name = "ApiError"; + } +} + +async function json(res: Response): Promise { + if (!res.ok) { + let message = `HTTP ${res.status}`; + try { + const body = (await res.json()) as { error?: string }; + if (body.error) message = body.error; + } catch { + /* keep the status-only message */ + } + throw new ApiError(res.status, message); + } + if (res.status === 204) return undefined as T; + return (await res.json()) as T; +} + +/** Every project-scoped call threads ?directory=. */ +function scoped(path: string, directory: string, extra: Record = {}): string { + const query = new URLSearchParams({ directory, ...extra }); + return `/api${path}?${query}`; +} + +export const api = { + health: () => fetch("/api/health").then((r) => json(r)), + + sessions: (directory: string, limit = 100) => + fetch(scoped("/sessions", directory, { limit: String(limit) })).then((r) => + json<{ sessions: SessionSummary[] }>(r), + ), + + session: (directory: string, id: string) => + fetch(scoped(`/sessions/${encodeURIComponent(id)}`, directory)).then((r) => + json<{ session: SessionSummary }>(r), + ), + + messages: (directory: string, id: string) => + fetch(scoped(`/sessions/${encodeURIComponent(id)}/messages`, directory)).then((r) => + json<{ messages: RawMessage[]; running: boolean }>(r), + ), + + todos: (directory: string, id: string) => + fetch(scoped(`/sessions/${encodeURIComponent(id)}/todos`, directory)).then((r) => + json<{ todos: Todo[] }>(r), + ), + + createSession: (input: { + directory: string; + title?: string; + agent?: string; + model?: { providerID: string; modelID: string }; + prompt?: string; + }) => + fetch("/api/sessions", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(input), + }).then((r) => json<{ session: SessionSummary }>(r)), + + prompt: (directory: string, id: string, text: string, model?: { providerID: string; modelID: string }) => + fetch(scoped(`/sessions/${encodeURIComponent(id)}/prompt`, directory), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, ...(model ? { model } : {}) }), + }).then((r) => json<{ accepted: boolean }>(r)), + + abort: (directory: string, id: string) => + fetch(scoped(`/sessions/${encodeURIComponent(id)}/abort`, directory), { method: "POST" }).then( + (r) => json<{ aborted: boolean }>(r), + ), + + remove: (directory: string, id: string) => + fetch(scoped(`/sessions/${encodeURIComponent(id)}`, directory), { method: "DELETE" }).then((r) => + json(r), + ), + + /** SSE endpoint URL — consumed by EventSource, not fetch. */ + eventsUrl: (directory?: string) => + directory ? `/api/events?directory=${encodeURIComponent(directory)}` : "/api/events", +}; + +/** Format a dollar amount the way the status bar and list rows expect. */ +export function formatCost(cost: number | undefined): string { + if (!cost) return "$0.00"; + if (cost < 0.01) return "<$0.01"; + return `$${cost.toFixed(2)}`; +} diff --git a/client/lib/derive.ts b/client/lib/derive.ts new file mode 100644 index 00000000..4fd556d8 --- /dev/null +++ b/client/lib/derive.ts @@ -0,0 +1,285 @@ +// client/lib/derive.ts +// +// Backend-neutral derivations over TranscriptEvent[]. Nothing here knows what +// OpenCode is — that is the point. These are the functions that survived the +// migration from the OpenHands runner unchanged in spirit, and they will +// survive the next one too. + +import type { ToolEvent, TranscriptEvent } from "./transcript.js"; + +// ── Merge ─────────────────────────────────────────────────────────────────── + +/** + * Fingerprint of everything that can change about an event after first sight. + * + * CRITICAL: OpenCode tool parts **mutate in place** — a call goes + * pending → running → completed and its `output` grows as it streams. The + * predecessor's log was append-only, so it could treat "is this id new?" as + * "did anything change?". Doing that here freezes tool chips at `running` + * forever. Compare content, not just presence. + */ +function fingerprint(event: TranscriptEvent): string { + switch (event.kind) { + case "tool": + return `${event.status}|${event.output?.length ?? 0}|${event.error ?? ""}|${event.durationMs ?? ""}`; + case "user": + case "agent": + case "thought": + return String(event.text.length); + case "status": + return `${event.label}|${event.detail ?? ""}`; + case "error": + return event.message; + } +} + +/** + * Merge a freshly fetched page into what we already have. + * + * Returns the SAME array reference when nothing changed, so downstream + * `useMemo`/`memo` boundaries do not invalidate on every poll. + */ +export function mergeEvents( + previous: TranscriptEvent[], + incoming: TranscriptEvent[], +): TranscriptEvent[] { + if (incoming.length === 0) return previous; + + const byId = new Map(); + for (const event of previous) byId.set(event.id, event); + + let changed = false; + for (const event of incoming) { + const existing = byId.get(event.id); + if (!existing || fingerprint(existing) !== fingerprint(event)) changed = true; + byId.set(event.id, event); + } + if (!changed) return previous; + + // ISO timestamps are fixed-width, so lexicographic order is chronological. + // Id is a deterministic tiebreak for events sharing a millisecond. + return [...byId.values()].sort( + (a, b) => a.timestamp.localeCompare(b.timestamp) || a.id.localeCompare(b.id), + ); +} + +// ── Grouping ──────────────────────────────────────────────────────────────── + +export type DisplayItem = + | { type: "event"; id: string; event: TranscriptEvent } + | { type: "actionGroup"; id: string; calls: ToolEvent[] }; + +/** + * Only finished, successful calls collapse. Errors and in-flight calls stay + * visible — nothing important should hide behind a chevron. + */ +function isCollapsible(event: TranscriptEvent): event is ToolEvent { + return event.kind === "tool" && event.status === "completed"; +} + +/** + * Fold consecutive successful tool calls into one "N actions completed" row. + * + * The group id is keyed on the FIRST call, which never changes as the run + * grows across polls — that is what keeps a user's expand/collapse choice + * stable while the agent is still working. + */ +export function collapseActionGroups( + events: TranscriptEvent[], + minGroupSize = 2, +): DisplayItem[] { + const out: DisplayItem[] = []; + let run: ToolEvent[] = []; + + const flush = (): void => { + if (run.length >= minGroupSize) { + out.push({ type: "actionGroup", id: `group-${run[0].id}`, calls: run }); + } else { + out.push(...run.map((event) => ({ type: "event" as const, id: event.id, event }))); + } + run = []; + }; + + for (const event of events) { + if (isCollapsible(event)) { + run.push(event); + continue; + } + flush(); + out.push({ type: "event", id: event.id, event }); + } + flush(); + return out; +} + +// ── Running activity ──────────────────────────────────────────────────────── + +export type RunningActivity = + | { kind: "tool"; name: string; detail: string; since: string | null } + | { kind: "thinking"; since: string | null }; + +/** + * What the agent appears to be doing right now. + * + * An unfinished call deeper in history is stale, not running — hence the + * `break` rather than a full scan. + */ +export function runningActivity(events: TranscriptEvent[]): RunningActivity { + let latest: string | null = null; + for (const event of events) { + if (!latest || event.timestamp > latest) latest = event.timestamp; + } + + for (let i = events.length - 1; i >= 0; i--) { + const event = events[i]; + if (event.kind === "status") continue; // separators are not activity + if (event.kind === "tool" && (event.status === "running" || event.status === "pending")) { + return { + kind: "tool", + name: event.name, + detail: event.detail ?? event.title ?? "", + since: event.timestamp, + }; + } + break; + } + return { kind: "thinking", since: latest }; +} + +// ── Command audit ─────────────────────────────────────────────────────────── + +export type CommandCategory = "command" | "edit" | "read" | "other"; + +export interface CommandEntry { + /** Equals the transcript row's data-event-id, so jump-to-event works. */ + id: string; + category: CommandCategory; + name: string; + text: string; + timestamp: string; + status: "ok" | "error" | "pending"; + outputPreview?: string; +} + +// Narrow on purpose: a loose /file/ would swallow unrelated tools. Anything +// unmatched falls through to "other" and is still listed, so a miss only +// affects filtering, never visibility. +const COMMAND_TOOLS = /^(bash|shell)$|terminal/i; +const EDIT_TOOLS = /^(edit|write|patch|apply_patch)$|str_replace/i; +const READ_TOOLS = /^(read|grep|glob|list|webfetch|websearch)$/i; + +function categorize(name: string): CommandCategory { + if (COMMAND_TOOLS.test(name)) return "command"; + if (EDIT_TOOLS.test(name)) return "edit"; + if (READ_TOOLS.test(name)) return "read"; + return "other"; +} + +function firstLine(text: string): string { + return text.split("\n").find((line) => line.trim().length > 0)?.trim() ?? ""; +} + +/** + * The audit trail, derived from the same events the transcript renders — so + * the two views can never disagree about what the agent did. + */ +export function extractCommands(events: TranscriptEvent[]): CommandEntry[] { + const out: CommandEntry[] = []; + for (const event of events) { + if (event.kind !== "tool") continue; + const text = event.detail ?? event.title; + if (!text) continue; + out.push({ + id: event.id, + category: categorize(event.name), + name: event.name, + text, + timestamp: event.timestamp, + status: + event.status === "completed" ? "ok" : event.status === "error" ? "error" : "pending", + ...(event.output ? { outputPreview: firstLine(event.output).slice(0, 120) } : {}), + }); + } + return out; +} + +// ── Merge-request detection ───────────────────────────────────────────────── + +// Bounded deliberately: matches end at the iid, so query strings, fragments, +// tab segments (/diffs, /files) and trailing punctuation are never captured. +// ')' and ']' are excluded so markdown-wrapped links terminate correctly. +const MR_URL_RE = + /https?:\/\/[^\s)\]>"']+\/-\/merge_requests\/\d+|https?:\/\/github\.com\/[^\s)\]>"'/]+\/[^\s)\]>"'/]+\/pull\/\d+/g; + +function scanText(text: string | undefined, seen: Set, out: string[]): void { + if (!text) return; + for (const match of text.matchAll(MR_URL_RE)) { + const url = match[0].replace(/\/+$/, ""); + if (!seen.has(url)) { + seen.add(url); + out.push(url); + } + } +} + +/** Merge-request / pull-request URLs the agent mentioned, in first-seen order. */ +export function extractMrUrls(events: TranscriptEvent[]): string[] { + const seen = new Set(); + const out: string[] = []; + for (const event of events) { + switch (event.kind) { + case "user": + case "agent": + case "thought": + scanText(event.text, seen, out); + break; + case "tool": + scanText(event.detail, seen, out); + scanText(event.title, seen, out); + scanText(event.output, seen, out); + scanText(event.error, seen, out); + break; + case "status": + scanText(event.label, seen, out); + scanText(event.detail, seen, out); + break; + case "error": + scanText(event.message, seen, out); + break; + } + } + return out; +} + +// ── Formatting ────────────────────────────────────────────────────────────── + +/** "3.2s" under a minute, "2m 05s" above it. */ +export function formatDurationMs(ms: number | undefined): string | null { + if (typeof ms !== "number" || ms < 0) return null; + if (ms < 1000) return `${ms}ms`; + const seconds = ms / 1000; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const minutes = Math.floor(seconds / 60); + const rest = Math.round(seconds % 60); + return `${minutes}m ${String(rest).padStart(2, "0")}s`; +} + +/** Relative label for a timestamp, e.g. "just now", "4m ago". */ +export function formatRelative(timestamp: string, now = Date.now()): string { + const then = Date.parse(timestamp); + if (Number.isNaN(then)) return ""; + const seconds = Math.max(0, Math.round((now - then) / 1000)); + if (seconds < 10) return "just now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +export function formatClockTime(timestamp: string): string { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); +} diff --git a/client/lib/useSessionStream.ts b/client/lib/useSessionStream.ts new file mode 100644 index 00000000..7c86f904 --- /dev/null +++ b/client/lib/useSessionStream.ts @@ -0,0 +1,167 @@ +// client/lib/useSessionStream.ts +// +// Live updates for a session. Two channels, deliberately: +// +// 1. A 3s poll, which is the DURABLE source of truth. +// 2. An SSE subscription, which only says "something changed, poll now". +// +// The stream never carries transcript content. If it drops, the UI degrades to +// exactly its pre-SSE behaviour instead of showing a divergent view. +// +// Connection budget matters: browsers cap HTTP/1.1 at ~6 connections per +// origin, so the stream opens only when the tab is visible AND the session is +// running. On error we close the EventSource ourselves — the browser's +// built-in infinite retry turned a server restart into a pool-exhausting +// storm in the predecessor — then back off 2s/4s/8s and give up, leaving the +// poll running. + +import { useCallback, useEffect, useRef, useState } from "react"; + +import { api, ApiError } from "./api.js"; + +const POLL_MS = 3_000; +const RETRY_BASE_MS = 2_000; +const MAX_RETRIES = 3; + +export function streamRetryDelay(retries: number): number | null { + if (retries >= MAX_RETRIES) return null; + return RETRY_BASE_MS * 2 ** retries; +} + +export interface SessionStreamState { + messages: unknown[]; + running: boolean; + todos: Array<{ content: string; status: string; priority: string }>; + error: string | null; + /** True once the first fetch has resolved, so the UI can skip a spinner. */ + loaded: boolean; + refresh: () => void; +} + +export function useSessionStream(directory: string, sessionId: string): SessionStreamState { + const [messages, setMessages] = useState([]); + const [running, setRunning] = useState(false); + const [todos, setTodos] = useState>([]); + const [error, setError] = useState(null); + const [loaded, setLoaded] = useState(false); + + const inFlight = useRef(false); + // Guards a stale response landing after the user navigated elsewhere. + const liveId = useRef(sessionId); + liveId.current = sessionId; + + const poll = useCallback(async () => { + if (inFlight.current) return; + inFlight.current = true; + try { + const [messageResult, todoResult] = await Promise.allSettled([ + api.messages(directory, sessionId), + api.todos(directory, sessionId), + ]); + if (liveId.current !== sessionId) return; + + if (messageResult.status === "fulfilled") { + setMessages(messageResult.value.messages); + setRunning(messageResult.value.running); + setError(null); + } else { + const reason = messageResult.reason as unknown; + setError(reason instanceof Error ? reason.message : String(reason)); + } + // Todos are supplementary — a failure there must not blank the transcript. + if (todoResult.status === "fulfilled") setTodos(todoResult.value.todos); + } finally { + inFlight.current = false; + setLoaded(true); + } + }, [directory, sessionId]); + + // Poll loop. Hidden tabs skip ticks and refresh once on return. + useEffect(() => { + void poll(); + const timer = setInterval(() => { + if (document.visibilityState === "hidden") return; + void poll(); + }, POLL_MS); + const onVisible = () => { + if (document.visibilityState === "visible") void poll(); + }; + document.addEventListener("visibilitychange", onVisible); + return () => { + clearInterval(timer); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [poll]); + + // SSE nudge channel. + useEffect(() => { + let source: EventSource | null = null; + let retries = 0; + let retryTimer: ReturnType | null = null; + let disposed = false; + + const close = () => { + source?.close(); + source = null; + if (retryTimer) { + clearTimeout(retryTimer); + retryTimer = null; + } + }; + + const open = () => { + if (disposed || source || document.visibilityState === "hidden") return; + source = new EventSource(api.eventsUrl(directory)); + source.onopen = () => { + retries = 0; + }; + source.onmessage = (message) => { + try { + const event = JSON.parse(message.data) as { type?: string; properties?: { sessionID?: string } }; + if (!event.type || event.type === "server.heartbeat" || event.type === "connected") return; + // Only react to events about this session; the bus is global. + const target = event.properties?.sessionID; + if (target && target !== sessionId) return; + void poll(); + } catch { + /* a malformed frame must never kill the stream */ + } + }; + source.onerror = () => { + close(); + const delay = streamRetryDelay(retries); + if (delay === null) return; // exhausted — the poll carries on alone + retries += 1; + retryTimer = setTimeout(open, delay); + }; + }; + + const onVisible = () => { + if (document.visibilityState === "visible") { + retries = 0; + open(); + } else { + close(); + } + }; + + open(); + document.addEventListener("visibilitychange", onVisible); + return () => { + disposed = true; + document.removeEventListener("visibilitychange", onVisible); + close(); + }; + }, [directory, sessionId, poll]); + + const refresh = useCallback(() => { + void poll(); + }, [poll]); + + return { messages, running, todos, error, loaded, refresh }; +} + +/** True when an error means "stop trying" rather than "retry later". */ +export function isGone(error: unknown): boolean { + return error instanceof ApiError && error.status === 404; +} diff --git a/client/main.tsx b/client/main.tsx index 042df44a..2b11d775 100644 --- a/client/main.tsx +++ b/client/main.tsx @@ -1,102 +1,21 @@ -import { StrictMode, useEffect, useState } from "react"; +import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import { BrowserRouter, Route, Routes } from "react-router-dom"; import { ThemeProvider } from "next-themes"; -import { Card, CardHeader, CardTitle, CardContent } from "./ds/card.js"; -import { Badge } from "./ds/badge.js"; -import { Alert } from "./ds/alert.js"; +import { HubPage } from "./pages/Hub.js"; +import { ConversationPage } from "./pages/Conversation.js"; import "./styles.css"; -interface HealthResponse { - healthy: boolean; - upstream: { - url: string; - reachable: boolean; - version?: string; - expected?: string; - versionMatches?: boolean; - error?: string; - }; -} - -/** - * Phase 0 landing page: proves the SPA builds, the BFF is reachable, and the - * OpenCode server behind it is healthy. Replaced by the conversation Hub in - * Phase 2. - */ -function App() { - const [health, setHealth] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - fetch("/api/health") - .then((res) => res.json()) - .then((body: HealthResponse) => { - if (!cancelled) setHealth(body); - }) - .catch((err: unknown) => { - if (!cancelled) setError(err instanceof Error ? err.message : String(err)); - }); - return () => { - cancelled = true; - }; - }, []); - - return ( -
-
-

custom-dca-opencode

- - Phase 0 - -
- - - - Server - - - {error ? ( - - Could not reach the BFF: {error} - - ) : !health ? ( -

Checking…

- ) : ( -
-
Upstream
-
{health.upstream.url}
-
Reachable
-
- {health.upstream.reachable ? "yes" : "no"} -
- {health.upstream.version ? ( - <> -
Version
-
- {health.upstream.version} - {health.upstream.versionMatches === false - ? ` (expected ${health.upstream.expected})` - : ""} -
- - ) : null} -
- )} -
-
-
- ); -} - createRoot(document.getElementById("root")!).render( - + + + } /> + } /> + + , ); diff --git a/client/pages/Conversation.tsx b/client/pages/Conversation.tsx new file mode 100644 index 00000000..18019f5b --- /dev/null +++ b/client/pages/Conversation.tsx @@ -0,0 +1,229 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { Link, useParams, useSearchParams } from "react-router-dom"; + +import { Alert } from "../ds/alert.js"; +import { Badge } from "../ds/badge.js"; +import { Button } from "../ds/button.js"; +import { LoadingIndicator } from "../ds/loading-indicator.js"; +import { RunningIndicator, Transcript } from "../components/transcript.js"; +import { api, formatCost, type SessionSummary } from "../lib/api.js"; +import { collapseActionGroups, mergeEvents, runningActivity } from "../lib/derive.js"; +import { normalizeTranscript, type RawMessage } from "../lib/events.js"; +import { useSessionStream } from "../lib/useSessionStream.js"; +import type { TranscriptEvent } from "../lib/transcript.js"; + +const WRAP_KEY = "opencode.wrapOutput.v1"; + +export function ConversationPage() { + const { id = "" } = useParams(); + const [params] = useSearchParams(); + const directory = params.get("directory") ?? ""; + + const stream = useSessionStream(directory, id); + const [session, setSession] = useState(null); + const [wrap, setWrap] = useState(() => localStorage.getItem(WRAP_KEY) !== "off"); + const [collapsedGroups, setCollapsedGroups] = useState>({}); + const [draft, setDraft] = useState(""); + const [sending, setSending] = useState(false); + + // Keep event identity stable across polls so memoised rows do not churn. + const [events, setEvents] = useState([]); + const transcript = useMemo( + () => normalizeTranscript(stream.messages as RawMessage[], { isRunning: stream.running }), + [stream.messages, stream.running], + ); + useEffect(() => { + setEvents((previous) => mergeEvents(previous, transcript.events)); + }, [transcript.events]); + + useEffect(() => { + if (!directory || !id) return; + let cancelled = false; + api + .session(directory, id) + .then((r) => !cancelled && setSession(r.session)) + .catch(() => undefined); + return () => { + cancelled = true; + }; + }, [directory, id, stream.running]); + + const items = useMemo(() => collapseActionGroups(events), [events]); + const activity = useMemo(() => runningActivity(events), [events]); + + const toggleGroup = useCallback((groupId: string) => { + setCollapsedGroups((state) => ({ ...state, [groupId]: !state[groupId] })); + }, []); + + const toggleWrap = () => { + setWrap((value) => { + localStorage.setItem(WRAP_KEY, value ? "off" : "on"); + return !value; + }); + }; + + // Stick to the bottom as the transcript grows. + const bottomRef = useRef(null); + useLayoutEffect(() => { + bottomRef.current?.scrollIntoView({ block: "end" }); + }, [events.length]); + + const send = async () => { + const text = draft.trim(); + if (!text) return; + setSending(true); + try { + await api.prompt(directory, id, text); + setDraft(""); + stream.refresh(); + } finally { + setSending(false); + } + }; + + if (!directory) { + return ( +
+ A `directory` query parameter is required to open a session. +
+ ); + } + + return ( +
+
+ + ← Sessions + +

+ {session?.title ?? "Session"} +

+ {stream.running && running} + {session && session.cost > 0 && ( + + {formatCost(session.cost)} + + )} + + {stream.running && ( + + )} +
+ + {/* R2: OpenCode never persists "running" state, so a crash mid-turn is + invisible unless derived. We surface it and let the human decide — + Resume prefills the composer rather than auto-sending, because + replaying an interrupted turn can redo destructive work. */} + {transcript.interrupted.interrupted && ( +
+ + + {transcript.interrupted.reason === "never-answered" + ? "This prompt was never answered." + : "This run did not finish."} + {" "} + The agent is not working on it now.{" "} + {" "} + to put a follow-up in the composer. + +
+ )} + + {stream.error && ( +
+ + {stream.error} + +
+ )} + +
+
+
+ {!stream.loaded ? ( + + ) : items.length === 0 ? ( +

+ No transcript events yet. +

+ ) : ( + + )} + {stream.running && ( +
+ +
+ )} +
+
+
+ + {stream.todos.length > 0 && ( + + )} +
+ +