diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 1c62182dd..7806c5148 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -2241,6 +2241,80 @@ describe("Codex app-server provider", () => { }); }); + test("keeps a settled child completed until Codex starts another child turn", async () => { + const appServer = createFakeCodexAppServer(); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + try { + const resultPromise = session.run("Delegate the investigation."); + await appServer.waitForTurnStart(); + + appServer.startsSubAgent({ + callId: "call-settled-child", + threadId: "settled-child-thread", + agentPath: "/root/settled-child", + }); + appServer.completeTurn({ threadId: "settled-child-thread" }); + appServer.says({ + threadId: "settled-child-thread", + itemId: "late-child-message", + text: "Late trailing output.", + }); + + const providerUpserts = events.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], + ); + expect(providerUpserts.at(-1)).toMatchObject({ + id: "settled-child-thread", + status: "completed", + }); + expect(events.at(-1)).toMatchObject({ + type: "timeline", + item: { + callId: "call-settled-child", + status: "completed", + detail: { type: "sub_agent", log: "[Assistant] Late trailing output." }, + }, + }); + + appServer.completesSubAgentActivity({ + callId: "late-child-interaction", + threadId: "settled-child-thread", + agentPath: "/root/settled-child", + kind: "interacted", + }); + expect( + events.findLast( + (event) => event.type === "provider_subagent" && event.event.type === "upsert", + ), + ).toMatchObject({ event: { id: "settled-child-thread", status: "completed" } }); + + appServer.startsTurn({ + threadId: "settled-child-thread", + turnId: "next-child-turn", + }); + expect( + events.findLast( + (event) => event.type === "provider_subagent" && event.event.type === "upsert", + ), + ).toMatchObject({ event: { id: "settled-child-thread", status: "running" } }); + + appServer.completeTurn({ threadId: "settled-child-thread" }); + appServer.completeTurn(); + await resultPromise; + appServer.assertNoErrors(); + } finally { + await session.close(); + } + }); + test("updates a registered child with its later native activity name", () => { const session = createSession(); const events: AgentStreamEvent[] = []; @@ -3453,6 +3527,10 @@ describe("Codex app-server provider", () => { const liveEvents: AgentStreamEvent[] = []; session.subscribe((event) => liveEvents.push(event)); + asInternals(session).handleNotification("turn/started", { + threadId: "v2-child-thread", + turn: { id: "v2-child-turn-after-resume" }, + }); asInternals(session).handleNotification("item/completed", { threadId: "test-thread", item: { @@ -3481,6 +3559,10 @@ describe("Codex app-server provider", () => { }); liveEvents.length = 0; + asInternals(session).handleNotification("turn/started", { + threadId: "legacy-child-thread", + turn: { id: "legacy-child-turn-after-resume" }, + }); asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "legacy-child-thread", itemId: "legacy-child-message-after-resume", 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 0a628498a..9680d62a0 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 @@ -1660,6 +1660,12 @@ interface CodexSubAgentActivity { kind: "started" | "interacted" | "interrupted"; } +function isTerminalSubAgentStatus( + status: ToolCallTimelineItem["status"], +): status is "completed" | "failed" | "canceled" { + return status === "completed" || status === "failed" || status === "canceled"; +} + function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null { const record = toObjectRecord(item); if (!record) { @@ -4901,7 +4907,7 @@ export class CodexAppServerAgentSession implements AgentSession { private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void { switch (parsed.kind) { case "thread_started": - this.emitSubAgentActivityUpdate(callId, "running"); + this.emitSubAgentActivityUpdate(callId, "running", { reopen: true }); return; case "turn_started": case "turn_completed": @@ -5174,10 +5180,13 @@ export class CodexAppServerAgentSession implements AgentSession { }, }; } - this.emitSubAgentActivityUpdate( - callId, - activity.kind === "interrupted" ? "canceled" : "running", - ); + let nextStatus: ToolCallTimelineItem["status"] | undefined = "running"; + if (activity.kind === "interrupted") { + nextStatus = "canceled"; + } else if (isTerminalSubAgentStatus(state.toolCall.status)) { + nextStatus = undefined; + } + this.emitSubAgentActivityUpdate(callId, nextStatus); return true; } @@ -5271,6 +5280,7 @@ export class CodexAppServerAgentSession implements AgentSession { private emitSubAgentActivityUpdate( callId: string, status?: ToolCallTimelineItem["status"], + options?: { reopen?: boolean }, ): void { const state = this.subAgentCallsByCallId.get(callId); if (!state || state.toolCall.detail.type !== "sub_agent") { @@ -5281,7 +5291,14 @@ export class CodexAppServerAgentSession implements AgentSession { childTimeline.length > 0 ? curateAgentActivity(childTimeline, { labelAssistantMessages: true }) : ""; - const resolvedStatus = status ?? state.toolCall.status; + let resolvedStatus = status ?? state.toolCall.status; + if ( + status === "running" && + !options?.reopen && + isTerminalSubAgentStatus(state.toolCall.status) + ) { + resolvedStatus = state.toolCall.status; + } for (const childThreadId of state.childThreadIds) { this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); } @@ -5420,7 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingCommandOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId); } - this.emitSubAgentActivityUpdate(callId, "running"); + this.emitSubAgentActivityUpdate(callId); } private handleSubAgentContextCompactionItem( @@ -5556,7 +5573,7 @@ export class CodexAppServerAgentSession implements AgentSession { ): void { const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (subAgentCallId) { - this.emitSubAgentActivityUpdate(subAgentCallId, "running"); + this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true }); return; } this.currentTurnId = parsed.turnId; diff --git a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts index f9df7ee62..35a96b6db 100644 --- a/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts +++ b/packages/server/src/server/agent/providers/codex/tool-call-mapper.test.ts @@ -379,6 +379,28 @@ describe("codex tool-call mapper", () => { }); }); + it.each([ + ["shutdown", "canceled"], + ["notFound", "failed"], + ] as const)("maps terminal child state %s to %s", (childStatus, expectedStatus) => { + const item = mapCodexToolCallFromThreadItem({ + type: "collabAgentToolCall", + id: `call-sub-agent-${childStatus}`, + tool: "closeAgent", + status: "completed", + receiverThreadIds: ["child-thread-1"], + agentsStates: { + "child-thread-1": { status: childStatus, message: null }, + }, + }); + + expect(item).toMatchObject({ + type: "tool_call", + callId: `call-sub-agent-${childStatus}`, + status: expectedStatus, + }); + }); + it("maps mcp read_file completion with detail", () => { const item = expectMapped( mapCodexToolCallFromThreadItem( 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 b3775e42c..7884c9bc7 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 @@ -569,10 +569,17 @@ function readStatus(value: unknown): string | undefined { function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] { const normalized = status.trim().toLowerCase(); - if (normalized === "error" || normalized === "errored") { - return "running"; + switch (normalized) { + case "error": + case "errored": + return "running"; + case "shutdown": + return "canceled"; + case "notfound": + return "failed"; + default: + return normalizeToolCallStatus(status, null, null); } - return normalizeToolCallStatus(status, null, null); } function resolveCollabAgentStatus(