/** * Runtime-level collab regression: boots the REAL CodexSessionRuntime against * a scripted mock app-server peer that replays the captured multi-agent wire * sequence (codexMultiAgentWire.json) plus the shapes the capture alone can't * script (receiver-turn bookkeeping via collabAgentToolCall, child terminal * lifecycle, approval pass-through). This is the layer the pure routing-table * test can't reach: ordering between the legacy receiver-turn suppressor and * v2 interception, registration state, and synthetic event emission. */ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFS from "node:fs"; import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { ThreadId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Stream from "effect/Stream"; import { assert, describe } from "vite-plus/test"; import wireFixture from "../testFixtures/codexMultiAgentWire.json" with { type: "json" }; import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; const ROOT = wireFixture.rootThreadId; const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; /** * The captured sequence, extended with the shapes the live capture didn't * include: a collabAgentToolCall with receiverThreadIds (feeds the legacy * receiver-turn map, so ordering vs. v2 interception is exercised), child * terminal lifecycle, and a serverRequest/resolved addressed to a child * (must pass through to the parent path, not vanish). */ function buildScript() { const captured = wireFixture.notifications; // Re-address the capture's schema-valid tool lifecycle pair to child A. // Hand-written item shapes can drift from the generated Codex schema. const childToolLifecycle = captured .filter( (entry) => (entry.method === "item/started" || entry.method === "item/completed") && (entry.params as { item?: { type?: string } }).item?.type === "collabAgentToolCall", ) .map((entry) => { const params = entry.params as { readonly threadId: string; readonly turnId: string; readonly item: Record; }; return { ...entry, params: { ...params, threadId: CHILD_A, turnId: `${CHILD_A}-turn-1`, item: { ...params.item, id: "call_fixture_child_tool", senderThreadId: CHILD_A, }, }, }; }); const childCompletionOnly = childToolLifecycle.find((entry) => entry.method === "item/completed"); assert.isDefined(childCompletionOnly); const extras = [ { method: "item/completed", params: { threadId: ROOT, item: { type: "collabAgentToolCall", id: "call_fixture_wait", tool: "wait", status: "completed", senderThreadId: ROOT, receiverThreadIds: [CHILD_A, CHILD_B], }, }, }, // Codex emits started + completed for one child item. They are one // logical activity and must not become two synthetic agent updates. ...childToolLifecycle, { ...childCompletionOnly, params: { ...childCompletionOnly.params, item: { ...childCompletionOnly.params.item, id: "call_fixture_child_completion_only", }, }, }, // Child terminal lifecycle AFTER the receiver map knows the children — // pre-fix, the legacy suppressor dropped these before interception saw // them, so no synthetic agent events were emitted. { method: "turn/completed", params: { threadId: CHILD_A, turn: { id: `${CHILD_A}-turn-1`, status: "completed", items: [] }, }, }, { method: "thread/closed", params: { threadId: CHILD_B } }, // Parent-owned traffic addressed to a child conversation: must reach the // parent path (approval correlation cleanup), not be swallowed. { method: "serverRequest/resolved", params: { threadId: CHILD_A, requestId: "req-1" } }, ]; return { rootThreadId: ROOT, notifications: [...captured.filter((entry) => entry.method !== "turn/completed"), ...extras], }; } const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); describe("CodexSessionRuntime collab integration", () => { it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => Effect.gen(function* () { // @effect-diagnostics-next-line preferSchemaOverJson:off NodeFS.writeFileSync(scriptPath, JSON.stringify(buildScript()), "utf8"); yield* Effect.addFinalizer(() => Effect.sync(() => NodeFS.rmSync(scriptPath, { force: true })), ); const runtime = yield* makeCodexSessionRuntime({ threadId: ThreadId.make("thread-collab-integration"), binaryPath: peerPath, cwd: "/tmp", runtimeMode: "full-access", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); const eventsFiber = yield* runtime.events.pipe( Stream.takeUntil((event) => event.method === "turn/completed"), Stream.runCollect, Effect.forkScoped, ); yield* runtime.start(); yield* runtime.sendTurn({ input: "fan out" }); const events = Array.from(yield* Fiber.join(eventsFiber)); const methods = events.map((event) => event.method); // Children registered from subAgentActivity become synthetic agent // lifecycle — including terminal rows that arrive AFTER the receiver // map knows them (the ordering this test exists to pin). assert.include(methods, "collabAgent/activity"); assert.include(methods, "collabAgent/turnCompleted"); assert.include(methods, "collabAgent/closed"); const childTurnCompleted = events.find( (event) => event.method === "collabAgent/turnCompleted" && (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_A, ); assert.isDefined(childTurnCompleted, "child A's turn completion becomes an agent event"); const childClosed = events.find( (event) => event.method === "collabAgent/closed" && (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, ); assert.isDefined(childClosed, "child B's close becomes an agent event"); const childToolItems = events.filter( (event) => event.method === "collabAgent/item" && (event.payload as { item?: { id?: string } }).item?.id === "call_fixture_child_tool", ); assert.lengthOf(childToolItems, 1, "one child item must emit one agent activity"); assert.equal(childToolItems[0]?.itemId, "call_fixture_child_tool"); assert.equal( (childToolItems[0]?.payload as { itemLifecycle?: string }).itemLifecycle, "started", ); const completionOnly = events.find( (event) => event.method === "collabAgent/item" && (event.payload as { item?: { id?: string } }).item?.id === "call_fixture_child_completion_only", ); assert.equal( (completionOnly?.payload as { itemLifecycle?: string }).itemLifecycle, "completed", ); // Parent-owned resolution passes through — not swallowed, not // re-labelled as an agent event. assert.include(methods, "serverRequest/resolved"); // The root's own subAgentActivity about "/root" must NOT register the // root as a child: the parent turn completion still flows. assert.include(methods, "turn/completed"); // No raw child conversation methods leak onto the parent stream. const leaked = events.filter((event) => { const payload = event.payload as { threadId?: string } | undefined; const addressedToChild = payload?.threadId === CHILD_A || payload?.threadId === CHILD_B; return addressedToChild && (event.method?.startsWith("thread/") ?? false); }); assert.deepEqual( leaked.map((event) => event.method), [], "child thread/* lifecycle must not appear as parent events", ); yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); // it.live: the runtime talks to a real child process; under it.effect's // TestClock the internal timers freeze and the join never completes. it.live("Stop interrupts every live child regardless of registration timing", () => Effect.gen(function* () { // Ordering + liveness torture for stop-everything: child A's // turn/started arrives BEFORE anything registers it (foreign // suppression path must record the live turn); child B's arrives after // registration; child A's interrupt HANGS (RPC never settles — worse // than rejecting) and the bounded deadline must still deliver B's and // the parent's interrupts. The turn stays open so children are live // when Stop fires. // Build from REAL captured rows (hand-written shapes fail notification // schema validation and are silently dropped): reorder so child A's // turn/started precedes its registration, and drop terminal rows so // children stay live when Stop fires. const byIndex = wireFixture.notifications; const isTurnStarted = (entry: (typeof byIndex)[number], child: string) => entry.method === "turn/started" && (entry.params as { threadId?: string }).threadId === child; const isRegistration = (entry: (typeof byIndex)[number], child: string) => { const item = (entry.params as { item?: { type?: string; agentThreadId?: string } }).item; return item?.type === "subAgentActivity" && item.agentThreadId === child; }; const turnStartedA = byIndex.find((entry) => isTurnStarted(entry, CHILD_A)); const turnStartedB = byIndex.find((entry) => isTurnStarted(entry, CHILD_B)); const registrationA = byIndex.find((entry) => isRegistration(entry, CHILD_A)); const registrationB = byIndex.find((entry) => isRegistration(entry, CHILD_B)); assert.isDefined(turnStartedA); assert.isDefined(turnStartedB); assert.isDefined(registrationA); assert.isDefined(registrationB); const script = { rootThreadId: ROOT, holdTurnOpen: true, hangInterruptFor: CHILD_A, notifications: [turnStartedA, registrationA, registrationB, turnStartedB], }; // @effect-diagnostics-next-line preferSchemaOverJson:off NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); const interruptsPath = `${scriptPath}.interrupts`; NodeFS.rmSync(interruptsPath, { force: true }); yield* Effect.addFinalizer(() => Effect.sync(() => { NodeFS.rmSync(scriptPath, { force: true }); NodeFS.rmSync(interruptsPath, { force: true }); }), ); const runtime = yield* makeCodexSessionRuntime({ threadId: ThreadId.make("thread-collab-stop"), binaryPath: peerPath, cwd: "/tmp", runtimeMode: "full-access", environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, }); // Wait for both children's turnStarted signals to be processed before // stopping (B via the registered-child path; A only produces live-turn // bookkeeping, so key on B's synthetic event). const childBStartedFiber = yield* runtime.events.pipe( Stream.filter( (event) => event.method === "collabAgent/turnStarted" && (event.payload as { agentThreadId?: string }).agentThreadId === CHILD_B, ), Stream.take(1), Stream.runCollect, Effect.forkScoped, ); yield* runtime.start(); yield* runtime.sendTurn({ input: "fan out and hang" }); const childBStarted = yield* Fiber.join(childBStartedFiber).pipe( Effect.timeoutOption("15 seconds"), ); assert.isTrue(childBStarted._tag === "Some", "child B turnStarted never arrived"); // Stop everything. A's interrupt hangs forever — the bounded child // deadline must expire and the parent interrupt must still be sent. yield* runtime.interruptTurn(); const parseInterruptLine = (line: string) => JSON.parse(line) as { threadId?: string }; const interrupted = NodeFS.readFileSync(interruptsPath, "utf8") .trim() .split("\n") .filter((line) => line.length > 0) .map(parseInterruptLine); const interruptedThreads = new Set(interrupted.map((entry) => entry.threadId)); assert.isTrue( interruptedThreads.has(CHILD_A), "pre-registration child A must still receive the interrupt RPC", ); assert.isTrue(interruptedThreads.has(CHILD_B), "registered child B must be interrupted"); assert.isTrue(interruptedThreads.has(ROOT), "parent turn must be interrupted last"); yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); });