fix(server): keep Codex sub-agent running on transient child error state

Fixes #1071
This commit is contained in:
xy-plus
2026-05-19 10:57:55 +08:00
committed by GitHub
parent aaabadb04b
commit 8ff63a6d71
3 changed files with 107 additions and 2 deletions
@@ -1352,6 +1352,40 @@ describe("Codex app-server provider", () => {
});
});
test("does not synthesize a parent sub-agent failure from child error state alone", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "call-sub-agent-transient-child-error",
tool: "spawnAgent",
status: "completed",
prompt: "Validate the child agent result.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "error", message: "Sub-agent failed" },
},
},
});
expect(events.at(-1)?.item).toMatchObject({
type: "tool_call",
callId: "call-sub-agent-transient-child-error",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Validate the child agent result.",
},
});
});
test("loads Codex persisted history from the app-server thread", async () => {
const session = createSession();
const requests: Array<{ method: string; params: unknown }> = [];
@@ -212,6 +212,64 @@ describe("codex tool-call mapper", () => {
});
});
it("does not fail a collabAgentToolCall from child error state alone", () => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
id: "call-sub-agent-transient-child-error",
tool: "spawnAgent",
status: "completed",
prompt: "Inspect the Codex stream path.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "error", message: "Sub-agent failed" },
},
});
expect(item).toEqual({
type: "tool_call",
callId: "call-sub-agent-transient-child-error",
name: "Sub-agent",
status: "running",
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Inspect the Codex stream path.",
log: "",
actions: [],
},
});
});
it("still fails a collabAgentToolCall from an explicitly failed child state", () => {
const item = mapCodexToolCallFromThreadItem({
type: "collabAgentToolCall",
id: "call-sub-agent-child-failed",
tool: "spawnAgent",
status: "completed",
prompt: "Inspect the Codex stream path.",
receiverThreadIds: ["child-thread-1"],
agentsStates: {
"child-thread-1": { status: "failed", message: "Child failed" },
},
});
expect(item).toEqual({
type: "tool_call",
callId: "call-sub-agent-child-failed",
name: "Sub-agent",
status: "failed",
error: { message: "Sub-agent failed" },
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "Inspect the Codex stream path.",
log: "",
actions: [],
},
});
});
it("maps mcp read_file completion with detail", () => {
const item = mapCodexToolCallFromThreadItem(
{
@@ -544,6 +544,14 @@ function readStatus(value: unknown): string | undefined {
return typeof value.status === "string" ? value.status : undefined;
}
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
const normalized = status.trim().toLowerCase();
if (normalized === "error" || normalized === "errored") {
return "running";
}
return normalizeToolCallStatus(status, null, null);
}
function resolveCollabAgentStatus(
item: z.infer<typeof CodexCollabAgentToolCallItemSchema>,
): ToolCallTimelineItem["status"] {
@@ -551,10 +559,15 @@ function resolveCollabAgentStatus(
return "failed";
}
const parentStatus = normalizeToolCallStatus(item.status, null, null);
if (parentStatus === "failed") {
return "failed";
}
const childStatuses = Object.values(item.agentsStates ?? {})
.map(readStatus)
.filter((status): status is string => typeof status === "string" && status.trim().length > 0)
.map((status) => normalizeToolCallStatus(status, null, null));
.map(normalizeCollabAgentChildStatus);
if (childStatuses.some((status) => status === "failed")) {
return "failed";
@@ -566,7 +579,7 @@ function resolveCollabAgentStatus(
return childStatuses.every((status) => status === "completed") ? "completed" : "running";
}
return normalizeToolCallStatus(item.status, item.error ?? null, null);
return parentStatus;
}
function buildMcpToolName(server: string | undefined, tool: string): string {