/** * Codex multi-agent wire fixtures. * * The child-interception path is the highest-blast-radius code in the * subagent stack: it decides, per notification, whether traffic reaches the * parent timeline at all. Three shipped bugs came from that decision being * implicit (root thread registered as its own child; `error` and * `serverRequest/resolved` swallowed by a catch-all). These tests pin the * decision against a REAL capture rather than hand-written shapes. * * Fixture provenance: codex-cli 0.145.0 driven directly over stdio with * gpt-5.6-luna at low effort, prompting a two-child fan-out (alpha, beta). * See codexMultiAgentWire.json. */ import { assert, describe, it } from "vite-plus/test"; import fixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; import { routeCodexChildNotification } from "./CodexSessionRuntime.ts"; interface WireNotification { readonly method: string; readonly params: Record; } const notifications = fixture.notifications as ReadonlyArray; const rootThreadId = fixture.rootThreadId; const childThreadIds = new Set(fixture.childThreadIds); /** Mirrors readNotificationThreadId's addressing for the captured methods. */ function notificationThreadId(entry: WireNotification): string | undefined { const params = entry.params; const thread = params.thread; if ( typeof thread === "object" && thread !== null && typeof (thread as { id?: unknown }).id === "string" ) { return (thread as { id: string }).id; } return typeof params.threadId === "string" ? params.threadId : undefined; } function subAgentActivityItems(): ReadonlyArray> { return notifications.flatMap((entry) => { const item = entry.params.item; if (typeof item !== "object" || item === null) return []; const record = item as Record; return record.type === "subAgentActivity" ? [record] : []; }); } describe("codex multi-agent wire capture", () => { it("captures a real two-child fan-out", () => { assert.equal(fixture.capturedWith.model, "gpt-5.6-luna"); assert.equal(childThreadIds.size, 2); const paths = subAgentActivityItems().map((item) => item.agentPath); assert.include(paths, "/root/alpha"); assert.include(paths, "/root/beta"); }); it("emits child traffic BEFORE the item that registers the child", () => { // Ordering hazard: the child's own thread/status/changed arrives before // the parent-side subAgentActivity naming it. Registration must tolerate // child-first arrival, so unregistered child traffic passes through // rather than being eaten (no regression vs. pre-feature behavior). const firstChildTraffic = notifications.findIndex((entry) => { const threadId = notificationThreadId(entry); return threadId !== undefined && childThreadIds.has(threadId); }); const firstRegistration = notifications.findIndex((entry) => { const item = entry.params.item; if (typeof item !== "object" || item === null) return false; const record = item as Record; return record.type === "subAgentActivity" && record.kind === "started"; }); assert.isAtLeast(firstChildTraffic, 0); assert.isAtLeast(firstRegistration, 0); assert.isBelow( firstChildTraffic, firstRegistration, "capture should exercise child-first ordering", ); }); it("contains a /root self-activity emitted from a CHILD thread", () => { // The bug this guards: the wire reports subAgentActivity about the ROOT // (agentPath "/root"). Registering it made the runtime intercept the // parent's own final message and turn/completed, so the thread hung // "working" forever. The root guard must key on agentPath/thread id, // never on which thread the notification arrived from. const rootSelfActivity = subAgentActivityItems().find((item) => item.agentPath === "/root"); assert.isDefined(rootSelfActivity, "capture should contain a /root self-activity"); assert.equal(rootSelfActivity?.agentThreadId, rootThreadId); }); it("routes every captured child method to a defined disposition", () => { const childMethods = new Set( notifications .filter((entry) => { const threadId = notificationThreadId(entry); return threadId !== undefined && childThreadIds.has(threadId); }) .map((entry) => entry.method), ); assert.isAbove(childMethods.size, 0); for (const method of childMethods) { const route = routeCodexChildNotification(method); // Child lifecycle traffic must become agent events — never silently // dropped, never leaked to the parent timeline. assert.equal(route, "agent-event", `${method} should map to an agent event`); } }); }); describe("routeCodexChildNotification", () => { it("maps child lifecycle to agent events", () => { for (const method of [ "turn/started", "turn/completed", "thread/status/changed", "thread/tokenUsage/updated", "thread/settings/updated", "model/rerouted", "item/started", "item/completed", "thread/closed", "error", ]) { assert.equal(routeCodexChildNotification(method), "agent-event", method); } }); it("drops only enumerated child chatter", () => { for (const method of [ "item/agentMessage/delta", "item/reasoning/textDelta", "item/commandExecution/outputDelta", "turn/plan/updated", "thread/name/updated", ]) { assert.equal(routeCodexChildNotification(method), "drop", method); } }); it("never routes child-owned thread lifecycle to the parent", () => { // These mutate PARENT thread state in CodexAdapter (archived/compacted), // so a child emitting them must never reach the parent path. This list // mirrors shouldSuppressChildConversationNotification (the v1 collab // suppressor) — the two must not drift (review finding: the router // initially omitted them and they leaked). for (const method of [ "thread/started", "thread/status/changed", "thread/archived", "thread/unarchived", "thread/closed", "thread/compacted", "thread/name/updated", "thread/tokenUsage/updated", "turn/started", "turn/completed", "turn/plan/updated", "item/plan/delta", "thread/settings/updated", "model/rerouted", ]) { assert.notEqual( routeCodexChildNotification(method), "parent", `${method} is child-owned and must not reach the parent path`, ); } }); it("sends parent-owned and UNKNOWN methods to the parent path", () => { // serverRequest/resolved clears the parent's approval correlation: // swallowing it left approvals stuck (shipped bug). Unknown methods take // the same route by design — a codex update that adds a notification // must degrade to "parent sees it", never to silent loss. assert.equal(routeCodexChildNotification("serverRequest/resolved"), "parent"); assert.equal(routeCodexChildNotification("thread/somethingBrandNew"), "parent"); assert.equal(routeCodexChildNotification("account/rateLimits/updated"), "parent"); }); });