From f2fe4db61e5e76ab7f9a628d374fb2726fbf02aa Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Wed, 15 Jul 2026 13:18:47 +0300 Subject: [PATCH 1/6] Fix Codex subagent thread isolation Keep child-thread output, usage, compaction, and completion notifications from mutating the parent ACP session. Generated-By: PostHog Code Task-Id: 971044c5-e622-4f07-b1b5-dca9b06d040c --- .../codex-app-server-agent.test.ts | 80 +++++++++++++++++++ .../codex-app-server-agent.ts | 44 ++++++---- 2 files changed, 109 insertions(+), 15 deletions(-) diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index 2ec361ddae..bef669fd3a 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -134,6 +134,86 @@ describe("CodexAppServerAgent", () => { }); }); + it("isolates subagent output, usage, compaction, and completion", async () => { + const stub = makeStubRpc({ + initialize: {}, + "thread/start": { thread: { id: "thr_1" } }, + "turn/start": { turn: { id: "turn_1", status: "inProgress" } }, + }); + const { client, sessionUpdates, extNotifications } = makeFakeClient(); + const agent = new CodexAppServerAgent(client, { + processOptions: { binaryPath: "/bundle/codex" }, + model: "gpt-5.5", + rpcFactory: stub.factory, + }); + + await agent.initialize(init); + await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + const promptDone = agent.prompt({ + sessionId: "thr_1", + prompt: [{ type: "text", text: "delegate this" }], + } as unknown as PromptRequest); + const sessionUpdateCount = sessionUpdates.length; + const extNotificationCount = extNotifications.length; + + stub.emit("item/agentMessage/delta", { + threadId: "subagent_1", + turnId: "subagent_turn_1", + itemId: "subagent_message_1", + delta: "subagent prose", + }); + stub.emit("thread/tokenUsage/updated", { + threadId: "subagent_1", + tokenUsage: { + total: { totalTokens: 9000 }, + modelContextWindow: 10000, + }, + }); + stub.emit("item/started", { + threadId: "subagent_1", + turnId: "subagent_turn_1", + item: { type: "contextCompaction", id: "subagent_compaction_1" }, + }); + stub.emit("item/completed", { + threadId: "subagent_1", + turnId: "subagent_turn_1", + item: { type: "contextCompaction", id: "subagent_compaction_1" }, + }); + stub.emit("turn/completed", { + threadId: "subagent_1", + turn: { id: "subagent_turn_1", status: "completed" }, + }); + + let promptSettled = false; + void promptDone.then(() => { + promptSettled = true; + }); + await Promise.resolve(); + expect({ + extNotifications: extNotifications.length, + promptSettled, + sessionUpdates: sessionUpdates.length, + }).toEqual({ + extNotifications: extNotificationCount, + promptSettled: false, + sessionUpdates: sessionUpdateCount, + }); + + stub.emit("item/agentMessage/delta", { + threadId: "thr_1", + turnId: "turn_1", + itemId: "message_1", + delta: "parent response", + }); + stub.emit("turn/completed", { + threadId: "thr_1", + turn: { id: "turn_1", status: "completed" }, + }); + + await expect(promptDone).resolves.toMatchObject({ stopReason: "end_turn" }); + expect(JSON.stringify(sessionUpdates)).toContain("parent response"); + }); + it("includes buffered command output when completion omits aggregatedOutput", async () => { const stub = makeStubRpc({ initialize: {}, diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index 1271ea62b5..b6211a1a1e 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -938,25 +938,26 @@ export class CodexAppServerAgent extends BaseAcpAgent { private handleNotification(method: string, params: unknown): void { const mappedParams = this.withBufferedCommandOutput(method, params); + const notificationThreadId = readNotificationThreadId(mappedParams); + const isMainThread = + !notificationThreadId || notificationThreadId === this.threadId; + if (this.sessionId && !this.session.cancelled) { - const notification = mapAppServerNotification( - this.sessionId, - method, - mappedParams, - ); - if (notification) { - void this.client - .sessionUpdate(notification) - .catch((err) => this.logger.warn("sessionUpdate failed", err)); - this.appendNotification(this.sessionId, notification); + if (isMainThread) { + const notification = mapAppServerNotification( + this.sessionId, + method, + mappedParams, + ); + if (notification) { + void this.client + .sessionUpdate(notification) + .catch((err) => this.logger.warn("sessionUpdate failed", err)); + this.appendNotification(this.sessionId, notification); + } } } - if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) { - // Capture the active turn id (steer precondition / interrupt target). - this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id); - } - if (method === APP_SERVER_NOTIFICATIONS.ITEM_STARTED) { this.mcp.capture(params); } @@ -964,6 +965,13 @@ export class CodexAppServerAgent extends BaseAcpAgent { this.mcp.release(params); } + if (!isMainThread) return; + + if (method === APP_SERVER_NOTIFICATIONS.TURN_STARTED) { + // Capture the active turn id (steer precondition / interrupt target). + this.turns.onStarted((params as { turn?: { id?: string } })?.turn?.id); + } + // codex auto-compaction surfaces as a contextCompaction item: item/started → in progress, // item/completed → boundary (codex emits no separate thread/compacted; that's a guarded // fallback). compactionActive dedupes to one boundary per compaction. @@ -1437,6 +1445,12 @@ function mapTurnStopReason(status: string | undefined): StopReason { return "end_turn"; } +function readNotificationThreadId(params: unknown): string | undefined { + if (!params || typeof params !== "object") return undefined; + const threadId = (params as { threadId?: unknown }).threadId; + return typeof threadId === "string" ? threadId : undefined; +} + /** The codex thread config override map: folds in MCP servers + makes extra workspace roots writable. Undefined when empty. */ function buildThreadConfig( mcpServers: ReturnType, From df536e0e3b4830d56b8ea2b85191d003b94e8f87 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Wed, 15 Jul 2026 13:26:12 +0300 Subject: [PATCH 2/6] 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 --- .../codex-app-server-agent.test.ts | 17 ++- .../adapters/codex-app-server/mapping.test.ts | 68 ++++++++++++ .../src/adapters/codex-app-server/mapping.ts | 104 ++++++++++++++++-- .../new-thread/buildThreadGroups.test.ts | 22 ++++ .../new-thread/conversationThreadConfig.ts | 15 ++- .../session-update/SubagentToolView.tsx | 8 +- .../session-update/ToolCallBlock.tsx | 16 +-- 7 files changed, 228 insertions(+), 22 deletions(-) diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index bef669fd3a..d20d2e4cc5 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -153,6 +153,19 @@ describe("CodexAppServerAgent", () => { sessionId: "thr_1", prompt: [{ type: "text", text: "delegate this" }], } as unknown as PromptRequest); + stub.emit("item/started", { + threadId: "thr_1", + turnId: "turn_1", + item: { + type: "collabAgentToolCall", + id: "spawn_1", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "thr_1", + receiverThreadIds: ["subagent_1"], + prompt: "Review the implementation", + }, + }); const sessionUpdateCount = sessionUpdates.length; const extNotificationCount = extNotifications.length; @@ -211,7 +224,9 @@ describe("CodexAppServerAgent", () => { }); await expect(promptDone).resolves.toMatchObject({ stopReason: "end_turn" }); - expect(JSON.stringify(sessionUpdates)).toContain("parent response"); + const serializedUpdates = JSON.stringify(sessionUpdates); + expect(serializedUpdates).toContain("spawn_agent"); + expect(serializedUpdates).toContain("parent response"); }); it("includes buffered command output when completion omits aggregatedOutput", async () => { diff --git a/packages/agent/src/adapters/codex-app-server/mapping.test.ts b/packages/agent/src/adapters/codex-app-server/mapping.test.ts index 776efab120..288e8732bf 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -230,6 +230,74 @@ describe("mapAppServerNotification", () => { }); }); + it("maps a spawned Codex agent to an explicit subagent tool call", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { + item: { + type: "collabAgentToolCall", + id: "spawn-1", + tool: "spawnAgent", + status: "inProgress", + senderThreadId: "main-thread", + receiverThreadIds: ["child-thread"], + prompt: "Review the authentication changes\nFocus on security.", + model: "gpt-5.5", + reasoningEffort: "high", + }, + }, + ); + + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "spawn-1", + title: "Review the authentication changes", + kind: "other", + status: "in_progress", + rawInput: { + prompt: "Review the authentication changes\nFocus on security.", + receiverThreadIds: ["child-thread"], + model: "gpt-5.5", + reasoningEffort: "high", + }, + _meta: { posthog: { toolName: "spawn_agent" } }, + }, + }); + }); + + it("keeps a completed spawn tool call active while its subagent is running", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, + { + item: { + type: "collabAgentToolCall", + id: "spawn-1", + tool: "spawnAgent", + status: "completed", + senderThreadId: "main-thread", + receiverThreadIds: ["child-thread"], + prompt: "Review the authentication changes", + agentsStates: { + "child-thread": { status: "running", message: null }, + }, + }, + }, + ); + + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call_update", + toolCallId: "spawn-1", + status: "in_progress", + }, + }); + }); + it("drops agent message items (their deltas already streamed)", () => { expect( mapAppServerNotification("s-1", APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, { diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index af9cd206a7..ccb0d7b6e0 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -206,6 +206,15 @@ export type AppServerItem = { // Present on message/reasoning items replayed from thread history. text?: string; content?: unknown; + senderThreadId?: string; + receiverThreadIds?: string[]; + prompt?: string | null; + model?: string | null; + reasoningEffort?: string | null; + agentsStates?: Record< + string, + { status?: string; message?: string | null } | undefined + >; }; function mcpResultText( @@ -295,14 +304,20 @@ export function mapHistoryItem( status: mapStatus(item.status), ...(tool.rawInput !== undefined ? { rawInput: tool.rawInput } : {}), ...(tool.locations?.length ? { locations: tool.locations } : {}), - ...(tool.mcp + ...(item.type === "collabAgentToolCall" ? { _meta: posthogToolMeta({ - toolName: mcpToolKey(tool.mcp), - mcp: tool.mcp, + toolName: collabAgentToolName(item.tool), }), } - : {}), + : tool.mcp + ? { + _meta: posthogToolMeta({ + toolName: mcpToolKey(tool.mcp), + mcp: tool.mcp, + }), + } + : {}), ...(content ? { content } : {}), }, }, @@ -394,6 +409,21 @@ function describeTool(item: AppServerItem): ToolDescriptor | null { rawInput: item.arguments, output: dynamicToolText(item.contentItems), }; + case "collabAgentToolCall": + return { + title: collabAgentTitle(item), + kind: "other", + rawInput: { + ...(item.prompt ? { prompt: item.prompt } : {}), + ...(item.receiverThreadIds?.length + ? { receiverThreadIds: item.receiverThreadIds } + : {}), + ...(item.model ? { model: item.model } : {}), + ...(item.reasoningEffort + ? { reasoningEffort: item.reasoningEffort } + : {}), + }, + }; case "webSearch": return { title: item.query ?? "Web search", kind: "fetch" }; default: @@ -401,6 +431,42 @@ function describeTool(item: AppServerItem): ToolDescriptor | null { } } +function collabAgentTitle(item: AppServerItem): string { + switch (item.tool) { + case "spawnAgent": + return item.prompt + ? item.prompt.split("\n", 1)[0].slice(0, 120) + : "Spawn subagent"; + case "sendInput": + return "Message subagent"; + case "resumeAgent": + return "Resume subagent"; + case "wait": + return "Wait for subagents"; + case "closeAgent": + return "Close subagent"; + default: + return "Subagent"; + } +} + +function collabAgentToolName(tool: string | undefined): string { + switch (tool) { + case "spawnAgent": + return "spawn_agent"; + case "sendInput": + return "send_input"; + case "resumeAgent": + return "resume_agent"; + case "wait": + return "wait_agent"; + case "closeAgent": + return "close_agent"; + default: + return "subagent"; + } +} + /** Distinct, non-empty changed paths for a fileChange item, order-preserved. */ export function changePaths(changes: AppServerItem["changes"]): string[] { if (!changes?.length) return []; @@ -459,14 +525,20 @@ function mapItem( status: "in_progress", ...(tool.rawInput !== undefined ? { rawInput: tool.rawInput } : {}), ...(tool.locations?.length ? { locations: tool.locations } : {}), - ...(tool.mcp + ...(item.type === "collabAgentToolCall" ? { _meta: posthogToolMeta({ - toolName: mcpToolKey(tool.mcp), - mcp: tool.mcp, + toolName: collabAgentToolName(item.tool), }), } - : {}), + : tool.mcp + ? { + _meta: posthogToolMeta({ + toolName: mcpToolKey(tool.mcp), + mcp: tool.mcp, + }), + } + : {}), }, }; } @@ -477,7 +549,7 @@ function mapItem( update: { sessionUpdate: "tool_call_update", toolCallId: item.id, - status: mapStatus(item.status), + status: mapToolStatus(item), ...(content ? { content } : {}), }, }; @@ -523,6 +595,20 @@ function mapStatus( return "in_progress"; } +function mapToolStatus( + item: AppServerItem, +): "completed" | "failed" | "in_progress" { + if ( + item.type === "collabAgentToolCall" && + Object.values(item.agentsStates ?? {}).some( + (agent) => agent?.status === "pendingInit" || agent?.status === "running", + ) + ) { + return "in_progress"; + } + return mapStatus(item.status); +} + function readItem(params: unknown): AppServerItem | null { if (params && typeof params === "object" && "item" in params) { const item = (params as Record).item; diff --git a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts index 5824702632..f338ac3bc4 100644 --- a/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts +++ b/packages/ui/src/features/sessions/components/new-thread/buildThreadGroups.test.ts @@ -73,4 +73,26 @@ describe("buildThreadGroups MCP detection", () => { expect(grouping.idToRowIndex.get("t1")).toBe(0); expect(grouping.idToRowIndex.get("t2")).toBe(0); }); + + it("counts only spawned agents as subagents", () => { + const items = [ + toolCallItem("spawn-1", { + posthog: { toolName: "spawn_agent" }, + }), + toolCallItem("wait-1", { + posthog: { toolName: "wait_agent" }, + }), + toolCallItem("close-1", { + posthog: { toolName: "close_agent" }, + }), + ]; + + const grouping = buildThreadGroups(items, "all", {}); + const row = grouping.rows[0]; + expect(row.kind).toBe("tool_group"); + if (row.kind !== "tool_group") return; + expect(row.summary.counts.subagents).toBe(1); + expect(row.summary.counts.other).toBe(2); + expect(row.summary.doneLabel).toBe("1 subagent, 2 tool calls"); + }); }); diff --git a/packages/ui/src/features/sessions/components/new-thread/conversationThreadConfig.ts b/packages/ui/src/features/sessions/components/new-thread/conversationThreadConfig.ts index 1ef473979b..9b7a01aea1 100644 --- a/packages/ui/src/features/sessions/components/new-thread/conversationThreadConfig.ts +++ b/packages/ui/src/features/sessions/components/new-thread/conversationThreadConfig.ts @@ -55,10 +55,19 @@ export const COLLAPSE_MODE_OPTIONS: { export const grouping = { /** - * Tool names that spawn a subagent. Counted separately in the chip summary. - * @see ToolCallBlock — same names drive the SubagentToolView branch. + * Tool names that create a subagent. Counted separately in the chip summary. */ - subagentToolNames: new Set(["Task", "Agent"]), + subagentToolNames: new Set(["Task", "Agent", "spawn_agent"]), + /** Collaboration tools rendered with the dedicated subagent view. */ + collaborationToolNames: new Set([ + "Task", + "Agent", + "spawn_agent", + "send_input", + "resume_agent", + "wait_agent", + "close_agent", + ]), /** * MCP-app tool calls are excluded from collapsing so their iframes stay * mounted (the `keepMounted` contract). Flip to false to fold them in. diff --git a/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx b/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx index 615be6857d..ca1308fc78 100644 --- a/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx @@ -86,7 +86,8 @@ export function SubagentToolView({ - {title || "Subagent"} + Subagent + {title && title !== "Subagent" ? ` · ${title}` : ""} @@ -135,7 +136,10 @@ export function SubagentToolView({ wasCancelled={wasCancelled} content={childContent} > - {title || "Subagent"} + + Subagent + {title && title !== "Subagent" ? ` · ${title}` : ""} + ); diff --git a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx index dcfe1732db..31620d09c7 100644 --- a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx @@ -16,6 +16,7 @@ import type { ToolCall } from "@posthog/ui/features/sessions/types"; import { Box } from "@radix-ui/themes"; import type { ConversationItem, TurnContext } from "../buildConversationItems"; import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; +import { grouping } from "../new-thread/conversationThreadConfig"; import { MCP_TOOL_BLOCK_COMPONENT, type McpToolBlockComponent, @@ -47,13 +48,10 @@ export function ToolCallBlock({ const props = { toolCall, turnCancelled, turnComplete }; - if ( - (toolName === "Task" || toolName === "Agent") && - childItems && - childItems.length > 0 - ) { + if (isSubagentTool(toolName)) { + const subagentChildItems = childItems ?? []; const turnContext: TurnContext = { - toolCalls: buildChildToolCallsMap(childItems), + toolCalls: buildChildToolCallsMap(subagentChildItems), childItems: childItemsMap ?? new Map(), turnCancelled: turnCancelled ?? false, turnComplete: turnComplete ?? false, @@ -62,7 +60,7 @@ export function ToolCallBlock({ @@ -111,6 +109,10 @@ export function ToolCallBlock({ return {content}; } +function isSubagentTool(toolName: string | undefined): boolean { + return grouping.collaborationToolNames.has(toolName ?? ""); +} + function buildChildToolCallsMap( childItems: ConversationItem[], ): Map { From 40c16f814a99f1171c34ee09cfdfde653077d97e Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Wed, 15 Jul 2026 13:44:47 +0300 Subject: [PATCH 3/6] Test Codex cloud subagent isolation Exercise the cloud task completion signal and document why child notifications must be filtered before ACP log persistence. Generated-By: PostHog Code Task-Id: 971044c5-e622-4f07-b1b5-dca9b06d040c --- packages/agent/README.md | 2 ++ .../codex-app-server/codex-app-server-agent.test.ts | 11 ++++++++++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/agent/README.md b/packages/agent/README.md index 29d54f9693..ca1ead4470 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -126,6 +126,8 @@ start() The two tapping layers are distinct. The inner tap (from `createAcpConnection`) persists to logs. The outer tap (in `AgentServer`) broadcasts to SSE. This means log persistence works for both cloud and local, while SSE broadcast is cloud-only. +Adapters must remove provider notifications that do not belong to the active parent session before writing ACP updates. Persisted ACP updates do not retain enough provider-specific identity to separate a subagent transcript or completion from its parent safely during cloud replay. + ### HTTP endpoints | Method | Path | Auth | Description | diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index d20d2e4cc5..a2f289e809 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -148,7 +148,10 @@ describe("CodexAppServerAgent", () => { }); await agent.initialize(init); - await agent.newSession({ cwd: "/repo" } as unknown as NewSessionRequest); + await agent.newSession({ + cwd: "/repo", + _meta: { environment: "cloud", taskRunId: "run_1" }, + } as unknown as NewSessionRequest); const promptDone = agent.prompt({ sessionId: "thr_1", prompt: [{ type: "text", text: "delegate this" }], @@ -227,6 +230,12 @@ describe("CodexAppServerAgent", () => { const serializedUpdates = JSON.stringify(sessionUpdates); expect(serializedUpdates).toContain("spawn_agent"); expect(serializedUpdates).toContain("parent response"); + expect(serializedUpdates).not.toContain("subagent prose"); + expect( + extNotifications.filter( + (notification) => notification.method === "_posthog/turn_complete", + ), + ).toHaveLength(1); }); it("includes buffered command output when completion omits aggregatedOutput", async () => { From e284656eab76ca20110d8f58e4a25c8041db0769 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Wed, 15 Jul 2026 13:54:30 +0300 Subject: [PATCH 4/6] Label Codex collaboration groups as subagents Read canonical tool metadata in the grouped conversation header so Codex spawn batches no longer fall back to the generic Other label. Generated-By: PostHog Code Task-Id: 971044c5-e622-4f07-b1b5-dca9b06d040c --- .../components/chat-thread/ToolGroup.test.tsx | 41 +++++++++++++++++++ .../components/chat-thread/ToolGroup.tsx | 17 +++++--- 2 files changed, 52 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/chat-thread/ToolGroup.test.tsx diff --git a/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.test.tsx b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.test.tsx new file mode 100644 index 0000000000..1a9ea7339f --- /dev/null +++ b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.test.tsx @@ -0,0 +1,41 @@ +import { posthogToolMeta } from "@posthog/shared"; +import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { Theme } from "@radix-ui/themes"; +import { render, screen } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { ToolGroup } from "./ToolGroup"; + +function subagentItem( + id: string, +): Extract { + return { + type: "session_update", + id, + update: { + sessionUpdate: "tool_call", + toolCallId: id, + title: "Subagent", + kind: "other", + status: "completed", + _meta: posthogToolMeta({ toolName: "spawn_agent" }), + }, + turnContext: { + toolCalls: new Map(), + childItems: new Map(), + turnCancelled: false, + turnComplete: true, + }, + } as Extract; +} + +describe("ToolGroup", () => { + it("labels Codex spawn batches as subagents", () => { + render( + + + , + ); + + expect(screen.getByText("Used Subagents")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx index 53afaf80fc..8053eaa6cb 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.tsx @@ -6,9 +6,11 @@ import { cn, Spinner, } from "@posthog/quill"; +import { readAgentToolName } from "@posthog/shared"; import type { ToolCall } from "@posthog/ui/features/sessions/types"; import { memo } from "react"; import type { ConversationItem } from "../buildConversationItems"; +import { grouping } from "../new-thread/conversationThreadConfig"; import { SessionUpdateView } from "../session-update/SessionUpdateView"; import { iconForToolCall } from "../session-update/toolCallUtils"; @@ -38,10 +40,10 @@ function resolveTool(item: ToolGroupItem["tools"][number]): { ...(update as unknown as ToolCall), status: (update as unknown as ToolCall).status ?? "in_progress", }; - const meta = fromMap._meta as - | { claudeCode?: { toolName?: string } } - | undefined; - return { toolCall: fromMap, toolName: meta?.claudeCode?.toolName }; + return { + toolCall: fromMap, + toolName: readAgentToolName(fromMap._meta), + }; } /** Identity used to decide if a group is "all the same tool". */ @@ -52,9 +54,12 @@ function toolKey(item: ToolGroupItem["tools"][number]): string { /** Human label for a uniform group, e.g. `ToolSearch` → "Tool search", `mcp__x__run` → "Run". */ function friendlyName(key: string): string { + if (grouping.subagentToolNames.has(key)) return "Subagents"; const last = key.includes("__") ? (key.split("__").pop() ?? key) : key; - // Split PascalCase/camelCase into words so `ToolSearch` reads "Tool search" rather than "Toolsearch". - const spaced = last.replace(/([a-z\d])([A-Z])/g, "$1 $2"); + // Split separators and PascalCase/camelCase so tool identifiers read naturally. + const spaced = last + .replace(/[_-]+/g, " ") + .replace(/([a-z\d])([A-Z])/g, "$1 $2"); return spaced.charAt(0).toUpperCase() + spaced.slice(1).toLowerCase(); } From 2a5c346f6c924b54f8e6f6e5232fdb7c6bcfcb42 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Wed, 15 Jul 2026 14:10:26 +0300 Subject: [PATCH 5/6] Fix Codex collaboration lifecycle handling Preserve terminal tool-call status, discard child output before buffering, and reserve the subagent UI for actual spawn operations. Add regressions for structured output, command buffering, active labels, and orchestration rendering. Generated-By: PostHog Code Task-Id: 971044c5-e622-4f07-b1b5-dca9b06d040c --- .../codex-app-server-agent.test.ts | 59 ++++++++++++++++++- .../codex-app-server-agent.ts | 6 +- .../adapters/codex-app-server/mapping.test.ts | 4 +- .../src/adapters/codex-app-server/mapping.ts | 16 +---- .../components/chat-thread/ToolGroup.test.tsx | 43 ++++++++++++-- .../new-thread/conversationThreadConfig.ts | 13 +--- .../session-update/ToolCallBlock.test.tsx | 13 ++++ .../session-update/ToolCallBlock.tsx | 8 +-- .../session-update/collaborationTools.ts | 9 +++ 9 files changed, 129 insertions(+), 42 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/session-update/collaborationTools.ts diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts index a2f289e809..f613c0f236 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.test.ts @@ -141,16 +141,29 @@ describe("CodexAppServerAgent", () => { "turn/start": { turn: { id: "turn_1", status: "inProgress" } }, }); const { client, sessionUpdates, extNotifications } = makeFakeClient(); + const structuredOutputs: Array> = []; const agent = new CodexAppServerAgent(client, { processOptions: { binaryPath: "/bundle/codex" }, model: "gpt-5.5", rpcFactory: stub.factory, + onStructuredOutput: async (output) => { + structuredOutputs.push(output); + }, }); + const schema = { + type: "object", + properties: { source: { type: "string" } }, + required: ["source"], + }; await agent.initialize(init); await agent.newSession({ cwd: "/repo", - _meta: { environment: "cloud", taskRunId: "run_1" }, + _meta: { + environment: "cloud", + jsonSchema: schema, + taskRunId: "run_1", + }, } as unknown as NewSessionRequest); const promptDone = agent.prompt({ sessionId: "thr_1", @@ -178,6 +191,27 @@ describe("CodexAppServerAgent", () => { itemId: "subagent_message_1", delta: "subagent prose", }); + stub.emit("item/reasoning/textDelta", { + threadId: "subagent_1", + turnId: "subagent_turn_1", + itemId: "subagent_reasoning_1", + delta: "subagent reasoning", + }); + stub.emit("item/completed", { + threadId: "subagent_1", + turnId: "subagent_turn_1", + item: { + type: "agentMessage", + id: "subagent_message_1", + text: '{"source":"child"}', + }, + }); + stub.emit("item/commandExecution/outputDelta", { + threadId: "subagent_1", + turnId: "subagent_turn_1", + itemId: "shared_command_id", + delta: "child command output", + }); stub.emit("thread/tokenUsage/updated", { threadId: "subagent_1", tokenUsage: { @@ -221,6 +255,26 @@ describe("CodexAppServerAgent", () => { itemId: "message_1", delta: "parent response", }); + stub.emit("item/completed", { + threadId: "thr_1", + turnId: "turn_1", + item: { + type: "commandExecution", + id: "shared_command_id", + command: "echo parent", + status: "completed", + aggregatedOutput: null, + }, + }); + stub.emit("item/completed", { + threadId: "thr_1", + turnId: "turn_1", + item: { + type: "agentMessage", + id: "message_1", + text: '{"source":"parent"}', + }, + }); stub.emit("turn/completed", { threadId: "thr_1", turn: { id: "turn_1", status: "completed" }, @@ -231,6 +285,9 @@ describe("CodexAppServerAgent", () => { expect(serializedUpdates).toContain("spawn_agent"); expect(serializedUpdates).toContain("parent response"); expect(serializedUpdates).not.toContain("subagent prose"); + expect(serializedUpdates).not.toContain("subagent reasoning"); + expect(serializedUpdates).not.toContain("child command output"); + expect(structuredOutputs).toEqual([{ source: "parent" }]); expect( extNotifications.filter( (notification) => notification.method === "_posthog/turn_complete", diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index b6211a1a1e..406ec589a3 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -937,10 +937,12 @@ export class CodexAppServerAgent extends BaseAcpAgent { } private handleNotification(method: string, params: unknown): void { - const mappedParams = this.withBufferedCommandOutput(method, params); - const notificationThreadId = readNotificationThreadId(mappedParams); + const notificationThreadId = readNotificationThreadId(params); const isMainThread = !notificationThreadId || notificationThreadId === this.threadId; + const mappedParams = isMainThread + ? this.withBufferedCommandOutput(method, params) + : params; if (this.sessionId && !this.session.cancelled) { if (isMainThread) { diff --git a/packages/agent/src/adapters/codex-app-server/mapping.test.ts b/packages/agent/src/adapters/codex-app-server/mapping.test.ts index 288e8732bf..11a5f22f90 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -268,7 +268,7 @@ describe("mapAppServerNotification", () => { }); }); - it("keeps a completed spawn tool call active while its subagent is running", () => { + it("keeps a completed spawn tool call terminal while its subagent is running", () => { const result = mapAppServerNotification( "s-1", APP_SERVER_NOTIFICATIONS.ITEM_COMPLETED, @@ -293,7 +293,7 @@ describe("mapAppServerNotification", () => { update: { sessionUpdate: "tool_call_update", toolCallId: "spawn-1", - status: "in_progress", + status: "completed", }, }); }); diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index ccb0d7b6e0..ac10a3a266 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -549,7 +549,7 @@ function mapItem( update: { sessionUpdate: "tool_call_update", toolCallId: item.id, - status: mapToolStatus(item), + status: mapStatus(item.status), ...(content ? { content } : {}), }, }; @@ -595,20 +595,6 @@ function mapStatus( return "in_progress"; } -function mapToolStatus( - item: AppServerItem, -): "completed" | "failed" | "in_progress" { - if ( - item.type === "collabAgentToolCall" && - Object.values(item.agentsStates ?? {}).some( - (agent) => agent?.status === "pendingInit" || agent?.status === "running", - ) - ) { - return "in_progress"; - } - return mapStatus(item.status); -} - function readItem(params: unknown): AppServerItem | null { if (params && typeof params === "object" && "item" in params) { const item = (params as Record).item; diff --git a/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.test.tsx b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.test.tsx index 1a9ea7339f..6aca2a9058 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.test.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ToolGroup.test.tsx @@ -1,12 +1,18 @@ +import { ServiceProvider } from "@posthog/di/react"; import { posthogToolMeta } from "@posthog/shared"; import type { ConversationItem } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { Theme } from "@radix-ui/themes"; import { render, screen } from "@testing-library/react"; +import { Container } from "inversify"; import { describe, expect, it } from "vitest"; import { ToolGroup } from "./ToolGroup"; function subagentItem( id: string, + options: { + status?: "completed" | "in_progress"; + turnComplete?: boolean; + } = {}, ): Extract { return { type: "session_update", @@ -16,14 +22,14 @@ function subagentItem( toolCallId: id, title: "Subagent", kind: "other", - status: "completed", + status: options.status ?? "completed", _meta: posthogToolMeta({ toolName: "spawn_agent" }), }, turnContext: { toolCalls: new Map(), childItems: new Map(), turnCancelled: false, - turnComplete: true, + turnComplete: options.turnComplete ?? true, }, } as Extract; } @@ -31,11 +37,38 @@ function subagentItem( describe("ToolGroup", () => { it("labels Codex spawn batches as subagents", () => { render( - - - , + + + + + , ); expect(screen.getByText("Used Subagents")).toBeInTheDocument(); }); + + it("labels unresolved Codex spawn batches as active subagents", () => { + render( + + + + + , + ); + + expect(screen.getByText("Using Subagents")).toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/features/sessions/components/new-thread/conversationThreadConfig.ts b/packages/ui/src/features/sessions/components/new-thread/conversationThreadConfig.ts index 9b7a01aea1..36de1a0f75 100644 --- a/packages/ui/src/features/sessions/components/new-thread/conversationThreadConfig.ts +++ b/packages/ui/src/features/sessions/components/new-thread/conversationThreadConfig.ts @@ -14,6 +14,7 @@ import { Wrench, } from "@phosphor-icons/react"; import type { CodeToolKind } from "@posthog/ui/features/sessions/types"; +import { SUBAGENT_SPAWN_TOOL_NAMES } from "../session-update/collaborationTools"; /** * Single source of truth for the modernized conversation thread's tuneable @@ -57,17 +58,7 @@ export const grouping = { /** * Tool names that create a subagent. Counted separately in the chip summary. */ - subagentToolNames: new Set(["Task", "Agent", "spawn_agent"]), - /** Collaboration tools rendered with the dedicated subagent view. */ - collaborationToolNames: new Set([ - "Task", - "Agent", - "spawn_agent", - "send_input", - "resume_agent", - "wait_agent", - "close_agent", - ]), + subagentToolNames: SUBAGENT_SPAWN_TOOL_NAMES, /** * MCP-app tool calls are excluded from collapsing so their iframes stay * mounted (the `keepMounted` contract). Flip to false to fold them in. diff --git a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.test.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.test.tsx index 8ac51cfdc8..757d38a961 100644 --- a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.test.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.test.tsx @@ -115,4 +115,17 @@ describe("ToolCallBlock codex routing", () => { expect(screen.getByText("Run tests")).toBeInTheDocument(); expect(screen.getByText("pnpm test")).toBeInTheDocument(); }); + + it("renders Codex orchestration calls as operations rather than subagents", () => { + renderBlock({ + toolCallId: "tc-wait-agent", + title: "Wait for subagents", + kind: "other", + status: "completed", + _meta: posthogToolMeta({ toolName: "wait_agent" }), + }); + + expect(screen.getByText("Wait for subagents")).toBeInTheDocument(); + expect(screen.queryByText(/^Subagent/)).not.toBeInTheDocument(); + }); }); diff --git a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx index 31620d09c7..a602acf31b 100644 --- a/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx +++ b/packages/ui/src/features/sessions/components/session-update/ToolCallBlock.tsx @@ -16,7 +16,7 @@ import type { ToolCall } from "@posthog/ui/features/sessions/types"; import { Box } from "@radix-ui/themes"; import type { ConversationItem, TurnContext } from "../buildConversationItems"; import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; -import { grouping } from "../new-thread/conversationThreadConfig"; +import { isSubagentSpawnTool } from "./collaborationTools"; import { MCP_TOOL_BLOCK_COMPONENT, type McpToolBlockComponent, @@ -48,7 +48,7 @@ export function ToolCallBlock({ const props = { toolCall, turnCancelled, turnComplete }; - if (isSubagentTool(toolName)) { + if (isSubagentSpawnTool(toolName)) { const subagentChildItems = childItems ?? []; const turnContext: TurnContext = { toolCalls: buildChildToolCallsMap(subagentChildItems), @@ -109,10 +109,6 @@ export function ToolCallBlock({ return {content}; } -function isSubagentTool(toolName: string | undefined): boolean { - return grouping.collaborationToolNames.has(toolName ?? ""); -} - function buildChildToolCallsMap( childItems: ConversationItem[], ): Map { diff --git a/packages/ui/src/features/sessions/components/session-update/collaborationTools.ts b/packages/ui/src/features/sessions/components/session-update/collaborationTools.ts new file mode 100644 index 0000000000..d482aeaa0f --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/collaborationTools.ts @@ -0,0 +1,9 @@ +export const SUBAGENT_SPAWN_TOOL_NAMES = new Set([ + "Task", + "Agent", + "spawn_agent", +]); + +export function isSubagentSpawnTool(toolName: string | undefined): boolean { + return SUBAGENT_SPAWN_TOOL_NAMES.has(toolName ?? ""); +} From 12ec84a85d43202e00b6a39c51098c8a8f012bd4 Mon Sep 17 00:00:00 2001 From: Richard Solomou Date: Wed, 15 Jul 2026 14:23:05 +0300 Subject: [PATCH 6/6] Fix duplicated lockfile package entry Remove the duplicate bippy package key introduced by the Storybook lockfile update so frozen installs can parse the lockfile. Generated-By: PostHog Code Task-Id: 971044c5-e622-4f07-b1b5-dca9b06d040c --- pnpm-lock.yaml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9f300a42da..4bcdd19bd1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8759,11 +8759,6 @@ packages: peerDependencies: react: 19.2.6 - bippy@0.5.43: - resolution: {integrity: sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==} - peerDependencies: - react: 19.2.6 - bippy@0.5.43: resolution: {integrity: sha512-Tvu7b1M7+d8b9/YHaCeODEsi2CgbuoBql+dWSBrNnCuqJ1gMUeY3i0r+319hvjjl5GVBP6FFWxrKnq3fhZER0w==} peerDependencies: