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>
This commit is contained in:
Mohamed Boudra
2026-07-12 21:13:00 +02:00
committed by GitHub
parent 41d882859c
commit 66445adc07
46 changed files with 5056 additions and 337 deletions

View File

@@ -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.

View File

@@ -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<AgentHandle> {
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()}`,

View File

@@ -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<typeof launchAgent>[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 });
}
});
}
});

View File

@@ -228,13 +228,20 @@ export interface AgentStreamViewHandle {
export interface AgentStreamViewProps {
agentId: string;
serverId?: string;
agent: AgentScreenAgent;
context: AgentScreenAgent;
streamItems: StreamItem[];
streamHead?: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
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<AgentStreamViewHandle, AgentStreamVi
{
agentId,
serverId,
agent,
context,
streamItems,
streamHead: providedStreamHead,
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
onOpenWorkspaceFile,
readOnly = false,
historyPagination,
},
ref,
) {
@@ -337,27 +347,37 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const setExplorerTabForCheckout = usePanelStore((state) => 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<AgentStreamViewHandle, AgentStreamVi
return;
}
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
if (!normalized) {
return;
}
@@ -402,10 +422,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
if (agent.workspaceId) {
if (context.workspaceId) {
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId: agent.workspaceId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
@@ -419,8 +439,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const checkout = {
serverId: resolvedServerId,
cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
};
setExplorerTabForCheckout({ ...checkout, tab: "files" });
openFileExplorerForCheckout({
@@ -444,7 +464,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const draftSetup = buildForkDraftSetup(agent);
const draftSetup = buildForkDraftSetup(context);
const prepareForkDraft = async () => {
const draftId = generateDraftId();
const payload = await client.buildAgentForkContext(
@@ -466,7 +486,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
};
if (target === "tab") {
const workspaceId = agent.workspaceId;
const workspaceId = context.workspaceId;
if (!workspaceId) {
throw new Error(t("message.actions.forkMissingWorkspace"));
}
@@ -481,7 +501,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const draftId = await prepareForkDraft();
const sourceDirectory =
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
context.projectPlacement?.checkout?.cwd?.trim() || context.cwd.trim() || undefined;
if (draftSetup) {
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId,
@@ -493,8 +513,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
buildNewWorkspaceRoute({
serverId: resolvedServerId,
sourceDirectory,
displayName: agent.projectPlacement?.projectName,
projectId: agent.projectPlacement?.projectKey,
displayName: context.projectPlacement?.projectName,
projectId: context.projectPlacement?.projectKey,
draftId,
}),
);
@@ -520,24 +540,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const baseRenderModel = useMemo(() => {
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<AgentStreamViewHandle, AgentStreamVi
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
capabilities={agent.capabilities}
capabilities={context.capabilities}
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
/>
);
},
[agent.capabilities, agentId, client, resolvedServerId],
[context.capabilities, agentId, client, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -668,7 +688,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
error={data.error}
status={data.status}
detail={data.detail}
cwd={agent.cwd}
cwd={context.cwd}
metadata={data.metadata}
isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile}
@@ -690,7 +710,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
/>
);
},
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
const renderStreamItemContent = useCallback(
@@ -747,10 +767,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
content,
layoutItem,
strategy: streamRenderStrategy,
onForkAssistantTurn: handleForkAssistantTurn,
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
});
},
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
@@ -758,7 +778,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = agent.status === "running";
const showRunningTurnFooter = context.status === "running";
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
@@ -775,11 +795,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
onForkAssistantTurn={handleForkAssistantTurn}
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
/>
) : 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;
}

View File

@@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({
<AgentStreamView
agentId={tabId}
serverId={serverId}
agent={draftAgent}
context={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}

View File

@@ -78,6 +78,7 @@ import {
applyLegacyDaemonWorkspaceOwnership,
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
} from "@/workspace/legacy-daemon-workspaces";
import { useProviderSubagentStore } from "@/subagents/provider-store";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -1349,6 +1350,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyTimelineResponse(message.payload);
});
const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => {
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();

View File

@@ -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({
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>

View File

@@ -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<string, PendingPermission>();
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<AgentScreenAgent>(
() => ({
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 (
<View style={styles.unsupported} testID="provider-subagent-panel-unsupported">
<Text style={styles.unsupportedText}>{t("message.actions.forkUnavailable")}</Text>
</View>
);
}
return (
<View style={styles.container} testID="provider-subagent-panel">
<AgentStreamView
agentId={streamId}
serverId={serverId}
context={streamContext}
streamItems={timeline?.tail ?? EMPTY_STREAM_ITEMS}
streamHead={timeline?.head ?? EMPTY_STREAM_ITEMS}
pendingPermissions={EMPTY_PERMISSIONS}
isAuthoritativeHistoryReady
onOpenWorkspaceFile={openFileInWorkspace}
readOnly
historyPagination={historyPagination}
/>
</View>
);
}
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,
};

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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)}`;
}

View File

@@ -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<string, unknown>): 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 });
}

View File

@@ -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." }),
]);
});
});

View File

@@ -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<SessionOutboundMessage, { type: "agent.provider_subagents.update" }>["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<number, ProviderSubagentTimelineRow>;
}
interface ProviderSubagentState {
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
timelines: Map<string, ProviderSubagentTimelineState>;
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<DaemonClient, "listProviderSubagents">;
const pendingListRequests = new WeakMap<ProviderSubagentListClient, Map<string, Promise<void>>>();
export function refreshProviderSubagents(
client: ProviderSubagentListClient,
serverId: string,
parentAgentId: string,
): Promise<void> {
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<number, ProviderSubagentTimelineRow>();
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<ProviderSubagentState>((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 };
});
},
}));

View File

@@ -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",

View File

@@ -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<typeof useSessionStore.getState>;
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
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]);
}

