web: fix args-object React crash; e2e plugin-contract scenario

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