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-13 03:13:00 +08:00
committed by GitHub
co-authored by Omer
parent 41d882859c
commit 66445adc07
46 changed files with 5056 additions and 337 deletions
+10 -2
View File
@@ -71,13 +71,21 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**:
## The subagents track ## 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 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. 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.
+5
View File
@@ -154,6 +154,10 @@ export async function launchAgent(input: {
provider: RewindFlowProvider; provider: RewindFlowProvider;
cwd: string; cwd: string;
mode: "full-access"; mode: "full-access";
providerConfig?: {
model?: string;
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
};
}): Promise<AgentHandle> { }): Promise<AgentHandle> {
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" }); execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], { execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
@@ -180,6 +184,7 @@ export async function launchAgent(input: {
} }
const agent = await client.createAgent({ const agent = await client.createAgent({
...fullAccessConfig(input.provider), ...fullAccessConfig(input.provider),
...input.providerConfig,
cwd: input.cwd, cwd: input.cwd,
workspaceId: createdWorkspace.workspace.id, workspaceId: createdWorkspace.workspace.id,
title: `rewind-flow-${input.provider}-${randomUUID()}`, title: `rewind-flow-${input.provider}-${randomUUID()}`,
@@ -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 });
}
});
}
});
+68 -31
View File
@@ -228,13 +228,20 @@ export interface AgentStreamViewHandle {
export interface AgentStreamViewProps { export interface AgentStreamViewProps {
agentId: string; agentId: string;
serverId?: string; serverId?: string;
agent: AgentScreenAgent; context: AgentScreenAgent;
streamItems: StreamItem[]; streamItems: StreamItem[];
streamHead?: StreamItem[];
pendingPermissions: Map<string, PendingPermission>; pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null; routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean; isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null; toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void; onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
readOnly?: boolean;
historyPagination?: {
hasOlder: boolean;
isLoadingOlder: boolean;
onLoadOlder: () => void;
};
} }
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [ const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
@@ -306,13 +313,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
{ {
agentId, agentId,
serverId, serverId,
agent, context,
streamItems, streamItems,
streamHead: providedStreamHead,
pendingPermissions, pendingPermissions,
routeBottomAnchorRequest = null, routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true, isAuthoritativeHistoryReady = true,
toast, toast,
onOpenWorkspaceFile, onOpenWorkspaceFile,
readOnly = false,
historyPagination,
}, },
ref, ref,
) { ) {
@@ -337,27 +347,37 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
// Get serverId (fallback to agent's serverId if not provided) // 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 client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
const streamHead = useSessionStore((state) => const sessionStreamHead = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId), state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
); );
const streamHead = providedStreamHead ?? sessionStreamHead;
const supportsAgentForkContext = useSessionStore( 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({ const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId, serverId: resolvedServerId,
workspaceId: agent.workspaceId, workspaceId: context.workspaceId,
workspaceRoot, workspaceRoot,
}); });
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({ const agentHistoryPagination = useLoadOlderAgentHistory({
serverId: resolvedServerId, serverId: resolvedServerId,
agentId, agentId,
toast, 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 // Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422. // tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android"; const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -379,7 +399,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return; return;
} }
const normalized = normalizeInlinePathTarget(target.path, agent.cwd); const normalized = normalizeInlinePathTarget(target.path, context.cwd);
if (!normalized) { if (!normalized) {
return; return;
} }
@@ -402,10 +422,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return; return;
} }
if (agent.workspaceId) { if (context.workspaceId) {
navigateToPreparedWorkspaceTab({ navigateToPreparedWorkspaceTab({
serverId: resolvedServerId, serverId: resolvedServerId,
workspaceId: agent.workspaceId, workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location), target: createWorkspaceFileTabTarget(location),
}); });
} }
@@ -419,8 +439,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const checkout = { const checkout = {
serverId: resolvedServerId, serverId: resolvedServerId,
cwd: agent.cwd, cwd: context.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true, isGit: context.projectPlacement?.checkout?.isGit ?? true,
}; };
setExplorerTabForCheckout({ ...checkout, tab: "files" }); setExplorerTabForCheckout({ ...checkout, tab: "files" });
openFileExplorerForCheckout({ openFileExplorerForCheckout({
@@ -444,7 +464,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (!client) { if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected")); throw new Error(t("workspace.terminal.hostDisconnected"));
} }
const draftSetup = buildForkDraftSetup(agent); const draftSetup = buildForkDraftSetup(context);
const prepareForkDraft = async () => { const prepareForkDraft = async () => {
const draftId = generateDraftId(); const draftId = generateDraftId();
const payload = await client.buildAgentForkContext( const payload = await client.buildAgentForkContext(
@@ -466,7 +486,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}; };
if (target === "tab") { if (target === "tab") {
const workspaceId = agent.workspaceId; const workspaceId = context.workspaceId;
if (!workspaceId) { if (!workspaceId) {
throw new Error(t("message.actions.forkMissingWorkspace")); throw new Error(t("message.actions.forkMissingWorkspace"));
} }
@@ -481,7 +501,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const draftId = await prepareForkDraft(); const draftId = await prepareForkDraft();
const sourceDirectory = const sourceDirectory =
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined; context.projectPlacement?.checkout?.cwd?.trim() || context.cwd.trim() || undefined;
if (draftSetup) { if (draftSetup) {
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({ useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId, draftId,
@@ -493,8 +513,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
buildNewWorkspaceRoute({ buildNewWorkspaceRoute({
serverId: resolvedServerId, serverId: resolvedServerId,
sourceDirectory, sourceDirectory,
displayName: agent.projectPlacement?.projectName, displayName: context.projectPlacement?.projectName,
projectId: agent.projectPlacement?.projectKey, projectId: context.projectPlacement?.projectKey,
draftId, draftId,
}), }),
); );
@@ -520,24 +540,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const baseRenderModel = useMemo(() => { const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({ return buildAgentStreamRenderModel({
agentStatus: agent.status, agentStatus: context.status,
tail: effectiveStreamItems, tail: effectiveStreamItems,
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD, head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
platform: isWeb ? "web" : "native", platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile, isMobileBreakpoint: isMobile,
}); });
}, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]); }, [context.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
const streamLayout = useMemo( const streamLayout = useMemo(
() => () =>
layoutStream({ layoutStream({
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
agentStatus: agent.status, agentStatus: context.status,
history: baseRenderModel.history, history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead, liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId, timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}), }),
[ [
agent.status, context.status,
baseRenderModel.history, baseRenderModel.history,
baseRenderModel.segments.liveHead, baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId, baseRenderModel.turnTiming.byAssistantId,
@@ -590,14 +610,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
images={item.images} images={item.images}
attachments={item.attachments} attachments={item.attachments}
timestamp={item.timestamp.getTime()} timestamp={item.timestamp.getTime()}
capabilities={agent.capabilities} capabilities={context.capabilities}
client={client} client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup} isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup} isLastInGroup={layoutItem.isLastInUserGroup}
/> />
); );
}, },
[agent.capabilities, agentId, client, resolvedServerId], [context.capabilities, agentId, client, resolvedServerId],
); );
const renderAssistantMessageItem = useCallback( const renderAssistantMessageItem = useCallback(
@@ -668,7 +688,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
error={data.error} error={data.error}
status={data.status} status={data.status}
detail={data.detail} detail={data.detail}
cwd={agent.cwd} cwd={context.cwd}
metadata={data.metadata} metadata={data.metadata}
isLastInSequence={layoutItem.isLastInToolSequence} isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile} onOpenFilePath={handleToolCallOpenFile}
@@ -690,7 +710,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
/> />
); );
}, },
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile], [context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
); );
const renderStreamItemContent = useCallback( const renderStreamItemContent = useCallback(
@@ -747,10 +767,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
content, content,
layoutItem, layoutItem,
strategy: streamRenderStrategy, strategy: streamRenderStrategy,
onForkAssistantTurn: handleForkAssistantTurn, onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
}); });
}, },
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy], [handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
); );
const pendingPermissionItems = useMemo( const pendingPermissionItems = useMemo(
@@ -758,7 +778,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId], [pendingPermissions, agentId],
); );
const showRunningTurnFooter = agent.status === "running"; const showRunningTurnFooter = context.status === "running";
const pendingPermissionsNode = useMemo( const pendingPermissionsNode = useMemo(
() => () =>
renderPendingPermissionsNode({ renderPendingPermissionsNode({
@@ -775,11 +795,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt} inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost} host={bottomTurnFooterHost}
strategy={streamRenderStrategy} strategy={streamRenderStrategy}
onForkAssistantTurn={handleForkAssistantTurn} onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
/> />
) : null, ) : null,
[ [
handleForkAssistantTurn, handleForkAssistantTurn,
readOnly,
showRunningTurnFooter, showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt, baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost, 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( function agentStreamViewPropsEqual(
left: AgentStreamViewProps, left: AgentStreamViewProps,
right: AgentStreamViewProps, right: AgentStreamViewProps,
@@ -1011,8 +1043,9 @@ function agentStreamViewPropsEqual(
const reasons: string[] = []; const reasons: string[] = [];
if (left.agentId !== right.agentId) reasons.push("agentId"); if (left.agentId !== right.agentId) reasons.push("agentId");
if (left.serverId !== right.serverId) reasons.push("serverId"); 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.streamItems !== right.streamItems) reasons.push("streamItems");
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions"); if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
if ( if (
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest) !bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
@@ -1024,6 +1057,10 @@ function agentStreamViewPropsEqual(
} }
if (left.toast !== right.toast) reasons.push("toast"); if (left.toast !== right.toast) reasons.push("toast");
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile"); 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); recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
return reasons.length === 0; return reasons.length === 0;
} }
@@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({
<AgentStreamView <AgentStreamView
agentId={tabId} agentId={tabId}
serverId={serverId} serverId={serverId}
agent={draftAgent} context={draftAgent}
streamItems={optimisticStreamItems} streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS} pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile} onOpenWorkspaceFile={onOpenWorkspaceFile}
@@ -78,6 +78,7 @@ import {
applyLegacyDaemonWorkspaceOwnership, applyLegacyDaemonWorkspaceOwnership,
backfillLegacyDaemonWorkspaceDirectoryIfEmpty, backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
} from "@/workspace/legacy-daemon-workspaces"; } from "@/workspace/legacy-daemon-workspaces";
import { useProviderSubagentStore } from "@/subagents/provider-store";
// Re-export types from session-store and draft-store for backward compatibility // Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store"; export type { DraftInput } from "@/stores/draft-store";
@@ -1349,6 +1350,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyTimelineResponse(message.payload); 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) => { const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
if (message.type !== "workspace_update") return; if (message.type !== "workspace_update") return;
if (message.payload.kind === "remove") { if (message.payload.kind === "remove") {
@@ -1750,6 +1756,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubAgentUpdate(); unsubAgentUpdate();
unsubAgentStream(); unsubAgentStream();
unsubAgentTimeline(); unsubAgentTimeline();
unsubProviderSubagentUpdate();
unsubWorkspaceUpdate(); unsubWorkspaceUpdate();
unsubScriptStatusUpdate(); unsubScriptStatusUpdate();
unsubCheckoutStatusUpdate(); unsubCheckoutStatusUpdate();
+9 -2
View File
@@ -1270,7 +1270,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
ref={streamViewRef} ref={streamViewRef}
agentId={agent.id} agentId={agent.id}
serverId={serverId} serverId={serverId}
agent={agent} context={agent}
streamItems={streamItems} streamItems={streamItems}
pendingPermissions={pendingPermissions} pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest} routeBottomAnchorRequest={routeBottomAnchorRequest}
@@ -1364,7 +1364,7 @@ function ActiveAgentComposer({
{ initialIsBelow: isCompactFormFactor }, { initialIsBelow: isCompactFormFactor },
); );
const paneContext = usePaneContext(); const paneContext = usePaneContext();
const { workspaceId, tabId, retargetCurrentTab } = paneContext; const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext;
const { archiveAgent } = useArchiveAgent(); const { archiveAgent } = useArchiveAgent();
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
@@ -1382,6 +1382,12 @@ function ActiveAgentComposer({
}, },
[serverId], [serverId],
); );
const handleOpenProviderSubagent = useCallback(
(parentAgentId: string, subagentId: string) => {
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
},
[openTab],
);
const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId }); const handleDetachSubagent = useDetachSubagent({ serverId });
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
@@ -1482,6 +1488,7 @@ function ActiveAgentComposer({
<SubagentsTrack <SubagentsTrack
rows={subagentRows} rows={subagentRows}
onOpenSubagent={handleOpenSubagent} onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent} onArchiveSubagent={handleArchiveSubagent}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined} onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/> />
@@ -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,
};
@@ -5,6 +5,7 @@ import { filePanelRegistration } from "@/panels/file-panel";
import { registerPanel } from "@/panels/panel-registry"; import { registerPanel } from "@/panels/panel-registry";
import { setupPanelRegistration } from "@/panels/setup-panel"; import { setupPanelRegistration } from "@/panels/setup-panel";
import { terminalPanelRegistration } from "@/panels/terminal-panel"; import { terminalPanelRegistration } from "@/panels/terminal-panel";
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
let panelsRegistered = false; let panelsRegistered = false;
@@ -14,6 +15,7 @@ export function ensurePanelsRegistered(): void {
} }
registerPanel(draftPanelRegistration); registerPanel(draftPanelRegistration);
registerPanel(agentPanelRegistration); registerPanel(agentPanelRegistration);
registerPanel(providerSubagentPanelRegistration);
registerPanel(setupPanelRegistration); registerPanel(setupPanelRegistration);
registerPanel(terminalPanelRegistration); registerPanel(terminalPanelRegistration);
registerPanel(browserPanelRegistration); registerPanel(browserPanelRegistration);
@@ -377,6 +377,9 @@ function getFallbackTabOptionDescription(
if (tab.target.kind === "browser") { if (tab.target.kind === "browser") {
return labels.browser; return labels.browser;
} }
if (tab.target.kind === "provider_subagent") {
return labels.agent;
}
return tab.target.path; return tab.target.path;
} }
@@ -138,6 +138,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "setup") { if (tab.target.kind === "setup") {
return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`; 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)}`; return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
} }
@@ -19,6 +19,7 @@ export interface WorkspaceDraftTabSetup {
export type WorkspaceTabTarget = export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
| { kind: "agent"; agentId: string } | { kind: "agent"; agentId: string }
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
| { kind: "terminal"; terminalId: string } | { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string } | { kind: "browser"; browserId: string }
| WorkspaceFileTabTarget | WorkspaceFileTabTarget
@@ -508,6 +509,17 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
if (kind === "agent" && typeof raw.agentId === "string") { if (kind === "agent" && typeof raw.agentId === "string") {
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); 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") { if (kind === "terminal" && typeof raw.terminalId === "string") {
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId }); return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
} }
@@ -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." }),
]);
});
});
@@ -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 };
});
},
}));
+32 -1
View File
@@ -1,6 +1,7 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest"; 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"; import { useSessionStore, type Agent } from "@/stores/session-store";
const SERVER_ID = "server-1"; const SERVER_ID = "server-1";
@@ -58,9 +59,37 @@ function setAgents(agents: Agent[]): void {
afterEach(() => { afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID); useSessionStore.getState().clearSession(SERVER_ID);
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
}); });
describe("selectSubagentsForParent", () => { 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", () => { it("returns only non-archived children for the requested parent", () => {
setAgents([ setAgents([
makeAgent({ id: "parent-a" }), makeAgent({ id: "parent-a" }),
@@ -194,6 +223,7 @@ describe("selectSubagentsForParent", () => {
expect(rows).toEqual([ expect(rows).toEqual([
{ {
kind: "paseo",
id: "child", id: "child",
provider: "claude", provider: "claude",
title: "Review child", title: "Review child",
@@ -205,6 +235,7 @@ describe("selectSubagentsForParent", () => {
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
"createdAt", "createdAt",
"id", "id",
"kind",
"provider", "provider",
"requiresAttention", "requiresAttention",
"status", "status",
+70 -2
View File
@@ -1,9 +1,13 @@
import { useEffect, useMemo } from "react";
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent"; import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
import equal from "fast-deep-equal"; import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore, type Agent } from "@/stores/session-store"; 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"]; id: Agent["id"];
provider: Agent["provider"]; provider: Agent["provider"];
title: Agent["title"]; title: Agent["title"];
@@ -12,7 +16,21 @@ export interface SubagentRow {
createdAt: Agent["createdAt"]; 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 SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
interface SelectSubagentsParams { interface SelectSubagentsParams {
serverId: string; serverId: string;
@@ -20,9 +38,11 @@ interface SelectSubagentsParams {
} }
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = []; const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
function toSubagentRow(agent: Agent): SubagentRow { function toSubagentRow(agent: Agent): SubagentRow {
return { return {
kind: "paseo",
id: agent.id, id: agent.id,
provider: agent.provider, provider: agent.provider,
title: agent.title, title: agent.title,
@@ -62,11 +82,59 @@ export function selectSubagentsForParent(
return rows; 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[] { export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId); const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
return useStoreWithEqualityFn( const paseoRows = useStoreWithEqualityFn(
useSessionStore, useSessionStore,
(state) => selectSubagentsForParent(state, params, pendingArchiveIds), (state) => selectSubagentsForParent(state, params, pendingArchiveIds),
equal, 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]);
} }
@@ -1,13 +1,16 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { SubagentRow } from "./select"; import type { PaseoSubagentRow, SubagentRow } from "./select";
import { import {
buildSubagentRowPresentationData, buildSubagentRowPresentationData,
formatHeaderLabel, formatHeaderLabel,
resolveRowLabel, resolveRowLabel,
} from "./track-presentation"; } from "./track-presentation";
function row(overrides: Partial<SubagentRow> & Pick<SubagentRow, "id">): SubagentRow { function row(
overrides: Partial<PaseoSubagentRow> & Pick<PaseoSubagentRow, "id">,
): PaseoSubagentRow {
return { return {
kind: "paseo",
id: overrides.id, id: overrides.id,
provider: overrides.provider ?? "codex", provider: overrides.provider ?? "codex",
title: overrides.title ?? `Agent ${overrides.id}`, title: overrides.title ?? `Agent ${overrides.id}`,
@@ -91,7 +94,9 @@ describe("resolveRowLabel", () => {
describe("buildSubagentRowPresentationData", () => { describe("buildSubagentRowPresentationData", () => {
it("namespaces the key with a subagent prefix", () => { 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", () => { it("marks the row ready when the title resolves to a real label", () => {
@@ -1,6 +1,12 @@
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import type { SubagentRow } from "./select"; 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 { export interface SubagentRowPresentationData {
key: string; key: string;
@@ -13,14 +19,15 @@ export interface SubagentRowPresentationData {
export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData { export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData {
const label = resolveRowLabel(row.title); const label = resolveRowLabel(row.title);
const status = presentationStatus(row);
return { return {
key: `subagent_${row.id}`, key: `${row.kind}_subagent_${row.id}`,
kind: "agent", kind: "agent",
label: label ?? "", label: label ?? "",
subtitle: "", subtitle: "",
titleState: label ? "ready" : "loading", titleState: label ? "ready" : "loading",
statusBucket: deriveSidebarStateBucket({ statusBucket: deriveSidebarStateBucket({
status: row.status, status,
requiresAttention: false, requiresAttention: false,
}), }),
}; };
+20 -9
View File
@@ -28,6 +28,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
export interface SubagentsTrackProps { export interface SubagentsTrackProps {
rows: SubagentRow[]; rows: SubagentRow[];
onOpenSubagent: (id: string) => void; onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
onArchiveSubagent: (id: string) => void; onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void; onDetachSubagent?: (id: string) => void;
} }
@@ -44,6 +45,7 @@ function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
export function SubagentsTrack({ export function SubagentsTrack({
rows, rows,
onOpenSubagent, onOpenSubagent,
onOpenProviderSubagent,
onArchiveSubagent, onArchiveSubagent,
onDetachSubagent, onDetachSubagent,
}: SubagentsTrackProps): ReactElement | null { }: SubagentsTrackProps): ReactElement | null {
@@ -105,6 +107,7 @@ export function SubagentsTrack({
key={row.id} key={row.id}
row={row} row={row}
onOpenSubagent={onOpenSubagent} onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
onArchiveSubagent={onArchiveSubagent} onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent} onDetachSubagent={onDetachSubagent}
/> />
@@ -120,6 +123,7 @@ export function SubagentsTrack({
interface SubagentsTrackRowProps { interface SubagentsTrackRowProps {
row: SubagentRow; row: SubagentRow;
onOpenSubagent: (id: string) => void; onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
onArchiveSubagent: (id: string) => void; onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void; onDetachSubagent?: (id: string) => void;
} }
@@ -127,6 +131,7 @@ interface SubagentsTrackRowProps {
function SubagentsTrackRow({ function SubagentsTrackRow({
row, row,
onOpenSubagent, onOpenSubagent,
onOpenProviderSubagent,
onArchiveSubagent, onArchiveSubagent,
onDetachSubagent, onDetachSubagent,
}: SubagentsTrackRowProps): ReactElement { }: SubagentsTrackRowProps): ReactElement {
@@ -137,8 +142,12 @@ function SubagentsTrackRow({
const displayLabel = const displayLabel =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label; presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
const handlePress = useCallback(() => { const handlePress = useCallback(() => {
onOpenSubagent(row.id); if (row.kind === "provider") {
}, [onOpenSubagent, row.id]); onOpenProviderSubagent(row.parentAgentId, row.id);
} else {
onOpenSubagent(row.id);
}
}, [onOpenProviderSubagent, onOpenSubagent, row]);
const handleArchivePress = useCallback(() => { const handleArchivePress = useCallback(() => {
onArchiveSubagent(row.id); onArchiveSubagent(row.id);
}, [onArchiveSubagent, row.id]); }, [onArchiveSubagent, row.id]);
@@ -167,13 +176,15 @@ function SubagentsTrackRow({
<Text style={styles.rowLabel} numberOfLines={1}> <Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel} {displayLabel}
</Text> </Text>
<SubagentRowActions {row.kind === "paseo" ? (
rowId={row.id} <SubagentRowActions
displayLabel={displayLabel} rowId={row.id}
visible={actionsVisible} displayLabel={displayLabel}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined} visible={actionsVisible}
onArchivePress={handleArchivePress} onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
/> onArchivePress={handleArchivePress}
/>
) : null}
</View> </View>
)} )}
</Pressable> </Pressable>
@@ -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);
});
});
@@ -21,6 +21,13 @@ export function normalizeWorkspaceTabTarget(
const agentId = trimNonEmpty(value.agentId); const agentId = trimNonEmpty(value.agentId);
return agentId ? { kind: "agent", agentId } : null; 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") { if (value.kind === "terminal") {
const terminalId = trimNonEmpty(value.terminalId); const terminalId = trimNonEmpty(value.terminalId);
return terminalId ? { kind: "terminal", terminalId } : null; return terminalId ? { kind: "terminal", terminalId } : null;
@@ -76,6 +83,9 @@ export function workspaceTabTargetsEqual(
if (left.kind === "agent" && right.kind === "agent") { if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId; 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") { if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId; return left.terminalId === right.terminalId;
} }
@@ -131,6 +141,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "agent") { if (target.kind === "agent") {
return `agent_${target.agentId}`; 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") { if (target.kind === "terminal") {
return `terminal_${target.terminalId}`; return `terminal_${target.terminalId}`;
} }
@@ -555,6 +555,7 @@ test("advertises client capabilities in hello", async () => {
protocolVersion: 1, protocolVersion: 1,
capabilities: { capabilities: {
custom_mode_icons: true, custom_mode_icons: true,
provider_subagents: true,
reasoning_merge_enum: true, reasoning_merge_enum: true,
terminal_reflowable_snapshot: true, terminal_reflowable_snapshot: true,
browser_host: { browser_host: {
+76
View File
@@ -490,6 +490,22 @@ export interface FetchAgentTimelineOptions {
timeout?: number; 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 // COMPAT(daemon-client-object-options): added in v0.1.102; remove after
// 2026-12-29 once SDK callers have migrated to object parameters. // 2026-12-29 once SDK callers have migrated to object parameters.
function normalizeFetchAgentOptions( function normalizeFetchAgentOptions(
@@ -2429,6 +2445,65 @@ export class DaemonClient {
return payload; 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( async buildAgentForkContext(
agentId: string, agentId: string,
options: AgentForkContextOptions = {}, options: AgentForkContextOptions = {},
@@ -4672,6 +4747,7 @@ export class DaemonClient {
[CLIENT_CAPS.customModeIcons]: true, [CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.reasoningMergeEnum]: true,
[CLIENT_CAPS.terminalReflowableSnapshot]: true, [CLIENT_CAPS.terminalReflowableSnapshot]: true,
[CLIENT_CAPS.providerSubagents]: true,
...this.config.capabilities, ...this.config.capabilities,
}, },
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
@@ -11,6 +11,9 @@ export const CLIENT_CAPS = {
// Old clients use a strict TerminalState schema and would reject the extra fields. // 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. // Drop the gate (always send the flags) when floor >= v0.1.88.
terminalReflowableSnapshot: "terminal_reflowable_snapshot", 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", browserHost: "browser_host",
} as const; } as const;
@@ -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" }] } });
});
});
+106
View File
@@ -1295,6 +1295,22 @@ export const FetchAgentTimelineRequestMessageSchema = z.object({
projection: z.enum(["projected", "canonical"]).optional(), 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({ export const AgentForkContextRequestMessageSchema = z.object({
type: z.literal("agent.fork_context.request"), type: z.literal("agent.fork_context.request"),
agentId: z.string(), agentId: z.string(),
@@ -2089,6 +2105,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
RestartServerRequestMessageSchema, RestartServerRequestMessageSchema,
DaemonUpdateRequestMessageSchema, DaemonUpdateRequestMessageSchema,
FetchAgentTimelineRequestMessageSchema, FetchAgentTimelineRequestMessageSchema,
ProviderSubagentListRequestMessageSchema,
ProviderSubagentTimelineRequestMessageSchema,
AgentForkContextRequestMessageSchema, AgentForkContextRequestMessageSchema,
SetAgentModeRequestMessageSchema, SetAgentModeRequestMessageSchema,
SetAgentModelRequestMessageSchema, SetAgentModelRequestMessageSchema,
@@ -2367,6 +2385,8 @@ export const ServerInfoStatusPayloadSchema = z
daemonSelfUpdate: z.boolean().optional(), daemonSelfUpdate: z.boolean().optional(),
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: z.boolean().optional(), agentForkContext: z.boolean().optional(),
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: z.boolean().optional(),
}) })
.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({ export const AgentForkContextResponseMessageSchema = z.object({
type: z.literal("agent.fork_context.response"), type: z.literal("agent.fork_context.response"),
payload: z.object({ payload: z.object({
@@ -4207,6 +4309,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ArchiveWorkspaceResponseMessageSchema, ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema, FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema, FetchAgentTimelineResponseMessageSchema,
ProviderSubagentListResponseMessageSchema,
ProviderSubagentTimelineResponseMessageSchema,
ProviderSubagentUpdateMessageSchema,
AgentForkContextResponseMessageSchema, AgentForkContextResponseMessageSchema,
CancelAgentResponseMessageSchema, CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema, ClearAgentAttentionResponseMessageSchema,
@@ -4679,6 +4784,7 @@ export const WSHelloMessageSchema = z.object({
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(), [CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(), [CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(), [CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(), [CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
}) })
.passthrough() .passthrough()
@@ -2331,6 +2331,27 @@ test("importProviderSession imports the selected session without listing and pub
timestamp: "2026-01-02T00:00:02.000Z", 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({ expect(events[0]).toMatchObject({
type: "agent_state", type: "agent_state",
agent: { agent: {
@@ -2553,6 +2580,150 @@ test("reloadAgentSession preserves timeline and does not force history replay",
expect(afterHydrate).toEqual(beforeReload); 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 () => { test("reloadAgentSession preserves current title when config title is unset", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-"));
const storagePath = join(workdir, "agents"); 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); 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 () => { test("subscribe emits state events for internal agents when subscribed by agentId", async () => {
const internalAgentId = "00000000-0000-4000-8000-000000000110"; const internalAgentId = "00000000-0000-4000-8000-000000000110";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
+144 -48
View File
@@ -66,6 +66,11 @@ import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js"; import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js"; import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.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 RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -145,6 +150,7 @@ export type {
export type AgentManagerEvent = export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent } | { type: "agent_state"; agent: ManagedAgent }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| { | {
type: "agent_stream"; type: "agent_stream";
agentId: string; agentId: string;
@@ -534,6 +540,7 @@ export class AgentManager {
private readonly providerEnabled = new Map<AgentProvider, boolean>(); private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly agents = new Map<string, LiveManagedAgent>(); private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore(); private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly providerSubagents = new ProviderSubagentStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>(); private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>(); private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly foregroundRuns = new ForegroundRunState(); private readonly foregroundRuns = new ForegroundRunState();
@@ -936,6 +943,28 @@ export class AgentManager {
return this.timelineStore.fetch(id, options); 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( createAgent(
config: AgentSessionConfig, config: AgentSessionConfig,
agentId: string | undefined, agentId: string | undefined,
@@ -1086,7 +1115,7 @@ export class AgentManager {
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows); const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
handedToRegistration = true; handedToRegistration = true;
return this.registerSession(imported.session, importedConfig, resolvedAgentId, { const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
labels: input.labels, labels: input.labels,
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
timelineRows, timelineRows,
@@ -1096,6 +1125,11 @@ export class AgentManager {
initialTitle, initialTitle,
publishWhenReady: true, 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 { } finally {
if (!handedToRegistration) { if (!handedToRegistration) {
await this.closeUnregisteredSession(imported.session); await this.closeUnregisteredSession(imported.session);
@@ -1168,6 +1202,9 @@ export class AgentManager {
// provider history into an empty timeline. // provider history into an empty timeline.
await this.deleteCommittedTimeline(agentId); await this.deleteCommittedTimeline(agentId);
this.timelineStore.delete(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. // Preserve existing labels and timeline during reload.
@@ -1261,6 +1298,9 @@ export class AgentManager {
const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
await agent.session.close(); await agent.session.close();
this.timelineStore.delete(agentId); this.timelineStore.delete(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
await this.persistSnapshot(closedAgent); await this.persistSnapshot(closedAgent);
this.emitClosedAgent(closedAgent, { persist: false }); this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace( this.logger.trace(
@@ -2837,6 +2877,11 @@ export class AgentManager {
agent: ActiveManagedAgent, agent: ActiveManagedAgent,
event: AgentStreamEvent, event: AgentStreamEvent,
): Promise<void> { ): 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 turnId = getAgentStreamEventTurnId(event);
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId); const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
this.logger.trace( this.logger.trace(
@@ -2975,49 +3020,79 @@ export class AgentManager {
} }
if (options?.force) { if (options?.force) {
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = []; await this.forceHydrateTimelineFromLegacyProviderHistory(agent, options.broadcast === true);
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);
return; 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; agent.historyPrimed = true;
try { try {
for await (const event of agent.session.streamHistory()) { 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") { if (event.type !== "timeline") {
continue; continue;
} }
@@ -3793,22 +3868,35 @@ export class AgentManager {
) { ) {
continue; 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) // Skip internal agents for global subscribers (those without a specific agentId)
if (!subscriber.agentId) { if (!subscriber.agentId && this.eventBelongsToInternalAgent(event)) {
if (event.type === "agent_state" && event.agent.internal) { continue;
continue;
}
if (event.type === "agent_stream") {
const agent = this.agents.get(event.agentId);
if (agent?.internal) {
continue;
}
}
} }
subscriber.callback(event); 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( private async normalizeConfig(
config: AgentSessionConfig, config: AgentSessionConfig,
options: NormalizeConfigOptions = {}, options: NormalizeConfigOptions = {},
@@ -4031,6 +4119,14 @@ export class AgentManager {
} }
return agent; 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 { export function commandMayHaveChangedExternalState(command: string): boolean {
@@ -426,6 +426,11 @@ export type AgentStreamEvent =
provider: AgentProvider; provider: AgentProvider;
reason: "finished" | "error" | "permission"; reason: "finished" | "error" | "permission";
timestamp: string; timestamp: string;
}
| {
type: "provider_subagent";
provider: AgentProvider;
event: import("./provider-subagents/store.js").ProviderSubagentInputEvent;
}; };
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined { export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
@@ -541,6 +546,7 @@ export interface ImportedProviderSession {
config: AgentSessionConfig; config: AgentSessionConfig;
persistence: AgentPersistenceHandle; persistence: AgentPersistenceHandle;
timeline: ImportedTimelineEntry[]; timeline: ImportedTimelineEntry[];
providerSubagentEvents?: Extract<AgentStreamEvent, { type: "provider_subagent" }>[];
} }
export interface AgentSessionConfig { export interface AgentSessionConfig {
@@ -33,13 +33,14 @@ export async function importSessionFromPersistence(input: {
const persistence = const persistence =
input.persistence ?? buildImportPersistenceHandle(input.provider, input.request, storedConfig); input.persistence ?? buildImportPersistenceHandle(input.provider, input.request, storedConfig);
const session = await input.resumeSession(persistence, config, input.context.launchContext); const session = await input.resumeSession(persistence, config, input.context.launchContext);
const timeline = await collectImportedTimeline(session.streamHistory()); const history = await collectImportedHistory(session.streamHistory());
return { return {
session, session,
config: storedConfig, config: storedConfig,
persistence, persistence,
timeline, timeline: history.timeline,
providerSubagentEvents: history.providerSubagentEvents,
}; };
} }
@@ -60,11 +61,17 @@ function buildImportPersistenceHandle(
}; };
} }
async function collectImportedTimeline( async function collectImportedHistory(events: AsyncGenerator<AgentStreamEvent>): Promise<{
events: AsyncGenerator<AgentStreamEvent>, timeline: ImportedTimelineEntry[];
): Promise<ImportedTimelineEntry[]> { providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[];
}> {
const timeline: ImportedTimelineEntry[] = []; const timeline: ImportedTimelineEntry[] = [];
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
for await (const event of events) { for await (const event of events) {
if (event.type === "provider_subagent") {
providerSubagentEvents.push(event);
continue;
}
if (event.type !== "timeline") { if (event.type !== "timeline") {
continue; continue;
} }
@@ -73,5 +80,5 @@ async function collectImportedTimeline(
...(event.timestamp ? { timestamp: event.timestamp } : {}), ...(event.timestamp ? { timestamp: event.timestamp } : {}),
}); });
} }
return timeline; return { timeline, providerSubagentEvents };
} }
@@ -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);
});
});
@@ -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;
}
}
@@ -211,6 +211,21 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
parent_tool_use_id: "task-call-1", parent_tool_use_id: "task-call-1",
elapsed_time_seconds: 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", type: "assistant",
parent_tool_use_id: "task-call-1", parent_tool_use_id: "task-call-1",
@@ -307,6 +322,44 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
); );
expect(projectedTaskCalls).toHaveLength(1); 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 () => { 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 () => { test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205))); queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
@@ -1897,6 +1897,10 @@ class ClaudeAgentSession implements AgentSession {
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null, getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
}); });
private persistedHistory: PersistedTimelineEntry[] = []; private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
AgentStreamEvent,
{ type: "provider_subagent" }
>[] = [];
private historyPending = false; private historyPending = false;
private turnState: TurnState = "idle"; private turnState: TurnState = "idle";
private nextTurnOrdinal = 1; private nextTurnOrdinal = 1;
@@ -2117,11 +2121,16 @@ class ClaudeAgentSession implements AgentSession {
} }
async *streamHistory(): AsyncGenerator<AgentStreamEvent> { async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (!this.historyPending || this.persistedHistory.length === 0) { if (
!this.historyPending ||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
) {
return; return;
} }
const history = this.persistedHistory; const history = this.persistedHistory;
const providerSubagentEvents = this.persistedProviderSubagentEvents;
this.persistedHistory = []; this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false; this.historyPending = false;
for (const entry of history) { for (const entry of history) {
yield { yield {
@@ -2131,6 +2140,7 @@ class ClaudeAgentSession implements AgentSession {
timestamp: entry.timestamp, timestamp: entry.timestamp,
}; };
} }
yield* providerSubagentEvents;
} }
async getAvailableModes(): Promise<AgentMode[]> { async getAvailableModes(): Promise<AgentMode[]> {
@@ -2613,6 +2623,7 @@ class ClaudeAgentSession implements AgentSession {
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true; this.queryRestartNeeded = true;
this.persistedHistory = []; this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false; this.historyPending = false;
this.userMessageIds = []; this.userMessageIds = [];
this.emittedUserMessageIds.clear(); this.emittedUserMessageIds.clear();
@@ -2642,6 +2653,7 @@ class ClaudeAgentSession implements AgentSession {
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true; this.queryRestartNeeded = true;
this.persistedHistory = []; this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false; this.historyPending = false;
this.userMessageIds = []; this.userMessageIds = [];
this.emittedUserMessageIds.clear(); this.emittedUserMessageIds.clear();
@@ -3539,6 +3551,7 @@ class ClaudeAgentSession implements AgentSession {
} }
this.persistence = null; this.persistence = null;
this.persistedHistory = []; this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false; this.historyPending = false;
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
this.queryRestartNeeded = false; this.queryRestartNeeded = false;
@@ -3610,6 +3623,7 @@ class ClaudeAgentSession implements AgentSession {
break; break;
case "user": case "user":
this.appendUserMessageEvents(message, events); this.appendUserMessageEvents(message, events);
this.appendSidechainResultEvents(message, events);
break; break;
case "assistant": { case "assistant": {
const timelineItems = this.mapBlocksToTimeline(message.message.content, { const timelineItems = this.mapBlocksToTimeline(message.message.content, {
@@ -3619,6 +3633,7 @@ class ClaudeAgentSession implements AgentSession {
for (const item of timelineItems) { for (const item of timelineItems) {
events.push({ type: "timeline", item, provider: "claude" }); events.push({ type: "timeline", item, provider: "claude" });
} }
this.appendSidechainResultEvents(message, events);
break; break;
} }
case "stream_event": case "stream_event":
@@ -3634,6 +3649,18 @@ class ClaudeAgentSession implements AgentSession {
return events; 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( private emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>, message: Extract<SDKMessage, { type: "user" }>,
turnId: string, turnId: string,
@@ -3830,6 +3857,7 @@ class ClaudeAgentSession implements AgentSession {
): void { ): void {
const usage = this.convertUsage(message, message.modelUsage); const usage = this.convertUsage(message, message.modelUsage);
if (message.subtype === "success") { if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …") // Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
// run client-side in the Claude CLI with no model turn — output_tokens // 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 // 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 "errors" in message && Array.isArray(message.errors) && message.errors.length > 0
? message.errors.join("\n") ? message.errors.join("\n")
: "Claude run failed"; : "Claude run failed";
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(errorMessage)); events.push(this.buildTurnFailedEvent(errorMessage));
} }
@@ -4166,7 +4195,9 @@ class ClaudeAgentSession implements AgentSession {
if (!historyPath || !fs.existsSync(historyPath)) { if (!historyPath || !fs.existsSync(historyPath)) {
return; return;
} }
this.ingestPersistedHistory(fs.readFileSync(historyPath, "utf8")); const content = fs.readFileSync(historyPath, "utf8");
this.ingestPersistedHistory(content);
this.ingestPersistedSidechains(content, readClaudeSidechainHistory(historyPath));
} catch { } catch {
// ignore history load failures // 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 { private ingestPersistedHistoryLine(line: string, timeline: PersistedTimelineEntry[]): void {
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) { if (!trimmed) {
@@ -4465,7 +4514,6 @@ class ClaudeAgentSession implements AgentSession {
if (typeof block.tool_use_id === "string") { if (typeof block.tool_use_id === "string") {
this.toolUseCache.delete(block.tool_use_id); 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; 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 { interface ClaudeHistoryEntry {
type?: unknown; type?: unknown;
subtype?: unknown; subtype?: unknown;
isCompactSummary?: unknown; isCompactSummary?: unknown;
isSidechain?: unknown; isSidechain?: unknown;
agentId?: unknown;
timestamp?: unknown;
uuid?: unknown; uuid?: unknown;
message?: { content?: unknown; [key: string]: unknown }; message?: { content?: unknown; [key: string]: unknown };
[key: string]: unknown; [key: string]: unknown;
@@ -15,6 +15,7 @@ let lastQuery: ReturnType<typeof buildSdkQueryMock> | null = null;
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER"; const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER"; const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER"; const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
const HISTORY_SIDECHAIN_MARKER = "HISTORY_ONLY_SIDECHAIN_MARKER";
function buildSdkQueryMock() { function buildSdkQueryMock() {
const events = [ const events = [
@@ -126,6 +127,52 @@ describe("ClaudeAgentSession history replay regression", () => {
content: HISTORY_ASSISTANT_MARKER, 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"), ].join("\n"),
"utf8", "utf8",
); );
@@ -219,6 +266,54 @@ describe("ClaudeAgentSession history replay regression", () => {
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER); 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 () => { test("listCommands includes rewind command", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
@@ -1,6 +1,10 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; 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 { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js"; import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
@@ -13,6 +17,7 @@ interface ClaudeContentChunk {
interface SubAgentActionEntry { interface SubAgentActionEntry {
index: number; index: number;
toolName: string; toolName: string;
input: unknown;
summary?: string; summary?: string;
} }
@@ -23,6 +28,7 @@ interface SubAgentActivityState {
actionKeys: string[]; actionKeys: string[];
nextActionIndex: number; nextActionIndex: number;
actionIndexByKey: Map<string, number>; actionIndexByKey: Map<string, number>;
completedActionKeys: Set<string>;
} }
interface SubAgentActionCandidate { interface SubAgentActionCandidate {
@@ -64,19 +70,34 @@ export class ClaudeSidechainTracker {
actionKeys: [], actionKeys: [],
nextActionIndex: 1, nextActionIndex: 1,
actionIndexByKey: new Map<string, number>(), actionIndexByKey: new Map<string, number>(),
completedActionKeys: new Set<string>(),
} satisfies SubAgentActivityState); } satisfies SubAgentActivityState);
this.activeSidechains.set(parentToolUseId, state); this.activeSidechains.set(parentToolUseId, state);
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId); const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
const actionCandidates = this.extractSubAgentActionCandidates(message); const actionCandidates = this.extractSubAgentActionCandidates(message);
const childTimelineItems = [
...this.extractSubAgentTimelineItems(message),
...this.extractSubAgentToolResults(message, state),
];
let actionUpdated = false; let actionUpdated = false;
for (const action of actionCandidates) { for (const action of actionCandidates) {
if (state.completedActionKeys.has(action.key)) continue;
if (this.appendSubAgentAction(state, action)) { if (this.appendSubAgentAction(state, action)) {
actionUpdated = true; 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 []; return [];
} }
@@ -103,6 +124,25 @@ export class ClaudeSidechainTracker {
}; };
return [ 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", type: "timeline",
item: { 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 { delete(toolUseId: string): void {
this.activeSidechains.delete(toolUseId); this.activeSidechains.delete(toolUseId);
} }
@@ -122,6 +202,66 @@ export class ClaudeSidechainTracker {
this.activeSidechains.clear(); 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( private updateSubAgentContextFromTaskInput(
state: SubAgentActivityState, state: SubAgentActivityState,
parentToolUseId: string, parentToolUseId: string,
@@ -259,6 +399,7 @@ export class ClaudeSidechainTracker {
state.actions[existingIndex] = { state.actions[existingIndex] = {
...existing, ...existing,
toolName: normalizedToolName, toolName: normalizedToolName,
input: existing.input ?? candidate.input,
...(nextSummary ? { summary: nextSummary } : {}), ...(nextSummary ? { summary: nextSummary } : {}),
}; };
return true; return true;
@@ -267,6 +408,7 @@ export class ClaudeSidechainTracker {
state.actions.push({ state.actions.push({
index: state.nextActionIndex, index: state.nextActionIndex,
toolName: normalizedToolName, toolName: normalizedToolName,
input: candidate.input,
...(summary ? { summary } : {}), ...(summary ? { summary } : {}),
}); });
state.nextActionIndex += 1; state.nextActionIndex += 1;
@@ -279,7 +421,8 @@ export class ClaudeSidechainTracker {
private trimSubAgentTail(state: SubAgentActivityState): void { private trimSubAgentTail(state: SubAgentActivityState): void {
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) { while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
state.actions.shift(); state.actions.shift();
state.actionKeys.shift(); const removedKey = state.actionKeys.shift();
if (removedKey) state.completedActionKeys.delete(removedKey);
} }
} }
@@ -1567,7 +1567,7 @@ describe("Codex app-server provider", () => {
asInternals(session).handleNotification("item/agentMessage/delta", { asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "child-thread-1", threadId: "child-thread-1",
itemId: "child-message-1", itemId: "child-message-1",
delta: "Found the path.", delta: "Found",
}); });
asInternals(session).handleNotification("item/completed", { asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-1", threadId: "child-thread-1",
@@ -1599,6 +1599,128 @@ describe("Codex app-server provider", () => {
actions: [], 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 () => { 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", event.item.callId === "child-command",
), ),
).toBe(false); ).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.filter((event) => event.type === "turn_completed")).toHaveLength(0);
expect(events.at(-1)).toMatchObject({ expect(events.at(-1)).toMatchObject({
type: "timeline", type: "timeline",
@@ -2343,10 +2479,28 @@ describe("Codex app-server provider", () => {
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => { test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
const session = createSession(); const session = createSession();
session.client = { session.client = {
request: vi.fn(async (method: string) => { request: vi.fn(async (method: string, params: unknown) => {
if (method !== "thread/read") { if (method !== "thread/read") {
return {}; 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 { return {
thread: { thread: {
turns: [ turns: [
@@ -2382,6 +2536,38 @@ describe("Codex app-server provider", () => {
for await (const event of session.streamHistory()) { for await (const event of session.streamHistory()) {
history.push(event); 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( expect(
history history
.filter((event) => event.type === "timeline" && event.item.type === "tool_call") .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 () => { test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => {
const session = createSession(); const session = createSession();
session.client = { session.client = {
request: vi.fn(async (method: string) => { request: vi.fn(async (method: string, params: unknown) => {
if (method !== "thread/read") { if (method !== "thread/read") {
return {}; return {};
} }
if ((params as { threadId?: string }).threadId !== "test-thread") {
return { thread: { turns: [] } };
}
return { return {
thread: { thread: {
turns: [ turns: [
@@ -2495,6 +2684,15 @@ describe("Codex app-server provider", () => {
history.push(event); history.push(event);
} }
expect(history).toEqual([ expect(history).toEqual([
{
type: "provider_subagent",
provider: "codex",
event: expect.objectContaining({
type: "upsert",
id: "history-child-thread",
status: "canceled",
}),
},
{ {
type: "timeline", type: "timeline",
provider: "codex", provider: "codex",
@@ -3061,6 +3061,7 @@ interface CodexSubAgentCallState {
pendingFileChangeOutputDeltas: Map<string, string[]>; pendingFileChangeOutputDeltas: Map<string, string[]>;
childItemOrder: string[]; childItemOrder: string[];
childItems: Map<string, AgentTimelineItem>; childItems: Map<string, AgentTimelineItem>;
childThreadIds: Set<string>;
} }
export class CodexAppServerAgentSession implements AgentSession { export class CodexAppServerAgentSession implements AgentSession {
@@ -3081,6 +3082,8 @@ export class CodexAppServerAgentSession implements AgentSession {
private planModeEnabled = false; private planModeEnabled = false;
private historyPending = false; private historyPending = false;
private persistedHistory: PersistedTimelineEntry[] = []; private persistedHistory: PersistedTimelineEntry[] = [];
private loadingPersistedHistory = false;
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
private pendingPermissions = new Map<string, AgentPermissionRequest>(); private pendingPermissions = new Map<string, AgentPermissionRequest>();
private mcpElicitationPermissionIds = new Map<number, string>(); private mcpElicitationPermissionIds = new Map<number, string>();
private pendingPermissionHandlers = new Map< private pendingPermissionHandlers = new Map<
@@ -3105,6 +3108,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private emittedExecCommandCompletedCallIds = new Set<string>(); private emittedExecCommandCompletedCallIds = new Set<string>();
private emittedItemStartedIds = new Set<string>(); private emittedItemStartedIds = new Set<string>();
private emittedItemCompletedIds = new Set<string>(); private emittedItemCompletedIds = new Set<string>();
private emittedProviderSubagentUserMessageKeys = new Set<string>();
private subAgentCallsByCallId = new Map<string, CodexSubAgentCallState>(); private subAgentCallsByCallId = new Map<string, CodexSubAgentCallState>();
private subAgentCallIdByChildThreadId = new Map<string, string>(); private subAgentCallIdByChildThreadId = new Map<string, string>();
private pendingSubAgentNotificationsByThreadId = new Map<string, ParsedCodexNotification[]>(); private pendingSubAgentNotificationsByThreadId = new Map<string, ParsedCodexNotification[]>();
@@ -3454,12 +3458,12 @@ export class CodexAppServerAgentSession implements AgentSession {
this.subAgentCallsByCallId.clear(); this.subAgentCallsByCallId.clear();
this.subAgentCallIdByChildThreadId.clear(); this.subAgentCallIdByChildThreadId.clear();
this.pendingSubAgentNotificationsByThreadId.clear(); this.pendingSubAgentNotificationsByThreadId.clear();
for (const route of subAgentRoutes) { this.persistedProviderSubagentEvents = [];
this.registerSubAgentToolCall({ this.loadingPersistedHistory = true;
timelineItem: route.toolCall, try {
rawItem: { agentThreadId: route.childThreadId }, await this.loadPersistedSubAgentHistories(client, subAgentRoutes);
parentCallId: null, } finally {
}); this.loadingPersistedHistory = false;
} }
this.resetCodexUserMessageTurns(); this.resetCodexUserMessageTurns();
for (const entry of timeline) { for (const entry of timeline) {
@@ -3471,6 +3475,44 @@ export class CodexAppServerAgentSession implements AgentSession {
this.historyPending = timeline.length > 0; 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> { private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return; if (!this.client || !this.currentThreadId) return;
try { try {
@@ -3818,12 +3860,20 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
async *streamHistory(): AsyncGenerator<AgentStreamEvent> { async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (!this.historyPending || this.persistedHistory.length === 0) { if (
(!this.historyPending || this.persistedHistory.length === 0) &&
this.persistedProviderSubagentEvents.length === 0
) {
return; return;
} }
const history = this.persistedHistory; const history = this.persistedHistory;
const providerSubagents = this.persistedProviderSubagentEvents;
this.persistedHistory = []; this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false; this.historyPending = false;
for (const event of providerSubagents) {
yield event;
}
for (const entry of history) { for (const entry of history) {
yield { yield {
type: "timeline", type: "timeline",
@@ -4454,6 +4504,10 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
private emitEvent(event: AgentStreamEvent): void { private emitEvent(event: AgentStreamEvent): void {
if (this.loadingPersistedHistory && event.type === "provider_subagent") {
this.persistedProviderSubagentEvents.push(event);
return;
}
this.notifySubscribers(event); this.notifySubscribers(event);
} }
@@ -4724,6 +4778,7 @@ export class CodexAppServerAgentSession implements AgentSession {
pendingFileChangeOutputDeltas: new Map<string, string[]>(), pendingFileChangeOutputDeltas: new Map<string, string[]>(),
childItemOrder: [], childItemOrder: [],
childItems: new Map<string, AgentTimelineItem>(), childItems: new Map<string, AgentTimelineItem>(),
childThreadIds: new Set<string>(),
} satisfies CodexSubAgentCallState); } satisfies CodexSubAgentCallState);
state.toolCall = { state.toolCall = {
@@ -4754,6 +4809,8 @@ export class CodexAppServerAgentSession implements AgentSession {
); );
for (const receiverThreadId of childThreadIds) { for (const receiverThreadId of childThreadIds) {
this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId); this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId);
state.childThreadIds.add(receiverThreadId);
this.emitProviderSubagentUpsert(receiverThreadId, state, timelineItem.status);
} }
return childThreadIds; return childThreadIds;
} }
@@ -4835,12 +4892,20 @@ export class CodexAppServerAgentSession implements AgentSession {
private emitCodexToolTimelineItem( private emitCodexToolTimelineItem(
timelineItem: ToolCallTimelineItem, timelineItem: ToolCallTimelineItem,
subAgentCallId: string | null, subAgentCallId: string | null,
childThreadId?: string | null,
): void { ): void {
if (!subAgentCallId) { if (!subAgentCallId) {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
return; return;
} }
this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem); 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( this.emitSubAgentActivityUpdate(
subAgentCallId, subAgentCallId,
timelineItem.status === "running" ? "running" : undefined, timelineItem.status === "running" ? "running" : undefined,
@@ -4867,6 +4932,9 @@ export class CodexAppServerAgentSession implements AgentSession {
? curateAgentActivity(childTimeline, { labelAssistantMessages: true }) ? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
: ""; : "";
const resolvedStatus = status ?? state.toolCall.status; const resolvedStatus = status ?? state.toolCall.status;
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
const baseToolCall = { const baseToolCall = {
...state.toolCall, ...state.toolCall,
detail: { detail: {
@@ -4895,6 +4963,100 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall }); 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( private handleSubAgentChildItemCompleted(
callId: string, callId: string,
itemId: string | undefined, itemId: string | undefined,
@@ -4949,6 +5111,13 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingAgentMessages.set(parsed.itemId, text); this.pendingAgentMessages.set(parsed.itemId, text);
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) { if (subAgentCallId) {
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, {
type: "assistant_message",
messageId: parsed.itemId,
text: parsed.delta,
});
}
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
type: "assistant_message", type: "assistant_message",
messageId: parsed.itemId, messageId: parsed.itemId,
@@ -4981,6 +5150,12 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingReasoning.set(parsed.itemId, prev); this.pendingReasoning.set(parsed.itemId, prev);
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) { if (subAgentCallId) {
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, {
type: "reasoning",
text: parsed.delta,
});
}
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, { this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
type: "reasoning", type: "reasoning",
text: prev.join(""), text: prev.join(""),
@@ -5080,6 +5255,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.latestPlanResult = null; this.latestPlanResult = null;
this.emittedItemStartedIds.clear(); this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear(); this.emittedItemCompletedIds.clear();
this.emittedProviderSubagentUserMessageKeys.clear();
this.emittedExecCommandStartedCallIds.clear(); this.emittedExecCommandStartedCallIds.clear();
this.emittedExecCommandCompletedCallIds.clear(); this.emittedExecCommandCompletedCallIds.clear();
this.pendingAgentMessages.clear(); this.pendingAgentMessages.clear();
@@ -5224,7 +5400,7 @@ export class CodexAppServerAgentSession implements AgentSession {
running: true, running: true,
}); });
if (timelineItem) { if (timelineItem) {
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
} }
} }
@@ -5257,7 +5433,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!subAgentCallId) { if (!subAgentCallId) {
this.emittedExecCommandCompletedCallIds.add(timelineItem.callId); 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, callId: parsed.callId,
changes: parsed.changes, 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, changes: parsed.changes,
stdout: parsed.stdout, stdout: parsed.stdout,
}); });
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId); this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
} }
} }
@@ -5372,8 +5548,11 @@ export class CodexAppServerAgentSession implements AgentSession {
parentCallId: childSubAgentCallId, parentCallId: childSubAgentCallId,
}) })
: []; : [];
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
if (childSubAgentCallId) { if (childSubAgentCallId) {
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem); this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
this.replayPendingSubAgentNotifications(registeredChildThreadIds); this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return; return;
} }
@@ -5408,7 +5587,6 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item); this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
} }
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem.type === "assistant_message") { if (timelineItem.type === "assistant_message") {
this.pendingAssistantMessageBoundary = true; this.pendingAssistantMessageBoundary = true;
@@ -5452,26 +5630,24 @@ export class CodexAppServerAgentSession implements AgentSession {
timelineItem: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>, timelineItem: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
streamedText: string, streamedText: string,
): void { ): void {
if (!timelineItem.text.startsWith(streamedText)) { const item = this.buildMissingFinalTextSuffix(timelineItem, streamedText);
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem }); if (item) this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item });
return; }
}
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); const suffix = timelineItem.text.slice(streamedText.length);
if (!suffix) { if (!suffix) return null;
return; return timelineItem.type === "assistant_message"
} ? {
this.emitEvent({ type: timelineItem.type,
type: "timeline", text: suffix,
provider: CODEX_PROVIDER, ...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}),
item: }
timelineItem.type === "assistant_message" : { type: timelineItem.type, text: suffix };
? {
type: timelineItem.type,
text: suffix,
...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}),
}
: { type: timelineItem.type, text: suffix },
});
} }
private applyBufferedDeltaTextToTimelineItem( private applyBufferedDeltaTextToTimelineItem(
@@ -5484,14 +5660,15 @@ export class CodexAppServerAgentSession implements AgentSession {
if (timelineItem.type === "assistant_message") { if (timelineItem.type === "assistant_message") {
const buffered = this.pendingAgentMessages.get(itemId); const buffered = this.pendingAgentMessages.get(itemId);
if (buffered && buffered.length > 0) { if (buffered && buffered.length > 0) {
timelineItem.text = buffered; if (!timelineItem.text.startsWith(buffered)) timelineItem.text = buffered;
} }
return; return;
} }
if (timelineItem.type === "reasoning") { if (timelineItem.type === "reasoning") {
const buffered = this.pendingReasoning.get(itemId); const buffered = this.pendingReasoning.get(itemId);
if (buffered && buffered.length > 0) { 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, parentCallId: childSubAgentCallId,
}); });
if (childSubAgentCallId) { if (childSubAgentCallId) {
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
if (parsed.item.id) { if (parsed.item.id) {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem); this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
} }
@@ -5580,6 +5758,18 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId); const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (childSubAgentCallId) { 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) { if (itemId) {
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem); this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -164,6 +164,34 @@ describe("OpenCodeServerManager generations", () => {
expect(runtime.terminatedPorts).toEqual([4474]); 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 () => { test("repeated rotations leave zero unreferenced retired servers", async () => {
const { manager, runtime } = createTestManager([4501, 4502, 4503]); const { manager, runtime } = createTestManager([4501, 4502, 4503]);
@@ -29,6 +29,7 @@ export interface OpenCodeServerManagerLike {
acquireCurrent(): Promise<OpenCodeServerAcquisition>; acquireCurrent(): Promise<OpenCodeServerAcquisition>;
acquireNew(): Promise<OpenCodeServerAcquisition>; acquireNew(): Promise<OpenCodeServerAcquisition>;
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>; acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
acquireExisting(url: string): OpenCodeServerAcquisition | null;
shutdown(): Promise<void>; 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 { private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
server.refCount += 1; server.refCount += 1;
let released = false; let released = false;
@@ -1,8 +1,9 @@
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js"; import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
export interface TestOpenCodeServerAcquisition { export interface TestOpenCodeServerAcquisition {
kind: "current" | "new" | "dedicated"; kind: "current" | "new" | "dedicated" | "existing";
env?: Record<string, string>; env?: Record<string, string>;
url?: string;
released: boolean; released: boolean;
} }
@@ -28,14 +29,20 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
return this.recordAcquisition({ kind: "dedicated", env }); 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: { private recordAcquisition(input: {
kind: TestOpenCodeServerAcquisition["kind"]; kind: TestOpenCodeServerAcquisition["kind"];
env?: Record<string, string>; env?: Record<string, string>;
url?: string;
}): OpenCodeServerAcquisition { }): OpenCodeServerAcquisition {
const acquisition: TestOpenCodeServerAcquisition = { const acquisition: TestOpenCodeServerAcquisition = {
kind: input.kind, kind: input.kind,
released: false, released: false,
...(input.env ? { env: input.env } : {}), ...(input.env ? { env: input.env } : {}),
...(input.url ? { url: input.url } : {}),
}; };
this.acquisitions.push(acquisition); this.acquisitions.push(acquisition);
return { return {
@@ -9,8 +9,9 @@ interface OpenCodeResponse {
export class TestOpenCodeHarness implements OpenCodeServerManagerLike { export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
readonly acquisitions: Array<{ readonly acquisitions: Array<{
kind: "current" | "new" | "dedicated"; kind: "current" | "new" | "dedicated" | "existing";
env?: Record<string, string>; env?: Record<string, string>;
url?: string;
releaseCount: number; releaseCount: number;
}> = []; }> = [];
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = []; readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
@@ -34,14 +35,20 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
return this.recordAcquisition({ kind: "dedicated", env }); 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: { private recordAcquisition(input: {
kind: "current" | "new" | "dedicated"; kind: "current" | "new" | "dedicated" | "existing";
env?: Record<string, string>; env?: Record<string, string>;
url?: string;
}): OpenCodeServerAcquisition { }): OpenCodeServerAcquisition {
const acquisition = { const acquisition = {
kind: input.kind, kind: input.kind,
releaseCount: 0, releaseCount: 0,
...(input.env ? { env: input.env } : {}), ...(input.env ? { env: input.env } : {}),
...(input.url ? { url: input.url } : {}),
}; };
this.acquisitions.push(acquisition); this.acquisitions.push(acquisition);
return { return {
@@ -82,6 +89,7 @@ export class TestOpenCodeClient {
sessionCommand: [] as unknown[], sessionCommand: [] as unknown[],
sessionCreate: [] as unknown[], sessionCreate: [] as unknown[],
sessionDelete: [] as unknown[], sessionDelete: [] as unknown[],
sessionChildren: [] as unknown[],
sessionGet: [] as unknown[], sessionGet: [] as unknown[],
sessionMessages: [] as unknown[], sessionMessages: [] as unknown[],
sessionPromptAsync: [] as unknown[], sessionPromptAsync: [] as unknown[],
@@ -106,6 +114,8 @@ export class TestOpenCodeClient {
sessionCommandResponse: OpenCodeResponse = {}; sessionCommandResponse: OpenCodeResponse = {};
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } }; sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
sessionDeleteResponse: OpenCodeResponse = {}; sessionDeleteResponse: OpenCodeResponse = {};
sessionChildrenResponses: OpenCodeResponse[] = [];
sessionChildrenImplementation: ((parameters: unknown) => Promise<OpenCodeResponse>) | null = null;
sessionGetResponse: OpenCodeResponse = { sessionGetResponse: OpenCodeResponse = {
data: { id: "session-1", directory: "/workspace/repo", title: null }, data: { id: "session-1", directory: "/workspace/repo", title: null },
}; };
@@ -219,6 +229,13 @@ export class TestOpenCodeClient {
this.calls.sessionDelete.push(parameters); this.calls.sessionDelete.push(parameters);
return this.sessionDeleteResponse; 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) => { get: async (parameters: unknown) => {
this.calls.sessionGet.push(parameters); this.calls.sessionGet.push(parameters);
return this.sessionGetResponse; return this.sessionGetResponse;
+132 -1
View File
@@ -1165,6 +1165,43 @@ export class Session {
return; 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 ( if (
this.voiceSession.isActiveForAgent(event.agentId) && this.voiceSession.isActiveForAgent(event.agentId) &&
event.event.type === "permission_requested" && event.event.type === "permission_requested" &&
@@ -1450,6 +1487,10 @@ export class Session {
switch (msg.type) { switch (msg.type) {
case "fetch_agent_timeline_request": case "fetch_agent_timeline_request":
return this.handleFetchAgentTimelineRequest(msg); 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": case "agent.fork_context.request":
return this.handleAgentForkContextRequest(msg); return this.handleAgentForkContextRequest(msg);
default: default:
@@ -2708,7 +2749,7 @@ export class Session {
extractTimestamps(record), extractTimestamps(record),
); );
} }
await this.agentManager.hydrateTimelineFromProvider(agentId); await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true });
await this.agentUpdates.forwardLiveAgent(snapshot); await this.agentUpdates.forwardLiveAgent(snapshot);
const timelineSize = this.agentManager.getTimeline(agentId).length; const timelineSize = this.agentManager.getTimeline(agentId).length;
if (requestId) { 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( private async handleAgentForkContextRequest(
msg: Extract<SessionInboundMessage, { type: "agent.fork_context.request" }>, msg: Extract<SessionInboundMessage, { type: "agent.fork_context.request" }>,
): Promise<void> { ): Promise<void> {
@@ -1259,6 +1259,8 @@ export class VoiceAssistantWebSocketServer {
daemonSelfUpdate: true, daemonSelfUpdate: true,
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28. // COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: true, agentForkContext: true,
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: true,
}, },
}; };
} }