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)