fix(codex): preserve final errors and settle rendered wait cards

This commit is contained in:
paseo-bot[bot]
2026-10-30 06:03:39 +00:00
parent a02fa39125
commit 8207f697c5
2 changed files with 146 additions and 28 deletions
@@ -1884,7 +1884,9 @@ function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | n
([threadId, value]): CodexCollabAgentUpdate[] => {
const state = toObjectRecord(value);
if (typeof state?.status !== "string") return [];
const status = normalizeCollabAgentChildStatus(state.status);
// Unlike a spawn snapshot, a control result can report the child's final error.
const status =
state.status === "errored" ? "failed" : normalizeCollabAgentChildStatus(state.status);
return [
{
threadId,
@@ -5823,23 +5825,34 @@ export class CodexAppServerAgentSession implements AgentSession {
return childThreadIds;
}
private handleRegisteredCollabAgentControl(
rawItem: { [key: string]: unknown },
control: CodexCollabAgentControl,
): boolean {
for (const update of control.updates) {
const callId = this.subAgentCallIdByChildThreadId.get(update.threadId);
if (!callId) continue;
const state = this.subAgentCallsByCallId.get(callId);
if (!state) continue;
const status = resolveCodexCollabAgentUpdate(state.toolCall.status, update);
if (status !== state.toolCall.status) {
this.emitSubAgentActivityUpdate(callId, status, { reopen: update.reopen });
}
}
// A control rendered at start must also render its completion, even if
// Codex's final result lists fewer receivers than its started record.
const hasOwnCard = typeof rawItem.id === "string" && this.subAgentCallsByCallId.has(rawItem.id);
return (
!hasOwnCard &&
control.childThreadIds.length > 0 &&
control.childThreadIds.every((id) => this.subAgentCallIdByChildThreadId.has(id))
);
}
private handleRegisteredSubAgentActivity(rawItem: { [key: string]: unknown }): boolean {
const control = readCodexCollabAgentControl(rawItem);
if (control) {
for (const update of control.updates) {
const callId = this.subAgentCallIdByChildThreadId.get(update.threadId);
if (!callId) continue;
const state = this.subAgentCallsByCallId.get(callId);
if (!state) continue;
const status = resolveCodexCollabAgentUpdate(state.toolCall.status, update);
if (status !== state.toolCall.status) {
this.emitSubAgentActivityUpdate(callId, status, { reopen: update.reopen });
}
}
return (
control.childThreadIds.length > 0 &&
control.childThreadIds.every((id) => this.subAgentCallIdByChildThreadId.has(id))
);
return this.handleRegisteredCollabAgentControl(rawItem, control);
}
const activity = readCodexSubAgentActivity(rawItem);
if (!activity) {
@@ -86,19 +86,77 @@ describe("Codex collaboration history", () => {
).toEqual([{ id: "first", status: "canceled" }]);
});
test("stops counting an errored child whose close record reports failed", async () => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
{ ...collab("closeAgent", { first: "errored" }), status: "failed" },
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "canceled" }]);
});
test.each(["wait", "closeAgent"])(
"restores an errored child as failed after %s",
async (tool) => {
const history = await replay([
collab("spawnAgent", { first: "pendingInit" }, "spawn-first"),
{ ...collab(tool, { first: "errored" }), status: "failed" },
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert"
? [{ id: event.event.id, status: event.event.status }]
: [],
),
).toEqual([{ id: "first", status: "failed" }]);
expect(
history.flatMap((event) =>
event.type === "timeline" && event.item.type === "tool_call"
? [{ id: event.item.callId, status: event.item.status, error: event.item.error }]
: [],
),
).toEqual([{ id: "spawn-first", status: "failed", error: { message: "Sub-agent failed" } }]);
},
);
test.each(["wait", "closeAgent"])(
"settles a live errored child as failed after %s",
async (tool) => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
null,
createTestLogger(),
async () => appServer.child,
);
const statuses = new Map<string, string>();
const cards = new Map<string, { status: string; error: unknown }>();
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") {
cards.set(event.item.callId, { status: event.item.status, error: event.item.error });
}
});
function notify(method: string, item: ReturnType<typeof collab>) {
appServer.child.stdout.write(
`${JSON.stringify({ method, params: { threadId: "thread-1", item } })}\n`,
);
}
try {
const result = session.run("Delegate the work.");
await appServer.waitForTurnStart();
notify("item/completed", collab("spawnAgent", { first: "pendingInit" }, "spawn-first"));
notify("item/started", {
...collab(tool, { first: "errored" }),
status: "inProgress",
agentsStates: {},
});
notify("item/completed", { ...collab(tool, { first: "errored" }), status: "failed" });
appServer.completeTurn();
await result;
expect([...statuses]).toEqual([["first", "failed"]]);
expect([...cards]).toEqual([
["spawn-first", { status: "failed", error: { message: "Sub-agent failed" } }],
]);
} finally {
await session.close();
appServer.assertNoErrors();
}
},
);
test("preserves completion when a close carries an older running snapshot", async () => {
const history = await replay([
@@ -136,6 +194,53 @@ describe("Codex collaboration history", () => {
]);
});
test("settles a rendered live wait when its completion lists only the finished receiver", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(
{ provider: "codex", cwd: process.cwd(), modeId: "full-access" },
null,
createTestLogger(),
async () => appServer.child,
);
const cards = new Map<string, string>();
const statuses = new Map<string, string>();
session.subscribe((event) => {
if (event.type === "timeline" && event.item.type === "tool_call") {
cards.set(event.item.callId, event.item.status);
}
if (event.type === "provider_subagent" && event.event.type === "upsert") {
statuses.set(event.event.id, event.event.status);
}
});
function notify(method: string, item: ReturnType<typeof collab>) {
appServer.child.stdout.write(
`${JSON.stringify({ method, params: { threadId: "thread-1", item } })}\n`,
);
}
try {
const result = session.run("Wait for the children.");
await appServer.waitForTurnStart();
notify("item/completed", collab("spawnAgent", { first: "pendingInit" }, "spawn-first"));
notify("item/started", {
...collab("wait", { first: "running", second: "running" }, "mixed-wait"),
status: "inProgress",
agentsStates: {},
});
await vi.waitFor(() => expect(cards.get("mixed-wait")).toBe("running"));
notify("item/completed", collab("wait", { first: "completed" }, "mixed-wait"));
appServer.completeTurn();
await result;
expect([...cards]).toEqual([
["spawn-first", "completed"],
["mixed-wait", "completed"],
]);
expect(statuses.get("first")).toBe("completed");
} finally {
await session.close();
appServer.assertNoErrors();
}
});
test("keeps completed live children settled until an explicit follow-up", async () => {
const appServer = createFakeCodexAppServer();
const session = new CodexAppServerAgentSession(