Skip to content

Commit 17b0264

Browse files
committed
fix(agent): show Codex subagent activity
Generated-By: PostHog Code Task-Id: d8f51f0f-0f93-47ef-9c93-4b70efc02672
1 parent 0b69cb9 commit 17b0264

7 files changed

Lines changed: 183 additions & 24 deletions

File tree

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

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,7 @@ describe("CodexAppServerAgent", () => {
143143
});
144144
});
145145

146-
it("isolates subagent output, usage, compaction, and completion", async () => {
146+
it("surfaces subagent activity while isolating its lifecycle state", async () => {
147147
const stub = makeStubRpc({
148148
initialize: {},
149149
"thread/start": { thread: { id: "thr_1" } },
@@ -191,7 +191,6 @@ describe("CodexAppServerAgent", () => {
191191
prompt: "Review the implementation",
192192
},
193193
});
194-
const sessionUpdateCount = sessionUpdates.length;
195194
const extNotificationCount = extNotifications.length;
196195

197196
stub.emit("item/agentMessage/delta", {
@@ -215,6 +214,16 @@ describe("CodexAppServerAgent", () => {
215214
text: '{"source":"child"}',
216215
},
217216
});
217+
stub.emit("item/started", {
218+
threadId: "subagent_1",
219+
turnId: "subagent_turn_1",
220+
item: {
221+
type: "commandExecution",
222+
id: "shared_command_id",
223+
command: "echo child",
224+
status: "inProgress",
225+
},
226+
});
218227
stub.emit("item/commandExecution/outputDelta", {
219228
threadId: "subagent_1",
220229
turnId: "subagent_turn_1",
@@ -251,11 +260,9 @@ describe("CodexAppServerAgent", () => {
251260
expect({
252261
extNotifications: extNotifications.length,
253262
promptSettled,
254-
sessionUpdates: sessionUpdates.length,
255263
}).toEqual({
256264
extNotifications: extNotificationCount,
257265
promptSettled: false,
258-
sessionUpdates: sessionUpdateCount,
259266
});
260267

261268
stub.emit("item/agentMessage/delta", {
@@ -292,10 +299,14 @@ describe("CodexAppServerAgent", () => {
292299
await expect(promptDone).resolves.toMatchObject({ stopReason: "end_turn" });
293300
const serializedUpdates = JSON.stringify(sessionUpdates);
294301
expect(serializedUpdates).toContain("spawn_agent");
302+
expect(serializedUpdates).toContain("subagent prose");
303+
expect(serializedUpdates).toContain("subagent reasoning");
304+
expect(serializedUpdates).toContain("child command output");
305+
expect(serializedUpdates).toContain(
306+
"subagent:subagent_1:shared_command_id",
307+
);
308+
expect(serializedUpdates).toContain('"parentToolCallId":"spawn_1"');
295309
expect(serializedUpdates).toContain("parent response");
296-
expect(serializedUpdates).not.toContain("subagent prose");
297-
expect(serializedUpdates).not.toContain("subagent reasoning");
298-
expect(serializedUpdates).not.toContain("child command output");
299310
expect(structuredOutputs).toEqual([{ source: "parent" }]);
300311
expect(
301312
extNotifications.filter(

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

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import type {
1515
RequestPermissionResponse,
1616
ResumeSessionRequest,
1717
ResumeSessionResponse,
18+
SessionNotification,
1819
SetSessionConfigOptionRequest,
1920
SetSessionConfigOptionResponse,
2021
StopReason,
@@ -236,6 +237,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
236237
/** Deployment environment; on "cloud" a non-danger sandbox would panic, so we skip the override. */
237238
private environment?: "local" | "cloud";
238239
private readonly commandOutputs = new Map<string, string>();
240+
private readonly subagentParents = new Map<string, string>();
239241
/** Extra writable roots for this session, folded into workspaceWrite sandbox turns. */
240242
private additionalDirectories?: string[];
241243
/** The session workspace stays writable when extra roots are applied per turn. */
@@ -460,6 +462,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
460462
): Promise<{ threadId: string; thread: AppServerThread | undefined }> {
461463
this.cancelNextGoalTurn = false;
462464
this.nativeGoalTurnId = undefined;
465+
this.subagentParents.clear();
463466
this.jsonSchema = params.meta?.jsonSchema ?? undefined;
464467
this.taskRunId = params.meta?.taskRunId;
465468
this.environment = params.meta?.environment;
@@ -1159,6 +1162,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
11591162

11601163
async closeSession(): Promise<void> {
11611164
this.commandOutputs.clear();
1165+
this.subagentParents.clear();
11621166
this.nativeGoalTurnId = undefined;
11631167
this.session.abortController.abort();
11641168
this.session.cancelled = true;
@@ -1178,23 +1182,25 @@ export class CodexAppServerAgent extends BaseAcpAgent {
11781182
const notificationThreadId = readNotificationThreadId(params);
11791183
const isMainThread =
11801184
!notificationThreadId || notificationThreadId === this.threadId;
1185+
this.captureSubagentRelationship(method, params, notificationThreadId);
11811186
const mappedParams = isMainThread
11821187
? this.withBufferedCommandOutput(method, params)
11831188
: params;
11841189

11851190
if (this.sessionId && !this.session.cancelled) {
1186-
if (isMainThread) {
1187-
const notification = mapAppServerNotification(
1188-
this.sessionId,
1189-
method,
1190-
mappedParams,
1191-
);
1192-
if (notification) {
1193-
void this.client
1194-
.sessionUpdate(notification)
1195-
.catch((err) => this.logger.warn("sessionUpdate failed", err));
1196-
this.appendNotification(this.sessionId, notification);
1197-
}
1191+
const notification = mapAppServerNotification(
1192+
this.sessionId,
1193+
method,
1194+
mappedParams,
1195+
);
1196+
const visibleNotification = isMainThread
1197+
? notification
1198+
: this.mapSubagentNotification(notification, notificationThreadId);
1199+
if (visibleNotification) {
1200+
void this.client
1201+
.sessionUpdate(visibleNotification)
1202+
.catch((err) => this.logger.warn("sessionUpdate failed", err));
1203+
this.appendNotification(this.sessionId, visibleNotification);
11981204
}
11991205
}
12001206

@@ -1307,6 +1313,87 @@ export class CodexAppServerAgent extends BaseAcpAgent {
13071313
}
13081314
}
13091315

1316+
private captureSubagentRelationship(
1317+
method: string,
1318+
params: unknown,
1319+
senderThreadId: string | undefined,
1320+
): void {
1321+
if (
1322+
method !== APP_SERVER_NOTIFICATIONS.ITEM_STARTED &&
1323+
method !== APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED
1324+
) {
1325+
return;
1326+
}
1327+
const item = (params as { item?: AppServerItem })?.item;
1328+
if (
1329+
item?.type !== "collabAgentToolCall" ||
1330+
item.tool !== "spawnAgent" ||
1331+
!item.id ||
1332+
!item.receiverThreadIds?.length
1333+
) {
1334+
return;
1335+
}
1336+
const parentToolCallId =
1337+
senderThreadId && senderThreadId !== this.threadId
1338+
? subagentToolCallId(senderThreadId, item.id)
1339+
: item.id;
1340+
for (const receiverThreadId of item.receiverThreadIds) {
1341+
this.subagentParents.set(receiverThreadId, parentToolCallId);
1342+
}
1343+
}
1344+
1345+
private mapSubagentNotification(
1346+
notification: SessionNotification | null,
1347+
threadId: string | undefined,
1348+
): SessionNotification | null {
1349+
if (!notification || !threadId) return null;
1350+
const parentToolCallId = this.subagentParents.get(threadId);
1351+
if (!parentToolCallId) return null;
1352+
const update = notification.update as SessionNotification["update"] & {
1353+
_meta?: Record<string, unknown>;
1354+
toolCallId?: string;
1355+
};
1356+
if (
1357+
update.sessionUpdate !== "agent_message_chunk" &&
1358+
update.sessionUpdate !== "agent_thought_chunk" &&
1359+
update.sessionUpdate !== "tool_call" &&
1360+
update.sessionUpdate !== "tool_call_update"
1361+
) {
1362+
return null;
1363+
}
1364+
const toolCallId = update.toolCallId
1365+
? subagentToolCallId(threadId, update.toolCallId)
1366+
: undefined;
1367+
if (update.sessionUpdate === "tool_call_update") {
1368+
return {
1369+
...notification,
1370+
update: { ...update, ...(toolCallId ? { toolCallId } : {}) },
1371+
} as SessionNotification;
1372+
}
1373+
const existingPosthog = (update._meta?.posthog ?? {}) as Record<
1374+
string,
1375+
unknown
1376+
>;
1377+
return {
1378+
...notification,
1379+
update: {
1380+
...update,
1381+
...(toolCallId ? { toolCallId } : {}),
1382+
_meta: {
1383+
...update._meta,
1384+
posthog: {
1385+
toolName:
1386+
typeof existingPosthog.toolName === "string"
1387+
? existingPosthog.toolName
1388+
: "subagent_activity",
1389+
...existingPosthog,
1390+
parentToolCallId,
1391+
},
1392+
},
1393+
},
1394+
} as SessionNotification;
1395+
}
1396+
13101397
private withBufferedCommandOutput(method: string, params: unknown): unknown {
13111398
if (!params || typeof params !== "object") {
13121399
return params;
@@ -1718,6 +1805,10 @@ function readNotificationThreadId(params: unknown): string | undefined {
17181805
return typeof threadId === "string" ? threadId : undefined;
17191806
}
17201807

1808+
function subagentToolCallId(threadId: string, toolCallId: string): string {
1809+
return `subagent:${threadId}:${toolCallId}`;
1810+
}
1811+
17211812
/** The codex thread config override map: folds in MCP servers + makes extra workspace roots writable. Undefined when empty. */
17221813
function buildThreadConfig(
17231814
mcpServers: ReturnType<typeof toCodexMcpServers>,

packages/shared/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,7 @@ export {
275275
readAgentToolName,
276276
readMcpToolDescriptor,
277277
readMcpToolName,
278+
readParentToolCallId,
278279
} from "./tool-meta";
279280
export { TypedEventEmitter } from "./typed-event-emitter";
280281
export { isSafeExternalUrl } from "./url";

packages/shared/src/tool-meta.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
readAgentToolName,
55
readMcpToolDescriptor,
66
readMcpToolName,
7+
readParentToolCallId,
78
} from "./tool-meta";
89

910
describe("parseMcpToolName", () => {
@@ -49,6 +50,25 @@ describe("readAgentToolName", () => {
4950
});
5051
});
5152

53+
describe("readParentToolCallId", () => {
54+
it("prefers the posthog channel over the legacy claudeCode fallback", () => {
55+
expect(
56+
readParentToolCallId({
57+
posthog: { toolName: "Bash", parentToolCallId: "parent-1" },
58+
claudeCode: { parentToolCallId: "stale" },
59+
}),
60+
).toBe("parent-1");
61+
});
62+
63+
it("falls back to claudeCode when posthog is absent", () => {
64+
expect(
65+
readParentToolCallId({
66+
claudeCode: { parentToolCallId: "parent-2" },
67+
}),
68+
).toBe("parent-2");
69+
});
70+
});
71+
5272
describe("readMcpToolDescriptor / readMcpToolName", () => {
5373
it("uses the structured mcp descriptor when present (no name parsing)", () => {
5474
const meta = {

packages/shared/src/tool-meta.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export interface PosthogToolMeta {
1212
toolName: string;
1313
/** Set only for MCP tool calls — the originating server + tool. */
1414
mcp?: { server: string; tool: string };
15+
/** Parent subagent tool call for nested activity. */
16+
parentToolCallId?: string;
1517
}
1618

1719
/** `_meta` fragment for adapters to spread onto a tool_call update. */
@@ -45,7 +47,7 @@ export function parseMcpToolName(
4547
interface ToolCallMeta {
4648
posthog?: PosthogToolMeta;
4749
/** Legacy Claude-adapter channel, read only as a fallback. */
48-
claudeCode?: { toolName?: string };
50+
claudeCode?: { toolName?: string; parentToolCallId?: string };
4951
}
5052

5153
function asToolCallMeta(meta: unknown): ToolCallMeta | undefined {
@@ -58,6 +60,12 @@ export function readAgentToolName(meta: unknown): string | undefined {
5860
return m?.posthog?.toolName ?? m?.claudeCode?.toolName;
5961
}
6062

63+
/** Parent subagent tool call: neutral channel first, legacy fallback. */
64+
export function readParentToolCallId(meta: unknown): string | undefined {
65+
const m = asToolCallMeta(meta);
66+
return m?.posthog?.parentToolCallId ?? m?.claudeCode?.parentToolCallId;
67+
}
68+
6169
/**
6270
* The MCP `{ server, tool }` descriptor for a tool call, or undefined for a
6371
* non-MCP call. Prefers the structured channel, else parses the legacy

packages/ui/src/features/sessions/components/buildConversationItems.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
isJsonRpcNotification,
1313
isJsonRpcRequest,
1414
isJsonRpcResponse,
15+
readParentToolCallId,
1516
type UserShellExecuteParams,
1617
} from "@posthog/shared";
1718
import {
@@ -747,10 +748,7 @@ function extractUserPrompt(params: unknown): {
747748
}
748749

749750
function getParentToolCallId(update: SessionUpdate): string | undefined {
750-
const meta = (update as Record<string, unknown>)?._meta as
751-
| { claudeCode?: { parentToolCallId?: string } }
752-
| undefined;
753-
return meta?.claudeCode?.parentToolCallId;
751+
return readParentToolCallId((update as Record<string, unknown>)._meta);
754752
}
755753

756754
function pushChildItem(b: ItemBuilder, parentId: string, update: RenderItem) {

packages/ui/src/features/sessions/components/incrementalConversationItems.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -475,4 +475,34 @@ describe("createIncrementalConversationBuilder", () => {
475475
expect(row2.turnContext.childItems).not.toBe(row1.turnContext.childItems);
476476
expect(row2.turnContext.childItems.get("agent1")?.length).toBe(1);
477477
});
478+
479+
it("groups canonical PostHog child metadata under its subagent", () => {
480+
const inc = createIncrementalConversationBuilder();
481+
const messages = [
482+
userPromptMsg(1, 1, "go"),
483+
toolCallMsg(2, "agent1", {
484+
_meta: { posthog: { toolName: "spawn_agent" } },
485+
}),
486+
updateMsg(3, {
487+
sessionUpdate: "tool_call",
488+
toolCallId: "child1",
489+
kind: "read",
490+
status: "pending",
491+
title: "child1",
492+
_meta: {
493+
posthog: {
494+
toolName: "subagent_activity",
495+
parentToolCallId: "agent1",
496+
},
497+
},
498+
}),
499+
];
500+
501+
const result = inc.update(messages, true);
502+
const row = result.items.find((item) => item.type === "session_update");
503+
if (row?.type !== "session_update") {
504+
throw new Error("expected agent session_update row");
505+
}
506+
expect(row.turnContext.childItems.get("agent1")?.length).toBe(1);
507+
});
478508
});

0 commit comments

Comments
 (0)