diff --git a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts index 95ea2f99..3777671a 100644 --- a/src/__tests__/CodexACPAgent/collab-agent-events.test.ts +++ b/src/__tests__/CodexACPAgent/collab-agent-events.test.ts @@ -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(); diff --git a/src/subagents/CodexSubagentEventRouter.ts b/src/subagents/CodexSubagentEventRouter.ts index 8d232f12..dd1b8381 100644 --- a/src/subagents/CodexSubagentEventRouter.ts +++ b/src/subagents/CodexSubagentEventRouter.ts @@ -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; @@ -305,11 +321,22 @@ export class CodexSubagentEventRouter { : []; } - private async materialize(childSessionId: string, path: string): Promise { + /** + * 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 { 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}`; @@ -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);