From ec97c8982b05ea2d64f7b54c029f383f4bb3b080 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 30 Oct 2026 06:45:18 +0000 Subject: [PATCH] fix(codex): keep control cards from claiming child routes Only spawn announcements establish child ownership. Wait and close results update existing children, while failed resume attempts retain their own visible result without replacing a previous child outcome. --- .../agent/providers/codex-app-server-agent.ts | 22 ++++++------- .../providers/codex/collab-replay.test.ts | 33 ++++++++++++++++++- 2 files changed, 42 insertions(+), 13 deletions(-) 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();