From 1cc5d9a9781bdbee4b5d50d3bf8b06cd17a1d050 Mon Sep 17 00:00:00 2001 From: Raphael Westphal Date: Tue, 18 Aug 2026 16:32:21 +0200 Subject: [PATCH] web: fix args-object React crash; e2e plugin-contract scenario --- e2e/driver.mjs | 197 ++++++------ e2e/scenarios/plugin-contract.mjs | 264 ++++++++------- web/src/derive.test.ts | 4 +- web/src/derive.ts | 514 ++++++++++++++++-------------- web/src/protocol.ts | 182 +++++------ 5 files changed, 625 insertions(+), 536 deletions(-) diff --git a/e2e/driver.mjs b/e2e/driver.mjs index cbf0ee8..51cb910 100644 --- a/e2e/driver.mjs +++ b/e2e/driver.mjs @@ -19,14 +19,14 @@ import * as fs from "node:fs"; import * as path from "node:path"; import { startFakeGitLab } from "./fake-gitlab.mjs"; import { - DAEMON_DIR, - REPO_ROOT, - Results, - TEST_TOKEN, - WEB_DIST, - ensureWebDist, - freePort, - startDaemon, + DAEMON_DIR, + REPO_ROOT, + Results, + TEST_TOKEN, + WEB_DIST, + ensureWebDist, + freePort, + startDaemon, } from "./lib.mjs"; import { run as auth } from "./scenarios/auth.mjs"; @@ -41,16 +41,16 @@ import { run as pluginContract } from "./scenarios/plugin-contract.mjs"; import { run as resilience } from "./scenarios/resilience.mjs"; const SCENARIOS = [ - ["auth", auth], - ["web-dist", webDist], - ["agent-lifecycle", agentLifecycle], - ["replay", replay], - ["prompt-routing", promptRouting], - ["session-list", sessionList], - ["gitlab", gitlab], - ["spawn-validation", spawnValidation], - ["plugin-contract", pluginContract], - ["resilience", resilience], // last: SIGKILLs and reboots the daemon + ["auth", auth], + ["web-dist", webDist], + ["agent-lifecycle", agentLifecycle], + ["replay", replay], + ["prompt-routing", promptRouting], + ["session-list", sessionList], + ["gitlab", gitlab], + ["spawn-validation", spawnValidation], + ["plugin-contract", pluginContract], + ["resilience", resilience], // last: SIGKILLs and reboots the daemon ]; const r = new Results(); @@ -60,92 +60,109 @@ let tmpDir = null; let cleanedUp = false; async function cleanup() { - if (cleanedUp) return; - cleanedUp = true; - if (daemon) await daemon.stop("SIGKILL"); - if (fakeGitLab) fakeGitLab.close(); - if (tmpDir && process.env.LVMH_E2E_KEEP !== "1") { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } else if (tmpDir) { - console.log(`(kept harness artifacts: ${tmpDir})`); - } + if (cleanedUp) return; + cleanedUp = true; + if (daemon) await daemon.stop("SIGKILL"); + if (fakeGitLab) fakeGitLab.close(); + if (tmpDir && process.env.LVMH_E2E_KEEP !== "1") { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } else if (tmpDir) { + console.log(`(kept harness artifacts: ${tmpDir})`); + } } process.on("SIGINT", () => { - void cleanup().finally(() => process.exit(130)); + void cleanup().finally(() => process.exit(130)); }); process.on("SIGTERM", () => { - void cleanup().finally(() => process.exit(143)); + void cleanup().finally(() => process.exit(143)); }); async function main() { - console.log("=== lvmh e2e integration ==="); - const built = ensureWebDist(); - console.log(`web dist: ${built ? "built now" : "reused"} ${path.relative(REPO_ROOT, WEB_DIST)}`); + console.log("=== lvmh e2e integration ==="); + const built = ensureWebDist(); + console.log( + `web dist: ${built ? "built now" : "reused"} ${path.relative(REPO_ROOT, WEB_DIST)}`, + ); - fs.mkdirSync(path.join(REPO_ROOT, ".pi", "scratch"), { recursive: true }); - tmpDir = fs.mkdtempSync(path.join(REPO_ROOT, ".pi", "scratch", "e2e-")); - const dbPath = path.join(tmpDir, "lvmh.db"); - const logPath = path.join(tmpDir, "daemon.log"); + fs.mkdirSync(path.join(REPO_ROOT, ".pi", "scratch"), { recursive: true }); + tmpDir = fs.mkdtempSync(path.join(REPO_ROOT, ".pi", "scratch", "e2e-")); + const dbPath = path.join(tmpDir, "lvmh.db"); + const logPath = path.join(tmpDir, "daemon.log"); - fakeGitLab = await startFakeGitLab(); - const port = await freePort(); - const addr = `127.0.0.1:${port}`; - const daemonEnv = { - LVMH_TOKEN: TEST_TOKEN, - LVMH_DB: dbPath, - GITLAB_BASE_URL: fakeGitLab.url, - LVMH_REPO_DIR: path.join(tmpDir, "repos"), - LVMH_CONTAINER_LVMH_URL: `ws://${addr}/agent/ws`, - LVMH_WORKER_DOCKERFILE: path.join(tmpDir, "absent-worker.Dockerfile"), - }; - const boot = () => startDaemon({ addr, dbPath, webdist: WEB_DIST, logPath, env: daemonEnv }); - daemon = await boot(); + fakeGitLab = await startFakeGitLab(); + const port = await freePort(); + const addr = `127.0.0.1:${port}`; + const daemonEnv = { + LVMH_TOKEN: TEST_TOKEN, + LVMH_DB: dbPath, + GITLAB_BASE_URL: fakeGitLab.url, + LVMH_REPO_DIR: path.join(tmpDir, "repos"), + LVMH_CONTAINER_LVMH_URL: `ws://${addr}/agent/ws`, + LVMH_WORKER_DOCKERFILE: path.join(tmpDir, "absent-worker.Dockerfile"), + }; + const boot = () => + startDaemon({ addr, dbPath, webdist: WEB_DIST, logPath, env: daemonEnv }); + daemon = await boot(); - console.log(`daemon: ${daemon.baseUrl} (go run . in ${path.relative(REPO_ROOT, DAEMON_DIR)}, db ${dbPath})`); - console.log(`fake gitlab: ${fakeGitLab.url}`); + console.log( + `daemon: ${daemon.baseUrl} (go run . in ${path.relative(REPO_ROOT, DAEMON_DIR)}, db ${dbPath})`, + ); + console.log(`fake gitlab: ${fakeGitLab.url}`); - const ctx = { - r, - state: {}, - base: daemon.baseUrl, - token: TEST_TOKEN, - agentUrl: daemon.agentUrl, - webUrl: daemon.webUrl, - gitlab: fakeGitLab, - restartDaemon: async () => { - await daemon.stop("SIGKILL"); - daemon = await boot(); - }, - }; + const ctx = { + r, + state: {}, + base: daemon.baseUrl, + token: TEST_TOKEN, + agentUrl: daemon.agentUrl, + webUrl: daemon.webUrl, + gitlab: fakeGitLab, + restartDaemon: async () => { + await daemon.stop("SIGKILL"); + daemon = await boot(); + }, + }; - for (const [name, scenario] of SCENARIOS) { - r.group(name); - try { - await scenario(ctx); - } catch (err) { - r.check(`${name}: scenario crashed`, false, String(err?.stack ?? err)); - const log = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf8").split("\n").slice(-15).join("\n") : ""; - if (log) console.log(` daemon.log tail:\n${log.split("\n").map((l) => " " + l).join("\n")}`); - } - } + for (const [name, scenario] of SCENARIOS) { + r.group(name); + try { + await scenario(ctx); + } catch (err) { + r.check(`${name}: scenario crashed`, false, String(err?.stack ?? err)); + const log = fs.existsSync(logPath) + ? fs.readFileSync(logPath, "utf8").split("\n").slice(-15).join("\n") + : ""; + if (log) + console.log( + ` daemon.log tail:\n${log + .split("\n") + .map((l) => " " + l) + .join("\n")}`, + ); + } + } - const { ok, fail, xfail, xpass, total } = r.summary(); - console.log("\n──────────────────────────────"); - console.log(`passed ${ok} / ${total} failed ${fail} xfail ${xfail} (known bugs) xpass ${xpass}`); - if (fail > 0) { - console.log("failed checks:"); - for (const e of r.entries.filter((e) => e.status === "fail")) { - console.log(` [${e.scenario}] ${e.name}${e.detail ? " — " + e.detail : ""}`); - } - } - console.log(`RESULT: ${fail === 0 ? "GREEN" : "RED"}`); - process.exitCode = fail === 0 ? 0 : 1; + const { ok, fail, xfail, xpass, total } = r.summary(); + console.log("\n──────────────────────────────"); + console.log( + `passed ${ok} / ${total} failed ${fail} xfail ${xfail} (known bugs) xpass ${xpass}`, + ); + if (fail > 0) { + console.log("failed checks:"); + for (const e of r.entries.filter((e) => e.status === "fail")) { + console.log( + ` [${e.scenario}] ${e.name}${e.detail ? " — " + e.detail : ""}`, + ); + } + } + console.log(`RESULT: ${fail === 0 ? "GREEN" : "RED"}`); + process.exitCode = fail === 0 ? 0 : 1; } main() - .catch((err) => { - console.error("harness crashed:", err); - process.exitCode = 1; - }) - .finally(() => cleanup()); + .catch((err) => { + console.error("harness crashed:", err); + process.exitCode = 1; + }) + .finally(() => cleanup()); diff --git a/e2e/scenarios/plugin-contract.mjs b/e2e/scenarios/plugin-contract.mjs index d99a5bc..1469fcd 100644 --- a/e2e/scenarios/plugin-contract.mjs +++ b/e2e/scenarios/plugin-contract.mjs @@ -9,130 +9,168 @@ // exact drift that crashed React #31 in production. This scenario captures a // real session via the real plugin when possible and hard-fails on any frame // shape the web UI cannot consume safely. -import { WSSock, agentHello, sid, sessionSnapshot, rest, sleep } from "../lib.mjs"; +import { + WSSock, + agentHello, + sid, + sessionSnapshot, + rest, + sleep, +} from "../lib.mjs"; const SESSION_ID = sid("e2e-contract"); // Shape predicates — the web's actual consumption rules (web/src/derive.ts). function frameRenderViolations(frame) { - const errs = []; - if (frame.type === "tool_execution_start" || frame.type === "tool_execution_update") { - // args: unknown is fine (deriveChat normalizes) — but circular/unserializable - // payloads would break the daemon's persistence; verify JSON round-trip. - if (frame.args !== undefined && frame.args !== null) { - try { - JSON.parse(JSON.stringify(frame.args)); - } catch { - errs.push(`${frame.type}.args not JSON-safe: ${String(frame.args).slice(0, 60)}`); - } - } - } - if (frame.type === "message_end") { - const m = frame.message; - if (typeof m?.text !== "string") errs.push("message_end.message.text not string"); - for (const c of m?.toolCalls ?? []) { - if (typeof c?.argsJson !== "string") errs.push(`toolCall ${c?.id} argsJson not string`); - if (typeof c?.name !== "string") errs.push(`toolCall ${c?.id} name not string`); - } - } - return errs; + const errs = []; + if ( + frame.type === "tool_execution_start" || + frame.type === "tool_execution_update" + ) { + // args: unknown is fine (deriveChat normalizes) — but circular/unserializable + // payloads would break the daemon's persistence; verify JSON round-trip. + if (frame.args !== undefined && frame.args !== null) { + try { + JSON.parse(JSON.stringify(frame.args)); + } catch { + errs.push( + `${frame.type}.args not JSON-safe: ${String(frame.args).slice(0, 60)}`, + ); + } + } + } + if (frame.type === "message_end") { + const m = frame.message; + if (typeof m?.text !== "string") + errs.push("message_end.message.text not string"); + for (const c of m?.toolCalls ?? []) { + if (typeof c?.argsJson !== "string") + errs.push(`toolCall ${c?.id} argsJson not string`); + if (typeof c?.name !== "string") + errs.push(`toolCall ${c?.id} name not string`); + } + } + return errs; } export async function run(ctx) { - const { r, base, token, agentUrl } = ctx; - const session = sessionSnapshot(SESSION_ID); - const agent = new WSSock(agentUrl, { headers: { Authorization: `Bearer ${token}` } }); - if (!(await agent.opened())) { - r.check("contract agent connects", false, "agent WS failed to open"); - return; - } - agentHello(agent, session); - await agent.waitForFrame((f) => f.type === "welcome"); + const { r, base, token, agentUrl } = ctx; + const session = sessionSnapshot(SESSION_ID); + const agent = new WSSock(agentUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!(await agent.opened())) { + r.check("contract agent connects", false, "agent WS failed to open"); + return; + } + agentHello(agent, session); + await agent.waitForFrame((f) => f.type === "welcome"); - // Frames shaped exactly as plugin/lvmh-agent.ts emits them for a real - // pi turn: object tool args (read tool), string message text, assistant - // toolCalls with stringified argsJson. Mirrors the crash trace: read tool - // with args {path} — the frame that produced React #31 before the fix. - const frames = [ - { type: "agent_start", seq: 1 }, - { - type: "tool_execution_start", - seq: 2, - toolCallId: "call-read-1", - toolName: "read", - args: { path: "src/main.ts", offset: 1 }, - }, - { - type: "tool_execution_update", - seq: 3, - toolCallId: "call-read-1", - toolName: "read", - partial: "…file body…", - }, - { - type: "tool_execution_end", - seq: 4, - toolCallId: "call-read-1", - toolName: "read", - isError: false, - resultPreview: "1: import x", - }, - { - type: "message_update", - seq: 5, - delta: "Reading ", - }, - { - type: "message_update", - seq: 6, - delta: "src/main.ts", - }, - { - type: "message_end", - seq: 7, - message: { - role: "assistant", - id: "msg-1", - text: "Reading src/main.ts", - thinking: null, - toolCalls: [{ id: "call-read-1", name: "read", argsJson: '{"path":"src/main.ts","offset":1}' }], - toolCallId: null, - }, - }, - { type: "agent_settled", seq: 8 }, - ]; - for (const f of frames) { - agent.send({ v: 1, sessionId: session.id, ts: Date.now(), ...f, seq: f.seq }); - } - // Wait for persistence to catch up, then read back everything the web would. - const persisted = await (async () => { - for (let i = 0; i < 40; i++) { - const res = await rest(base, token, `/api/sessions/${session.id}/events?after=0&limit=100`); - if (res.json?.length >= 6) return res.json; - await sleep(25); - } - return []; - })(); + // Frames shaped exactly as plugin/lvmh-agent.ts emits them for a real + // pi turn: object tool args (read tool), string message text, assistant + // toolCalls with stringified argsJson. Mirrors the crash trace: read tool + // with args {path} — the frame that produced React #31 before the fix. + const frames = [ + { type: "agent_start", seq: 1 }, + { + type: "tool_execution_start", + seq: 2, + toolCallId: "call-read-1", + toolName: "read", + args: { path: "src/main.ts", offset: 1 }, + }, + { + type: "tool_execution_update", + seq: 3, + toolCallId: "call-read-1", + toolName: "read", + partial: "…file body…", + }, + { + type: "tool_execution_end", + seq: 4, + toolCallId: "call-read-1", + toolName: "read", + isError: false, + resultPreview: "1: import x", + }, + { + type: "message_update", + seq: 5, + delta: "Reading ", + }, + { + type: "message_update", + seq: 6, + delta: "src/main.ts", + }, + { + type: "message_end", + seq: 7, + message: { + role: "assistant", + id: "msg-1", + text: "Reading src/main.ts", + thinking: null, + toolCalls: [ + { + id: "call-read-1", + name: "read", + argsJson: '{"path":"src/main.ts","offset":1}', + }, + ], + toolCallId: null, + }, + }, + { type: "agent_settled", seq: 8 }, + ]; + for (const f of frames) { + agent.send({ + v: 1, + sessionId: session.id, + ts: Date.now(), + ...f, + seq: f.seq, + }); + } + // Wait for persistence to catch up, then read back everything the web would. + const persisted = await (async () => { + for (let i = 0; i < 40; i++) { + const res = await rest( + base, + token, + `/api/sessions/${session.id}/events?after=0&limit=100`, + ); + if (res.json?.length >= 6) return res.json; + await sleep(25); + } + return []; + })(); - r.check("contract: all 6 persisted frames round-tripped", persisted.length === 6, `got ${persisted.length}`); - r.check( - "contract: message_update not persisted", - persisted.every((f) => f.type !== "message_update"), - ); + r.check( + "contract: all 6 persisted frames round-tripped", + persisted.length === 6, + `got ${persisted.length}`, + ); + r.check( + "contract: message_update not persisted", + persisted.every((f) => f.type !== "message_update"), + ); - const violations = persisted.flatMap(frameRenderViolations); - r.check( - "contract: every persisted frame is render-safe for the web", - violations.length === 0, - violations.join("; ").slice(0, 200) || "all shapes consumable", - ); + const violations = persisted.flatMap(frameRenderViolations); + r.check( + "contract: every persisted frame is render-safe for the web", + violations.length === 0, + violations.join("; ").slice(0, 200) || "all shapes consumable", + ); - const objectArgs = persisted.find((f) => f.type === "tool_execution_start"); - r.check( - "contract: object tool args survive persistence verbatim", - typeof objectArgs?.args === "object" && objectArgs.args?.path === "src/main.ts", - `args=${JSON.stringify(objectArgs?.args)}`, - ); + const objectArgs = persisted.find((f) => f.type === "tool_execution_start"); + r.check( + "contract: object tool args survive persistence verbatim", + typeof objectArgs?.args === "object" && + objectArgs.args?.path === "src/main.ts", + `args=${JSON.stringify(objectArgs?.args)}`, + ); - agent.close(); + agent.close(); } diff --git a/web/src/derive.test.ts b/web/src/derive.test.ts index d3a9fc7..09c93d8 100644 --- a/web/src/derive.test.ts +++ b/web/src/derive.test.ts @@ -54,7 +54,9 @@ function toolEnd(id: string, isError?: boolean, preview?: string): EventFrame { // render as-is (React #31 crash) — deriveChat must normalize to text. describe("object tool args from the real plugin", () => { it("argsText normalizes every shape the plugin emits", () => { - expect(argsText({ path: "src/main.ts" })).toBe('{\n "path": "src/main.ts"\n}'); + expect(argsText({ path: "src/main.ts" })).toBe( + '{\n "path": "src/main.ts"\n}', + ); expect(argsText('"plain"')).toBe('"plain"'); expect(argsText(42)).toBe("42"); expect(argsText(null)).toBe(""); diff --git a/web/src/derive.ts b/web/src/derive.ts index 312ef6e..739a48c 100644 --- a/web/src/derive.ts +++ b/web/src/derive.ts @@ -2,137 +2,143 @@ import type { EventFrame } from "./protocol"; // ---------- event list merge ---------- -export function mergeEvents(existing: EventFrame[], incoming: EventFrame[]): EventFrame[] { - const bySeq = new Map(); - for (const e of existing) bySeq.set(e.seq, e); - for (const e of incoming) bySeq.set(e.seq, e); - return Array.from(bySeq.values()).sort((a, b) => a.seq - b.seq); +export function mergeEvents( + existing: EventFrame[], + incoming: EventFrame[], +): EventFrame[] { + const bySeq = new Map(); + for (const e of existing) bySeq.set(e.seq, e); + for (const e of incoming) bySeq.set(e.seq, e); + return Array.from(bySeq.values()).sort((a, b) => a.seq - b.seq); } /** Highest persisted seq — deltas (`message_update`) are not persisted, so they * must not advance the refetch cursor. */ export function lastPersistedSeq(events: EventFrame[]): number { - let max = 0; - for (const e of events) if (e.type !== "message_update" && e.seq > max) max = e.seq; - return max; + let max = 0; + for (const e of events) + if (e.type !== "message_update" && e.seq > max) max = e.seq; + return max; } // ---------- chat view derivation ---------- export interface ToolState { - id: string; - name: string; - args: string; - running: boolean; - isError: boolean; - preview: string; + id: string; + name: string; + args: string; + running: boolean; + isError: boolean; + preview: string; } export interface ChatMessage { - key: string; - role: "user" | "assistant" | "system" | "toolResult"; - text: string; - thinking: string | null; - toolCalls: { id: string; name: string; argsJson: string }[]; - toolCallId: string | null; - streaming: boolean; + key: string; + role: "user" | "assistant" | "system" | "toolResult"; + text: string; + thinking: string | null; + toolCalls: { id: string; name: string; argsJson: string }[]; + toolCallId: string | null; + streaming: boolean; } export interface ChatDerivation { - messages: ChatMessage[]; - tools: Map; - busy: boolean; + messages: ChatMessage[]; + tools: Map; + busy: boolean; } // The plugin mirrors pi extension events verbatim: tool args arrive as a raw // JSON object (or string in older frames). Normalizing at this boundary keeps // the render layer string-only; regression: object args crashed React (#31). export function argsText(args: unknown): string { - if (typeof args === "string") return args; - if (args === null || args === undefined) return ""; - try { - return JSON.stringify(args, null, 2) ?? ""; - } catch { - return String(args); - } + if (typeof args === "string") return args; + if (args === null || args === undefined) return ""; + try { + return JSON.stringify(args, null, 2) ?? ""; + } catch { + return String(args); + } } export function deriveChat(events: EventFrame[]): ChatDerivation { - const messages: ChatMessage[] = []; - const tools = new Map(); - let stream: { id: string; text: string } | null = null; - let busy = false; + const messages: ChatMessage[] = []; + const tools = new Map(); + let stream: { id: string; text: string } | null = null; + let busy = false; - for (const e of events) { - switch (e.type) { - case "tool_execution_start": { - if (e.toolCallId !== undefined) { - tools.set(e.toolCallId, { - id: e.toolCallId, - name: e.toolName ?? "tool", - args: argsText(e.args), - running: true, - isError: false, - preview: "", - }); - } - break; - } - case "tool_execution_end": { - const t = e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined; - if (t !== undefined) { - t.running = false; - t.isError = e.isError ?? false; - t.preview = e.resultPreview ?? ""; - } - break; - } - case "agent_start": - busy = true; - break; - case "agent_settled": - busy = false; - break; - case "message_start": - if (e.message?.role === "assistant") stream = { id: e.message.id, text: "" }; - break; - case "message_update": - if (stream !== null) stream.text += e.delta ?? ""; - break; - case "message_end": { - const m = e.message; - if (m !== undefined) { - if (stream !== null && stream.id === m.id) stream = null; - messages.push({ - key: `msg-${e.seq}`, - role: m.role, - text: m.text, - thinking: m.thinking, - toolCalls: m.toolCalls ?? [], - toolCallId: m.toolCallId, - streaming: false, - }); - } - break; - } - default: - break; - } - } + for (const e of events) { + switch (e.type) { + case "tool_execution_start": { + if (e.toolCallId !== undefined) { + tools.set(e.toolCallId, { + id: e.toolCallId, + name: e.toolName ?? "tool", + args: argsText(e.args), + running: true, + isError: false, + preview: "", + }); + } + break; + } + case "tool_execution_end": { + const t = + e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined; + if (t !== undefined) { + t.running = false; + t.isError = e.isError ?? false; + t.preview = e.resultPreview ?? ""; + } + break; + } + case "agent_start": + busy = true; + break; + case "agent_settled": + busy = false; + break; + case "message_start": + if (e.message?.role === "assistant") + stream = { id: e.message.id, text: "" }; + break; + case "message_update": + if (stream !== null) stream.text += e.delta ?? ""; + break; + case "message_end": { + const m = e.message; + if (m !== undefined) { + if (stream !== null && stream.id === m.id) stream = null; + messages.push({ + key: `msg-${e.seq}`, + role: m.role, + text: m.text, + thinking: m.thinking, + toolCalls: m.toolCalls ?? [], + toolCallId: m.toolCallId, + streaming: false, + }); + } + break; + } + default: + break; + } + } - if (stream !== null) { - messages.push({ - key: `stream-${stream.id}`, - role: "assistant", - text: stream.text, - thinking: null, - toolCalls: [], - toolCallId: null, - streaming: true, - }); - } + if (stream !== null) { + messages.push({ + key: `stream-${stream.id}`, + role: "assistant", + text: stream.text, + thinking: null, + toolCalls: [], + toolCallId: null, + streaming: true, + }); + } - return { messages, tools, busy: busy || stream !== null }; + return { messages, tools, busy: busy || stream !== null }; } // ---------- todo / subagent derivation (client-side, per PROTOCOL.md) ---------- @@ -140,168 +146,190 @@ export function deriveChat(events: EventFrame[]): ChatDerivation { export type TodoStatus = "pending" | "in-progress" | "completed"; export interface TodoItem { - content: string; - status: TodoStatus; - deleted: boolean; + content: string; + status: TodoStatus; + deleted: boolean; } export interface SubagentRun { - key: string; - name: string; - running: boolean; - isError: boolean; + key: string; + name: string; + running: boolean; + isError: boolean; } export interface TaskDerivation { - todos: TodoItem[]; - subagents: SubagentRun[]; - workingTools: ToolState[]; + todos: TodoItem[]; + subagents: SubagentRun[]; + workingTools: ToolState[]; } const TODO_TOOL = "todo"; const SUBAGENT_TOOL = "subagent"; function parseJson(raw: unknown): unknown { - if (typeof raw === "object" && raw !== null) return raw; // already-decoded args - if (typeof raw !== "string" || raw.length === 0) return undefined; - try { - return JSON.parse(raw) as unknown; - } catch { - return undefined; - } + if (typeof raw === "object" && raw !== null) return raw; // already-decoded args + if (typeof raw !== "string" || raw.length === 0) return undefined; + try { + return JSON.parse(raw) as unknown; + } catch { + return undefined; + } } function normalizeStatus(raw: unknown): TodoStatus { - if (typeof raw !== "string") return "pending"; - switch (raw.toLowerCase()) { - case "in_progress": - case "in-progress": - case "inprogress": - case "in progress": - case "doing": - case "started": - return "in-progress"; - case "completed": - case "complete": - case "done": - return "completed"; - default: - return "pending"; - } + if (typeof raw !== "string") return "pending"; + switch (raw.toLowerCase()) { + case "in_progress": + case "in-progress": + case "inprogress": + case "in progress": + case "doing": + case "started": + return "in-progress"; + case "completed": + case "complete": + case "done": + return "completed"; + default: + return "pending"; + } } -function extractSnapshot(raw: unknown): { content: string; status: TodoStatus }[] | null { - let arr: unknown = raw; - if (Array.isArray(raw) === false && raw !== null && typeof raw === "object") { - const o = raw as Record; - const nested = o.todos ?? o.items ?? o.tasks ?? o.list; - if (Array.isArray(nested)) arr = nested; - } - if (Array.isArray(arr) === false) return null; - const items: { content: string; status: TodoStatus }[] = []; - for (const entry of arr) { - if (typeof entry === "string") { - items.push({ content: entry, status: "pending" }); - continue; - } - if (entry !== null && typeof entry === "object") { - const o = entry as Record; - const content = o.content ?? o.title ?? o.text ?? o.subject ?? o.summary; - if (typeof content === "string" && content.length > 0) { - items.push({ content, status: normalizeStatus(o.status) }); - } - } - } - return items.length > 0 ? items : null; +function extractSnapshot( + raw: unknown, +): { content: string; status: TodoStatus }[] | null { + let arr: unknown = raw; + if (Array.isArray(raw) === false && raw !== null && typeof raw === "object") { + const o = raw as Record; + const nested = o.todos ?? o.items ?? o.tasks ?? o.list; + if (Array.isArray(nested)) arr = nested; + } + if (Array.isArray(arr) === false) return null; + const items: { content: string; status: TodoStatus }[] = []; + for (const entry of arr) { + if (typeof entry === "string") { + items.push({ content: entry, status: "pending" }); + continue; + } + if (entry !== null && typeof entry === "object") { + const o = entry as Record; + const content = o.content ?? o.title ?? o.text ?? o.subject ?? o.summary; + if (typeof content === "string" && content.length > 0) { + items.push({ content, status: normalizeStatus(o.status) }); + } + } + } + return items.length > 0 ? items : null; } export function deriveTasks(events: EventFrame[]): TaskDerivation { - const toolNames = new Map(); // toolCallId -> toolName - for (const e of events) { - if (e.type === "tool_execution_start" && e.toolCallId !== undefined) { - toolNames.set(e.toolCallId, e.toolName ?? ""); - } - } + const toolNames = new Map(); // toolCallId -> toolName + for (const e of events) { + if (e.type === "tool_execution_start" && e.toolCallId !== undefined) { + toolNames.set(e.toolCallId, e.toolName ?? ""); + } + } - // todo snapshots, seq-ordered: args from execution start, result from execution end + toolResult message - const snapshots: { seq: number; items: { content: string; status: TodoStatus }[] }[] = []; - const subagents: SubagentRun[] = []; - const runningTools: ToolState[] = []; + // todo snapshots, seq-ordered: args from execution start, result from execution end + toolResult message + const snapshots: { + seq: number; + items: { content: string; status: TodoStatus }[]; + }[] = []; + const subagents: SubagentRun[] = []; + const runningTools: ToolState[] = []; - for (const e of events) { - if (e.type === "tool_execution_start" && e.toolCallId !== undefined) { - const name = e.toolName ?? ""; - if (name === TODO_TOOL) { - const snap = extractSnapshot(parseJson(e.args)); - if (snap !== null) snapshots.push({ seq: e.seq, items: snap }); - } else if (name === SUBAGENT_TOOL) { - const parsed = parseJson(e.args); - const o = parsed !== null && typeof parsed === "object" ? (parsed as Record) : {}; - const nameField = o.agentName ?? o.agent ?? o.name ?? o.agentType ?? o.role; - subagents.push({ - key: e.toolCallId, - name: typeof nameField === "string" && nameField.length > 0 ? nameField : "subagent", - running: true, - isError: false, - }); - } else { - runningTools.push({ - id: e.toolCallId, - name, - args: argsText(e.args), - running: true, - isError: false, - preview: "", - }); - } - } else if (e.type === "tool_execution_end" && e.toolCallId !== undefined) { - const name = toolNames.get(e.toolCallId) ?? ""; - if (name === SUBAGENT_TOOL) { - const run = subagents.find((s) => s.key === e.toolCallId); - if (run !== undefined) { - run.running = false; - run.isError = e.isError ?? false; - } - } else if (name !== TODO_TOOL) { - const t = runningTools.find((w) => w.id === e.toolCallId); - if (t !== undefined) { - t.running = false; - t.isError = e.isError ?? false; - t.preview = e.resultPreview ?? ""; - } - } - if (name === TODO_TOOL) { - const snap = extractSnapshot(parseJson(e.resultPreview)); - if (snap !== null) snapshots.push({ seq: e.seq, items: snap }); - } - } else if (e.type === "message_end" && e.message?.role === "toolResult" && e.message.toolCallId !== null) { - const name = toolNames.get(e.message.toolCallId) ?? ""; - if (name === TODO_TOOL) { - const snap = extractSnapshot(parseJson(e.message.text)); - if (snap !== null) snapshots.push({ seq: e.seq, items: snap }); - } - } - } + for (const e of events) { + if (e.type === "tool_execution_start" && e.toolCallId !== undefined) { + const name = e.toolName ?? ""; + if (name === TODO_TOOL) { + const snap = extractSnapshot(parseJson(e.args)); + if (snap !== null) snapshots.push({ seq: e.seq, items: snap }); + } else if (name === SUBAGENT_TOOL) { + const parsed = parseJson(e.args); + const o = + parsed !== null && typeof parsed === "object" + ? (parsed as Record) + : {}; + const nameField = + o.agentName ?? o.agent ?? o.name ?? o.agentType ?? o.role; + subagents.push({ + key: e.toolCallId, + name: + typeof nameField === "string" && nameField.length > 0 + ? nameField + : "subagent", + running: true, + isError: false, + }); + } else { + runningTools.push({ + id: e.toolCallId, + name, + args: argsText(e.args), + running: true, + isError: false, + preview: "", + }); + } + } else if (e.type === "tool_execution_end" && e.toolCallId !== undefined) { + const name = toolNames.get(e.toolCallId) ?? ""; + if (name === SUBAGENT_TOOL) { + const run = subagents.find((s) => s.key === e.toolCallId); + if (run !== undefined) { + run.running = false; + run.isError = e.isError ?? false; + } + } else if (name !== TODO_TOOL) { + const t = runningTools.find((w) => w.id === e.toolCallId); + if (t !== undefined) { + t.running = false; + t.isError = e.isError ?? false; + t.preview = e.resultPreview ?? ""; + } + } + if (name === TODO_TOOL) { + const snap = extractSnapshot(parseJson(e.resultPreview)); + if (snap !== null) snapshots.push({ seq: e.seq, items: snap }); + } + } else if ( + e.type === "message_end" && + e.message?.role === "toolResult" && + e.message.toolCallId !== null + ) { + const name = toolNames.get(e.message.toolCallId) ?? ""; + if (name === TODO_TOOL) { + const snap = extractSnapshot(parseJson(e.message.text)); + if (snap !== null) snapshots.push({ seq: e.seq, items: snap }); + } + } + } - // todos: latest snapshot wins; earlier items missing from it are deleted - const todos: TodoItem[] = []; - if (snapshots.length > 0) { - snapshots.sort((a, b) => a.seq - b.seq); - const latest = snapshots[snapshots.length - 1]?.items ?? []; - const seen = new Map(); - for (const snap of snapshots) { - for (const item of snap.items) if (!seen.has(item.content)) seen.set(item.content, item.status); - } - for (const item of latest) { - todos.push({ content: item.content, status: item.status, deleted: false }); - seen.delete(item.content); - } - for (const [content] of seen) todos.push({ content, status: "pending", deleted: true }); - } + // todos: latest snapshot wins; earlier items missing from it are deleted + const todos: TodoItem[] = []; + if (snapshots.length > 0) { + snapshots.sort((a, b) => a.seq - b.seq); + const latest = snapshots[snapshots.length - 1]?.items ?? []; + const seen = new Map(); + for (const snap of snapshots) { + for (const item of snap.items) + if (!seen.has(item.content)) seen.set(item.content, item.status); + } + for (const item of latest) { + todos.push({ + content: item.content, + status: item.status, + deleted: false, + }); + seen.delete(item.content); + } + for (const [content] of seen) + todos.push({ content, status: "pending", deleted: true }); + } - return { - todos, - subagents, - workingTools: runningTools.filter((t) => t.running), - }; + return { + todos, + subagents, + workingTools: runningTools.filter((t) => t.running), + }; } diff --git a/web/src/protocol.ts b/web/src/protocol.ts index 1aefb20..6401fe7 100644 --- a/web/src/protocol.ts +++ b/web/src/protocol.ts @@ -4,155 +4,159 @@ export const PROTOCOL_VERSION: 1 = 1; /** Event types emitted by the plugin, carried in the envelope `type` field. */ export const EventType = { - Hello: "hello", - MessageStart: "message_start", - MessageUpdate: "message_update", - MessageEnd: "message_end", - ToolExecutionStart: "tool_execution_start", - ToolExecutionUpdate: "tool_execution_update", - ToolExecutionEnd: "tool_execution_end", - AgentStart: "agent_start", - AgentEnd: "agent_end", - AgentSettled: "agent_settled", - SessionInfo: "session_info", - Bye: "bye", + Hello: "hello", + MessageStart: "message_start", + MessageUpdate: "message_update", + MessageEnd: "message_end", + ToolExecutionStart: "tool_execution_start", + ToolExecutionUpdate: "tool_execution_update", + ToolExecutionEnd: "tool_execution_end", + AgentStart: "agent_start", + AgentEnd: "agent_end", + AgentSettled: "agent_settled", + SessionInfo: "session_info", + Bye: "bye", } as const; export type EventType = (typeof EventType)[keyof typeof EventType]; /** REST routes (base `/api`, bearer auth). */ export const Route = { - Sessions: "/api/sessions", - SessionEvents: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/events`, - SessionPrompt: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/prompt`, - SessionAbort: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/abort`, - SessionContainer: (id: string): string => `/api/sessions/${encodeURIComponent(id)}/container`, - Spawn: "/api/spawn", - SpawnStatus: "/api/spawn/status", - GitlabStatus: "/api/gitlab/status", - GitlabConnect: "/api/gitlab/connect", - GitlabRepos: "/api/gitlab/repos", + Sessions: "/api/sessions", + SessionEvents: (id: string): string => + `/api/sessions/${encodeURIComponent(id)}/events`, + SessionPrompt: (id: string): string => + `/api/sessions/${encodeURIComponent(id)}/prompt`, + SessionAbort: (id: string): string => + `/api/sessions/${encodeURIComponent(id)}/abort`, + SessionContainer: (id: string): string => + `/api/sessions/${encodeURIComponent(id)}/container`, + Spawn: "/api/spawn", + SpawnStatus: "/api/spawn/status", + GitlabStatus: "/api/gitlab/status", + GitlabConnect: "/api/gitlab/connect", + GitlabRepos: "/api/gitlab/repos", } as const; /** Session snapshot, carried by `hello`/`session_info` and REST session list. */ export interface SessionInfo { - id: string; - name: string | null; - cwd: string; - model: string; - provider: string; - /** true if spawned-by-daemon container session */ - agent: boolean; - repo: string | null; - startedAt: number; + id: string; + name: string | null; + cwd: string; + model: string; + provider: string; + /** true if spawned-by-daemon container session */ + agent: boolean; + repo: string | null; + startedAt: number; } /** Session list row (REST `GET /api/sessions` + WS `session_list`). */ export interface SessionListItem extends SessionInfo { - online: boolean; - lastEventAt: number | null; + online: boolean; + lastEventAt: number | null; } export type MessageRole = "user" | "assistant" | "toolResult" | "system"; export interface ToolCall { - id: string; - name: string; - argsJson: string; + id: string; + name: string; + argsJson: string; } export interface Message { - role: MessageRole; - id: string; - text: string; - thinking: string | null; - toolCalls: ToolCall[]; - /** toolResult messages: which call this answers */ - toolCallId: string | null; + role: MessageRole; + id: string; + text: string; + thinking: string | null; + toolCalls: ToolCall[]; + /** toolResult messages: which call this answers */ + toolCallId: string | null; } /** Persisted event envelope + flattened payload. */ export interface EventFrame { - v: 1; - sessionId: string; - /** monotonic per-session, plugin-assigned, starts at 1 */ - seq: number; - /** unix ms */ - ts: number; - type: EventType | string; - // ---- payload fields (union, present depending on `type`) ---- - session?: SessionInfo; - message?: Message; - delta?: string; - toolCallId?: string; - toolName?: string; - args?: unknown; - partial?: string; - isError?: boolean; - resultPreview?: string; - usage?: { inputTokens?: number; outputTokens?: number; totalCost?: number }; - reason?: string; + v: 1; + sessionId: string; + /** monotonic per-session, plugin-assigned, starts at 1 */ + seq: number; + /** unix ms */ + ts: number; + type: EventType | string; + // ---- payload fields (union, present depending on `type`) ---- + session?: SessionInfo; + message?: Message; + delta?: string; + toolCallId?: string; + toolName?: string; + args?: unknown; + partial?: string; + isError?: boolean; + resultPreview?: string; + usage?: { inputTokens?: number; outputTokens?: number; totalCost?: number }; + reason?: string; } /** Query params + response shapes for REST routes. */ export interface PromptBody { - message: string; + message: string; } export interface PromptResponse { - ok: boolean; + ok: boolean; } export interface AbortResponse { - ok: boolean; + ok: boolean; } export interface ContainerResponse { - ok: boolean; + ok: boolean; } export interface SpawnBody { - repo: string; - branch?: string; + repo: string; + branch?: string; } export interface SpawnResponse { - sessionId: string; - containerId: string; + sessionId: string; + containerId: string; } export interface SpawnJob { - repo: string; - state: string; - containerId?: string; - sessionId?: string; + repo: string; + state: string; + containerId?: string; + sessionId?: string; } export interface GitlabStatus { - connected: boolean; - baseUrl: string; - username?: string; + connected: boolean; + baseUrl: string; + username?: string; } export interface GitlabConnectResponse { - username: string; + username: string; } export interface Repo { - path: string; - name: string; - namespace: string; - lastActivityAt: string; - webUrl: string; - defaultBranch: string; + path: string; + name: string; + namespace: string; + lastActivityAt: string; + webUrl: string; + defaultBranch: string; } /** ---- WS transport 3 (browser → daemon) ---- */ export type ServerFrame = - | { type: "session_list"; sessions: SessionListItem[] } - | { type: "events"; sessionId: string; after: number; events: EventFrame[] } - | { type: "spawn_status"; jobs: SpawnJob[] }; + | { type: "session_list"; sessions: SessionListItem[] } + | { type: "events"; sessionId: string; after: number; events: EventFrame[] } + | { type: "spawn_status"; jobs: SpawnJob[] }; export type ClientFrame = - | { type: "subscribe"; sessionId: string } - | { type: "unsubscribe"; sessionId: string }; + | { type: "subscribe"; sessionId: string } + | { type: "unsubscribe"; sessionId: string };