Skip to content

Commit df536e0

Browse files
Show parent-level Codex subagent activity
Keep compact collaboration rows and accurate status while child-thread output and lifecycle notifications remain isolated from the parent session. Generated-By: PostHog Code Task-Id: 971044c5-e622-4f07-b1b5-dca9b06d040c
1 parent f2fe4db commit df536e0

7 files changed

Lines changed: 228 additions & 22 deletions

File tree

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,19 @@ describe("CodexAppServerAgent", () => {
153153
sessionId: "thr_1",
154154
prompt: [{ type: "text", text: "delegate this" }],
155155
} as unknown as PromptRequest);
156+
stub.emit("item/started", {
157+
threadId: "thr_1",
158+
turnId: "turn_1",
159+
item: {
160+
type: "collabAgentToolCall",
161+
id: "spawn_1",
162+
tool: "spawnAgent",
163+
status: "inProgress",
164+
senderThreadId: "thr_1",
165+
receiverThreadIds: ["subagent_1"],
166+
prompt: "Review the implementation",
167+
},
168+
});
156169
const sessionUpdateCount = sessionUpdates.length;
157170
const extNotificationCount = extNotifications.length;
158171

@@ -211,7 +224,9 @@ describe("CodexAppServerAgent", () => {
211224
});
212225

213226
await expect(promptDone).resolves.toMatchObject({ stopReason: "end_turn" });
214-
expect(JSON.stringify(sessionUpdates)).toContain("parent response");
227+
const serializedUpdates = JSON.stringify(sessionUpdates);
228+
expect(serializedUpdates).toContain("spawn_agent");
229+
expect(serializedUpdates).toContain("parent response");
215230
});
216231

217232
it("includes buffered command output when completion omits aggregatedOutput", async () => {

packages/agent/src/adapters/codex-app-server/mapping.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,74 @@ describe("mapAppServerNotification", () => {
230230
});
231231
});
232232

