web: fix args-object React crash; e2e plugin-contract scenario
This commit is contained in:
+151
-113
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user