diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 10e3e7f842..e4a3963e15 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1884,7 +1884,9 @@ function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | n ([threadId, value]): CodexCollabAgentUpdate[] => { const state = toObjectRecord(value); if (typeof state?.status !== "string") return []; - const status = normalizeCollabAgentChildStatus(state.status); + // Unlike a spawn snapshot, a control result can report the child's final error. + const status = + state.status === "errored" ? "failed" : normalizeCollabAgentChildStatus(state.status); return [ { threadId, @@ -5823,23 +5825,34 @@ export class CodexAppServerAgentSession implements AgentSession { return childThreadIds; } + private handleRegisteredCollabAgentControl( + rawItem: { [key: string]: unknown }, + control: CodexCollabAgentControl, + ): boolean { + for (const update of control.updates) { + const callId = this.subAgentCallIdByChildThreadId.get(update.threadId); + if (!callId) continue; + const state = this.subAgentCallsByCallId.get(callId); + if (!state) continue; + const status = resolveCodexCollabAgentUpdate(state.toolCall.status, update); + if (status !== state.toolCall.status) { + this.emitSubAgentActivityUpdate(callId, status, { reopen: update.reopen }); + } + } + // A control rendered at start must also render its completion, even if + // Codex's final result lists fewer receivers than its started record. + const hasOwnCard = typeof rawItem.id === "string" && this.subAgentCallsByCallId.has(rawItem.id); + return ( + !hasOwnCard && + control.childThreadIds.length > 0 && + control.childThreadIds.every((id) => this.subAgentCallIdByChildThreadId.has(id)) + ); + } + private handleRegisteredSubAgentActivity(rawItem: { [key: string]: unknown }): boolean { const control = readCodexCollabAgentControl(rawItem); if (control) { - for (const update of control.updates) { - const callId = this.subAgentCallIdByChildThreadId.get(update.threadId); - if (!callId) continue; - const state = this.subAgentCallsByCallId.get(callId); - if (!state) continue; - const status = resolveCodexCollabAgentUpdate(state.toolCall.status, update); - if (status !== state.toolCall.status) { - this.emitSubAgentActivityUpdate(callId, status, { reopen: update.reopen }); - } - } - return ( - control.childThreadIds.length > 0 && - control.childThreadIds.every((id) => this.subAgentCallIdByChildThreadId.has(id)) - ); + return this.handleRegisteredCollabAgentControl(rawItem, control); } const activity = readCodexSubAgentActivity(rawItem); if (!activity) { diff --git a/packages/server/src/server/agent/providers/codex/collab-replay.test.ts b/packages/server/src/server/agent/providers/codex/collab-replay.test.ts index 1773600bdb..6b916a59fd 100644 --- a/packages/server/src/server/agent/providers/codex/collab-replay.test.ts +++ b/packages/server/src/server/agent/providers/codex/collab-replay.test.ts @@ -86,19 +86,77 @@ describe("Codex collaboration history", () => { ).toEqual([{ id: "first", status: "canceled" }]); }); - test("stops counting an errored child whose close record reports failed", async () => { - const history = await replay([ - collab("spawnAgent", { first: "pendingInit" }, "spawn-first"), - { ...collab("closeAgent", { first: "errored" }), status: "failed" }, - ]); - expect( - history.flatMap((event) => - event.type === "provider_subagent" && event.event.type === "upsert" - ? [{ id: event.event.id, status: event.event.status }] - : [], - ), - ).toEqual([{ id: "first", status: "canceled" }]); - }); + test.each(["wait", "closeAgent"])( + "restores an errored child as failed after %s", + async (tool) => { + const history = await replay([ + collab("spawnAgent", { first: "pendingInit" }, "spawn-first"), + { ...collab(tool, { first: "errored" }), status: "failed" }, + ]); + expect( + history.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" + ? [{ id: event.event.id, status: event.event.status }] + : [], + ), + ).toEqual([{ id: "first", status: "failed" }]); + expect( + history.flatMap((event) => + event.type === "timeline" && event.item.type === "tool_call" + ? [{ id: event.item.callId, status: event.item.status, error: event.item.error }] + : [], + ), + ).toEqual([{ id: "spawn-first", status: "failed", error: { message: "Sub-agent failed" } }]); + }, + ); + + test.each(["wait", "closeAgent"])( + "settles a live errored child as failed after %s", + async (tool) => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: process.cwd(), modeId: "full-access" }, + null, + createTestLogger(), + async () => appServer.child, + ); + const statuses = new Map(); + const cards = new Map(); + session.subscribe((event) => { + if (event.type === "provider_subagent" && event.event.type === "upsert") { + statuses.set(event.event.id, event.event.status); + } + if (event.type === "timeline" && event.item.type === "tool_call") { + cards.set(event.item.callId, { status: event.item.status, error: event.item.error }); + } + }); + function notify(method: string, item: ReturnType) { + appServer.child.stdout.write( + `${JSON.stringify({ method, params: { threadId: "thread-1", item } })}\n`, + ); + } + try { + const result = session.run("Delegate the work."); + await appServer.waitForTurnStart(); + notify("item/completed", collab("spawnAgent", { first: "pendingInit" }, "spawn-first")); + notify("item/started", { + ...collab(tool, { first: "errored" }), + status: "inProgress", + agentsStates: {}, + }); + notify("item/completed", { ...collab(tool, { first: "errored" }), status: "failed" }); + appServer.completeTurn(); + await result; + expect([...statuses]).toEqual([["first", "failed"]]); + expect([...cards]).toEqual([ + ["spawn-first", { status: "failed", error: { message: "Sub-agent failed" } }], + ]); + } finally { + await session.close(); + appServer.assertNoErrors(); + } + }, + ); test("preserves completion when a close carries an older running snapshot", async () => { const history = await replay([ @@ -136,6 +194,53 @@ describe("Codex collaboration history", () => { ]); }); + test("settles a rendered live wait when its completion lists only the finished receiver", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: process.cwd(), modeId: "full-access" }, + null, + createTestLogger(), + async () => appServer.child, + ); + const cards = new Map(); + const statuses = new Map(); + session.subscribe((event) => { + if (event.type === "timeline" && event.item.type === "tool_call") { + cards.set(event.item.callId, event.item.status); + } + if (event.type === "provider_subagent" && event.event.type === "upsert") { + statuses.set(event.event.id, event.event.status); + } + }); + function notify(method: string, item: ReturnType) { + appServer.child.stdout.write( + `${JSON.stringify({ method, params: { threadId: "thread-1", item } })}\n`, + ); + } + try { + const result = session.run("Wait for the children."); + await appServer.waitForTurnStart(); + notify("item/completed", collab("spawnAgent", { first: "pendingInit" }, "spawn-first")); + notify("item/started", { + ...collab("wait", { first: "running", second: "running" }, "mixed-wait"), + status: "inProgress", + agentsStates: {}, + }); + await vi.waitFor(() => expect(cards.get("mixed-wait")).toBe("running")); + notify("item/completed", collab("wait", { first: "completed" }, "mixed-wait")); + appServer.completeTurn(); + await result; + expect([...cards]).toEqual([ + ["spawn-first", "completed"], + ["mixed-wait", "completed"], + ]); + expect(statuses.get("first")).toBe("completed"); + } finally { + await session.close(); + appServer.assertNoErrors(); + } + }); + test("keeps completed live children settled until an explicit follow-up", async () => { const appServer = createFakeCodexAppServer(); const session = new CodexAppServerAgentSession(