fix(subagents): preserve active descendants and native names

This commit is contained in:
Mohamed Boudra
2026-07-15 00:10:31 +02:00
parent 8529eda8a7
commit 5d2b18fa86
4 changed files with 125 additions and 9 deletions
@@ -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<AgentSession> {
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",
]);
@@ -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<string, StoredAgentRecord[]>();
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<string>()): 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) {
@@ -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[] = [];
@@ -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",