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", () => {
|
test("updates a registered child with its later native activity name", () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
@@ -3453,6 +3527,10 @@ describe("Codex app-server provider", () => {
|
|||||||
|
|
||||||
const liveEvents: AgentStreamEvent[] = [];
|
const liveEvents: AgentStreamEvent[] = [];
|
||||||
session.subscribe((event) => liveEvents.push(event));
|
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", {
|
asInternals(session).handleNotification("item/completed", {
|
||||||
threadId: "test-thread",
|
threadId: "test-thread",
|
||||||
item: {
|
item: {
|
||||||
@@ -3481,6 +3559,10 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
liveEvents.length = 0;
|
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", {
|
asInternals(session).handleNotification("item/agentMessage/delta", {
|
||||||
threadId: "legacy-child-thread",
|
threadId: "legacy-child-thread",
|
||||||
itemId: "legacy-child-message-after-resume",
|
itemId: "legacy-child-message-after-resume",
|
||||||
|
|||||||
@@ -1660,6 +1660,12 @@ interface CodexSubAgentActivity {
|
|||||||
kind: "started" | "interacted" | "interrupted";
|
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 {
|
function readCodexSubAgentActivity(item: unknown): CodexSubAgentActivity | null {
|
||||||
const record = toObjectRecord(item);
|
const record = toObjectRecord(item);
|
||||||
if (!record) {
|
if (!record) {
|
||||||
@@ -4901,7 +4907,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
|
private dispatchSubAgentNotification(parsed: ParsedCodexNotification, callId: string): void {
|
||||||
switch (parsed.kind) {
|
switch (parsed.kind) {
|
||||||
case "thread_started":
|
case "thread_started":
|
||||||
this.emitSubAgentActivityUpdate(callId, "running");
|
this.emitSubAgentActivityUpdate(callId, "running", { reopen: true });
|
||||||
return;
|
return;
|
||||||
case "turn_started":
|
case "turn_started":
|
||||||
case "turn_completed":
|
case "turn_completed":
|
||||||
@@ -5174,10 +5180,13 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.emitSubAgentActivityUpdate(
|
let nextStatus: ToolCallTimelineItem["status"] | undefined = "running";
|
||||||
callId,
|
if (activity.kind === "interrupted") {
|
||||||
activity.kind === "interrupted" ? "canceled" : "running",
|
nextStatus = "canceled";
|
||||||
);
|
} else if (isTerminalSubAgentStatus(state.toolCall.status)) {
|
||||||
|
nextStatus = undefined;
|
||||||
|
}
|
||||||
|
this.emitSubAgentActivityUpdate(callId, nextStatus);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5271,6 +5280,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private emitSubAgentActivityUpdate(
|
private emitSubAgentActivityUpdate(
|
||||||
callId: string,
|
callId: string,
|
||||||
status?: ToolCallTimelineItem["status"],
|
status?: ToolCallTimelineItem["status"],
|
||||||
|
options?: { reopen?: boolean },
|
||||||
): void {
|
): void {
|
||||||
const state = this.subAgentCallsByCallId.get(callId);
|
const state = this.subAgentCallsByCallId.get(callId);
|
||||||
if (!state || state.toolCall.detail.type !== "sub_agent") {
|
if (!state || state.toolCall.detail.type !== "sub_agent") {
|
||||||
@@ -5281,7 +5291,14 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
childTimeline.length > 0
|
childTimeline.length > 0
|
||||||
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
|
? 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) {
|
for (const childThreadId of state.childThreadIds) {
|
||||||
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
|
||||||
}
|
}
|
||||||
@@ -5420,7 +5437,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
this.pendingCommandOutputDeltas.delete(itemId);
|
this.pendingCommandOutputDeltas.delete(itemId);
|
||||||
this.pendingFileChangeOutputDeltas.delete(itemId);
|
this.pendingFileChangeOutputDeltas.delete(itemId);
|
||||||
}
|
}
|
||||||
this.emitSubAgentActivityUpdate(callId, "running");
|
this.emitSubAgentActivityUpdate(callId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleSubAgentContextCompactionItem(
|
private handleSubAgentContextCompactionItem(
|
||||||
@@ -5556,7 +5573,7 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
): void {
|
): void {
|
||||||
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
|
||||||
if (subAgentCallId) {
|
if (subAgentCallId) {
|
||||||
this.emitSubAgentActivityUpdate(subAgentCallId, "running");
|
this.emitSubAgentActivityUpdate(subAgentCallId, "running", { reopen: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.currentTurnId = parsed.turnId;
|
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", () => {
|
it("maps mcp read_file completion with detail", () => {
|
||||||
const item = expectMapped(
|
const item = expectMapped(
|
||||||
mapCodexToolCallFromThreadItem(
|
mapCodexToolCallFromThreadItem(
|
||||||
|
|||||||
@@ -569,10 +569,17 @@ function readStatus(value: unknown): string | undefined {
|
|||||||
|
|
||||||
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
function normalizeCollabAgentChildStatus(status: string): ToolCallTimelineItem["status"] {
|
||||||
const normalized = status.trim().toLowerCase();
|
const normalized = status.trim().toLowerCase();
|
||||||
if (normalized === "error" || normalized === "errored") {
|
switch (normalized) {
|
||||||
return "running";
|
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(
|
function resolveCollabAgentStatus(
|
||||||
|
|||||||
Reference in New Issue
Block a user