fix: stop completed Codex subagents appearing active (#3188)
This commit is contained in:
@@ -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", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
@@ -3453,6 +3527,10 @@ describe("Codex app-server provider", () => {
|
||||
|
||||
const liveEvents: AgentStreamEvent[] = [];
|
||||
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", {
|
||||
threadId: "test-thread",
|
||||
item: {
|
||||
@@ -3481,6 +3559,10 @@ describe("Codex app-server provider", () => {
|
||||
});
|
||||
|
||||
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", {
|
||||
threadId: "legacy-child-thread",
|
||||
itemId: "legacy-child-message-after-resume",
|
||||
|
||||
@@ -1660,6 +1660,12 @@ interface CodexSubAgentActivity {
|
||||
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 {
|
||||
const record = toObjectRecord(item);
|
||||
if (!record) {
|
||||
@@ -4901,7 +4907,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
|
||||
switch (parsed.kind) {
|
||||
case "thread_started":
|
||||
this.emitSubAgentActivityUpdate(callId, "running");
|
||||
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
|
||||
return;
|
||||
case "turn_started":
|
||||
case "turn_completed":
|
||||
@@ -5174,10 +5180,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
},
|
||||
};
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(
|
||||
callId,
|
||||
activity.kind === "interrupted" ? "canceled" : "running",
|
||||
);
|
||||
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
|
||||
if (activity.kind === "interrupted") {
|
||||
nextStatus = "canceled";
|
||||
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
|
||||
nextStatus = undefined;
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(callId, nextStatus);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -5271,6 +5280,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
private emitSubAgentActivityUpdate(
|
||||
callId: string,
|
||||
status?: ToolCallTimelineItem["status"],
|
||||
options?: { reopen?: boolean },
|
||||
): void {
|
||||
const state = this.subAgentCallsByCallId.get(callId);
|
||||
if (!state || state.toolCall.detail.type !== "sub_agent") {
|
||||
@@ -5281,7 +5291,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
childTimeline.length > 0
|
||||
? 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) {
|
||||
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
||||
}
|
||||
@@ -5420,7 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
this.pendingCommandOutputDeltas.delete(itemId);
|
||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||
}
|
||||
this.emitSubAgentActivityUpdate(callId, "running");
|
||||
this.emitSubAgentActivityUpdate(callId);
|
||||
}
|
||||
|
||||
private handleSubAgentContextCompactionItem(
|
||||
@@ -5556,7 +5573,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
): void {
|
||||
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||
if (subAgentCallId) {
|
||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true });
|
||||
return;
|
||||
}
|
||||
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", () => {
|
||||
const item = expectMapped(
|
||||
mapCodexToolCallFromThreadItem(
|
||||
|
||||
@@ -569,10 +569,17 @@ function readStatus(value: unknown): string | undefined {
|
||||
|
||||
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
||||
const normalized = status.trim().toLowerCase();
|
||||
if (normalized === "error" || normalized === "errored") {
|
||||
return "running";
|
||||
switch (normalized) {
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user