From 1552f0711bbca9e7da4bab2a9bdd1e224a48ca18 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 20 Jul 2026 16:47:06 -0400 Subject: [PATCH 1/2] fix: restore Codex subagent waiting indicator PR #3522 hid both `wait` and `closeAgent` collab-agent events. `wait` ("Waiting for subagents") is the visible state that the main thread is blocked on a running subagent; dropping it made the conversation look done while a subagent was still working. Keep `closeAgent` hidden as lifecycle noise but restore `wait` so the in-progress spinner shows. Generated-By: PostHog Code Task-Id: 515f2e33-762c-412a-8a2d-a217bbace052 --- .../adapters/codex-app-server/mapping.test.ts | 43 +++++++++++++++++++ .../src/adapters/codex-app-server/mapping.ts | 2 +- 2 files changed, 44 insertions(+), 1 deletion(-) 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 a44aa3b927..830e8c21cf 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.test.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.test.ts @@ -262,6 +262,49 @@ describe("mapAppServerNotification", () => { }); }); + it("maps a started wait tool call so the UI shows the main thread waiting on its subagents", () => { + const result = mapAppServerNotification( + "s-1", + APP_SERVER_NOTIFICATIONS.ITEM_STARTED, + { + item: { + type: "collabAgentToolCall", + id: "wait-1", + tool: "wait", + status: "inProgress", + senderThreadId: "main-thread", + }, + }, + ); + + expect(result).toEqual({ + sessionId: "s-1", + update: { + sessionUpdate: "tool_call", + toolCallId: "wait-1", + title: "Wait for subagents", + kind: "other", + status: "in_progress", + rawInput: {}, + _meta: { posthog: { toolName: "wait_agent" } }, + }, + }); + }); + + it("still drops closeAgent lifecycle events", () => { + expect( + mapAppServerNotification("s-1", APP_SERVER_NOTIFICATIONS.ITEM_STARTED, { + item: { + type: "collabAgentToolCall", + id: "close-1", + tool: "closeAgent", + status: "inProgress", + senderThreadId: "main-thread", + }, + }), + ).toBeNull(); + }); + it("keeps a completed spawn tool call terminal while its subagent is running", () => { const result = mapAppServerNotification( "s-1", diff --git a/packages/agent/src/adapters/codex-app-server/mapping.ts b/packages/agent/src/adapters/codex-app-server/mapping.ts index 6f5dfc8d51..ff7ee96d72 100644 --- a/packages/agent/src/adapters/codex-app-server/mapping.ts +++ b/packages/agent/src/adapters/codex-app-server/mapping.ts @@ -411,7 +411,7 @@ function describeTool(item: AppServerItem): ToolDescriptor | null { output: dynamicToolText(item.contentItems), }; case "collabAgentToolCall": - if (item.tool === "wait" || item.tool === "closeAgent") { + if (item.tool === "closeAgent") { return null; } return { From 880a27a1b8978cccc38ebc9377e4a9d03d262384 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Mon, 20 Jul 2026 16:53:46 -0400 Subject: [PATCH 2/2] fix: keep subagent spinner and footer live while a subagent runs A subagent (Task/Agent tool call) keeps running after the parent turn ends or after the spawn tool call settles, but the conversation UI derived "loading" from the parent turn's turnComplete and the spawn's own status, so the spinner and the footer's generating indicator stopped mid-subagent, making the conversation look done. Derive live state from the subagent's own nested child tool calls: - SubagentToolView keeps its spinner while any child is non-terminal, even after the parent turn completes. - The new-thread ToolGroup / chip summary stay "Using" while a subagent in the group has active children. - Both footers (legacy ConversationView and ChatThreadFooter) keep the generating indicator alive via conversationHasActiveSubagent. Display-only: the prompt-lifecycle gates (queue/steer) still read the raw isPromptPending, so a follow-up message sends rather than queuing while a detached subagent finishes. Works for both Claude and Codex. Generated-By: PostHog Code Task-Id: 515f2e33-762c-412a-8a2d-a217bbace052 --- .../sessions/components/ConversationView.tsx | 12 +- .../components/buildConversationItems.test.ts | 136 +++++++++++++++++- .../components/buildConversationItems.ts | 52 +++++++ .../chat-thread/ChatThreadFooter.tsx | 11 +- .../components/chat-thread/ToolGroup.tsx | 15 +- .../new-thread/buildThreadGroups.ts | 12 ++ .../session-update/SubagentToolView.test.tsx | 100 +++++++++++++ .../session-update/SubagentToolView.tsx | 14 +- 8 files changed, 341 insertions(+), 11 deletions(-) create mode 100644 packages/ui/src/features/sessions/components/session-update/SubagentToolView.test.tsx diff --git a/packages/ui/src/features/sessions/components/ConversationView.tsx b/packages/ui/src/features/sessions/components/ConversationView.tsx index 5a049e930a..b988d136a2 100644 --- a/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -15,6 +15,7 @@ import type { ConversationItem, TurnContext, } from "@posthog/ui/features/sessions/components/buildConversationItems"; +import { conversationHasActiveSubagent } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { ConversationSearchBar } from "@posthog/ui/features/sessions/components/ConversationSearchBar"; import { PROMPT_RECALL_HINT_KEY, @@ -197,6 +198,15 @@ export function ConversationView({ [conversationItems, optimisticItems, isCloud], ); + // A subagent keeps running detached after the parent prompt completes; keep the + // footer's generating indicator alive until its nested child work settles. This + // is display-only: the prompt-lifecycle gates (queue/steer) still read the raw + // `isPromptPending`, so a follow-up message sends instead of queueing. + const footerPromptPending = useMemo( + () => !!isPromptPending || conversationHasActiveSubagent(items), + [isPromptPending, items], + ); + // Fold each completed turn's tool-call work into a collapsible chip, and emit // the keepMounted indices (standalone MCP-app rows, whose iframes must survive // scrolling) + the item→row map in the same pass. @@ -454,7 +464,7 @@ export function ConversationView({
{ expect(lastTurnInfo?.durationMs).toBeGreaterThan(0); }); }); + + describe("active subagent detection", () => { + const spawnMsg = (ts: number, toolCallId: string): AcpMessage => ({ + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + toolCallId, + kind: "other", + status: "in_progress", + title: "Do the thing", + _meta: posthogToolMeta({ toolName: "Task" }), + }, + }, + }, + }); + + const spawnUpdate = ( + ts: number, + toolCallId: string, + status: string, + ): AcpMessage => ({ + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { sessionUpdate: "tool_call_update", toolCallId, status }, + }, + }, + }); + + const childToolMsg = ( + ts: number, + toolCallId: string, + parentId: string, + status: string, + ): AcpMessage => ({ + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { + sessionUpdate: "tool_call", + toolCallId, + kind: "execute", + status, + title: toolCallId, + _meta: posthogToolMeta({ + toolName: "Bash", + parentToolCallId: parentId, + }), + }, + }, + }, + }); + + const childToolUpdate = ( + ts: number, + toolCallId: string, + status: string, + ): AcpMessage => ({ + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: "session/update", + params: { + update: { sessionUpdate: "tool_call_update", toolCallId, status }, + }, + }, + }); + + it("reports an active subagent while a child tool call is in_progress", () => { + // Claude: the subagent's nested tool calls stream within the parent turn, + // before the prompt response settles it. The spawn row is already marked + // completed (its result is being assembled), but a child is mid-flight. + const events = [ + userPromptMsg(1, 1, "go"), + spawnMsg(2, "spawn-1"), + spawnUpdate(3, "spawn-1", "completed"), + childToolMsg(4, "child-1", "spawn-1", "in_progress"), + ]; + + const { items } = buildConversationItems(events, true); + expect(conversationHasActiveSubagent(items)).toBe(true); + }); + + it("still reports active after the parent turn ends but a child is in_flight (detached subagent)", () => { + const events = [ + userPromptMsg(1, 1, "go"), + spawnMsg(2, "spawn-1"), + childToolMsg(3, "child-1", "spawn-1", "in_progress"), + spawnUpdate(4, "spawn-1", "completed"), + promptResponseMsg(5, 1), + ]; + + const { items } = buildConversationItems(events, false); + expect(conversationHasActiveSubagent(items)).toBe(true); + }); + + it("clears once every child tool call has settled", () => { + const events = [ + userPromptMsg(1, 1, "go"), + spawnMsg(2, "spawn-1"), + childToolMsg(3, "child-1", "spawn-1", "in_progress"), + childToolUpdate(4, "child-1", "completed"), + spawnUpdate(5, "spawn-1", "completed"), + promptResponseMsg(6, 1), + ]; + + const { items } = buildConversationItems(events, false); + expect(conversationHasActiveSubagent(items)).toBe(false); + }); + + it("ignores a non-subagent tool call with no children", () => { + const events = [ + userPromptMsg(1, 1, "go"), + childToolMsg(2, "t1", "nonexistent-parent", "in_progress"), + promptResponseMsg(3, 1), + ]; + + const { items } = buildConversationItems(events, false); + expect(conversationHasActiveSubagent(items)).toBe(false); + }); + }); }); // Local alias kept intentionally narrow to the shape we care about in tests. diff --git a/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index 17abfe38e2..75ddba7910 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -13,6 +13,7 @@ import { isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, + readAgentToolName, readParentToolCallId, type UserShellExecuteParams, } from "@posthog/shared"; @@ -31,6 +32,7 @@ import { type SkillButtonId, } from "@posthog/ui/features/skill-buttons/prompts"; import type { Step, StepStatus } from "@posthog/ui/primitives/StepList"; +import { SUBAGENT_SPAWN_TOOL_NAMES } from "./session-update/collaborationTools"; import type { RenderItem } from "./session-update/SessionUpdateView"; export interface TurnContext { @@ -165,6 +167,56 @@ function isTerminalToolStatus(status: string | null | undefined): boolean { return status != null && TERMINAL_TOOL_STATUSES.has(status); } +/** + * Whether a subagent spawn (Task/Agent tool call) still has work in flight. + * The parent turn ends when the spawn tool call returns, but the subagent keeps + * running detached; its nested tool calls arrive as children on the spawn's + * `TurnContext`. Without this check the spawn row's spinner stops the moment + * the parent turn completes, making the conversation look done mid-subagent. + */ +export function hasActiveSubagent( + context: Pick, + spawnToolCallId: string, +): boolean { + const children = context.childItems.get(spawnToolCallId); + if (!children?.length) return false; + for (const child of children) { + if (child.type !== "session_update") continue; + if (child.update.sessionUpdate !== "tool_call") continue; + const resolved = child.update.toolCallId + ? context.toolCalls.get(child.update.toolCallId) + : undefined; + const status = resolved?.status ?? child.update.status; + if (!isTerminalToolStatus(status)) return true; + } + return false; +} + +/** + * Whether any subagent anywhere in the conversation still has work in flight. + * Drives the footer's generating indicator after the parent prompt has + * completed but a detached subagent is still running. + */ +export function conversationHasActiveSubagent( + items: ConversationItem[], +): boolean { + for (const item of items) { + if (item.type !== "session_update") continue; + if (item.update.sessionUpdate !== "tool_call") continue; + if (!isSubagentSpawnUpdate(item.update)) continue; + if (hasActiveSubagent(item.turnContext, item.update.toolCallId)) + return true; + } + return false; +} + +function isSubagentSpawnUpdate( + update: RenderItem, +): update is Extract { + if (update.sessionUpdate !== "tool_call") return false; + return SUBAGENT_SPAWN_TOOL_NAMES.has(readAgentToolName(update._meta) ?? ""); +} + function isThoughtItem( item: ConversationItem, ): item is ConversationItem & { type: "session_update" } { diff --git a/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx b/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx index cabb5777e3..867cdc3b21 100644 --- a/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx +++ b/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx @@ -1,5 +1,6 @@ import type { AcpMessage } from "@posthog/shared"; import type { Task } from "@posthog/shared/domain-types"; +import { conversationHasActiveSubagent } from "@posthog/ui/features/sessions/components/buildConversationItems"; import { SessionFooter } from "@posthog/ui/features/sessions/components/SessionFooter"; import { useContextUsage } from "@posthog/ui/features/sessions/hooks/useContextUsage"; import { useConversationItems } from "@posthog/ui/features/sessions/hooks/useConversationItems"; @@ -37,18 +38,24 @@ export function ChatThreadFooter({ }: ChatThreadFooterProps) { const showDebugLogs = useSettingsStore((s) => s.debugLogsCloudRuns); const contextUsage = useContextUsage(events); - const { lastTurnInfo, isCompacting, completedToolCallCount } = + const { items, lastTurnInfo, isCompacting, completedToolCallCount } = useConversationItems(events, isPromptPending, { showDebugLogs }); const pendingPermissions = usePendingPermissionsForTask(taskId ?? ""); const queuedCount = useQueuedMessagesForTask(taskId).length; const session = useSessionForTask(taskId); const pausedDurationMs = session?.pausedDurationMs ?? 0; + // Keep the generating indicator alive while a detached subagent is still + // running after the parent prompt completed. Display-only; the prompt + // lifecycle (queue/steer) still reads the raw `isPromptPending`. + const footerPromptPending = + !!isPromptPending || conversationHasActiveSubagent(items); + return (
(); @@ -160,6 +162,15 @@ function summarize(items: ConversationItem[]): GroupSummary { if (name && grouping.subagentToolNames.has(name)) { counts.subagents++; addIcon(SUBAGENT_ICON, "subagent"); + // A settled spawn with the parent turn complete can still have a detached + // subagent running in its children — that keeps the chip live. + if ( + !subagentActive && + update.toolCallId && + hasActiveSubagent(item.turnContext, update.toolCallId) + ) { + subagentActive = true; + } } else if (readMcpToolDescriptor(update._meta)) { counts.other++; addIcon(MCP_ICON, "mcp"); @@ -211,6 +222,7 @@ function summarize(items: ConversationItem[]): GroupSummary { if (trailingThoughtStreaming) liveLabel = "Thinking…"; const active = trailingThoughtStreaming || + subagentActive || lastToolStatus === "pending" || lastToolStatus === "in_progress"; const hasCountableWork = diff --git a/packages/ui/src/features/sessions/components/session-update/SubagentToolView.test.tsx b/packages/ui/src/features/sessions/components/session-update/SubagentToolView.test.tsx new file mode 100644 index 0000000000..0a299bb2b8 --- /dev/null +++ b/packages/ui/src/features/sessions/components/session-update/SubagentToolView.test.tsx @@ -0,0 +1,100 @@ +import { posthogToolMeta } from "@posthog/shared"; +import type { + ConversationSessionUpdate, + ToolCall, +} from "@posthog/ui/features/sessions/types"; +import { Theme } from "@radix-ui/themes"; +import { render } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import type { ConversationItem, TurnContext } from "../buildConversationItems"; +import { SubagentToolView } from "./SubagentToolView"; + +// The legacy thread path (no ChatThreadChrome provider) renders the bespoke +// bordered box; that's enough to assert the spinner vs. robot-icon swap. +function makeTurnContext( + spawnId: string, + children: ConversationItem[], +): TurnContext { + const toolCalls = new Map(); + for (const child of children) { + if (child.type !== "session_update") continue; + const update = child.update as ConversationSessionUpdate; + if (update.sessionUpdate === "tool_call" && update.toolCallId) { + toolCalls.set(update.toolCallId, update as unknown as ToolCall); + } + } + return { + toolCalls, + childItems: new Map([[spawnId, children]]), + turnCancelled: false, + turnComplete: true, + }; +} + +function childToolCall( + id: string, + parentId: string, + status: ToolCall["status"], +): ConversationItem { + return { + type: "session_update", + id, + update: { + sessionUpdate: "tool_call", + toolCallId: id, + kind: "execute", + status, + title: id, + _meta: posthogToolMeta({ toolName: "Bash", parentToolCallId: parentId }), + }, + turnContext: {} as TurnContext, + }; +} + +function renderView( + toolCall: ToolCall, + turnContext: TurnContext, + childItems: ConversationItem[], +) { + return render( + + + , + ); +} + +describe("SubagentToolView", () => { + const spawnToolCall: ToolCall = { + toolCallId: "spawn-1", + title: "Do the thing", + kind: "other", + status: "completed", + _meta: posthogToolMeta({ toolName: "Task" }), + }; + + it("shows the robot icon (no spinner) once the subagent and its children are done", () => { + const children = [childToolCall("child-1", "spawn-1", "completed")]; + const turnContext = makeTurnContext("spawn-1", children); + const { container } = renderView(spawnToolCall, turnContext, children); + + // DotsCircleSpinner renders braille-dot frames (class `ph-dots-frame`) only + // while loading; otherwise LoadingIcon shows the Robot . A settled + // subagent shows no spinner frames. + expect(container.querySelector(".ph-dots-frame")).toBeNull(); + }); + + it("keeps the spinner while a child tool call is in_progress even though the turn is complete", () => { + const children = [childToolCall("child-1", "spawn-1", "in_progress")]; + const turnContext = makeTurnContext("spawn-1", children); + const { container } = renderView(spawnToolCall, turnContext, children); + + // The spawn is `completed` and `turnComplete` is true, but a child is still + // in flight — the spinner must stay up so the row doesn't read as done. + expect(container.querySelector(".ph-dots-frame")).not.toBeNull(); + }); +}); 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 ca1308fc78..f32b6eb2b9 100644 --- a/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx +++ b/packages/ui/src/features/sessions/components/session-update/SubagentToolView.tsx @@ -13,6 +13,7 @@ import { import { Box, Flex, IconButton, Text } from "@radix-ui/themes"; import { useState } from "react"; import type { ConversationItem, TurnContext } from "../buildConversationItems"; +import { hasActiveSubagent } from "../buildConversationItems"; import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; import { SessionUpdateView } from "./SessionUpdateView"; import { ToolRow } from "./ToolRow"; @@ -40,6 +41,10 @@ export function SubagentToolView({ turnCancelled, turnComplete, ); + // The parent turn completes when the spawn tool call returns, but the subagent + // keeps running detached. Keep the spinner alive while any of its child tool + // calls are non-terminal, or the row reads as done mid-subagent. + const subagentActive = hasActiveSubagent(turnContext, toolCall.toolCallId); const chatChrome = useChatThreadChrome(); const [isExpanded, setIsExpanded] = useState(false); @@ -75,7 +80,7 @@ export function SubagentToolView({ @@ -124,14 +129,17 @@ export function SubagentToolView({ - + } /> Delegated to a subagent } - isLoading={isLoading} + isLoading={isLoading || subagentActive} isFailed={isFailed} wasCancelled={wasCancelled} content={childContent}