mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-30 13:13:25 +00:00
fix(codex): preserve final errors and settle rendered wait cards
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user