web: fix args-object React crash; e2e plugin-contract scenario
This commit is contained in:
+24
-7
@@ -81,7 +81,9 @@ process.on("SIGTERM", () => {
|
||||
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(
|
||||
`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-"));
|
||||
@@ -99,10 +101,13 @@ async function main() {
|
||||
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 });
|
||||
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(
|
||||
`daemon: ${daemon.baseUrl} (go run . in ${path.relative(REPO_ROOT, DAEMON_DIR)}, db ${dbPath})`,
|
||||
);
|
||||
console.log(`fake gitlab: ${fakeGitLab.url}`);
|
||||
|
||||
const ctx = {
|
||||
@@ -125,18 +130,30 @@ async function main() {
|
||||
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 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}`);
|
||||
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(
|
||||
` [${e.scenario}] ${e.name}${e.detail ? " — " + e.detail : ""}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(`RESULT: ${fail === 0 ? "GREEN" : "RED"}`);
|
||||
|
||||
@@ -9,30 +9,45 @@
|
||||
// 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") {
|
||||
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)}`);
|
||||
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");
|
||||
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 (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;
|
||||
@@ -41,7 +56,9 @@ function frameRenderViolations(frame) {
|
||||
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}` } });
|
||||
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;
|
||||
@@ -95,26 +112,46 @@ export async function run(ctx) {
|
||||
id: "msg-1",
|
||||
text: "Reading src/main.ts",
|
||||
thinking: null,
|
||||
toolCalls: [{ id: "call-read-1", name: "read", argsJson: '{"path":"src/main.ts","offset":1}' }],
|
||||
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 });
|
||||
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`);
|
||||
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: 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"),
|
||||
@@ -130,7 +167,8 @@ export async function run(ctx) {
|
||||
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",
|
||||
typeof objectArgs?.args === "object" &&
|
||||
objectArgs.args?.path === "src/main.ts",
|
||||
`args=${JSON.stringify(objectArgs?.args)}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -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("");
|
||||
|
||||
+41
-13
@@ -2,7 +2,10 @@ import type { EventFrame } from "./protocol";
|
||||
|
||||
// ---------- event list merge ----------
|
||||
|
||||
export function mergeEvents(existing: EventFrame[], incoming: EventFrame[]): EventFrame[] {
|
||||
export function mergeEvents(
|
||||
existing: EventFrame[],
|
||||
incoming: EventFrame[],
|
||||
): EventFrame[] {
|
||||
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);
|
||||
@@ -13,7 +16,8 @@ export function mergeEvents(existing: EventFrame[], incoming: EventFrame[]): Eve
|
||||
* 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;
|
||||
for (const e of events)
|
||||
if (e.type !== "message_update" && e.seq > max) max = e.seq;
|
||||
return max;
|
||||
}
|
||||
|
||||
@@ -79,7 +83,8 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
|
||||
break;
|
||||
}
|
||||
case "tool_execution_end": {
|
||||
const t = e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined;
|
||||
const t =
|
||||
e.toolCallId !== undefined ? tools.get(e.toolCallId) : undefined;
|
||||
if (t !== undefined) {
|
||||
t.running = false;
|
||||
t.isError = e.isError ?? false;
|
||||
@@ -94,7 +99,8 @@ export function deriveChat(events: EventFrame[]): ChatDerivation {
|
||||
busy = false;
|
||||
break;
|
||||
case "message_start":
|
||||
if (e.message?.role === "assistant") stream = { id: e.message.id, text: "" };
|
||||
if (e.message?.role === "assistant")
|
||||
stream = { id: e.message.id, text: "" };
|
||||
break;
|
||||
case "message_update":
|
||||
if (stream !== null) stream.text += e.delta ?? "";
|
||||
@@ -190,7 +196,9 @@ function normalizeStatus(raw: unknown): TodoStatus {
|
||||
}
|
||||
}
|
||||
|
||||
function extractSnapshot(raw: unknown): { content: string; status: TodoStatus }[] | 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<string, unknown>;
|
||||
@@ -224,7 +232,10 @@ export function deriveTasks(events: EventFrame[]): TaskDerivation {
|
||||
}
|
||||
|
||||
// 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: {
|
||||
seq: number;
|
||||
items: { content: string; status: TodoStatus }[];
|
||||
}[] = [];
|
||||
const subagents: SubagentRun[] = [];
|
||||
const runningTools: ToolState[] = [];
|
||||
|
||||
@@ -236,11 +247,18 @@ export function deriveTasks(events: EventFrame[]): TaskDerivation {
|
||||
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<string, unknown>) : {};
|
||||
const nameField = o.agentName ?? o.agent ?? o.name ?? o.agentType ?? o.role;
|
||||
const o =
|
||||
parsed !== null && typeof parsed === "object"
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
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",
|
||||
name:
|
||||
typeof nameField === "string" && nameField.length > 0
|
||||
? nameField
|
||||
: "subagent",
|
||||
running: true,
|
||||
isError: false,
|
||||
});
|
||||
@@ -274,7 +292,11 @@ export function deriveTasks(events: EventFrame[]): TaskDerivation {
|
||||
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) {
|
||||
} 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));
|
||||
@@ -290,13 +312,19 @@ export function deriveTasks(events: EventFrame[]): TaskDerivation {
|
||||
const latest = snapshots[snapshots.length - 1]?.items ?? [];
|
||||
const seen = new Map<string, TodoStatus>();
|
||||
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 });
|
||||
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 });
|
||||
for (const [content] of seen)
|
||||
todos.push({ content, status: "pending", deleted: true });
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
+8
-4
@@ -22,10 +22,14 @@ 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`,
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user