Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/agent/src/adapters/codex-app-server/mapping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion packages/agent/src/adapters/codex-app-server/mapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -454,7 +464,7 @@ export function ConversationView({
<div className={compact ? "pb-1" : "pb-16"}>
<SessionFooter
task={task}
isPromptPending={isPromptPending}
isPromptPending={footerPromptPending}
promptStartedAt={promptStartedAt}
lastGenerationDuration={
lastTurnInfo?.isComplete
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { makeAttachmentUri } from "@posthog/core/sessions/promptContent";
import type { AcpMessage } from "@posthog/shared";
import { type AcpMessage, posthogToolMeta } from "@posthog/shared";
import { describe, expect, it } from "vitest";
import {
buildConversationItems,
type ConversationItem,
conversationHasActiveSubagent,
} from "./buildConversationItems";

function consoleMsg(ts: number, message: string, level = "info"): AcpMessage {
Expand Down Expand Up @@ -952,6 +953,139 @@ describe("buildConversationItems", () => {
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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
isJsonRpcNotification,
isJsonRpcRequest,
isJsonRpcResponse,
readAgentToolName,
readParentToolCallId,
type UserShellExecuteParams,
} from "@posthog/shared";
Expand All @@ -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 {
Expand Down Expand Up @@ -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<TurnContext, "toolCalls" | "childItems">,
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<RenderItem, { sessionUpdate: "tool_call" }> {
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" } {
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<div className="pt-1">
<SessionFooter
task={task}
isPromptPending={isPromptPending}
isPromptPending={footerPromptPending}
promptStartedAt={promptStartedAt}
lastGenerationDuration={
lastTurnInfo?.isComplete
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { readAgentToolName } from "@posthog/shared";
import type { ToolCall } from "@posthog/ui/features/sessions/types";
import { memo } from "react";
import type { ConversationItem } from "../buildConversationItems";
import { hasActiveSubagent } from "../buildConversationItems";
import { grouping } from "../new-thread/conversationThreadConfig";
import { isSubagentSpawnTool } from "../session-update/collaborationTools";
import { SessionUpdateView } from "../session-update/SessionUpdateView";
import { iconForToolCall } from "../session-update/toolCallUtils";

Expand Down Expand Up @@ -64,13 +66,18 @@ function friendlyName(key: string): string {
}

export function isToolActive(item: ToolGroupItem["tools"][number]): boolean {
const { toolCall } = resolveTool(item);
const { toolCall, toolName } = resolveTool(item);
if (item.turnContext.turnCancelled) return false;

const incomplete =
toolCall.status === "pending" || toolCall.status === "in_progress";
if (incomplete && !item.turnContext.turnComplete) return true;

// The spawn settled and the parent turn ended, but the subagent keeps running
// detached — keep the group live on its nested child work.
return (
incomplete &&
!item.turnContext.turnCancelled &&
!item.turnContext.turnComplete
isSubagentSpawnTool(toolName) &&
hasActiveSubagent(item.turnContext, toolCall.toolCallId)
);
}

Expand Down
Loading
Loading