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 2e1e97e04e..1c71b9627e 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 @@ -1892,6 +1892,7 @@ function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | n ? { kind: "submission", childThreadIds: receivers } : null; } + if (tool !== "wait" && tool !== "closeAgent") return null; const outcomeStates = toObjectRecord(record.agentsStates); const updates = Object.entries(outcomeStates ?? {}).flatMap( ([threadId, value]): CodexCollabAgentUpdate[] => { @@ -1968,7 +1969,7 @@ function updateHistoricalSubAgentActivity( }; } -function readCodexHistoricalSubAgentThreadIds(item: unknown): string[] { +function readCodexSpawnedSubAgentThreadIds(item: unknown): string[] { const activity = readCodexSubAgentActivity(item); if (activity) { return activity.kind === "started" ? [activity.agentThreadId] : []; @@ -2237,7 +2238,7 @@ async function projectCodexThreadHistory( ? { providerTurnId: row.turnId } : {}), }); - for (const childThreadId of readCodexHistoricalSubAgentThreadIds(item)) { + for (const childThreadId of readCodexSpawnedSubAgentThreadIds(item)) { subAgentsByThreadId.set(childThreadId, { timelineIndex: timeline.length - 1, pendingFollowUp: false, @@ -4137,6 +4138,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.registerSubAgentToolCall({ timelineItem: next.route.toolCall, rawItem: { agentThreadId: next.route.childThreadId }, + childThreadIds: [next.route.childThreadId], parentCallId: next.parentCallId, parentSubagentId: next.parentSubagentId, }); @@ -5784,6 +5786,7 @@ export class CodexAppServerAgentSession implements AgentSession { private registerSubAgentToolCall(params: { timelineItem: ToolCallTimelineItem; rawItem: { [key: string]: unknown }; + childThreadIds: readonly string[]; parentCallId: string | null; parentSubagentId: string | null; }): string[] { @@ -5825,16 +5828,9 @@ export class CodexAppServerAgentSession implements AgentSession { } this.subAgentCallsByCallId.set(timelineItem.callId, state); - const receiverThreadIds = Array.isArray(rawItem.receiverThreadIds) - ? rawItem.receiverThreadIds.filter((value): value is string => typeof value === "string") - : []; - const agentThreadId = - typeof rawItem.agentThreadId === "string" && rawItem.agentThreadId.length > 0 - ? rawItem.agentThreadId - : null; - const childThreadIds = Array.from( - new Set(agentThreadId ? [...receiverThreadIds, agentThreadId] : receiverThreadIds), - ).filter((threadId) => threadId !== this.currentThreadId); + const childThreadIds = Array.from(new Set(params.childThreadIds)).filter( + (threadId) => threadId !== this.currentThreadId, + ); for (const receiverThreadId of childThreadIds) { const owningCallId = this.subAgentCallIdByChildThreadId.get(receiverThreadId); if (owningCallId && owningCallId !== timelineItem.callId) { @@ -6770,6 +6766,7 @@ export class CodexAppServerAgentSession implements AgentSession { ? this.registerSubAgentToolCall({ timelineItem, rawItem: parsed.item, + childThreadIds: readCodexSpawnedSubAgentThreadIds(parsed.item), parentCallId: childSubAgentCallId, parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), }) @@ -6937,6 +6934,7 @@ export class CodexAppServerAgentSession implements AgentSession { const registeredChildThreadIds = this.registerSubAgentToolCall({ timelineItem, rawItem: parsed.item, + childThreadIds: readCodexSpawnedSubAgentThreadIds(parsed.item), parentCallId: childSubAgentCallId, parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId), }); 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 7d6bfaa879..8f60f395e5 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 @@ -174,6 +174,35 @@ describe("Codex collaboration history", () => { ).toEqual([{ id: "closeAgent", status: "canceled" }]); }); + test.each(["notFound", "errored"])( + "preserves completion and shows a failed resume reporting %s", + async (status) => { + const history = await replay([ + collab("spawnAgent", { first: "pendingInit" }, "spawn-first"), + collab("wait", { first: "completed" }), + collab("closeAgent", { first: "completed" }), + { ...collab("resumeAgent", { first: status }), status: "failed" }, + ]); + expect( + history.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" + ? [event.event.status] + : [], + ), + ).toEqual(["completed"]); + expect( + history.flatMap((event) => + event.type === "timeline" && event.item.type === "tool_call" + ? [{ id: event.item.callId, status: event.item.status }] + : [], + ), + ).toEqual([ + { id: "spawn-first", status: "completed" }, + { id: "resumeAgent", status: "failed" }, + ]); + }, + ); + test("stops counting a child closed while running", async () => { const history = await replay([ collab("spawnAgent", { first: "pendingInit" }, "spawn-first"), @@ -330,13 +359,15 @@ describe("Codex collaboration history", () => { }); await vi.waitFor(() => expect(cards.get("mixed-wait")).toBe("running")); notify("item/completed", collab("wait", { first: "completed" }, "mixed-wait")); + notify("item/completed", collab("wait", { second: "completed" }, "wait-second")); appServer.completeTurn(); await result; + expect([...statuses]).toEqual([["first", "completed"]]); expect([...cards]).toEqual([ ["spawn-first", "completed"], ["mixed-wait", "completed"], + ["wait-second", "completed"], ]); - expect(statuses.get("first")).toBe("completed"); } finally { await session.close(); appServer.assertNoErrors();