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 { 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}