From 66445adc078004021413f3eb6b54ea68229d2bb5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 12 Jul 2026 21:13:00 +0200 Subject: [PATCH] Show provider subagents in the subagents track (#2013) * Add provider-owned subagent stream contract * OpenCode provider: detect child sessions, attach adopted children to the hosting server, synthesize external turns - Emit internal provider_child_session_detected / provider_session_deleted stream events from the global event stream (before the no-active-turn gate), including BFS hydration of existing descendants on subscribe - Track child sessions in a registry mapping child session id -> hosting server url; resumeSession attaches via acquireExisting() so adopted children share the parent's server generation and keep receiving live events - Synthesize external turns for adopted (externally driven) sessions so timeline, tool calls, and permission requests stream without a Paseo-initiated prompt - Honor launchContext.env in resumeSession (acquireDedicated parity with createSession) - Stop folding child tool parts into the parent's sub_agent action log; the parent row stays as a pointer * Render provider subagents in workspace timelines * fix provider subagent timeline reliability * fix provider subagent lifecycle edge cases * fix provider subagent event fidelity * Fix provider subagent history fidelity * Fix restored provider subagent state * Fix provider subagent visibility edges * Restore provider subagent timelines * Page provider subagent history * Forward subagent questions reliably * fix(subagents): preserve OpenCode child context * fix(subagents): harden provider timeline routing * fix(subagents): preserve provider page continuity * fix(subagents): retain live child prompts --------- Co-authored-by: Omer <639682+omercnet@users.noreply.github.com> --- docs/agent-lifecycle.md | 12 +- packages/app/e2e/helpers/rewind-flow.ts | 5 + .../app/e2e/provider-subagents.real.spec.ts | 84 ++ packages/app/src/agent-stream/view.tsx | 99 +- .../app/src/composer/draft/workspace-tab.tsx | 2 +- packages/app/src/contexts/session-context.tsx | 7 + packages/app/src/panels/agent-panel.tsx | 11 +- .../src/panels/provider-subagent-panel.tsx | 190 +++ packages/app/src/panels/register-panels.ts | 2 + .../screens/workspace/workspace-screen.tsx | 3 + .../screens/workspace/workspace-tab-menu.ts | 3 + .../src/stores/workspace-tabs-store/state.ts | 12 + .../app/src/subagents/provider-store.test.ts | 362 ++++++ packages/app/src/subagents/provider-store.ts | 304 +++++ packages/app/src/subagents/select.test.ts | 33 +- packages/app/src/subagents/select.ts | 72 +- .../src/subagents/track-presentation.test.ts | 11 +- .../app/src/subagents/track-presentation.ts | 11 +- packages/app/src/subagents/track.tsx | 29 +- .../app/src/workspace-tabs/identity.test.ts | 45 + packages/app/src/workspace-tabs/identity.ts | 13 + packages/client/src/daemon-client.test.ts | 1 + packages/client/src/daemon-client.ts | 76 ++ packages/protocol/src/client-capabilities.ts | 3 + .../src/messages.provider-subagents.test.ts | 76 ++ packages/protocol/src/messages.ts | 106 ++ .../src/server/agent/agent-manager.test.ts | 234 +++- .../server/src/server/agent/agent-manager.ts | 192 ++- .../src/server/agent/agent-sdk-types.ts | 6 + .../server/agent/provider-session-import.ts | 19 +- .../agent/provider-subagents/store.test.ts | 106 ++ .../server/agent/provider-subagents/store.ts | 168 +++ .../claude/agent.sub-agent-sidechain.test.ts | 93 ++ .../server/agent/providers/claude/agent.ts | 237 +++- .../agent.voice-history-regression.test.ts | 95 ++ .../providers/claude/sidechain-tracker.ts | 149 ++- .../providers/codex-app-server-agent.test.ts | 204 +++- .../agent/providers/codex-app-server-agent.ts | 256 +++- .../agent/providers/opencode-agent.test.ts | 1073 +++++++++++++++++ .../server/agent/providers/opencode-agent.ts | 774 +++++++++--- .../providers/opencode-server-manager.test.ts | 28 + .../providers/opencode/server-manager.ts | 22 + .../providers/opencode/test-server-manager.ts | 9 +- .../test-utils/test-opencode-harness.ts | 21 +- packages/server/src/server/session.ts | 133 +- .../server/src/server/websocket-server.ts | 2 + 46 files changed, 5056 insertions(+), 337 deletions(-) create mode 100644 packages/app/e2e/provider-subagents.real.spec.ts create mode 100644 packages/app/src/panels/provider-subagent-panel.tsx create mode 100644 packages/app/src/subagents/provider-store.test.ts create mode 100644 packages/app/src/subagents/provider-store.ts create mode 100644 packages/app/src/workspace-tabs/identity.test.ts create mode 100644 packages/protocol/src/messages.provider-subagents.test.ts create mode 100644 packages/server/src/server/agent/provider-subagents/store.test.ts create mode 100644 packages/server/src/server/agent/provider-subagents/store.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index e9e2f70f5..2e2a2a2a1 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -71,13 +71,21 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**: ## The subagents track -The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`): +The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children: + +- **Paseo subagents** are full managed agents. Their membership rule (`packages/app/src/subagents/select.ts`) is: ``` parentAgentId === thisAgent.id AND !archivedAt ``` -Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client. +- **Provider subagents** are child executions owned by Claude, Codex, or OpenCode. They are not inserted into `AgentManager` as managed agents. Providers emit a separate descriptor and timeline stream through `agent.provider_subagents.*`; the client keeps that state outside the normal agent store and merges only the presentation rows into the track. + +Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal interactive agent pane. A provider subagent tab is a read-only timeline pane with no composer, archive, detach, rewind, or fork actions. Both panes use `AgentStreamView`, so message, reasoning, tool-call, and layout rendering stay identical. + +Provider timelines use the same structural timeline item format but deliberately have a separate lifecycle and transport. A provider thread/session identifier is not a Paseo agent identifier, and closing its tab is always layout-only. + +Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no Paseo lifecycle controls and disappear only when the provider removes them or the parent session is discarded. To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. diff --git a/packages/app/e2e/helpers/rewind-flow.ts b/packages/app/e2e/helpers/rewind-flow.ts index faf8d49b9..a567e161d 100644 --- a/packages/app/e2e/helpers/rewind-flow.ts +++ b/packages/app/e2e/helpers/rewind-flow.ts @@ -154,6 +154,10 @@ export async function launchAgent(input: { provider: RewindFlowProvider; cwd: string; mode: "full-access"; + providerConfig?: { + model?: string; + extra?: { codex?: { features?: { multi_agent_v2?: boolean } } }; + }; }): Promise { execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" }); execFileSync("git", ["config", "user.email", "paseo-test@example.com"], { @@ -180,6 +184,7 @@ export async function launchAgent(input: { } const agent = await client.createAgent({ ...fullAccessConfig(input.provider), + ...input.providerConfig, cwd: input.cwd, workspaceId: createdWorkspace.workspace.id, title: `rewind-flow-${input.provider}-${randomUUID()}`, diff --git a/packages/app/e2e/provider-subagents.real.spec.ts b/packages/app/e2e/provider-subagents.real.spec.ts new file mode 100644 index 000000000..62c08694e --- /dev/null +++ b/packages/app/e2e/provider-subagents.real.spec.ts @@ -0,0 +1,84 @@ +import { mkdtempSync, realpathSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test, expect } from "./fixtures"; +import { + cleanupRewindFlow, + launchAgent, + sendMessage, + type AgentHandle, + type RewindFlowProvider, +} from "./helpers/rewind-flow"; +import { openSubagentsTrack } from "./helpers/subagents"; + +interface ProviderSubagentCase { + provider: RewindFlowProvider; + sentinel: string; + prompt: string; + providerConfig?: Parameters[0]["providerConfig"]; +} + +const cases: ProviderSubagentCase[] = [ + { + provider: "claude", + sentinel: "CLAUDE_CHILD_SENTINEL", + providerConfig: { model: "opus" }, + prompt: + "Use the Task tool exactly once with the Explore subagent. Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", + }, + { + provider: "codex", + sentinel: "CODEX_CHILD_SENTINEL", + providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } }, + prompt: + 'Use collaboration.spawn_agent exactly once with task_name "sentinel_child" and fork_turns "none". Ask it to reply with exactly CODEX_CHILD_SENTINEL and do nothing else. Wait for it with collaboration.wait_agent, then reply ROOT_DONE.', + }, + { + provider: "opencode", + sentinel: "OPENCODE_CHILD_SENTINEL", + prompt: + "Use the task tool exactly once with the explore subagent. Ask it to reply with exactly OPENCODE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE.", + }, +]; + +test.describe("real provider subagent timelines", () => { + test.setTimeout(600_000); + + for (const scenario of cases) { + test(`${scenario.provider} exposes native child output from the subagent track`, async ({ + page, + }) => { + const cwd = realpathSync( + mkdtempSync(path.join(tmpdir(), `paseo-provider-subagent-${scenario.provider}-`)), + ); + let handle: AgentHandle | undefined; + + try { + handle = await launchAgent({ + page, + provider: scenario.provider, + cwd, + mode: "full-access", + providerConfig: scenario.providerConfig, + }); + await sendMessage(handle, scenario.prompt); + await openSubagentsTrack(page); + + const rows = page.locator('[data-testid^="subagents-track-row-"]'); + await expect(rows).toHaveCount(1, { timeout: 60_000 }); + await rows.first().click(); + + const panel = page.getByTestId("provider-subagent-panel"); + await expect(panel).toBeVisible({ timeout: 30_000 }); + await expect( + panel.getByTestId("assistant-message").filter({ hasText: scenario.sentinel }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + panel.getByText("Start chatting with this agent...", { exact: true }), + ).toHaveCount(0); + } finally { + await cleanupRewindFlow({ handle, cwd }); + } + }); + } +}); diff --git a/packages/app/src/agent-stream/view.tsx b/packages/app/src/agent-stream/view.tsx index 17afca016..9d9e79950 100644 --- a/packages/app/src/agent-stream/view.tsx +++ b/packages/app/src/agent-stream/view.tsx @@ -228,13 +228,20 @@ export interface AgentStreamViewHandle { export interface AgentStreamViewProps { agentId: string; serverId?: string; - agent: AgentScreenAgent; + context: AgentScreenAgent; streamItems: StreamItem[]; + streamHead?: StreamItem[]; pendingPermissions: Map; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady?: boolean; toast?: ToastApi | null; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; + readOnly?: boolean; + historyPagination?: { + hasOlder: boolean; + isLoadingOlder: boolean; + onLoadOlder: () => void; + }; } const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ @@ -306,13 +313,16 @@ const AgentStreamViewComponent = forwardRef state.setExplorerTabForCheckout); // Get serverId (fallback to agent's serverId if not provided) - const resolvedServerId = serverId ?? agent.serverId ?? ""; + const resolvedServerId = serverId ?? context.serverId ?? ""; const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null); - const streamHead = useSessionStore((state) => + const sessionStreamHead = useSessionStore((state) => state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), ); + const streamHead = providedStreamHead ?? sessionStreamHead; const supportsAgentForkContext = useSessionStore( - (state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true, + (state) => + !readOnly && + state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true, ); - const workspaceRoot = agent.cwd?.trim() || ""; + const workspaceRoot = context.cwd?.trim() || ""; const { requestDirectoryListing } = useFileExplorerActions({ serverId: resolvedServerId, - workspaceId: agent.workspaceId, + workspaceId: context.workspaceId, workspaceRoot, }); - const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({ + const agentHistoryPagination = useLoadOlderAgentHistory({ serverId: resolvedServerId, agentId, toast, }); + const { isLoadingOlder, hasOlder, loadOlder } = historyPagination + ? { + isLoadingOlder: historyPagination.isLoadingOlder, + hasOlder: historyPagination.hasOlder, + loadOlder: historyPagination.onLoadOlder, + } + : agentHistoryPagination; // Keep entry/exit animations off on Android due to RN dispatchDraw crashes // tracked in react-native-reanimated#8422. const shouldDisableEntryExitAnimations = Platform.OS === "android"; @@ -379,7 +399,7 @@ const AgentStreamViewComponent = forwardRef { const draftId = generateDraftId(); const payload = await client.buildAgentForkContext( @@ -466,7 +486,7 @@ const AgentStreamViewComponent = forwardRef { return buildAgentStreamRenderModel({ - agentStatus: agent.status, + agentStatus: context.status, tail: effectiveStreamItems, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, platform: isWeb ? "web" : "native", isMobileBreakpoint: isMobile, }); - }, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]); + }, [context.status, isMobile, effectiveStreamHead, effectiveStreamItems]); const streamLayout = useMemo( () => layoutStream({ strategy: streamRenderStrategy, - agentStatus: agent.status, + agentStatus: context.status, history: baseRenderModel.history, liveHead: baseRenderModel.segments.liveHead, timingByAssistantId: baseRenderModel.turnTiming.byAssistantId, }), [ - agent.status, + context.status, baseRenderModel.history, baseRenderModel.segments.liveHead, baseRenderModel.turnTiming.byAssistantId, @@ -590,14 +610,14 @@ const AgentStreamViewComponent = forwardRef ); }, - [agent.capabilities, agentId, client, resolvedServerId], + [context.capabilities, agentId, client, resolvedServerId], ); const renderAssistantMessageItem = useCallback( @@ -668,7 +688,7 @@ const AgentStreamViewComponent = forwardRef ); }, - [agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile], + [context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile], ); const renderStreamItemContent = useCallback( @@ -747,10 +767,10 @@ const AgentStreamViewComponent = forwardRef renderPendingPermissionsNode({ @@ -775,11 +795,12 @@ const AgentStreamViewComponent = forwardRef ) : null, [ handleForkAssistantTurn, + readOnly, showRunningTurnFooter, baseRenderModel.turnTiming.runningStartedAt, bottomTurnFooterHost, @@ -1004,6 +1025,17 @@ function bottomAnchorRouteRequestsEqual( ); } +function historyPaginationPropsEqual( + left: AgentStreamViewProps["historyPagination"], + right: AgentStreamViewProps["historyPagination"], +): boolean { + return ( + left?.hasOlder === right?.hasOlder && + left?.isLoadingOlder === right?.isLoadingOlder && + left?.onLoadOlder === right?.onLoadOlder + ); +} + function agentStreamViewPropsEqual( left: AgentStreamViewProps, right: AgentStreamViewProps, @@ -1011,8 +1043,9 @@ function agentStreamViewPropsEqual( const reasons: string[] = []; if (left.agentId !== right.agentId) reasons.push("agentId"); if (left.serverId !== right.serverId) reasons.push("serverId"); - reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent)); + reasons.push(...collectAgentScreenAgentDiffs(left.context, right.context)); if (left.streamItems !== right.streamItems) reasons.push("streamItems"); + if (left.streamHead !== right.streamHead) reasons.push("streamHead"); if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions"); if ( !bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest) @@ -1024,6 +1057,10 @@ function agentStreamViewPropsEqual( } if (left.toast !== right.toast) reasons.push("toast"); if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile"); + if (left.readOnly !== right.readOnly) reasons.push("readOnly"); + if (!historyPaginationPropsEqual(left.historyPagination, right.historyPagination)) { + reasons.push("historyPagination"); + } recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons); return reasons.length === 0; } diff --git a/packages/app/src/composer/draft/workspace-tab.tsx b/packages/app/src/composer/draft/workspace-tab.tsx index dc1befb39..1a0338f75 100644 --- a/packages/app/src/composer/draft/workspace-tab.tsx +++ b/packages/app/src/composer/draft/workspace-tab.tsx @@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({ { + if (message.type !== "agent.provider_subagents.update") return; + useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); + }); + const unsubWorkspaceUpdate = client.on("workspace_update", (message) => { if (message.type !== "workspace_update") return; if (message.payload.kind === "remove") { @@ -1750,6 +1756,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentUpdate(); unsubAgentStream(); unsubAgentTimeline(); + unsubProviderSubagentUpdate(); unsubWorkspaceUpdate(); unsubScriptStatusUpdate(); unsubCheckoutStatusUpdate(); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index f806f68c6..73bf635f9 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -1270,7 +1270,7 @@ const AgentStreamSection = memo(function AgentStreamSection({ ref={streamViewRef} agentId={agent.id} serverId={serverId} - agent={agent} + context={agent} streamItems={streamItems} pendingPermissions={pendingPermissions} routeBottomAnchorRequest={routeBottomAnchorRequest} @@ -1364,7 +1364,7 @@ function ActiveAgentComposer({ { initialIsBelow: isCompactFormFactor }, ); const paneContext = usePaneContext(); - const { workspaceId, tabId, retargetCurrentTab } = paneContext; + const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext; const { archiveAgent } = useArchiveAgent(); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); @@ -1382,6 +1382,12 @@ function ActiveAgentComposer({ }, [serverId], ); + const handleOpenProviderSubagent = useCallback( + (parentAgentId: string, subagentId: string) => { + openTab({ kind: "provider_subagent", parentAgentId, subagentId }); + }, + [openTab], + ); const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId }); const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ @@ -1482,6 +1488,7 @@ function ActiveAgentComposer({ diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx new file mode 100644 index 000000000..2bff0118c --- /dev/null +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -0,0 +1,190 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Text, View } from "react-native"; +import { StyleSheet } from "react-native-unistyles"; +import invariant from "tiny-invariant"; +import { useShallow } from "zustand/react/shallow"; +import { AgentStreamView } from "@/agent-stream/view"; +import { getProviderIcon } from "@/components/provider-icons"; +import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine"; +import { usePaneContext } from "@/panels/pane-context"; +import type { PanelDescriptor, PanelRegistration } from "@/panels/panel-registry"; +import { useSessionStore } from "@/stores/session-store"; +import { + providerSubagentKey, + providerSubagentLifecycleStatus, + refreshProviderSubagents, + useProviderSubagentStore, +} from "@/subagents/provider-store"; +import { useTranslation } from "react-i18next"; +import type { PendingPermission } from "@/types/shared"; +import type { StreamItem } from "@/types/stream"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; + +const EMPTY_PERMISSIONS = new Map(); +const EMPTY_STREAM_ITEMS: StreamItem[] = []; + +function formatProviderLabel(provider: string): string { + return provider + .split(/[-_\s]+/) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function useProviderSubagentDescriptor( + target: { kind: "provider_subagent"; parentAgentId: string; subagentId: string }, + context: { serverId: string }, +): PanelDescriptor { + const descriptor = useProviderSubagentStore((state) => + state.descriptors.get( + providerSubagentKey(context.serverId, target.parentAgentId, target.subagentId), + ), + ); + const parentProvider = useSessionStore( + (state) => state.sessions[context.serverId]?.agents.get(target.parentAgentId)?.provider, + ); + const provider = descriptor?.provider ?? parentProvider ?? "agent"; + const label = descriptor?.title?.trim() || descriptor?.description?.trim() || "Subagent"; + return { + label, + subtitle: `${formatProviderLabel(provider)} subagent`, + titleState: descriptor ? "ready" : "loading", + icon: getProviderIcon(provider), + statusBucket: descriptor + ? deriveSidebarStateBucket({ + status: providerSubagentLifecycleStatus(descriptor.status), + requiresAttention: descriptor.status === "failed", + }) + : null, + }; +} + +function ProviderSubagentPanel() { + const { t } = useTranslation(); + const { serverId, target, openFileInWorkspace } = usePaneContext(); + invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target"); + const key = providerSubagentKey(serverId, target.parentAgentId, target.subagentId); + const streamId = `provider:${encodeURIComponent(target.parentAgentId)}:${encodeURIComponent(target.subagentId)}`; + const { descriptor, timeline } = useProviderSubagentStore( + useShallow((state) => ({ + descriptor: state.descriptors.get(key) ?? null, + timeline: state.timelines.get(key) ?? null, + })), + ); + const parent = useSessionStore( + (state) => + state.sessions[serverId]?.agents.get(target.parentAgentId) ?? + state.sessions[serverId]?.agentDetails.get(target.parentAgentId) ?? + null, + ); + const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); + const serverInfo = useSessionStore((state) => state.sessions[serverId]?.serverInfo ?? null); + // COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12. + const supported = serverInfo?.features?.providerSubagents === true; + const [isLoadingOlder, setIsLoadingOlder] = useState(false); + + useEffect(() => { + if (!client || !supported) return; + void refreshProviderSubagents(client, serverId, target.parentAgentId).catch(() => undefined); + }, [client, serverId, supported, target.parentAgentId]); + + useEffect(() => { + if (!client || !supported) return; + void client + .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { + direction: "tail", + limit: TIMELINE_FETCH_PAGE_SIZE, + }) + .then((payload) => { + useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + return undefined; + }) + .catch(() => undefined); + }, [client, serverId, supported, target.parentAgentId, target.subagentId]); + + const loadOlder = useCallback(() => { + if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) return; + const firstSeq = timeline.rows.size ? Math.min(...timeline.rows.keys()) : null; + if (firstSeq === null) return; + setIsLoadingOlder(true); + void client + .fetchProviderSubagentTimeline(target.parentAgentId, target.subagentId, { + direction: "before", + cursor: { epoch: timeline.epoch, seq: firstSeq }, + limit: TIMELINE_FETCH_PAGE_SIZE, + }) + .then((payload) => { + useProviderSubagentStore.getState().replaceTimeline(serverId, payload); + return undefined; + }) + .catch(() => undefined) + .finally(() => setIsLoadingOlder(false)); + }, [ + client, + isLoadingOlder, + serverId, + supported, + target.parentAgentId, + target.subagentId, + timeline, + ]); + + const streamContext = useMemo( + () => ({ + serverId, + id: streamId, + provider: descriptor?.provider ?? parent?.provider, + status: descriptor ? providerSubagentLifecycleStatus(descriptor.status) : "initializing", + cwd: descriptor?.cwd ?? parent?.cwd ?? "", + workspaceId: parent?.workspaceId, + projectPlacement: parent?.projectPlacement, + }), + [descriptor, parent, serverId, streamId], + ); + const historyPagination = useMemo( + () => ({ + hasOlder: timeline?.hasOlder === true, + isLoadingOlder, + onLoadOlder: loadOlder, + }), + [isLoadingOlder, loadOlder, timeline?.hasOlder], + ); + + if (serverInfo && !supported) { + return ( + + {t("message.actions.forkUnavailable")} + + ); + } + + return ( + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + container: { flex: 1 }, + unsupported: { flex: 1, alignItems: "center", justifyContent: "center", padding: 24 }, + unsupportedText: { color: theme.colors.foregroundMuted, textAlign: "center" }, +})); + +export const providerSubagentPanelRegistration: PanelRegistration<"provider_subagent"> = { + kind: "provider_subagent", + component: ProviderSubagentPanel, + useDescriptor: useProviderSubagentDescriptor, +}; diff --git a/packages/app/src/panels/register-panels.ts b/packages/app/src/panels/register-panels.ts index ebcf73428..b6d160d79 100644 --- a/packages/app/src/panels/register-panels.ts +++ b/packages/app/src/panels/register-panels.ts @@ -5,6 +5,7 @@ import { filePanelRegistration } from "@/panels/file-panel"; import { registerPanel } from "@/panels/panel-registry"; import { setupPanelRegistration } from "@/panels/setup-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel"; +import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel"; let panelsRegistered = false; @@ -14,6 +15,7 @@ export function ensurePanelsRegistered(): void { } registerPanel(draftPanelRegistration); registerPanel(agentPanelRegistration); + registerPanel(providerSubagentPanelRegistration); registerPanel(setupPanelRegistration); registerPanel(terminalPanelRegistration); registerPanel(browserPanelRegistration); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ad250ea9a..b35b3a1b4 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -377,6 +377,9 @@ function getFallbackTabOptionDescription( if (tab.target.kind === "browser") { return labels.browser; } + if (tab.target.kind === "provider_subagent") { + return labels.agent; + } return tab.target.path; } diff --git a/packages/app/src/screens/workspace/workspace-tab-menu.ts b/packages/app/src/screens/workspace/workspace-tab-menu.ts index 334f68bb8..211444517 100644 --- a/packages/app/src/screens/workspace/workspace-tab-menu.ts +++ b/packages/app/src/screens/workspace/workspace-tab-menu.ts @@ -138,6 +138,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string { if (tab.target.kind === "setup") { return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`; } + if (tab.target.kind === "provider_subagent") { + return `workspace-provider-subagent-close-${tab.target.subagentId}`; + } return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`; } diff --git a/packages/app/src/stores/workspace-tabs-store/state.ts b/packages/app/src/stores/workspace-tabs-store/state.ts index 509f187fb..6d4ebcb77 100644 --- a/packages/app/src/stores/workspace-tabs-store/state.ts +++ b/packages/app/src/stores/workspace-tabs-store/state.ts @@ -19,6 +19,7 @@ export interface WorkspaceDraftTabSetup { export type WorkspaceTabTarget = | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } | { kind: "agent"; agentId: string } + | { kind: "provider_subagent"; parentAgentId: string; subagentId: string } | { kind: "terminal"; terminalId: string } | { kind: "browser"; browserId: string } | WorkspaceFileTabTarget @@ -508,6 +509,17 @@ function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTar if (kind === "agent" && typeof raw.agentId === "string") { return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); } + if ( + kind === "provider_subagent" && + typeof raw.parentAgentId === "string" && + typeof raw.subagentId === "string" + ) { + return normalizeWorkspaceTabTarget({ + kind: "provider_subagent", + parentAgentId: raw.parentAgentId, + subagentId: raw.subagentId, + }); + } if (kind === "terminal" && typeof raw.terminalId === "string") { return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); } diff --git a/packages/app/src/subagents/provider-store.test.ts b/packages/app/src/subagents/provider-store.test.ts new file mode 100644 index 000000000..03c6ade16 --- /dev/null +++ b/packages/app/src/subagents/provider-store.test.ts @@ -0,0 +1,362 @@ +import { afterEach, describe, expect, test } from "vitest"; +import { providerSubagentKey, useProviderSubagentStore } from "./provider-store"; + +const SERVER_ID = "server-1"; +const PARENT_ID = "parent-1"; +const SUBAGENT_ID = "child-1"; + +afterEach(() => { + useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); +}); + +describe("provider subagent client store", () => { + test("builds a shared stream model from ordered provider updates", () => { + const subagents = useProviderSubagentStore.getState(); + subagents.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Explore", + description: "Inspect the repository", + status: "running", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:00.000Z", + toolCallId: "call-1", + }, + }); + subagents.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 2, + timestamp: "2026-07-12T10:00:02.000Z", + item: { type: "assistant_message", text: "New live output." }, + }); + subagents.replaceTimeline(SERVER_ID, { + requestId: "history-1", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 1, nextSeq: 2 }, + hasOlder: false, + hasNewer: true, + rows: [ + { + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Older history." }, + }, + ], + error: null, + }); + const liveTimeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); + subagents.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Explore", + description: "Inspect the repository", + status: "running", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:01.500Z", + toolCallId: "call-1", + }, + }); + expect( + useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)), + ).toBe(liveTimeline); + subagents.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Explore", + description: "Inspect the repository", + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + const state = useProviderSubagentStore.getState(); + expect(state.descriptors.get(key)?.status).toBe("completed"); + expect(state.timelines.get(key)?.head).toEqual([]); + expect(state.timelines.get(key)?.tail).toEqual([ + expect.objectContaining({ + kind: "assistant_message", + text: "Older history.New live output.", + }), + ]); + }); + + test("removes timelines for children no longer returned by the provider", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Removed child output." }, + }); + + store.replaceList(SERVER_ID, PARENT_ID, []); + + expect( + useProviderSubagentStore + .getState() + .timelines.has(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)), + ).toBe(false); + }); + + test("applies terminal list status to a timeline received before its descriptor", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Restored output." }, + }); + const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID); + expect(useProviderSubagentStore.getState().timelines.get(key)?.head).not.toEqual([]); + + store.replaceList(SERVER_ID, PARENT_ID, [ + { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Restored child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + ]); + + const timeline = useProviderSubagentStore.getState().timelines.get(key); + expect(timeline?.head).toEqual([]); + expect(timeline?.tail).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Restored output." }), + ]); + }); + + test("keeps late timeline rows terminal after the descriptor completes", () => { + const store = useProviderSubagentStore.getState(); + store.applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: SUBAGENT_ID, + parentAgentId: PARENT_ID, + provider: "codex", + title: "Restored child", + description: null, + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + toolCallId: "call-1", + }, + }); + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-1", + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Late restored output." }, + }); + + const timeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); + expect(timeline?.head).toEqual([]); + expect(timeline?.tail).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Late restored output." }), + ]); + }); + + test("merges bounded older pages and tracks whether more history remains", () => { + const store = useProviderSubagentStore.getState(); + store.replaceTimeline(SERVER_ID, { + requestId: "tail-page", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 2, maxSeq: 2, nextSeq: 3 }, + hasOlder: true, + hasNewer: false, + rows: [ + { + seq: 2, + timestamp: "2026-07-12T10:00:02.000Z", + item: { type: "assistant_message", text: "Recent output." }, + }, + ], + error: null, + }); + store.replaceTimeline(SERVER_ID, { + requestId: "older-page", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "before", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 2, nextSeq: 3 }, + hasOlder: false, + hasNewer: true, + rows: [ + { + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Older output." }, + }, + ], + error: null, + }); + + const timeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); + expect(timeline?.hasOlder).toBe(false); + expect([...timeline!.rows.keys()]).toEqual([2, 1]); + expect(timeline?.head).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Older output.Recent output." }), + ]); + }); + + test("ignores delayed live updates from a stale timeline epoch", () => { + const store = useProviderSubagentStore.getState(); + store.replaceTimeline(SERVER_ID, { + requestId: "current-page", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-current", + reset: true, + staleCursor: false, + gap: false, + window: { minSeq: 2, maxSeq: 2, nextSeq: 3 }, + hasOlder: false, + hasNewer: false, + rows: [ + { + seq: 2, + timestamp: "2026-07-12T10:00:02.000Z", + item: { type: "assistant_message", text: "Current output." }, + }, + ], + error: null, + }); + + store.applyUpdate(SERVER_ID, { + kind: "timeline", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + epoch: "epoch-stale", + seq: 3, + timestamp: "2026-07-12T10:00:03.000Z", + item: { type: "assistant_message", text: "Stale output." }, + }); + + const timeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); + expect(timeline?.epoch).toBe("epoch-current"); + expect([...timeline!.rows.keys()]).toEqual([2]); + expect(timeline?.head).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Current output." }), + ]); + }); + + test("replaces cached rows with an authoritative tail page after a reconnect gap", () => { + const store = useProviderSubagentStore.getState(); + store.replaceTimeline(SERVER_ID, { + requestId: "old-tail", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 500, nextSeq: 501 }, + hasOlder: true, + hasNewer: false, + rows: [ + { + seq: 100, + timestamp: "2026-07-12T10:00:00.000Z", + item: { type: "assistant_message", text: "Old cached output." }, + }, + ], + error: null, + }); + store.replaceTimeline(SERVER_ID, { + requestId: "reconnect-tail", + parentAgentId: PARENT_ID, + subagentId: SUBAGENT_ID, + provider: "codex", + direction: "tail", + epoch: "epoch-1", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 1, maxSeq: 500, nextSeq: 501 }, + hasOlder: true, + hasNewer: false, + rows: [ + { + seq: 401, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Current tail output." }, + }, + ], + error: null, + }); + + const timeline = useProviderSubagentStore + .getState() + .timelines.get(providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID)); + expect([...timeline!.rows.keys()]).toEqual([401]); + expect(timeline?.head).toEqual([ + expect.objectContaining({ kind: "assistant_message", text: "Current tail output." }), + ]); + }); +}); diff --git a/packages/app/src/subagents/provider-store.ts b/packages/app/src/subagents/provider-store.ts new file mode 100644 index 000000000..f0d76ee70 --- /dev/null +++ b/packages/app/src/subagents/provider-store.ts @@ -0,0 +1,304 @@ +import type { + AgentStreamEventPayload, + ProviderSubagentDescriptorPayload, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; +import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { create } from "zustand"; +import { applyStreamEvent } from "@/types/stream"; +import type { StreamItem } from "@/types/stream"; +import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; + +type ProviderSubagentTimelineItem = Extract< + Extract["payload"], + { kind: "timeline" } +>["item"]; + +interface ProviderSubagentTimelineRow { + provider: ProviderSubagentDescriptorPayload["provider"]; + item: ProviderSubagentTimelineItem; + timestamp: string; +} + +export interface ProviderSubagentTimelineState { + tail: StreamItem[]; + head: StreamItem[]; + epoch: string | null; + lastSeq: number; + hasOlder: boolean; + rows: Map; +} + +interface ProviderSubagentState { + descriptors: Map; + timelines: Map; + replaceList( + serverId: string, + parentAgentId: string, + subagents: ProviderSubagentDescriptorPayload[], + ): void; + applyUpdate( + serverId: string, + payload: Extract< + SessionOutboundMessage, + { type: "agent.provider_subagents.update" } + >["payload"], + ): void; + replaceTimeline( + serverId: string, + payload: Extract< + SessionOutboundMessage, + { type: "agent.provider_subagents.timeline.get.response" } + >["payload"], + ): void; +} + +export function providerSubagentKey( + serverId: string, + parentAgentId: string, + subagentId: string, +): string { + return `${serverId}\0${parentAgentId}\0${subagentId}`; +} + +export function providerSubagentLifecycleStatus( + status: ProviderSubagentDescriptorPayload["status"], +): AgentLifecycleStatus { + if (status === "running") return "running"; + if (status === "failed") return "error"; + return "idle"; +} + +type ProviderSubagentListClient = Pick; + +const pendingListRequests = new WeakMap>>(); + +export function refreshProviderSubagents( + client: ProviderSubagentListClient, + serverId: string, + parentAgentId: string, +): Promise { + const requestKey = `${serverId}\0${parentAgentId}`; + let clientRequests = pendingListRequests.get(client); + if (!clientRequests) { + clientRequests = new Map(); + pendingListRequests.set(client, clientRequests); + } + const pending = clientRequests.get(requestKey); + if (pending) return pending; + + const request = client + .listProviderSubagents(parentAgentId) + .then((payload) => { + useProviderSubagentStore.getState().replaceList(serverId, parentAgentId, payload.subagents); + return undefined; + }) + .finally(() => { + clientRequests?.delete(requestKey); + }); + clientRequests.set(requestKey, request); + return request; +} + +function parentPrefix(serverId: string, parentAgentId: string): string { + return `${serverId}\0${parentAgentId}\0`; +} + +const EMPTY_TIMELINE: ProviderSubagentTimelineState = { + tail: [], + head: [], + epoch: null, + lastSeq: 0, + hasOlder: false, + rows: new Map(), +}; + +function providerSubagentTerminalEvent( + subagent: ProviderSubagentDescriptorPayload, +): AgentStreamEventPayload | null { + if (subagent.status === "running") { + return null; + } + if (subagent.status === "failed") { + return { type: "turn_failed", provider: subagent.provider, error: "Subagent failed" }; + } + if (subagent.status === "canceled") { + return { type: "turn_canceled", provider: subagent.provider, reason: "canceled" }; + } + return { type: "turn_completed", provider: subagent.provider }; +} + +function buildTimelineState( + rows: ProviderSubagentTimelineState["rows"], + epoch: string | null, + descriptor?: ProviderSubagentDescriptorPayload, + hasOlder = false, +): ProviderSubagentTimelineState { + let timeline = { tail: [] as StreamItem[], head: [] as StreamItem[] }; + for (const [, row] of [...rows].sort(([left], [right]) => left - right)) { + timeline = applyStreamEvent({ + ...timeline, + event: { type: "timeline", provider: row.provider, item: row.item }, + timestamp: new Date(row.timestamp), + }); + } + const terminalEvent = descriptor ? providerSubagentTerminalEvent(descriptor) : null; + if (terminalEvent && descriptor) { + timeline = applyStreamEvent({ + ...timeline, + event: terminalEvent, + timestamp: new Date(descriptor.updatedAt), + }); + } + return { + ...timeline, + epoch, + lastSeq: rows.size ? Math.max(...rows.keys()) : 0, + hasOlder, + rows, + }; +} + +function buildTimelineResponseRows( + existing: ProviderSubagentTimelineState | undefined, + payload: Extract< + SessionOutboundMessage, + { type: "agent.provider_subagents.timeline.get.response" } + >["payload"], + provider: ProviderSubagentDescriptorPayload["provider"], +): ProviderSubagentTimelineState["rows"] { + const rows = new Map(); + for (const row of payload.rows) { + rows.set(row.seq, { provider, item: row.item, timestamp: row.timestamp }); + } + if (payload.reset || existing?.epoch !== payload.epoch) { + return rows; + } + if (payload.direction !== "tail") { + return new Map([...existing.rows, ...rows]); + } + + let nextSeq = payload.rows.length + ? Math.max(...payload.rows.map((row) => row.seq)) + 1 + : payload.window.maxSeq + 1; + for (const [seq, row] of [...existing.rows].sort(([left], [right]) => left - right)) { + if (seq < nextSeq) continue; + if (seq !== nextSeq) break; + rows.set(seq, row); + nextSeq += 1; + } + return rows; +} + +export const useProviderSubagentStore = create((set) => ({ + descriptors: new Map(), + timelines: new Map(), + replaceList(serverId, parentAgentId, subagents) { + set((state) => { + const prefix = parentPrefix(serverId, parentAgentId); + const descriptors = new Map( + [...state.descriptors].filter(([key]) => !key.startsWith(prefix)), + ); + for (const subagent of subagents) { + descriptors.set(providerSubagentKey(serverId, parentAgentId, subagent.id), subagent); + } + const retainedKeys = new Set(descriptors.keys()); + const timelines = new Map( + [...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)), + ); + for (const subagent of subagents) { + const key = providerSubagentKey(serverId, parentAgentId, subagent.id); + const current = timelines.get(key); + const previous = state.descriptors.get(key); + if (current && previous?.status !== subagent.status) { + timelines.set( + key, + buildTimelineState(current.rows, current.epoch, subagent, current.hasOlder), + ); + } + } + return { descriptors, timelines }; + }); + }, + applyUpdate(serverId, payload) { + set((state) => { + if (payload.kind === "upsert") { + const key = providerSubagentKey( + serverId, + payload.subagent.parentAgentId, + payload.subagent.id, + ); + const descriptors = new Map(state.descriptors); + const previous = descriptors.get(key); + descriptors.set(key, payload.subagent); + let timelines = state.timelines; + const current = state.timelines.get(key); + if (current && previous?.status !== payload.subagent.status) { + timelines = new Map(state.timelines); + timelines.set( + key, + buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder), + ); + } + return { descriptors, timelines }; + } + if (payload.kind === "remove") { + const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); + const descriptors = new Map(state.descriptors); + const timelines = new Map(state.timelines); + descriptors.delete(key); + timelines.delete(key); + return { descriptors, timelines }; + } + const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); + const existing = state.timelines.get(key); + if (existing?.epoch && existing.epoch !== payload.epoch) { + return state; + } + const current = existing ?? EMPTY_TIMELINE; + if (payload.seq <= current.lastSeq) { + return state; + } + const rows = new Map(current.rows); + rows.set(payload.seq, { + provider: payload.provider, + item: payload.item, + timestamp: payload.timestamp, + }); + const descriptor = state.descriptors.get(key); + const next = + descriptor && descriptor.status !== "running" + ? buildTimelineState(rows, payload.epoch, descriptor, current.hasOlder) + : applyStreamEvent({ + tail: current.tail, + head: current.head, + event: { type: "timeline", provider: payload.provider, item: payload.item }, + timestamp: new Date(payload.timestamp), + }); + const timelines = new Map(state.timelines); + timelines.set(key, { + ...next, + epoch: payload.epoch, + lastSeq: payload.seq, + hasOlder: current.hasOlder, + rows, + }); + return { timelines }; + }); + }, + replaceTimeline(serverId, payload) { + const provider = payload.provider; + if (!provider) { + return; + } + set((state) => { + const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId); + const existing = state.timelines.get(key); + const rows = buildTimelineResponseRows(existing, payload, provider); + const descriptor = state.descriptors.get(key); + const timelines = new Map(state.timelines); + timelines.set(key, buildTimelineState(rows, payload.epoch, descriptor, payload.hasOlder)); + return { timelines }; + }); + }, +})); diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index c8022735b..4a8599467 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -1,6 +1,7 @@ import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import { afterEach, describe, expect, it } from "vitest"; -import { selectSubagentsForParent } from "./select"; +import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select"; +import { useProviderSubagentStore } from "./provider-store"; import { useSessionStore, type Agent } from "@/stores/session-store"; const SERVER_ID = "server-1"; @@ -58,9 +59,37 @@ function setAgents(agents: Agent[]): void { afterEach(() => { useSessionStore.getState().clearSession(SERVER_ID); + useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() }); }); describe("selectSubagentsForParent", () => { + it("hides cached provider children when the host does not support them", () => { + useProviderSubagentStore.getState().applyUpdate(SERVER_ID, { + kind: "upsert", + subagent: { + id: "provider-child", + parentAgentId: "parent-a", + provider: "codex", + title: "Provider child", + description: null, + status: "completed", + createdAt: "2026-03-08T10:01:00.000Z", + updatedAt: "2026-03-08T10:02:00.000Z", + toolCallId: "call-1", + }, + }); + const params = { serverId: SERVER_ID, parentAgentId: "parent-a" }; + + expect( + selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, false), + ).toEqual([]); + expect( + selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, true).map( + (row) => row.id, + ), + ).toEqual(["provider-child"]); + }); + it("returns only non-archived children for the requested parent", () => { setAgents([ makeAgent({ id: "parent-a" }), @@ -194,6 +223,7 @@ describe("selectSubagentsForParent", () => { expect(rows).toEqual([ { + kind: "paseo", id: "child", provider: "claude", title: "Review child", @@ -205,6 +235,7 @@ describe("selectSubagentsForParent", () => { expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ "createdAt", "id", + "kind", "provider", "requiresAttention", "status", diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 08807b689..a235cc445 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -1,9 +1,13 @@ +import { useEffect, useMemo } from "react"; import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent"; import equal from "fast-deep-equal"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useSessionStore, type Agent } from "@/stores/session-store"; +import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store"; +import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages"; -export interface SubagentRow { +export interface PaseoSubagentRow { + kind: "paseo"; id: Agent["id"]; provider: Agent["provider"]; title: Agent["title"]; @@ -12,7 +16,21 @@ export interface SubagentRow { createdAt: Agent["createdAt"]; } +export interface ProviderSubagentRow { + kind: "provider"; + id: string; + parentAgentId: string; + provider: ProviderSubagentDescriptorPayload["provider"]; + title: string | null; + status: ProviderSubagentDescriptorPayload["status"]; + requiresAttention: boolean; + createdAt: Date; +} + +export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow; + type SessionStoreSnapshot = ReturnType; +type ProviderSubagentStoreSnapshot = ReturnType; interface SelectSubagentsParams { serverId: string; @@ -20,9 +38,11 @@ interface SelectSubagentsParams { } const EMPTY_SUBAGENT_ROWS: SubagentRow[] = []; +const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = []; function toSubagentRow(agent: Agent): SubagentRow { return { + kind: "paseo", id: agent.id, provider: agent.provider, title: agent.title, @@ -62,11 +82,59 @@ export function selectSubagentsForParent( return rows; } +export function selectProviderSubagentsForParent( + state: ProviderSubagentStoreSnapshot, + params: SelectSubagentsParams, + supported: boolean, +): ProviderSubagentRow[] { + if (!supported) return EMPTY_PROVIDER_SUBAGENT_ROWS; + const rows: ProviderSubagentRow[] = []; + const prefix = `${params.serverId}\0${params.parentAgentId}\0`; + for (const [key, subagent] of state.descriptors) { + if (!key.startsWith(prefix)) continue; + rows.push({ + kind: "provider", + id: subagent.id, + parentAgentId: subagent.parentAgentId, + provider: subagent.provider, + title: subagent.title ?? subagent.description, + status: subagent.status, + requiresAttention: subagent.status === "failed", + createdAt: new Date(subagent.createdAt), + }); + } + rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); + return rows; +} + export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] { const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId); - return useStoreWithEqualityFn( + const paseoRows = useStoreWithEqualityFn( useSessionStore, (state) => selectSubagentsForParent(state, params, pendingArchiveIds), equal, ); + const supported = useSessionStore( + (state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true, + ); + const providerRows = useStoreWithEqualityFn( + useProviderSubagentStore, + (state) => selectProviderSubagentsForParent(state, params, supported), + equal, + ); + const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null); + + useEffect(() => { + if (!client || !supported) return; + void refreshProviderSubagents(client, params.serverId, params.parentAgentId).catch( + () => undefined, + ); + }, [client, params.parentAgentId, params.serverId, supported]); + + return useMemo(() => { + if (providerRows.length === 0) return paseoRows; + const rows = [...paseoRows, ...providerRows]; + rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); + return rows; + }, [paseoRows, providerRows]); } diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 82170690e..2802c1707 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -1,13 +1,16 @@ import { describe, expect, it } from "vitest"; -import type { SubagentRow } from "./select"; +import type { PaseoSubagentRow, SubagentRow } from "./select"; import { buildSubagentRowPresentationData, formatHeaderLabel, resolveRowLabel, } from "./track-presentation"; -function row(overrides: Partial & Pick): SubagentRow { +function row( + overrides: Partial & Pick, +): PaseoSubagentRow { return { + kind: "paseo", id: overrides.id, provider: overrides.provider ?? "codex", title: overrides.title ?? `Agent ${overrides.id}`, @@ -91,7 +94,9 @@ describe("resolveRowLabel", () => { describe("buildSubagentRowPresentationData", () => { it("namespaces the key with a subagent prefix", () => { - expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe("subagent_child-a"); + expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe( + "paseo_subagent_child-a", + ); }); it("marks the row ready when the title resolves to a real label", () => { diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index d9c626313..d7255cc77 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -1,6 +1,12 @@ import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SubagentRow } from "./select"; +import { providerSubagentLifecycleStatus } from "./provider-store"; + +function presentationStatus(row: SubagentRow) { + if (row.kind === "paseo") return row.status; + return providerSubagentLifecycleStatus(row.status); +} export interface SubagentRowPresentationData { key: string; @@ -13,14 +19,15 @@ export interface SubagentRowPresentationData { export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData { const label = resolveRowLabel(row.title); + const status = presentationStatus(row); return { - key: `subagent_${row.id}`, + key: `${row.kind}_subagent_${row.id}`, kind: "agent", label: label ?? "", subtitle: "", titleState: label ? "ready" : "loading", statusBucket: deriveSidebarStateBucket({ - status: row.status, + status, requiresAttention: false, }), }; diff --git a/packages/app/src/subagents/track.tsx b/packages/app/src/subagents/track.tsx index c768e82ed..c0ff19ec3 100644 --- a/packages/app/src/subagents/track.tsx +++ b/packages/app/src/subagents/track.tsx @@ -28,6 +28,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({ export interface SubagentsTrackProps { rows: SubagentRow[]; onOpenSubagent: (id: string) => void; + onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; onDetachSubagent?: (id: string) => void; } @@ -44,6 +45,7 @@ function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { export function SubagentsTrack({ rows, onOpenSubagent, + onOpenProviderSubagent, onArchiveSubagent, onDetachSubagent, }: SubagentsTrackProps): ReactElement | null { @@ -105,6 +107,7 @@ export function SubagentsTrack({ key={row.id} row={row} onOpenSubagent={onOpenSubagent} + onOpenProviderSubagent={onOpenProviderSubagent} onArchiveSubagent={onArchiveSubagent} onDetachSubagent={onDetachSubagent} /> @@ -120,6 +123,7 @@ export function SubagentsTrack({ interface SubagentsTrackRowProps { row: SubagentRow; onOpenSubagent: (id: string) => void; + onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void; onArchiveSubagent: (id: string) => void; onDetachSubagent?: (id: string) => void; } @@ -127,6 +131,7 @@ interface SubagentsTrackRowProps { function SubagentsTrackRow({ row, onOpenSubagent, + onOpenProviderSubagent, onArchiveSubagent, onDetachSubagent, }: SubagentsTrackRowProps): ReactElement { @@ -137,8 +142,12 @@ function SubagentsTrackRow({ const displayLabel = presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; const handlePress = useCallback(() => { - onOpenSubagent(row.id); - }, [onOpenSubagent, row.id]); + if (row.kind === "provider") { + onOpenProviderSubagent(row.parentAgentId, row.id); + } else { + onOpenSubagent(row.id); + } + }, [onOpenProviderSubagent, onOpenSubagent, row]); const handleArchivePress = useCallback(() => { onArchiveSubagent(row.id); }, [onArchiveSubagent, row.id]); @@ -167,13 +176,15 @@ function SubagentsTrackRow({ {displayLabel} - + {row.kind === "paseo" ? ( + + ) : null} )} diff --git a/packages/app/src/workspace-tabs/identity.test.ts b/packages/app/src/workspace-tabs/identity.test.ts new file mode 100644 index 000000000..b75270958 --- /dev/null +++ b/packages/app/src/workspace-tabs/identity.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "vitest"; +import { + buildDeterministicWorkspaceTabId, + normalizeWorkspaceTabTarget, + workspaceTabTargetsEqual, +} from "./identity"; + +describe("provider subagent tab identity", () => { + test("normalizes and compares the parent and provider child as one tab identity", () => { + const target = normalizeWorkspaceTabTarget({ + kind: "provider_subagent", + parentAgentId: " parent-a ", + subagentId: " child-a ", + }); + + expect(target).toEqual({ + kind: "provider_subagent", + parentAgentId: "parent-a", + subagentId: "child-a", + }); + expect( + target && + workspaceTabTargetsEqual(target, { + kind: "provider_subagent", + parentAgentId: "parent-a", + subagentId: "child-a", + }), + ).toBe(true); + }); + + test("does not collide when parent and child ids contain separators", () => { + const first = buildDeterministicWorkspaceTabId({ + kind: "provider_subagent", + parentAgentId: "a_b", + subagentId: "c", + }); + const second = buildDeterministicWorkspaceTabId({ + kind: "provider_subagent", + parentAgentId: "a", + subagentId: "b_c", + }); + + expect(first).not.toBe(second); + }); +}); diff --git a/packages/app/src/workspace-tabs/identity.ts b/packages/app/src/workspace-tabs/identity.ts index 35a2693e8..3e5bf004e 100644 --- a/packages/app/src/workspace-tabs/identity.ts +++ b/packages/app/src/workspace-tabs/identity.ts @@ -21,6 +21,13 @@ export function normalizeWorkspaceTabTarget( const agentId = trimNonEmpty(value.agentId); return agentId ? { kind: "agent", agentId } : null; } + if (value.kind === "provider_subagent") { + const parentAgentId = trimNonEmpty(value.parentAgentId); + const subagentId = trimNonEmpty(value.subagentId); + return parentAgentId && subagentId + ? { kind: "provider_subagent", parentAgentId, subagentId } + : null; + } if (value.kind === "terminal") { const terminalId = trimNonEmpty(value.terminalId); return terminalId ? { kind: "terminal", terminalId } : null; @@ -76,6 +83,9 @@ export function workspaceTabTargetsEqual( if (left.kind === "agent" && right.kind === "agent") { return left.agentId === right.agentId; } + if (left.kind === "provider_subagent" && right.kind === "provider_subagent") { + return left.parentAgentId === right.parentAgentId && left.subagentId === right.subagentId; + } if (left.kind === "terminal" && right.kind === "terminal") { return left.terminalId === right.terminalId; } @@ -131,6 +141,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st if (target.kind === "agent") { return `agent_${target.agentId}`; } + if (target.kind === "provider_subagent") { + return `provider_subagent_${target.parentAgentId.length}_${target.parentAgentId}_${target.subagentId.length}_${target.subagentId}`; + } if (target.kind === "terminal") { return `terminal_${target.terminalId}`; } diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index de15526f0..a636fa4d5 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -555,6 +555,7 @@ test("advertises client capabilities in hello", async () => { protocolVersion: 1, capabilities: { custom_mode_icons: true, + provider_subagents: true, reasoning_merge_enum: true, terminal_reflowable_snapshot: true, browser_host: { diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 4e1784c89..3919b0eb5 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -490,6 +490,22 @@ export interface FetchAgentTimelineOptions { timeout?: number; } +export type ProviderSubagentListPayload = Extract< + SessionOutboundMessage, + { type: "agent.provider_subagents.list.response" } +>["payload"]; +export type ProviderSubagentTimelinePayload = Extract< + SessionOutboundMessage, + { type: "agent.provider_subagents.timeline.get.response" } +>["payload"]; +export interface FetchProviderSubagentTimelineOptions { + direction?: ProviderSubagentTimelinePayload["direction"]; + cursor?: FetchAgentTimelineCursor; + limit?: number; + requestId?: string; + timeout?: number; +} + // COMPAT(daemon-client-object-options): added in v0.1.102; remove after // 2026-12-29 once SDK callers have migrated to object parameters. function normalizeFetchAgentOptions( @@ -2429,6 +2445,65 @@ export class DaemonClient { return payload; } + async listProviderSubagents( + parentAgentId: string, + options: { requestId?: string; timeout?: number } = {}, + ): Promise { + const requestId = this.createRequestId(options.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "agent.provider_subagents.list.request", + parentAgentId, + requestId, + }); + const payload = await this.sendRequest({ + requestId, + message, + timeout: options.timeout, + options: { skipQueue: true }, + select: (response) => + response.type === "agent.provider_subagents.list.response" && + response.payload.requestId === requestId + ? response.payload + : null, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload; + } + + async fetchProviderSubagentTimeline( + parentAgentId: string, + subagentId: string, + options: FetchProviderSubagentTimelineOptions = {}, + ): Promise { + const requestId = this.createRequestId(options.requestId); + const message = SessionInboundMessageSchema.parse({ + type: "agent.provider_subagents.timeline.get.request", + parentAgentId, + subagentId, + requestId, + ...(options.direction ? { direction: options.direction } : {}), + ...(options.cursor ? { cursor: options.cursor } : {}), + ...(typeof options.limit === "number" ? { limit: options.limit } : {}), + }); + const payload = await this.sendRequest({ + requestId, + message, + timeout: options.timeout, + options: { skipQueue: true }, + select: (response) => + response.type === "agent.provider_subagents.timeline.get.response" && + response.payload.requestId === requestId + ? response.payload + : null, + }); + if (payload.error) { + throw new Error(payload.error); + } + return payload; + } + async buildAgentForkContext( agentId: string, options: AgentForkContextOptions = {}, @@ -4672,6 +4747,7 @@ export class DaemonClient { [CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.terminalReflowableSnapshot]: true, + [CLIENT_CAPS.providerSubagents]: true, ...this.config.capabilities, }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 1b315d5d9..63e01a5e4 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -11,6 +11,9 @@ export const CLIENT_CAPS = { // Old clients use a strict TerminalState schema and would reject the extra fields. // Drop the gate (always send the flags) when floor >= v0.1.88. terminalReflowableSnapshot: "terminal_reflowable_snapshot", + // COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned + // child descriptors and timelines only to clients that understand the new messages. + providerSubagents: "provider_subagents", browserHost: "browser_host", } as const; diff --git a/packages/protocol/src/messages.provider-subagents.test.ts b/packages/protocol/src/messages.provider-subagents.test.ts new file mode 100644 index 000000000..db75d6a61 --- /dev/null +++ b/packages/protocol/src/messages.provider-subagents.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, test } from "vitest"; +import { SessionInboundMessageSchema, SessionOutboundMessageSchema } from "./messages.js"; + +describe("provider subagent protocol", () => { + test("accepts a scoped timeline request and structured live update", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "agent.provider_subagents.timeline.get.request", + parentAgentId: "parent-1", + subagentId: "child-1", + requestId: "request-1", + }), + ).toMatchObject({ parentAgentId: "parent-1", subagentId: "child-1" }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.provider_subagents.update", + payload: { + kind: "timeline", + parentAgentId: "parent-1", + subagentId: "child-1", + provider: "claude", + epoch: "epoch-1", + seq: 4, + timestamp: "2026-07-12T10:00:00.000Z", + item: { type: "assistant_message", text: "Found it." }, + }, + }), + ).toMatchObject({ + payload: { + kind: "timeline", + parentAgentId: "parent-1", + subagentId: "child-1", + seq: 4, + }, + }); + }); + + test("accepts a provider child working directory while remaining compatible when absent", () => { + const descriptor = { + id: "child-1", + parentAgentId: "parent-1", + provider: "opencode", + title: "Explore", + description: null, + status: "running", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:00.000Z", + toolCallId: null, + }; + + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.provider_subagents.list.response", + payload: { + requestId: "request-1", + parentAgentId: "parent-1", + subagents: [{ ...descriptor, cwd: "/workspace/child" }], + error: null, + }, + }), + ).toMatchObject({ payload: { subagents: [{ cwd: "/workspace/child" }] } }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.provider_subagents.list.response", + payload: { + requestId: "request-2", + parentAgentId: "parent-1", + subagents: [descriptor], + error: null, + }, + }), + ).toMatchObject({ payload: { subagents: [{ id: "child-1" }] } }); + }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index f4d89490f..c19c9af8b 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1295,6 +1295,22 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({ projection: z.enum(["projected", "canonical"]).optional(), }); +export const ProviderSubagentListRequestMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.list.request"), + parentAgentId: z.string(), + requestId: z.string(), +}); + +export const ProviderSubagentTimelineRequestMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.timeline.get.request"), + parentAgentId: z.string(), + subagentId: z.string(), + requestId: z.string(), + direction: z.enum(["tail", "before", "after"]).optional(), + cursor: AgentTimelineCursorSchema.optional(), + limit: z.number().int().nonnegative().optional(), +}); + export const AgentForkContextRequestMessageSchema = z.object({ type: z.literal("agent.fork_context.request"), agentId: z.string(), @@ -2089,6 +2105,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ RestartServerRequestMessageSchema, DaemonUpdateRequestMessageSchema, FetchAgentTimelineRequestMessageSchema, + ProviderSubagentListRequestMessageSchema, + ProviderSubagentTimelineRequestMessageSchema, AgentForkContextRequestMessageSchema, SetAgentModeRequestMessageSchema, SetAgentModelRequestMessageSchema, @@ -2367,6 +2385,8 @@ export const ServerInfoStatusPayloadSchema = z daemonSelfUpdate: z.boolean().optional(), // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: z.boolean().optional(), + // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. + providerSubagents: z.boolean().optional(), }) .optional(), }) @@ -2958,6 +2978,88 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({ }), }); +export const ProviderSubagentDescriptorPayloadSchema = z.object({ + id: z.string(), + parentAgentId: z.string(), + provider: AgentProviderSchema, + title: z.string().nullable(), + description: z.string().nullable(), + status: z.enum(["running", "completed", "failed", "canceled"]), + createdAt: z.string(), + updatedAt: z.string(), + toolCallId: z.string().nullable(), + cwd: z.string().nullable().optional(), +}); + +export type ProviderSubagentDescriptorPayload = z.infer< + typeof ProviderSubagentDescriptorPayloadSchema +>; + +export const ProviderSubagentListResponseMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.list.response"), + payload: z.object({ + requestId: z.string(), + parentAgentId: z.string(), + subagents: z.array(ProviderSubagentDescriptorPayloadSchema), + error: z.string().nullable(), + }), +}); + +export const ProviderSubagentTimelineResponseMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.timeline.get.response"), + payload: z.object({ + requestId: z.string(), + parentAgentId: z.string(), + subagentId: z.string(), + provider: AgentProviderSchema.nullable(), + direction: z.enum(["tail", "before", "after"]), + epoch: z.string(), + reset: z.boolean(), + staleCursor: z.boolean(), + gap: z.boolean(), + window: z.object({ + minSeq: z.number().int().nonnegative(), + maxSeq: z.number().int().nonnegative(), + nextSeq: z.number().int().nonnegative(), + }), + hasOlder: z.boolean(), + hasNewer: z.boolean(), + rows: z.array( + z.object({ + item: AgentTimelineItemPayloadSchema, + timestamp: z.string(), + seq: z.number().int().nonnegative(), + }), + ), + error: z.string().nullable(), + }), +}); + +export const ProviderSubagentUpdateMessageSchema = z.object({ + type: z.literal("agent.provider_subagents.update"), + payload: z.discriminatedUnion("kind", [ + z.object({ + kind: z.literal("upsert"), + subagent: ProviderSubagentDescriptorPayloadSchema, + }), + z.object({ + kind: z.literal("timeline"), + parentAgentId: z.string(), + subagentId: z.string(), + provider: AgentProviderSchema, + item: AgentTimelineItemPayloadSchema, + timestamp: z.string(), + seq: z.number().int().nonnegative(), + epoch: z.string(), + }), + z.object({ + kind: z.literal("remove"), + parentAgentId: z.string(), + subagentId: z.string(), + }), + ]), +}); + export const AgentForkContextResponseMessageSchema = z.object({ type: z.literal("agent.fork_context.response"), payload: z.object({ @@ -4207,6 +4309,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ArchiveWorkspaceResponseMessageSchema, FetchAgentResponseMessageSchema, FetchAgentTimelineResponseMessageSchema, + ProviderSubagentListResponseMessageSchema, + ProviderSubagentTimelineResponseMessageSchema, + ProviderSubagentUpdateMessageSchema, AgentForkContextResponseMessageSchema, CancelAgentResponseMessageSchema, ClearAgentAttentionResponseMessageSchema, @@ -4679,6 +4784,7 @@ export const WSHelloMessageSchema = z.object({ [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), [CLIENT_CAPS.customModeIcons]: z.boolean().optional(), [CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(), + [CLIENT_CAPS.providerSubagents]: z.boolean().optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), }) .passthrough() diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 64c4689e7..5cbff4b03 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2331,6 +2331,27 @@ test("importProviderSession imports the selected session without listing and pub timestamp: "2026-01-02T00:00:02.000Z", }, ], + providerSubagentEvents: [ + { + type: "provider_subagent" as const, + provider: "codex" as const, + event: { + type: "upsert" as const, + id: "thread-child", + title: "Imported child", + status: "completed" as const, + }, + }, + { + type: "provider_subagent" as const, + provider: "codex" as const, + event: { + type: "timeline" as const, + id: "thread-child", + item: { type: "assistant_message" as const, text: "Child result" }, + }, + }, + ], }; } } @@ -2373,7 +2394,13 @@ test("importProviderSession imports the selected session without listing and pub }, }, ]); - expect(events).toHaveLength(1); + expect(manager.listProviderSubagents(imported.id)).toEqual([ + expect.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }), + ]); + expect(manager.fetchProviderSubagentTimeline(imported.id, "thread-child").rows).toEqual([ + expect.objectContaining({ item: { type: "assistant_message", text: "Child result" } }), + ]); + expect(events).toHaveLength(3); expect(events[0]).toMatchObject({ type: "agent_state", agent: { @@ -2553,6 +2580,150 @@ test("reloadAgentSession preserves timeline and does not force history replay", expect(afterHydrate).toEqual(beforeReload); }); +test("reloadAgentSession clears provider children before rehydrating from disk", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-reload-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let activeSession: TestAgentSession | null = null; + class ProviderChildClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + activeSession = new TestAgentSession(config); + return activeSession; + } + + override async resumeSession( + _handle: AgentPersistenceHandle, + config?: Partial, + ): Promise { + return new TestAgentSession({ + provider: "codex", + cwd: config?.cwd ?? workdir, + }); + } + } + const manager = new AgentManager({ + clients: { codex: new ProviderChildClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000116", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + activeSession?.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { type: "upsert", id: "stale-child", title: "Stale child", status: "running" }, + }); + await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1)); + + await manager.reloadAgentSession(snapshot.id, undefined, { rehydrateFromDisk: true }); + + expect(manager.listProviderSubagents(snapshot.id)).toEqual([]); +}); + +test("hydrateTimelineFromProvider restores and broadcasts provider children from session history", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-history-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class ProviderChildHistorySession extends TestAgentSession { + override async *streamHistory(): AsyncGenerator { + yield { + type: "provider_subagent", + provider: "codex", + event: { + type: "upsert", + id: "restored-child", + title: "Restored child", + status: "completed", + }, + }; + } + } + class ProviderChildHistoryClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + return new ProviderChildHistorySession(config); + } + } + const manager = new AgentManager({ + clients: { codex: new ProviderChildHistoryClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000117", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { + agentId: snapshot.id, + replayState: false, + }); + + await manager.hydrateTimelineFromProvider(snapshot.id, { broadcast: true }); + + expect(manager.listProviderSubagents(snapshot.id)).toEqual([ + expect.objectContaining({ + id: "restored-child", + parentAgentId: snapshot.id, + title: "Restored child", + status: "completed", + }), + ]); + expect(events).toContainEqual({ + type: "provider_subagent", + event: { + type: "upsert", + subagent: expect.objectContaining({ + id: "restored-child", + parentAgentId: snapshot.id, + }), + }, + }); +}); + +test("force provider hydration removes children absent from current history", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-force-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let session: TestAgentSession | null = null; + class ProviderChildForceClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + session = new TestAgentSession(config); + return session; + } + } + const manager = new AgentManager({ + clients: { codex: new ProviderChildForceClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000118", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + session?.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { type: "upsert", id: "removed-by-rewind", status: "completed" }, + }); + await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1)); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { + agentId: snapshot.id, + replayState: false, + }); + + await manager.hydrateTimelineFromProvider(snapshot.id, { force: true, broadcast: true }); + + expect(manager.listProviderSubagents(snapshot.id)).toEqual([]); + expect(events).toContainEqual({ + type: "provider_subagent", + event: { + type: "remove", + parentAgentId: snapshot.id, + subagentId: "removed-by-rewind", + }, + }); +}); + test("reloadAgentSession preserves current title when config title is unset", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-")); const storagePath = join(workdir, "agents"); @@ -5006,6 +5177,67 @@ test("subscribe does not emit state events for internal agents to global subscri expect(receivedEvents.filter((id) => id === generatedAgentIds[1]).length).toBe(0); }); +test("subscribe hides provider subagents of internal parents from global subscribers", async () => { + const internalAgentId = "00000000-0000-4000-8000-000000000117"; + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-internal-provider-child-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const sessionHolder: { current: TestAgentSession | null } = { current: null }; + class InternalProviderChildClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + sessionHolder.current = new TestAgentSession(config); + return sessionHolder.current; + } + } + const manager = new AgentManager({ + clients: { codex: new InternalProviderChildClient() }, + registry: storage, + logger, + idFactory: () => internalAgentId, + }); + const globalEvents: AgentManagerEvent[] = []; + const scopedEvents: AgentManagerEvent[] = []; + manager.subscribe((event) => globalEvents.push(event), { replayState: false }); + await manager.createAgent( + { provider: "codex", cwd: workdir, title: "Internal Agent", internal: true }, + undefined, + { workspaceId: undefined }, + ); + manager.subscribe((event) => scopedEvents.push(event), { + agentId: internalAgentId, + replayState: false, + }); + + sessionHolder.current?.pushEvent({ + type: "provider_subagent", + provider: "codex", + event: { type: "upsert", id: "hidden-child", title: "Hidden child", status: "running" }, + }); + await manager.flush(); + + expect(globalEvents.filter((event) => event.type === "provider_subagent")).toEqual([]); + expect(scopedEvents).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ + type: "upsert", + subagent: expect.objectContaining({ + id: "hidden-child", + parentAgentId: internalAgentId, + }), + }), + }), + ); + expect(() => manager.listProviderSubagents(internalAgentId)).toThrow( + `Unknown agent '${internalAgentId}'`, + ); + expect(() => manager.getProviderSubagent(internalAgentId, "hidden-child")).toThrow( + `Unknown agent '${internalAgentId}'`, + ); + expect(() => manager.fetchProviderSubagentTimeline(internalAgentId, "hidden-child")).toThrow( + `Unknown agent '${internalAgentId}'`, + ); +}); + test("subscribe emits state events for internal agents when subscribed by agentId", async () => { const internalAgentId = "00000000-0000-4000-8000-000000000110"; const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 690cfe2a2..f74bbdc99 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -66,6 +66,11 @@ import { isSystemInjectedEnvelope } from "./agent-prompt.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js"; import type { PaseoToolCatalogFactory } from "./tools/types.js"; +import { + ProviderSubagentStore, + type ProviderSubagentDescriptor, + type ProviderSubagentStoreEvent, +} from "./provider-subagents/store.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; @@ -145,6 +150,7 @@ export type { export type AgentManagerEvent = | { type: "agent_state"; agent: ManagedAgent } + | { type: "provider_subagent"; event: ProviderSubagentStoreEvent } | { type: "agent_stream"; agentId: string; @@ -534,6 +540,7 @@ export class AgentManager { private readonly providerEnabled = new Map(); private readonly agents = new Map(); private readonly timelineStore = new InMemoryAgentTimelineStore(); + private readonly providerSubagents = new ProviderSubagentStore(); private readonly agentsAwaitingInitialSnapshotPersist = new Set(); private readonly sessionEventTails = new Map>(); private readonly foregroundRuns = new ForegroundRunState(); @@ -936,6 +943,28 @@ export class AgentManager { return this.timelineStore.fetch(id, options); } + listProviderSubagents(parentAgentId: string): ProviderSubagentDescriptor[] { + this.requirePublicAgent(parentAgentId); + return this.providerSubagents.list(parentAgentId); + } + + getProviderSubagent( + parentAgentId: string, + subagentId: string, + ): ProviderSubagentDescriptor | null { + this.requirePublicAgent(parentAgentId); + return this.providerSubagents.get(parentAgentId, subagentId); + } + + fetchProviderSubagentTimeline( + parentAgentId: string, + subagentId: string, + options?: AgentTimelineFetchOptions, + ): AgentTimelineFetchResult { + this.requirePublicAgent(parentAgentId); + return this.providerSubagents.fetchTimeline(parentAgentId, subagentId, options); + } + createAgent( config: AgentSessionConfig, agentId: string | undefined, @@ -1086,7 +1115,7 @@ export class AgentManager { const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows); handedToRegistration = true; - return this.registerSession(imported.session, importedConfig, resolvedAgentId, { + const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, { labels: input.labels, workspaceId: input.workspaceId, timelineRows, @@ -1096,6 +1125,11 @@ export class AgentManager { initialTitle, publishWhenReady: true, }); + for (const event of imported.providerSubagentEvents ?? []) { + const update = this.providerSubagents.apply(agent.id, event.provider, event.event); + this.dispatch({ type: "provider_subagent", event: update }); + } + return agent; } finally { if (!handedToRegistration) { await this.closeUnregisteredSession(imported.session); @@ -1168,6 +1202,9 @@ export class AgentManager { // provider history into an empty timeline. await this.deleteCommittedTimeline(agentId); this.timelineStore.delete(agentId); + for (const event of this.providerSubagents.deleteParent(agentId)) { + this.dispatch({ type: "provider_subagent", event }); + } } // Preserve existing labels and timeline during reload. @@ -1261,6 +1298,9 @@ export class AgentManager { const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); await agent.session.close(); this.timelineStore.delete(agentId); + for (const event of this.providerSubagents.deleteParent(agentId)) { + this.dispatch({ type: "provider_subagent", event }); + } await this.persistSnapshot(closedAgent); this.emitClosedAgent(closedAgent, { persist: false }); this.logger.trace( @@ -2837,6 +2877,11 @@ export class AgentManager { agent: ActiveManagedAgent, event: AgentStreamEvent, ): Promise { + if (event.type === "provider_subagent") { + const update = this.providerSubagents.apply(agent.id, event.provider, event.event); + this.dispatch({ type: "provider_subagent", event: update }); + return; + } const turnId = getAgentStreamEventTurnId(event); const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId); this.logger.trace( @@ -2975,49 +3020,79 @@ export class AgentManager { } if (options?.force) { - const historyEvents: Extract[] = []; - for await (const event of agent.session.streamHistory()) { - if (event.type === "timeline") { - if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { - continue; - } - historyEvents.push(event); - } - } - - this.agentStreamCoalescer.flushAndDiscard(agent.id); - await this.deleteCommittedTimeline(agent.id); - this.timelineStore.delete(agent.id); - this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() }); - agent.historyPrimed = true; - - for (const event of historyEvents) { - const item = limitAgentTimelineItemContent(event.item); - const row = this.recordTimeline( - agent.id, - item, - event.timestamp ? { timestamp: event.timestamp } : undefined, - ); - if (options?.broadcast) { - this.dispatchStream( - agent.id, - { ...event, item }, - { - seq: row.seq, - epoch: this.timelineStore.getEpoch(agent.id), - timestamp: row.timestamp, - }, - ); - } - } - this.touchUpdatedAt(agent); - this.emitState(agent); + await this.forceHydrateTimelineFromLegacyProviderHistory(agent, options.broadcast === true); return; } + await this.primeTimelineFromLegacyProviderHistory(agent, options?.broadcast === true); + } + + private async forceHydrateTimelineFromLegacyProviderHistory( + agent: ActiveManagedAgent, + broadcast: boolean, + ): Promise { + const historyEvents: Extract[] = []; + const providerSubagentEvents: Extract[] = []; + for await (const event of agent.session.streamHistory()) { + if (event.type === "timeline") { + if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) { + continue; + } + historyEvents.push(event); + } else if (event.type === "provider_subagent") { + providerSubagentEvents.push(event); + } + } + + this.agentStreamCoalescer.flushAndDiscard(agent.id); + await this.deleteCommittedTimeline(agent.id); + this.timelineStore.delete(agent.id); + this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() }); + agent.historyPrimed = true; + + for (const event of this.providerSubagents.deleteParent(agent.id)) { + if (broadcast) { + this.dispatch({ type: "provider_subagent", event }); + } + } + for (const event of providerSubagentEvents) { + const update = this.providerSubagents.apply(agent.id, event.provider, event.event); + if (broadcast) { + this.dispatch({ type: "provider_subagent", event: update }); + } + } + for (const event of historyEvents) { + const row = this.recordTimeline( + agent.id, + event.item, + event.timestamp ? { timestamp: event.timestamp } : undefined, + ); + if (broadcast) { + this.dispatchStream(agent.id, event, { + seq: row.seq, + epoch: this.timelineStore.getEpoch(agent.id), + timestamp: row.timestamp, + }); + } + } + this.touchUpdatedAt(agent); + this.emitState(agent); + } + + private async primeTimelineFromLegacyProviderHistory( + agent: ActiveManagedAgent, + broadcast: boolean, + ): Promise { agent.historyPrimed = true; try { for await (const event of agent.session.streamHistory()) { + if (event.type === "provider_subagent") { + const update = this.providerSubagents.apply(agent.id, event.provider, event.event); + if (broadcast) { + this.dispatch({ type: "provider_subagent", event: update }); + } + continue; + } if (event.type !== "timeline") { continue; } @@ -3793,22 +3868,35 @@ export class AgentManager { ) { continue; } + if ( + subscriber.agentId && + event.type === "provider_subagent" && + subscriber.agentId !== + (event.event.type === "upsert" + ? event.event.subagent.parentAgentId + : event.event.parentAgentId) + ) { + continue; + } // Skip internal agents for global subscribers (those without a specific agentId) - if (!subscriber.agentId) { - if (event.type === "agent_state" && event.agent.internal) { - continue; - } - if (event.type === "agent_stream") { - const agent = this.agents.get(event.agentId); - if (agent?.internal) { - continue; - } - } + if (!subscriber.agentId && this.eventBelongsToInternalAgent(event)) { + continue; } subscriber.callback(event); } } + private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean { + if (event.type === "agent_state") return event.agent.internal === true; + if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true; + if (event.type !== "provider_subagent") return false; + const parentAgentId = + event.event.type === "upsert" + ? event.event.subagent.parentAgentId + : event.event.parentAgentId; + return this.agents.get(parentAgentId)?.internal === true; + } + private async normalizeConfig( config: AgentSessionConfig, options: NormalizeConfigOptions = {}, @@ -4031,6 +4119,14 @@ export class AgentManager { } return agent; } + + private requirePublicAgent(id: string): LiveManagedAgent { + const agent = this.requireAgent(id); + if (agent.internal) { + throw new Error(`Unknown agent '${agent.id}'`); + } + return agent; + } } export function commandMayHaveChangedExternalState(command: string): boolean { diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 86dddab26..9bfcf03ef 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -426,6 +426,11 @@ export type AgentStreamEvent = provider: AgentProvider; reason: "finished" | "error" | "permission"; timestamp: string; + } + | { + type: "provider_subagent"; + provider: AgentProvider; + event: import("./provider-subagents/store.js").ProviderSubagentInputEvent; }; export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined { @@ -541,6 +546,7 @@ export interface ImportedProviderSession { config: AgentSessionConfig; persistence: AgentPersistenceHandle; timeline: ImportedTimelineEntry[]; + providerSubagentEvents?: Extract[]; } export interface AgentSessionConfig { diff --git a/packages/server/src/server/agent/provider-session-import.ts b/packages/server/src/server/agent/provider-session-import.ts index 98311e6c3..b09a5ab87 100644 --- a/packages/server/src/server/agent/provider-session-import.ts +++ b/packages/server/src/server/agent/provider-session-import.ts @@ -33,13 +33,14 @@ export async function importSessionFromPersistence(input: { const persistence = input.persistence ?? buildImportPersistenceHandle(input.provider, input.request, storedConfig); const session = await input.resumeSession(persistence, config, input.context.launchContext); - const timeline = await collectImportedTimeline(session.streamHistory()); + const history = await collectImportedHistory(session.streamHistory()); return { session, config: storedConfig, persistence, - timeline, + timeline: history.timeline, + providerSubagentEvents: history.providerSubagentEvents, }; } @@ -60,11 +61,17 @@ function buildImportPersistenceHandle( }; } -async function collectImportedTimeline( - events: AsyncGenerator, -): Promise { +async function collectImportedHistory(events: AsyncGenerator): Promise<{ + timeline: ImportedTimelineEntry[]; + providerSubagentEvents: Extract[]; +}> { const timeline: ImportedTimelineEntry[] = []; + const providerSubagentEvents: Extract[] = []; for await (const event of events) { + if (event.type === "provider_subagent") { + providerSubagentEvents.push(event); + continue; + } if (event.type !== "timeline") { continue; } @@ -73,5 +80,5 @@ async function collectImportedTimeline( ...(event.timestamp ? { timestamp: event.timestamp } : {}), }); } - return timeline; + return { timeline, providerSubagentEvents }; } diff --git a/packages/server/src/server/agent/provider-subagents/store.test.ts b/packages/server/src/server/agent/provider-subagents/store.test.ts new file mode 100644 index 000000000..ba1f3b527 --- /dev/null +++ b/packages/server/src/server/agent/provider-subagents/store.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "vitest"; +import { ProviderSubagentStore } from "./store.js"; + +describe("ProviderSubagentStore", () => { + test("keeps provider children and their timelines scoped to the parent agent", () => { + const subagents = new ProviderSubagentStore(); + + subagents.apply("parent-a", "codex", { + type: "upsert", + id: "child-1", + title: "Explore", + cwd: "/workspace/child", + status: "running", + timestamp: "2026-07-12T10:00:00.000Z", + }); + subagents.apply("parent-a", "codex", { + type: "timeline", + id: "child-1", + item: { type: "assistant_message", text: "Found it." }, + timestamp: "2026-07-12T10:00:01.000Z", + }); + subagents.apply("parent-a", "codex", { + type: "upsert", + id: "child-1", + status: "completed", + timestamp: "2026-07-12T10:00:02.000Z", + }); + subagents.apply("parent-b", "claude", { + type: "upsert", + id: "child-1", + title: "Review", + status: "running", + timestamp: "2026-07-12T10:00:03.000Z", + }); + + expect(subagents.list("parent-a")).toEqual([ + expect.objectContaining({ + id: "child-1", + parentAgentId: "parent-a", + provider: "codex", + title: "Explore", + cwd: "/workspace/child", + status: "completed", + createdAt: "2026-07-12T10:00:00.000Z", + updatedAt: "2026-07-12T10:00:02.000Z", + }), + ]); + expect(subagents.fetchTimeline("parent-a", "child-1").rows).toEqual([ + { + seq: 1, + timestamp: "2026-07-12T10:00:01.000Z", + item: { type: "assistant_message", text: "Found it." }, + }, + ]); + expect(subagents.list("parent-b")[0]).toMatchObject({ provider: "claude", title: "Review" }); + expect(subagents.deleteParent("parent-a")).toEqual([ + { type: "remove", parentAgentId: "parent-a", subagentId: "child-1" }, + ]); + expect(subagents.list("parent-a")).toEqual([]); + expect(subagents.list("parent-b")).toHaveLength(1); + }); + + test("limits oversized provider child tool output before storage", () => { + const subagents = new ProviderSubagentStore(); + const output = "x".repeat(70 * 1024); + const update = subagents.apply("parent-a", "opencode", { + type: "timeline", + id: "child-1", + item: { + type: "tool_call", + callId: "call-1", + name: "shell", + status: "completed", + error: null, + detail: { type: "shell", command: "print", output }, + }, + }); + + expect(update.type).toBe("timeline"); + const [row] = subagents.fetchTimeline("parent-a", "child-1").rows; + expect(row?.item).toMatchObject({ + type: "tool_call", + detail: { type: "shell", output: "x".repeat(64 * 1024) }, + }); + }); + + test("pages provider history on projected item boundaries", () => { + const subagents = new ProviderSubagentStore(); + for (let index = 0; index < 101; index += 1) { + subagents.apply("parent-a", "opencode", { + type: "timeline", + id: "child-1", + item: { type: "assistant_message", text: String(index) }, + }); + } + + const page = subagents.fetchTimeline("parent-a", "child-1", { + direction: "tail", + limit: 1, + }); + expect(page.rows).toHaveLength(101); + expect(page.rows[0]?.seq).toBe(1); + expect(page.rows.at(-1)?.seq).toBe(101); + expect(page.hasOlder).toBe(false); + }); +}); diff --git a/packages/server/src/server/agent/provider-subagents/store.ts b/packages/server/src/server/agent/provider-subagents/store.ts new file mode 100644 index 000000000..0d05e6426 --- /dev/null +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -0,0 +1,168 @@ +import type { AgentProvider, AgentTimelineItem } from "../agent-sdk-types.js"; +import { limitAgentTimelineItemContent } from "../agent-timeline-content.js"; +import { InMemoryAgentTimelineStore } from "../agent-timeline-store.js"; +import type { + AgentTimelineFetchOptions, + AgentTimelineFetchResult, + AgentTimelineRow, +} from "../agent-timeline-store-types.js"; +import { selectTimelineWindowByProjectedLimit } from "../timeline-projection.js"; + +export type ProviderSubagentStatus = "running" | "completed" | "failed" | "canceled"; + +export interface ProviderSubagentDescriptor { + id: string; + parentAgentId: string; + provider: AgentProvider; + title: string | null; + description: string | null; + status: ProviderSubagentStatus; + createdAt: string; + updatedAt: string; + toolCallId: string | null; + cwd: string | null; +} + +export type ProviderSubagentInputEvent = + | { + type: "upsert"; + id: string; + title?: string | null; + description?: string | null; + status: ProviderSubagentStatus; + toolCallId?: string | null; + cwd?: string | null; + timestamp?: string; + } + | { + type: "timeline"; + id: string; + item: AgentTimelineItem; + timestamp?: string; + } + | { type: "remove"; id: string }; + +export type ProviderSubagentStoreEvent = + | { type: "upsert"; subagent: ProviderSubagentDescriptor } + | { + type: "timeline"; + parentAgentId: string; + subagentId: string; + provider: AgentProvider; + row: AgentTimelineRow; + epoch: string; + } + | { type: "remove"; parentAgentId: string; subagentId: string }; + +function storeKey(parentAgentId: string, subagentId: string): string { + return `${parentAgentId}\0${subagentId}`; +} + +export class ProviderSubagentStore { + private readonly descriptors = new Map(); + private readonly timelines = new InMemoryAgentTimelineStore(); + + apply( + parentAgentId: string, + provider: AgentProvider, + event: ProviderSubagentInputEvent, + ): ProviderSubagentStoreEvent { + const key = storeKey(parentAgentId, event.id); + if (event.type === "remove") { + this.descriptors.delete(key); + this.timelines.delete(key); + return { type: "remove", parentAgentId, subagentId: event.id }; + } + + if (event.type === "timeline") { + if (!this.timelines.has(key)) { + this.timelines.initialize(key); + } + const row = this.timelines.append(key, limitAgentTimelineItemContent(event.item), { + timestamp: event.timestamp, + }); + return { + type: "timeline", + parentAgentId, + subagentId: event.id, + provider, + row, + epoch: this.timelines.getEpoch(key), + }; + } + + const previous = this.descriptors.get(key); + if (!this.timelines.has(key)) { + this.timelines.initialize(key); + } + const timestamp = event.timestamp ?? new Date().toISOString(); + const subagent: ProviderSubagentDescriptor = { + id: event.id, + parentAgentId, + provider, + title: event.title === undefined ? (previous?.title ?? null) : event.title, + description: + event.description === undefined ? (previous?.description ?? null) : event.description, + status: event.status, + createdAt: previous?.createdAt ?? timestamp, + updatedAt: timestamp, + toolCallId: + event.toolCallId === undefined ? (previous?.toolCallId ?? null) : event.toolCallId, + cwd: event.cwd === undefined ? (previous?.cwd ?? null) : event.cwd, + }; + this.descriptors.set(key, subagent); + return { type: "upsert", subagent }; + } + + list(parentAgentId: string): ProviderSubagentDescriptor[] { + return [...this.descriptors.values()] + .filter((subagent) => subagent.parentAgentId === parentAgentId) + .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); + } + + get(parentAgentId: string, subagentId: string): ProviderSubagentDescriptor | null { + return this.descriptors.get(storeKey(parentAgentId, subagentId)) ?? null; + } + + fetchTimeline( + parentAgentId: string, + subagentId: string, + options?: AgentTimelineFetchOptions, + ): AgentTimelineFetchResult { + const direction = options?.direction ?? "tail"; + const limit = options?.limit === undefined ? 200 : Math.max(0, Math.floor(options.limit)); + const timeline = this.timelines.fetch(storeKey(parentAgentId, subagentId), { + ...options, + limit: 0, + }); + if (limit === 0 || timeline.rows.length === 0) { + return timeline; + } + const selected = selectTimelineWindowByProjectedLimit({ + rows: timeline.rows, + direction: timeline.reset ? "tail" : direction, + limit, + }); + const firstRow = selected.selectedRows[0]; + const lastRow = selected.selectedRows[selected.selectedRows.length - 1]; + return { + ...timeline, + rows: selected.selectedRows, + hasOlder: + timeline.hasOlder || (firstRow !== undefined && firstRow.seq > timeline.window.minSeq), + hasNewer: + timeline.hasNewer || (lastRow !== undefined && lastRow.seq < timeline.window.maxSeq), + }; + } + + deleteParent(parentAgentId: string): ProviderSubagentStoreEvent[] { + const events: ProviderSubagentStoreEvent[] = []; + for (const subagent of this.list(parentAgentId)) { + const key = storeKey(parentAgentId, subagent.id); + this.descriptors.delete(key); + this.timelines.delete(key); + events.push({ type: "remove", parentAgentId, subagentId: subagent.id }); + } + return events; + } +} diff --git a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts index a7b8e0fe7..07380ca38 100644 --- a/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.sub-agent-sidechain.test.ts @@ -211,6 +211,21 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { parent_tool_use_id: "task-call-1", elapsed_time_seconds: 1, }, + { + type: "user", + parent_tool_use_id: "task-call-1", + message: { + content: [ + { + type: "tool_result", + tool_use_id: "sub-read-1", + tool_name: "Read", + content: "README contents", + is_error: false, + }, + ], + }, + }, { type: "assistant", parent_tool_use_id: "task-call-1", @@ -307,6 +322,44 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { ); expect(projectedTaskCalls).toHaveLength(1); + + const providerEvents = events.flatMap((event) => + event.type === "provider_subagent" ? [event.event] : [], + ); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "task-call-1", + item: expect.objectContaining({ + type: "tool_call", + callId: "sub-read-1", + status: "running", + }), + }); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "task-call-1", + item: expect.objectContaining({ + type: "tool_call", + callId: "sub-read-1", + status: "completed", + }), + }); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "task-call-1", + item: { + type: "assistant_message", + messageId: "subagent-message-1", + text: "Sub-agent narration belongs inside the Task row, not the parent transcript.", + }, + }); + expect(providerEvents.at(-1)).toMatchObject({ + type: "upsert", + id: "task-call-1", + title: "Explore", + description: "Inspect repository structure", + status: "completed", + }); }); test("keeps sidechain assistant text out of the parent transcript", async () => { @@ -349,6 +402,46 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => { }); }); + test("keeps a failed Task subagent failed when the parent turn succeeds", async () => { + const failedEvents = buildTailScenarioEvents(1); + const taskResult = failedEvents.find( + (event) => + typeof event === "object" && + event !== null && + "type" in event && + event.type === "assistant", + ) as { message: Record } | undefined; + if (!taskResult) throw new Error("expected Task result fixture"); + taskResult.message = { + ...taskResult.message, + content: [ + { + type: "tool_result", + tool_use_id: "task-tail-1", + tool_name: "Task", + content: "failed", + is_error: true, + }, + ], + }; + queryFactory.mockImplementation(() => buildQueryMock(failedEvents)); + const session = await new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }).createSession({ provider: "claude", cwd: process.cwd() }); + + const events = await collectUntilTerminal(streamSession(session, "delegate work")); + await session.close(); + + expect( + events + .filter((event) => event.type === "provider_subagent") + .map((event) => event.event) + .at(-1), + ).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" }); + }); + test("tails sub-agent actions instead of dropping latest entries at cap", async () => { queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 4b2d63627..a2dde63a6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1897,6 +1897,10 @@ class ClaudeAgentSession implements AgentSession { getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, }); private persistedHistory: PersistedTimelineEntry[] = []; + private persistedProviderSubagentEvents: Extract< + AgentStreamEvent, + { type: "provider_subagent" } + >[] = []; private historyPending = false; private turnState: TurnState = "idle"; private nextTurnOrdinal = 1; @@ -2117,11 +2121,16 @@ class ClaudeAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { - if (!this.historyPending || this.persistedHistory.length === 0) { + if ( + !this.historyPending || + (this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0) + ) { return; } const history = this.persistedHistory; + const providerSubagentEvents = this.persistedProviderSubagentEvents; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; for (const entry of history) { yield { @@ -2131,6 +2140,7 @@ class ClaudeAgentSession implements AgentSession { timestamp: entry.timestamp, }; } + yield* providerSubagentEvents; } async getAvailableModes(): Promise { @@ -2613,6 +2623,7 @@ class ClaudeAgentSession implements AgentSession { this.cachedRuntimeInfo = null; this.queryRestartNeeded = true; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; this.userMessageIds = []; this.emittedUserMessageIds.clear(); @@ -2642,6 +2653,7 @@ class ClaudeAgentSession implements AgentSession { this.cachedRuntimeInfo = null; this.queryRestartNeeded = true; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; this.userMessageIds = []; this.emittedUserMessageIds.clear(); @@ -3539,6 +3551,7 @@ class ClaudeAgentSession implements AgentSession { } this.persistence = null; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; this.cachedRuntimeInfo = null; this.queryRestartNeeded = false; @@ -3610,6 +3623,7 @@ class ClaudeAgentSession implements AgentSession { break; case "user": this.appendUserMessageEvents(message, events); + this.appendSidechainResultEvents(message, events); break; case "assistant": { const timelineItems = this.mapBlocksToTimeline(message.message.content, { @@ -3619,6 +3633,7 @@ class ClaudeAgentSession implements AgentSession { for (const item of timelineItems) { events.push({ type: "timeline", item, provider: "claude" }); } + this.appendSidechainResultEvents(message, events); break; } case "stream_event": @@ -3634,6 +3649,18 @@ class ClaudeAgentSession implements AgentSession { return events; } + private appendSidechainResultEvents(message: SDKMessage, events: AgentStreamEvent[]): void { + const content = toObjectRecord(toObjectRecord(message)?.message)?.content; + if (!Array.isArray(content)) return; + for (const block of content) { + const chunk = toObjectRecord(block); + if (chunk?.type !== "tool_result" || typeof chunk.tool_use_id !== "string") continue; + events.push( + ...this.sidechainTracker.finish(chunk.tool_use_id, chunk.is_error ? "failed" : "completed"), + ); + } + } + private emitSubmittedUserMessage( message: Extract, turnId: string, @@ -3830,6 +3857,7 @@ class ClaudeAgentSession implements AgentSession { ): void { const usage = this.convertUsage(message, message.modelUsage); if (message.subtype === "success") { + events.push(...this.sidechainTracker.finishAll("completed")); // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") // run client-side in the Claude CLI with no model turn — output_tokens // is 0 and the user-visible text is carried in `result`. Surface it only @@ -3855,6 +3883,7 @@ class ClaudeAgentSession implements AgentSession { "errors" in message && Array.isArray(message.errors) && message.errors.length > 0 ? message.errors.join("\n") : "Claude run failed"; + events.push(...this.sidechainTracker.finishAll("failed")); events.push(this.buildTurnFailedEvent(errorMessage)); } @@ -4166,7 +4195,9 @@ class ClaudeAgentSession implements AgentSession { if (!historyPath || !fs.existsSync(historyPath)) { return; } - this.ingestPersistedHistory(fs.readFileSync(historyPath, "utf8")); + const content = fs.readFileSync(historyPath, "utf8"); + this.ingestPersistedHistory(content); + this.ingestPersistedSidechains(content, readClaudeSidechainHistory(historyPath)); } catch { // ignore history load failures } @@ -4188,6 +4219,24 @@ class ClaudeAgentSession implements AgentSession { } } + private ingestPersistedSidechains(parentContent: string, sidechainContents: string[]): void { + const parentEntries = parseClaudeHistoryRecords(parentContent).filter( + (entry) => entry.isSidechain !== true, + ); + const sidechainEntries = [parentContent, ...sidechainContents] + .flatMap(parseClaudeHistoryRecords) + .filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string"); + if (sidechainEntries.length === 0) { + return; + } + this.persistedProviderSubagentEvents.push( + ...buildClaudePersistedSidechainEvents(parentEntries, sidechainEntries, (entry) => + this.convertHistoryEntry(entry), + ), + ); + this.historyPending = true; + } + private ingestPersistedHistoryLine(line: string, timeline: PersistedTimelineEntry[]): void { const trimmed = line.trim(); if (!trimmed) { @@ -4465,7 +4514,6 @@ class ClaudeAgentSession implements AgentSession { if (typeof block.tool_use_id === "string") { this.toolUseCache.delete(block.tool_use_id); - this.sidechainTracker.delete(block.tool_use_id); } } @@ -4947,11 +4995,194 @@ function normalizeHistoryBlocks(content: unknown): ClaudeContentChunk[] | null { return null; } +function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] { + const entries: ClaudeHistoryEntry[] = []; + for (const line of content.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const entry = toObjectRecord(JSON.parse(trimmed)); + if (entry) entries.push(entry); + } catch { + // Ignore individual corrupt history rows, matching the parent history replay behavior. + } + } + return entries; +} + +function readClaudeSidechainHistory(historyPath: string): string[] { + const sessionDirectory = path.join( + path.dirname(historyPath), + path.basename(historyPath, ".jsonl"), + ); + const sidechainDirectory = path.join(sessionDirectory, "subagents"); + if (!fs.existsSync(sidechainDirectory)) return []; + + const contents: string[] = []; + const directories = [sidechainDirectory]; + while (directories.length > 0) { + const directory = directories.pop(); + if (!directory) continue; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + directories.push(entryPath); + } else if (entry.isFile() && entry.name.endsWith(".jsonl")) { + contents.push(fs.readFileSync(entryPath, "utf8")); + } + } + } + return contents; +} + +interface ClaudeHistoricalSubagentToolCall { + subagentType?: string; + description?: string; +} + +function readClaudeHistoricalSubagentToolCalls( + entries: ClaudeHistoryEntry[], +): Map { + const toolCalls = new Map(); + for (const entry of entries) { + const content = toObjectRecord(entry.message)?.content; + if (!Array.isArray(content)) continue; + for (const value of content) { + const block = toObjectRecord(value); + if ( + block?.type !== "tool_use" || + (block.name !== "Task" && block.name !== "Agent") || + typeof block.id !== "string" + ) { + continue; + } + const input = toObjectRecord(block.input); + const subagentType = readNonEmptyString(input?.subagent_type); + const description = readNonEmptyString(input?.description); + toolCalls.set(block.id, { + ...(subagentType ? { subagentType } : {}), + ...(description ? { description } : {}), + }); + } + } + return toolCalls; +} + +function readClaudeHistoricalSubagentToolResults( + entries: ClaudeHistoryEntry[], +): Map { + const results = new Map(); + for (const entry of entries) { + const content = toObjectRecord(entry.message)?.content; + if (!Array.isArray(content)) continue; + for (const value of content) { + const block = toObjectRecord(value); + if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue; + const match = /agentId:\s*([\w-]+)/.exec(JSON.stringify(block.content)); + if (!match?.[1]) continue; + results.set(match[1], { toolCallId: block.tool_use_id, failed: block.is_error === true }); + } + } + return results; +} + +function groupClaudeSidechainEntries( + entries: ClaudeHistoryEntry[], +): Map { + const entriesByAgentId = new Map(); + for (const entry of entries) { + if (typeof entry.agentId !== "string") continue; + const grouped = entriesByAgentId.get(entry.agentId) ?? []; + grouped.push(entry); + entriesByAgentId.set(entry.agentId, grouped); + } + return entriesByAgentId; +} + +function buildClaudePersistedSidechainEvents( + parentEntries: ClaudeHistoryEntry[], + sidechainEntries: ClaudeHistoryEntry[], + convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[], +): Extract[] { + const events: Extract[] = []; + const toolCalls = readClaudeHistoricalSubagentToolCalls(parentEntries); + const toolResults = readClaudeHistoricalSubagentToolResults(parentEntries); + for (const [agentId, entries] of groupClaudeSidechainEntries(sidechainEntries)) { + events.push( + ...buildClaudePersistedSidechainAgentEvents( + agentId, + entries, + toolCalls, + toolResults, + convertEntry, + ), + ); + } + return events; +} + +function buildClaudePersistedSidechainAgentEvents( + agentId: string, + entries: ClaudeHistoryEntry[], + toolCalls: ReadonlyMap, + toolResults: ReadonlyMap, + convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[], +): Extract[] { + const result = toolResults.get(agentId); + const id = result?.toolCallId ?? agentId; + const toolCall = result ? toolCalls.get(result.toolCallId) : undefined; + const firstTimestamp = normalizeProviderReplayTimestamp(entries[0]?.timestamp); + const events: Extract[] = [ + { + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id, + title: toolCall?.subagentType ?? "Claude subagent", + description: toolCall?.description ?? null, + status: "running", + toolCallId: result?.toolCallId ?? null, + ...(firstTimestamp ? { timestamp: firstTimestamp } : {}), + }, + }, + ]; + for (const entry of entries) { + const timestamp = normalizeProviderReplayTimestamp(entry.timestamp); + for (const item of convertEntry(entry)) { + events.push({ + type: "provider_subagent", + provider: "claude", + event: { + type: "timeline", + id, + item, + ...(timestamp ? { timestamp } : {}), + }, + }); + } + } + const lastTimestamp = normalizeProviderReplayTimestamp(entries.at(-1)?.timestamp); + events.push({ + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id, + status: result?.failed ? "failed" : "completed", + ...(lastTimestamp ? { timestamp: lastTimestamp } : {}), + }, + }); + return events; +} + interface ClaudeHistoryEntry { type?: unknown; subtype?: unknown; isCompactSummary?: unknown; isSidechain?: unknown; + agentId?: unknown; + timestamp?: unknown; uuid?: unknown; message?: { content?: unknown; [key: string]: unknown }; [key: string]: unknown; diff --git a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts index 919c4256c..a9d852a45 100644 --- a/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.voice-history-regression.test.ts @@ -15,6 +15,7 @@ let lastQuery: ReturnType | null = null; const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; +const HISTORY_SIDECHAIN_MARKER = "HISTORY_ONLY_SIDECHAIN_MARKER"; function buildSdkQueryMock() { const events = [ @@ -126,6 +127,52 @@ describe("ClaudeAgentSession history replay regression", () => { content: HISTORY_ASSISTANT_MARKER, }, }), + JSON.stringify({ + type: "assistant", + uuid: "history-task-call-message", + sessionId: "history-session", + cwd, + message: { + role: "assistant", + content: [ + { + type: "tool_use", + id: "history-task-call", + name: "Agent", + input: { description: "Inspect persisted history" }, + }, + ], + }, + }), + JSON.stringify({ + type: "assistant", + isSidechain: true, + agentId: "history-child", + uuid: "history-child-message", + timestamp: "2026-07-12T10:00:01.000Z", + sessionId: "history-session", + cwd, + message: { + id: "history-child-message", + role: "assistant", + content: [{ type: "text", text: HISTORY_SIDECHAIN_MARKER }], + }, + }), + JSON.stringify({ + type: "user", + sessionId: "history-session", + cwd, + message: { + role: "user", + content: [ + { + type: "tool_result", + tool_use_id: "history-task-call", + content: "done\nagentId: history-child", + }, + ], + }, + }), ].join("\n"), "utf8", ); @@ -219,6 +266,54 @@ describe("ClaudeAgentSession history replay regression", () => { expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); }); + test("replays persisted sidechains as provider subagent timelines", async () => { + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.resumeSession( + { + provider: "claude", + sessionId: "history-session", + nativeHandle: "history-session", + metadata: { provider: "claude", cwd }, + }, + { cwd }, + ); + const historyEvents: AgentStreamEvent[] = []; + + try { + for await (const event of session.streamHistory()) historyEvents.push(event); + } finally { + await session.close(); + } + + expect(historyEvents).toContainEqual({ + type: "provider_subagent", + provider: "claude", + event: { + type: "timeline", + id: "history-task-call", + item: { + type: "assistant_message", + text: HISTORY_SIDECHAIN_MARKER, + messageId: "history-child-message", + }, + timestamp: "2026-07-12T10:00:01.000Z", + }, + }); + expect(historyEvents).toContainEqual({ + type: "provider_subagent", + provider: "claude", + event: expect.objectContaining({ + type: "upsert", + id: "history-task-call", + status: "completed", + }), + }); + }); + test("listCommands includes rewind command", async () => { const logger = createTestLogger(); const client = new ClaudeAgentClient({ diff --git a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts index d1634117d..aef9e91b6 100644 --- a/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts +++ b/packages/server/src/server/agent/providers/claude/sidechain-tracker.ts @@ -1,6 +1,10 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; -import { mapClaudeRunningToolCall } from "./tool-call-mapper.js"; +import { + mapClaudeCompletedToolCall, + mapClaudeFailedToolCall, + mapClaudeRunningToolCall, +} from "./tool-call-mapper.js"; import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; @@ -13,6 +17,7 @@ interface ClaudeContentChunk { interface SubAgentActionEntry { index: number; toolName: string; + input: unknown; summary?: string; } @@ -23,6 +28,7 @@ interface SubAgentActivityState { actionKeys: string[]; nextActionIndex: number; actionIndexByKey: Map; + completedActionKeys: Set; } interface SubAgentActionCandidate { @@ -64,19 +70,34 @@ export class ClaudeSidechainTracker { actionKeys: [], nextActionIndex: 1, actionIndexByKey: new Map(), + completedActionKeys: new Set(), } satisfies SubAgentActivityState); this.activeSidechains.set(parentToolUseId, state); const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); const actionCandidates = this.extractSubAgentActionCandidates(message); + const childTimelineItems = [ + ...this.extractSubAgentTimelineItems(message), + ...this.extractSubAgentToolResults(message, state), + ]; let actionUpdated = false; for (const action of actionCandidates) { + if (state.completedActionKeys.has(action.key)) continue; if (this.appendSubAgentAction(state, action)) { actionUpdated = true; + const toolCall = mapClaudeRunningToolCall({ + name: action.toolName, + callId: action.key, + input: action.input, + output: null, + }); + if (toolCall) { + childTimelineItems.push(toolCall); + } } } - if (!contextUpdated && !actionUpdated) { + if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) { return []; } @@ -103,6 +124,25 @@ export class ClaudeSidechainTracker { }; return [ + { + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id: parentToolUseId, + title: state.subAgentType ?? "Claude subagent", + description: state.description ?? null, + status: "running", + toolCallId: parentToolUseId, + }, + }, + ...childTimelineItems.map( + (item): AgentStreamEvent => ({ + type: "provider_subagent", + provider: "claude", + event: { type: "timeline", id: parentToolUseId, item }, + }), + ), { type: "timeline", item: { @@ -114,6 +154,46 @@ export class ClaudeSidechainTracker { ]; } + finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] { + const events: AgentStreamEvent[] = []; + for (const [id, state] of this.activeSidechains) { + events.push({ + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id, + title: state.subAgentType ?? "Claude subagent", + description: state.description ?? null, + status, + toolCallId: id, + }, + }); + } + this.activeSidechains.clear(); + return events; + } + + finish(id: string, status: "completed" | "failed" | "canceled"): AgentStreamEvent[] { + const state = this.activeSidechains.get(id); + if (!state) return []; + this.activeSidechains.delete(id); + return [ + { + type: "provider_subagent", + provider: "claude", + event: { + type: "upsert", + id, + title: state.subAgentType ?? "Claude subagent", + description: state.description ?? null, + status, + toolCallId: id, + }, + }, + ]; + } + delete(toolUseId: string): void { this.activeSidechains.delete(toolUseId); } @@ -122,6 +202,66 @@ export class ClaudeSidechainTracker { this.activeSidechains.clear(); } + private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] { + if (message.type !== "assistant" || !Array.isArray(message.message?.content)) { + return []; + } + const messageId = readTrimmedString(message.message.id); + const items: AgentTimelineItem[] = []; + for (const block of message.message.content) { + if (!isClaudeContentChunk(block)) continue; + if (block.type === "text") { + const text = readTrimmedString(block.text); + if (text) { + items.push({ + type: "assistant_message", + text, + ...(messageId ? { messageId } : {}), + }); + } + } else if (block.type === "thinking") { + const text = readTrimmedString(block.thinking); + if (text) items.push({ type: "reasoning", text }); + } + } + return items; + } + + private extractSubAgentToolResults( + message: SDKMessage, + state: SubAgentActivityState, + ): AgentTimelineItem[] { + const messageRecord = message as unknown as Record; + const messageContainer = messageRecord.message as Record | undefined; + const content = messageContainer?.content; + if (!Array.isArray(content)) return []; + + const items: AgentTimelineItem[] = []; + for (const block of content) { + if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue; + const callId = readTrimmedString(block.tool_use_id); + if (!callId || state.completedActionKeys.has(callId)) continue; + const actionIndex = state.actionIndexByKey.get(callId); + const action = actionIndex === undefined ? undefined : state.actions[actionIndex]; + const toolName = action?.toolName ?? readTrimmedString(block.tool_name); + if (!toolName) continue; + const params = { + name: toolName, + callId, + input: action?.input ?? null, + output: block.content ?? null, + }; + const toolCall = block.is_error + ? mapClaudeFailedToolCall({ ...params, error: block }) + : mapClaudeCompletedToolCall(params); + if (toolCall) { + state.completedActionKeys.add(callId); + items.push(toolCall); + } + } + return items; + } + private updateSubAgentContextFromTaskInput( state: SubAgentActivityState, parentToolUseId: string, @@ -259,6 +399,7 @@ export class ClaudeSidechainTracker { state.actions[existingIndex] = { ...existing, toolName: normalizedToolName, + input: existing.input ?? candidate.input, ...(nextSummary ? { summary: nextSummary } : {}), }; return true; @@ -267,6 +408,7 @@ export class ClaudeSidechainTracker { state.actions.push({ index: state.nextActionIndex, toolName: normalizedToolName, + input: candidate.input, ...(summary ? { summary } : {}), }); state.nextActionIndex += 1; @@ -279,7 +421,8 @@ export class ClaudeSidechainTracker { private trimSubAgentTail(state: SubAgentActivityState): void { while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) { state.actions.shift(); - state.actionKeys.shift(); + const removedKey = state.actionKeys.shift(); + if (removedKey) state.completedActionKeys.delete(removedKey); } } diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index cc4c00929..5522feaa8 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -1567,7 +1567,7 @@ describe("Codex app-server provider", () => { asInternals(session).handleNotification("item/agentMessage/delta", { threadId: "child-thread-1", itemId: "child-message-1", - delta: "Found the path.", + delta: "Found", }); asInternals(session).handleNotification("item/completed", { threadId: "child-thread-1", @@ -1599,6 +1599,128 @@ describe("Codex app-server provider", () => { actions: [], }, }); + + const providerEvents = events.flatMap((event) => + event.type === "provider_subagent" ? [event.event] : [], + ); + expect(providerEvents).toContainEqual( + expect.objectContaining({ + type: "upsert", + id: "child-thread-1", + description: "Report findings.", + }), + ); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "child-thread-1", + item: { + type: "assistant_message", + messageId: "child-message-1", + text: "Found", + }, + }); + expect(providerEvents).toContainEqual({ + type: "timeline", + id: "child-thread-1", + item: { + type: "assistant_message", + messageId: "child-message-1", + text: " the path.", + }, + }); + expect(providerEvents.at(-1)).toMatchObject({ + type: "upsert", + id: "child-thread-1", + status: "completed", + }); + }); + + test("renders child MCP image results in the provider subagent timeline", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "spawn-image-child", + kind: "started", + agentThreadId: "image-child-thread", + agentPath: "/root/image-child", + }, + }); + + asInternals(session).handleNotification("item/completed", { + threadId: "image-child-thread", + item: { + id: "child-mcp-image", + type: "mcpToolCall", + status: "completed", + server: "paseo", + tool: "browser_screenshot", + arguments: {}, + result: { + content: [{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" }], + }, + }, + }); + + const childItems = events.flatMap((event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "image-child-thread" + ? [event.event.item] + : [], + ); + expect(childItems).toHaveLength(2); + expect(childItems[0]).toMatchObject({ type: "tool_call", callId: "child-mcp-image" }); + expect(childItems[1]).toMatchObject({ type: "assistant_message" }); + if (childItems[1]?.type !== "assistant_message") { + throw new Error("Expected child image markdown"); + } + const source = markdownImageSource(childItems[1].text); + expect(existsSync(source)).toBe(true); + rmSync(source, { force: true }); + }); + + test("renders a child user message once across lifecycle notifications", () => { + const session = createSession(); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + asInternals(session).handleNotification("item/completed", { + threadId: "test-thread", + item: { + type: "subAgentActivity", + id: "spawn-user-child", + kind: "started", + agentThreadId: "user-child-thread", + agentPath: "/root/user-child", + }, + }); + const childUserMessage = { + type: "userMessage", + id: "child-user-message", + content: [{ type: "text", text: "Inspect this path." }], + }; + + asInternals(session).handleNotification("item/started", { + threadId: "user-child-thread", + item: childUserMessage, + }); + asInternals(session).handleNotification("item/completed", { + threadId: "user-child-thread", + item: childUserMessage, + }); + + expect( + events.filter( + (event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.id === "user-child-thread" && + event.event.item.type === "user_message", + ), + ).toHaveLength(1); }); test("keeps the parent running when a MultiAgentV2 sub-agent finishes", async () => { @@ -2095,6 +2217,20 @@ describe("Codex app-server provider", () => { event.item.callId === "child-command", ), ).toBe(false); + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: { + type: "timeline", + id: "legacy-envelope-child", + item: expect.objectContaining({ + type: "tool_call", + callId: "child-command", + status: "running", + }), + }, + }), + ); expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(0); expect(events.at(-1)).toMatchObject({ type: "timeline", @@ -2343,10 +2479,28 @@ describe("Codex app-server provider", () => { test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => { const session = createSession(); session.client = { - request: vi.fn(async (method: string) => { + request: vi.fn(async (method: string, params: unknown) => { if (method !== "thread/read") { return {}; } + const threadId = (params as { threadId?: string }).threadId; + if (threadId !== "test-thread") { + return { + thread: { + turns: [ + { + items: [ + { + type: "agentMessage", + id: `message-${threadId}`, + text: `History from ${threadId}`, + }, + ], + }, + ], + }, + }; + } return { thread: { turns: [ @@ -2382,6 +2536,38 @@ describe("Codex app-server provider", () => { for await (const event of session.streamHistory()) { history.push(event); } + expect( + history.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [], + ), + ).toMatchObject([ + { type: "upsert", id: "legacy-child-thread", status: "completed" }, + { type: "upsert", id: "v2-child-thread", status: "completed" }, + ]); + expect( + history.flatMap((event) => + event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [], + ), + ).toEqual([ + { + type: "timeline", + id: "legacy-child-thread", + item: { + type: "assistant_message", + messageId: "message-legacy-child-thread", + text: "History from legacy-child-thread", + }, + }, + { + type: "timeline", + id: "v2-child-thread", + item: { + type: "assistant_message", + messageId: "message-v2-child-thread", + text: "History from v2-child-thread", + }, + }, + ]); expect( history .filter((event) => event.type === "timeline" && event.item.type === "tool_call") @@ -2447,10 +2633,13 @@ describe("Codex app-server provider", () => { test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => { const session = createSession(); session.client = { - request: vi.fn(async (method: string) => { + request: vi.fn(async (method: string, params: unknown) => { if (method !== "thread/read") { return {}; } + if ((params as { threadId?: string }).threadId !== "test-thread") { + return { thread: { turns: [] } }; + } return { thread: { turns: [ @@ -2495,6 +2684,15 @@ describe("Codex app-server provider", () => { history.push(event); } expect(history).toEqual([ + { + type: "provider_subagent", + provider: "codex", + event: expect.objectContaining({ + type: "upsert", + id: "history-child-thread", + status: "canceled", + }), + }, { type: "timeline", provider: "codex", diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index c9239dd0a..aa23cf47f 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3061,6 +3061,7 @@ interface CodexSubAgentCallState { pendingFileChangeOutputDeltas: Map; childItemOrder: string[]; childItems: Map; + childThreadIds: Set; } export class CodexAppServerAgentSession implements AgentSession { @@ -3081,6 +3082,8 @@ export class CodexAppServerAgentSession implements AgentSession { private planModeEnabled = false; private historyPending = false; private persistedHistory: PersistedTimelineEntry[] = []; + private loadingPersistedHistory = false; + private persistedProviderSubagentEvents: AgentStreamEvent[] = []; private pendingPermissions = new Map(); private mcpElicitationPermissionIds = new Map(); private pendingPermissionHandlers = new Map< @@ -3105,6 +3108,7 @@ export class CodexAppServerAgentSession implements AgentSession { private emittedExecCommandCompletedCallIds = new Set(); private emittedItemStartedIds = new Set(); private emittedItemCompletedIds = new Set(); + private emittedProviderSubagentUserMessageKeys = new Set(); private subAgentCallsByCallId = new Map(); private subAgentCallIdByChildThreadId = new Map(); private pendingSubAgentNotificationsByThreadId = new Map(); @@ -3454,12 +3458,12 @@ export class CodexAppServerAgentSession implements AgentSession { this.subAgentCallsByCallId.clear(); this.subAgentCallIdByChildThreadId.clear(); this.pendingSubAgentNotificationsByThreadId.clear(); - for (const route of subAgentRoutes) { - this.registerSubAgentToolCall({ - timelineItem: route.toolCall, - rawItem: { agentThreadId: route.childThreadId }, - parentCallId: null, - }); + this.persistedProviderSubagentEvents = []; + this.loadingPersistedHistory = true; + try { + await this.loadPersistedSubAgentHistories(client, subAgentRoutes); + } finally { + this.loadingPersistedHistory = false; } this.resetCodexUserMessageTurns(); for (const entry of timeline) { @@ -3471,6 +3475,44 @@ export class CodexAppServerAgentSession implements AgentSession { this.historyPending = timeline.length > 0; } + private async loadPersistedSubAgentHistories( + client: CodexAppServerClientLike, + rootRoutes: readonly PersistedSubAgentRoute[], + ): Promise { + const queue = rootRoutes.map((route) => ({ route, parentCallId: null as string | null })); + const visitedThreadIds = new Set(); + while (queue.length > 0 && visitedThreadIds.size < 100) { + const next = queue.shift(); + if (!next || visitedThreadIds.has(next.route.childThreadId)) { + continue; + } + visitedThreadIds.add(next.route.childThreadId); + this.registerSubAgentToolCall({ + timelineItem: next.route.toolCall, + rawItem: { agentThreadId: next.route.childThreadId }, + parentCallId: next.parentCallId, + }); + try { + const childHistory = await loadCodexThreadHistoryTimeline({ + threadId: next.route.childThreadId, + cwd: this.config.cwd ?? null, + requestThread: (childThreadId) => readCodexThread(client, childThreadId), + }); + for (const entry of childHistory.timeline) { + this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp); + } + for (const route of childHistory.subAgentRoutes) { + queue.push({ route, parentCallId: next.route.toolCall.callId }); + } + } catch (error) { + this.logger.trace( + { err: error, childThreadId: next.route.childThreadId }, + "Failed to load persisted Codex child history", + ); + } + } + } + private async ensureThreadLoaded(): Promise { if (!this.client || !this.currentThreadId) return; try { @@ -3818,12 +3860,20 @@ export class CodexAppServerAgentSession implements AgentSession { } async *streamHistory(): AsyncGenerator { - if (!this.historyPending || this.persistedHistory.length === 0) { + if ( + (!this.historyPending || this.persistedHistory.length === 0) && + this.persistedProviderSubagentEvents.length === 0 + ) { return; } const history = this.persistedHistory; + const providerSubagents = this.persistedProviderSubagentEvents; this.persistedHistory = []; + this.persistedProviderSubagentEvents = []; this.historyPending = false; + for (const event of providerSubagents) { + yield event; + } for (const entry of history) { yield { type: "timeline", @@ -4454,6 +4504,10 @@ export class CodexAppServerAgentSession implements AgentSession { } private emitEvent(event: AgentStreamEvent): void { + if (this.loadingPersistedHistory && event.type === "provider_subagent") { + this.persistedProviderSubagentEvents.push(event); + return; + } this.notifySubscribers(event); } @@ -4724,6 +4778,7 @@ export class CodexAppServerAgentSession implements AgentSession { pendingFileChangeOutputDeltas: new Map(), childItemOrder: [], childItems: new Map(), + childThreadIds: new Set(), } satisfies CodexSubAgentCallState); state.toolCall = { @@ -4754,6 +4809,8 @@ export class CodexAppServerAgentSession implements AgentSession { ); for (const receiverThreadId of childThreadIds) { this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId); + state.childThreadIds.add(receiverThreadId); + this.emitProviderSubagentUpsert(receiverThreadId, state, timelineItem.status); } return childThreadIds; } @@ -4835,12 +4892,20 @@ export class CodexAppServerAgentSession implements AgentSession { private emitCodexToolTimelineItem( timelineItem: ToolCallTimelineItem, subAgentCallId: string | null, + childThreadId?: string | null, ): void { if (!subAgentCallId) { this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); return; } this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem); + const state = this.subAgentCallsByCallId.get(subAgentCallId); + if (state) { + const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds; + for (const targetThreadId of targetThreadIds) { + this.emitProviderSubagentTimeline(targetThreadId, timelineItem); + } + } this.emitSubAgentActivityUpdate( subAgentCallId, timelineItem.status === "running" ? "running" : undefined, @@ -4867,6 +4932,9 @@ export class CodexAppServerAgentSession implements AgentSession { ? curateAgentActivity(childTimeline, { labelAssistantMessages: true }) : ""; const resolvedStatus = status ?? state.toolCall.status; + for (const childThreadId of state.childThreadIds) { + this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus); + } const baseToolCall = { ...state.toolCall, detail: { @@ -4895,6 +4963,100 @@ export class CodexAppServerAgentSession implements AgentSession { this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall }); } + private emitProviderSubagentUpsert( + childThreadId: string, + state: CodexSubAgentCallState, + status: ToolCallTimelineItem["status"], + ): void { + const detail = state.toolCall.detail; + if (detail.type !== "sub_agent") { + return; + } + let providerStatus: "running" | "completed" | "failed" | "canceled" = "running"; + if (status === "completed") { + providerStatus = "completed"; + } else if (status === "failed") { + providerStatus = "failed"; + } else if (status === "canceled") { + providerStatus = "canceled"; + } + this.emitEvent({ + type: "provider_subagent", + provider: CODEX_PROVIDER, + event: { + type: "upsert", + id: childThreadId, + title: detail.subAgentType ?? "Codex subagent", + description: detail.description ?? null, + status: providerStatus, + toolCallId: state.callId, + }, + }); + } + + private emitProviderSubagentTimeline( + childThreadId: string, + item: AgentTimelineItem, + timestamp?: string, + ): void { + this.emitEvent({ + type: "provider_subagent", + provider: CODEX_PROVIDER, + event: { + type: "timeline", + id: childThreadId, + item, + ...(timestamp ? { timestamp } : {}), + }, + }); + } + + private emitCompletedProviderSubagentItem( + parsed: Extract, + timelineItem: AgentTimelineItem, + ): void { + const itemId = parsed.item.id; + if (!parsed.threadId) return; + if (timelineItem.type === "assistant_message" && itemId) { + const streamedText = this.pendingAgentMessages.get(itemId); + if (streamedText !== undefined) { + const suffix = this.buildMissingFinalTextSuffix(timelineItem, streamedText); + if (suffix) this.emitProviderSubagentTimeline(parsed.threadId, suffix); + return; + } + } + if (timelineItem.type === "reasoning" && itemId) { + const streamedText = this.pendingReasoning.get(itemId)?.join(""); + if (streamedText !== undefined) { + const suffix = this.buildMissingFinalTextSuffix(timelineItem, streamedText); + if (suffix) this.emitProviderSubagentTimeline(parsed.threadId, suffix); + return; + } + } + this.emitProviderSubagentTimeline(parsed.threadId, timelineItem); + } + + private emitStartedProviderSubagentItem( + threadId: string | null, + timelineItem: AgentTimelineItem, + ): void { + if (threadId) { + this.emitProviderSubagentTimeline(threadId, timelineItem); + } + } + + private emitProviderSubagentTimelineItems( + threadId: string | null, + timelineItems: readonly AgentTimelineItem[], + ): void { + if (!threadId) { + return; + } + for (const timelineItem of timelineItems) { + this.emitProviderSubagentTimeline(threadId, timelineItem); + } + } + private handleSubAgentChildItemCompleted( callId: string, itemId: string | undefined, @@ -4949,6 +5111,13 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingAgentMessages.set(parsed.itemId, text); const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (subAgentCallId) { + if (parsed.threadId) { + this.emitProviderSubagentTimeline(parsed.threadId, { + type: "assistant_message", + messageId: parsed.itemId, + text: parsed.delta, + }); + } this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { type: "assistant_message", messageId: parsed.itemId, @@ -4981,6 +5150,12 @@ export class CodexAppServerAgentSession implements AgentSession { this.pendingReasoning.set(parsed.itemId, prev); const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (subAgentCallId) { + if (parsed.threadId) { + this.emitProviderSubagentTimeline(parsed.threadId, { + type: "reasoning", + text: parsed.delta, + }); + } this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { type: "reasoning", text: prev.join(""), @@ -5080,6 +5255,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.latestPlanResult = null; this.emittedItemStartedIds.clear(); this.emittedItemCompletedIds.clear(); + this.emittedProviderSubagentUserMessageKeys.clear(); this.emittedExecCommandStartedCallIds.clear(); this.emittedExecCommandCompletedCallIds.clear(); this.pendingAgentMessages.clear(); @@ -5224,7 +5400,7 @@ export class CodexAppServerAgentSession implements AgentSession { running: true, }); if (timelineItem) { - this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); + this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId); } } @@ -5257,7 +5433,7 @@ export class CodexAppServerAgentSession implements AgentSession { if (!subAgentCallId) { this.emittedExecCommandCompletedCallIds.add(timelineItem.callId); } - this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); + this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId); } } @@ -5306,7 +5482,7 @@ export class CodexAppServerAgentSession implements AgentSession { callId: parsed.callId, changes: parsed.changes, }); - this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); + this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId); } } @@ -5336,7 +5512,7 @@ export class CodexAppServerAgentSession implements AgentSession { changes: parsed.changes, stdout: parsed.stdout, }); - this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); + this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId); } } @@ -5372,8 +5548,11 @@ export class CodexAppServerAgentSession implements AgentSession { parentCallId: childSubAgentCallId, }) : []; + const imageItems = mcpToolResultImagesToTimeline(parsed.item); if (childSubAgentCallId) { + this.emitCompletedProviderSubagentItem(parsed, timelineItem); this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem); + this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems); this.replayPendingSubAgentNotifications(registeredChildThreadIds); return; } @@ -5408,7 +5587,6 @@ export class CodexAppServerAgentSession implements AgentSession { } this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); } - const imageItems = mcpToolResultImagesToTimeline(parsed.item); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); if (timelineItem.type === "assistant_message") { this.pendingAssistantMessageBoundary = true; @@ -5452,26 +5630,24 @@ export class CodexAppServerAgentSession implements AgentSession { timelineItem: Extract, streamedText: string, ): void { - if (!timelineItem.text.startsWith(streamedText)) { - this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); - return; - } + const item = this.buildMissingFinalTextSuffix(timelineItem, streamedText); + if (item) this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item }); + } + + private buildMissingFinalTextSuffix( + timelineItem: Extract, + streamedText: string, + ): AgentTimelineItem | null { + if (!timelineItem.text.startsWith(streamedText)) return timelineItem; const suffix = timelineItem.text.slice(streamedText.length); - if (!suffix) { - return; - } - this.emitEvent({ - type: "timeline", - provider: CODEX_PROVIDER, - item: - timelineItem.type === "assistant_message" - ? { - type: timelineItem.type, - text: suffix, - ...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}), - } - : { type: timelineItem.type, text: suffix }, - }); + if (!suffix) return null; + return timelineItem.type === "assistant_message" + ? { + type: timelineItem.type, + text: suffix, + ...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}), + } + : { type: timelineItem.type, text: suffix }; } private applyBufferedDeltaTextToTimelineItem( @@ -5484,14 +5660,15 @@ export class CodexAppServerAgentSession implements AgentSession { if (timelineItem.type === "assistant_message") { const buffered = this.pendingAgentMessages.get(itemId); if (buffered && buffered.length > 0) { - timelineItem.text = buffered; + if (!timelineItem.text.startsWith(buffered)) timelineItem.text = buffered; } return; } if (timelineItem.type === "reasoning") { const buffered = this.pendingReasoning.get(itemId); if (buffered && buffered.length > 0) { - timelineItem.text = buffered.join(""); + const streamedText = buffered.join(""); + if (!timelineItem.text.startsWith(streamedText)) timelineItem.text = streamedText; } } } @@ -5537,6 +5714,7 @@ export class CodexAppServerAgentSession implements AgentSession { parentCallId: childSubAgentCallId, }); if (childSubAgentCallId) { + this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem); if (parsed.item.id) { this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem); } @@ -5580,6 +5758,18 @@ export class CodexAppServerAgentSession implements AgentSession { } const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); if (childSubAgentCallId) { + const childMessageId = itemId ?? timelineItem.messageId; + if (!childMessageId) { + return; + } + const childMessageKey = `${parsed.threadId ?? childSubAgentCallId}:${childMessageId}`; + if (this.emittedProviderSubagentUserMessageKeys.has(childMessageKey)) { + return; + } + this.emittedProviderSubagentUserMessageKeys.add(childMessageKey); + if (parsed.threadId) { + this.emitProviderSubagentTimeline(parsed.threadId, timelineItem); + } if (itemId) { this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem); } diff --git a/packages/server/src/server/agent/providers/opencode-agent.test.ts b/packages/server/src/server/agent/providers/opencode-agent.test.ts index 97cdd6f3e..54232acd7 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.test.ts @@ -8,6 +8,7 @@ import type { Event as OpenCodeEvent } from "@opencode-ai/sdk/v2/client"; import { __openCodeInternals, OpenCodeAgentClient, + type OpenCodeEventTranslationState, translateOpenCodeEvent, } from "./opencode-agent.js"; import { streamSession } from "./test-utils/session-stream-adapter.js"; @@ -80,6 +81,16 @@ async function collectTurnEvents(iterator: AsyncGenerator): Pr return result; } +function providerAssistantMessages(events: AgentStreamEvent[], text: string): AgentStreamEvent[] { + return events.filter( + (event) => + event.type === "provider_subagent" && + event.event.type === "timeline" && + event.event.item.type === "assistant_message" && + event.event.item.text === text, + ); +} + function assistantTurnEvents({ sessionId = "session-1", text = "Hello from OpenCode", @@ -2301,6 +2312,1068 @@ describe("OpenCode persisted sessions", () => { }); }); +describe("OpenCode provider subagent contract", () => { + async function createAdoptedChildSession(): Promise<{ + readonly runtime: TestOpenCodeHarness; + readonly parent: Awaited>; + readonly child: Awaited>; + readonly childClient: TestOpenCodeClient; + }> { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + const childClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_external" } }; + runtime.enqueueClient(parentClient); + runtime.enqueueClient(childClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession( + { provider: "opencode", cwd: "/workspace/repo" }, + { env: { PASEO_AGENT_ID: "parent-agent" } }, + ); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_external", + parentID: "ses_parent_external", + title: "Externally driven child", + }, + }, + }); + await Promise.resolve(); + await Promise.resolve(); + + const child = await client.resumeSession( + { + provider: "opencode", + sessionId: "ses_child_external", + nativeHandle: "ses_child_external", + metadata: { cwd: "/workspace/repo" }, + }, + undefined, + { env: { PASEO_AGENT_ID: "child-agent" } }, + ); + return { runtime, parent, child, childClient }; + } + + test("resumes an adopted child on the parent's registered OpenCode server", async () => { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + const childClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_registry" } }; + runtime.enqueueClient(parentClient); + runtime.enqueueClient(childClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession( + { provider: "opencode", cwd: "/workspace/repo" }, + { env: { PASEO_AGENT_ID: "parent-agent" } }, + ); + const events: AgentStreamEvent[] = []; + parent.subscribe((event) => events.push(event)); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_registry", + parentID: "ses_parent_registry", + title: "Live child", + }, + }, + }); + await Promise.resolve(); + await Promise.resolve(); + + const child = await client.resumeSession( + { + provider: "opencode", + sessionId: "ses_child_registry", + nativeHandle: "ses_child_registry", + metadata: { cwd: "/workspace/repo" }, + }, + undefined, + { env: { PASEO_AGENT_ID: "child-agent" } }, + ); + await child.close(); + await parent.close(); + + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_registry", + title: "Live child", + status: "running", + }, + }); + expect(runtime.acquisitions).toEqual([ + { kind: "dedicated", env: { PASEO_AGENT_ID: "parent-agent" }, releaseCount: 1 }, + { kind: "existing", url: runtime.server.url, releaseCount: 1 }, + ]); + expect(runtime.clientCreations).toEqual([ + { baseUrl: runtime.server.url, directory: "/workspace/repo" }, + { baseUrl: runtime.server.url, directory: "/workspace/repo" }, + ]); + }); + + test("synthesizes a turn for externally driven adopted child timeline events", async () => { + const { child, childClient, parent } = await createAdoptedChildSession(); + const completed = createTestDeferred(); + const events: AgentStreamEvent[] = []; + child.subscribe((event) => { + events.push(event); + if (event.type === "turn_completed") { + completed.resolve(); + } + }); + + for (const event of [ + ...assistantTurnEvents({ sessionId: "ses_child_external", text: "child says hi" }).slice( + 0, + 2, + ), + { + type: "session.status", + properties: { sessionID: "ses_child_external", status: { type: "busy" } }, + }, + { type: "session.idle", properties: { sessionID: "ses_child_external" } }, + ]) { + childClient.emitEvent(event); + } + + await completed.promise; + await child.close(); + await parent.close(); + + expect(events.map((event) => event.type)).toEqual([ + "turn_started", + "timeline", + "turn_completed", + ]); + expect(events.map((event) => ("turnId" in event ? event.turnId : undefined))).toEqual([ + "opencode-turn-0", + "opencode-turn-0", + "opencode-turn-0", + ]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "timeline", + item: { type: "assistant_message", text: "child says hi" }, + }), + ); + }); + + test("synthesizes a turn for externally driven adopted child permissions", async () => { + const { child, childClient, parent } = await createAdoptedChildSession(); + const completed = createTestDeferred(); + const events: AgentStreamEvent[] = []; + child.subscribe((event) => { + events.push(event); + if (event.type === "turn_completed") { + completed.resolve(); + } + }); + + childClient.emitEvent({ + type: "permission.asked", + properties: { + id: "perm_child_external", + sessionID: "ses_child_external", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test", cwd: "/workspace/repo" }, + }, + }); + childClient.emitEvent({ + type: "session.idle", + properties: { sessionID: "ses_child_external" }, + }); + + await completed.promise; + await child.close(); + await parent.close(); + + expect(events.map((event) => event.type)).toEqual([ + "turn_started", + "permission_requested", + "turn_completed", + ]); + expect(events).toContainEqual( + expect.objectContaining({ + type: "permission_requested", + request: expect.objectContaining({ id: "perm_child_external" }), + turnId: "opencode-turn-0", + }), + ); + }); + + test("forwards provider child permissions through the parent session", async () => { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_permission" } }; + runtime.enqueueClient(parentClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + parent.subscribe((event) => events.push(event)); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_provider_child_permission", + parentID: "ses_parent_permission", + title: "Permission child", + directory: "/workspace/child", + }, + }, + }); + await vi.waitFor(() => { + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: expect.objectContaining({ + type: "upsert", + id: "ses_provider_child_permission", + }), + }); + }); + + parentClient.emitEvent({ + type: "permission.asked", + properties: { + id: "perm_provider_child", + sessionID: "ses_provider_child_permission", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test", cwd: "/workspace/repo" }, + }, + }); + await vi.waitFor(() => { + expect(parent.getPendingPermissions()).toEqual([ + expect.objectContaining({ id: "perm_provider_child" }), + ]); + }); + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: "permission_requested", + request: expect.objectContaining({ id: "perm_provider_child" }), + }), + ); + }); + expect( + events.filter( + (event) => + event.type === "permission_requested" && event.request.id === "perm_provider_child", + ), + ).toHaveLength(1); + + await parent.respondToPermission("perm_provider_child", { behavior: "allow" }); + expect(parentClient.calls.permissionReply).toContainEqual( + expect.objectContaining({ + requestID: "perm_provider_child", + directory: "/workspace/child", + reply: "once", + }), + ); + await parent.close(); + }); + + test("auto-approves provider child permissions in the child directory", async () => { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_auto_permission" } }; + runtime.enqueueClient(parentClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession({ + provider: "opencode", + cwd: "/workspace/repo", + featureValues: { auto_accept: true }, + }); + parent.subscribe(() => undefined); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_provider_child_auto_permission", + parentID: "ses_parent_auto_permission", + title: "Auto permission child", + directory: "/workspace/auto-child", + }, + }, + }); + await vi.waitFor(() => + expect(parentClient.calls.sessionChildren.length).toBeGreaterThanOrEqual(1), + ); + parentClient.emitEvent({ + type: "permission.asked", + properties: { + id: "perm_provider_child_auto", + sessionID: "ses_provider_child_auto_permission", + permission: "bash", + patterns: ["npm test"], + metadata: { command: "npm test" }, + }, + }); + + await vi.waitFor(() => + expect(parentClient.calls.permissionReply).toContainEqual( + expect.objectContaining({ + requestID: "perm_provider_child_auto", + directory: "/workspace/auto-child", + reply: "once", + }), + ), + ); + expect(parent.getPendingPermissions()).toEqual([]); + await parent.close(); + }); + + test("forwards provider child questions through the parent session", async () => { + const runtime = new TestOpenCodeHarness(); + const parentClient = new TestOpenCodeClient(); + parentClient.sessionCreateResponse = { data: { id: "ses_parent_question" } }; + runtime.enqueueClient(parentClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const parent = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + parent.subscribe((event) => events.push(event)); + + parentClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_provider_child_question", + parentID: "ses_parent_question", + title: "Question child", + directory: "/workspace/question-child", + }, + }, + }); + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + event: expect.objectContaining({ id: "ses_provider_child_question" }), + }), + ), + ); + + parentClient.emitEvent({ + type: "question.asked", + properties: { + id: "question_provider_child", + sessionID: "ses_provider_child_question", + questions: [ + { + question: "Which path?", + header: "Path", + options: [{ label: "A", description: "Choose A" }], + }, + ], + }, + }); + + await vi.waitFor(() => { + expect(parent.getPendingPermissions()).toEqual([ + expect.objectContaining({ + id: "question_provider_child", + kind: "question", + input: expect.objectContaining({ + questions: [expect.objectContaining({ question: "Which path?" })], + }), + }), + ]); + }); + expect( + events.filter( + (event) => + event.type === "permission_requested" && event.request.id === "question_provider_child", + ), + ).toHaveLength(1); + + await parent.respondToPermission("question_provider_child", { + behavior: "allow", + updatedInput: { answers: { Path: "A" } }, + }); + expect(parentClient.calls.questionReply).toContainEqual( + expect.objectContaining({ + requestID: "question_provider_child", + directory: "/workspace/question-child", + answers: [["A"]], + }), + ); + await parent.close(); + }); + + test("emits a provider subagent for a child created while the parent has no active turn", async () => { + const releaseChildEvent = createTestDeferred(); + const childConsumed = createTestDeferred(); + const fakeClient = { + global: { + event: vi.fn().mockResolvedValue({ + stream: (async function* () { + yield { type: "server.connected", properties: {} }; + await releaseChildEvent.promise; + yield { + type: "session.created", + properties: { + info: { + id: "ses_child_background", + parentID: "ses_parent", + title: "Plugin child", + }, + }, + }; + yield { + type: "message.updated", + properties: { + info: { + id: "msg_child_background", + sessionID: "ses_child_background", + role: "assistant", + }, + }, + }; + yield { + type: "message.part.updated", + properties: { + part: { + id: "part_child_background", + sessionID: "ses_child_background", + messageID: "msg_child_background", + type: "text", + text: "Background findings.", + time: { start: 1, end: 2 }, + }, + }, + }; + yield { + type: "session.idle", + properties: { sessionID: "ses_child_background" }, + }; + childConsumed.resolve(); + })(), + }), + }, + session: { + abort: vi.fn().mockResolvedValue({ error: null }), + update: vi.fn().mockResolvedValue({ error: null }), + }, + } as never; + const session = new __openCodeInternals.OpenCodeAgentSession( + { provider: "opencode", cwd: "/tmp/test" }, + fakeClient, + "ses_parent", + createTestLogger(), + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + releaseChildEvent.resolve(); + await childConsumed.promise; + await session.close(); + + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_background", + title: "Plugin child", + status: "running", + }, + }); + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: "ses_child_background", + item: { type: "assistant_message", text: "Background findings." }, + }, + }); + expect(events.at(-1)).toEqual({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_background", status: "completed" }, + }); + }); + + test("translates plugin child sessions without a waiting sub_agent tool call", () => { + const state = createOpenCodeTranslationState("ses_parent"); + + const events = translateOpenCodeEvent( + { + type: "session.updated", + properties: { + info: { + id: "ses_child_plugin", + parentID: "ses_parent", + title: "Background plugin child", + }, + }, + } as OpenCodeEvent, + state, + ); + + expect(events).toEqual([ + { + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_plugin", + title: "Background plugin child", + status: "running", + }, + }, + ]); + }); + + test("translates provider deletion of a known child session", () => { + const state = createOpenCodeTranslationState("ses_parent"); + state.subAgentCallIdByChildSessionId?.set("ses_child_deleted", "call_task"); + + const events = translateOpenCodeEvent( + { + type: "session.deleted", + properties: { sessionID: "ses_child_deleted" }, + } as OpenCodeEvent, + state, + ); + + expect(events).toEqual([ + { + type: "provider_subagent", + provider: "opencode", + event: { type: "remove", id: "ses_child_deleted" }, + }, + ]); + }); + + test("hydrates existing children breadth-first and emits each detection once after subscribe", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent" } }; + openCodeClient.sessionChildrenResponses = [ + { + data: [ + { id: "ses_child_a", parentID: "ses_parent", title: "Child A" }, + { id: "ses_child_b", parentID: "ses_parent", title: "Child B" }, + ], + }, + { data: [{ id: "ses_grandchild_a", parentID: "ses_child_a", title: "Grandchild A" }] }, + { data: [] }, + { data: [] }, + ]; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(4)); + await session.close(); + + expect(openCodeClient.calls.sessionChildren).toEqual([ + { path: { id: "ses_parent" } }, + { path: { id: "ses_child_a" } }, + { path: { id: "ses_child_b" } }, + { path: { id: "ses_grandchild_a" } }, + ]); + expect(events).toEqual([ + { + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_a", title: "Child A", status: "completed" }, + }, + { + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_b", title: "Child B", status: "completed" }, + }, + { + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_grandchild_a", + title: "Grandchild A", + status: "completed", + }, + }, + ]); + }); + + test("hydrates persisted child messages into the provider subagent timeline", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_with_history" } }; + openCodeClient.sessionChildrenResponses = [ + { + data: [ + { + id: "ses_child_with_history", + parentID: "ses_parent_with_history", + title: "Historical child", + directory: "/workspace/child", + }, + ], + }, + { data: [] }, + ]; + openCodeClient.sessionMessagesResponse = { + data: [ + { + info: { + id: "msg_child_history", + sessionID: "ses_child_with_history", + role: "assistant", + time: { created: 2, completed: 2.1 }, + }, + parts: [ + { + id: "prt_child_history", + sessionID: "ses_child_with_history", + messageID: "msg_child_history", + type: "text", + text: "Persisted child result.", + time: { start: 2, end: 2.1 }, + }, + ], + }, + ], + }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(2)); + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: "ses_child_with_history", + title: "Historical child", + status: "completed", + cwd: "/workspace/child", + }, + }); + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: "ses_child_with_history", + item: { + type: "assistant_message", + text: "Persisted child result.", + }, + timestamp: "1970-01-01T00:00:02.000Z", + }, + }), + ); + expect(openCodeClient.calls.sessionMessages).toEqual([ + { sessionID: "ses_child_with_history", directory: "/workspace/child" }, + ]); + await session.close(); + }); + + test("preserves child events that arrive while existing children hydrate", async () => { + const hydration = createTestDeferred<{ + data: Array<{ id: string; parentID: string; title: string }>; + }>(); + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_hydrating" } }; + let childListRequest = 0; + openCodeClient.sessionChildrenImplementation = async () => { + childListRequest += 1; + return childListRequest === 1 ? await hydration.promise : { data: [] }; + }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1)); + + openCodeClient.emitEvent({ + type: "message.updated", + properties: { + info: { + id: "msg_child_hydrating", + sessionID: "ses_child_hydrating", + role: "assistant", + }, + }, + }); + openCodeClient.emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: "prt_child_hydrating", + sessionID: "ses_child_hydrating", + messageID: "msg_child_hydrating", + type: "text", + text: "Hydration did not lose this.", + time: { start: 1, end: 2 }, + }, + }, + }); + hydration.resolve({ + data: [ + { + id: "ses_child_hydrating", + parentID: "ses_parent_hydrating", + title: "Hydrating child", + }, + ], + }); + + await vi.waitFor(() => + expect(events).toContainEqual( + expect.objectContaining({ + type: "provider_subagent", + provider: "opencode", + event: expect.objectContaining({ + type: "timeline", + id: "ses_child_hydrating", + item: { + type: "assistant_message", + text: "Hydration did not lose this.", + }, + }), + }), + ), + ); + await session.close(); + }); + + test("does not duplicate persisted child output when the matching live event was buffered", async () => { + const hydration = createTestDeferred<{ + data: Array<{ id: string; parentID: string; title: string }>; + }>(); + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_dedupe" } }; + let childListRequest = 0; + openCodeClient.sessionChildrenImplementation = async () => { + childListRequest += 1; + return childListRequest === 1 ? await hydration.promise : { data: [] }; + }; + const message = { + id: "msg_child_dedupe", + sessionID: "ses_child_dedupe", + role: "assistant" as const, + time: { created: 1, completed: 2 }, + }; + const part = { + id: "prt_child_dedupe", + sessionID: "ses_child_dedupe", + messageID: "msg_child_dedupe", + type: "text" as const, + text: "Only once.", + time: { start: 1, end: 2 }, + }; + openCodeClient.sessionMessagesResponse = { data: [{ info: message, parts: [part] }] }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1)); + + openCodeClient.emitEvent({ type: "message.updated", properties: { info: message } }); + openCodeClient.emitEvent({ type: "message.part.updated", properties: { part } }); + hydration.resolve({ + data: [ + { + id: "ses_child_dedupe", + parentID: "ses_parent_dedupe", + title: "Dedupe child", + }, + ], + }); + + await vi.waitFor(() => expect(providerAssistantMessages(events, "Only once.")).toHaveLength(1)); + await session.close(); + }); + + test("marks a hydrated child running when OpenCode reports it busy", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_busy" } }; + openCodeClient.sessionChildrenResponses = [ + { data: [{ id: "ses_child_busy", parentID: "ses_parent_busy", title: "Busy child" }] }, + { data: [] }, + ]; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(2)); + + openCodeClient.emitEvent({ + type: "session.status", + properties: { sessionID: "ses_child_busy", status: { type: "busy" } }, + }); + + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: "ses_child_busy", status: "running" }, + }), + ); + await session.close(); + }); + + test("preserves a live provider child user prompt", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_child_prompt" } }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + openCodeClient.emitEvent({ + type: "session.created", + properties: { + info: { + id: "ses_child_prompt", + parentID: "ses_parent_child_prompt", + title: "Prompt child", + }, + }, + }); + openCodeClient.emitEvent({ + type: "message.updated", + properties: { + info: { id: "msg_child_prompt", sessionID: "ses_child_prompt", role: "user" }, + }, + }); + openCodeClient.emitEvent({ + type: "message.part.updated", + properties: { + part: { + id: "prt_child_prompt", + sessionID: "ses_child_prompt", + messageID: "msg_child_prompt", + type: "text", + text: "Inspect the auth flow.", + time: { start: 1, end: 2 }, + }, + }, + }); + + await vi.waitFor(() => + expect(events).toContainEqual({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: "ses_child_prompt", + item: { + type: "user_message", + text: "Inspect the auth flow.", + messageId: "msg_child_prompt", + }, + timestamp: undefined, + }, + }), + ); + await session.close(); + }); + + test("does not rehydrate children for repeated events from an unrelated session", async () => { + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_isolated" } }; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + session.subscribe(() => undefined); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1)); + await Promise.resolve(); + + for (const messageId of ["sibling-message-1", "sibling-message-2"]) { + openCodeClient.emitEvent({ + type: "message.updated", + properties: { + info: { + id: messageId, + sessionID: "ses_unrelated_sibling", + role: "assistant", + }, + }, + }); + } + await Promise.resolve(); + await Promise.resolve(); + + expect(openCodeClient.calls.sessionChildren).toHaveLength(1); + await session.close(); + }); + + test("closes without waiting for child hydration", async () => { + const hydration = createTestDeferred<{ data: [] }>(); + const runtime = new TestOpenCodeHarness(); + const openCodeClient = new TestOpenCodeClient(); + openCodeClient.sessionCreateResponse = { data: { id: "ses_parent_close" } }; + openCodeClient.sessionChildrenImplementation = async () => await hydration.promise; + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/workspace/repo" }); + session.subscribe(() => undefined); + await vi.waitFor(() => expect(openCodeClient.calls.sessionChildren).toHaveLength(1)); + + await session.close(); + + expect(runtime.acquisitions[0]?.releaseCount).toBe(1); + hydration.resolve({ data: [] }); + }); + + test("does not fold child tool parts into the parent sub_agent action log", () => { + const state = createOpenCodeTranslationState("ses_parent"); + const events: AgentStreamEvent[] = []; + + events.push( + ...translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "prt_parent_task", + sessionID: "ses_parent", + messageID: "msg_parent", + type: "tool", + tool: "task", + callID: "call_task", + state: { + status: "running", + input: { subagent_type: "explore", description: "Inspect repo" }, + }, + }, + }, + } as OpenCodeEvent, + state, + ), + ...translateOpenCodeEvent( + { + type: "session.created", + properties: { info: { id: "ses_child", parentID: "ses_parent" } }, + } as OpenCodeEvent, + state, + ), + ...translateOpenCodeEvent( + { + type: "message.part.updated", + properties: { + part: { + id: "prt_child_tool", + sessionID: "ses_child", + messageID: "msg_child", + type: "tool", + tool: "bash", + callID: "call_bash", + state: { status: "completed", input: { command: "echo child" }, output: "child\n" }, + }, + }, + } as OpenCodeEvent, + state, + ), + ); + + const subAgentItems = events.flatMap((event) => { + if (event.type !== "timeline" || event.item.type !== "tool_call") { + return []; + } + return event.item.detail.type === "sub_agent" ? [event.item] : []; + }); + const latest = subAgentItems.at(-1); + if (!latest || latest.detail.type !== "sub_agent") { + throw new Error("expected parent sub_agent timeline item"); + } + + expect(latest.detail).toEqual({ + type: "sub_agent", + subAgentType: "explore", + description: "Inspect repo", + childSessionId: "ses_child", + log: "", + actions: [], + }); + }); +}); + +function createOpenCodeTranslationState(sessionId: string): OpenCodeEventTranslationState { + return { + sessionId, + cwd: "/workspace/repo", + messageRoles: new Map(), + accumulatedUsage: {}, + streamedPartKeys: new Set(), + emittedStructuredMessageIds: new Set(), + compactionSummaryMessageIds: new Set(), + emittedCompactionPartIds: new Set(), + partTypes: new Map(), + subAgentsByCallId: new Map(), + subAgentCallIdByChildSessionId: new Map(), + }; +} + function createTestDeferred(): { promise: Promise; resolve: (value: T) => void; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index d22c286b5..35aaa6c53 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -65,7 +65,6 @@ import { } from "../provider-launch-config.js"; import { withTimeout } from "../../../utils/promise-timeout.js"; import { execCommand } from "../../../utils/spawn.js"; -import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display"; import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js"; import { OpenCodeServerManager, @@ -104,9 +103,35 @@ const OPENCODE_LEGACY_FULL_ACCESS_MODE_ID = "full-access"; const OPENCODE_AUTO_ACCEPT_FEATURE_ID = "auto_accept"; const OPENCODE_PERSISTED_SESSION_LIMIT = 200; const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10_000; +const OPENCODE_CHILD_SESSION_HYDRATION_LIMIT = 100; +const OPENCODE_CHILD_SESSION_SERVER_REGISTRY_LIMIT = 500; const OPENCODE_PERMISSION_ACTION_ALLOW_ONCE = "allow_once"; const OPENCODE_PERMISSION_ACTION_ALLOW_ALWAYS = "allow_always"; +// OpenCode child sessions run on the server process that spawned them. Adoption +// resumes must attach to that same helper server to receive live global events. +const openCodeChildSessionServerUrls = new Map(); + +function registerOpenCodeChildSessionServerUrl(sessionId: string, serverUrl: string): void { + openCodeChildSessionServerUrls.delete(sessionId); + openCodeChildSessionServerUrls.set(sessionId, serverUrl); + if (openCodeChildSessionServerUrls.size <= OPENCODE_CHILD_SESSION_SERVER_REGISTRY_LIMIT) { + return; + } + const oldestSessionId = openCodeChildSessionServerUrls.keys().next().value; + if (typeof oldestSessionId === "string") { + openCodeChildSessionServerUrls.delete(oldestSessionId); + } +} + +function unregisterOpenCodeChildSessionServerUrl(sessionId: string): void { + openCodeChildSessionServerUrls.delete(sessionId); +} + +function getOpenCodeChildSessionServerUrl(sessionId: string): string | undefined { + return openCodeChildSessionServerUrls.get(sessionId); +} + const DEFAULT_MODES: AgentMode[] = [ { id: OPENCODE_BUILD_MODE_ID, @@ -532,6 +557,19 @@ function resolvePartDedupeKey( return null; } +function matchesHydratedFingerprint( + fingerprints: Map | undefined, + id: string, + value: unknown, +): boolean { + const hydratedFingerprint = fingerprints?.get(id); + if (!hydratedFingerprint) { + return false; + } + fingerprints?.delete(id); + return hydratedFingerprint === JSON.stringify(value); +} + function normalizeOpenCodeModeId(modeId: string | null | undefined): string { const trimmed = typeof modeId === "string" ? modeId.trim() : ""; if (!trimmed || trimmed === "default") { @@ -1303,6 +1341,7 @@ export class OpenCodeAgentClient implements AgentClient { acquisition.release, options?.persistSession, launchContext?.agentId, + url, ); } catch (error) { acquisition.release(); @@ -1328,7 +1367,15 @@ export class OpenCodeAgentClient implements AgentClient { cwd, }; const openCodeConfig = this.assertConfig(config); - const acquisition = await this.serverManager.acquireCurrent(); + const registeredServerUrl = getOpenCodeChildSessionServerUrl(handle.sessionId); + const registeredAcquisition = registeredServerUrl + ? this.serverManager.acquireExisting(registeredServerUrl) + : null; + const acquisition = + registeredAcquisition ?? + (launchContext?.env + ? await this.serverManager.acquireDedicated(launchContext.env) + : await this.serverManager.acquireCurrent()); const { url } = acquisition.server; const client = this.createOpenCodeClient({ baseUrl: url, @@ -1347,6 +1394,8 @@ export class OpenCodeAgentClient implements AgentClient { acquisition.release, undefined, launchContext?.agentId, + url, + registeredAcquisition !== null, ); } catch (error) { acquisition.release(); @@ -1628,12 +1677,14 @@ export interface OpenCodeEventTranslationState { emittedStructuredMessageIds: Set; compactionSummaryMessageIds: Set; emittedCompactionPartIds: Set; + hydratedMessageFingerprints?: Map; + hydratedPartFingerprints?: Map; suppressAssistantMessagesUntilIdle?: { active: boolean }; /** Tracks the type of each part by ID, learned from message.part.updated events. */ partTypes: Map; subAgentsByCallId?: Map; subAgentCallIdByChildSessionId?: Map; - pendingChildToolPartsBySessionId?: Map; + knownChildSessionIds?: Set; modelContextWindowsByModelKey?: ReadonlyMap; onAssistantModelContextWindowResolved?: (contextWindowMaxTokens: number) => void; } @@ -1665,24 +1716,19 @@ type OpenCodeToolPartEventPart = Extract< { type: "tool" } >; -interface OpenCodeSubAgentActionEntry { - index: number; - key: string; - toolName: string; - summary?: string; +interface OpenCodeChildSessionInfo { + id: string; + parentSessionId: string; + title?: string; + directory?: string; + revert?: OpenCodePersistedSession["revert"]; } interface OpenCodeSubAgentActivityState { toolCall: ToolCallTimelineItem; - actions: OpenCodeSubAgentActionEntry[]; - actionIndexByKey: Map; - nextActionIndex: number; childSessionId?: string; } -const MAX_OPENCODE_SUB_AGENT_ACTIONS = 200; -const MAX_OPENCODE_PENDING_CHILD_TOOL_PARTS = 200; - function stringifyStructuredAssistantMessage(value: unknown): string | null { if (value === undefined) { return null; @@ -1937,6 +1983,9 @@ export function translateOpenCodeEvent( case "session.updated": appendOpenCodeSessionCreatedOrUpdated(event, state, events); break; + case "session.deleted": + appendOpenCodeSessionDeleted(event, state, events); + break; case "message.updated": appendOpenCodeMessageUpdated(event, state, events); break; @@ -2000,27 +2049,64 @@ function resetOpenCodeTurnTrackingState(state: OpenCodeEventTranslationState): v function getOpenCodeSubAgentMaps(state: OpenCodeEventTranslationState): { byCallId: Map; callIdByChildSessionId: Map; - pendingChildToolPartsBySessionId: Map; } { state.subAgentsByCallId ??= new Map(); state.subAgentCallIdByChildSessionId ??= new Map(); - state.pendingChildToolPartsBySessionId ??= new Map(); return { byCallId: state.subAgentsByCallId, callIdByChildSessionId: state.subAgentCallIdByChildSessionId, - pendingChildToolPartsBySessionId: state.pendingChildToolPartsBySessionId, }; } +function getOpenCodeKnownChildSessionIds(state: OpenCodeEventTranslationState): Set { + state.knownChildSessionIds ??= new Set(); + return state.knownChildSessionIds; +} + function isOpenCodeSessionTrackedByParent( sessionId: string, state: OpenCodeEventTranslationState, ): boolean { return ( - sessionId === state.sessionId || state.subAgentCallIdByChildSessionId?.has(sessionId) === true + sessionId === state.sessionId || + state.knownChildSessionIds?.has(sessionId) === true || + state.subAgentCallIdByChildSessionId?.has(sessionId) === true ); } +function appendOpenCodeChildSessionDetected( + child: OpenCodeChildSessionInfo, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], + status: "running" | "completed" = "running", +): boolean { + if ( + child.id === state.sessionId || + !isOpenCodeSessionTrackedByParent(child.parentSessionId, state) + ) { + return false; + } + + const knownChildSessionIds = getOpenCodeKnownChildSessionIds(state); + if (knownChildSessionIds.has(child.id)) { + return false; + } + + knownChildSessionIds.add(child.id); + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "upsert", + id: child.id, + title: child.title ?? "OpenCode subagent", + status, + ...(child.directory ? { cwd: child.directory } : {}), + }, + }); + return true; +} + function getOpenCodeSubAgentState( callId: string, state: OpenCodeEventTranslationState, @@ -2035,9 +2121,6 @@ function getOpenCodeSubAgentState( const created: OpenCodeSubAgentActivityState = { toolCall, - actions: [], - actionIndexByKey: new Map(), - nextActionIndex: 1, }; maps.byCallId.set(callId, created); return created; @@ -2053,19 +2136,6 @@ function linkOpenCodeSubAgentChildSession( maps.callIdByChildSessionId.set(childSessionId, activity.toolCall.callId); } -function buildOpenCodeSubAgentLog( - detail: Extract, - activity: OpenCodeSubAgentActivityState, -): string { - const actionLog = activity.actions - .map((action) => - action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`, - ) - .join("\n"); - const parts = [actionLog, detail.log].filter((part) => part.trim().length > 0); - return parts.join("\n\n"); -} - function buildOpenCodeSubAgentTimelineItem( activity: OpenCodeSubAgentActivityState, ): ToolCallTimelineItem { @@ -2079,7 +2149,6 @@ function buildOpenCodeSubAgentTimelineItem( detail: { ...toolCall.detail, ...(childSessionId ? { childSessionId } : {}), - log: buildOpenCodeSubAgentLog(toolCall.detail, activity), }, }; } @@ -2098,42 +2167,6 @@ function registerOpenCodeSubAgentToolCall( return buildOpenCodeSubAgentTimelineItem(activity); } -function bufferOpenCodeSubAgentChildToolPart( - part: OpenCodeToolPartEventPart, - state: OpenCodeEventTranslationState, -): void { - const maps = getOpenCodeSubAgentMaps(state); - if (maps.byCallId.size === 0) { - return; - } - const totalPending = [...maps.pendingChildToolPartsBySessionId.values()].reduce( - (total, parts) => total + parts.length, - 0, - ); - if (totalPending >= MAX_OPENCODE_PENDING_CHILD_TOOL_PARTS) { - return; - } - const pending = maps.pendingChildToolPartsBySessionId.get(part.sessionID) ?? []; - pending.push(part); - maps.pendingChildToolPartsBySessionId.set(part.sessionID, pending); -} - -function flushOpenCodeSubAgentChildToolParts( - childSessionId: string, - state: OpenCodeEventTranslationState, - events: AgentStreamEvent[], -): void { - const maps = getOpenCodeSubAgentMaps(state); - const pending = maps.pendingChildToolPartsBySessionId.get(childSessionId); - if (!pending || pending.length === 0) { - return; - } - maps.pendingChildToolPartsBySessionId.delete(childSessionId); - for (const part of pending) { - appendOpenCodeSubAgentChildToolPart(part, state, events); - } -} - function findOnlyOpenCodeSubAgentWaitingForChild( state: OpenCodeEventTranslationState, ): OpenCodeSubAgentActivityState | null { @@ -2147,60 +2180,6 @@ function findOnlyOpenCodeSubAgentWaitingForChild( return candidates.length === 1 ? (candidates[0] ?? null) : null; } -function summarizeOpenCodeSubAgentAction( - item: ToolCallTimelineItem, - cwd: string | undefined, -): string | undefined { - const display = buildToolCallDisplayModel({ - name: item.name, - status: item.status, - error: item.error, - metadata: item.metadata, - detail: item.detail, - cwd, - }); - return display.summary ?? display.errorText; -} - -function appendOpenCodeSubAgentAction( - activity: OpenCodeSubAgentActivityState, - item: ToolCallTimelineItem, - cwd: string | undefined, -): boolean { - const key = item.callId || `${item.name}:${activity.actions.length}`; - const existingIndex = activity.actionIndexByKey.get(key); - const summary = summarizeOpenCodeSubAgentAction(item, cwd); - - if (existingIndex !== undefined) { - const action = activity.actions[existingIndex]; - if (!action) { - return false; - } - const changed = action.toolName !== item.name || action.summary !== summary; - action.toolName = item.name; - if (summary) { - action.summary = summary; - } else { - delete action.summary; - } - return changed; - } - - if (activity.actions.length >= MAX_OPENCODE_SUB_AGENT_ACTIONS) { - return false; - } - - activity.actionIndexByKey.set(key, activity.actions.length); - activity.actions.push({ - index: activity.nextActionIndex, - key, - toolName: item.name, - ...(summary ? { summary } : {}), - }); - activity.nextActionIndex += 1; - return true; -} - function appendOpenCodeToolCallTimelineItem( item: ToolCallTimelineItem, state: OpenCodeEventTranslationState, @@ -2212,9 +2191,6 @@ function appendOpenCodeToolCallTimelineItem( provider: "opencode", item: timelineItem, }); - if (timelineItem.detail.type === "sub_agent" && timelineItem.detail.childSessionId) { - flushOpenCodeSubAgentChildToolParts(timelineItem.detail.childSessionId, state, events); - } } function appendOpenCodeSubAgentChildSessionLinked( @@ -2232,36 +2208,6 @@ function appendOpenCodeSubAgentChildSessionLinked( provider: "opencode", item: buildOpenCodeSubAgentTimelineItem(activity), }); - flushOpenCodeSubAgentChildToolParts(childSessionId, state, events); -} - -function appendOpenCodeSubAgentChildToolPart( - part: OpenCodeToolPartEventPart, - state: OpenCodeEventTranslationState, - events: AgentStreamEvent[], -): void { - const maps = getOpenCodeSubAgentMaps(state); - const parentCallId = maps.callIdByChildSessionId.get(part.sessionID); - if (!parentCallId) { - bufferOpenCodeSubAgentChildToolPart(part, state); - return; - } - const activity = maps.byCallId.get(parentCallId); - if (!activity) { - return; - } - const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part); - if (!parsedToolPart.success || !parsedToolPart.data) { - return; - } - if (!appendOpenCodeSubAgentAction(activity, parsedToolPart.data, state.cwd)) { - return; - } - events.push({ - type: "timeline", - provider: "opencode", - item: buildOpenCodeSubAgentTimelineItem(activity), - }); } function appendOpenCodeSessionCreatedOrUpdated( @@ -2285,11 +2231,45 @@ function appendOpenCodeSessionCreatedOrUpdated( } const parentSessionId = readNonEmptyString(info?.parentID) ?? readNonEmptyString(info?.parentId); + if (parentSessionId) { + appendOpenCodeChildSessionDetected( + { + id: event.properties.info.id, + parentSessionId, + ...(readNonEmptyString(info?.title) + ? { title: readNonEmptyString(info?.title) ?? undefined } + : {}), + ...(readNonEmptyString(info?.directory) + ? { directory: readNonEmptyString(info?.directory) ?? undefined } + : {}), + }, + state, + events, + ); + } if (parentSessionId === state.sessionId) { appendOpenCodeSubAgentChildSessionLinked(event.properties.info.id, state, events); } } +function appendOpenCodeSessionDeleted( + event: Extract, + state: OpenCodeEventTranslationState, + events: AgentStreamEvent[], +): void { + const sessionId = event.properties.sessionID; + if (!isOpenCodeSessionTrackedByParent(sessionId, state)) { + return; + } + state.knownChildSessionIds?.delete(sessionId); + state.subAgentCallIdByChildSessionId?.delete(sessionId); + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "remove", id: sessionId }, + }); +} + function appendOpenCodeMessageUpdated( event: Extract, state: OpenCodeEventTranslationState, @@ -2300,6 +2280,9 @@ function appendOpenCodeMessageUpdated( return; } state.messageRoles.set(info.id, info.role); + if (matchesHydratedFingerprint(state.hydratedMessageFingerprints, info.id, info)) { + return; + } if (info.role === "user") { appendOpenCodeUserMessageUpdated(info, state, events); return; @@ -2364,9 +2347,9 @@ function appendOpenCodeMessagePartUpdated( return; } if (part.sessionID !== state.sessionId) { - if (part.type === "tool") { - appendOpenCodeSubAgentChildToolPart(part, state, events); - } + return; + } + if (matchesHydratedFingerprint(state.hydratedPartFingerprints, part.id, part)) { return; } const messageRole = state.messageRoles.get(part.messageID); @@ -2448,6 +2431,15 @@ function appendOpenCodeTextPart( events: AgentStreamEvent[], ): void { if (messageRole === "user") { + if (!part.time?.end || !part.text || state.emittedUserMessageIds?.has(part.messageID)) { + return; + } + state.emittedUserMessageIds?.add(part.messageID); + events.push({ + type: "timeline", + provider: "opencode", + item: { type: "user_message", text: part.text, messageId: part.messageID }, + }); return; } if (!part.time?.end) { @@ -2739,6 +2731,23 @@ function unwrapOpenCodeGlobalEvent(event: unknown): OpenCodeEvent | null { return null; } +function getOpenCodeEventSessionId(event: OpenCodeEvent): string | null { + const properties = readOpenCodeRecord(event.properties); + const info = readOpenCodeRecord(properties?.info); + const part = readOpenCodeRecord(properties?.part); + return ( + readNonEmptyString(properties?.sessionID) ?? + readNonEmptyString(properties?.sessionId) ?? + readNonEmptyString(info?.sessionID) ?? + readNonEmptyString(info?.sessionId) ?? + readNonEmptyString(part?.sessionID) ?? + readNonEmptyString(part?.sessionId) ?? + (event.type === "session.created" || event.type === "session.updated" + ? readNonEmptyString(info?.id) + : null) + ); +} + function isOpenCodeUserMessageEvent(event: OpenCodeEvent, sessionId: string): boolean { return ( event.type === "message.updated" && @@ -2758,6 +2767,76 @@ function isOpenCodeTerminalEvent(event: OpenCodeEvent, sessionId: string): boole ); } +function isOpenCodeProviderInternalEvent(event: AgentStreamEvent): boolean { + return event.type === "provider_subagent"; +} + +function readOpenCodeChildSessionInfo(value: unknown): OpenCodeChildSessionInfo | null { + const record = readOpenCodeRecord(value); + if (!record) { + return null; + } + const id = readNonEmptyString(record.id); + const parentSessionId = + readNonEmptyString(record.parentID) ?? readNonEmptyString(record.parentId); + if (!id || !parentSessionId) { + return null; + } + const title = readNonEmptyString(record.title); + const directory = readNonEmptyString(record.directory); + const revert = readOpenCodeRecord(record.revert) as OpenCodePersistedSession["revert"] | null; + return { + id, + parentSessionId, + ...(title ? { title } : {}), + ...(directory ? { directory } : {}), + ...(revert ? { revert } : {}), + }; +} + +function readOpenCodeChildSessionInfosFromResponse( + response: unknown, +): OpenCodeChildSessionInfo[] | null { + const record = readOpenCodeRecord(response); + if (!record || record.error) { + return null; + } + const data = record.data; + if (!Array.isArray(data)) { + return null; + } + return data.flatMap((item) => { + const child = readOpenCodeChildSessionInfo(item); + return child ? [child] : []; + }); +} + +async function listOpenCodeChildSessions( + client: OpencodeClient, + sessionId: string, + directory: string, +): Promise { + try { + const pathResponse: unknown = await Reflect.apply(client.session.children, client.session, [ + { path: { id: sessionId } }, + ]); + const pathChildren = readOpenCodeChildSessionInfosFromResponse(pathResponse); + if (pathChildren) { + return pathChildren; + } + } catch (error) { + if (!(error instanceof Error)) { + throw error; + } + } + + const sessionIdResponse = await client.session.children({ + sessionID: sessionId, + directory, + }); + return readOpenCodeChildSessionInfosFromResponse(sessionIdResponse) ?? []; +} + class OpenCodeAgentSession implements AgentSession { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; @@ -2793,10 +2872,17 @@ class OpenCodeAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; + private activeForegroundTurnSource: "paseo" | "external" | null = null; private readonly runningToolCalls = new Map(); private subAgentsByCallId = new Map(); private subAgentCallIdByChildSessionId = new Map(); - private pendingChildToolPartsBySessionId = new Map(); + private knownChildSessionIds = new Set(); + private readonly childTranslationStates = new Map(); + private readonly childSessionCwds = new Map(); + private readonly pendingPermissionDirectories = new Map(); + private childHydrationPromise: Promise | null = null; + private childHydrationCompleted = false; + private readonly unrelatedSessionIds = new Set(); private selectedModelContextWindowMaxTokens: number | undefined; private releaseServer: (() => void) | null; private eventStreamAbortController: AbortController | null = null; @@ -2815,6 +2901,8 @@ class OpenCodeAgentSession implements AgentSession { releaseServer?: () => void, persistSession = true, private readonly agentId?: string, + private readonly serverUrl?: string, + private readonly externallyDriven = false, ) { this.config = config; this.client = client; @@ -2876,7 +2964,7 @@ class OpenCodeAgentSession implements AgentSession { } async interrupt(): Promise { - const turnId = this.activeForegroundTurnId; + let turnId = this.activeForegroundTurnId; const turnAbortController = this.abortController; turnAbortController?.abort(); // COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until @@ -2955,14 +3043,21 @@ class OpenCodeAgentSession implements AgentSession { options?: AgentRunOptions, ): Promise<{ turnId: string }> { if (this.activeForegroundTurnId) { - throw new Error("A foreground turn is already active"); + if (this.activeForegroundTurnSource === "external") { + // A direct Paseo prompt owns the foreground; close the adopted child run first. + this.finishForegroundTurn( + { type: "turn_completed", provider: "opencode", usage: undefined }, + this.activeForegroundTurnId, + ); + } else { + throw new Error("A foreground turn is already active"); + } } await this.awaitPendingAbortBeforeStartingTurn(); this.runningToolCalls.clear(); this.subAgentsByCallId.clear(); this.subAgentCallIdByChildSessionId.clear(); - this.pendingChildToolPartsBySessionId.clear(); const turnAbortController = new AbortController(); this.abortController = turnAbortController; await this.ensureMcpServersConfigured(); @@ -2988,6 +3083,7 @@ class OpenCodeAgentSession implements AgentSession { const turnId = this.createTurnId(); this.activeForegroundTurnId = turnId; + this.activeForegroundTurnSource = "paseo"; this.notifySubscribers({ type: "turn_started", provider: "opencode" }, turnId); const slashCommand = await this.resolveSlashCommandInvocation(prompt); @@ -3153,11 +3249,135 @@ class OpenCodeAgentSession implements AgentSession { } subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.subscribers.add(callback); + this.startChildSessionHydration(); return () => { this.subscribers.delete(callback); }; } + private startChildSessionHydration(): void { + if (this.childHydrationPromise) { + return; + } + const hydration = this.hydrateChildSessions() + .then(() => { + this.childHydrationCompleted = true; + return undefined; + }) + .finally(() => { + if (this.childHydrationPromise === hydration) { + this.childHydrationPromise = null; + } + }); + this.childHydrationPromise = hydration; + void hydration.catch((error) => { + this.logger.warn( + { err: error, sessionId: this.sessionId }, + "OpenCode child hydration failed", + ); + }); + } + + private async hydrateChildSessions(): Promise { + const queue = [this.sessionId]; + const visited = new Set(); + while (queue.length > 0 && visited.size < OPENCODE_CHILD_SESSION_HYDRATION_LIMIT) { + const parentSessionId = queue.shift(); + if (!parentSessionId || visited.has(parentSessionId)) { + continue; + } + visited.add(parentSessionId); + const children = await listOpenCodeChildSessions( + this.client, + parentSessionId, + this.config.cwd, + ); + if (this.closed) return; + for (const child of children) { + const detectionEvents: AgentStreamEvent[] = []; + appendOpenCodeChildSessionDetected( + child, + this.createTranslationState(), + detectionEvents, + "completed", + ); + for (const event of detectionEvents) { + this.recordProviderInternalEvent(event); + this.notifySubscribers(event, null); + } + try { + await this.hydrateChildSessionTimeline(child); + } catch (error) { + this.logger.warn( + { err: error, sessionId: child.id }, + "OpenCode child timeline hydration failed", + ); + } + if (visited.size + queue.length < OPENCODE_CHILD_SESSION_HYDRATION_LIMIT) { + queue.push(child.id); + } + } + } + } + + private async hydrateChildSessionTimeline(child: OpenCodeChildSessionInfo): Promise { + const messages = await readOpenCodeSessionMessagesFromSdk(this.client, { + id: child.id, + directory: child.directory ?? this.config.cwd, + ...(child.revert ? { revert: child.revert } : {}), + } as OpenCodePersistedSession); + const translationState = this.getChildTranslationState(child.id); + let latestReplayedMessage: OpenCodeSessionMessage | null = null; + for (const message of messages) { + if (message.info.role === "assistant" && message.info.time?.completed === undefined) { + continue; + } + latestReplayedMessage = message; + for (const timelineEvent of buildOpenCodeReplayTimelineEvents(message)) { + const event: AgentStreamEvent = { + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: child.id, + item: timelineEvent.item, + ...(timelineEvent.timestamp ? { timestamp: timelineEvent.timestamp } : {}), + }, + }; + this.recordProviderInternalEvent(event); + this.notifySubscribers(event, null); + } + } + if (latestReplayedMessage) { + translationState.hydratedMessageFingerprints?.set( + latestReplayedMessage.info.id, + JSON.stringify(latestReplayedMessage.info), + ); + for (const part of latestReplayedMessage.parts) { + translationState.hydratedPartFingerprints?.set(part.id, JSON.stringify(part)); + } + } + } + + private recordProviderInternalEvent(event: AgentStreamEvent): void { + if (event.type !== "provider_subagent") { + return; + } + if (event.event.type === "upsert") { + this.unrelatedSessionIds.delete(event.event.id); + if (event.event.cwd) { + this.childSessionCwds.set(event.event.id, event.event.cwd); + } + if (this.serverUrl) { + registerOpenCodeChildSessionServerUrl(event.event.id, this.serverUrl); + } + } else if (event.event.type === "remove") { + unregisterOpenCodeChildSessionServerUrl(event.event.id); + this.childTranslationStates.delete(event.event.id); + this.childSessionCwds.delete(event.event.id); + } + } + private startEventStream(): void { void this.ensureEventStreamReady().catch((error) => { this.logger.warn({ err: error, sessionId: this.sessionId }, "OpenCode event stream failed"); @@ -3276,7 +3496,7 @@ class OpenCodeAgentSession implements AgentSession { eventCount: number; }): Promise { const { rawEvent, eventCount } = params; - const turnId = this.activeForegroundTurnId; + let turnId = this.activeForegroundTurnId; const event = unwrapOpenCodeGlobalEvent(rawEvent); this.traceOpenCode("provider.opencode.raw_event", { turnId: turnId ?? undefined, @@ -3290,7 +3510,20 @@ class OpenCodeAgentSession implements AgentSession { if (!event) { return; } + const translated = await this.translateEvent(event); + const foregroundEvents: AgentStreamEvent[] = []; + for (const translatedEvent of translated) { + if (isOpenCodeProviderInternalEvent(translatedEvent)) { + this.notifySubscribers(translatedEvent, null); + } else { + foregroundEvents.push(translatedEvent); + } + } + if (!turnId && this.shouldStartExternalDrivenTurn(event, foregroundEvents)) { + turnId = this.startExternalDrivenTurn(); + } if (!turnId) { + this.emitBackgroundPermissionRequests(foregroundEvents); this.traceOpenCode("provider.opencode.event.skip", { n: eventCount, reason: "no_active_turn", @@ -3310,16 +3543,15 @@ class OpenCodeAgentSession implements AgentSession { return; } } - const translated = await this.translateEvent(event); this.traceOpenCode("provider.opencode.parsed_event", { turnId, n: eventCount, - count: translated.length, - types: translated.map((t) => t.type), - events: translated, + count: foregroundEvents.length, + types: foregroundEvents.map((t) => t.type), + events: foregroundEvents, }); - for (const e of translated) { + for (const e of foregroundEvents) { if (this.activeForegroundTurnId !== turnId) { this.traceOpenCode("provider.opencode.parsed_event.skip_active", { turnId, type: e.type }); return; @@ -3340,6 +3572,47 @@ class OpenCodeAgentSession implements AgentSession { } } + private emitBackgroundPermissionRequests(events: readonly AgentStreamEvent[]): void { + for (const event of events) { + if (event.type === "permission_requested") { + this.notifySubscribers(event, null); + } + } + } + + private shouldStartExternalDrivenTurn( + event: OpenCodeEvent, + foregroundEvents: readonly AgentStreamEvent[], + ): boolean { + if (!this.externallyDriven) { + return false; + } + if (this.activeForegroundTurnId) { + return false; + } + if (foregroundEvents.some((foregroundEvent) => !toTerminalTurnEvent(foregroundEvent))) { + return true; + } + return ( + event.type === "session.status" && + event.properties.sessionID === this.sessionId && + event.properties.status.type === "busy" + ); + } + + private startExternalDrivenTurn(): string { + const turnId = this.createTurnId(); + this.activeForegroundTurnId = turnId; + this.activeForegroundTurnSource = "external"; + this.runningToolCalls.clear(); + this.subAgentsByCallId.clear(); + this.subAgentCallIdByChildSessionId.clear(); + this.pendingUserMessageText = null; + this.abortController = null; + this.notifySubscribers({ type: "turn_started", provider: "opencode" }, turnId); + return turnId; + } + private finishForegroundTurn( event: Extract, turnId: string, @@ -3361,6 +3634,7 @@ class OpenCodeAgentSession implements AgentSession { } this.pendingUserMessageText = null; this.activeForegroundTurnId = null; + this.activeForegroundTurnSource = null; this.abortController = null; this.notifySubscribers(event, turnId); } @@ -3401,11 +3675,11 @@ class OpenCodeAgentSession implements AgentSession { this.runningToolCalls.clear(); } - private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string): void { + private notifySubscribers(event: AgentStreamEvent, turnIdOverride?: string | null): void { if (this.closed) { return; } - const turnId = turnIdOverride ?? this.activeForegroundTurnId; + const turnId = turnIdOverride === null ? null : (turnIdOverride ?? this.activeForegroundTurnId); const tagged = turnId ? { ...event, turnId } : event; this.traceOpenCode("provider.opencode.event_emit", { turnId: getAgentStreamEventTurnId(tagged), @@ -3523,11 +3797,12 @@ class OpenCodeAgentSession implements AgentSession { throw new Error(`No pending permission request with id '${requestId}'`); } + const directory = this.pendingPermissionDirectories.get(requestId) ?? this.config.cwd; if (pending.kind === "question") { if (response.behavior === "deny") { await this.client.question.reject({ requestID: requestId, - directory: this.config.cwd, + directory, }); } else { const answersRecord = readOpenCodeRecord(response.updatedInput?.answers); @@ -3546,24 +3821,26 @@ class OpenCodeAgentSession implements AgentSession { await this.client.question.reply({ requestID: requestId, - directory: this.config.cwd, + directory, answers, }); } this.pendingPermissions.delete(requestId); + this.pendingPermissionDirectories.delete(requestId); return; } const reply = resolveOpenCodePermissionReply(response); await this.client.permission.reply({ requestID: requestId, - directory: this.config.cwd, + directory, reply, message: response.behavior === "deny" ? response.message : undefined, }); this.pendingPermissions.delete(requestId); + this.pendingPermissionDirectories.delete(requestId); } describePersistence(): AgentPersistenceHandle | null { @@ -3748,8 +4025,8 @@ class OpenCodeAgentSession implements AgentSession { ); } - private async translateEvent(event: OpenCodeEvent): Promise { - const translated = translateOpenCodeEvent(event, { + private createTranslationState(): OpenCodeEventTranslationState { + return { sessionId: this.sessionId, cwd: this.config.cwd, messageRoles: this.messageRoles, @@ -3765,7 +4042,7 @@ class OpenCodeAgentSession implements AgentSession { partTypes: this.partTypes, subAgentsByCallId: this.subAgentsByCallId, subAgentCallIdByChildSessionId: this.subAgentCallIdByChildSessionId, - pendingChildToolPartsBySessionId: this.pendingChildToolPartsBySessionId, + knownChildSessionIds: this.knownChildSessionIds, modelContextWindowsByModelKey: this.modelContextWindowsByModelKey, onAssistantModelContextWindowResolved: (contextWindowMaxTokens) => { this.accumulatedUsage.contextWindowMaxTokens = contextWindowMaxTokens; @@ -3773,7 +4050,127 @@ class OpenCodeAgentSession implements AgentSession { this.selectedModelContextWindowMaxTokens = contextWindowMaxTokens; } }, - }); + }; + } + + private getChildTranslationState(sessionId: string): OpenCodeEventTranslationState { + const existing = this.childTranslationStates.get(sessionId); + if (existing) { + return existing; + } + const state: OpenCodeEventTranslationState = { + sessionId, + cwd: this.config.cwd, + messageRoles: new Map(), + emittedUserMessageIds: new Set(), + accumulatedUsage: {}, + streamedPartKeys: new Set(), + emittedStructuredMessageIds: new Set(), + compactionSummaryMessageIds: new Set(), + emittedCompactionPartIds: new Set(), + hydratedMessageFingerprints: new Map(), + hydratedPartFingerprints: new Map(), + suppressAssistantMessagesUntilIdle: { active: false }, + partTypes: new Map(), + subAgentsByCallId: new Map(), + subAgentCallIdByChildSessionId: new Map(), + knownChildSessionIds: new Set(), + modelContextWindowsByModelKey: this.modelContextWindowsByModelKey, + }; + this.childTranslationStates.set(sessionId, state); + return state; + } + + private appendProviderSubagentEvents(event: OpenCodeEvent, translated: AgentStreamEvent[]): void { + const childSessionId = getOpenCodeEventSessionId(event); + const isKnownChild = childSessionId && this.knownChildSessionIds.has(childSessionId); + if (!childSessionId || childSessionId === this.sessionId || !isKnownChild) { + return; + } + translated.push(...this.translateProviderSubagentEvent(childSessionId, event)); + } + + private translateProviderSubagentEvent( + sessionId: string, + event: OpenCodeEvent, + ): AgentStreamEvent[] { + const translated = translateOpenCodeEvent(event, this.getChildTranslationState(sessionId)); + const events: AgentStreamEvent[] = []; + let markedRunning = false; + const markRunning = () => { + if (markedRunning) return; + markedRunning = true; + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: sessionId, status: "running" }, + }); + }; + if (event.type === "session.status" && event.properties.status.type === "busy") { + markRunning(); + } + for (const childEvent of translated) { + if (childEvent.type === "timeline") { + markRunning(); + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { + type: "timeline", + id: sessionId, + item: childEvent.item, + timestamp: childEvent.timestamp, + }, + }); + } else if (childEvent.type === "turn_started") { + markRunning(); + } else if (childEvent.type === "turn_completed") { + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: sessionId, status: "completed" }, + }); + } else if (childEvent.type === "turn_failed") { + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: sessionId, status: "failed" }, + }); + } else if (childEvent.type === "turn_canceled") { + events.push({ + type: "provider_subagent", + provider: "opencode", + event: { type: "upsert", id: sessionId, status: "canceled" }, + }); + } else if ( + childEvent.type === "permission_requested" && + childEvent.request.kind === "question" + ) { + events.push(childEvent); + } + } + return events; + } + + private async translateEvent(event: OpenCodeEvent): Promise { + const eventSessionId = getOpenCodeEventSessionId(event); + if ( + event.type !== "session.created" && + eventSessionId && + eventSessionId !== this.sessionId && + !this.knownChildSessionIds.has(eventSessionId) && + !this.unrelatedSessionIds.has(eventSessionId) + ) { + if (!this.childHydrationCompleted) { + this.startChildSessionHydration(); + await this.childHydrationPromise?.catch(() => undefined); + } + if (!this.knownChildSessionIds.has(eventSessionId)) { + this.unrelatedSessionIds.add(eventSessionId); + } + } + const translated = translateOpenCodeEvent(event, this.createTranslationState()); + this.appendProviderSubagentEvents(event, translated); const events: AgentStreamEvent[] = []; if (typeof this.accumulatedUsage.totalCostUsd === "number") { @@ -3784,12 +4181,20 @@ class OpenCodeAgentSession implements AgentSession { } for (const translatedEvent of translated) { + this.recordProviderInternalEvent(translatedEvent); if (translatedEvent.type === "permission_requested") { - const autoApproved = await this.tryAutoApproveToolPermission(translatedEvent.request); + const directory = + (eventSessionId ? this.childSessionCwds.get(eventSessionId) : undefined) ?? + this.config.cwd; + const autoApproved = await this.tryAutoApproveToolPermission( + translatedEvent.request, + directory, + ); if (autoApproved) { continue; } this.pendingPermissions.set(translatedEvent.request.id, translatedEvent.request); + this.pendingPermissionDirectories.set(translatedEvent.request.id, directory); } if (translatedEvent.type === "turn_completed") { if (hasNormalizedOpenCodeUsage(this.accumulatedUsage)) { @@ -3805,7 +4210,10 @@ class OpenCodeAgentSession implements AgentSession { return events; } - private async tryAutoApproveToolPermission(request: AgentPermissionRequest): Promise { + private async tryAutoApproveToolPermission( + request: AgentPermissionRequest, + directory: string, + ): Promise { if (!this.autoAcceptEnabled || request.kind !== "tool") { return false; } @@ -3813,7 +4221,7 @@ class OpenCodeAgentSession implements AgentSession { try { await this.client.permission.reply({ requestID: request.id, - directory: this.config.cwd, + directory, reply: "once", }); return true; diff --git a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts index e4ad07d14..60c93c6ef 100644 --- a/packages/server/src/server/agent/providers/opencode-server-manager.test.ts +++ b/packages/server/src/server/agent/providers/opencode-server-manager.test.ts @@ -164,6 +164,34 @@ describe("OpenCodeServerManager generations", () => { expect(runtime.terminatedPorts).toEqual([4474]); }); + test("acquireExisting keeps a retired dedicated server alive until every reference releases", async () => { + const { manager, runtime } = createTestManager([4475]); + + const dedicatedAcquisition = await manager.acquireDedicated({ PASEO_AGENT_ID: "parent" }); + const existingAcquisition = manager.acquireExisting(dedicatedAcquisition.server.url); + + expect(existingAcquisition?.server.url).toBe("http://127.0.0.1:4475"); + + dedicatedAcquisition.release(); + expect(runtime.terminatedPorts).toEqual([]); + + existingAcquisition?.release(); + expect(runtime.terminatedPorts).toEqual([4475]); + }); + + test("acquireExisting returns null for unknown or dead server urls", async () => { + const { manager, runtime } = createTestManager([4476]); + + const acquisition = await manager.acquireDedicated({ PASEO_AGENT_ID: "parent" }); + const url = acquisition.server.url; + + expect(manager.acquireExisting("http://127.0.0.1:9999")).toBe(null); + + acquisition.release(); + expect(runtime.terminatedPorts).toEqual([4476]); + expect(manager.acquireExisting(url)).toBe(null); + }); + test("repeated rotations leave zero unreferenced retired servers", async () => { const { manager, runtime } = createTestManager([4501, 4502, 4503]); diff --git a/packages/server/src/server/agent/providers/opencode/server-manager.ts b/packages/server/src/server/agent/providers/opencode/server-manager.ts index 566151f73..3fbb89ad2 100644 --- a/packages/server/src/server/agent/providers/opencode/server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/server-manager.ts @@ -29,6 +29,7 @@ export interface OpenCodeServerManagerLike { acquireCurrent(): Promise; acquireNew(): Promise; acquireDedicated(env: Record): Promise; + acquireExisting(url: string): OpenCodeServerAcquisition | null; shutdown(): Promise; } @@ -164,6 +165,27 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike { } } + acquireExisting(url: string): OpenCodeServerAcquisition | null { + const server = this.findLiveServerByUrl(url); + return server ? this.acquireServer(server) : null; + } + + private findLiveServerByUrl(url: string): OpenCodeServerGeneration | null { + const servers = [ + ...(this.currentServer ? [this.currentServer] : []), + ...Array.from(this.retiredServers), + ]; + return servers.find((server) => server.url === url && this.isServerLive(server)) ?? null; + } + + private isServerLive(server: OpenCodeServerGeneration): boolean { + return ( + !server.process.killed && + server.process.exitCode === null && + server.process.signalCode === null + ); + } + private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition { server.refCount += 1; let released = false; diff --git a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts index d7461f672..f13a2eb95 100644 --- a/packages/server/src/server/agent/providers/opencode/test-server-manager.ts +++ b/packages/server/src/server/agent/providers/opencode/test-server-manager.ts @@ -1,8 +1,9 @@ import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js"; export interface TestOpenCodeServerAcquisition { - kind: "current" | "new" | "dedicated"; + kind: "current" | "new" | "dedicated" | "existing"; env?: Record; + url?: string; released: boolean; } @@ -28,14 +29,20 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike { return this.recordAcquisition({ kind: "dedicated", env }); } + acquireExisting(url: string): OpenCodeServerAcquisition | null { + return url === this.server.url ? this.recordAcquisition({ kind: "existing", url }) : null; + } + private recordAcquisition(input: { kind: TestOpenCodeServerAcquisition["kind"]; env?: Record; + url?: string; }): OpenCodeServerAcquisition { const acquisition: TestOpenCodeServerAcquisition = { kind: input.kind, released: false, ...(input.env ? { env: input.env } : {}), + ...(input.url ? { url: input.url } : {}), }; this.acquisitions.push(acquisition); return { diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts index 3935b10f0..754b1b86d 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/test-opencode-harness.ts @@ -9,8 +9,9 @@ interface OpenCodeResponse { export class TestOpenCodeHarness implements OpenCodeServerManagerLike { readonly acquisitions: Array<{ - kind: "current" | "new" | "dedicated"; + kind: "current" | "new" | "dedicated" | "existing"; env?: Record; + url?: string; releaseCount: number; }> = []; readonly clientCreations: Array<{ baseUrl: string; directory: string }> = []; @@ -34,14 +35,20 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike { return this.recordAcquisition({ kind: "dedicated", env }); } + acquireExisting(url: string): OpenCodeServerAcquisition | null { + return url === this.server.url ? this.recordAcquisition({ kind: "existing", url }) : null; + } + private recordAcquisition(input: { - kind: "current" | "new" | "dedicated"; + kind: "current" | "new" | "dedicated" | "existing"; env?: Record; + url?: string; }): OpenCodeServerAcquisition { const acquisition = { kind: input.kind, releaseCount: 0, ...(input.env ? { env: input.env } : {}), + ...(input.url ? { url: input.url } : {}), }; this.acquisitions.push(acquisition); return { @@ -82,6 +89,7 @@ export class TestOpenCodeClient { sessionCommand: [] as unknown[], sessionCreate: [] as unknown[], sessionDelete: [] as unknown[], + sessionChildren: [] as unknown[], sessionGet: [] as unknown[], sessionMessages: [] as unknown[], sessionPromptAsync: [] as unknown[], @@ -106,6 +114,8 @@ export class TestOpenCodeClient { sessionCommandResponse: OpenCodeResponse = {}; sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } }; sessionDeleteResponse: OpenCodeResponse = {}; + sessionChildrenResponses: OpenCodeResponse[] = []; + sessionChildrenImplementation: ((parameters: unknown) => Promise) | null = null; sessionGetResponse: OpenCodeResponse = { data: { id: "session-1", directory: "/workspace/repo", title: null }, }; @@ -219,6 +229,13 @@ export class TestOpenCodeClient { this.calls.sessionDelete.push(parameters); return this.sessionDeleteResponse; }, + children: async (parameters: unknown) => { + this.calls.sessionChildren.push(parameters); + if (this.sessionChildrenImplementation) { + return await this.sessionChildrenImplementation(parameters); + } + return this.sessionChildrenResponses.shift() ?? { data: [] }; + }, get: async (parameters: unknown) => { this.calls.sessionGet.push(parameters); return this.sessionGetResponse; diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 894ccb300..53edfa871 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -1165,6 +1165,43 @@ export class Session { return; } + if (event.type === "provider_subagent") { + if (!this.supports(CLIENT_CAPS.providerSubagents)) { + return; + } + const update = event.event; + if (update.type === "upsert") { + this.emit({ + type: "agent.provider_subagents.update", + payload: { kind: "upsert", subagent: update.subagent }, + }); + } else if (update.type === "timeline") { + this.emit({ + type: "agent.provider_subagents.update", + payload: { + kind: "timeline", + parentAgentId: update.parentAgentId, + subagentId: update.subagentId, + provider: update.provider, + item: update.row.item, + timestamp: update.row.timestamp, + seq: update.row.seq, + epoch: update.epoch, + }, + }); + } else { + this.emit({ + type: "agent.provider_subagents.update", + payload: { + kind: "remove", + parentAgentId: update.parentAgentId, + subagentId: update.subagentId, + }, + }); + } + return; + } + if ( this.voiceSession.isActiveForAgent(event.agentId) && event.event.type === "permission_requested" && @@ -1450,6 +1487,10 @@ export class Session { switch (msg.type) { case "fetch_agent_timeline_request": return this.handleFetchAgentTimelineRequest(msg); + case "agent.provider_subagents.list.request": + return this.handleProviderSubagentListRequest(msg); + case "agent.provider_subagents.timeline.get.request": + return this.handleProviderSubagentTimelineRequest(msg); case "agent.fork_context.request": return this.handleAgentForkContextRequest(msg); default: @@ -2708,7 +2749,7 @@ export class Session { extractTimestamps(record), ); } - await this.agentManager.hydrateTimelineFromProvider(agentId); + await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true }); await this.agentUpdates.forwardLiveAgent(snapshot); const timelineSize = this.agentManager.getTimeline(agentId).length; if (requestId) { @@ -5215,6 +5256,96 @@ export class Session { } } + private async handleProviderSubagentListRequest( + msg: Extract, + ): Promise { + try { + this.emit({ + type: "agent.provider_subagents.list.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagents: this.agentManager.listProviderSubagents(msg.parentAgentId), + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "agent.provider_subagents.list.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagents: [], + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + private async handleProviderSubagentTimelineRequest( + msg: Extract, + ): Promise { + const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail"); + try { + const descriptor = this.agentManager.getProviderSubagent(msg.parentAgentId, msg.subagentId); + if (!descriptor) { + throw new Error("Provider subagent not found"); + } + const timeline = this.agentManager.fetchProviderSubagentTimeline( + msg.parentAgentId, + msg.subagentId, + { + direction, + cursor: msg.cursor, + limit: msg.limit ?? (direction === "after" ? 0 : 200), + }, + ); + this.emit({ + type: "agent.provider_subagents.timeline.get.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagentId: msg.subagentId, + provider: descriptor.provider, + direction, + epoch: timeline.epoch, + reset: timeline.reset, + staleCursor: timeline.staleCursor, + gap: timeline.gap, + window: timeline.window, + hasOlder: timeline.hasOlder, + hasNewer: timeline.hasNewer, + rows: timeline.rows.map((row) => ({ + item: row.item, + timestamp: row.timestamp, + seq: row.seq, + })), + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "agent.provider_subagents.timeline.get.response", + payload: { + requestId: msg.requestId, + parentAgentId: msg.parentAgentId, + subagentId: msg.subagentId, + provider: null, + direction, + epoch: "", + reset: false, + staleCursor: false, + gap: false, + window: { minSeq: 0, maxSeq: 0, nextSeq: 0 }, + hasOlder: false, + hasNewer: false, + rows: [], + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + private async handleAgentForkContextRequest( msg: Extract, ): Promise { diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 5b45cf7a3..6a74ad5af 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -1259,6 +1259,8 @@ export class VoiceAssistantWebSocketServer { daemonSelfUpdate: true, // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. agentForkContext: true, + // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. + providerSubagents: true, }, }; }