fix: stop completed Codex subagents appearing active (#3188)

This commit is contained in:
James Strain
2026-08-12 14:05:45 +02:00
committed by GitHub
parent 2f6b585a3a
commit b58ccf9269
4 changed files with 139 additions and 11 deletions
@@ -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", () => { test("updates a registered child with its later native activity name", () => {
const session = createSession(); const session = createSession();
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
@@ -3453,6 +3527,10 @@ describe("Codex app-server provider", () => {
const liveEvents: AgentStreamEvent[] = []; const liveEvents: AgentStreamEvent[] = [];
session.subscribe((event) => liveEvents.push(event)); 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", { asInternals(session).handleNotification("item/completed", {
threadId: "test-thread", threadId: "test-thread",
item: { item: {
@@ -3481,6 +3559,10 @@ describe("Codex app-server provider", () => {
}); });
liveEvents.length = 0; 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", { asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "legacy-child-thread", threadId: "legacy-child-thread",
itemId: "legacy-child-message-after-resume", itemId: "legacy-child-message-after-resume",
@@ -1660,6 +1660,12 @@ interface CodexSubAgentActivity {
kind: "started" | "interacted" | "interrupted"; 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 { function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null {
const record = toObjectRecord(item); const record = toObjectRecord(item);
if (!record) { if (!record) {
@@ -4901,7 +4907,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void { private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
switch (parsed.kind) { switch (parsed.kind) {
case "thread_started": case "thread_started":
this.emitSubAgentActivityUpdate(callId, "running"); this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
return; return;
case "turn_started": case "turn_started":
case "turn_completed": case "turn_completed":
@@ -5174,10 +5180,13 @@ export class CodexAppServerAgentSession implements AgentSession {
}, },
}; };
} }
this.emitSubAgentActivityUpdate( let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
callId, if (activity.kind === "interrupted") {
activity.kind === "interrupted" ? "canceled" : "running", nextStatus = "canceled";
); } else if (isTerminalSubAgentStatus(state.toolCall.status)) {
nextStatus = undefined;
}
this.emitSubAgentActivityUpdate(callId, nextStatus);
return true; return true;
} }
@@ -5271,6 +5280,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private emitSubAgentActivityUpdate( private emitSubAgentActivityUpdate(
callId: string, callId: string,
status?: ToolCallTimelineItem["status"], status?: ToolCallTimelineItem["status"],
options?: { reopen?: boolean },
): void { ): void {
const state = this.subAgentCallsByCallId.get(callId); const state = this.subAgentCallsByCallId.get(callId);
if (!state || state.toolCall.detail.type !== "sub_agent") { if (!state || state.toolCall.detail.type !== "sub_agent") {
@@ -5281,7 +5291,14 @@ export class CodexAppServerAgentSession implements AgentSession {
childTimeline.length > 0 childTimeline.length > 0
? curateAgentActivity(childTimeline, { labelAssistantMessages: true }) ? 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) { for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
} }
@@ -5420,7 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingCommandOutputDeltas.delete(itemId); this.pendingCommandOutputDeltas.delete(itemId);
this.pendingFileChangeOutputDeltas.delete(itemId); this.pendingFileChangeOutputDeltas.delete(itemId);
} }
this.emitSubAgentActivityUpdate(callId, "running"); this.emitSubAgentActivityUpdate(callId);
} }
private handleSubAgentContextCompactionItem( private handleSubAgentContextCompactionItem(
@@ -5556,7 +5573,7 @@ export class CodexAppServerAgentSession implements AgentSession {
): void { ): void {
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) { if (subAgentCallId) {
this.emitSubAgentActivityUpdate(subAgentCallId, "running"); this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true });
return; return;
} }
this.currentTurnId = parsed.turnId; this.currentTurnId = parsed.turnId;
@@ -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", () => { it("maps mcp read_file completion with detail", () => {
const item = expectMapped( const item = expectMapped(
mapCodexToolCallFromThreadItem( mapCodexToolCallFromThreadItem(
@@ -569,10 +569,17 @@ function readStatus(value: unknown): string | undefined {
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] { function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
const normalized = status.trim().toLowerCase(); const normalized = status.trim().toLowerCase();
if (normalized === "error" || normalized === "errored") { switch (normalized) {
return "running"; 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( function resolveCollabAgentStatus(