diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 3dae02feac84..6a1b6f4d1426 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -512,24 +512,141 @@ function startLifecycleRuntime() { }); } +const childEvent = (id: string, method: string, payload: Record) => ({ + id: asEventId(id), + kind: "notification" as const, + provider: ProviderDriverKind.make("codex"), + createdAt: "2026-01-01T00:00:00.000Z", + method, + threadId: asThreadId("thread-1"), + turnId: asTurnId("turn-1"), + payload, +}); + lifecycleLayer("CodexAdapterLive lifecycle", (it) => { - it.effect("does not reactivate an idle child after a parent interaction", () => + it.effect("preserves a nested spawning child as the parent", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit( + childEvent("evt-child-b-started", "collabAgent/activity", { + agentThreadId: "child-b", + agentPath: "/root/child-a/child-b", + activityKind: "started", + parentThreadId: "child-a", + }), + ); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.ok(firstEvent._tag === "Some"); + NodeAssert.ok(firstEvent.value.type === "task.started"); + NodeAssert.equal(firstEvent.value.payload.taskId, "child-b"); + NodeAssert.equal(firstEvent.value.payload.parentAgentId, "child-a"); + }), + ); + + it.effect("preserves the parent on collabAgent/started", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit( + childEvent("evt-child-announced", "collabAgent/started", { + agentThreadId: "child-1", + agentPath: "/root/parent/child", + parentThreadId: "parent-1", + }), + ); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.ok(firstEvent._tag === "Some"); + NodeAssert.ok(firstEvent.value.type === "task.started"); + NodeAssert.equal(firstEvent.value.payload.taskId, "child-1"); + NodeAssert.equal(firstEvent.value.payload.parentAgentId, "parent-1"); + }), + ); + + it.effect("does not start a task for an interacted activity", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const firstEventFiber = yield* Stream.runHead(adapter.streamEvents).pipe(Effect.forkChild); + + yield* runtime.emit( + childEvent("evt-child-interacted", "collabAgent/activity", { + agentThreadId: "child-1", + agentPath: "/root/parent/child", + activityKind: "interacted", + parentThreadId: "parent-1", + }), + ); + yield* runtime.emit( + childEvent("evt-sentinel-running", "collabAgent/turnStarted", { + agentThreadId: "child-2", + agentPath: "/root/sentinel", + }), + ); + + const firstEvent = yield* Fiber.join(firstEventFiber); + NodeAssert.ok(firstEvent._tag === "Some"); + NodeAssert.ok(firstEvent.value.type === "task.updated"); + NodeAssert.equal(firstEvent.value.payload.taskId, "child-2"); + NodeAssert.equal(firstEvent.value.payload.status, "running"); + }), + ); + + it.effect("repeats the parent on child lifecycle rows", () => Effect.gen(function* () { const { adapter, runtime } = yield* startLifecycleRuntime(); const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( Effect.forkChild, ); - const childEvent = (id: string, method: string, payload: Record) => ({ - id: asEventId(id), - kind: "notification" as const, - provider: ProviderDriverKind.make("codex"), - createdAt: "2026-01-01T00:00:00.000Z", - method, - threadId: asThreadId("thread-1"), - turnId: asTurnId("turn-1"), - payload, - }); + yield* runtime.emit( + childEvent("evt-child-interrupted", "collabAgent/activity", { + agentThreadId: "child-1", + agentPath: "/root/parent/child", + activityKind: "interrupted", + parentThreadId: "parent-1", + }), + ); + yield* runtime.emit( + childEvent("evt-child-usage", "collabAgent/tokenUsage", { + agentThreadId: "child-1", + agentPath: "/root/parent/child", + parentThreadId: "parent-1", + tokenUsage: { total: { totalTokens: 1 } }, + }), + ); + yield* runtime.emit( + childEvent("evt-child-item", "collabAgent/item", { + agentThreadId: "child-1", + agentPath: "/root/parent/child", + parentThreadId: "parent-1", + item: { type: "commandExecution", command: "pwd" }, + }), + ); + + const events = Array.from(yield* Fiber.join(eventsFiber)); + const updated = events[0]; + NodeAssert.ok(updated?.type === "task.updated"); + NodeAssert.equal(updated.payload.status, "interrupted"); + NodeAssert.equal(updated.payload.parentAgentId, "parent-1"); + const usageProgress = events[1]; + NodeAssert.ok(usageProgress?.type === "task.progress"); + NodeAssert.equal(usageProgress.payload.parentAgentId, "parent-1"); + const itemProgress = events[2]; + NodeAssert.ok(itemProgress?.type === "task.progress"); + NodeAssert.equal(itemProgress.payload.parentAgentId, "parent-1"); + }), + ); + + it.effect("does not reactivate an idle child after a parent interaction", () => + Effect.gen(function* () { + const { adapter, runtime } = yield* startLifecycleRuntime(); + const eventsFiber = yield* Stream.runCollect(Stream.take(adapter.streamEvents, 3)).pipe( + Effect.forkChild, + ); yield* runtime.emit( childEvent("evt-child-running", "collabAgent/turnStarted", { diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 7cef4911bc0c..d26be462390f 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -530,35 +530,33 @@ function mapCollabAgentEvent( // finding: progress rows renamed math_one to its UUID). const knownName = nickname ?? pathLeaf; const title = knownName ?? agentThreadId; - // Identity repeated on every status patch so rows are self-describing when + // Identity repeated on every lifecycle row so rows are self-describing when // the start row ages out of activity retention (review finding: a // reconstructed agent had a UUID name and no role/path). const statusLinkage = { role, ...(knownName ? { title: knownName } : {}), ...(agentPath ? { agentPath } : {}), + ...(typeof payload.parentThreadId === "string" + ? { parentAgentId: payload.parentThreadId } + : {}), timelineBypass: true, } as const; + const buildTaskStarted = (): ProviderRuntimeEvent => ({ + ...base, + type: "task.started", + payload: { + ...statusLinkage, + taskId, + description: title, + title, + }, + }); + switch (event.method) { case "collabAgent/started": - return [ - { - ...base, - type: "task.started", - payload: { - taskId, - description: title, - title, - role, - ...(agentPath ? { agentPath } : {}), - ...(typeof payload.parentThreadId === "string" - ? { parentAgentId: payload.parentThreadId } - : {}), - timelineBypass: true, - }, - }, - ]; + return [buildTaskStarted()]; case "collabAgent/activity": { const activityKind = typeof payload.activityKind === "string" ? payload.activityKind : ""; if (activityKind === "interrupted") { @@ -575,20 +573,7 @@ function mapCollabAgentEvent( // alone (no thread/started with a spawn source), so this is the one // shot at a task.started with a real name — agentPath leaf beats a // bare thread-id title. - return [ - { - ...base, - type: "task.started", - payload: { - taskId, - description: title, - title, - role, - ...(agentPath ? { agentPath } : {}), - timelineBypass: true, - }, - }, - ]; + return [buildTaskStarted()]; } // Reading a child's result also emits "interacted" after its turn is idle. // Only the child's turn or thread lifecycle can prove it resumed work. @@ -704,9 +689,8 @@ function mapCollabAgentEvent( payload: { taskId, description: title, - ...(knownName ? { title: knownName } : {}), + ...statusLinkage, typedUsage, - timelineBypass: true, }, }, ]; @@ -736,9 +720,8 @@ function mapCollabAgentEvent( payload: { taskId, description: title, - ...(knownName ? { title: knownName } : {}), + ...statusLinkage, summary, - timelineBypass: true, }, }, ]; diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index a1b46e003520..596f9aad5041 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -24,17 +24,90 @@ import { makeCodexSessionRuntime } from "./CodexSessionRuntime.ts"; const ROOT = wireFixture.rootThreadId; const [CHILD_A, CHILD_B] = wireFixture.childThreadIds as [string, string]; +const NESTED_CHILD = "nested-child-thread"; +const UNPARENTED_CHILD = "unparented-child-thread"; const MEMORY = "memory-consolidation-thread"; /** - * 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). + * The captured sequence, extended with shapes the live capture didn't include: + * explicit child spawn metadata, parent-envelope precedence, receiver-turn + * bookkeeping, child terminal lifecycle, and child-addressed approval cleanup. */ function buildScript() { - const captured = wireFixture.notifications; + const captured = wireFixture.notifications.filter((entry) => entry.method !== "turn/completed"); + const rootThreadStarted = captured.find((entry) => entry.method === "thread/started"); + const childAStartedIndex = captured.findIndex((entry) => { + const item = ( + entry.params as { item?: { type?: string; kind?: string; agentThreadId?: string } } + ).item; + return ( + item?.type === "subAgentActivity" && item.kind === "started" && item.agentThreadId === CHILD_A + ); + }); + if (!rootThreadStarted || childAStartedIndex < 0) { + throw new Error("captured collab fixture is missing child A registration"); + } + const childAStarted = captured[childAStartedIndex]!; + const childAStartedParams = childAStarted.params as { + readonly item: Record; + readonly [key: string]: unknown; + }; + const childThreadStarted = { + ...rootThreadStarted, + params: { + thread: { + ...rootThreadStarted.params.thread, + id: CHILD_A, + sessionId: CHILD_A, + parentThreadId: ROOT, + agentNickname: "alpha", + agentRole: "worker", + source: { + subAgent: { + thread_spawn: { + agent_nickname: "alpha", + agent_role: "worker", + agent_path: "/root/alpha", + depth: 1, + parent_thread_id: ROOT, + }, + }, + }, + }, + }, + }; + const notifications: Array = captured.map((entry, index) => + index === childAStartedIndex + ? { ...entry, params: { ...entry.params, threadId: "activity-envelope-thread" } } + : entry, + ); + notifications.splice(childAStartedIndex, 0, childThreadStarted); + const unrelatedInteraction = { + ...childAStarted, + params: { + ...childAStartedParams, + threadId: "interaction-envelope-thread", + item: { + ...childAStartedParams.item, + kind: "interacted", + agentThreadId: UNPARENTED_CHILD, + agentPath: "/root/unparented", + }, + }, + }; + const nestedSpawn = { + ...childAStarted, + params: { + ...childAStartedParams, + threadId: CHILD_A, + item: { + ...childAStartedParams.item, + kind: "started", + agentThreadId: NESTED_CHILD, + agentPath: "/root/alpha/nested", + }, + }, + }; const extras = [ { method: "item/completed", @@ -67,7 +140,7 @@ function buildScript() { ]; return { rootThreadId: ROOT, - notifications: [...captured.filter((entry) => entry.method !== "turn/completed"), ...extras], + notifications: [...notifications, nestedSpawn, unrelatedInteraction, ...extras], }; } @@ -102,6 +175,18 @@ describe("CodexSessionRuntime collab integration", () => { const events = Array.from(yield* Fiber.join(eventsFiber)); const methods = events.map((event) => event.method); + const findChildEvent = (method: string, childId: string, activityKind?: string) => + events.find((event) => { + const payload = event.payload as { + agentThreadId?: string; + activityKind?: string; + }; + return ( + event.method === method && + payload.agentThreadId === childId && + (activityKind === undefined || payload.activityKind === activityKind) + ); + }); // Children registered from subAgentActivity become synthetic agent // lifecycle — including terminal rows that arrive AFTER the receiver @@ -110,19 +195,44 @@ describe("CodexSessionRuntime collab integration", () => { 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, + const childActivity = findChildEvent("collabAgent/activity", CHILD_A, "started"); + assert.isDefined(childActivity, "child A's started activity becomes an agent event"); + assert.equal( + (childActivity?.payload as { parentThreadId?: string } | undefined)?.parentThreadId, + ROOT, + "a later activity must not clobber the explicit spawn parent", + ); + + const nestedChildActivity = findChildEvent("collabAgent/activity", NESTED_CHILD, "started"); + assert.isDefined(nestedChildActivity, "nested child activity becomes an agent event"); + assert.equal( + (nestedChildActivity?.payload as { parentThreadId?: string } | undefined)?.parentThreadId, + CHILD_A, + "a nested child keeps the spawning child's thread as its parent", ); - 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, + const childTurnCompleted = findChildEvent("collabAgent/turnCompleted", CHILD_A); + assert.isDefined(childTurnCompleted, "child A's turn completion becomes an agent event"); + assert.equal( + (childTurnCompleted?.payload as { parentThreadId?: string } | undefined)?.parentThreadId, + ROOT, + "child turn completion repeats its parent thread", ); + + const childClosed = findChildEvent("collabAgent/closed", CHILD_B); assert.isDefined(childClosed, "child B's close becomes an agent event"); + assert.equal( + (childClosed?.payload as { parentThreadId?: string } | undefined)?.parentThreadId, + ROOT, + "child close repeats its parent thread", + ); + + const interacted = findChildEvent("collabAgent/activity", UNPARENTED_CHILD, "interacted"); + assert.isDefined(interacted, "an interacted child still becomes an agent event"); + assert.isUndefined( + (interacted?.payload as { parentThreadId?: string } | undefined)?.parentThreadId, + "a non-start activity envelope must not become the child's parent", + ); // Parent-owned resolution passes through — not swallowed, not // re-labelled as an agent event. diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 29bb992611c1..b15a3c7ee02d 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -1095,7 +1095,8 @@ export const makeCodexSessionRuntime = ( // every subsequent root notification — including the final // assistant message and turn/completed — so the thread hung // "working" after all subagents finished (live-probe finding). - const rootProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); + const session = yield* Ref.get(sessionRef); + const rootProviderThreadId = currentProviderThreadId(session); if ( item.agentThreadId === rootProviderThreadId || item.agentPath === "/root" || @@ -1103,8 +1104,8 @@ export const makeCodexSessionRuntime = ( ) { return false; } - const activitySpawnTurnId = (yield* Ref.get(sessionRef)).activeTurnId ?? undefined; - yield* Ref.update(collabChildAgentsRef, (current) => { + const activitySpawnTurnId = session.activeTurnId ?? undefined; + const registeredChild = yield* Ref.modify(collabChildAgentsRef, (current) => { const existing = current.get(item.agentThreadId); const next = new Map(current); // Merge-late semantics: when thread/started registered first, a @@ -1113,8 +1114,9 @@ export const makeCodexSessionRuntime = ( // ones. spawnTurnId is registration-time-only: for an already // registered child, a later activity during an UNRELATED turn // must not backfill that turn as the spawn batch (review - // finding); an unset spawn turn stays unset. - next.set(item.agentThreadId, { + // finding); an unset spawn turn stays unset. A started activity's + // envelope thread id identifies the spawning parent. + const registeredChild: CollabChildAgentState = { agentThreadId: item.agentThreadId, nickname: existing?.nickname ?? @@ -1122,21 +1124,26 @@ export const makeCodexSessionRuntime = ( role: existing?.role, agentPath: existing?.agentPath ?? item.agentPath, depth: existing?.depth, - parentThreadId: existing?.parentThreadId, + parentThreadId: + existing?.parentThreadId ?? + (item.kind === "started" ? notification.params.threadId : undefined), spawnTurnId: existing ? existing.spawnTurnId : activitySpawnTurnId, - }); - return next; + }; + next.set(item.agentThreadId, registeredChild); + return [registeredChild, next]; }); - const registeredChild = (yield* Ref.get(collabChildAgentsRef)).get(item.agentThreadId); yield* emitEvent({ kind: "notification", threadId: options.threadId, method: "collabAgent/activity", - ...(registeredChild?.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), + ...(registeredChild.spawnTurnId ? { turnId: registeredChild.spawnTurnId } : {}), payload: { agentThreadId: item.agentThreadId, agentPath: item.agentPath, activityKind: item.kind, + ...(registeredChild.parentThreadId + ? { parentThreadId: registeredChild.parentThreadId } + : {}), }, }); return true; @@ -1164,6 +1171,7 @@ export const makeCodexSessionRuntime = ( ...(child.nickname ? { nickname: child.nickname } : {}), ...(child.role ? { role: child.role } : {}), ...(child.agentPath ? { agentPath: child.agentPath } : {}), + ...(child.parentThreadId ? { parentThreadId: child.parentThreadId } : {}), }; switch (notification.method) { case "turn/started": {