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 e4a3963e15..374809e296 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 @@ -1862,7 +1862,6 @@ interface CodexCollabAgentUpdate { threadId: string; status: ToolCallTimelineItem["status"]; preservesTerminal: boolean; - reopen: boolean; } interface CodexCollabAgentControl { @@ -1880,7 +1879,9 @@ function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | n ) { return null; } - const updates = Object.entries(toObjectRecord(record.agentsStates) ?? {}).flatMap( + // sendInput samples status at submission; child lifecycle events own the new turn. + const outcomeStates = record.tool === "sendInput" ? {} : toObjectRecord(record.agentsStates); + const updates = Object.entries(outcomeStates ?? {}).flatMap( ([threadId, value]): CodexCollabAgentUpdate[] => { const state = toObjectRecord(value); if (typeof state?.status !== "string") return []; @@ -1892,7 +1893,6 @@ function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | n threadId, status: record.tool === "closeAgent" && status === "running" ? "canceled" : status, preservesTerminal: state.status === "shutdown" || record.tool === "closeAgent", - reopen: record.tool === "sendInput" && record.status === "completed", }, ]; }, @@ -1912,7 +1912,7 @@ function resolveCodexCollabAgentUpdate( ): ToolCallTimelineItem["status"] { if ( isTerminalSubAgentStatus(previous) && - (update.preservesTerminal || (update.status === "running" && !update.reopen)) + (update.preservesTerminal || update.status === "running") ) { return previous; } @@ -5836,7 +5836,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (!state) continue; const status = resolveCodexCollabAgentUpdate(state.toolCall.status, update); if (status !== state.toolCall.status) { - this.emitSubAgentActivityUpdate(callId, status, { reopen: update.reopen }); + this.emitSubAgentActivityUpdate(callId, status); } } // A control rendered at start must also render its completion, even if 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 6b916a59fd..09697f1428 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 @@ -241,65 +241,79 @@ describe("Codex collaboration history", () => { } }); - test("keeps completed live children settled until an explicit follow-up", async () => { - 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 timelineCallIds = new Set(); - 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); - } - }); - function complete(tool: string, states: Record, id = tool) { - if (tool !== "spawnAgent") { + test.each(["completed", "interrupted", "running"])( + "keeps a live follow-up running despite a late %s sendInput snapshot", + async (submissionStatus) => { + 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 timelineCallIds = new Set(); + 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); + } + if (event.type === "timeline" && event.item.type === "assistant_message") { + parentMessage = event.item.text; + } + }); + function complete(tool: string, states: Record, id = tool) { + if (tool !== "spawnAgent") { + appServer.child.stdout.write( + `${JSON.stringify({ + method: "item/started", + params: { + threadId: "thread-1", + item: { ...collab(tool, states, id), status: "inProgress", agentsStates: {} }, + }, + })}\n`, + ); + } appServer.child.stdout.write( `${JSON.stringify({ - method: "item/started", - params: { - threadId: "thread-1", - item: { ...collab(tool, states, id), status: "inProgress", agentsStates: {} }, - }, + method: "item/completed", + params: { threadId: "thread-1", item: collab(tool, states, id) }, })}\n`, ); } - appServer.child.stdout.write( - `${JSON.stringify({ - method: "item/completed", - params: { threadId: "thread-1", item: collab(tool, states, id) }, - })}\n`, - ); - } - try { - const result = session.run("Delegate the work."); - await appServer.waitForTurnStart(); - complete("spawnAgent", { first: "pendingInit" }, "spawn-first"); - complete("spawnAgent", { second: "pendingInit" }, "spawn-second"); - complete("wait", { first: "completed", second: "running" }); - await vi.waitFor(() => { + try { + const result = session.run("Delegate the work."); + await appServer.waitForTurnStart(); + complete("spawnAgent", { first: "pendingInit" }, "spawn-first"); + complete("spawnAgent", { second: "pendingInit" }, "spawn-second"); + complete("wait", { first: "completed", second: "running" }); + await vi.waitFor(() => { + expect(statuses.get("first")).toBe("completed"); + expect(statuses.get("second")).toBe("running"); + }); + complete("closeAgent", { first: "shutdown" }); + complete("wait", { first: "running" }, "stale-wait"); + appServer.says({ threadId: "thread-1", text: "Still waiting." }); + appServer.completeTurn(); + await result; expect(statuses.get("first")).toBe("completed"); - expect(statuses.get("second")).toBe("running"); - }); - complete("closeAgent", { first: "shutdown" }); - complete("wait", { first: "running" }, "stale-wait"); - appServer.says({ threadId: "thread-1", text: "Still waiting." }); - appServer.completeTurn(); - await result; - expect(statuses.get("first")).toBe("completed"); - complete("sendInput", { first: "running" }); - await vi.waitFor(() => expect(statuses.get("first")).toBe("running")); - expect([...timelineCallIds]).toEqual(["spawn-first", "spawn-second"]); - } finally { - await session.close(); - appServer.assertNoErrors(); - } - }); + appServer.startsTurn({ threadId: "first", turnId: "follow-up" }); + await vi.waitFor(() => expect(statuses.get("first")).toBe("running")); + complete("sendInput", { first: submissionStatus }); + appServer.says({ threadId: "thread-1", text: "Follow-up submitted." }); + await vi.waitFor(() => expect(parentMessage).toBe("Follow-up submitted.")); + expect(statuses.get("first")).toBe("running"); + // 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(); + } + }, + ); });