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 { 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());
+151 -113
View File
@@ -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();
}