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
+5
View File
@@ -154,6 +154,10 @@ export async function launchAgent(input: {
provider: RewindFlowProvider;
cwd: string;
mode: "full-access";
providerConfig?: {
model?: string;
extra?: { codex?: { features?: { multi_agent_v2?: boolean } } };
};
}): Promise<AgentHandle> {
execFileSync("git", ["init", "-b", "main"], { cwd: input.cwd, stdio: "ignore" });
execFileSync("git", ["config", "user.email", "paseo-test@example.com"], {
@@ -180,6 +184,7 @@ export async function launchAgent(input: {
}
const agent = await client.createAgent({
...fullAccessConfig(input.provider),
...input.providerConfig,
cwd: input.cwd,
workspaceId: createdWorkspace.workspace.id,
title: `rewind-flow-${input.provider}-${randomUUID()}`,
@@ -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 {
agentId: string;
serverId?: string;
agent: AgentScreenAgent;
context: AgentScreenAgent;
streamItems: StreamItem[];
streamHead?: StreamItem[];
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
readOnly?: boolean;
historyPagination?: {
hasOlder: boolean;
isLoadingOlder: boolean;
onLoadOlder: () => void;
};
}
const AGENT_CAPABILITY_FLAG_KEYS: (keyof AgentCapabilityFlags)[] = [
@@ -306,13 +313,16 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
{
agentId,
serverId,
agent,
context,
streamItems,
streamHead: providedStreamHead,
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
onOpenWorkspaceFile,
readOnly = false,
historyPagination,
},
ref,
) {
@@ -337,27 +347,37 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
// Get serverId (fallback to agent's serverId if not provided)
const resolvedServerId = serverId ?? agent.serverId ?? "";
const resolvedServerId = serverId ?? context.serverId ?? "";
const client = useSessionStore((state) => state.sessions[resolvedServerId]?.client ?? null);
const streamHead = useSessionStore((state) =>
const sessionStreamHead = useSessionStore((state) =>
state.sessions[resolvedServerId]?.agentStreamHead?.get(agentId),
);
const streamHead = providedStreamHead ?? sessionStreamHead;
const supportsAgentForkContext = useSessionStore(
(state) => state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
(state) =>
!readOnly &&
state.sessions[resolvedServerId]?.serverInfo?.features?.agentForkContext === true,
);
const workspaceRoot = agent.cwd?.trim() || "";
const workspaceRoot = context.cwd?.trim() || "";
const { requestDirectoryListing } = useFileExplorerActions({
serverId: resolvedServerId,
workspaceId: agent.workspaceId,
workspaceId: context.workspaceId,
workspaceRoot,
});
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
const agentHistoryPagination = useLoadOlderAgentHistory({
serverId: resolvedServerId,
agentId,
toast,
});
const { isLoadingOlder, hasOlder, loadOlder } = historyPagination
? {
isLoadingOlder: historyPagination.isLoadingOlder,
hasOlder: historyPagination.hasOlder,
loadOlder: historyPagination.onLoadOlder,
}
: agentHistoryPagination;
// Keep entry/exit animations off on Android due to RN dispatchDraw crashes
// tracked in react-native-reanimated#8422.
const shouldDisableEntryExitAnimations = Platform.OS === "android";
@@ -379,7 +399,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
const normalized = normalizeInlinePathTarget(target.path, agent.cwd);
const normalized = normalizeInlinePathTarget(target.path, context.cwd);
if (!normalized) {
return;
}
@@ -402,10 +422,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return;
}
if (agent.workspaceId) {
if (context.workspaceId) {
navigateToPreparedWorkspaceTab({
serverId: resolvedServerId,
workspaceId: agent.workspaceId,
workspaceId: context.workspaceId,
target: createWorkspaceFileTabTarget(location),
});
}
@@ -419,8 +439,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const checkout = {
serverId: resolvedServerId,
cwd: agent.cwd,
isGit: agent.projectPlacement?.checkout?.isGit ?? true,
cwd: context.cwd,
isGit: context.projectPlacement?.checkout?.isGit ?? true,
};
setExplorerTabForCheckout({ ...checkout, tab: "files" });
openFileExplorerForCheckout({
@@ -444,7 +464,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
if (!client) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const draftSetup = buildForkDraftSetup(agent);
const draftSetup = buildForkDraftSetup(context);
const prepareForkDraft = async () => {
const draftId = generateDraftId();
const payload = await client.buildAgentForkContext(
@@ -466,7 +486,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
};
if (target === "tab") {
const workspaceId = agent.workspaceId;
const workspaceId = context.workspaceId;
if (!workspaceId) {
throw new Error(t("message.actions.forkMissingWorkspace"));
}
@@ -481,7 +501,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const draftId = await prepareForkDraft();
const sourceDirectory =
agent.projectPlacement?.checkout?.cwd?.trim() || agent.cwd.trim() || undefined;
context.projectPlacement?.checkout?.cwd?.trim() || context.cwd.trim() || undefined;
if (draftSetup) {
useWorkspaceDraftSubmissionStore.getState().setDraftSetup({
draftId,
@@ -493,8 +513,8 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
buildNewWorkspaceRoute({
serverId: resolvedServerId,
sourceDirectory,
displayName: agent.projectPlacement?.projectName,
projectId: agent.projectPlacement?.projectKey,
displayName: context.projectPlacement?.projectName,
projectId: context.projectPlacement?.projectKey,
draftId,
}),
);
@@ -520,24 +540,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
const baseRenderModel = useMemo(() => {
return buildAgentStreamRenderModel({
agentStatus: agent.status,
agentStatus: context.status,
tail: effectiveStreamItems,
head: effectiveStreamHead ?? EMPTY_STREAM_HEAD,
platform: isWeb ? "web" : "native",
isMobileBreakpoint: isMobile,
});
}, [agent.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
}, [context.status, isMobile, effectiveStreamHead, effectiveStreamItems]);
const streamLayout = useMemo(
() =>
layoutStream({
strategy: streamRenderStrategy,
agentStatus: agent.status,
agentStatus: context.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
}),
[
agent.status,
context.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
baseRenderModel.turnTiming.byAssistantId,
@@ -590,14 +610,14 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
images={item.images}
attachments={item.attachments}
timestamp={item.timestamp.getTime()}
capabilities={agent.capabilities}
capabilities={context.capabilities}
client={client}
isFirstInGroup={layoutItem.isFirstInUserGroup}
isLastInGroup={layoutItem.isLastInUserGroup}
/>
);
},
[agent.capabilities, agentId, client, resolvedServerId],
[context.capabilities, agentId, client, resolvedServerId],
);
const renderAssistantMessageItem = useCallback(
@@ -668,7 +688,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
error={data.error}
status={data.status}
detail={data.detail}
cwd={agent.cwd}
cwd={context.cwd}
metadata={data.metadata}
isLastInSequence={layoutItem.isLastInToolSequence}
onOpenFilePath={handleToolCallOpenFile}
@@ -690,7 +710,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
/>
);
},
[agent.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
[context.cwd, setInlineDetailsExpanded, handleToolCallOpenFile],
);
const renderStreamItemContent = useCallback(
@@ -747,10 +767,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
content,
layoutItem,
strategy: streamRenderStrategy,
onForkAssistantTurn: handleForkAssistantTurn,
onForkAssistantTurn: readOnly ? undefined : handleForkAssistantTurn,
});
},
[handleForkAssistantTurn, renderStreamItemContent, streamRenderStrategy],
[handleForkAssistantTurn, readOnly, renderStreamItemContent, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
@@ -758,7 +778,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showRunningTurnFooter = agent.status === "running";
const showRunningTurnFooter = context.status === "running";
const pendingPermissionsNode = useMemo(
() =>
renderPendingPermissionsNode({
@@ -775,11 +795,12 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
onForkAssistantTurn={handleForkAssistantTurn}
onForkAssistantTurn={readOnly ? undefined : handleForkAssistantTurn}
/>
) : null,
[
handleForkAssistantTurn,
readOnly,
showRunningTurnFooter,
baseRenderModel.turnTiming.runningStartedAt,
bottomTurnFooterHost,
@@ -1004,6 +1025,17 @@ function bottomAnchorRouteRequestsEqual(
);
}
function historyPaginationPropsEqual(
left: AgentStreamViewProps["historyPagination"],
right: AgentStreamViewProps["historyPagination"],
): boolean {
return (
left?.hasOlder === right?.hasOlder &&
left?.isLoadingOlder === right?.isLoadingOlder &&
left?.onLoadOlder === right?.onLoadOlder
);
}
function agentStreamViewPropsEqual(
left: AgentStreamViewProps,
right: AgentStreamViewProps,
@@ -1011,8 +1043,9 @@ function agentStreamViewPropsEqual(
const reasons: string[] = [];
if (left.agentId !== right.agentId) reasons.push("agentId");
if (left.serverId !== right.serverId) reasons.push("serverId");
reasons.push(...collectAgentScreenAgentDiffs(left.agent, right.agent));
reasons.push(...collectAgentScreenAgentDiffs(left.context, right.context));
if (left.streamItems !== right.streamItems) reasons.push("streamItems");
if (left.streamHead !== right.streamHead) reasons.push("streamHead");
if (left.pendingPermissions !== right.pendingPermissions) reasons.push("pendingPermissions");
if (
!bottomAnchorRouteRequestsEqual(left.routeBottomAnchorRequest, right.routeBottomAnchorRequest)
@@ -1024,6 +1057,10 @@ function agentStreamViewPropsEqual(
}
if (left.toast !== right.toast) reasons.push("toast");
if (left.onOpenWorkspaceFile !== right.onOpenWorkspaceFile) reasons.push("onOpenWorkspaceFile");
if (left.readOnly !== right.readOnly) reasons.push("readOnly");
if (!historyPaginationPropsEqual(left.historyPagination, right.historyPagination)) {
reasons.push("historyPagination");
}
recordRenderProfileReasons(`AgentStreamView:${right.agentId}`, reasons);
return reasons.length === 0;
}
@@ -673,7 +673,7 @@ export function WorkspaceDraftAgentTab({
<AgentStreamView
agentId={tabId}
serverId={serverId}
agent={draftAgent}
context={draftAgent}
streamItems={optimisticStreamItems}
pendingPermissions={EMPTY_PENDING_PERMISSIONS}
onOpenWorkspaceFile={onOpenWorkspaceFile}
@@ -78,6 +78,7 @@ import {
applyLegacyDaemonWorkspaceOwnership,
backfillLegacyDaemonWorkspaceDirectoryIfEmpty,
} from "@/workspace/legacy-daemon-workspaces";
import { useProviderSubagentStore } from "@/subagents/provider-store";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -1349,6 +1350,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
applyTimelineResponse(message.payload);
});
const unsubProviderSubagentUpdate = client.on("agent.provider_subagents.update", (message) => {
if (message.type !== "agent.provider_subagents.update") return;
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
});
const unsubWorkspaceUpdate = client.on("workspace_update", (message) => {
if (message.type !== "workspace_update") return;
if (message.payload.kind === "remove") {
@@ -1750,6 +1756,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
unsubAgentUpdate();
unsubAgentStream();
unsubAgentTimeline();
unsubProviderSubagentUpdate();
unsubWorkspaceUpdate();
unsubScriptStatusUpdate();
unsubCheckoutStatusUpdate();
+9 -2
View File
@@ -1270,7 +1270,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
ref={streamViewRef}
agentId={agent.id}
serverId={serverId}
agent={agent}
context={agent}
streamItems={streamItems}
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
@@ -1364,7 +1364,7 @@ function ActiveAgentComposer({
{ initialIsBelow: isCompactFormFactor },
);
const paneContext = usePaneContext();
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
const { workspaceId, tabId, retargetCurrentTab, openTab } = paneContext;
const { archiveAgent } = useArchiveAgent();
const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab);
const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent);
@@ -1382,6 +1382,12 @@ function ActiveAgentComposer({
},
[serverId],
);
const handleOpenProviderSubagent = useCallback(
(parentAgentId: string, subagentId: string) => {
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
},
[openTab],
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId });
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
@@ -1482,6 +1488,7 @@ function ActiveAgentComposer({
<SubagentsTrack
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>
@@ -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 { setupPanelRegistration } from "@/panels/setup-panel";
import { terminalPanelRegistration } from "@/panels/terminal-panel";
import { providerSubagentPanelRegistration } from "@/panels/provider-subagent-panel";
let panelsRegistered = false;
@@ -14,6 +15,7 @@ export function ensurePanelsRegistered(): void {
}
registerPanel(draftPanelRegistration);
registerPanel(agentPanelRegistration);
registerPanel(providerSubagentPanelRegistration);
registerPanel(setupPanelRegistration);
registerPanel(terminalPanelRegistration);
registerPanel(browserPanelRegistration);
@@ -377,6 +377,9 @@ function getFallbackTabOptionDescription(
if (tab.target.kind === "browser") {
return labels.browser;
}
if (tab.target.kind === "provider_subagent") {
return labels.agent;
}
return tab.target.path;
}
@@ -138,6 +138,9 @@ function getCloseButtonTestId(tab: WorkspaceTabDescriptor): string {
if (tab.target.kind === "setup") {
return `workspace-setup-close-${encodeWorkspaceIdForPathSegment(tab.target.workspaceId)}`;
}
if (tab.target.kind === "provider_subagent") {
return `workspace-provider-subagent-close-${tab.target.subagentId}`;
}
return `workspace-file-close-${encodeFilePathForPathSegment(tab.target.path)}`;
}
@@ -19,6 +19,7 @@ export interface WorkspaceDraftTabSetup {
export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
| { kind: "agent"; agentId: string }
| { kind: "provider_subagent"; parentAgentId: string; subagentId: string }
| { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string }
| WorkspaceFileTabTarget
@@ -508,6 +509,17 @@ function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTar
if (kind === "agent" && typeof raw.agentId === "string") {
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });
}
if (
kind === "provider_subagent" &&
typeof raw.parentAgentId === "string" &&
typeof raw.subagentId === "string"
) {
return normalizeWorkspaceTabTarget({
kind: "provider_subagent",
parentAgentId: raw.parentAgentId,
subagentId: raw.subagentId,
});
}
if (kind === "terminal" && typeof raw.terminalId === "string") {
return normalizeWorkspaceTabTarget({ kind: "terminal", terminalId: raw.terminalId });
}
@@ -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 { afterEach, describe, expect, it } from "vitest";
import { selectSubagentsForParent } from "./select";
import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select";
import { useProviderSubagentStore } from "./provider-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
const SERVER_ID = "server-1";
@@ -58,9 +59,37 @@ function setAgents(agents: Agent[]): void {
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
});
describe("selectSubagentsForParent", () => {
it("hides cached provider children when the host does not support them", () => {
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
kind: "upsert",
subagent: {
id: "provider-child",
parentAgentId: "parent-a",
provider: "codex",
title: "Provider child",
description: null,
status: "completed",
createdAt: "2026-03-08T10:01:00.000Z",
updatedAt: "2026-03-08T10:02:00.000Z",
toolCallId: "call-1",
},
});
const params = { serverId: SERVER_ID, parentAgentId: "parent-a" };
expect(
selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, false),
).toEqual([]);
expect(
selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, true).map(
(row) => row.id,
),
).toEqual(["provider-child"]);
});
it("returns only non-archived children for the requested parent", () => {
setAgents([
makeAgent({ id: "parent-a" }),
@@ -194,6 +223,7 @@ describe("selectSubagentsForParent", () => {
expect(rows).toEqual([
{
kind: "paseo",
id: "child",
provider: "claude",
title: "Review child",
@@ -205,6 +235,7 @@ describe("selectSubagentsForParent", () => {
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
"createdAt",
"id",
"kind",
"provider",
"requiresAttention",
"status",
+70 -2
View File
@@ -1,9 +1,13 @@
import { useEffect, useMemo } from "react";
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
export interface SubagentRow {
export interface PaseoSubagentRow {
kind: "paseo";
id: Agent["id"];
provider: Agent["provider"];
title: Agent["title"];
@@ -12,7 +16,21 @@ export interface SubagentRow {
createdAt: Agent["createdAt"];
}
export interface ProviderSubagentRow {
kind: "provider";
id: string;
parentAgentId: string;
provider: ProviderSubagentDescriptorPayload["provider"];
title: string | null;
status: ProviderSubagentDescriptorPayload["status"];
requiresAttention: boolean;
createdAt: Date;
}
export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow;
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
interface SelectSubagentsParams {
serverId: string;
@@ -20,9 +38,11 @@ interface SelectSubagentsParams {
}
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
function toSubagentRow(agent: Agent): SubagentRow {
return {
kind: "paseo",
id: agent.id,
provider: agent.provider,
title: agent.title,
@@ -62,11 +82,59 @@ export function selectSubagentsForParent(
return rows;
}
export function selectProviderSubagentsForParent(
state: ProviderSubagentStoreSnapshot,
params: SelectSubagentsParams,
supported: boolean,
): ProviderSubagentRow[] {
if (!supported) return EMPTY_PROVIDER_SUBAGENT_ROWS;
const rows: ProviderSubagentRow[] = [];
const prefix = `${params.serverId}\0${params.parentAgentId}\0`;
for (const [key, subagent] of state.descriptors) {
if (!key.startsWith(prefix)) continue;
rows.push({
kind: "provider",
id: subagent.id,
parentAgentId: subagent.parentAgentId,
provider: subagent.provider,
title: subagent.title ?? subagent.description,
status: subagent.status,
requiresAttention: subagent.status === "failed",
createdAt: new Date(subagent.createdAt),
});
}
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
return rows;
}
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
const pendingArchiveIds = usePendingArchiveAgentIds(params.serverId);
return useStoreWithEqualityFn(
const paseoRows = useStoreWithEqualityFn(
useSessionStore,
(state) => selectSubagentsForParent(state, params, pendingArchiveIds),
equal,
);
const supported = useSessionStore(
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
);
const providerRows = useStoreWithEqualityFn(
useProviderSubagentStore,
(state) => selectProviderSubagentsForParent(state, params, supported),
equal,
);
const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null);
useEffect(() => {
if (!client || !supported) return;
void refreshProviderSubagents(client, params.serverId, params.parentAgentId).catch(
() => undefined,
);
}, [client, params.parentAgentId, params.serverId, supported]);
return useMemo(() => {
if (providerRows.length === 0) return paseoRows;
const rows = [...paseoRows, ...providerRows];
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
return rows;
}, [paseoRows, providerRows]);
}
@@ -1,13 +1,16 @@
import { describe, expect, it } from "vitest";
import type { SubagentRow } from "./select";
import type { PaseoSubagentRow, SubagentRow } from "./select";
import {
buildSubagentRowPresentationData,
formatHeaderLabel,
resolveRowLabel,
} from "./track-presentation";
function row(overrides: Partial<SubagentRow> & Pick<SubagentRow, "id">): SubagentRow {
function row(
overrides: Partial<PaseoSubagentRow> & Pick<PaseoSubagentRow, "id">,
): PaseoSubagentRow {
return {
kind: "paseo",
id: overrides.id,
provider: overrides.provider ?? "codex",
title: overrides.title ?? `Agent ${overrides.id}`,
@@ -91,7 +94,9 @@ describe("resolveRowLabel", () => {
describe("buildSubagentRowPresentationData", () => {
it("namespaces the key with a subagent prefix", () => {
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe("subagent_child-a");
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe(
"paseo_subagent_child-a",
);
});
it("marks the row ready when the title resolves to a real label", () => {
@@ -1,6 +1,12 @@
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import type { SubagentRow } from "./select";
import { providerSubagentLifecycleStatus } from "./provider-store";
function presentationStatus(row: SubagentRow) {
if (row.kind === "paseo") return row.status;
return providerSubagentLifecycleStatus(row.status);
}
export interface SubagentRowPresentationData {
key: string;
@@ -13,14 +19,15 @@ export interface SubagentRowPresentationData {
export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowPresentationData {
const label = resolveRowLabel(row.title);
const status = presentationStatus(row);
return {
key: `subagent_${row.id}`,
key: `${row.kind}_subagent_${row.id}`,
kind: "agent",
label: label ?? "",
subtitle: "",
titleState: label ? "ready" : "loading",
statusBucket: deriveSidebarStateBucket({
status: row.status,
status,
requiresAttention: false,
}),
};
+20 -9
View File
@@ -28,6 +28,7 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
export interface SubagentsTrackProps {
rows: SubagentRow[];
onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void;
}
@@ -44,6 +45,7 @@ function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
export function SubagentsTrack({
rows,
onOpenSubagent,
onOpenProviderSubagent,
onArchiveSubagent,
onDetachSubagent,
}: SubagentsTrackProps): ReactElement | null {
@@ -105,6 +107,7 @@ export function SubagentsTrack({
key={row.id}
row={row}
onOpenSubagent={onOpenSubagent}
onOpenProviderSubagent={onOpenProviderSubagent}
onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent}
/>
@@ -120,6 +123,7 @@ export function SubagentsTrack({
interface SubagentsTrackRowProps {
row: SubagentRow;
onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void;
}
@@ -127,6 +131,7 @@ interface SubagentsTrackRowProps {
function SubagentsTrackRow({
row,
onOpenSubagent,
onOpenProviderSubagent,
onArchiveSubagent,
onDetachSubagent,
}: SubagentsTrackRowProps): ReactElement {
@@ -137,8 +142,12 @@ function SubagentsTrackRow({
const displayLabel =
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
const handlePress = useCallback(() => {
onOpenSubagent(row.id);
}, [onOpenSubagent, row.id]);
if (row.kind === "provider") {
onOpenProviderSubagent(row.parentAgentId, row.id);
} else {
onOpenSubagent(row.id);
}
}, [onOpenProviderSubagent, onOpenSubagent, row]);
const handleArchivePress = useCallback(() => {
onArchiveSubagent(row.id);
}, [onArchiveSubagent, row.id]);
@@ -167,13 +176,15 @@ function SubagentsTrackRow({
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={actionsVisible}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
{row.kind === "paseo" ? (
<SubagentRowActions
rowId={row.id}
displayLabel={displayLabel}
visible={actionsVisible}
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/>
) : null}
</View>
)}
</Pressable>
@@ -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);
return agentId ? { kind: "agent", agentId } : null;
}
if (value.kind === "provider_subagent") {
const parentAgentId = trimNonEmpty(value.parentAgentId);
const subagentId = trimNonEmpty(value.subagentId);
return parentAgentId && subagentId
? { kind: "provider_subagent", parentAgentId, subagentId }
: null;
}
if (value.kind === "terminal") {
const terminalId = trimNonEmpty(value.terminalId);
return terminalId ? { kind: "terminal", terminalId } : null;
@@ -76,6 +83,9 @@ export function workspaceTabTargetsEqual(
if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId;
}
if (left.kind === "provider_subagent" && right.kind === "provider_subagent") {
return left.parentAgentId === right.parentAgentId && left.subagentId === right.subagentId;
}
if (left.kind === "terminal" && right.kind === "terminal") {
return left.terminalId === right.terminalId;
}
@@ -131,6 +141,9 @@ export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): st
if (target.kind === "agent") {
return `agent_${target.agentId}`;
}
if (target.kind === "provider_subagent") {
return `provider_subagent_${target.parentAgentId.length}_${target.parentAgentId}_${target.subagentId.length}_${target.subagentId}`;
}
if (target.kind === "terminal") {
return `terminal_${target.terminalId}`;
}
@@ -555,6 +555,7 @@ test("advertises client capabilities in hello", async () => {
protocolVersion: 1,
capabilities: {
custom_mode_icons: true,
provider_subagents: true,
reasoning_merge_enum: true,
terminal_reflowable_snapshot: true,
browser_host: {
+76
View File
@@ -490,6 +490,22 @@ export interface FetchAgentTimelineOptions {
timeout?: number;
}
export type ProviderSubagentListPayload = Extract<
SessionOutboundMessage,
{ type: "agent.provider_subagents.list.response" }
>["payload"];
export type ProviderSubagentTimelinePayload = Extract<
SessionOutboundMessage,
{ type: "agent.provider_subagents.timeline.get.response" }
>["payload"];
export interface FetchProviderSubagentTimelineOptions {
direction?: ProviderSubagentTimelinePayload["direction"];
cursor?: FetchAgentTimelineCursor;
limit?: number;
requestId?: string;
timeout?: number;
}
// COMPAT(daemon-client-object-options): added in v0.1.102; remove after
// 2026-12-29 once SDK callers have migrated to object parameters.
function normalizeFetchAgentOptions(
@@ -2429,6 +2445,65 @@ export class DaemonClient {
return payload;
}
async listProviderSubagents(
parentAgentId: string,
options: { requestId?: string; timeout?: number } = {},
): Promise<ProviderSubagentListPayload> {
const requestId = this.createRequestId(options.requestId);
const message = SessionInboundMessageSchema.parse({
type: "agent.provider_subagents.list.request",
parentAgentId,
requestId,
});
const payload = await this.sendRequest({
requestId,
message,
timeout: options.timeout,
options: { skipQueue: true },
select: (response) =>
response.type === "agent.provider_subagents.list.response" &&
response.payload.requestId === requestId
? response.payload
: null,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload;
}
async fetchProviderSubagentTimeline(
parentAgentId: string,
subagentId: string,
options: FetchProviderSubagentTimelineOptions = {},
): Promise<ProviderSubagentTimelinePayload> {
const requestId = this.createRequestId(options.requestId);
const message = SessionInboundMessageSchema.parse({
type: "agent.provider_subagents.timeline.get.request",
parentAgentId,
subagentId,
requestId,
...(options.direction ? { direction: options.direction } : {}),
...(options.cursor ? { cursor: options.cursor } : {}),
...(typeof options.limit === "number" ? { limit: options.limit } : {}),
});
const payload = await this.sendRequest({
requestId,
message,
timeout: options.timeout,
options: { skipQueue: true },
select: (response) =>
response.type === "agent.provider_subagents.timeline.get.response" &&
response.payload.requestId === requestId
? response.payload
: null,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload;
}
async buildAgentForkContext(
agentId: string,
options: AgentForkContextOptions = {},
@@ -4672,6 +4747,7 @@ export class DaemonClient {
[CLIENT_CAPS.customModeIcons]: true,
[CLIENT_CAPS.reasoningMergeEnum]: true,
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
[CLIENT_CAPS.providerSubagents]: true,
...this.config.capabilities,
},
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
@@ -11,6 +11,9 @@ export const CLIENT_CAPS = {
// Old clients use a strict TerminalState schema and would reject the extra fields.
// Drop the gate (always send the flags) when floor >= v0.1.88.
terminalReflowableSnapshot: "terminal_reflowable_snapshot",
// COMPAT(providerSubagents): added in v0.1.107. The daemon emits provider-owned
// child descriptors and timelines only to clients that understand the new messages.
providerSubagents: "provider_subagents",
browserHost: "browser_host",
} as const;
@@ -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(),
});
export const ProviderSubagentListRequestMessageSchema = z.object({
type: z.literal("agent.provider_subagents.list.request"),
parentAgentId: z.string(),
requestId: z.string(),
});
export const ProviderSubagentTimelineRequestMessageSchema = z.object({
type: z.literal("agent.provider_subagents.timeline.get.request"),
parentAgentId: z.string(),
subagentId: z.string(),
requestId: z.string(),
direction: z.enum(["tail", "before", "after"]).optional(),
cursor: AgentTimelineCursorSchema.optional(),
limit: z.number().int().nonnegative().optional(),
});
export const AgentForkContextRequestMessageSchema = z.object({
type: z.literal("agent.fork_context.request"),
agentId: z.string(),
@@ -2089,6 +2105,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
RestartServerRequestMessageSchema,
DaemonUpdateRequestMessageSchema,
FetchAgentTimelineRequestMessageSchema,
ProviderSubagentListRequestMessageSchema,
ProviderSubagentTimelineRequestMessageSchema,
AgentForkContextRequestMessageSchema,
SetAgentModeRequestMessageSchema,
SetAgentModelRequestMessageSchema,
@@ -2367,6 +2385,8 @@ export const ServerInfoStatusPayloadSchema = z
daemonSelfUpdate: z.boolean().optional(),
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: z.boolean().optional(),
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: z.boolean().optional(),
})
.optional(),
})
@@ -2958,6 +2978,88 @@ export const FetchAgentTimelineResponseMessageSchema = z.object({
}),
});
export const ProviderSubagentDescriptorPayloadSchema = z.object({
id: z.string(),
parentAgentId: z.string(),
provider: AgentProviderSchema,
title: z.string().nullable(),
description: z.string().nullable(),
status: z.enum(["running", "completed", "failed", "canceled"]),
createdAt: z.string(),
updatedAt: z.string(),
toolCallId: z.string().nullable(),
cwd: z.string().nullable().optional(),
});
export type ProviderSubagentDescriptorPayload = z.infer<
typeof ProviderSubagentDescriptorPayloadSchema
>;
export const ProviderSubagentListResponseMessageSchema = z.object({
type: z.literal("agent.provider_subagents.list.response"),
payload: z.object({
requestId: z.string(),
parentAgentId: z.string(),
subagents: z.array(ProviderSubagentDescriptorPayloadSchema),
error: z.string().nullable(),
}),
});
export const ProviderSubagentTimelineResponseMessageSchema = z.object({
type: z.literal("agent.provider_subagents.timeline.get.response"),
payload: z.object({
requestId: z.string(),
parentAgentId: z.string(),
subagentId: z.string(),
provider: AgentProviderSchema.nullable(),
direction: z.enum(["tail", "before", "after"]),
epoch: z.string(),
reset: z.boolean(),
staleCursor: z.boolean(),
gap: z.boolean(),
window: z.object({
minSeq: z.number().int().nonnegative(),
maxSeq: z.number().int().nonnegative(),
nextSeq: z.number().int().nonnegative(),
}),
hasOlder: z.boolean(),
hasNewer: z.boolean(),
rows: z.array(
z.object({
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seq: z.number().int().nonnegative(),
}),
),
error: z.string().nullable(),
}),
});
export const ProviderSubagentUpdateMessageSchema = z.object({
type: z.literal("agent.provider_subagents.update"),
payload: z.discriminatedUnion("kind", [
z.object({
kind: z.literal("upsert"),
subagent: ProviderSubagentDescriptorPayloadSchema,
}),
z.object({
kind: z.literal("timeline"),
parentAgentId: z.string(),
subagentId: z.string(),
provider: AgentProviderSchema,
item: AgentTimelineItemPayloadSchema,
timestamp: z.string(),
seq: z.number().int().nonnegative(),
epoch: z.string(),
}),
z.object({
kind: z.literal("remove"),
parentAgentId: z.string(),
subagentId: z.string(),
}),
]),
});
export const AgentForkContextResponseMessageSchema = z.object({
type: z.literal("agent.fork_context.response"),
payload: z.object({
@@ -4207,6 +4309,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ArchiveWorkspaceResponseMessageSchema,
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
ProviderSubagentListResponseMessageSchema,
ProviderSubagentTimelineResponseMessageSchema,
ProviderSubagentUpdateMessageSchema,
AgentForkContextResponseMessageSchema,
CancelAgentResponseMessageSchema,
ClearAgentAttentionResponseMessageSchema,
@@ -4679,6 +4784,7 @@ export const WSHelloMessageSchema = z.object({
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
[CLIENT_CAPS.customModeIcons]: z.boolean().optional(),
[CLIENT_CAPS.terminalReflowableSnapshot]: z.boolean().optional(),
[CLIENT_CAPS.providerSubagents]: z.boolean().optional(),
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
})
.passthrough()
@@ -2331,6 +2331,27 @@ test("importProviderSession imports the selected session without listing and pub
timestamp: "2026-01-02T00:00:02.000Z",
},
],
providerSubagentEvents: [
{
type: "provider_subagent" as const,
provider: "codex" as const,
event: {
type: "upsert" as const,
id: "thread-child",
title: "Imported child",
status: "completed" as const,
},
},
{
type: "provider_subagent" as const,
provider: "codex" as const,
event: {
type: "timeline" as const,
id: "thread-child",
item: { type: "assistant_message" as const, text: "Child result" },
},
},
],
};
}
}
@@ -2373,7 +2394,13 @@ test("importProviderSession imports the selected session without listing and pub
},
},
]);
expect(events).toHaveLength(1);
expect(manager.listProviderSubagents(imported.id)).toEqual([
expect.objectContaining({ id: "thread-child", title: "Imported child", status: "completed" }),
]);
expect(manager.fetchProviderSubagentTimeline(imported.id, "thread-child").rows).toEqual([
expect.objectContaining({ item: { type: "assistant_message", text: "Child result" } }),
]);
expect(events).toHaveLength(3);
expect(events[0]).toMatchObject({
type: "agent_state",
agent: {
@@ -2553,6 +2580,150 @@ test("reloadAgentSession preserves timeline and does not force history replay",
expect(afterHydrate).toEqual(beforeReload);
});
test("reloadAgentSession clears provider children before rehydrating from disk", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-reload-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let activeSession: TestAgentSession | null = null;
class ProviderChildClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
activeSession = new TestAgentSession(config);
return activeSession;
}
override async resumeSession(
_handle: AgentPersistenceHandle,
config?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
return new TestAgentSession({
provider: "codex",
cwd: config?.cwd ?? workdir,
});
}
}
const manager = new AgentManager({
clients: { codex: new ProviderChildClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000116",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
activeSession?.pushEvent({
type: "provider_subagent",
provider: "codex",
event: { type: "upsert", id: "stale-child", title: "Stale child", status: "running" },
});
await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1));
await manager.reloadAgentSession(snapshot.id, undefined, { rehydrateFromDisk: true });
expect(manager.listProviderSubagents(snapshot.id)).toEqual([]);
});
test("hydrateTimelineFromProvider restores and broadcasts provider children from session history", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-history-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
class ProviderChildHistorySession extends TestAgentSession {
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "provider_subagent",
provider: "codex",
event: {
type: "upsert",
id: "restored-child",
title: "Restored child",
status: "completed",
},
};
}
}
class ProviderChildHistoryClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new ProviderChildHistorySession(config);
}
}
const manager = new AgentManager({
clients: { codex: new ProviderChildHistoryClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000117",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), {
agentId: snapshot.id,
replayState: false,
});
await manager.hydrateTimelineFromProvider(snapshot.id, { broadcast: true });
expect(manager.listProviderSubagents(snapshot.id)).toEqual([
expect.objectContaining({
id: "restored-child",
parentAgentId: snapshot.id,
title: "Restored child",
status: "completed",
}),
]);
expect(events).toContainEqual({
type: "provider_subagent",
event: {
type: "upsert",
subagent: expect.objectContaining({
id: "restored-child",
parentAgentId: snapshot.id,
}),
},
});
});
test("force provider hydration removes children absent from current history", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-provider-child-force-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
let session: TestAgentSession | null = null;
class ProviderChildForceClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
session = new TestAgentSession(config);
return session;
}
}
const manager = new AgentManager({
clients: { codex: new ProviderChildForceClient() },
registry: storage,
logger,
idFactory: () => "00000000-0000-4000-8000-000000000118",
});
const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
session?.pushEvent({
type: "provider_subagent",
provider: "codex",
event: { type: "upsert", id: "removed-by-rewind", status: "completed" },
});
await vi.waitFor(() => expect(manager.listProviderSubagents(snapshot.id)).toHaveLength(1));
const events: AgentManagerEvent[] = [];
manager.subscribe((event) => events.push(event), {
agentId: snapshot.id,
replayState: false,
});
await manager.hydrateTimelineFromProvider(snapshot.id, { force: true, broadcast: true });
expect(manager.listProviderSubagents(snapshot.id)).toEqual([]);
expect(events).toContainEqual({
type: "provider_subagent",
event: {
type: "remove",
parentAgentId: snapshot.id,
subagentId: "removed-by-rewind",
},
});
});
test("reloadAgentSession preserves current title when config title is unset", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-title-"));
const storagePath = join(workdir, "agents");
@@ -5006,6 +5177,67 @@ test("subscribe does not emit state events for internal agents to global subscri
expect(receivedEvents.filter((id) => id === generatedAgentIds[1]).length).toBe(0);
});
test("subscribe hides provider subagents of internal parents from global subscribers", async () => {
const internalAgentId = "00000000-0000-4000-8000-000000000117";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-internal-provider-child-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const sessionHolder: { current: TestAgentSession | null } = { current: null };
class InternalProviderChildClient extends TestAgentClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
sessionHolder.current = new TestAgentSession(config);
return sessionHolder.current;
}
}
const manager = new AgentManager({
clients: { codex: new InternalProviderChildClient() },
registry: storage,
logger,
idFactory: () => internalAgentId,
});
const globalEvents: AgentManagerEvent[] = [];
const scopedEvents: AgentManagerEvent[] = [];
manager.subscribe((event) => globalEvents.push(event), { replayState: false });
await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Internal Agent", internal: true },
undefined,
{ workspaceId: undefined },
);
manager.subscribe((event) => scopedEvents.push(event), {
agentId: internalAgentId,
replayState: false,
});
sessionHolder.current?.pushEvent({
type: "provider_subagent",
provider: "codex",
event: { type: "upsert", id: "hidden-child", title: "Hidden child", status: "running" },
});
await manager.flush();
expect(globalEvents.filter((event) => event.type === "provider_subagent")).toEqual([]);
expect(scopedEvents).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "upsert",
subagent: expect.objectContaining({
id: "hidden-child",
parentAgentId: internalAgentId,
}),
}),
}),
);
expect(() => manager.listProviderSubagents(internalAgentId)).toThrow(
`Unknown agent '${internalAgentId}'`,
);
expect(() => manager.getProviderSubagent(internalAgentId, "hidden-child")).toThrow(
`Unknown agent '${internalAgentId}'`,
);
expect(() => manager.fetchProviderSubagentTimeline(internalAgentId, "hidden-child")).toThrow(
`Unknown agent '${internalAgentId}'`,
);
});
test("subscribe emits state events for internal agents when subscribed by agentId", async () => {
const internalAgentId = "00000000-0000-4000-8000-000000000110";
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
+144 -48
View File
@@ -66,6 +66,11 @@ import { isSystemInjectedEnvelope } from "./agent-prompt.js";
import { stripInternalPaseoMcpServer, withRuntimePaseoMcpServer } from "./runtime-mcp-config.js";
import { resolveCreateAgentTitles } from "./create-agent-title.js";
import type { PaseoToolCatalogFactory } from "./tools/types.js";
import {
ProviderSubagentStore,
type ProviderSubagentDescriptor,
type ProviderSubagentStoreEvent,
} from "./provider-subagents/store.js";
const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000;
const INTERRUPT_SESSION_TIMEOUT_MS = 2_000;
@@ -145,6 +150,7 @@ export type {
export type AgentManagerEvent =
| { type: "agent_state"; agent: ManagedAgent }
| { type: "provider_subagent"; event: ProviderSubagentStoreEvent }
| {
type: "agent_stream";
agentId: string;
@@ -534,6 +540,7 @@ export class AgentManager {
private readonly providerEnabled = new Map<AgentProvider, boolean>();
private readonly agents = new Map<string, LiveManagedAgent>();
private readonly timelineStore = new InMemoryAgentTimelineStore();
private readonly providerSubagents = new ProviderSubagentStore();
private readonly agentsAwaitingInitialSnapshotPersist = new Set<string>();
private readonly sessionEventTails = new Map<string, Promise<void>>();
private readonly foregroundRuns = new ForegroundRunState();
@@ -936,6 +943,28 @@ export class AgentManager {
return this.timelineStore.fetch(id, options);
}
listProviderSubagents(parentAgentId: string): ProviderSubagentDescriptor[] {
this.requirePublicAgent(parentAgentId);
return this.providerSubagents.list(parentAgentId);
}
getProviderSubagent(
parentAgentId: string,
subagentId: string,
): ProviderSubagentDescriptor | null {
this.requirePublicAgent(parentAgentId);
return this.providerSubagents.get(parentAgentId, subagentId);
}
fetchProviderSubagentTimeline(
parentAgentId: string,
subagentId: string,
options?: AgentTimelineFetchOptions,
): AgentTimelineFetchResult {
this.requirePublicAgent(parentAgentId);
return this.providerSubagents.fetchTimeline(parentAgentId, subagentId, options);
}
createAgent(
config: AgentSessionConfig,
agentId: string | undefined,
@@ -1086,7 +1115,7 @@ export class AgentManager {
const initialTitle = resolveImportedAgentTitle(importedConfig, timelineRows);
handedToRegistration = true;
return this.registerSession(imported.session, importedConfig, resolvedAgentId, {
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
@@ -1096,6 +1125,11 @@ export class AgentManager {
initialTitle,
publishWhenReady: true,
});
for (const event of imported.providerSubagentEvents ?? []) {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
this.dispatch({ type: "provider_subagent", event: update });
}
return agent;
} finally {
if (!handedToRegistration) {
await this.closeUnregisteredSession(imported.session);
@@ -1168,6 +1202,9 @@ export class AgentManager {
// provider history into an empty timeline.
await this.deleteCommittedTimeline(agentId);
this.timelineStore.delete(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
}
// Preserve existing labels and timeline during reload.
@@ -1261,6 +1298,9 @@ export class AgentManager {
const closedAgent = this.prepareAgentForClosure(agent, "agent closed");
await agent.session.close();
this.timelineStore.delete(agentId);
for (const event of this.providerSubagents.deleteParent(agentId)) {
this.dispatch({ type: "provider_subagent", event });
}
await this.persistSnapshot(closedAgent);
this.emitClosedAgent(closedAgent, { persist: false });
this.logger.trace(
@@ -2837,6 +2877,11 @@ export class AgentManager {
agent: ActiveManagedAgent,
event: AgentStreamEvent,
): Promise<void> {
if (event.type === "provider_subagent") {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
this.dispatch({ type: "provider_subagent", event: update });
return;
}
const turnId = getAgentStreamEventTurnId(event);
const matchingWaiters = this.foregroundRuns.getMatchingWaiters(agent, turnId);
this.logger.trace(
@@ -2975,49 +3020,79 @@ export class AgentManager {
}
if (options?.force) {
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
for await (const event of agent.session.streamHistory()) {
if (event.type === "timeline") {
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
continue;
}
historyEvents.push(event);
}
}
this.agentStreamCoalescer.flushAndDiscard(agent.id);
await this.deleteCommittedTimeline(agent.id);
this.timelineStore.delete(agent.id);
this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() });
agent.historyPrimed = true;
for (const event of historyEvents) {
const item = limitAgentTimelineItemContent(event.item);
const row = this.recordTimeline(
agent.id,
item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (options?.broadcast) {
this.dispatchStream(
agent.id,
{ ...event, item },
{
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
},
);
}
}
this.touchUpdatedAt(agent);
this.emitState(agent);
await this.forceHydrateTimelineFromLegacyProviderHistory(agent, options.broadcast === true);
return;
}
await this.primeTimelineFromLegacyProviderHistory(agent, options?.broadcast === true);
}
private async forceHydrateTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
broadcast: boolean,
): Promise<void> {
const historyEvents: Extract<AgentStreamEvent, { type: "timeline" }>[] = [];
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
for await (const event of agent.session.streamHistory()) {
if (event.type === "timeline") {
if (event.item.type === "user_message" && isSystemInjectedEnvelope(event.item.text)) {
continue;
}
historyEvents.push(event);
} else if (event.type === "provider_subagent") {
providerSubagentEvents.push(event);
}
}
this.agentStreamCoalescer.flushAndDiscard(agent.id);
await this.deleteCommittedTimeline(agent.id);
this.timelineStore.delete(agent.id);
this.timelineStore.initialize(agent.id, { timestamp: new Date().toISOString() });
agent.historyPrimed = true;
for (const event of this.providerSubagents.deleteParent(agent.id)) {
if (broadcast) {
this.dispatch({ type: "provider_subagent", event });
}
}
for (const event of providerSubagentEvents) {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
if (broadcast) {
this.dispatch({ type: "provider_subagent", event: update });
}
}
for (const event of historyEvents) {
const row = this.recordTimeline(
agent.id,
event.item,
event.timestamp ? { timestamp: event.timestamp } : undefined,
);
if (broadcast) {
this.dispatchStream(agent.id, event, {
seq: row.seq,
epoch: this.timelineStore.getEpoch(agent.id),
timestamp: row.timestamp,
});
}
}
this.touchUpdatedAt(agent);
this.emitState(agent);
}
private async primeTimelineFromLegacyProviderHistory(
agent: ActiveManagedAgent,
broadcast: boolean,
): Promise<void> {
agent.historyPrimed = true;
try {
for await (const event of agent.session.streamHistory()) {
if (event.type === "provider_subagent") {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
if (broadcast) {
this.dispatch({ type: "provider_subagent", event: update });
}
continue;
}
if (event.type !== "timeline") {
continue;
}
@@ -3793,22 +3868,35 @@ export class AgentManager {
) {
continue;
}
if (
subscriber.agentId &&
event.type === "provider_subagent" &&
subscriber.agentId !==
(event.event.type === "upsert"
? event.event.subagent.parentAgentId
: event.event.parentAgentId)
) {
continue;
}
// Skip internal agents for global subscribers (those without a specific agentId)
if (!subscriber.agentId) {
if (event.type === "agent_state" && event.agent.internal) {
continue;
}
if (event.type === "agent_stream") {
const agent = this.agents.get(event.agentId);
if (agent?.internal) {
continue;
}
}
if (!subscriber.agentId && this.eventBelongsToInternalAgent(event)) {
continue;
}
subscriber.callback(event);
}
}
private eventBelongsToInternalAgent(event: AgentManagerEvent): boolean {
if (event.type === "agent_state") return event.agent.internal === true;
if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true;
if (event.type !== "provider_subagent") return false;
const parentAgentId =
event.event.type === "upsert"
? event.event.subagent.parentAgentId
: event.event.parentAgentId;
return this.agents.get(parentAgentId)?.internal === true;
}
private async normalizeConfig(
config: AgentSessionConfig,
options: NormalizeConfigOptions = {},
@@ -4031,6 +4119,14 @@ export class AgentManager {
}
return agent;
}
private requirePublicAgent(id: string): LiveManagedAgent {
const agent = this.requireAgent(id);
if (agent.internal) {
throw new Error(`Unknown agent '${agent.id}'`);
}
return agent;
}
}
export function commandMayHaveChangedExternalState(command: string): boolean {
@@ -426,6 +426,11 @@ export type AgentStreamEvent =
provider: AgentProvider;
reason: "finished" | "error" | "permission";
timestamp: string;
}
| {
type: "provider_subagent";
provider: AgentProvider;
event: import("./provider-subagents/store.js").ProviderSubagentInputEvent;
};
export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined {
@@ -541,6 +546,7 @@ export interface ImportedProviderSession {
config: AgentSessionConfig;
persistence: AgentPersistenceHandle;
timeline: ImportedTimelineEntry[];
providerSubagentEvents?: Extract<AgentStreamEvent, { type: "provider_subagent" }>[];
}
export interface AgentSessionConfig {
@@ -33,13 +33,14 @@ export async function importSessionFromPersistence(input: {
const persistence =
input.persistence ?? buildImportPersistenceHandle(input.provider, input.request, storedConfig);
const session = await input.resumeSession(persistence, config, input.context.launchContext);
const timeline = await collectImportedTimeline(session.streamHistory());
const history = await collectImportedHistory(session.streamHistory());
return {
session,
config: storedConfig,
persistence,
timeline,
timeline: history.timeline,
providerSubagentEvents: history.providerSubagentEvents,
};
}
@@ -60,11 +61,17 @@ function buildImportPersistenceHandle(
};
}
async function collectImportedTimeline(
events: AsyncGenerator<AgentStreamEvent>,
): Promise<ImportedTimelineEntry[]> {
async function collectImportedHistory(events: AsyncGenerator<AgentStreamEvent>): Promise<{
timeline: ImportedTimelineEntry[];
providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[];
}> {
const timeline: ImportedTimelineEntry[] = [];
const providerSubagentEvents: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
for await (const event of events) {
if (event.type === "provider_subagent") {
providerSubagentEvents.push(event);
continue;
}
if (event.type !== "timeline") {
continue;
}
@@ -73,5 +80,5 @@ async function collectImportedTimeline(
...(event.timestamp ? { timestamp: event.timestamp } : {}),
});
}
return timeline;
return { timeline, providerSubagentEvents };
}
@@ -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",
elapsed_time_seconds: 1,
},
{
type: "user",
parent_tool_use_id: "task-call-1",
message: {
content: [
{
type: "tool_result",
tool_use_id: "sub-read-1",
tool_name: "Read",
content: "README contents",
is_error: false,
},
],
},
},
{
type: "assistant",
parent_tool_use_id: "task-call-1",
@@ -307,6 +322,44 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
);
expect(projectedTaskCalls).toHaveLength(1);
const providerEvents = events.flatMap((event) =>
event.type === "provider_subagent" ? [event.event] : [],
);
expect(providerEvents).toContainEqual({
type: "timeline",
id: "task-call-1",
item: expect.objectContaining({
type: "tool_call",
callId: "sub-read-1",
status: "running",
}),
});
expect(providerEvents).toContainEqual({
type: "timeline",
id: "task-call-1",
item: expect.objectContaining({
type: "tool_call",
callId: "sub-read-1",
status: "completed",
}),
});
expect(providerEvents).toContainEqual({
type: "timeline",
id: "task-call-1",
item: {
type: "assistant_message",
messageId: "subagent-message-1",
text: "Sub-agent narration belongs inside the Task row, not the parent transcript.",
},
});
expect(providerEvents.at(-1)).toMatchObject({
type: "upsert",
id: "task-call-1",
title: "Explore",
description: "Inspect repository structure",
status: "completed",
});
});
test("keeps sidechain assistant text out of the parent transcript", async () => {
@@ -349,6 +402,46 @@ describe("ClaudeAgentSession sub-agent sidechain updates", () => {
});
});
test("keeps a failed Task subagent failed when the parent turn succeeds", async () => {
const failedEvents = buildTailScenarioEvents(1);
const taskResult = failedEvents.find(
(event) =>
typeof event === "object" &&
event !== null &&
"type" in event &&
event.type === "assistant",
) as { message: Record<string, unknown> } | undefined;
if (!taskResult) throw new Error("expected Task result fixture");
taskResult.message = {
...taskResult.message,
content: [
{
type: "tool_result",
tool_use_id: "task-tail-1",
tool_name: "Task",
content: "failed",
is_error: true,
},
],
};
queryFactory.mockImplementation(() => buildQueryMock(failedEvents));
const session = await new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
}).createSession({ provider: "claude", cwd: process.cwd() });
const events = await collectUntilTerminal(streamSession(session, "delegate work"));
await session.close();
expect(
events
.filter((event) => event.type === "provider_subagent")
.map((event) => event.event)
.at(-1),
).toMatchObject({ type: "upsert", id: "task-tail-1", status: "failed" });
});
test("tails sub-agent actions instead of dropping latest entries at cap", async () => {
queryFactory.mockImplementation(() => buildQueryMock(buildTailScenarioEvents(205)));
@@ -1897,6 +1897,10 @@ class ClaudeAgentSession implements AgentSession {
getToolInput: (toolUseId) => this.toolUseCache.get(toolUseId)?.input ?? null,
});
private persistedHistory: PersistedTimelineEntry[] = [];
private persistedProviderSubagentEvents: Extract<
AgentStreamEvent,
{ type: "provider_subagent" }
>[] = [];
private historyPending = false;
private turnState: TurnState = "idle";
private nextTurnOrdinal = 1;
@@ -2117,11 +2121,16 @@ class ClaudeAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (!this.historyPending || this.persistedHistory.length === 0) {
if (
!this.historyPending ||
(this.persistedHistory.length === 0 && this.persistedProviderSubagentEvents.length === 0)
) {
return;
}
const history = this.persistedHistory;
const providerSubagentEvents = this.persistedProviderSubagentEvents;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
for (const entry of history) {
yield {
@@ -2131,6 +2140,7 @@ class ClaudeAgentSession implements AgentSession {
timestamp: entry.timestamp,
};
}
yield* providerSubagentEvents;
}
async getAvailableModes(): Promise<AgentMode[]> {
@@ -2613,6 +2623,7 @@ class ClaudeAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
@@ -2642,6 +2653,7 @@ class ClaudeAgentSession implements AgentSession {
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = true;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.userMessageIds = [];
this.emittedUserMessageIds.clear();
@@ -3539,6 +3551,7 @@ class ClaudeAgentSession implements AgentSession {
}
this.persistence = null;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
this.cachedRuntimeInfo = null;
this.queryRestartNeeded = false;
@@ -3610,6 +3623,7 @@ class ClaudeAgentSession implements AgentSession {
break;
case "user":
this.appendUserMessageEvents(message, events);
this.appendSidechainResultEvents(message, events);
break;
case "assistant": {
const timelineItems = this.mapBlocksToTimeline(message.message.content, {
@@ -3619,6 +3633,7 @@ class ClaudeAgentSession implements AgentSession {
for (const item of timelineItems) {
events.push({ type: "timeline", item, provider: "claude" });
}
this.appendSidechainResultEvents(message, events);
break;
}
case "stream_event":
@@ -3634,6 +3649,18 @@ class ClaudeAgentSession implements AgentSession {
return events;
}
private appendSidechainResultEvents(message: SDKMessage, events: AgentStreamEvent[]): void {
const content = toObjectRecord(toObjectRecord(message)?.message)?.content;
if (!Array.isArray(content)) return;
for (const block of content) {
const chunk = toObjectRecord(block);
if (chunk?.type !== "tool_result" || typeof chunk.tool_use_id !== "string") continue;
events.push(
...this.sidechainTracker.finish(chunk.tool_use_id, chunk.is_error ? "failed" : "completed"),
);
}
}
private emitSubmittedUserMessage(
message: Extract<SDKMessage, { type: "user" }>,
turnId: string,
@@ -3830,6 +3857,7 @@ class ClaudeAgentSession implements AgentSession {
): void {
const usage = this.convertUsage(message, message.modelUsage);
if (message.subtype === "success") {
events.push(...this.sidechainTracker.finishAll("completed"));
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
// run client-side in the Claude CLI with no model turn — output_tokens
// is 0 and the user-visible text is carried in `result`. Surface it only
@@ -3855,6 +3883,7 @@ class ClaudeAgentSession implements AgentSession {
"errors" in message && Array.isArray(message.errors) && message.errors.length > 0
? message.errors.join("\n")
: "Claude run failed";
events.push(...this.sidechainTracker.finishAll("failed"));
events.push(this.buildTurnFailedEvent(errorMessage));
}
@@ -4166,7 +4195,9 @@ class ClaudeAgentSession implements AgentSession {
if (!historyPath || !fs.existsSync(historyPath)) {
return;
}
this.ingestPersistedHistory(fs.readFileSync(historyPath, "utf8"));
const content = fs.readFileSync(historyPath, "utf8");
this.ingestPersistedHistory(content);
this.ingestPersistedSidechains(content, readClaudeSidechainHistory(historyPath));
} catch {
// ignore history load failures
}
@@ -4188,6 +4219,24 @@ class ClaudeAgentSession implements AgentSession {
}
}
private ingestPersistedSidechains(parentContent: string, sidechainContents: string[]): void {
const parentEntries = parseClaudeHistoryRecords(parentContent).filter(
(entry) => entry.isSidechain !== true,
);
const sidechainEntries = [parentContent, ...sidechainContents]
.flatMap(parseClaudeHistoryRecords)
.filter((entry) => entry.isSidechain === true && typeof entry.agentId === "string");
if (sidechainEntries.length === 0) {
return;
}
this.persistedProviderSubagentEvents.push(
...buildClaudePersistedSidechainEvents(parentEntries, sidechainEntries, (entry) =>
this.convertHistoryEntry(entry),
),
);
this.historyPending = true;
}
private ingestPersistedHistoryLine(line: string, timeline: PersistedTimelineEntry[]): void {
const trimmed = line.trim();
if (!trimmed) {
@@ -4465,7 +4514,6 @@ class ClaudeAgentSession implements AgentSession {
if (typeof block.tool_use_id === "string") {
this.toolUseCache.delete(block.tool_use_id);
this.sidechainTracker.delete(block.tool_use_id);
}
}
@@ -4947,11 +4995,194 @@ function normalizeHistoryBlocks(content: unknown): ClaudeContentChunk[] | null {
return null;
}
function parseClaudeHistoryRecords(content: string): ClaudeHistoryEntry[] {
const entries: ClaudeHistoryEntry[] = [];
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed) continue;
try {
const entry = toObjectRecord(JSON.parse(trimmed));
if (entry) entries.push(entry);
} catch {
// Ignore individual corrupt history rows, matching the parent history replay behavior.
}
}
return entries;
}
function readClaudeSidechainHistory(historyPath: string): string[] {
const sessionDirectory = path.join(
path.dirname(historyPath),
path.basename(historyPath, ".jsonl"),
);
const sidechainDirectory = path.join(sessionDirectory, "subagents");
if (!fs.existsSync(sidechainDirectory)) return [];
const contents: string[] = [];
const directories = [sidechainDirectory];
while (directories.length > 0) {
const directory = directories.pop();
if (!directory) continue;
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
directories.push(entryPath);
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
contents.push(fs.readFileSync(entryPath, "utf8"));
}
}
}
return contents;
}
interface ClaudeHistoricalSubagentToolCall {
subagentType?: string;
description?: string;
}
function readClaudeHistoricalSubagentToolCalls(
entries: ClaudeHistoryEntry[],
): Map<string, ClaudeHistoricalSubagentToolCall> {
const toolCalls = new Map<string, ClaudeHistoricalSubagentToolCall>();
for (const entry of entries) {
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
const block = toObjectRecord(value);
if (
block?.type !== "tool_use" ||
(block.name !== "Task" && block.name !== "Agent") ||
typeof block.id !== "string"
) {
continue;
}
const input = toObjectRecord(block.input);
const subagentType = readNonEmptyString(input?.subagent_type);
const description = readNonEmptyString(input?.description);
toolCalls.set(block.id, {
...(subagentType ? { subagentType } : {}),
...(description ? { description } : {}),
});
}
}
return toolCalls;
}
function readClaudeHistoricalSubagentToolResults(
entries: ClaudeHistoryEntry[],
): Map<string, { toolCallId: string; failed: boolean }> {
const results = new Map<string, { toolCallId: string; failed: boolean }>();
for (const entry of entries) {
const content = toObjectRecord(entry.message)?.content;
if (!Array.isArray(content)) continue;
for (const value of content) {
const block = toObjectRecord(value);
if (block?.type !== "tool_result" || typeof block.tool_use_id !== "string") continue;
const match = /agentId:\s*([\w-]+)/.exec(JSON.stringify(block.content));
if (!match?.[1]) continue;
results.set(match[1], { toolCallId: block.tool_use_id, failed: block.is_error === true });
}
}
return results;
}
function groupClaudeSidechainEntries(
entries: ClaudeHistoryEntry[],
): Map<string, ClaudeHistoryEntry[]> {
const entriesByAgentId = new Map<string, ClaudeHistoryEntry[]>();
for (const entry of entries) {
if (typeof entry.agentId !== "string") continue;
const grouped = entriesByAgentId.get(entry.agentId) ?? [];
grouped.push(entry);
entriesByAgentId.set(entry.agentId, grouped);
}
return entriesByAgentId;
}
function buildClaudePersistedSidechainEvents(
parentEntries: ClaudeHistoryEntry[],
sidechainEntries: ClaudeHistoryEntry[],
convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[],
): Extract<AgentStreamEvent, { type: "provider_subagent" }>[] {
const events: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [];
const toolCalls = readClaudeHistoricalSubagentToolCalls(parentEntries);
const toolResults = readClaudeHistoricalSubagentToolResults(parentEntries);
for (const [agentId, entries] of groupClaudeSidechainEntries(sidechainEntries)) {
events.push(
...buildClaudePersistedSidechainAgentEvents(
agentId,
entries,
toolCalls,
toolResults,
convertEntry,
),
);
}
return events;
}
function buildClaudePersistedSidechainAgentEvents(
agentId: string,
entries: ClaudeHistoryEntry[],
toolCalls: ReadonlyMap<string, ClaudeHistoricalSubagentToolCall>,
toolResults: ReadonlyMap<string, { toolCallId: string; failed: boolean }>,
convertEntry: (entry: ClaudeHistoryEntry) => AgentTimelineItem[],
): Extract<AgentStreamEvent, { type: "provider_subagent" }>[] {
const result = toolResults.get(agentId);
const id = result?.toolCallId ?? agentId;
const toolCall = result ? toolCalls.get(result.toolCallId) : undefined;
const firstTimestamp = normalizeProviderReplayTimestamp(entries[0]?.timestamp);
const events: Extract<AgentStreamEvent, { type: "provider_subagent" }>[] = [
{
type: "provider_subagent",
provider: "claude",
event: {
type: "upsert",
id,
title: toolCall?.subagentType ?? "Claude subagent",
description: toolCall?.description ?? null,
status: "running",
toolCallId: result?.toolCallId ?? null,
...(firstTimestamp ? { timestamp: firstTimestamp } : {}),
},
},
];
for (const entry of entries) {
const timestamp = normalizeProviderReplayTimestamp(entry.timestamp);
for (const item of convertEntry(entry)) {
events.push({
type: "provider_subagent",
provider: "claude",
event: {
type: "timeline",
id,
item,
...(timestamp ? { timestamp } : {}),
},
});
}
}
const lastTimestamp = normalizeProviderReplayTimestamp(entries.at(-1)?.timestamp);
events.push({
type: "provider_subagent",
provider: "claude",
event: {
type: "upsert",
id,
status: result?.failed ? "failed" : "completed",
...(lastTimestamp ? { timestamp: lastTimestamp } : {}),
},
});
return events;
}
interface ClaudeHistoryEntry {
type?: unknown;
subtype?: unknown;
isCompactSummary?: unknown;
isSidechain?: unknown;
agentId?: unknown;
timestamp?: unknown;
uuid?: unknown;
message?: { content?: unknown; [key: string]: unknown };
[key: string]: unknown;
@@ -15,6 +15,7 @@ let lastQuery: ReturnType<typeof buildSdkQueryMock> | null = null;
const LIVE_REPLY_MARKER = "LIVE_ONLY_REPLY_MARKER";
const HISTORY_USER_MARKER = "HISTORY_ONLY_USER_MARKER";
const HISTORY_ASSISTANT_MARKER = "HISTORY_ONLY_ASSISTANT_MARKER";
const HISTORY_SIDECHAIN_MARKER = "HISTORY_ONLY_SIDECHAIN_MARKER";
function buildSdkQueryMock() {
const events = [
@@ -126,6 +127,52 @@ describe("ClaudeAgentSession history replay regression", () => {
content: HISTORY_ASSISTANT_MARKER,
},
}),
JSON.stringify({
type: "assistant",
uuid: "history-task-call-message",
sessionId: "history-session",
cwd,
message: {
role: "assistant",
content: [
{
type: "tool_use",
id: "history-task-call",
name: "Agent",
input: { description: "Inspect persisted history" },
},
],
},
}),
JSON.stringify({
type: "assistant",
isSidechain: true,
agentId: "history-child",
uuid: "history-child-message",
timestamp: "2026-07-12T10:00:01.000Z",
sessionId: "history-session",
cwd,
message: {
id: "history-child-message",
role: "assistant",
content: [{ type: "text", text: HISTORY_SIDECHAIN_MARKER }],
},
}),
JSON.stringify({
type: "user",
sessionId: "history-session",
cwd,
message: {
role: "user",
content: [
{
type: "tool_result",
tool_use_id: "history-task-call",
content: "done\nagentId: history-child",
},
],
},
}),
].join("\n"),
"utf8",
);
@@ -219,6 +266,54 @@ describe("ClaudeAgentSession history replay regression", () => {
expect(timelineText).toContain(HISTORY_ASSISTANT_MARKER);
});
test("replays persisted sidechains as provider subagent timelines", async () => {
const client = new ClaudeAgentClient({
logger: createTestLogger(),
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.resumeSession(
{
provider: "claude",
sessionId: "history-session",
nativeHandle: "history-session",
metadata: { provider: "claude", cwd },
},
{ cwd },
);
const historyEvents: AgentStreamEvent[] = [];
try {
for await (const event of session.streamHistory()) historyEvents.push(event);
} finally {
await session.close();
}
expect(historyEvents).toContainEqual({
type: "provider_subagent",
provider: "claude",
event: {
type: "timeline",
id: "history-task-call",
item: {
type: "assistant_message",
text: HISTORY_SIDECHAIN_MARKER,
messageId: "history-child-message",
},
timestamp: "2026-07-12T10:00:01.000Z",
},
});
expect(historyEvents).toContainEqual({
type: "provider_subagent",
provider: "claude",
event: expect.objectContaining({
type: "upsert",
id: "history-task-call",
status: "completed",
}),
});
});
test("listCommands includes rewind command", async () => {
const logger = createTestLogger();
const client = new ClaudeAgentClient({
@@ -1,6 +1,10 @@
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { mapClaudeRunningToolCall } from "./tool-call-mapper.js";
import {
mapClaudeCompletedToolCall,
mapClaudeFailedToolCall,
mapClaudeRunningToolCall,
} from "./tool-call-mapper.js";
import { buildToolCallDisplayModel } from "@getpaseo/protocol/tool-call-display";
import type { AgentMetadata, AgentStreamEvent, AgentTimelineItem } from "../../agent-sdk-types.js";
@@ -13,6 +17,7 @@ interface ClaudeContentChunk {
interface SubAgentActionEntry {
index: number;
toolName: string;
input: unknown;
summary?: string;
}
@@ -23,6 +28,7 @@ interface SubAgentActivityState {
actionKeys: string[];
nextActionIndex: number;
actionIndexByKey: Map<string, number>;
completedActionKeys: Set<string>;
}
interface SubAgentActionCandidate {
@@ -64,19 +70,34 @@ export class ClaudeSidechainTracker {
actionKeys: [],
nextActionIndex: 1,
actionIndexByKey: new Map<string, number>(),
completedActionKeys: new Set<string>(),
} satisfies SubAgentActivityState);
this.activeSidechains.set(parentToolUseId, state);
const contextUpdated = this.updateSubAgentContextFromTaskInput(state, parentToolUseId);
const actionCandidates = this.extractSubAgentActionCandidates(message);
const childTimelineItems = [
...this.extractSubAgentTimelineItems(message),
...this.extractSubAgentToolResults(message, state),
];
let actionUpdated = false;
for (const action of actionCandidates) {
if (state.completedActionKeys.has(action.key)) continue;
if (this.appendSubAgentAction(state, action)) {
actionUpdated = true;
const toolCall = mapClaudeRunningToolCall({
name: action.toolName,
callId: action.key,
input: action.input,
output: null,
});
if (toolCall) {
childTimelineItems.push(toolCall);
}
}
}
if (!contextUpdated && !actionUpdated) {
if (!contextUpdated && !actionUpdated && childTimelineItems.length === 0) {
return [];
}
@@ -103,6 +124,25 @@ export class ClaudeSidechainTracker {
};
return [
{
type: "provider_subagent",
provider: "claude",
event: {
type: "upsert",
id: parentToolUseId,
title: state.subAgentType ?? "Claude subagent",
description: state.description ?? null,
status: "running",
toolCallId: parentToolUseId,
},
},
...childTimelineItems.map(
(item): AgentStreamEvent => ({
type: "provider_subagent",
provider: "claude",
event: { type: "timeline", id: parentToolUseId, item },
}),
),
{
type: "timeline",
item: {
@@ -114,6 +154,46 @@ export class ClaudeSidechainTracker {
];
}
finishAll(status: "completed" | "failed" | "canceled"): AgentStreamEvent[] {
const events: AgentStreamEvent[] = [];
for (const [id, state] of this.activeSidechains) {
events.push({
type: "provider_subagent",
provider: "claude",
event: {
type: "upsert",
id,
title: state.subAgentType ?? "Claude subagent",
description: state.description ?? null,
status,
toolCallId: id,
},
});
}
this.activeSidechains.clear();
return events;
}
finish(id: string, status: "completed" | "failed" | "canceled"): AgentStreamEvent[] {
const state = this.activeSidechains.get(id);
if (!state) return [];
this.activeSidechains.delete(id);
return [
{
type: "provider_subagent",
provider: "claude",
event: {
type: "upsert",
id,
title: state.subAgentType ?? "Claude subagent",
description: state.description ?? null,
status,
toolCallId: id,
},
},
];
}
delete(toolUseId: string): void {
this.activeSidechains.delete(toolUseId);
}
@@ -122,6 +202,66 @@ export class ClaudeSidechainTracker {
this.activeSidechains.clear();
}
private extractSubAgentTimelineItems(message: SDKMessage): AgentTimelineItem[] {
if (message.type !== "assistant" || !Array.isArray(message.message?.content)) {
return [];
}
const messageId = readTrimmedString(message.message.id);
const items: AgentTimelineItem[] = [];
for (const block of message.message.content) {
if (!isClaudeContentChunk(block)) continue;
if (block.type === "text") {
const text = readTrimmedString(block.text);
if (text) {
items.push({
type: "assistant_message",
text,
...(messageId ? { messageId } : {}),
});
}
} else if (block.type === "thinking") {
const text = readTrimmedString(block.thinking);
if (text) items.push({ type: "reasoning", text });
}
}
return items;
}
private extractSubAgentToolResults(
message: SDKMessage,
state: SubAgentActivityState,
): AgentTimelineItem[] {
const messageRecord = message as unknown as Record<string, unknown>;
const messageContainer = messageRecord.message as Record<string, unknown> | undefined;
const content = messageContainer?.content;
if (!Array.isArray(content)) return [];
const items: AgentTimelineItem[] = [];
for (const block of content) {
if (!isClaudeContentChunk(block) || !block.type.endsWith("tool_result")) continue;
const callId = readTrimmedString(block.tool_use_id);
if (!callId || state.completedActionKeys.has(callId)) continue;
const actionIndex = state.actionIndexByKey.get(callId);
const action = actionIndex === undefined ? undefined : state.actions[actionIndex];
const toolName = action?.toolName ?? readTrimmedString(block.tool_name);
if (!toolName) continue;
const params = {
name: toolName,
callId,
input: action?.input ?? null,
output: block.content ?? null,
};
const toolCall = block.is_error
? mapClaudeFailedToolCall({ ...params, error: block })
: mapClaudeCompletedToolCall(params);
if (toolCall) {
state.completedActionKeys.add(callId);
items.push(toolCall);
}
}
return items;
}
private updateSubAgentContextFromTaskInput(
state: SubAgentActivityState,
parentToolUseId: string,
@@ -259,6 +399,7 @@ export class ClaudeSidechainTracker {
state.actions[existingIndex] = {
...existing,
toolName: normalizedToolName,
input: existing.input ?? candidate.input,
...(nextSummary ? { summary: nextSummary } : {}),
};
return true;
@@ -267,6 +408,7 @@ export class ClaudeSidechainTracker {
state.actions.push({
index: state.nextActionIndex,
toolName: normalizedToolName,
input: candidate.input,
...(summary ? { summary } : {}),
});
state.nextActionIndex += 1;
@@ -279,7 +421,8 @@ export class ClaudeSidechainTracker {
private trimSubAgentTail(state: SubAgentActivityState): void {
while (state.actions.length > MAX_SUB_AGENT_LOG_ENTRIES) {
state.actions.shift();
state.actionKeys.shift();
const removedKey = state.actionKeys.shift();
if (removedKey) state.completedActionKeys.delete(removedKey);
}
}
@@ -1567,7 +1567,7 @@ describe("Codex app-server provider", () => {
asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "child-thread-1",
itemId: "child-message-1",
delta: "Found the path.",
delta: "Found",
});
asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-1",
@@ -1599,6 +1599,128 @@ describe("Codex app-server provider", () => {
actions: [],
},
});
const providerEvents = events.flatMap((event) =>
event.type === "provider_subagent" ? [event.event] : [],
);
expect(providerEvents).toContainEqual(
expect.objectContaining({
type: "upsert",
id: "child-thread-1",
description: "Report findings.",
}),
);
expect(providerEvents).toContainEqual({
type: "timeline",
id: "child-thread-1",
item: {
type: "assistant_message",
messageId: "child-message-1",
text: "Found",
},
});
expect(providerEvents).toContainEqual({
type: "timeline",
id: "child-thread-1",
item: {
type: "assistant_message",
messageId: "child-message-1",
text: " the path.",
},
});
expect(providerEvents.at(-1)).toMatchObject({
type: "upsert",
id: "child-thread-1",
status: "completed",
});
});
test("renders child MCP image results in the provider subagent timeline", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-image-child",
kind: "started",
agentThreadId: "image-child-thread",
agentPath: "/root/image-child",
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "image-child-thread",
item: {
id: "child-mcp-image",
type: "mcpToolCall",
status: "completed",
server: "paseo",
tool: "browser_screenshot",
arguments: {},
result: {
content: [{ type: "image", data: ONE_BY_ONE_PNG_BASE64, mimeType: "image/png" }],
},
},
});
const childItems = events.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "image-child-thread"
? [event.event.item]
: [],
);
expect(childItems).toHaveLength(2);
expect(childItems[0]).toMatchObject({ type: "tool_call", callId: "child-mcp-image" });
expect(childItems[1]).toMatchObject({ type: "assistant_message" });
if (childItems[1]?.type !== "assistant_message") {
throw new Error("Expected child image markdown");
}
const source = markdownImageSource(childItems[1].text);
expect(existsSync(source)).toBe(true);
rmSync(source, { force: true });
});
test("renders a child user message once across lifecycle notifications", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "subAgentActivity",
id: "spawn-user-child",
kind: "started",
agentThreadId: "user-child-thread",
agentPath: "/root/user-child",
},
});
const childUserMessage = {
type: "userMessage",
id: "child-user-message",
content: [{ type: "text", text: "Inspect this path." }],
};
asInternals(session).handleNotification("item/started", {
threadId: "user-child-thread",
item: childUserMessage,
});
asInternals(session).handleNotification("item/completed", {
threadId: "user-child-thread",
item: childUserMessage,
});
expect(
events.filter(
(event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === "user-child-thread" &&
event.event.item.type === "user_message",
),
).toHaveLength(1);
});
test("keeps the parent running when a MultiAgentV2 sub-agent finishes", async () => {
@@ -2095,6 +2217,20 @@ describe("Codex app-server provider", () => {
event.item.callId === "child-command",
),
).toBe(false);
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: {
type: "timeline",
id: "legacy-envelope-child",
item: expect.objectContaining({
type: "tool_call",
callId: "child-command",
status: "running",
}),
},
}),
);
expect(events.filter((event) => event.type === "turn_completed")).toHaveLength(0);
expect(events.at(-1)).toMatchObject({
type: "timeline",
@@ -2343,10 +2479,28 @@ describe("Codex app-server provider", () => {
test("loads mixed legacy and MultiAgentV2 sub-agent history", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string) => {
request: vi.fn(async (method: string, params: unknown) => {
if (method !== "thread/read") {
return {};
}
const threadId = (params as { threadId?: string }).threadId;
if (threadId !== "test-thread") {
return {
thread: {
turns: [
{
items: [
{
type: "agentMessage",
id: `message-${threadId}`,
text: `History from ${threadId}`,
},
],
},
],
},
};
}
return {
thread: {
turns: [
@@ -2382,6 +2536,38 @@ describe("Codex app-server provider", () => {
for await (const event of session.streamHistory()) {
history.push(event);
}
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
),
).toMatchObject([
{ type: "upsert", id: "legacy-child-thread", status: "completed" },
{ type: "upsert", id: "v2-child-thread", status: "completed" },
]);
expect(
history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "timeline" ? [event.event] : [],
),
).toEqual([
{
type: "timeline",
id: "legacy-child-thread",
item: {
type: "assistant_message",
messageId: "message-legacy-child-thread",
text: "History from legacy-child-thread",
},
},
{
type: "timeline",
id: "v2-child-thread",
item: {
type: "assistant_message",
messageId: "message-v2-child-thread",
text: "History from v2-child-thread",
},
},
]);
expect(
history
.filter((event) => event.type === "timeline" && event.item.type === "tool_call")
@@ -2447,10 +2633,13 @@ describe("Codex app-server provider", () => {
test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string) => {
request: vi.fn(async (method: string, params: unknown) => {
if (method !== "thread/read") {
return {};
}
if ((params as { threadId?: string }).threadId !== "test-thread") {
return { thread: { turns: [] } };
}
return {
thread: {
turns: [
@@ -2495,6 +2684,15 @@ describe("Codex app-server provider", () => {
history.push(event);
}
expect(history).toEqual([
{
type: "provider_subagent",
provider: "codex",
event: expect.objectContaining({
type: "upsert",
id: "history-child-thread",
status: "canceled",
}),
},
{
type: "timeline",
provider: "codex",
@@ -3061,6 +3061,7 @@ interface CodexSubAgentCallState {
pendingFileChangeOutputDeltas: Map<string, string[]>;
childItemOrder: string[];
childItems: Map<string, AgentTimelineItem>;
childThreadIds: Set<string>;
}
export class CodexAppServerAgentSession implements AgentSession {
@@ -3081,6 +3082,8 @@ export class CodexAppServerAgentSession implements AgentSession {
private planModeEnabled = false;
private historyPending = false;
private persistedHistory: PersistedTimelineEntry[] = [];
private loadingPersistedHistory = false;
private persistedProviderSubagentEvents: AgentStreamEvent[] = [];
private pendingPermissions = new Map<string, AgentPermissionRequest>();
private mcpElicitationPermissionIds = new Map<number, string>();
private pendingPermissionHandlers = new Map<
@@ -3105,6 +3108,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private emittedExecCommandCompletedCallIds = new Set<string>();
private emittedItemStartedIds = new Set<string>();
private emittedItemCompletedIds = new Set<string>();
private emittedProviderSubagentUserMessageKeys = new Set<string>();
private subAgentCallsByCallId = new Map<string, CodexSubAgentCallState>();
private subAgentCallIdByChildThreadId = new Map<string, string>();
private pendingSubAgentNotificationsByThreadId = new Map<string, ParsedCodexNotification[]>();
@@ -3454,12 +3458,12 @@ export class CodexAppServerAgentSession implements AgentSession {
this.subAgentCallsByCallId.clear();
this.subAgentCallIdByChildThreadId.clear();
this.pendingSubAgentNotificationsByThreadId.clear();
for (const route of subAgentRoutes) {
this.registerSubAgentToolCall({
timelineItem: route.toolCall,
rawItem: { agentThreadId: route.childThreadId },
parentCallId: null,
});
this.persistedProviderSubagentEvents = [];
this.loadingPersistedHistory = true;
try {
await this.loadPersistedSubAgentHistories(client, subAgentRoutes);
} finally {
this.loadingPersistedHistory = false;
}
this.resetCodexUserMessageTurns();
for (const entry of timeline) {
@@ -3471,6 +3475,44 @@ export class CodexAppServerAgentSession implements AgentSession {
this.historyPending = timeline.length > 0;
}
private async loadPersistedSubAgentHistories(
client: CodexAppServerClientLike,
rootRoutes: readonly PersistedSubAgentRoute[],
): Promise<void> {
const queue = rootRoutes.map((route) => ({ route, parentCallId: null as string | null }));
const visitedThreadIds = new Set<string>();
while (queue.length > 0 && visitedThreadIds.size < 100) {
const next = queue.shift();
if (!next || visitedThreadIds.has(next.route.childThreadId)) {
continue;
}
visitedThreadIds.add(next.route.childThreadId);
this.registerSubAgentToolCall({
timelineItem: next.route.toolCall,
rawItem: { agentThreadId: next.route.childThreadId },
parentCallId: next.parentCallId,
});
try {
const childHistory = await loadCodexThreadHistoryTimeline({
threadId: next.route.childThreadId,
cwd: this.config.cwd ?? null,
requestThread: (childThreadId) => readCodexThread(client, childThreadId),
});
for (const entry of childHistory.timeline) {
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
}
for (const route of childHistory.subAgentRoutes) {
queue.push({ route, parentCallId: next.route.toolCall.callId });
}
} catch (error) {
this.logger.trace(
{ err: error, childThreadId: next.route.childThreadId },
"Failed to load persisted Codex child history",
);
}
}
}
private async ensureThreadLoaded(): Promise<void> {
if (!this.client || !this.currentThreadId) return;
try {
@@ -3818,12 +3860,20 @@ export class CodexAppServerAgentSession implements AgentSession {
}
async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
if (!this.historyPending || this.persistedHistory.length === 0) {
if (
(!this.historyPending || this.persistedHistory.length === 0) &&
this.persistedProviderSubagentEvents.length === 0
) {
return;
}
const history = this.persistedHistory;
const providerSubagents = this.persistedProviderSubagentEvents;
this.persistedHistory = [];
this.persistedProviderSubagentEvents = [];
this.historyPending = false;
for (const event of providerSubagents) {
yield event;
}
for (const entry of history) {
yield {
type: "timeline",
@@ -4454,6 +4504,10 @@ export class CodexAppServerAgentSession implements AgentSession {
}
private emitEvent(event: AgentStreamEvent): void {
if (this.loadingPersistedHistory && event.type === "provider_subagent") {
this.persistedProviderSubagentEvents.push(event);
return;
}
this.notifySubscribers(event);
}
@@ -4724,6 +4778,7 @@ export class CodexAppServerAgentSession implements AgentSession {
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
childItemOrder: [],
childItems: new Map<string, AgentTimelineItem>(),
childThreadIds: new Set<string>(),
} satisfies CodexSubAgentCallState);
state.toolCall = {
@@ -4754,6 +4809,8 @@ export class CodexAppServerAgentSession implements AgentSession {
);
for (const receiverThreadId of childThreadIds) {
this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId);
state.childThreadIds.add(receiverThreadId);
this.emitProviderSubagentUpsert(receiverThreadId, state, timelineItem.status);
}
return childThreadIds;
}
@@ -4835,12 +4892,20 @@ export class CodexAppServerAgentSession implements AgentSession {
private emitCodexToolTimelineItem(
timelineItem: ToolCallTimelineItem,
subAgentCallId: string | null,
childThreadId?: string | null,
): void {
if (!subAgentCallId) {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
return;
}
this.upsertSubAgentChildItem(subAgentCallId, timelineItem.callId, timelineItem);
const state = this.subAgentCallsByCallId.get(subAgentCallId);
if (state) {
const targetThreadIds = childThreadId ? [childThreadId] : state.childThreadIds;
for (const targetThreadId of targetThreadIds) {
this.emitProviderSubagentTimeline(targetThreadId, timelineItem);
}
}
this.emitSubAgentActivityUpdate(
subAgentCallId,
timelineItem.status === "running" ? "running" : undefined,
@@ -4867,6 +4932,9 @@ export class CodexAppServerAgentSession implements AgentSession {
? curateAgentActivity(childTimeline, { labelAssistantMessages: true })
: "";
const resolvedStatus = status ?? state.toolCall.status;
for (const childThreadId of state.childThreadIds) {
this.emitProviderSubagentUpsert(childThreadId, state, resolvedStatus);
}
const baseToolCall = {
...state.toolCall,
detail: {
@@ -4895,6 +4963,100 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: nextToolCall });
}
private emitProviderSubagentUpsert(
childThreadId: string,
state: CodexSubAgentCallState,
status: ToolCallTimelineItem["status"],
): void {
const detail = state.toolCall.detail;
if (detail.type !== "sub_agent") {
return;
}
let providerStatus: "running" | "completed" | "failed" | "canceled" = "running";
if (status === "completed") {
providerStatus = "completed";
} else if (status === "failed") {
providerStatus = "failed";
} else if (status === "canceled") {
providerStatus = "canceled";
}
this.emitEvent({
type: "provider_subagent",
provider: CODEX_PROVIDER,
event: {
type: "upsert",
id: childThreadId,
title: detail.subAgentType ?? "Codex subagent",
description: detail.description ?? null,
status: providerStatus,
toolCallId: state.callId,
},
});
}
private emitProviderSubagentTimeline(
childThreadId: string,
item: AgentTimelineItem,
timestamp?: string,
): void {
this.emitEvent({
type: "provider_subagent",
provider: CODEX_PROVIDER,
event: {
type: "timeline",
id: childThreadId,
item,
...(timestamp ? { timestamp } : {}),
},
});
}
private emitCompletedProviderSubagentItem(
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
timelineItem: AgentTimelineItem,
): void {
const itemId = parsed.item.id;
if (!parsed.threadId) return;
if (timelineItem.type === "assistant_message" && itemId) {
const streamedText = this.pendingAgentMessages.get(itemId);
if (streamedText !== undefined) {
const suffix = this.buildMissingFinalTextSuffix(timelineItem, streamedText);
if (suffix) this.emitProviderSubagentTimeline(parsed.threadId, suffix);
return;
}
}
if (timelineItem.type === "reasoning" && itemId) {
const streamedText = this.pendingReasoning.get(itemId)?.join("");
if (streamedText !== undefined) {
const suffix = this.buildMissingFinalTextSuffix(timelineItem, streamedText);
if (suffix) this.emitProviderSubagentTimeline(parsed.threadId, suffix);
return;
}
}
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
}
private emitStartedProviderSubagentItem(
threadId: string | null,
timelineItem: AgentTimelineItem,
): void {
if (threadId) {
this.emitProviderSubagentTimeline(threadId, timelineItem);
}
}
private emitProviderSubagentTimelineItems(
threadId: string | null,
timelineItems: readonly AgentTimelineItem[],
): void {
if (!threadId) {
return;
}
for (const timelineItem of timelineItems) {
this.emitProviderSubagentTimeline(threadId, timelineItem);
}
}
private handleSubAgentChildItemCompleted(
callId: string,
itemId: string | undefined,
@@ -4949,6 +5111,13 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingAgentMessages.set(parsed.itemId, text);
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, {
type: "assistant_message",
messageId: parsed.itemId,
text: parsed.delta,
});
}
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
type: "assistant_message",
messageId: parsed.itemId,
@@ -4981,6 +5150,12 @@ export class CodexAppServerAgentSession implements AgentSession {
this.pendingReasoning.set(parsed.itemId, prev);
const subAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (subAgentCallId) {
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, {
type: "reasoning",
text: parsed.delta,
});
}
this.upsertSubAgentChildItem(subAgentCallId, parsed.itemId, {
type: "reasoning",
text: prev.join(""),
@@ -5080,6 +5255,7 @@ export class CodexAppServerAgentSession implements AgentSession {
this.latestPlanResult = null;
this.emittedItemStartedIds.clear();
this.emittedItemCompletedIds.clear();
this.emittedProviderSubagentUserMessageKeys.clear();
this.emittedExecCommandStartedCallIds.clear();
this.emittedExecCommandCompletedCallIds.clear();
this.pendingAgentMessages.clear();
@@ -5224,7 +5400,7 @@ export class CodexAppServerAgentSession implements AgentSession {
running: true,
});
if (timelineItem) {
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId);
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
}
}
@@ -5257,7 +5433,7 @@ export class CodexAppServerAgentSession implements AgentSession {
if (!subAgentCallId) {
this.emittedExecCommandCompletedCallIds.add(timelineItem.callId);
}
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId);
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
}
}
@@ -5306,7 +5482,7 @@ export class CodexAppServerAgentSession implements AgentSession {
callId: parsed.callId,
changes: parsed.changes,
});
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId);
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
}
}
@@ -5336,7 +5512,7 @@ export class CodexAppServerAgentSession implements AgentSession {
changes: parsed.changes,
stdout: parsed.stdout,
});
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId);
this.emitCodexToolTimelineItem(timelineItem, subAgentCallId, parsed.threadId);
}
}
@@ -5372,8 +5548,11 @@ export class CodexAppServerAgentSession implements AgentSession {
parentCallId: childSubAgentCallId,
})
: [];
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
if (childSubAgentCallId) {
this.emitCompletedProviderSubagentItem(parsed, timelineItem);
this.handleSubAgentChildItemCompleted(childSubAgentCallId, parsed.item.id, timelineItem);
this.emitProviderSubagentTimelineItems(parsed.threadId, imageItems);
this.replayPendingSubAgentNotifications(registeredChildThreadIds);
return;
}
@@ -5408,7 +5587,6 @@ export class CodexAppServerAgentSession implements AgentSession {
}
this.warnOnIncompleteEditToolCall(timelineItem, "item_completed", parsed.item);
}
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
if (timelineItem.type === "assistant_message") {
this.pendingAssistantMessageBoundary = true;
@@ -5452,26 +5630,24 @@ export class CodexAppServerAgentSession implements AgentSession {
timelineItem: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
streamedText: string,
): void {
if (!timelineItem.text.startsWith(streamedText)) {
this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item: timelineItem });
return;
}
const item = this.buildMissingFinalTextSuffix(timelineItem, streamedText);
if (item) this.emitEvent({ type: "timeline", provider: CODEX_PROVIDER, item });
}
private buildMissingFinalTextSuffix(
timelineItem: Extract<AgentTimelineItem, { type: "assistant_message" | "reasoning" }>,
streamedText: string,
): AgentTimelineItem | null {
if (!timelineItem.text.startsWith(streamedText)) return timelineItem;
const suffix = timelineItem.text.slice(streamedText.length);
if (!suffix) {
return;
}
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item:
timelineItem.type === "assistant_message"
? {
type: timelineItem.type,
text: suffix,
...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}),
}
: { type: timelineItem.type, text: suffix },
});
if (!suffix) return null;
return timelineItem.type === "assistant_message"
? {
type: timelineItem.type,
text: suffix,
...(timelineItem.messageId ? { messageId: timelineItem.messageId } : {}),
}
: { type: timelineItem.type, text: suffix };
}
private applyBufferedDeltaTextToTimelineItem(
@@ -5484,14 +5660,15 @@ export class CodexAppServerAgentSession implements AgentSession {
if (timelineItem.type === "assistant_message") {
const buffered = this.pendingAgentMessages.get(itemId);
if (buffered && buffered.length > 0) {
timelineItem.text = buffered;
if (!timelineItem.text.startsWith(buffered)) timelineItem.text = buffered;
}
return;
}
if (timelineItem.type === "reasoning") {
const buffered = this.pendingReasoning.get(itemId);
if (buffered && buffered.length > 0) {
timelineItem.text = buffered.join("");
const streamedText = buffered.join("");
if (!timelineItem.text.startsWith(streamedText)) timelineItem.text = streamedText;
}
}
}
@@ -5537,6 +5714,7 @@ export class CodexAppServerAgentSession implements AgentSession {
parentCallId: childSubAgentCallId,
});
if (childSubAgentCallId) {
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
if (parsed.item.id) {
this.upsertSubAgentChildItem(childSubAgentCallId, parsed.item.id, timelineItem);
}
@@ -5580,6 +5758,18 @@ export class CodexAppServerAgentSession implements AgentSession {
}
const childSubAgentCallId = this.getSubAgentCallIdForThread(parsed.threadId);
if (childSubAgentCallId) {
const childMessageId = itemId ?? timelineItem.messageId;
if (!childMessageId) {
return;
}
const childMessageKey = `${parsed.threadId ?? childSubAgentCallId}:${childMessageId}`;
if (this.emittedProviderSubagentUserMessageKeys.has(childMessageKey)) {
return;
}
this.emittedProviderSubagentUserMessageKeys.add(childMessageKey);
if (parsed.threadId) {
this.emitProviderSubagentTimeline(parsed.threadId, timelineItem);
}
if (itemId) {
this.upsertSubAgentChildItem(childSubAgentCallId, itemId, timelineItem);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -164,6 +164,34 @@ describe("OpenCodeServerManager generations", () => {
expect(runtime.terminatedPorts).toEqual([4474]);
});
test("acquireExisting keeps a retired dedicated server alive until every reference releases", async () => {
const { manager, runtime } = createTestManager([4475]);
const dedicatedAcquisition = await manager.acquireDedicated({ PASEO_AGENT_ID: "parent" });
const existingAcquisition = manager.acquireExisting(dedicatedAcquisition.server.url);
expect(existingAcquisition?.server.url).toBe("http://127.0.0.1:4475");
dedicatedAcquisition.release();
expect(runtime.terminatedPorts).toEqual([]);
existingAcquisition?.release();
expect(runtime.terminatedPorts).toEqual([4475]);
});
test("acquireExisting returns null for unknown or dead server urls", async () => {
const { manager, runtime } = createTestManager([4476]);
const acquisition = await manager.acquireDedicated({ PASEO_AGENT_ID: "parent" });
const url = acquisition.server.url;
expect(manager.acquireExisting("http://127.0.0.1:9999")).toBe(null);
acquisition.release();
expect(runtime.terminatedPorts).toEqual([4476]);
expect(manager.acquireExisting(url)).toBe(null);
});
test("repeated rotations leave zero unreferenced retired servers", async () => {
const { manager, runtime } = createTestManager([4501, 4502, 4503]);
@@ -29,6 +29,7 @@ export interface OpenCodeServerManagerLike {
acquireCurrent(): Promise<OpenCodeServerAcquisition>;
acquireNew(): Promise<OpenCodeServerAcquisition>;
acquireDedicated(env: Record<string, string>): Promise<OpenCodeServerAcquisition>;
acquireExisting(url: string): OpenCodeServerAcquisition | null;
shutdown(): Promise<void>;
}
@@ -164,6 +165,27 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
}
}
acquireExisting(url: string): OpenCodeServerAcquisition | null {
const server = this.findLiveServerByUrl(url);
return server ? this.acquireServer(server) : null;
}
private findLiveServerByUrl(url: string): OpenCodeServerGeneration | null {
const servers = [
...(this.currentServer ? [this.currentServer] : []),
...Array.from(this.retiredServers),
];
return servers.find((server) => server.url === url && this.isServerLive(server)) ?? null;
}
private isServerLive(server: OpenCodeServerGeneration): boolean {
return (
!server.process.killed &&
server.process.exitCode === null &&
server.process.signalCode === null
);
}
private acquireServer(server: OpenCodeServerGeneration): OpenCodeServerAcquisition {
server.refCount += 1;
let released = false;
@@ -1,8 +1,9 @@
import type { OpenCodeServerAcquisition, OpenCodeServerManagerLike } from "./server-manager.js";
export interface TestOpenCodeServerAcquisition {
kind: "current" | "new" | "dedicated";
kind: "current" | "new" | "dedicated" | "existing";
env?: Record<string, string>;
url?: string;
released: boolean;
}
@@ -28,14 +29,20 @@ export class TestOpenCodeServerManager implements OpenCodeServerManagerLike {
return this.recordAcquisition({ kind: "dedicated", env });
}
acquireExisting(url: string): OpenCodeServerAcquisition | null {
return url === this.server.url ? this.recordAcquisition({ kind: "existing", url }) : null;
}
private recordAcquisition(input: {
kind: TestOpenCodeServerAcquisition["kind"];
env?: Record<string, string>;
url?: string;
}): OpenCodeServerAcquisition {
const acquisition: TestOpenCodeServerAcquisition = {
kind: input.kind,
released: false,
...(input.env ? { env: input.env } : {}),
...(input.url ? { url: input.url } : {}),
};
this.acquisitions.push(acquisition);
return {
@@ -9,8 +9,9 @@ interface OpenCodeResponse {
export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
readonly acquisitions: Array<{
kind: "current" | "new" | "dedicated";
kind: "current" | "new" | "dedicated" | "existing";
env?: Record<string, string>;
url?: string;
releaseCount: number;
}> = [];
readonly clientCreations: Array<{ baseUrl: string; directory: string }> = [];
@@ -34,14 +35,20 @@ export class TestOpenCodeHarness implements OpenCodeServerManagerLike {
return this.recordAcquisition({ kind: "dedicated", env });
}
acquireExisting(url: string): OpenCodeServerAcquisition | null {
return url === this.server.url ? this.recordAcquisition({ kind: "existing", url }) : null;
}
private recordAcquisition(input: {
kind: "current" | "new" | "dedicated";
kind: "current" | "new" | "dedicated" | "existing";
env?: Record<string, string>;
url?: string;
}): OpenCodeServerAcquisition {
const acquisition = {
kind: input.kind,
releaseCount: 0,
...(input.env ? { env: input.env } : {}),
...(input.url ? { url: input.url } : {}),
};
this.acquisitions.push(acquisition);
return {
@@ -82,6 +89,7 @@ export class TestOpenCodeClient {
sessionCommand: [] as unknown[],
sessionCreate: [] as unknown[],
sessionDelete: [] as unknown[],
sessionChildren: [] as unknown[],
sessionGet: [] as unknown[],
sessionMessages: [] as unknown[],
sessionPromptAsync: [] as unknown[],
@@ -106,6 +114,8 @@ export class TestOpenCodeClient {
sessionCommandResponse: OpenCodeResponse = {};
sessionCreateResponse: OpenCodeResponse = { data: { id: "session-1" } };
sessionDeleteResponse: OpenCodeResponse = {};
sessionChildrenResponses: OpenCodeResponse[] = [];
sessionChildrenImplementation: ((parameters: unknown) => Promise<OpenCodeResponse>) | null = null;
sessionGetResponse: OpenCodeResponse = {
data: { id: "session-1", directory: "/workspace/repo", title: null },
};
@@ -219,6 +229,13 @@ export class TestOpenCodeClient {
this.calls.sessionDelete.push(parameters);
return this.sessionDeleteResponse;
},
children: async (parameters: unknown) => {
this.calls.sessionChildren.push(parameters);
if (this.sessionChildrenImplementation) {
return await this.sessionChildrenImplementation(parameters);
}
return this.sessionChildrenResponses.shift() ?? { data: [] };
},
get: async (parameters: unknown) => {
this.calls.sessionGet.push(parameters);
return this.sessionGetResponse;
+132 -1
View File
@@ -1165,6 +1165,43 @@ export class Session {
return;
}
if (event.type === "provider_subagent") {
if (!this.supports(CLIENT_CAPS.providerSubagents)) {
return;
}
const update = event.event;
if (update.type === "upsert") {
this.emit({
type: "agent.provider_subagents.update",
payload: { kind: "upsert", subagent: update.subagent },
});
} else if (update.type === "timeline") {
this.emit({
type: "agent.provider_subagents.update",
payload: {
kind: "timeline",
parentAgentId: update.parentAgentId,
subagentId: update.subagentId,
provider: update.provider,
item: update.row.item,
timestamp: update.row.timestamp,
seq: update.row.seq,
epoch: update.epoch,
},
});
} else {
this.emit({
type: "agent.provider_subagents.update",
payload: {
kind: "remove",
parentAgentId: update.parentAgentId,
subagentId: update.subagentId,
},
});
}
return;
}
if (
this.voiceSession.isActiveForAgent(event.agentId) &&
event.event.type === "permission_requested" &&
@@ -1450,6 +1487,10 @@ export class Session {
switch (msg.type) {
case "fetch_agent_timeline_request":
return this.handleFetchAgentTimelineRequest(msg);
case "agent.provider_subagents.list.request":
return this.handleProviderSubagentListRequest(msg);
case "agent.provider_subagents.timeline.get.request":
return this.handleProviderSubagentTimelineRequest(msg);
case "agent.fork_context.request":
return this.handleAgentForkContextRequest(msg);
default:
@@ -2708,7 +2749,7 @@ export class Session {
extractTimestamps(record),
);
}
await this.agentManager.hydrateTimelineFromProvider(agentId);
await this.agentManager.hydrateTimelineFromProvider(agentId, { broadcast: true });
await this.agentUpdates.forwardLiveAgent(snapshot);
const timelineSize = this.agentManager.getTimeline(agentId).length;
if (requestId) {
@@ -5215,6 +5256,96 @@ export class Session {
}
}
private async handleProviderSubagentListRequest(
msg: Extract<SessionInboundMessage, { type: "agent.provider_subagents.list.request" }>,
): Promise<void> {
try {
this.emit({
type: "agent.provider_subagents.list.response",
payload: {
requestId: msg.requestId,
parentAgentId: msg.parentAgentId,
subagents: this.agentManager.listProviderSubagents(msg.parentAgentId),
error: null,
},
});
} catch (error) {
this.emit({
type: "agent.provider_subagents.list.response",
payload: {
requestId: msg.requestId,
parentAgentId: msg.parentAgentId,
subagents: [],
error: error instanceof Error ? error.message : String(error),
},
});
}
}
private async handleProviderSubagentTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "agent.provider_subagents.timeline.get.request" }>,
): Promise<void> {
const direction: AgentTimelineFetchDirection = msg.direction ?? (msg.cursor ? "after" : "tail");
try {
const descriptor = this.agentManager.getProviderSubagent(msg.parentAgentId, msg.subagentId);
if (!descriptor) {
throw new Error("Provider subagent not found");
}
const timeline = this.agentManager.fetchProviderSubagentTimeline(
msg.parentAgentId,
msg.subagentId,
{
direction,
cursor: msg.cursor,
limit: msg.limit ?? (direction === "after" ? 0 : 200),
},
);
this.emit({
type: "agent.provider_subagents.timeline.get.response",
payload: {
requestId: msg.requestId,
parentAgentId: msg.parentAgentId,
subagentId: msg.subagentId,
provider: descriptor.provider,
direction,
epoch: timeline.epoch,
reset: timeline.reset,
staleCursor: timeline.staleCursor,
gap: timeline.gap,
window: timeline.window,
hasOlder: timeline.hasOlder,
hasNewer: timeline.hasNewer,
rows: timeline.rows.map((row) => ({
item: row.item,
timestamp: row.timestamp,
seq: row.seq,
})),
error: null,
},
});
} catch (error) {
this.emit({
type: "agent.provider_subagents.timeline.get.response",
payload: {
requestId: msg.requestId,
parentAgentId: msg.parentAgentId,
subagentId: msg.subagentId,
provider: null,
direction,
epoch: "",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: 0, nextSeq: 0 },
hasOlder: false,
hasNewer: false,
rows: [],
error: error instanceof Error ? error.message : String(error),
},
});
}
}
private async handleAgentForkContextRequest(
msg: Extract<SessionInboundMessage, { type: "agent.fork_context.request" }>,
): Promise<void> {
@@ -1259,6 +1259,8 @@ export class VoiceAssistantWebSocketServer {
daemonSelfUpdate: true,
// COMPAT(agentForkContext): added in v0.1.102, remove gate after 2026-12-28.
agentForkContext: true,
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: true,
},
};
}