Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
81 changes: 81 additions & 0 deletions src/__tests__/CodexACPAgent/collab-agent-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,6 +1023,87 @@ describe("CodexEventHandler - collab agent tool call events", () => {
expect(terminal?.args[0].update.state).toBe("cancelled");
});

it("announces an unannounced spawn so its output is not lost", async () => {
// Codex names the subagents it defines, but an ad-hoc `spawn_agent`
// produces no activity item at all. The spawn's own completion is the
// last moment before the child starts talking, so it is what announces.
await initializeNativeSubagents();
const notifications: ServerNotification[] = [
{
method: "item/completed",
params: {
threadId: sessionId,
turnId: "turn-1",
completedAtMs: 0,
item: {
type: "collabAgentToolCall",
id: "call-spawn-adhoc",
tool: "spawnAgent",
status: "completed",
senderThreadId: sessionId,
receiverThreadIds: ["thread-adhoc"],
prompt: "Trace the data flow.",
model: null,
reasoningEffort: null,
agentsStates: {
"thread-adhoc": {status: "pendingInit", message: null},
},
},
},
},
{
method: "item/started",
params: {
threadId: "thread-adhoc",
turnId: "turn-child",
startedAtMs: 0,
item: {
type: "commandExecution",
id: "child-command",
pluginId: null,
scriptPath: null,
command: "rg --files",
cwd: "/test/project",
processId: null,
source: "agent",
status: "inProgress",
commandActions: [],
aggregatedOutput: null,
exitCode: null,
durationMs: null,
},
},
},
];

await setupPromptAndSendNotifications(mockFixture, sessionId, sessionState, notifications);

const updates = mockFixture.getAcpConnectionEvents([])
.filter(event => event.method === "sessionUpdate")
.map(event => event.args[0]);
// Announced against the parent, under the fallback identity, carrying
// the spawn prompt as the task.
expect(updates).toContainEqual({
sessionId,
update: {
sessionUpdate: "subagent_spawned",
subagentSessionId: "thread-adhoc",
name: "Agent ad-adhoc",
task: "Trace the data flow.",
capabilities: {},
},
});
// The work it does belongs to the child's session, not the thread's.
expect(updates.filter(update => update.sessionId === "thread-adhoc"))
.toContainEqual(expect.objectContaining({
update: expect.objectContaining({sessionUpdate: "tool_call"}),
}));
// And the spawn itself never appears in the thread as a bare tool call.
expect(updates.filter(update => update.sessionId === sessionId)
.map(update => update.update.sessionUpdate))
.not.toContain("tool_call");
});

it("waits for a pending spawn without publishing fallback identity and suppresses late activity", async () => {
await initializeNativeSubagents();
const appServer = mockFixture.getCodexAppServerClient();
Expand Down
35 changes: 31 additions & 4 deletions src/subagents/CodexSubagentEventRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,22 @@ export class CodexSubagentEventRouter {
}
}

// Waiting for an activity item to name the child only works for
// subagents Codex names; an ad-hoc `spawn_agent` is never announced, so
// the child talks into a buffer that fills and drops its oldest updates
// while the client never learns it existed. A completed spawn is Codex
// confirming the child, and lands before any of its output. An activity
// item that does arrive first still wins: materializing twice is a no-op.
if (item.tool === "spawnAgent" && notification.method === "item/completed") {
for (const childSessionId of item.receiverThreadIds) {
if (!this.pendingSpawns.has(childSessionId)) continue;
const state = item.agentsStates[childSessionId];
if (state && terminalStateOf(state.status) !== undefined) continue;
logger.log(`Announcing spawned subagent ${childSessionId} from its spawn; Codex named no agent path`);
await this.materialize(childSessionId);
}
}

for (const [childSessionId, state] of Object.entries(item.agentsStates)) {
const terminalState = state && terminalStateOf(state.status);
if (!terminalState) continue;
Expand Down Expand Up @@ -305,11 +321,22 @@ export class CodexSubagentEventRouter {
: [];
}

private async materialize(childSessionId: string, path: string): Promise<void> {
/**
* Announces a child as a native subagent session. `path` is Codex's agent
* path, which names the subagent and locates it in the tree; a spawn that
* was never announced by an activity item has none, and is announced under
* the fallback identity against the parent recorded when it was spawned.
*/
private async materialize(childSessionId: string, path?: string): Promise<void> {
if (this.children.has(childSessionId)) return;
const pending = this.pendingSpawns.get(childSessionId);
const name = nameFromAgentPath(path, fallbackName(childSessionId));
const inferredParent = this.parentForPath(path);
if (path === undefined && !pending) return;
const name = path === undefined
? fallbackName(childSessionId)
: nameFromAgentPath(path, fallbackName(childSessionId));
const inferredParent = path === undefined
? {threadId: this.rootSessionId, sessionId: this.rootSessionId}
: this.parentForPath(path);
const parentThreadId = pending?.parentThreadId ?? inferredParent.threadId;
const parentSessionId = pending?.parentSessionId ?? inferredParent.sessionId;
const task = pending?.task ?? `Delegated task for ${name}`;
Expand All @@ -326,7 +353,7 @@ export class CodexSubagentEventRouter {
sessionId: childSessionId,
name,
task,
path: normalizeAgentPath(path),
...(path === undefined ? {} : {path: normalizeAgentPath(path)}),
generation: 1,
});
this.pendingSpawns.delete(childSessionId);
Expand Down