Preserve nested subagent ownership (#4321)

* fix(subagents): preserve recursive provider ownership

Provider events retained enough lineage to identify nested work, but the canonical descriptor discarded the direct owner. Preserve that relationship at ingestion so child timelines and hierarchy remain attached to the process that emitted them, including after history replay.

* test(e2e): clarify nested ownership journey

* test(e2e): separate nested ownership actions

* fix(subagents): retain nested ownership across replay

Wait and interaction events can reference descendants without spawning them. Preserve the original spawning owner and route persisted background notifications through that same relationship after history reload.

* test(subagents): hide provider turn plumbing

* fix(subagents): scope nested tracks to their host

Plugin-provided icons are host-specific. Carry the panel host through nested child tracks so the rebased ownership UI resolves the same provider presentation as its parent.

* fix(workspaces): preserve launched terminal focus
This commit is contained in:
Mohamed Boudra
2026-09-04 23:49:26 +02:00
committed by GitHub
parent c610676203
commit f4b209be4d
27 changed files with 1193 additions and 128 deletions
+4 -3
View File
@@ -156,9 +156,9 @@ The rows combine two kinds of children:
parentAgentId === thisAgent.id AND !archivedAt
```
- **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.
- **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. A descriptor's optional `parentSubagentId` identifies its direct provider-subagent parent; an absent value identifies a direct child of the managed agent.
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.
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. It shows its own direct children in a subagents track. 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.
@@ -174,7 +174,8 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta
- **A resumed task can be announced again with a new `tool_use_id`.** The first Task tool id remains the canonical descriptor and later ids are routing aliases for the same session-scoped task. The resumed prompt is added to that child timeline.
- **Effort is only reachable through hooks.** It appears nowhere on the message stream at any depth, and the level Paseo requests is not necessarily the level that runs — a model that does not support it is silently downgraded. A hook firing inside a subagent reports the active post-downgrade level next to its `agent_id`, which is the same id `task_started` calls `task_id`.
- **Backgrounded subagents emit no frames carrying `parent_tool_use_id` at all.** Everything keyed off that field sees nothing for one; they are visible only because the task protocol announces them.
- **On replay, `<session>/subagents/` holds every descendant, not just this session's children.** `agent-<id>.meta.json` carries `spawnDepth`: `1` is a direct child, `2+` was spawned by another subagent and its `toolUseId` names a Task call made inside its parent's session, which nothing in this transcript can resolve. Replaying those adds rows the live stream never showed, each with no Task card and no recoverable outcome, so they render as running forever. One recorded session showed 10 subagents live and would have replayed 22.
- **Nested ownership comes from the launching sidechain, not `spawn_depth` alone.** A sidechain's Agent or Bash tool call records the direct owner of that `tool_use_id`; the following `task_started` inherits it. This routes a grandchild descriptor and child-owned background notifications without relying on labels or flattening them into the managed parent.
- **On replay, `<session>/subagents/` holds every descendant beside the root.** Resolve the tree one proven generation at a time: the root transcript admits direct children, then each admitted sidechain transcript admits its children by `toolUseId`. `spawnDepth` orders candidates but does not establish ownership. Unresolved sidecars remain excluded as ambient or unrelated work.
- **Replay `totalTokens` is a context-size reading, not cumulative spend.** Claude Code finalizes a subagent by summing the _last_ assistant message's usage block and shipping that as `usage.total_tokens`. Summing per-entry usage instead multiplies the cached prefix by the turn count and reports a number several times larger than the live path.
Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.
@@ -9,6 +9,11 @@ import {
type AgentHandle,
type RewindFlowProvider,
} from "../support/helpers/rewind-flow";
import {
expectNestedProviderSubagentOwnership,
launchNestedProviderSubagentOwnershipScenario,
reopenNestedProviderSession,
} from "../support/helpers/provider-subagents";
import { openSubagentsTrack } from "../support/helpers/subagents";
interface ProviderSubagentCase {
@@ -26,7 +31,7 @@ const cases: ProviderSubagentCase[] = [
provider: "claude",
sentinel: "CLAUDE_CHILD_SENTINEL",
expectedName: "sentinel_child",
providerConfig: { model: "opus" },
providerConfig: { model: "claude-sonnet-5" },
prompt:
'Use Claude Code\'s native Task tool exactly once. Set its subagent_type input to "Explore" and its name input to "sentinel_child". Ask it to reply with exactly CLAUDE_CHILD_SENTINEL and do nothing else. Wait for it, then reply ROOT_DONE. Do not use Paseo tools.',
},
@@ -147,3 +152,23 @@ test.describe("real provider subagent timelines", () => {
});
}
});
test.describe("real Claude nested subagent ownership", () => {
test.setTimeout(600_000);
test("keeps a grandchild and its background notification with their direct owners", async ({
page,
}, testInfo) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-claude-nested-ui-")));
let handle: AgentHandle | undefined;
try {
handle = await launchNestedProviderSubagentOwnershipScenario(page, cwd);
await expectNestedProviderSubagentOwnership(page, testInfo, "live");
await reopenNestedProviderSession(handle);
await expectNestedProviderSubagentOwnership(page, testInfo, "reopened");
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
@@ -0,0 +1,94 @@
import { expect, type Locator, type Page, type TestInfo } from "@playwright/test";
import { launchAgent, sendMessage, type AgentHandle } from "./rewind-flow";
import { openSubagentsTrack } from "./subagents";
const NESTED_OWNERSHIP_PROMPT =
"You are ROOT_OWNER. Use Claude Code's native Agent tool exactly once, never Paseo tools. " +
"Name the agent direct_owner and give it this complete task: You are DIRECT_OWNER. Use " +
"Claude Code's native Agent tool exactly once. Name that agent nested_owner and give it " +
"this complete task: You are NESTED_OWNER. Use Bash exactly once to run `sleep 2; printf " +
"'NESTED_BACKGROUND_SENTINEL\\n'` with run_in_background true. Wait for the background " +
"command's completion notification. Use TaskOutput with block true to wait for the Bash task and verify exit code 0 and the sentinel output before replying exactly NESTED_DONE. Do not finish while it is running. Wait for nested_owner " +
"to finish, then reply exactly DIRECT_DONE. Wait for direct_owner to finish, then reply " +
"exactly ROOT_DONE.";
export async function launchNestedProviderSubagentOwnershipScenario(
page: Page,
cwd: string,
): Promise<AgentHandle> {
const handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "claude-sonnet-5" },
});
await sendMessage(handle, NESTED_OWNERSHIP_PROMPT);
await expect(page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible({
timeout: 120_000,
});
return handle;
}
async function attachScreenshot(page: Page, testInfo: TestInfo, name: string): Promise<void> {
const path = testInfo.outputPath(`${name}.png`);
await page.screenshot({ path });
await testInfo.attach(name, { path, contentType: "image/png" });
}
async function expectOnlySubagent(page: Page, label: RegExp): Promise<Locator> {
const rows = page.locator('[data-testid^="subagents-track-row-"]');
await expect(rows).toHaveCount(1);
const row = page.getByTestId("subagents-track-header-panel").getByRole("button", { name: label });
await expect(row).toBeVisible();
return row;
}
async function selectOnlyRootSubagent(page: Page, label: RegExp): Promise<void> {
await openSubagentsTrack(page);
const row = await expectOnlySubagent(page, label);
await row.click();
}
async function openOwnedSubagents(page: Page, ownerPanel: Locator): Promise<void> {
await ownerPanel.getByTestId("subagents-track-header").click();
await expect(page.getByTestId("subagents-track-header-panel")).toBeVisible();
}
export async function expectNestedProviderSubagentOwnership(
page: Page,
testInfo: TestInfo,
phase: string,
): Promise<void> {
await expect(
page.getByText("Task notification", { exact: true }).filter({ visible: true }),
).toHaveCount(0);
await selectOnlyRootSubagent(page, /^direct_owner\b/);
const directPanel = page.getByTestId("provider-subagent-panel").filter({ visible: true });
await expect(directPanel).toBeVisible();
await openOwnedSubagents(page, directPanel);
const nestedRow = await expectOnlySubagent(page, /^nested_owner\b/);
await attachScreenshot(page, testInfo, `nested-ownership-${phase}-tree`);
await nestedRow.click();
const nestedPanel = page.locator('[data-testid="provider-subagent-panel"]:visible');
await expect(
nestedPanel
.getByRole("button", {
name: /^Task notification Background command .*completed \(exit code 0\)/,
})
.first(),
).toBeVisible();
await expect(nestedPanel).toContainText("NESTED_BACKGROUND_SENTINEL");
await expect(nestedPanel).toContainText("NESTED_DONE");
await attachScreenshot(page, testInfo, `nested-ownership-${phase}-timeline`);
}
export async function reopenNestedProviderSession(handle: AgentHandle): Promise<void> {
await handle.client.refreshAgent(handle.agentId);
await handle.page.reload();
await handle.page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
await expect(handle.page.getByText("ROOT_DONE", { exact: true }).last()).toBeVisible();
}
@@ -318,9 +318,9 @@ export async function assertFileContains(filePath: string, text: string): Promis
export async function assertComposerIdle(handle: Pick<AgentHandle, "page">): Promise<void> {
await expectComposerEditable(handle.page);
await expect(handle.page.getByRole("button", { name: /stop|cancel/i })).toHaveCount(0, {
timeout: 30_000,
});
await expect(
handle.page.getByRole("button", { name: /^(?:Stop agent|Canceling agent)$/ }),
).toHaveCount(0, { timeout: 30_000 });
await expect(handle.page.getByTestId("turn-working-indicator")).toHaveCount(0, {
timeout: 30_000,
});
@@ -5,10 +5,18 @@ import invariant from "tiny-invariant";
import { useShallow } from "zustand/react/shallow";
import { AgentStreamView } from "@/agent-stream/view";
import { getProviderIcon } from "@/components/provider-icons";
import {
resolveComposerTrackControlClearance,
resolveComposerTrackTailClearance,
} from "@/composer/pill-styles";
import { ComposerTrackBar } from "@/composer/tracks";
import { useIsCompactFormFactor } from "@/constants/layout";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { usePaneContext } from "@/panels/pane-context";
import { definePanel, type PanelDescriptor } from "@/panels/panel-registry";
import { useSessionStore } from "@/stores/session-store";
import { useSubagentsForParent } from "@/subagents/select";
import { SubagentsTrack } from "@/subagents/track";
import {
providerSubagentKey,
providerSubagentLifecycleStatus,
@@ -24,6 +32,38 @@ import type { TurnPresentation } from "@/timeline/turn-liveness";
const EMPTY_PERMISSIONS = new Map<string, PendingPermission>();
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const NOOP_SUBAGENT = () => undefined;
function resolveChildTrackClearance(childCount: number, isCompact: boolean) {
if (childCount === 0) return { tail: 0, controls: 0 };
return {
tail: resolveComposerTrackTailClearance(isCompact),
controls: resolveComposerTrackControlClearance(isCompact),
};
}
function ProviderSubagentChildTrack({
serverId,
rows,
onOpenProviderSubagent,
}: {
serverId: string;
rows: ReturnType<typeof useSubagentsForParent>;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
}) {
if (rows.length === 0) return null;
return (
<ComposerTrackBar>
<SubagentsTrack
serverId={serverId}
rows={rows}
onOpenSubagent={NOOP_SUBAGENT}
onOpenProviderSubagent={onOpenProviderSubagent}
onArchiveSubagent={NOOP_SUBAGENT}
/>
</ComposerTrackBar>
);
}
function formatProviderLabel(provider: string): string {
return provider
@@ -68,7 +108,7 @@ function useProviderSubagentDescriptor(
function ProviderSubagentPanel() {
const { t } = useTranslation();
const { serverId, target, openFileInWorkspace } = usePaneContext();
const { serverId, target, openFileInWorkspace, openTab } = 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)}`;
@@ -89,6 +129,19 @@ function ProviderSubagentPanel() {
// COMPAT(providerSubagents): added in v0.2.11, remove after 2027-01-12.
const supported = serverInfo?.features?.providerSubagents === true;
const [isLoadingOlder, setIsLoadingOlder] = useState(false);
const isCompact = useIsCompactFormFactor();
const childRows = useSubagentsForParent({
serverId,
parentAgentId: target.parentAgentId,
providerParentSubagentId: target.subagentId,
});
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
const openProviderChild = useCallback(
(parentAgentId: string, subagentId: string) => {
openTab({ kind: "provider_subagent", parentAgentId, subagentId });
},
[openTab],
);
useEffect(() => {
if (!client || !supported) return;
@@ -207,6 +260,13 @@ function ProviderSubagentPanel() {
onOpenWorkspaceFile={openFileInWorkspace}
readOnly
historyPagination={historyPagination}
bottomOverlayTailClearance={childTrackClearance.tail}
bottomOverlayControlClearance={childTrackClearance.controls}
/>
<ProviderSubagentChildTrack
serverId={serverId}
rows={childRows}
onOpenProviderSubagent={openProviderChild}
/>
</View>
);
@@ -117,6 +117,11 @@ import {
} from "./new-workspace-initial-context";
import { buildNewWorkspaceProjectIconTargets } from "./new-workspace/project-icon-targets";
import { useNewWorkspaceProjectPicker } from "./new-workspace/project-picker";
import {
buildTerminalsQueryKey,
type ListTerminalsPayload,
upsertCreatedTerminalPayload,
} from "./workspace/terminals/state";
const ThemedFolderPlus = withUnistyles(FolderPlus);
const foregroundMutedColorMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
@@ -2106,10 +2111,20 @@ export function NewWorkspaceScreen({
undefined,
{ command: input.command, args: input.args, workspaceId: input.workspaceId },
);
if (!createdTerminal.terminal) {
const terminal = createdTerminal.terminal;
if (!terminal) {
throw new Error(createdTerminal.error ?? t("newWorkspace.errors.createWorktreeFailed"));
}
return { terminalId: createdTerminal.terminal.id };
queryClient.setQueryData<ListTerminalsPayload>(
buildTerminalsQueryKey(selectedServerId, input.workspaceDirectory, input.workspaceId),
(current) =>
upsertCreatedTerminalPayload({
current,
terminal,
workspaceDirectory: input.workspaceDirectory,
}),
);
return { terminalId: terminal.id };
},
sendTerminalInput: (terminalId, data) => {
withConnectedClient().sendTerminalInput(terminalId, { type: "input", data });
@@ -2127,6 +2142,7 @@ export function NewWorkspaceScreen({
}, [
ensureWorkspace,
launchTarget,
queryClient,
selectedServerId,
selectedSourceDirectory,
selectedTerminalProfile,
+48
View File
@@ -128,6 +128,54 @@ describe("selectSubagentsForParent", () => {
expect(useProviderSubagentStore.getState().descriptors.size).toBe(1);
});
it("places nested provider children only beneath their direct provider parent", () => {
const store = useProviderSubagentStore.getState();
const base = {
parentAgentId: "parent-a",
provider: "claude" as const,
title: "general-purpose",
subtitle: null,
status: "running" as const,
createdAt: "2026-09-04T10:00:00.000Z",
updatedAt: "2026-09-04T10:00:00.000Z",
toolCallId: null,
};
store.applyUpdate(SERVER_ID, {
kind: "upsert",
subagent: { ...base, id: "direct", description: "Direct", parentSubagentId: null },
});
store.applyUpdate(SERVER_ID, {
kind: "upsert",
subagent: {
...base,
id: "nested",
description: "Nested",
parentSubagentId: "direct",
},
});
expect(
selectProviderSubagentsForParent(
useProviderSubagentStore.getState(),
{ serverId: SERVER_ID, parentAgentId: "parent-a" },
true,
true,
).map((row) => row.id),
).toEqual(["direct"]);
expect(
selectProviderSubagentsForParent(
useProviderSubagentStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-a",
providerParentSubagentId: "direct",
},
true,
true,
).map((row) => row.id),
).toEqual(["nested"]);
});
it("returns only non-archived children for the requested parent", () => {
setAgents([
makeAgent({ id: "parent-a" }),
+17 -2
View File
@@ -44,6 +44,8 @@ type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.
interface SelectSubagentsParams {
serverId: string;
parentAgentId: string;
/** Select children of this provider subagent instead of children of the managed agent. */
providerParentSubagentId?: string;
}
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
@@ -97,12 +99,20 @@ export function selectProviderSubagentsForParent(
state: ProviderSubagentStoreSnapshot,
params: SelectSubagentsParams,
supported: boolean,
nestingSupported = false,
): ProviderSubagentRow[] {
if (!supported) return EMPTY_PROVIDER_SUBAGENT_ROWS;
if (params.providerParentSubagentId && !nestingSupported) 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) || state.hiddenFromTrack.has(key)) continue;
if (
nestingSupported &&
(subagent.parentSubagentId ?? null) !== (params.providerParentSubagentId ?? null)
) {
continue;
}
rows.push({
kind: "provider",
id: subagent.id,
@@ -130,9 +140,13 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo
const supported = useSessionStore(
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
);
const nestingSupported = useSessionStore(
(state) =>
state.sessions[params.serverId]?.serverInfo?.features?.providerSubagentNesting === true,
);
const providerRows = useStoreWithEqualityFn(
useProviderSubagentStore,
(state) => selectProviderSubagentsForParent(state, params, supported),
(state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported),
equal,
);
const client = useSessionStore((state) => state.sessions[params.serverId]?.client ?? null);
@@ -145,9 +159,10 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo
}, [client, params.parentAgentId, params.serverId, supported]);
return useMemo(() => {
if (params.providerParentSubagentId) return providerRows;
if (providerRows.length === 0) return paseoRows;
const rows = [...paseoRows, ...providerRows];
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
return rows;
}, [paseoRows, providerRows]);
}, [params.providerParentSubagentId, paseoRows, providerRows]);
}
+4
View File
@@ -3493,6 +3493,8 @@ export const ServerInfoStatusPayloadSchema = z
agentForkContextCursor: z.boolean().optional(),
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: z.boolean().optional(),
// COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04.
providerSubagentNesting: z.boolean().optional(),
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
workspacePinning: z.boolean().optional(),
// COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X.
@@ -4458,6 +4460,8 @@ export const AgentTimelineListPromptsResponseMessageSchema = z.object({
export const ProviderSubagentDescriptorPayloadSchema = z.object({
id: z.string(),
parentAgentId: z.string(),
// COMPAT(providerSubagentNesting): added in v0.7, remove optional after 2027-03-04.
parentSubagentId: z.string().nullable().optional(),
provider: AgentProviderSchema,
title: z.string().nullable(),
description: z.string().nullable(),
@@ -13,6 +13,8 @@ export type ProviderSubagentStatus = "running" | "completed" | "failed" | "cance
export interface ProviderSubagentDescriptor {
id: string;
parentAgentId: string;
/** Direct provider-subagent parent. Null identifies a child of the managed agent. */
parentSubagentId: string | null;
provider: AgentProvider;
title: string | null;
description: string | null;
@@ -38,6 +40,7 @@ export type ProviderSubagentInputEvent =
toolCallId?: string | null;
cwd?: string | null;
subtitle?: string | null;
parentSubagentId?: string | null;
timestamp?: string;
}
| {
@@ -123,6 +126,7 @@ export class ProviderSubagentStore {
toolCallId: stickyField(event.toolCallId, previous?.toolCallId),
cwd: stickyField(event.cwd, previous?.cwd),
subtitle: stickyField(event.subtitle, previous?.subtitle),
parentSubagentId: stickyField(event.parentSubagentId, previous?.parentSubagentId),
};
this.descriptors.set(key, subagent);
return { type: "upsert", subagent };
@@ -313,4 +313,101 @@ describe("background Claude subagents", () => {
},
});
});
test.each([
{
source: "system task protocol",
notification: {
type: "system",
subtype: "task_notification",
task_id: "bash-task",
tool_use_id: "toolu_bash",
status: "completed",
summary: "sleep 2; printf NESTED_BACKGROUND_SENTINEL",
},
},
{
source: "queued user envelope",
notification: {
type: "user",
uuid: "bash-notification",
message: {
role: "user",
content:
"<task-notification>\n<task-id>bash-task</task-id>\n<tool-use-id>toolu_bash</tool-use-id>\n<status>completed</status>\n<summary>sleep 2; printf NESTED_BACKGROUND_SENTINEL</summary>\n</task-notification>",
},
},
},
])(
"routes a child-owned background notification from $source to that child's timeline",
async ({ notification }) => {
queryFactory.mockImplementation(() =>
buildQueryMock([
{ type: "system", subtype: "init", session_id: "nested-session" },
{
type: "system",
subtype: "task_started",
task_id: "direct-task",
tool_use_id: "toolu_direct",
task_type: "local_agent",
subagent_type: "general-purpose",
description: "Run a background command",
spawn_depth: 1,
},
{
type: "assistant",
parent_tool_use_id: "toolu_direct",
message: {
model: "claude-sonnet-5",
content: [
{
type: "tool_use",
id: "toolu_bash",
name: "Bash",
input: { command: "sleep 2; printf NESTED_BACKGROUND_SENTINEL" },
},
],
},
},
{
type: "system",
subtype: "task_started",
task_id: "bash-task",
tool_use_id: "toolu_bash",
task_type: "local_bash",
owned_by_subagent: true,
is_backgrounded: true,
},
notification,
{ type: "result", subtype: "success", usage: {}, total_cost_usd: 0 },
]),
);
const session = await new ClaudeAgentClient({
logger: createTestLogger(),
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 === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification",
),
).toEqual([]);
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
id: "toolu_direct",
item: expect.objectContaining({ type: "tool_call", name: "task_notification" }),
}),
}),
);
},
);
});
@@ -0,0 +1,149 @@
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import pino from "pino";
import { expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
import { ClaudeAgentClient } from "./agent.js";
const ROOT_PROMPT = `You are ROOT_OWNER. Use Claude Code's native Agent tool exactly once, never Paseo tools.
Name the agent direct_owner and give it this complete task:
You are DIRECT_OWNER. Use Claude Code's native Agent tool exactly once. Name that agent nested_owner and give it this complete task:
You are NESTED_OWNER. Use Bash exactly once to run \`sleep 2; printf 'NESTED_BACKGROUND_SENTINEL\\n'\` with run_in_background true. Use TaskOutput with block true to wait for the Bash task, verify its exit code is 0 and output is NESTED_BACKGROUND_SENTINEL, and wait for its completion notification before replying exactly NESTED_DONE. Do not finish while the command is running.
Wait for nested_owner to finish, then reply exactly DIRECT_DONE.
Wait for direct_owner to finish, then reply exactly ROOT_DONE.`;
test("attributes a nested Claude child and its background notification to their direct owners", async () => {
const cwd = mkdtempSync(path.join(tmpdir(), "paseo-claude-nested-ownership-"));
const client = new ClaudeAgentClient({ logger: pino({ level: "trace" }) });
const session = await client.createSession({
provider: "claude",
cwd,
model: "claude-sonnet-5",
modeId: "bypassPermissions",
});
try {
const events = await collectSessionTurnEvents(session, ROOT_PROMPT);
expect(events).toContainEqual(expect.objectContaining({ type: "turn_completed" }));
expect(
events
.flatMap((event) =>
event.type === "timeline" && event.item.type === "assistant_message"
? [event.item.text]
: [],
)
.join("")
.trim(),
).toBe("ROOT_DONE");
const descriptors = events
.filter((event) => event.type === "provider_subagent" && event.event.type === "upsert")
.map((event) => event.event)
.filter((event) => event.type === "upsert" && event.description);
const direct = descriptors.find((event) => event.description === "direct_owner");
const nested = descriptors.find((event) => event.description === "nested_owner");
expect(direct).toMatchObject({ description: "direct_owner" });
expect(nested).toMatchObject({ description: "nested_owner" });
expect(nested?.parentSubagentId).toBe(direct?.id);
expect(
events.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.callId === nested?.id,
),
).toEqual([]);
expect(
events.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification",
),
).toEqual([]);
expect(events).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
id: nested?.id,
item: expect.objectContaining({
type: "tool_call",
name: "task_notification",
}),
}),
}),
);
const notification = events
.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.id === nested?.id &&
event.event.item.type === "tool_call" &&
event.event.item.name === "task_notification"
? [event.event.item]
: [],
)
.at(-1);
expect(notification?.metadata?.status).toBe("completed");
const outputFile = notification?.metadata?.outputFile;
expect(typeof outputFile).toBe("string");
expect(readFileSync(outputFile as string, "utf8").trim()).toMatch(
/^NESTED_BACKGROUND_SENTINEL\s+\[exited with code 0\]$/,
);
const persistence = session.describePersistence();
expect(persistence).not.toBeNull();
await session.close();
const restored = await client.resumeSession(persistence!, { cwd });
try {
const replayed: AgentStreamEvent[] = [];
for await (const event of restored.streamHistory()) replayed.push(event);
expect(
replayed.filter(
(event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification",
),
).toEqual([]);
expect(replayed).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "upsert",
id: nested?.id,
parentSubagentId: direct?.id,
}),
}),
);
expect(replayed).toContainEqual(
expect.objectContaining({
type: "provider_subagent",
event: expect.objectContaining({
type: "timeline",
id: nested?.id,
item: expect.objectContaining({
name: "task_notification",
metadata: expect.objectContaining({
toolUseId: notification?.metadata?.toolUseId,
status: "completed",
}),
}),
}),
}),
);
} finally {
await restored.close();
}
} finally {
await session.close().catch(() => undefined);
rmSync(cwd, { recursive: true, force: true });
}
}, 360_000);
@@ -259,6 +259,89 @@ describe("ClaudeAgentSession persisted subagent replay", () => {
rmSync(tempRoot, { recursive: true, force: true });
});
test.each(["system", "user", "queue-operation"] as const)(
"restores nested background notification ownership from %s history",
async (form) => {
const nestedId = "toolu_nested";
const bashId = "toolu_nested_bash";
const timestamp = "2026-07-26T06:28:01.000Z";
function notification(toolUseId: string) {
const content = `<task-notification><task-id>${toolUseId}-task</task-id><tool-use-id>${toolUseId}</tool-use-id><status>completed</status><summary>Background command completed</summary></task-notification>`;
const records = {
system: {
subtype: "task_notification",
task_id: `${toolUseId}-task`,
tool_use_id: toolUseId,
status: "completed",
summary: "Background command completed",
},
user: { uuid: `${toolUseId}-notification`, message: { role: "user", content } },
"queue-operation": { operation: "enqueue", content },
};
return JSON.stringify({ type: form, timestamp, ...records[form] });
}
const subagentDir = writeParentSession([
taskToolUse(),
taskToolResult(),
notification(bashId),
notification("toolu_root_bash"),
notification("toolu_orphan_bash"),
]);
function toolEntry(agentId: string, id: string, name: string) {
return JSON.stringify({
type: "assistant",
isSidechain: true,
agentId,
message: { role: "assistant", content: [{ type: "tool_use", id, name, input: {} }] },
});
}
writeSubagent({
subagentDir,
meta: JSON.stringify({ toolUseId: TOOL_USE_ID }),
sidechainLines: [toolEntry(AGENT_ID, nestedId, "Agent")],
});
writeSubagent({
subagentDir,
agentId: "nested-agent",
meta: JSON.stringify({ toolUseId: nestedId }),
sidechainLines: [toolEntry("nested-agent", bashId, "Bash")],
});
writeSubagent({
subagentDir,
agentId: "orphan-agent",
meta: JSON.stringify({ toolUseId: "missing-parent-tool" }),
sidechainLines: [toolEntry("orphan-agent", "toolu_orphan_bash", "Bash")],
});
const replayed = await replayEvents();
const rootNotifications = replayed.flatMap((event) =>
event.type === "timeline" &&
event.item.type === "tool_call" &&
event.item.name === "task_notification"
? [event.item.metadata?.toolUseId]
: [],
);
expect(rootNotifications).toEqual(["toolu_root_bash", "toolu_orphan_bash"]);
const childNotifications = replayed.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "timeline" &&
event.event.item.type === "tool_call" &&
event.event.item.name === "task_notification"
? [event.event]
: [],
);
expect(childNotifications).toEqual([
expect.objectContaining({
id: nestedId,
timestamp,
item: expect.objectContaining({
metadata: expect.objectContaining({ toolUseId: bashId }),
}),
}),
]);
},
);
test("links a subagent to its Task call through the meta sidecar", async () => {
writeSession({
parentLines: [taskToolUse(), taskToolResult()],
@@ -287,6 +287,11 @@ interface AsyncMessageInput<T> {
iterable: AsyncIterable<T>;
}
interface ClaudeReplayOwnership {
restoredIds: ReadonlySet<string>;
toolOwners: ReadonlyMap<string, string>;
}
interface PersistedTimelineEntry {
item: AgentTimelineItem;
timestamp?: string;
@@ -4162,6 +4167,9 @@ class ClaudeAgentSession implements AgentSession {
private buildSubagentToolCallCard(
declaration: Extract<SubagentObservation, { kind: "declared" }>,
): AgentStreamEvent | null {
// The launching sidechain already owns and renders this tool call. Mirroring it into the
// managed parent's transcript would flatten both the card and the child relationship.
if (declaration.parentSubagentId) return null;
const toolCall = mapClaudeRunningToolCall({
name: "Task",
callId: declaration.id,
@@ -4283,11 +4291,6 @@ class ClaudeAgentSession implements AgentSession {
message: Extract<SDKMessage, { type: "system"; subtype: "task_notification" }>,
events: AgentStreamEvent[],
): void {
// TODO: subagent timelines are best-effort. Subagent task_notifications
// arrive without parent_tool_use_id but with tool_use_id pointing at the
// the parent's subagent tool call, so they slip past the sidechain router and pollute
// the parent timeline. Drop them here; eventually thread them into the
// parent tool call's sub_agent log instead.
const taskUseId = message.tool_use_id;
const cachedTool = taskUseId ? this.toolUseCache.get(taskUseId) : undefined;
// The task protocol owns provider-subagent identity. Workflow launch results arrive before
@@ -4300,6 +4303,15 @@ class ClaudeAgentSession implements AgentSession {
return;
}
const taskNotificationItem = mapTaskNotificationSystemRecordToToolCall(message);
const ownerSubagentId = this.taskProtocolSource.resolveTaskOwner(message.task_id, taskUseId);
if (taskNotificationItem && ownerSubagentId) {
events.push({
type: "provider_subagent",
provider: "claude",
event: { type: "timeline", id: ownerSubagentId, item: taskNotificationItem },
});
return;
}
if (taskNotificationItem) {
events.push({
type: "timeline",
@@ -4333,11 +4345,7 @@ class ClaudeAgentSession implements AgentSession {
messageId,
});
if (taskNotificationItem) {
events.push({
type: "timeline",
item: taskNotificationItem,
provider: "claude",
});
this.appendUserTaskNotificationEvent(taskNotificationItem, events);
return;
}
if (typeof content === "string" && content.length > 0) {
@@ -4359,6 +4367,28 @@ class ClaudeAgentSession implements AgentSession {
}
}
private appendUserTaskNotificationEvent(
item: Extract<AgentTimelineItem, { type: "tool_call" }>,
events: AgentStreamEvent[],
): void {
const taskId = typeof item.metadata?.taskId === "string" ? item.metadata.taskId : undefined;
const toolUseId =
typeof item.metadata?.toolUseId === "string" ? item.metadata.toolUseId : undefined;
if (taskId && this.taskProtocolSource.isDeclaredTask(taskId)) return;
const ownerSubagentId = taskId
? this.taskProtocolSource.resolveTaskOwner(taskId, toolUseId)
: undefined;
if (ownerSubagentId) {
events.push({
type: "provider_subagent",
provider: "claude",
event: { type: "timeline", id: ownerSubagentId, item },
});
return;
}
events.push({ type: "timeline", item, provider: "claude" });
}
private appendUserContentArrayEvents(
content: ReadonlyArray<unknown>,
messageId: string | undefined,
@@ -4807,27 +4837,24 @@ class ClaudeAgentSession implements AgentSession {
return;
}
const content = fs.readFileSync(historyPath, "utf8");
const restoredProviderSubagentIds = this.ingestPersistedSidechains(
const replay = this.ingestPersistedSidechains(
content,
readClaudeSidechainHistory(historyPath),
);
this.ingestPersistedHistory(content, restoredProviderSubagentIds);
this.ingestPersistedHistory(content, replay);
} catch {
// ignore history load failures
}
}
private ingestPersistedHistory(
content: string,
restoredProviderSubagentIds: ReadonlySet<string>,
): void {
private ingestPersistedHistory(content: string, replay: ClaudeReplayOwnership): void {
if (!content) {
return;
}
const timeline: PersistedTimelineEntry[] = [];
for (const line of content.split(/\r?\n/)) {
this.ingestPersistedHistoryLine(line, timeline, restoredProviderSubagentIds);
this.ingestPersistedHistoryLine(line, timeline, replay);
}
if (timeline.length > 0) {
@@ -4839,7 +4866,7 @@ class ClaudeAgentSession implements AgentSession {
private ingestPersistedSidechains(
parentContent: string,
sidechains: ClaudeSidechainHistory,
): Set<string> {
): ClaudeReplayOwnership {
const parentEntries = parseClaudeHistoryRecords(parentContent).filter(
(entry) => entry.isSidechain !== true,
);
@@ -4849,16 +4876,18 @@ class ClaudeAgentSession implements AgentSession {
// Replay produces the same observations the live task protocol produces, then folds them
// with the same function, so identity and status are derived once for both paths.
const subagentReplay = observeReplaySubagents({
subagents: [...groupClaudeSidechainEntries(sidechainEntries)].map(([agentId, entries]) => ({
agentId,
meta: sidechains.metaByAgentId.get(agentId) ?? null,
entries,
parentFacts: readClaudeReplayParentFacts(entries as ClaudeHistoryEntry[]),
})),
parent: readClaudeReplayParentFacts(parentEntries),
convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry),
});
const observations = [
...observeReplaySubagents({
subagents: [...groupClaudeSidechainEntries(sidechainEntries)].map(([agentId, entries]) => ({
agentId,
meta: sidechains.metaByAgentId.get(agentId) ?? null,
entries,
})),
parent: readClaudeReplayParentFacts(parentEntries),
convertEntry: (entry) => this.convertHistoryEntry(entry as ClaudeHistoryEntry),
}),
...subagentReplay.observations,
...observeReplayWorkflows({
workflows: sidechains.workflowContents
.map(parseClaudeWorkflowRun)
@@ -4880,7 +4909,11 @@ class ClaudeAgentSession implements AgentSession {
.filter((observation) => observation.kind === "declared")
.map((observation) => observation.id),
);
if (observations.length === 0) return restoredProviderSubagentIds;
const replay = {
restoredIds: restoredProviderSubagentIds,
toolOwners: subagentReplay.toolOwners,
};
if (observations.length === 0) return replay;
this.persistedProviderSubagentEvents.push(
...foldSubagentObservations(observations).map(
@@ -4892,13 +4925,13 @@ class ClaudeAgentSession implements AgentSession {
),
);
this.historyPending = true;
return restoredProviderSubagentIds;
return replay;
}
private ingestPersistedHistoryLine(
line: string,
timeline: PersistedTimelineEntry[],
restoredProviderSubagentIds: ReadonlySet<string>,
replay: ClaudeReplayOwnership,
): void {
const trimmed = line.trim();
if (!trimmed) {
@@ -4921,11 +4954,29 @@ class ClaudeAgentSession implements AgentSession {
return;
}
const notificationToolUseId = readTaskNotificationToolUseIdFromHistoryRecord(entry);
if (notificationToolUseId && restoredProviderSubagentIds.has(notificationToolUseId)) {
if (notificationToolUseId && replay.restoredIds.has(notificationToolUseId)) {
return;
}
const historyTimestamp = normalizeProviderReplayTimestamp(entry.timestamp);
const notificationOwner = notificationToolUseId
? replay.toolOwners.get(notificationToolUseId)
: undefined;
if (notificationOwner) {
for (const item of this.convertHistoryEntry(entry)) {
this.persistedProviderSubagentEvents.push({
type: "provider_subagent",
provider: "claude",
event: {
type: "timeline",
id: notificationOwner,
item,
timestamp: historyTimestamp ?? undefined,
},
});
}
return;
}
const taskSnapshot = this.taskState.observe(entry);
const items = [...(taskSnapshot ? [taskSnapshot] : []), ...this.convertHistoryEntry(entry)];
const isVisibleUserEntry =
@@ -180,6 +180,83 @@ describe("ClaudeTaskProtocolSource", () => {
]);
});
it("declares a nested task beneath the sidechain agent that launched it", () => {
const source = new ClaudeTaskProtocolSource();
source.observe(
taskStarted({
task_id: "direct-task",
tool_use_id: "toolu_direct",
spawn_depth: 1,
}),
);
source.observeSidechainFrame(
{
type: "assistant",
parent_tool_use_id: "toolu_direct",
message: {
model: "claude-sonnet-5",
content: [
{
type: "tool_use",
id: "toolu_nested",
name: "Agent",
input: { name: "nested-owner" },
},
],
},
} as unknown as SDKMessage,
"toolu_direct",
);
expect(
source.observe(
taskStarted({
task_id: "nested-task",
tool_use_id: "toolu_nested",
spawn_depth: 2,
}),
),
).toContainEqual(
expect.objectContaining({
kind: "declared",
id: "toolu_nested",
parentSubagentId: "toolu_direct",
}),
);
expect(source.needsSyntheticParentToolCard("toolu_nested")).toBe(false);
});
it("resolves a background task to the sidechain agent that launched it", () => {
const source = new ClaudeTaskProtocolSource();
source.observe(
taskStarted({ task_id: "direct-task", tool_use_id: "toolu_direct", spawn_depth: 1 }),
);
source.observeSidechainFrame(
{
type: "assistant",
parent_tool_use_id: "toolu_direct",
message: {
model: "claude-sonnet-5",
content: [
{ type: "tool_use", id: "toolu_bash", name: "Bash", input: { command: "sleep 2" } },
],
},
} as unknown as SDKMessage,
"toolu_direct",
);
source.observe(
taskStarted({
task_id: "bash-task",
tool_use_id: "toolu_bash",
task_type: "local_bash",
subagent_type: undefined,
owned_by_subagent: true,
}),
);
expect(source.resolveTaskOwner("bash-task", "toolu_bash")).toBe("toolu_direct");
});
it("does not re-announce a status that already holds", () => {
const source = new ClaudeTaskProtocolSource();
source.observe(taskStarted());
@@ -154,6 +154,10 @@ export class ClaudeTaskProtocolSource {
private readonly subagentIdByTaskId = new Map<string, string>();
/** Every announced tool id -> the first tool id that publicly identifies the child. */
private readonly canonicalIdByToolUseId = new Map<string, string>();
/** Tool calls made inside a sidechain, keyed to the direct child that emitted them. */
private readonly ownerSubagentIdByToolUseId = new Map<string, string>();
/** Announced tasks inherit the owner recorded for their tool call, including local_bash. */
private readonly ownerSubagentIdByTaskId = new Map<string, string>();
/**
* Every subagent id this source declared. It is the source's whole vocabulary: an id that is
* not in here was either filtered at declaration or never announced, and this source has
@@ -229,6 +233,14 @@ export class ClaudeTaskProtocolSource {
return subagentId !== undefined && this.declaredIds.has(subagentId);
}
/** Resolve a non-subagent task (for example local_bash) to its emitting sidechain. */
resolveTaskOwner(taskId: string, toolUseId?: string): string | undefined {
return (
this.ownerSubagentIdByTaskId.get(taskId) ??
(toolUseId ? this.ownerSubagentIdByToolUseId.get(toolUseId) : undefined)
);
}
needsSyntheticParentToolCard(subagentId: string): boolean {
return !this.idsWithExistingParentToolCard.has(subagentId);
}
@@ -259,6 +271,8 @@ export class ClaudeTaskProtocolSource {
reset(): void {
this.subagentIdByTaskId.clear();
this.canonicalIdByToolUseId.clear();
this.ownerSubagentIdByToolUseId.clear();
this.ownerSubagentIdByTaskId.clear();
this.declaredIds.clear();
this.workflowTaskIds.clear();
this.lastWorkflowResultByTaskId.clear();
@@ -310,32 +324,50 @@ export class ClaudeTaskProtocolSource {
this.sawAnyTask = true;
const id = readString(message.tool_use_id);
const parentSubagentId = id ? this.ownerSubagentIdByToolUseId.get(id) : undefined;
if (parentSubagentId) this.ownerSubagentIdByTaskId.set(message.task_id, parentSubagentId);
// skip_transcript marks ambient housekeeping the transcript should not show.
if (!id || message.skip_transcript === true || !isProviderSubagentTask(message)) return [];
this.sawTaskStarted = true;
const existingId = this.subagentIdByTaskId.get(message.task_id);
if (existingId) {
this.canonicalIdByToolUseId.set(id, existingId);
const observations: SubagentObservation[] = [];
if (this.lastStatusById.get(existingId) !== "running") {
this.lastStatusById.set(existingId, "running");
observations.push({ kind: "status", id: existingId, status: "running" });
}
const prompt =
message.task_type === CLAUDE_WORKFLOW_TASK_TYPE
? readString(message.description)
: readString(message.prompt);
if (prompt) {
observations.push({
kind: "timeline",
id: existingId,
item: { type: "user_message", text: prompt },
});
}
return observations;
return this.observeExistingTaskStart(message, id, existingId);
}
return this.observeNewTaskStart(message, id, parentSubagentId);
}
private observeExistingTaskStart(
message: TaskStartedMessage,
toolUseId: string,
existingId: string,
): SubagentObservation[] {
this.canonicalIdByToolUseId.set(toolUseId, existingId);
const observations: SubagentObservation[] = [];
if (this.lastStatusById.get(existingId) !== "running") {
this.lastStatusById.set(existingId, "running");
observations.push({ kind: "status", id: existingId, status: "running" });
}
const prompt =
message.task_type === CLAUDE_WORKFLOW_TASK_TYPE
? readString(message.description)
: readString(message.prompt);
if (prompt) {
observations.push({
kind: "timeline",
id: existingId,
item: { type: "user_message", text: prompt },
});
}
return observations;
}
private observeNewTaskStart(
message: TaskStartedMessage,
id: string,
parentSubagentId: string | undefined,
): SubagentObservation[] {
this.subagentIdByTaskId.set(message.task_id, id);
this.canonicalIdByToolUseId.set(id, id);
this.declaredIds.add(id);
@@ -344,8 +376,10 @@ export class ClaudeTaskProtocolSource {
// An explicit `name` on the Task call wins over the agent type, matching how replay titles the
// same subagent. Without it a fan-out of five Explores reads as five identical rows.
const isWorkflow = message.task_type === CLAUDE_WORKFLOW_TASK_TYPE;
if (isWorkflow) {
if (isWorkflow || parentSubagentId) {
this.idsWithExistingParentToolCard.add(id);
}
if (isWorkflow) {
this.workflowTaskIds.add(message.task_id);
}
const title = isWorkflow
@@ -359,6 +393,7 @@ export class ClaudeTaskProtocolSource {
toolCallId: id,
...(title ? { title } : {}),
...(description ? { description } : {}),
...(parentSubagentId ? { parentSubagentId } : {}),
},
];
const initialPresentation = title ? { title } : {};
@@ -447,13 +482,23 @@ export class ClaudeTaskProtocolSource {
* The model the child is actually running, read off its own assistant frames.
*
* Routed through the declaration for the same reason status is: a frame carrying the tool_use
* id of a task this source filtered out — ambient housekeeping, a workflow child, a nested
* grandchild announced in someone else's session — would otherwise fold to an upsert with no
* id of a task this source filtered out — ambient housekeeping or a workflow child — would
* otherwise fold to an upsert with no
* identity and a defaulted "running" status. That is the nameless, never-finishing row the
* declaration filter exists to prevent, arriving by a different door.
*/
observeSidechainFrame(message: SDKMessage, subagentId: string): SubagentObservation[] {
if (message.type !== "assistant" || !this.declaredIds.has(subagentId)) return [];
const content = message.message?.content;
if (Array.isArray(content)) {
for (const block of content) {
if (!block || typeof block !== "object") continue;
const record = block as { type?: unknown; id?: unknown };
if (record.type === "tool_use" && typeof record.id === "string") {
this.ownerSubagentIdByToolUseId.set(record.id, subagentId);
}
}
}
const model = resolveObservedClaudeModelId(
typeof message.message?.model === "string" ? message.message.model : undefined,
);
@@ -24,6 +24,8 @@ export type SubagentObservation =
/** The task this child was given. What actually distinguishes it from its siblings. */
description?: string;
toolCallId?: string;
/** Provider subagent that launched this one. Absent means a direct child of the agent. */
parentSubagentId?: string;
timestamp?: string;
}
| { kind: "status"; id: string; status: ProviderSubagentStatus; timestamp?: string }
@@ -84,6 +86,9 @@ export function foldSubagentObservations(
...(observation.title === undefined ? {} : { title: observation.title }),
...(observation.description === undefined ? {} : { description: observation.description }),
...(observation.toolCallId === undefined ? {} : { toolCallId: observation.toolCallId }),
...(observation.parentSubagentId === undefined
? {}
: { parentSubagentId: observation.parentSubagentId }),
...(observation.timestamp ? { timestamp: observation.timestamp } : {}),
});
}
@@ -84,7 +84,7 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
});
}).observations;
expect(observations[0]).toMatchObject({
kind: "declared",
@@ -95,25 +95,39 @@ describe("observeReplaySubagents", () => {
});
});
it("replays only this session's own children, not their descendants", () => {
// Claude Code writes every descendant into the same subagents/ directory. A grandchild's
// toolUseId names a Task call made inside its parent's session, so nothing here can resolve
// it: it would replay as an extra row the live stream never showed, with no Task card and no
// outcome. One recorded session showed 10 subagents live and 22 on reopen.
it("replays descendants beneath the sidechain that declared their Task", () => {
const nestedToolUseId = "toolu_012rzYnFZA";
const observations = observeReplaySubagents({
subagents: [
{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 }, entries: [] },
{
agentId: AGENT_ID,
meta: { toolUseId: TOOL_USE_ID, spawnDepth: 1 },
entries: [],
parentFacts: {
toolCalls: new Map([
[nestedToolUseId, { title: "Explore", description: "Nested audit" }],
]),
linksByAgentId: new Map(),
outcomesByToolCallId: new Map([[nestedToolUseId, { failed: false }]]),
},
},
{
agentId: "a6acb4b898",
meta: { toolUseId: "toolu_012rzYnFZA", agentType: "Explore", spawnDepth: 2 },
meta: { toolUseId: nestedToolUseId, agentType: "Explore", spawnDepth: 2 },
entries: [],
},
],
parent: parentWithTaskCall(),
convertEntry: () => [],
});
}).observations;
expect([...new Set(observations.map((observation) => observation.id))]).toEqual([TOOL_USE_ID]);
expect(observations).toContainEqual(
expect.objectContaining({
kind: "declared",
id: nestedToolUseId,
parentSubagentId: TOOL_USE_ID,
}),
);
});
it("keeps a subagent recorded before spawnDepth existed", () => {
@@ -121,7 +135,7 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
});
}).observations;
expect(observations[0]).toMatchObject({ kind: "declared", id: TOOL_USE_ID });
});
@@ -137,7 +151,7 @@ describe("observeReplaySubagents", () => {
],
parent: emptyParent(),
convertEntry: () => [],
});
}).observations;
expect(observations).toEqual([]);
});
@@ -153,7 +167,7 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent,
convertEntry: () => [],
});
}).observations;
expect(declared).toMatchObject({ id: TOOL_USE_ID, toolCallId: TOOL_USE_ID, title: "Explore" });
expect(rest.at(-1)).toMatchObject({ kind: "status", status: "completed" });
@@ -164,7 +178,7 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: null, entries: [] }],
parent: emptyParent(),
convertEntry: () => [],
});
}).observations;
expect(observations).toEqual([]);
});
@@ -188,7 +202,7 @@ describe("observeReplaySubagents", () => {
],
parent: emptyParent(),
convertEntry: () => [],
}),
}).observations,
);
expect(descriptor).toBeNull();
@@ -214,7 +228,7 @@ describe("observeReplaySubagents", () => {
],
parent: emptyParent(),
convertEntry: () => [],
}),
}).observations,
);
expect(descriptor).toBeNull();
@@ -230,7 +244,7 @@ describe("observeReplaySubagents", () => {
outcomesByToolCallId: new Map(),
},
convertEntry: () => [],
}),
}).observations,
);
expect(descriptor).toMatchObject({ id: TOOL_USE_ID, status: "failed" });
@@ -253,7 +267,7 @@ describe("observeReplaySubagents", () => {
],
parent: emptyParent(),
convertEntry: () => [],
});
}).observations;
expect(observations).toEqual([]);
});
@@ -276,7 +290,7 @@ describe("observeReplaySubagents", () => {
],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
}),
}).observations,
);
expect(descriptor).toMatchObject({ status: "completed" });
@@ -287,7 +301,7 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: { ...parentWithTaskCall(), outcomesByToolCallId: new Map() },
convertEntry: () => [],
});
}).observations;
expect(observations.some((o) => o.kind === "status")).toBe(false);
});
@@ -296,7 +310,7 @@ describe("observeReplaySubagents", () => {
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(true),
convertEntry: () => [],
});
}).observations;
expect(observations.at(-1)).toMatchObject({ kind: "status", status: "failed" });
});
@@ -311,7 +325,7 @@ describe("observeReplaySubagents", () => {
],
parent: parentWithTaskCall(),
convertEntry: () => [{ type: "reasoning", text: "thinking" }],
});
}).observations;
expect(observations.find((observation) => observation.kind === "timeline")).toMatchObject({
kind: "timeline",
@@ -333,7 +347,7 @@ describe("replay runtime", () => {
],
parent: parentWithTaskCall(),
convertEntry: () => [],
});
}).observations;
expect(observations.find((o) => o.kind === "subtitle")).toEqual({
kind: "subtitle",
@@ -356,7 +370,7 @@ describe("replay runtime", () => {
],
parent: parentWithTaskCall(),
convertEntry: () => [],
});
}).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · Sonnet 5 · Low",
@@ -368,7 +382,7 @@ describe("replay runtime", () => {
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries: [] }],
parent: parentWithTaskCall(),
convertEntry: () => [],
});
}).observations;
expect(observations.some((o) => o.kind === "subtitle")).toBe(false);
});
@@ -384,7 +398,7 @@ describe("replay runtime", () => {
],
parent: parentWithTaskCall(),
convertEntry: () => [],
});
}).observations;
expect(observations.find((o) => o.kind === "subtitle")).toMatchObject({
subtitle: "general-purpose · glm-5.1",
});
@@ -397,7 +411,7 @@ describe("replay usage", () => {
subagents: [{ agentId: AGENT_ID, meta: { toolUseId: TOOL_USE_ID }, entries }],
parent: parentWithTaskCall(),
convertEntry: () => [],
}).find((observation) => observation.kind === "subtitle");
}).observations.find((observation) => observation.kind === "subtitle");
}
it("derives the counters Claude Code itself reports, from a real transcript's shape", () => {
@@ -532,7 +546,7 @@ describe("replay usage", () => {
],
parent: parentWithTaskCall(),
convertEntry: () => [],
});
}).observations;
for (const event of foldSubagentObservations(observations)) {
store.apply("parent", "claude", event);
}
@@ -579,7 +593,7 @@ describe("live and replay agree", () => {
],
parent: parentWithTaskCall(),
convertEntry: () => [],
});
}).observations;
const fromWire = applyToStore(liveObservations);
const fromDisk = applyToStore(replayObservations);
@@ -142,6 +142,8 @@ export interface ClaudeReplaySubagentInput {
agentId: string;
meta: ClaudeSubagentMeta | null;
entries: ClaudeReplayEntry[];
/** Task declarations made by this subagent, used to attach its direct children. */
parentFacts?: ClaudeReplayParentFacts;
}
export interface ClaudeReplayParentFacts {
@@ -229,6 +231,7 @@ function declareSubagent(
subagent: ClaudeReplaySubagentInput,
link: ParentLink,
toolCall: { title?: string; description?: string } | undefined,
parentSubagentId?: string,
): SubagentObservation {
const title = toolCall?.title ?? subagent.meta?.agentType;
const description = toolCall?.description ?? subagent.meta?.description;
@@ -239,6 +242,7 @@ function declareSubagent(
...(title ? { title } : {}),
...(description ? { description } : {}),
...(link.toolCallId ? { toolCallId: link.toolCallId } : {}),
...(parentSubagentId ? { parentSubagentId } : {}),
...(firstTimestamp ? { timestamp: firstTimestamp } : {}),
};
}
@@ -274,12 +278,15 @@ function observeSubagent(
subagent: ClaudeReplaySubagentInput,
parent: ClaudeReplayParentFacts,
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[],
parentSubagentId?: string,
): SubagentObservation[] {
const link = resolveParentLink(subagent, parent);
if (!link) return [];
const toolCall = parent.toolCalls.get(link.toolCallId);
const observations: SubagentObservation[] = [declareSubagent(subagent, link, toolCall)];
const observations: SubagentObservation[] = [
declareSubagent(subagent, link, toolCall, parentSubagentId),
];
const subtitle = observeSubtitle(subagent, link, toolCall?.title ?? subagent.meta?.agentType);
if (subtitle) observations.push(subtitle);
@@ -311,34 +318,72 @@ function observeSubagent(
return observations;
}
/**
* Whether this transcript belongs to a child of the session being replayed.
*
* Claude Code writes every descendant into the same `subagents/` directory, not just the session's
* own children, and `spawnDepth` is what separates them: 1 is a direct child, 2+ was spawned by
* another subagent. A grandchild's `toolUseId` names a Task call made inside its parent's session,
* so nothing in this transcript can resolve it — surveyed across recorded sessions, every depth-1
* id matched a Task tool_use block in the parent transcript and no deeper id did. Replaying them
* anyway adds rows the live stream never showed, each with no Task card and no recoverable outcome.
* One session recorded 10 subagents live and would replay 22.
*
* Absent depth means a session recorded before the field existed: keep the subagent rather than
* lose it, matching how every other missing field here is treated.
*/
function isDirectChild(subagent: ClaudeReplaySubagentInput): boolean {
const depth = subagent.meta?.spawnDepth;
return depth === undefined || depth <= 1;
function recordReplayToolOwners(
owners: Map<string, string>,
entries: readonly ClaudeReplayEntry[],
subagentId: string,
): void {
for (const entry of entries) {
if (entry.type !== "assistant" || !Array.isArray(entry.message?.content)) continue;
for (const block of entry.message.content) {
if (block?.type === "tool_use" && typeof block.id === "string") {
owners.set(block.id, subagentId);
}
}
}
}
export function observeReplaySubagents(input: {
subagents: readonly ClaudeReplaySubagentInput[];
parent: ClaudeReplayParentFacts;
convertEntry: (entry: ClaudeReplayEntry) => AgentTimelineItem[];
}): SubagentObservation[] {
}): { observations: SubagentObservation[]; toolOwners: ReadonlyMap<string, string> } {
const observations: SubagentObservation[] = [];
for (const subagent of input.subagents) {
if (!isDirectChild(subagent)) continue;
observations.push(...observeSubagent(subagent, input.parent, input.convertEntry));
const toolOwners = new Map<string, string>();
const unresolved = [...input.subagents].sort(
(left, right) => (left.meta?.spawnDepth ?? 1) - (right.meta?.spawnDepth ?? 1),
);
const resolvedParents = new Map<string, ClaudeReplayParentFacts>();
// Claude stores every descendant beside the root transcript. Resolve one generation at a
// time: a child is admitted only when its tool id is declared by a transcript whose own parent
// has already been proven. This preserves the old boundary against unrelated ambient sidecars.
let madeProgress = true;
while (unresolved.length > 0 && madeProgress) {
madeProgress = false;
for (let index = unresolved.length - 1; index >= 0; index--) {
const subagent = unresolved[index];
if (!subagent) continue;
const resolved = resolveReplayOwner(subagent, input.parent, resolvedParents);
if (!resolved) continue;
const { ownerId, parent, link } = resolved;
// Only proven descendants may own notifications; ambient sidecars cannot claim them.
recordReplayToolOwners(toolOwners, subagent.entries, link.id);
observations.push(...observeSubagent(subagent, parent, input.convertEntry, ownerId));
if (subagent.parentFacts) resolvedParents.set(link.id, subagent.parentFacts);
unresolved.splice(index, 1);
madeProgress = true;
}
}
return observations;
return { observations, toolOwners };
}
function resolveReplayOwner(
subagent: ClaudeReplaySubagentInput,
rootParent: ClaudeReplayParentFacts,
resolvedParents: ReadonlyMap<string, ClaudeReplayParentFacts>,
): {
ownerId?: string;
parent: ClaudeReplayParentFacts;
link: NonNullable<ReturnType<typeof resolveParentLink>>;
} | null {
const rootLink = resolveParentLink(subagent, rootParent);
if (rootLink) return { parent: rootParent, link: rootLink };
for (const [ownerId, parent] of resolvedParents) {
const link = resolveParentLink(subagent, parent);
if (link) return { ownerId, parent, link };
}
return null;
}
@@ -7,6 +7,18 @@ import {
} from "./task-notification-tool-call.js";
describe("task-notification-tool-call", () => {
it.each([undefined, "ordinary queued prompt"])(
"ignores queue bookkeeping without a task notification (%s)",
(content) => {
expect(
mapTaskNotificationSystemRecordToToolCall({
type: "queue-operation",
operation: "enqueue",
content,
}),
).toBeNull();
},
);
it("detects task notification user content in string payloads", () => {
expect(
isTaskNotificationUserContent(
@@ -178,8 +178,9 @@ function parseTaskNotificationFromSystemRecord(record: unknown): TaskNotificatio
const systemRecord: TaskNotificationHistoryRecord = parsedRecord.data;
const isSystemTaskNotification =
systemRecord.type === "system" && systemRecord.subtype === "task_notification";
const isQueueOperation = systemRecord.type === "queue-operation";
if (!isSystemTaskNotification && !isQueueOperation) {
const isQueuedTaskNotification =
systemRecord.type === "queue-operation" && isTaskNotificationUserContent(systemRecord.content);
if (!isSystemTaskNotification && !isQueuedTaskNotification) {
return null;
}
const rawText = toNonEmptyString(systemRecord.content);
@@ -278,6 +279,7 @@ function toTaskNotificationToolCall(
synthetic: true,
source: "claude_task_notification",
...(envelope.taskId ? { taskId: envelope.taskId } : {}),
...(envelope.toolUseId ? { toolUseId: envelope.toolUseId } : {}),
...(envelope.status ? { status: envelope.status } : {}),
...(envelope.outputFile ? { outputFile: envelope.outputFile } : {}),
};
@@ -3266,6 +3266,22 @@ describe("Codex app-server provider", () => {
turn: { status: "completed" },
});
const providerSubagents = events.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
expect(providerSubagents).toContainEqual(
expect.objectContaining({
id: "child-thread-root",
parentSubagentId: null,
}),
);
expect(providerSubagents).toContainEqual(
expect.objectContaining({
id: "grandchild-thread",
parentSubagentId: "child-thread-root",
}),
);
const beforeParentCompletes = events
.filter((event) => event.type === "timeline" && event.item.type === "tool_call")
.map((event) => event.item);
@@ -3288,6 +3304,70 @@ describe("Codex app-server provider", () => {
});
});
test("keeps a nested child under its spawning parent after a root interaction", () => {
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-child-root",
kind: "started",
agentThreadId: "child-thread-root",
agentPath: "/root/child",
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "child-thread-root",
item: {
type: "subAgentActivity",
id: "spawn-grandchild",
kind: "started",
agentThreadId: "grandchild-thread",
agentPath: "/root/child/grandchild",
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "collabAgentToolCall",
id: "wait-for-grandchild",
tool: "wait",
status: "completed",
receiverThreadIds: ["grandchild-thread"],
agentsStates: {
"grandchild-thread": { status: "running", message: null },
},
},
});
asInternals(session).handleNotification("item/agentMessage/delta", {
threadId: "grandchild-thread",
itemId: "grandchild-after-wait",
delta: "Still nested.",
});
const grandchildUpserts = events.flatMap((event) =>
event.type === "provider_subagent" &&
event.event.type === "upsert" &&
event.event.id === "grandchild-thread"
? [event.event]
: [],
);
expect(grandchildUpserts.map((event) => event.parentSubagentId)).toEqual([
"child-thread-root",
"child-thread-root",
]);
expect(events.at(-1)).toMatchObject({
type: "timeline",
item: {
callId: "spawn-child-root",
detail: { type: "sub_agent", log: expect.stringContaining("Still nested.") },
},
});
});
test("never treats an unmapped foreign terminal as the root terminal", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
@@ -4085,6 +4165,69 @@ describe("Codex app-server provider", () => {
});
});
test("restores nested MultiAgentV2 ownership from persisted child threads", async () => {
const session = createSession();
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
if (method !== "thread/read") {
return {};
}
const threadId = (params as { threadId?: string }).threadId;
const itemsByThreadId = {
"test-thread": [
{
type: "subAgentActivity",
id: "spawn-persisted-child",
kind: "started",
agentThreadId: "persisted-child",
agentPath: "/root/persisted-child",
},
{
type: "collabAgentToolCall",
id: "root-wait-grandchild",
tool: "wait",
status: "completed",
receiverThreadIds: ["persisted-grandchild"],
agentsStates: { "persisted-grandchild": { status: "completed" } },
},
],
"persisted-child": [
{
type: "subAgentActivity",
id: "spawn-persisted-grandchild",
kind: "started",
agentThreadId: "persisted-grandchild",
agentPath: "/root/persisted-child/grandchild",
},
],
} as const;
const items = threadId ? itemsByThreadId[threadId as keyof typeof itemsByThreadId] : [];
return {
thread: {
turns: items ? [{ items }] : [],
},
};
}),
};
await asInternals(session).loadPersistedHistory();
const history: AgentStreamEvent[] = [];
for await (const event of session.streamHistory()) {
history.push(event);
}
const upserts = history.flatMap((event) =>
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
);
expect(upserts).toEqual([
expect.objectContaining({ id: "persisted-child", parentSubagentId: null }),
expect.objectContaining({
id: "persisted-grandchild",
parentSubagentId: "persisted-child",
}),
]);
});
test("coalesces persisted MultiAgentV2 activity for one child into one terminal card", async () => {
const session = createSession();
session.client = {
@@ -1781,13 +1781,17 @@ function updateHistoricalSubAgentActivity(
function readCodexHistoricalSubAgentThreadIds(item: unknown): string[] {
const activity = readCodexSubAgentActivity(item);
if (activity) {
return [activity.agentThreadId];
return activity.kind === "started" ? [activity.agentThreadId] : [];
}
const record = toObjectRecord(item);
const normalizedType = normalizeCodexThreadItemType(
typeof record?.type === "string" ? record.type : undefined,
);
if (normalizedType !== "collabAgentToolCall" || !Array.isArray(record?.receiverThreadIds)) {
if (
normalizedType !== "collabAgentToolCall" ||
record?.tool !== "spawnAgent" ||
!Array.isArray(record.receiverThreadIds)
) {
return [];
}
return record.receiverThreadIds.filter(
@@ -3256,6 +3260,7 @@ interface CodexSubAgentCallState {
callId: string;
toolCall: ToolCallTimelineItem;
parentCallId: string | null;
parentSubagentId: string | null;
activityItemIds: Set<string>;
pendingCommandOutputDeltas: Map<string, string[]>;
pendingFileChangeOutputDeltas: Map<string, string[]>;
@@ -3264,6 +3269,13 @@ interface CodexSubAgentCallState {
childThreadIds: Set<string>;
}
function resolveCodexParentSubagentId(
parentCallId: string | null,
emittingThreadId: string | null,
): string | null {
return parentCallId ? emittingThreadId : null;
}
interface CodexPendingPermissionHandler {
resolve: (value: unknown) => void;
kind: "command" | "file" | "question" | "mcp_elicitation" | "plan";
@@ -3799,7 +3811,11 @@ export class CodexAppServerAgentSession implements AgentSession {
client: CodexAppServerClientLike,
rootRoutes: readonly PersistedSubAgentRoute[],
): Promise<void> {
const queue = rootRoutes.map((route) => ({ route, parentCallId: null as string | null }));
const queue = rootRoutes.map((route) => ({
route,
parentCallId: null as string | null,
parentSubagentId: null as string | null,
}));
const visitedThreadIds = new Set(this.currentThreadId ? [this.currentThreadId] : []);
while (queue.length > 0 && visitedThreadIds.size < 100) {
const next = queue.shift();
@@ -3811,6 +3827,7 @@ export class CodexAppServerAgentSession implements AgentSession {
timelineItem: next.route.toolCall,
rawItem: { agentThreadId: next.route.childThreadId },
parentCallId: next.parentCallId,
parentSubagentId: next.parentSubagentId,
});
try {
const childHistory = await loadCodexThreadHistoryTimeline({
@@ -3822,7 +3839,11 @@ export class CodexAppServerAgentSession implements AgentSession {
this.emitProviderSubagentTimeline(next.route.childThreadId, entry.item, entry.timestamp);
}
for (const route of childHistory.subAgentRoutes) {
queue.push({ route, parentCallId: next.route.toolCall.callId });
queue.push({
route,
parentCallId: next.route.toolCall.callId,
parentSubagentId: next.route.childThreadId,
});
}
} catch (error) {
this.logger.trace(
@@ -5354,8 +5375,9 @@ export class CodexAppServerAgentSession implements AgentSession {
timelineItem: ToolCallTimelineItem;
rawItem: { [key: string]: unknown };
parentCallId: string | null;
parentSubagentId: string | null;
}): string[] {
const { timelineItem, rawItem, parentCallId } = params;
const { timelineItem, rawItem, parentCallId, parentSubagentId } = params;
if (timelineItem.detail.type !== "sub_agent") {
return [];
}
@@ -5367,6 +5389,7 @@ export class CodexAppServerAgentSession implements AgentSession {
callId: timelineItem.callId,
toolCall: timelineItem,
parentCallId,
parentSubagentId,
activityItemIds: new Set<string>(),
pendingCommandOutputDeltas: new Map<string, string[]>(),
pendingFileChangeOutputDeltas: new Map<string, string[]>(),
@@ -5385,6 +5408,7 @@ export class CodexAppServerAgentSession implements AgentSession {
},
};
state.parentCallId ??= parentCallId;
state.parentSubagentId ??= parentSubagentId;
const activity = readCodexSubAgentActivity(rawItem);
if (activity?.id) {
state.activityItemIds.add(activity.id);
@@ -5402,6 +5426,10 @@ export class CodexAppServerAgentSession implements AgentSession {
new Set(agentThreadId ? [...receiverThreadIds, agentThreadId] : receiverThreadIds),
).filter((threadId) => threadId !== this.currentThreadId);
for (const receiverThreadId of childThreadIds) {
const owningCallId = this.subAgentCallIdByChildThreadId.get(receiverThreadId);
if (owningCallId && owningCallId !== timelineItem.callId) {
continue;
}
this.subAgentCallIdByChildThreadId.set(receiverThreadId, timelineItem.callId);
state.childThreadIds.add(receiverThreadId);
this.emitProviderSubagentUpsert(receiverThreadId, state, timelineItem.status);
@@ -5620,6 +5648,7 @@ export class CodexAppServerAgentSession implements AgentSession {
description: detail.description ?? null,
status: providerStatus,
toolCallId: state.callId,
parentSubagentId: state.parentSubagentId,
},
});
}
@@ -6257,6 +6286,7 @@ export class CodexAppServerAgentSession implements AgentSession {
timelineItem,
rawItem: parsed.item,
parentCallId: childSubAgentCallId,
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
})
: [];
const imageItems = mcpToolResultImagesToTimeline(parsed.item);
@@ -6424,6 +6454,7 @@ export class CodexAppServerAgentSession implements AgentSession {
timelineItem,
rawItem: parsed.item,
parentCallId: childSubAgentCallId,
parentSubagentId: resolveCodexParentSubagentId(childSubAgentCallId, parsed.threadId),
});
if (childSubAgentCallId) {
this.emitStartedProviderSubagentItem(parsed.threadId, timelineItem);
@@ -4414,6 +4414,7 @@ describe("OpenCode provider subagent contract", () => {
event: {
type: "upsert",
id: "ses_child_registry",
parentSubagentId: null,
description: "Live child",
status: "running",
},
@@ -5338,6 +5339,7 @@ describe("OpenCode provider subagent contract", () => {
event: {
type: "upsert",
id: "ses_child_background",
parentSubagentId: null,
description: "Plugin child",
status: "running",
},
@@ -5386,6 +5388,7 @@ describe("OpenCode provider subagent contract", () => {
event: {
type: "upsert",
id: "ses_child_plugin",
parentSubagentId: null,
description: "Background plugin child",
status: "running",
},
@@ -5629,6 +5632,7 @@ describe("OpenCode provider subagent contract", () => {
event: {
type: "upsert",
id: "ses_child_rich",
parentSubagentId: null,
title: "explore",
description: "Investigate flaky test",
status: "running",
@@ -5655,7 +5659,12 @@ describe("OpenCode provider subagent contract", () => {
{
type: "provider_subagent",
provider: "opencode",
event: { type: "upsert", id: "ses_child_bare", status: "running" },
event: {
type: "upsert",
id: "ses_child_bare",
parentSubagentId: null,
status: "running",
},
},
]);
});
@@ -5980,12 +5989,24 @@ describe("OpenCode provider subagent contract", () => {
{
type: "provider_subagent",
provider: "opencode",
event: { type: "upsert", id: "ses_child_a", description: "Child A", status: "completed" },
event: {
type: "upsert",
id: "ses_child_a",
parentSubagentId: null,
description: "Child A",
status: "completed",
},
},
{
type: "provider_subagent",
provider: "opencode",
event: { type: "upsert", id: "ses_child_b", description: "Child B", status: "completed" },
event: {
type: "upsert",
id: "ses_child_b",
parentSubagentId: null,
description: "Child B",
status: "completed",
},
},
{
type: "provider_subagent",
@@ -5993,6 +6014,7 @@ describe("OpenCode provider subagent contract", () => {
event: {
type: "upsert",
id: "ses_grandchild_a",
parentSubagentId: "ses_child_a",
description: "Grandchild A",
status: "completed",
},
@@ -6055,6 +6077,7 @@ describe("OpenCode provider subagent contract", () => {
event: {
type: "upsert",
id: "ses_child_with_history",
parentSubagentId: null,
description: "Historical child",
status: "completed",
cwd: "/workspace/child",
@@ -6142,6 +6165,7 @@ describe("OpenCode provider subagent contract", () => {
event: {
type: "upsert",
id: "ses_child_hydrated_facts",
parentSubagentId: null,
description: "Chase the regression",
status: "completed",
subtitle: "claude-sonnet-5 · Max",
@@ -2423,6 +2423,7 @@ function appendOpenCodeChildSessionDetected(
event: {
type: "upsert",
id: child.id,
parentSubagentId: child.parentSessionId === state.sessionId ? null : child.parentSessionId,
...(title ? { title } : {}),
...(child.title && !presentation.descriptionFromLink ? { description: child.title } : {}),
...(status ? { status } : {}),
@@ -4702,8 +4703,13 @@ class OpenCodeAgentSession implements AgentSession {
return;
}
if (event.type === "provider_subagent" && event.event.type === "upsert" && event.event.status) {
if (isDeepStrictEqual(this.childStatuses.get(event.event.id), event.event)) return;
this.childStatuses.set(event.event.id, structuredClone(event.event));
const previous = this.childStatuses.get(event.event.id);
const current =
event.event.parentSubagentId === undefined && previous?.parentSubagentId !== undefined
? { ...event.event, parentSubagentId: previous.parentSubagentId }
: event.event;
if (isDeepStrictEqual(previous, current)) return;
this.childStatuses.set(event.event.id, structuredClone(current));
}
const turnId = turnIdOverride === null ? null : (turnIdOverride ?? this.activeForegroundTurnId);
const tagged = turnId ? { ...event, turnId } : event;
@@ -76,3 +76,15 @@ export async function* streamSession(
}
}
}
export async function collectSessionTurnEvents(
session: Pick<AgentSession, "startTurn" | "subscribe">,
prompt: AgentPromptInput,
options?: AgentRunOptions,
): Promise<AgentStreamEvent[]> {
const events: AgentStreamEvent[] = [];
for await (const event of streamSession(session, prompt, options)) {
events.push(event);
}
return events;
}
@@ -1720,6 +1720,8 @@ export class VoiceAssistantWebSocketServer {
agentForkContextCursor: true,
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: true,
// COMPAT(providerSubagentNesting): added in v0.7, remove gate after 2027-03-04.
providerSubagentNesting: true,
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
workspacePinning: true,
// COMPAT(hubRelationship): added in v0.1.X, drop the gate when floor >= v0.1.X.