// plugin-contract.mjs — golden-trace scenario: replays frames captured from // the REAL plugin (plugin/lvmh-agent.ts mirroring real pi events) through the // daemon and asserts every persisted frame the web would render is // render-safe: tool args may be objects (plugin mirrors pi verbatim), message // text is string, toolCalls argsJson is string. // // Why this exists: every scripted scenario spoke the hand-typed contract // (args: string) while the real plugin emits args as raw JSON objects — the // 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"; 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; } 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"); // 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"), ); 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)}`, ); agent.close(); }