View File

@@ -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<SubagentRow> & Pick<SubagentRow, "id">): SubagentRow {
function row(
overrides: Partial<PaseoSubagentRow> & Pick<PaseoSubagentRow, "id">,
): 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", () => {

View File

@@ -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,
}),
};

View File

@@ -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({
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={actionsVisible}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
{row.kind === "paseo" ? (
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={actionsVisible}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
) : null}
</View>
)}
</Pressable>

View File

@@ -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);
});
});

View File

@@ -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}`;
}

View File

@@ -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: {

View File

@@ -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<ProviderSubagentListPayload> {
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<ProviderSubagentTimelinePayload> {
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 } : {}),

View File

@@ -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;

View File

@@ -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" }] } });
});
});

View File

@@ -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()

View File

@@ -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<AgentSession> {
activeSession = new TestAgentSession(config);
return activeSession;
}
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
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<AgentStreamEvent> {
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<AgentSession> {
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<AgentSession> {
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<AgentSession> {
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-"));

View File

@@ -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<AgentProvider, boolean>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly providerSubagents = new ProviderSubagentStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
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<void> {
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<AgentStreamEvent, { type: "timeline" }>[] = [];
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<void> {
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
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<void> {
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 {

View File

@@ -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<AgentStreamEvent, { type: "provider_subagent" }>[];
}
export interface AgentSessionConfig {

View File

@@ -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<AgentStreamEvent>,
): Promise<ImportedTimelineEntry[]> {
async function collectImportedHistory(events: AsyncGenerator<AgentStreamEvent>): Promise<{
timeline: ImportedTimelineEntry[];
providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[];
}> {
const timeline: ImportedTimelineEntry[] = [];
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
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 };
}

View File

@@ -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);
});
});

View File

@@ -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<string, ProviderSubagentDescriptor>();
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;
}
}

View File

@@ -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<string, unknown> } | 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)));

View File

@@ -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<AgentStreamEvent> {
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<AgentMode[]> {
@@ -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<SDKMessage, { type: "user" }>,
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<string, ClaudeHistoricalSubagentToolCall> {
const toolCalls = new Map<string, ClaudeHistoricalSubagentToolCall>();
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<string, { toolCallId: string; failed: boolean }> {
const results = new Map<string, { toolCallId: string; failed: boolean }>();
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<string, ClaudeHistoryEntry[]> {
const entriesByAgentId = new Map<string, ClaudeHistoryEntry[]>();
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<AgentStreamEvent, { type: "provider_subagent" }>[] {
const events: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
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<string, ClaudeHistoricalSubagentToolCall>,
toolResults: ReadonlyMap<string, { toolCallId: string; failed: boolean }>,
convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[],
): Extract<AgentStreamEvent, { type: "provider_subagent" }>[] {
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<AgentStreamEvent, { type: "provider_subagent" }>[] = [
{
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;

View File

@@ -15,6 +15,7 @@ let lastQuery: ReturnType<typeof buildSdkQueryMock> | 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({

View File

@@ -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<string, number>;
completedActionKeys: Set<string>;
}
interface SubAgentActionCandidate {
@@ -64,19 +70,34 @@ export class ClaudeSidechainTracker {
actionKeys: [],
nextActionIndex: 1,
actionIndexByKey: new Map<string, number>(),
completedActionKeys: new Set<string>(),
} 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<string, unknown>;
const messageContainer = messageRecord.message as Record<string, unknown> | 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);
}
}

View File

@@ -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",

View File

@@ -3061,6 +3061,7 @@ interface CodexSubAgentCallState {
pendingFileChangeOutputDeltas: Map<string, string[]>;
childItemOrder: string[];
childItems: Map<string, AgentTimelineItem>;
childThreadIds: Set<string>;
}
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<string, AgentPermissionRequest>();
private mcpElicitationPermissionIds = new Map<number, string>();
private pendingPermissionHandlers = new Map<
@@ -3105,6 +3108,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private emittedExecCommandCompletedCallIds = new Set<string>();
private emittedItemStartedIds = new Set<string>();
private emittedItemCompletedIds = new Set<string>();
private emittedProviderSubagentUserMessageKeys = new Set<string>();
private subAgentCallsByCallId = new Map<string, CodexSubAgentCallState>();
private subAgentCallIdByChildThreadId = new Map<string, string>();
private pendingSubAgentNotificationsByThreadId = new Map<string, ParsedCodexNotification[]>();
@@ -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<void> {
const queue = rootRoutes.map((route) => ({ route, parentCallId: null as string | null }));
const visitedThreadIds = new Set<string>();
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<void> {
if (!this.client || !this.currentThreadId) return;
try {
@@ -3818,12 +3860,20 @@ export class CodexAppServerAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
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<string, string[]>(),
childItemOrder: [],
childItems: new Map<string, AgentTimelineItem>(),
childThreadIds: new Set<string>(),
} 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<ParsedCodexNotification, { kind: "item_completed" }>,
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<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
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<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -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]);

View File

@@ -29,6 +29,7 @@ export interface OpenCodeServerManagerLike {
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
acquireNew(): Promise<OpenCodeServerAcquisition>;
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
acquireExisting(url: string): OpenCodeServerAcquisition | null;
shutdown(): Promise<void>;
}
@@ -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;

View File

@@ -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<string, string>;
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<string, string>;
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 {

View File

@@ -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<string, string>;
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<string, string>;
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<OpenCodeResponse>) | 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;

View File

@@ -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<SessionInboundMessage, { type: "agent.provider_subagents.list.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "agent.provider_subagents.timeline.get.request" }>,
): Promise<void> {
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<SessionInboundMessage, { type: "agent.fork_context.request" }>,
): Promise<void> {

View File

@@ -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,
},
};
}