fix(codex): leave follow-up status to child lifecycle events

This commit is contained in:
paseo-bot[bot]
2026-10-30 06:03:39 +00:00
parent 8207f697c5
commit 5e7c7e609e
2 changed files with 74 additions and 60 deletions
@@ -1862,7 +1862,6 @@ interface CodexCollabAgentUpdate {
threadId: string;
status: ToolCallTimelineItem["status"];
preservesTerminal: boolean;
reopen: boolean;
}
interface CodexCollabAgentControl {
@@ -1880,7 +1879,9 @@ function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | n
) {
return null;
}
const updates = Object.entries(toObjectRecord(record.agentsStates) ?? {}).flatMap(
// sendInput samples status at submission; child lifecycle events own the new turn.
const outcomeStates = record.tool === "sendInput" ? {} : toObjectRecord(record.agentsStates);
const updates = Object.entries(outcomeStates ?? {}).flatMap(
([threadId, value]): CodexCollabAgentUpdate[] => {
const state = toObjectRecord(value);
if (typeof state?.status !== "string") return [];
@@ -1892,7 +1893,6 @@ function readCodexCollabAgentControl(item: unknown): CodexCollabAgentControl | n
threadId,
status: record.tool === "closeAgent" && status === "running" ? "canceled" : status,
preservesTerminal: state.status === "shutdown" || record.tool === "closeAgent",
reopen: record.tool === "sendInput" && record.status === "completed",
},
];
},
@@ -1912,7 +1912,7 @@ function resolveCodexCollabAgentUpdate(
): ToolCallTimelineItem["status"] {
if (
isTerminalSubAgentStatus(previous) &&
(update.preservesTerminal || (update.status === "running" && !update.reopen))
(update.preservesTerminal || update.status === "running")
) {
return previous;
}
@@ -5836,7 +5836,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!state) continue;
const status = resolveCodexCollabAgentUpdate(state.toolCall.status, update);
if (status !== state.toolCall.status) {
this.emitSubAgentActivityUpdate(callId, status, { reopen: update.reopen });
this.emitSubAgentActivityUpdate(callId, status);
}
}
// A control rendered at start must also render its completion, even if
@@ -241,65 +241,79 @@ describe("Codex collaboration history", () => {
}
});
test("keeps completed live children settled until an explicit follow-up", async () => {
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 timelineCallIds = new Set<string>();
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") {
timelineCallIds.add(event.item.callId);
}
});
function complete(tool: string, states: Record<string, string>, id = tool) {
if (tool !== "spawnAgent") {
test.each(["completed", "interrupted", "running"])(
"keeps a live follow-up running despite a late %s sendInput snapshot",
async (submissionStatus) => {
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 timelineCallIds = new Set<string>();
let parentMessage = "";
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") {
timelineCallIds.add(event.item.callId);
}
if (event.type === "timeline" && event.item.type === "assistant_message") {
parentMessage = event.item.text;
}
});
function complete(tool: string, states: Record<string, string>, id = tool) {
if (tool !== "spawnAgent") {
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/started",
params: {
threadId: "thread-1",
item: { ...collab(tool, states, id), status: "inProgress", agentsStates: {} },
},
})}\n`,
);
}
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/started",
params: {
threadId: "thread-1",
item: { ...collab(tool, states, id), status: "inProgress", agentsStates: {} },
},
method: "item/completed",
params: { threadId: "thread-1", item: collab(tool, states, id) },
})}\n`,
);
}
appServer.child.stdout.write(
`${JSON.stringify({
method: "item/completed",
params: { threadId: "thread-1", item: collab(tool, states, id) },
})}\n`,
);
}
try {
const result = session.run("Delegate the work.");
await appServer.waitForTurnStart();
complete("spawnAgent", { first: "pendingInit" }, "spawn-first");
complete("spawnAgent", { second: "pendingInit" }, "spawn-second");
complete("wait", { first: "completed", second: "running" });
await vi.waitFor(() => {
try {
const result = session.run("Delegate the work.");
await appServer.waitForTurnStart();
complete("spawnAgent", { first: "pendingInit" }, "spawn-first");
complete("spawnAgent", { second: "pendingInit" }, "spawn-second");
complete("wait", { first: "completed", second: "running" });
await vi.waitFor(() => {
expect(statuses.get("first")).toBe("completed");
expect(statuses.get("second")).toBe("running");
});
complete("closeAgent", { first: "shutdown" });
complete("wait", { first: "running" }, "stale-wait");
appServer.says({ threadId: "thread-1", text: "Still waiting." });
appServer.completeTurn();
await result;
expect(statuses.get("first")).toBe("completed");
expect(statuses.get("second")).toBe("running");
});
complete("closeAgent", { first: "shutdown" });
complete("wait", { first: "running" }, "stale-wait");
appServer.says({ threadId: "thread-1", text: "Still waiting." });
appServer.completeTurn();
await result;
expect(statuses.get("first")).toBe("completed");
complete("sendInput", { first: "running" });
await vi.waitFor(() => expect(statuses.get("first")).toBe("running"));
expect([...timelineCallIds]).toEqual(["spawn-first", "spawn-second"]);
} finally {
await session.close();
appServer.assertNoErrors();
}
});
appServer.startsTurn({ threadId: "first", turnId: "follow-up" });
await vi.waitFor(() => expect(statuses.get("first")).toBe("running"));
complete("sendInput", { first: submissionStatus });
appServer.says({ threadId: "thread-1", text: "Follow-up submitted." });
await vi.waitFor(() => expect(parentMessage).toBe("Follow-up submitted."));
expect(statuses.get("first")).toBe("running");
// Only the child's completion settles the follow-up.
appServer.completeTurn({ threadId: "first" });
await vi.waitFor(() => expect(statuses.get("first")).toBe("completed"));
expect([...timelineCallIds]).toEqual(["spawn-first", "spawn-second"]);
} finally {
await session.close();
appServer.assertNoErrors();
}
},
);
});