From 5d2b18fa864f745f1e97e1b3b1890032bc1c0253 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Jul 2026 22:38:17 +0200 Subject: [PATCH] fix(subagents): preserve active descendants and native names --- .../src/server/agent/agent-manager.test.ts | 34 ++++++++++-- .../server/src/server/agent/agent-manager.ts | 31 +++++++++-- .../providers/codex-app-server-agent.test.ts | 54 +++++++++++++++++++ .../agent/providers/codex-app-server-agent.ts | 15 ++++++ 4 files changed, 125 insertions(+), 9 deletions(-) diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ee1f6d0962..ba3297582e 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2849,15 +2849,18 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro } } class SubagentsClient extends TestAgentClient { - private sessionsCreated = 0; + readonly sessions: TestAgentSession[] = []; override async createSession(config: AgentSessionConfig): Promise { - this.sessionsCreated += 1; - return this.sessionsCreated === 1 ? new ParentSession(config) : new TestAgentSession(config); + const session = + this.sessions.length === 0 ? new ParentSession(config) : new TestAgentSession(config); + this.sessions.push(session); + return session; } } + const client = new SubagentsClient(); const manager = new AgentManager({ - clients: { codex: new SubagentsClient() }, + clients: { codex: client }, registry: storage, logger, }); @@ -2869,6 +2872,27 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro undefined, { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, ); + const managedWithRunningDescendant = await manager.createAgent( + { provider: "codex", cwd: workdir }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined }, + ); + const runningGrandchild = await manager.createAgent( + { provider: "codex", cwd: workdir }, + undefined, + { + labels: { [PARENT_AGENT_ID_LABEL]: managedWithRunningDescendant.id }, + workspaceId: undefined, + }, + ); + client.sessions[3]?.pushEvent({ + type: "turn_started", + provider: "codex", + turnId: "running-grandchild-turn", + }); + await vi.waitFor(() => { + expect(manager.getAgent(runningGrandchild.id)?.lifecycle).toBe("running"); + }); await manager.hydrateTimelineFromProvider(parent.id); const result = await manager.archiveFinishedSubagents(parent.id); @@ -2878,6 +2902,8 @@ test("archiveFinishedSubagents archives managed children and keeps dismissed pro dismissedProviderSubagentIds: ["native-finished", "native-failed"], }); expectArchivedAgentRecord(await storage.get(managedFinished.id), "closed"); + expect((await storage.get(managedWithRunningDescendant.id))?.archivedAt).toBeFalsy(); + expect(manager.getAgent(runningGrandchild.id)?.lifecycle).toBe("running"); expect(manager.listProviderSubagents(parent.id).map((subagent) => subagent.id)).toEqual([ "native-running", ]); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 25632c6566..28ba0ad1f0 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -971,14 +971,35 @@ export class AgentManager { const parent = this.requirePublicAgent(parentAgentId); const registry = this.requireRegistry(); const archivedAgentIds: string[] = []; - - for (const record of await registry.list()) { - if (record.archivedAt || record.labels[PARENT_AGENT_ID_LABEL] !== parentAgentId) { - continue; + const records = (await registry.list()).filter((record) => !record.archivedAt); + const recordsByParentId = new Map(); + for (const record of records) { + const recordParentId = record.labels[PARENT_AGENT_ID_LABEL]; + if (!recordParentId) continue; + const siblings = recordsByParentId.get(recordParentId) ?? []; + siblings.push(record); + recordsByParentId.set(recordParentId, siblings); + } + const hasBusyDescendant = (agentId: string, visited = new Set()): boolean => { + if (visited.has(agentId)) return true; + visited.add(agentId); + for (const child of recordsByParentId.get(agentId) ?? []) { + const liveChild = this.agents.get(child.id); + if ( + isAgentBusy(liveChild?.lifecycle ?? child.lastStatus) || + hasBusyDescendant(child.id, visited) + ) { + return true; + } } + visited.delete(agentId); + return false; + }; + + for (const record of recordsByParentId.get(parentAgentId) ?? []) { const live = this.agents.get(record.id); const status = live?.lifecycle ?? record.lastStatus; - if (isAgentBusy(status)) { + if (isAgentBusy(status) || hasBusyDescendant(record.id)) { continue; } if (live) { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index 4db6c748cf..96ed337ae1 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1847,6 +1847,60 @@ describe("Codex app-server provider", () => { }); }); + test("updates a registered child with its later native activity name", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "collabAgentToolCall", + id: "call-native-name-later", + tool: "spawnAgent", + status: "completed", + prompt: "Inspect the repository.", + receiverThreadIds: ["child-native-name-later"], + agentsStates: { + "child-native-name-later": { status: "pendingInit", message: null }, + }, + }, + }); + asInternals(session).handleNotification("item/started", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "activity-native-name-later", + kind: "started", + agentThreadId: "child-native-name-later", + agentPath: "/root/research/investigator", + }, + }); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + provider: "codex", + event: expect.objectContaining({ + type: "upsert", + id: "child-native-name-later", + title: "research/investigator", + }), + }), + ); + expect(events.at(-1)).toMatchObject({ + type: "timeline", + item: { + callId: "call-native-name-later", + detail: { + type: "sub_agent", + subAgentType: "research/investigator", + description: "Inspect the repository.", + }, + }, + }); + }); + test("renders child MCP image results in the provider subagent timeline", () => { const session = createSession(); const events: AgentStreamEvent[] = []; diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 48aeddb904..4cc7048d60 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -4843,6 +4843,21 @@ export class CodexAppServerAgentSession implements AgentSession { if (activity.id) { state.activityItemIds.add(activity.id); } + const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, { + cwd: this.config.cwd ?? null, + }); + if ( + activityToolCall?.detail.type === "sub_agent" && + state.toolCall.detail.type === "sub_agent" + ) { + state.toolCall = { + ...state.toolCall, + detail: { + ...state.toolCall.detail, + subAgentType: activityToolCall.detail.subAgentType, + }, + }; + } this.emitSubAgentActivityUpdate( callId, activity.kind === "interrupted" ? "canceled" : "running",