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