From 9615afb83704317aee8bd5608a048ca2282709c9 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:16:48 +0000 Subject: [PATCH] fix(codex): preserve follow-up instructions in parent history Follow-up submissions carry user-visible instructions and must keep their own cards. Their sampled child states describe submission time, so only the submission result settles the card while child lifecycle events continue to own child status. --- .../agent/providers/codex-app-server-agent.ts | 6 +-- .../providers/codex/collab-replay.test.ts | 53 +++++++++++++++++-- .../agent/providers/codex/tool-call-mapper.ts | 5 ++ 3 files changed, 56 insertions(+), 8 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 374809e296..fd22f97f32 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 @@ -1875,12 +1875,12 @@ function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | n !record || normalizeCodexThreadItemType(typeof record.type === "string" ? record.type : undefined) !== "collabAgentToolCall" || - record.tool === "spawnAgent" + record.tool === "spawnAgent" || + record.tool === "sendInput" ) { return null; } - // sendInput samples status at submission; child lifecycle events own the new turn. - const outcomeStates = record.tool === "sendInput" ? {} : toObjectRecord(record.agentsStates); + const outcomeStates = toObjectRecord(record.agentsStates); const updates = Object.entries(outcomeStates ?? {}).flatMap( ([threadId, value]): CodexCollabAgentUpdate[] => { const state = toObjectRecord(value); 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 09697f1428..4a3104e5b6 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 @@ -72,6 +72,38 @@ describe("Codex collaboration history", () => { ]); }); + test.each(["completed", "interrupted", "running"])( + "keeps follow-up instructions in replay without applying the %s submission snapshot", + async (submissionStatus) => { + const history = await replay([ + collab("spawnAgent", { first: "pendingInit" }, "spawn-first"), + collab("wait", { first: "completed" }), + { ...collab("sendInput", { first: submissionStatus }), prompt: "Now add tests" }, + ]); + expect( + history.flatMap((event) => + event.type === "timeline" && event.item.type === "tool_call" + ? [{ id: event.item.callId, status: event.item.status, detail: event.item.detail }] + : [], + ), + ).toEqual([ + { id: "spawn-first", status: "completed", detail: expect.anything() }, + { + id: "sendInput", + status: "completed", + detail: expect.objectContaining({ description: "Now add tests" }), + }, + ]); + expect( + history.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" + ? [{ id: event.event.id, status: event.event.status }] + : [], + ), + ).toEqual([{ id: "first", status: "completed" }]); + }, + ); + test("stops counting a child closed while running", async () => { const history = await replay([ collab("spawnAgent", { first: "pendingInit" }, "spawn-first"), @@ -252,27 +284,34 @@ describe("Codex collaboration history", () => { async () => appServer.child, ); const statuses = new Map(); - const timelineCallIds = new Set(); + const timelineCards = new Map(); let parentMessage = ""; 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") { - timelineCallIds.add(event.item.callId); + timelineCards.set(event.item.callId, { + status: event.item.status, + detail: event.item.detail, + }); } if (event.type === "timeline" && event.item.type === "assistant_message") { parentMessage = event.item.text; } }); function complete(tool: string, states: Record, id = tool) { + const item = { + ...collab(tool, states, id), + ...(tool === "sendInput" ? { prompt: "Now add tests" } : {}), + }; if (tool !== "spawnAgent") { appServer.child.stdout.write( `${JSON.stringify({ method: "item/started", params: { threadId: "thread-1", - item: { ...collab(tool, states, id), status: "inProgress", agentsStates: {} }, + item: { ...item, status: "inProgress", agentsStates: {} }, }, })}\n`, ); @@ -280,7 +319,7 @@ describe("Codex collaboration history", () => { appServer.child.stdout.write( `${JSON.stringify({ method: "item/completed", - params: { threadId: "thread-1", item: collab(tool, states, id) }, + params: { threadId: "thread-1", item }, })}\n`, ); } @@ -306,10 +345,14 @@ describe("Codex collaboration history", () => { appServer.says({ threadId: "thread-1", text: "Follow-up submitted." }); await vi.waitFor(() => expect(parentMessage).toBe("Follow-up submitted.")); expect(statuses.get("first")).toBe("running"); + expect([...timelineCards.keys()]).toEqual(["spawn-first", "spawn-second", "sendInput"]); + expect(timelineCards.get("sendInput")).toEqual({ + status: "completed", + detail: expect.objectContaining({ description: "Now add tests" }), + }); // Only the child's completion settles the follow-up. appServer.completeTurn({ threadId: "first" }); await vi.waitFor(() => expect(statuses.get("first")).toBe("completed")); - expect([...timelineCallIds]).toEqual(["spawn-first", "spawn-second"]); } finally { await session.close(); appServer.assertNoErrors(); diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts index 9d02198cfd..9c34da6614 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.ts @@ -572,6 +572,11 @@ function resolveCollabAgentStatus( return "failed"; } + // Sending input completes the submission, not the child's new turn. + if (item.tool === "sendInput") { + return parentStatus; + } + const childStatuses = Object.values(item.agentsStates ?? {}) .map(readStatus) .filter((status): status is string => typeof status === "string" && status.trim().length > 0)