233+
it("maps a spawned Codex agent to an explicit subagent tool call", () => {
234+
const result = mapAppServerNotification(
235+
"s-1",
236+
APP_SERVER_NOTIFICATIONS.ITEM_STARTED,
237+
{
238+
item: {
239+
type: "collabAgentToolCall",
240+
id: "spawn-1",
241+
tool: "spawnAgent",
242+
status: "inProgress",
243+
senderThreadId: "main-thread",
244+
receiverThreadIds: ["child-thread"],
245+
prompt: "Review the authentication changes\nFocus on security.",
246+
model: "gpt-5.5",
247+
reasoningEffort: "high",
248+
},
249+
},
250+
);
251+
252+
expect(result).toEqual({
253+
sessionId: "s-1",
254+
update: {
255+
sessionUpdate: "tool_call",
256+
toolCallId: "spawn-1",
257+
title: "Review the authentication changes",
258+
kind: "other",
259+
status: "in_progress",
260+
rawInput: {
261+
prompt: "Review the authentication changes\nFocus on security.",
262+
receiverThreadIds: ["child-thread"],
263+
model: "gpt-5.5",
264+
reasoningEffort: "high",
265+
},
266+
_meta: { posthog: { toolName: "spawn_agent" } },
267+
},
268+
});
269+
});
270+
271+
it("keeps a completed spawn tool call active while its subagent is running", () => {
272+
const result = mapAppServerNotification(
273+
"s-1",
274+
APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED,
275+
{
276+
item: {
277+
type: "collabAgentToolCall",
278+
id: "spawn-1",
279+
tool: "spawnAgent",
280+
status: "completed",
281+
senderThreadId: "main-thread",
282+
receiverThreadIds: ["child-thread"],
283+
prompt: "Review the authentication changes",
284+
agentsStates: {
285+
"child-thread": { status: "running", message: null },
286+
},
287+
},
288+
},
289+
);
290+
291+
expect(result).toEqual({
292+
sessionId: "s-1",
293+
update: {
294+
sessionUpdate: "tool_call_update",
295+
toolCallId: "spawn-1",
296+
status: "in_progress",
297+
},
298+
});
299+
});
300+
233301
it("drops agent message items (their deltas already streamed)", () => {
234302
expect(
235303
mapAppServerNotification("s-1", APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, {

packages/agent/src/adapters/codex-app-server/mapping.ts

Lines changed: 95 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,15 @@ export type AppServerItem = {
206206
// Present on message/reasoning items replayed from thread history.
207207
text?: string;
208208
content?: unknown;
209+
senderThreadId?: string;
210+
receiverThreadIds?: string[];
211+
prompt?: string | null;
212+
model?: string | null;
213+
reasoningEffort?: string | null;
214+
agentsStates?: Record<
215+
string,
216+
{ status?: string; message?: string | null } | undefined
217+
>;
209218
};
210219

211220
function mcpResultText(
@@ -295,14 +304,20 @@ export function mapHistoryItem(
295304
status: mapStatus(item.status),
296305
...(tool.rawInput !== undefined ? { rawInput: tool.rawInput } : {}),
297306
...(tool.locations?.length ? { locations: tool.locations } : {}),
298-
...(tool.mcp
307+
...(item.type === "collabAgentToolCall"
299308
? {
300309
_meta: posthogToolMeta({
301-
toolName: mcpToolKey(tool.mcp),
302-
mcp: tool.mcp,
310+
toolName: collabAgentToolName(item.tool),
303311
}),
304312
}
305-
: {}),
313+
: tool.mcp
314+
? {
315+
_meta: posthogToolMeta({
316+
toolName: mcpToolKey(tool.mcp),
317+
mcp: tool.mcp,
318+
}),
319+
}
320+
: {}),
306321
...(content ? { content } : {}),
307322
},
308323
},
@@ -394,13 +409,64 @@ function describeTool(item: AppServerItem): ToolDescriptor | null {
394409
rawInput: item.arguments,
395410
output: dynamicToolText(item.contentItems),
396411
};
412+
case "collabAgentToolCall":
413+
return {
414+
title: collabAgentTitle(item),
415+
kind: "other",
416+
rawInput: {
417+
...(item.prompt ? { prompt: item.prompt } : {}),
418+
...(item.receiverThreadIds?.length
419+
? { receiverThreadIds: item.receiverThreadIds }
420+
: {}),
421+
...(item.model ? { model: item.model } : {}),
422+
...(item.reasoningEffort
423+
? { reasoningEffort: item.reasoningEffort }
424+
: {}),
425+
},
426+
};
397427
case "webSearch":
398428
return { title: item.query ?? "Web search", kind: "fetch" };
399429
default:
400430
return null;
401431
}
402432
}
403433

434+
function collabAgentTitle(item: AppServerItem): string {
435+
switch (item.tool) {
436+
case "spawnAgent":
437+
return item.prompt
438+
? item.prompt.split("\n", 1)[0].slice(0, 120)
439+
: "Spawn subagent";
440+
case "sendInput":
441+
return "Message subagent";
442+
case "resumeAgent":
443+
return "Resume subagent";
444+
case "wait":
445+
return "Wait for subagents";
446+
case "closeAgent":
447+
return "Close subagent";
448+
default:
449+
return "Subagent";
450+
}
451+
}
452+
453+
function collabAgentToolName(tool: string | undefined): string {
454+
switch (tool) {
455+
case "spawnAgent":
456+
return "spawn_agent";
457+
case "sendInput":
458+
return "send_input";
459+
case "resumeAgent":
460+
return "resume_agent";
461+
case "wait":
462+
return "wait_agent";
463+
case "closeAgent":
464+
return "close_agent";
465+
default:
466+
return "subagent";
467+
}
468+
}
469+
404470
/** Distinct, non-empty changed paths for a fileChange item, order-preserved. */
405471
export function changePaths(changes: AppServerItem["changes"]): string[] {
406472
if (!changes?.length) return [];
@@ -459,14 +525,20 @@ function mapItem(
459525
status: "in_progress",
460526
...(tool.rawInput !== undefined ? { rawInput: tool.rawInput } : {}),
461527
...(tool.locations?.length ? { locations: tool.locations } : {}),
462-
...(tool.mcp
528+
...(item.type === "collabAgentToolCall"
463529
? {
464530
_meta: posthogToolMeta({
465-
toolName: mcpToolKey(tool.mcp),
466-
mcp: tool.mcp,
531+
toolName: collabAgentToolName(item.tool),
467532
}),
468533
}
469-
: {}),
534+
: tool.mcp
535+
? {
536+
_meta: posthogToolMeta({
537+
toolName: mcpToolKey(tool.mcp),
538+
mcp: tool.mcp,
539+
}),
540+
}
541+
: {}),
470542
},
471543
};
472544
}
@@ -477,7 +549,7 @@ function mapItem(
477549
update: {
478550
sessionUpdate: "tool_call_update",
479551
toolCallId: item.id,
480-
status: mapStatus(item.status),
552+
status: mapToolStatus(item),
481553
...(content ? { content } : {}),
482554
},
483555
};
@@ -523,6 +595,20 @@ function mapStatus(
523595
return "in_progress";
524596
}
525597

598+
function mapToolStatus(
599+
item: AppServerItem,
600+
): "completed" | "failed" | "in_progress" {
601+
if (
602+
item.type === "collabAgentToolCall" &&
603+
Object.values(item.agentsStates ?? {}).some(
604+
(agent) => agent?.status === "pendingInit" || agent?.status === "running",
605+
)
606+
) {
607+
return "in_progress";
608+
}
609+
return mapStatus(item.status);
610+
}
611+
526612
function readItem(params: unknown): AppServerItem | null {
527613
if (params && typeof params === "object" && "item" in params) {
528614
const item = (params as Record<string, unknown>).item;

packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,4 +73,26 @@ describe("buildThreadGroups MCP detection", () => {
7373
expect(grouping.idToRowIndex.get("t1")).toBe(0);
7474
expect(grouping.idToRowIndex.get("t2")).toBe(0);
7575
});
76+
77+
it("counts only spawned agents as subagents", () => {
78+
const items = [
79+
toolCallItem("spawn-1", {
80+
posthog: { toolName: "spawn_agent" },
81+
}),
82+
toolCallItem("wait-1", {
83+
posthog: { toolName: "wait_agent" },
84+
}),
85+
toolCallItem("close-1", {
86+
posthog: { toolName: "close_agent" },
87+
}),
88+
];
89+
90+
const grouping = buildThreadGroups(items, "all", {});
91+
const row = grouping.rows[0];
92+
expect(row.kind).toBe("tool_group");
93+
if (row.kind !== "tool_group") return;
94+
expect(row.summary.counts.subagents).toBe(1);
95+
expect(row.summary.counts.other).toBe(2);
96+
expect(row.summary.doneLabel).toBe("1 subagent, 2 tool calls");
97+
});
7698
});

packages/ui/src/features/sessions/components/new-thread/conversationThreadConfig.ts

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,19 @@ export const COLLAPSE_MODE_OPTIONS: {
5555

5656
export const grouping = {
5757
/**
58-
* Tool names that spawn a subagent. Counted separately in the chip summary.
59-
* @see ToolCallBlock — same names drive the SubagentToolView branch.
58+
* Tool names that create a subagent. Counted separately in the chip summary.
6059
*/
61-
subagentToolNames: new Set<string>(["Task", "Agent"]),
60+
subagentToolNames: new Set<string>(["Task", "Agent", "spawn_agent"]),
61+
/** Collaboration tools rendered with the dedicated subagent view. */
62+
collaborationToolNames: new Set<string>([
63+
"Task",
64+
"Agent",
65+
"spawn_agent",
66+
"send_input",
67+
"resume_agent",
68+
"wait_agent",
69+
"close_agent",
70+
]),
6271
/**
6372
* MCP-app tool calls are excluded from collapsing so their iframes stay
6473
* mounted (the `keepMounted` contract). Flip to false to fold them in.

packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export function SubagentToolView({
8686
</TooltipContent>
8787
</Tooltip>
8888
<Text className="text-[13px] text-gray-10">
89-
{title || "Subagent"}
89+
<span className="font-medium text-gray-12">Subagent</span>
90+
{title && title !== "Subagent" ? ` · ${title}` : ""}
9091
</Text>
9192
<StatusIndicators isFailed={isFailed} wasCancelled={wasCancelled} />
9293
</Flex>
@@ -135,7 +136,10 @@ export function SubagentToolView({
135136
wasCancelled={wasCancelled}
136137
content={childContent}
137138
>
138-
{title || "Subagent"}
139+
<span>
140+
<span className="font-medium text-gray-12">Subagent</span>
141+
{title && title !== "Subagent" ? ` · ${title}` : ""}
142+
</span>
139143
</ToolRow>
140144
</div>
141145
);

packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import type { ToolCall } from "@posthog/ui/features/sessions/types";
1616
import { Box } from "@radix-ui/themes";
1717
import type { ConversationItem, TurnContext } from "../buildConversationItems";
1818
import { useChatThreadChrome } from "../chat-thread/chatThreadChrome";
19+
import { grouping } from "../new-thread/conversationThreadConfig";
1920
import {
2021
MCP_TOOL_BLOCK_COMPONENT,
2122
type McpToolBlockComponent,
@@ -47,13 +48,10 @@ export function ToolCallBlock({
4748

4849
const props = { toolCall, turnCancelled, turnComplete };
4950

50-
if (
51-
(toolName === "Task" || toolName === "Agent") &&
52-
childItems &&
53-
childItems.length > 0
54-
) {
51+
if (isSubagentTool(toolName)) {
52+
const subagentChildItems = childItems ?? [];
5553
const turnContext: TurnContext = {
56-
toolCalls: buildChildToolCallsMap(childItems),
54+
toolCalls: buildChildToolCallsMap(subagentChildItems),
5755
childItems: childItemsMap ?? new Map(),
5856
turnCancelled: turnCancelled ?? false,
5957
turnComplete: turnComplete ?? false,
@@ -62,7 +60,7 @@ export function ToolCallBlock({
6260
<Box>
6361
<SubagentToolView
6462
{...props}
65-
childItems={childItems}
63+
childItems={subagentChildItems}
6664
turnContext={turnContext}
6765
/>
6866
</Box>
@@ -111,6 +109,10 @@ export function ToolCallBlock({
111109
return <Box>{content}</Box>;
112110
}
113111

112+
function isSubagentTool(toolName: string | undefined): boolean {
113+
return grouping.collaborationToolNames.has(toolName ?? "");
114+
}
115+
114116
function buildChildToolCallsMap(
115117
childItems: ConversationItem[],
116118
): Map<string, ToolCall> {

0 commit comments

Comments
 (0)