feat(subagents): archive finished children and show native names

Keep native provider sessions intact while letting users clear completed children from the track. Persist dismissed provider child IDs so history replay does not restore them.
This commit is contained in:
Mohamed Boudra
2026-07-15 00:10:30 +02:00
parent d706c4339b
commit f62359fae0
33 changed files with 518 additions and 41 deletions
+5 -2
View File
@@ -89,7 +89,9 @@ Clicking either kind opens a workspace tab. A Paseo subagent tab is a normal int
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.
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.
The track header's **Archive finished** action handles both kinds in one daemon operation. Finished Paseo children are soft-deleted normally. Finished provider children are hidden from the track; their native sessions are untouched. The daemon persists those provider IDs on the parent as dismissal tombstones so replaying Claude or Codex history does not resurrect them. Running children of either kind stay visible.
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.
@@ -109,7 +111,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
### Subagent accumulation under long-lived parents
A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem.
A parent that spawns many subagents will see the track grow until the user chooses **Archive finished**. Cleanup is explicit rather than automatic so completed child output remains available until the user is done with it.
### Cross-client tab dismissal
@@ -131,5 +133,6 @@ Each agent is a single JSON file. Fields relevant to this doc:
| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) |
| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` when `relationship.kind === "subagent"` |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
| `dismissedProviderSubagentIds` | `string[]` | Native child IDs hidden by the parent's **Archive finished** action |
See [`docs/data-model.md`](./data-model.md) for the full agent record.
@@ -14,6 +14,7 @@ import { openSubagentsTrack } from "./helpers/subagents";
interface ProviderSubagentCase {
provider: RewindFlowProvider;
sentinel: string;
expectedName: string;
prompt: string;
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
}
@@ -22,20 +23,23 @@ const cases: ProviderSubagentCase[] = [
{
provider: "claude",
sentinel: "CLAUDE_CHILD_SENTINEL",
expectedName: "sentinel_child",
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.",
'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.',
},
{
provider: "codex",
sentinel: "CODEX_CHILD_SENTINEL",
expectedName: "sentinel_child",
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.',
'Use the native collaboration.spawn_agent tool 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. Do not use Paseo tools.',
},
{
provider: "opencode",
sentinel: "OPENCODE_CHILD_SENTINEL",
expectedName: "Explore",
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.",
},
@@ -66,6 +70,7 @@ test.describe("real provider subagent timelines", () => {
const rows = page.locator('[data-testid^="subagents-track-row-"]');
await expect(rows).toHaveCount(1, { timeout: 60_000 });
await expect(rows.first()).toContainText(scenario.expectedName);
await rows.first().click();
const panel = page.getByTestId("provider-subagent-panel");
@@ -76,6 +81,15 @@ test.describe("real provider subagent timelines", () => {
await expect(
panel.getByText("Start chatting with this agent...", { exact: true }),
).toHaveCount(0);
await page.getByTestId(`workspace-tab-agent_${handle.agentId}`).first().click();
await expect(
page.getByTestId("assistant-message").filter({ hasText: "ROOT_DONE" }).last(),
).toBeVisible({ timeout: 60_000 });
const archiveFinished = page.getByTestId("subagents-track-archive-finished");
await expect(archiveFinished).toBeVisible({ timeout: 30_000 });
await archiveFinished.click();
await expect(rows).toHaveCount(0, { timeout: 30_000 });
} finally {
await cleanupRewindFlow({ handle, cwd });
}
+2
View File
@@ -1370,6 +1370,8 @@ export const ar: TranslationResources = {
detachTooltip: "فصل الوكيل الفرعي",
archiveAction: "أرشيف{{label}}",
archiveTooltip: "أرشفة الوكيل الفرعي",
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
archiveFinishedTooltip: "أرشفة المكتملين",
},
panels: {
draft: {
+2
View File
@@ -1378,6 +1378,8 @@ export const en = {
detachTooltip: "Detach subagent",
archiveAction: "Archive {{label}}",
archiveTooltip: "Archive subagent",
archiveFinishedAction: "Archive finished subagents",
archiveFinishedTooltip: "Archive finished",
},
panels: {
draft: {
+2
View File
@@ -1409,6 +1409,8 @@ export const es: TranslationResources = {
detachTooltip: "Separar subagente",
archiveAction: "Archivo{{label}}",
archiveTooltip: "Subagente de archivo",
archiveFinishedAction: "Archivar subagentes finalizados",
archiveFinishedTooltip: "Archivar finalizados",
},
panels: {
draft: {
+2
View File
@@ -1412,6 +1412,8 @@ export const fr: TranslationResources = {
detachTooltip: "Detacher le sous-agent",
archiveAction: "Archiver{{label}}",
archiveTooltip: "Sous-agent d'archivage",
archiveFinishedAction: "Archiver les sous-agents terminés",
archiveFinishedTooltip: "Archiver les terminés",
},
panels: {
draft: {
+2
View File
@@ -1387,6 +1387,8 @@ export const ja: TranslationResources = {
detachTooltip: "サブエージェントを切り離す",
archiveAction: "{{label}}をアーカイブ",
archiveTooltip: "サブエージェントをアーカイブ",
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
archiveFinishedTooltip: "完了した項目をアーカイブ",
},
panels: {
draft: {
+2
View File
@@ -1395,6 +1395,8 @@ export const ptBR: TranslationResources = {
detachTooltip: "Desanexar subagente",
archiveAction: "Arquivar {{label}}",
archiveTooltip: "Arquivar subagente",
archiveFinishedAction: "Arquivar subagentes concluídos",
archiveFinishedTooltip: "Arquivar concluídos",
},
panels: {
draft: {
+2
View File
@@ -1401,6 +1401,8 @@ export const ru: TranslationResources = {
detachTooltip: "Отсоединить субагент",
archiveAction: "Архив{{label}}",
archiveTooltip: "Архивный субагент",
archiveFinishedAction: "Архивировать завершенные субагенты",
archiveFinishedTooltip: "Архивировать завершенные",
},
panels: {
draft: {
+2
View File
@@ -1354,6 +1354,8 @@ export const zhCN: TranslationResources = {
detachTooltip: "分离 subagent",
archiveAction: "归档 {{label}}",
archiveTooltip: "归档 subagent",
archiveFinishedAction: "归档已完成的 subagent",
archiveFinishedTooltip: "归档已完成项",
},
panels: {
draft: {
+14 -1
View File
@@ -68,7 +68,12 @@ import { type Agent, useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import type { Theme } from "@/styles/theme";
import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents";
import {
useArchiveFinishedSubagents,
useArchiveSubagent,
useDetachSubagent,
useSubagentsForParent,
} from "@/subagents";
import { SubagentsTrack } from "@/subagents/track";
import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream";
@@ -1376,6 +1381,9 @@ function ActiveAgentComposer({
const canDetachSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
);
const canArchiveFinishedSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.archiveFinishedSubagents === true,
);
const handleOpenSubagent = useCallback(
(subagentId: string) => {
navigateToAgent({ serverId, agentId: subagentId });
@@ -1390,6 +1398,10 @@ function ActiveAgentComposer({
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId });
const handleArchiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
});
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
cwd,
@@ -1490,6 +1502,7 @@ function ActiveAgentComposer({
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onArchiveFinished={canArchiveFinishedSubagents ? handleArchiveFinishedSubagents : undefined}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>
<Composer
+4
View File
@@ -2,5 +2,9 @@ export type { SubagentRow } from "./select";
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
export {
useArchiveFinishedSubagents,
type UseArchiveFinishedSubagentsInput,
} from "./use-archive-finished-subagents";
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
export { isWorkspaceRootAgent } from "./workspace-root-policy";
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { PaseoSubagentRow, SubagentRow } from "./select";
import {
buildSubagentRowPresentationData,
countFinishedSubagents,
formatHeaderLabel,
resolveRowLabel,
} from "./track-presentation";
@@ -72,6 +73,41 @@ describe("formatHeaderLabel", () => {
});
});
describe("countFinishedSubagents", () => {
it("counts terminal managed and native children", () => {
const providerRows: SubagentRow[] = [
{
kind: "provider",
id: "native-running",
parentAgentId: "parent",
provider: "claude",
title: "running",
status: "running",
requiresAttention: false,
createdAt: new Date("2026-04-20T00:00:00.000Z"),
},
{
kind: "provider",
id: "native-failed",
parentAgentId: "parent",
provider: "claude",
title: "failed",
status: "failed",
requiresAttention: true,
createdAt: new Date("2026-04-20T00:00:01.000Z"),
},
];
expect(
countFinishedSubagents([
row({ id: "managed-running", status: "running" }),
row({ id: "managed-idle", status: "idle" }),
...providerRows,
]),
).toBe(2);
});
});
describe("resolveRowLabel", () => {
it("returns null when title is not a string", () => {
expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null);
@@ -48,6 +48,15 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string {
return parts.join(" · ");
}
export function countFinishedSubagents(rows: readonly SubagentRow[]): number {
return rows.filter((row) => {
if (row.kind === "provider") {
return row.status !== "running";
}
return row.status !== "initializing" && row.status !== "running";
}).length;
}
export function resolveRowLabel(title: SubagentRow["title"]): string | null {
if (typeof title !== "string") {
return null;
+56 -21
View File
@@ -13,7 +13,11 @@ import {
} from "@/screens/workspace/workspace-tab-presentation";
import type { Theme } from "@/styles/theme";
import type { SubagentRow } from "./select";
import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-presentation";
import {
buildSubagentRowPresentationData,
countFinishedSubagents,
formatHeaderLabel,
} from "./track-presentation";
const ThemedArchive = withUnistyles(Archive);
const ThemedChevronDown = withUnistyles(ChevronDown);
@@ -30,6 +34,7 @@ export interface SubagentsTrackProps {
onOpenSubagent: (id: string) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
onArchiveSubagent: (id: string) => void;
onArchiveFinished?: () => void;
onDetachSubagent?: (id: string) => void;
}
@@ -47,8 +52,10 @@ export function SubagentsTrack({
onOpenSubagent,
onOpenProviderSubagent,
onArchiveSubagent,
onArchiveFinished,
onDetachSubagent,
}: SubagentsTrackProps): ReactElement | null {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const toggleExpanded = useCallback(() => {
@@ -62,10 +69,13 @@ export function SubagentsTrack({
const headerStyle = useCallback(
({ hovered, pressed }: PressableStateCallbackType) => [
styles.header,
expanded ? styles.headerDivider : styles.headerCollapsed,
styles.headerToggle,
(hovered || pressed) && styles.headerActive,
],
[],
);
const headerContainerStyle = useMemo(
() => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed],
[expanded],
);
@@ -74,27 +84,42 @@ export function SubagentsTrack({
}
const headerLabel = formatHeaderLabel(rows);
const finishedCount = countFinishedSubagents(rows);
return (
<View style={styles.outer} testID="subagents-track">
<View style={styles.track}>
<View style={surfaceStyle}>
<Pressable
accessibilityRole="button"
accessibilityLabel={headerLabel}
testID="subagents-track-header"
onPress={toggleExpanded}
style={headerStyle}
>
{expanded ? (
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.headerLabel} numberOfLines={1}>
{headerLabel}
</Text>
</Pressable>
<View style={headerContainerStyle}>
<Pressable
accessibilityRole="button"
accessibilityLabel={headerLabel}
testID="subagents-track-header"
onPress={toggleExpanded}
style={headerStyle}
>
{expanded ? (
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.headerLabel} numberOfLines={1}>
{headerLabel}
</Text>
</Pressable>
{finishedCount > 0 && onArchiveFinished ? (
<View style={styles.headerAction}>
<SubagentActionButton
accessibilityLabel={t("subagents.archiveFinishedAction")}
testID="subagents-track-archive-finished"
tooltipLabel={t("subagents.archiveFinishedTooltip")}
icon="archive"
visible
onPress={onArchiveFinished}
/>
</View>
) : null}
</View>
{expanded ? (
<ScrollView
style={styles.scroll}
@@ -306,12 +331,22 @@ const styles = StyleSheet.create((theme) => ({
header: {
flexDirection: "row",
alignItems: "center",
},
headerToggle: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[3],
paddingLeft: theme.spacing[3],
paddingRight: theme.spacing[1],
paddingVertical: theme.spacing[2],
},
headerAction: {
paddingRight: theme.spacing[2],
},
headerCollapsed: {
paddingBottom: theme.spacing[6],
paddingBottom: theme.spacing[4],
},
headerActive: {
backgroundColor: theme.colors.surface2,
@@ -0,0 +1,28 @@
import { useCallback } from "react";
import { useToast } from "@/contexts/toast-context";
import { i18n } from "@/i18n/i18next";
import { useSessionStore } from "@/stores/session-store";
import { toErrorMessage } from "@/utils/error-messages";
export interface UseArchiveFinishedSubagentsInput {
serverId: string;
parentAgentId: string;
}
export function useArchiveFinishedSubagents({
serverId,
parentAgentId,
}: UseArchiveFinishedSubagentsInput): () => void {
const toast = useToast();
return useCallback(() => {
const client = useSessionStore.getState().sessions[serverId]?.client;
if (!client) {
toast.error(i18n.t("workspaceSetup.errors.hostDisconnected"));
return;
}
void client.archiveFinishedSubagents(parentAgentId).catch((error) => {
toast.error(toErrorMessage(error));
});
}, [parentAgentId, serverId, toast]);
}
+31
View File
@@ -498,6 +498,10 @@ export type ProviderSubagentListPayload = Extract<
SessionOutboundMessage,
{ type: "agent.provider_subagents.list.response" }
>["payload"];
export type ArchiveFinishedSubagentsPayload = Extract<
SessionOutboundMessage,
{ type: "agent.subagents.archive_finished.response" }
>["payload"];
export type ProviderSubagentTimelinePayload = Extract<
SessionOutboundMessage,
{ type: "agent.provider_subagents.timeline.get.response" }
@@ -2514,6 +2518,33 @@ export class DaemonClient {
return payload;
}
async archiveFinishedSubagents(
parentAgentId: string,
options: { requestId?: string; timeout?: number } = {},
): Promise<ArchiveFinishedSubagentsPayload> {
const requestId = this.createRequestId(options.requestId);
const message = SessionInboundMessageSchema.parse({
type: "agent.subagents.archive_finished.request",
parentAgentId,
requestId,
});
const payload = await this.sendRequest({
requestId,
message,
timeout: options.timeout,
options: { skipQueue: true },
select: (response) =>
response.type === "agent.subagents.archive_finished.response" &&
response.payload.requestId === requestId
? response.payload
: null,
});
if (payload.error) {
throw new Error(payload.error);
}
return payload;
}
async fetchProviderSubagentTimeline(
parentAgentId: string,
subagentId: string,
@@ -73,4 +73,32 @@ describe("provider subagent protocol", () => {
}),
).toMatchObject({ payload: { subagents: [{ id: "child-1" }] } });
});
test("accepts one archive-finished operation across managed and provider children", () => {
expect(
SessionInboundMessageSchema.parse({
type: "agent.subagents.archive_finished.request",
parentAgentId: "parent-1",
requestId: "request-1",
}),
).toMatchObject({ parentAgentId: "parent-1" });
expect(
SessionOutboundMessageSchema.parse({
type: "agent.subagents.archive_finished.response",
payload: {
requestId: "request-1",
parentAgentId: "parent-1",
archivedAgentIds: ["managed-1"],
dismissedProviderSubagentIds: ["native-1"],
error: null,
},
}),
).toMatchObject({
payload: {
archivedAgentIds: ["managed-1"],
dismissedProviderSubagentIds: ["native-1"],
},
});
});
});
+21
View File
@@ -1308,6 +1308,12 @@ export const ProviderSubagentListRequestMessageSchema = z.object({
requestId: z.string(),
});
export const ArchiveFinishedSubagentsRequestMessageSchema = z.object({
type: z.literal("agent.subagents.archive_finished.request"),
parentAgentId: z.string(),
requestId: z.string(),
});
export const ProviderSubagentTimelineRequestMessageSchema = z.object({
type: z.literal("agent.provider_subagents.timeline.get.request"),
parentAgentId: z.string(),
@@ -2149,6 +2155,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
DaemonUpdateRequestMessageSchema,
FetchAgentTimelineRequestMessageSchema,
ProviderSubagentListRequestMessageSchema,
ArchiveFinishedSubagentsRequestMessageSchema,
ProviderSubagentTimelineRequestMessageSchema,
AgentForkContextRequestMessageSchema,
SetAgentModeRequestMessageSchema,
@@ -2431,6 +2438,8 @@ export const ServerInfoStatusPayloadSchema = z
agentForkContext: z.boolean().optional(),
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: z.boolean().optional(),
// COMPAT(archiveFinishedSubagents): added in v0.1.108, remove gate after 2027-01-14.
archiveFinishedSubagents: z.boolean().optional(),
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
workspacePinning: z.boolean().optional(),
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.
@@ -3066,6 +3075,17 @@ export const ProviderSubagentListResponseMessageSchema = z.object({
}),
});
export const ArchiveFinishedSubagentsResponseMessageSchema = z.object({
type: z.literal("agent.subagents.archive_finished.response"),
payload: z.object({
requestId: z.string(),
parentAgentId: z.string(),
archivedAgentIds: z.array(z.string()),
dismissedProviderSubagentIds: z.array(z.string()),
error: z.string().nullable(),
}),
});
export const ProviderSubagentTimelineResponseMessageSchema = z.object({
type: z.literal("agent.provider_subagents.timeline.get.response"),
payload: z.object({
@@ -4373,6 +4393,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchAgentResponseMessageSchema,
FetchAgentTimelineResponseMessageSchema,
ProviderSubagentListResponseMessageSchema,
ArchiveFinishedSubagentsResponseMessageSchema,
ProviderSubagentTimelineResponseMessageSchema,
ProviderSubagentUpdateMessageSchema,
AgentForkContextResponseMessageSchema,
@@ -74,6 +74,7 @@ export async function ensureAgentLoaded(
}
snapshot = await deps.agentManager.createAgent(config, agentId, {
labels: record.labels,
dismissedProviderSubagentIds: record.dismissedProviderSubagentIds,
workspaceId: record.workspaceId,
});
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
@@ -2826,6 +2826,73 @@ test("hydrateTimelineFromProvider restores and broadcasts provider children from
});
});
test("archiveFinishedSubagents archives managed children and keeps dismissed provider history hidden", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-archive-finished-subagents-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
class ParentSession extends TestAgentSession {
override async *streamHistory(): AsyncGenerator<AgentStreamEvent> {
yield {
type: "provider_subagent",
provider: "codex",
event: { type: "upsert", id: "native-finished", status: "completed" },
};
yield {
type: "provider_subagent",
provider: "codex",
event: { type: "upsert", id: "native-running", status: "running" },
};
yield {
type: "provider_subagent",
provider: "codex",
event: { type: "upsert", id: "native-failed", status: "failed" },
};
}
}
class SubagentsClient extends TestAgentClient {
private sessionsCreated = 0;
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.sessionsCreated += 1;
return this.sessionsCreated === 1 ? new ParentSession(config) : new TestAgentSession(config);
}
}
const manager = new AgentManager({
clients: { codex: new SubagentsClient() },
registry: storage,
logger,
});
const parent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const managedFinished = await manager.createAgent(
{ provider: "codex", cwd: workdir },
undefined,
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id }, workspaceId: undefined },
);
await manager.hydrateTimelineFromProvider(parent.id);
const result = await manager.archiveFinishedSubagents(parent.id);
expect(result).toEqual({
archivedAgentIds: [managedFinished.id],
dismissedProviderSubagentIds: ["native-finished", "native-failed"],
});
expectArchivedAgentRecord(await storage.get(managedFinished.id), "closed");
expect(manager.listProviderSubagents(parent.id).map((subagent) => subagent.id)).toEqual([
"native-running",
]);
expect((await storage.get(parent.id))?.dismissedProviderSubagentIds).toEqual([
"native-finished",
"native-failed",
]);
await manager.hydrateTimelineFromProvider(parent.id, { force: true });
expect(manager.listProviderSubagents(parent.id).map((subagent) => subagent.id)).toEqual([
"native-running",
]);
});
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);
@@ -226,6 +226,7 @@ type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
export interface CreateAgentOptions {
labels?: Record<string, string>;
dismissedProviderSubagentIds?: string[];
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
@@ -338,6 +339,7 @@ interface ManagedAgentBase {
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
*/
labels: Record<string, string>;
dismissedProviderSubagentIds: Set<string>;
}
type ManagedAgentWithSession = ManagedAgentBase & {
@@ -962,6 +964,51 @@ export class AgentManager {
return this.providerSubagents.list(parentAgentId);
}
async archiveFinishedSubagents(parentAgentId: string): Promise<{
archivedAgentIds: string[];
dismissedProviderSubagentIds: string[];
}> {
const parent = this.requirePublicAgent(parentAgentId);
const registry = this.requireRegistry();
const archivedAgentIds: string[] = [];
for (const record of await registry.list()) {
if (record.archivedAt || record.labels[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
continue;
}
const live = this.agents.get(record.id);
const status = live?.lifecycle ?? record.lastStatus;
if (isAgentBusy(status)) {
continue;
}
if (live) {
await this.archiveAgent(record.id);
} else {
await this.markRecordArchived(record);
await this.cascadeArchiveChildren(record.id);
}
archivedAgentIds.push(record.id);
}
const dismissedProviderSubagentIds = this.providerSubagents
.list(parentAgentId)
.filter((subagent) => subagent.status !== "running")
.map((subagent) => subagent.id);
for (const subagentId of dismissedProviderSubagentIds) {
parent.dismissedProviderSubagentIds.add(subagentId);
const event = this.providerSubagents.apply(parentAgentId, parent.provider, {
type: "remove",
id: subagentId,
});
this.dispatch({ type: "provider_subagent", event });
}
if (dismissedProviderSubagentIds.length > 0) {
await this.persistSnapshot(parent);
}
return { archivedAgentIds, dismissedProviderSubagentIds };
}
getProviderSubagent(
parentAgentId: string,
subagentId: string,
@@ -1005,6 +1052,7 @@ export class AgentManager {
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
return this.registerSession(session, storedConfig, resolvedAgentId, {
labels: options.labels,
dismissedProviderSubagentIds: options.dismissedProviderSubagentIds,
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
});
@@ -1030,6 +1078,7 @@ export class AgentManager {
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
dismissedProviderSubagentIds?: string[];
},
): Promise<ManagedAgent> {
return this.trackAgentRegistrationOperation(
@@ -1047,6 +1096,7 @@ export class AgentManager {
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
dismissedProviderSubagentIds?: string[];
},
): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations();
@@ -1233,6 +1283,7 @@ export class AgentManager {
lastUsage: preservedLastUsage,
lastError: preservedLastError,
attention: preservedAttention,
dismissedProviderSubagentIds: [...existing.dismissedProviderSubagentIds],
});
} finally {
if (!handedToRegistration) {
@@ -1443,6 +1494,7 @@ export class AgentManager {
attention: { requiresAttention: false },
internal: record.internal,
labels: record.labels,
dismissedProviderSubagentIds: new Set(record.dismissedProviderSubagentIds),
},
});
}
@@ -2566,6 +2618,7 @@ export class AgentManager {
initialTitle?: string | null;
publishWhenReady?: boolean;
workspaceId?: string;
dismissedProviderSubagentIds?: string[];
},
): Promise<ManagedAgent> {
let registered = false;
@@ -2701,6 +2754,7 @@ export class AgentManager {
attention?: AttentionState;
persistence?: AgentPersistenceHandle;
workspaceId?: string;
dismissedProviderSubagentIds?: string[];
}
| undefined;
}): ActiveManagedAgent {
@@ -2738,6 +2792,7 @@ export class AgentManager {
attention: resolveInitialAttention(options?.attention),
internal: config.internal ?? false,
labels: options?.labels ?? {},
dismissedProviderSubagentIds: new Set(options?.dismissedProviderSubagentIds ?? []),
} as ActiveManagedAgent;
}
@@ -2851,8 +2906,10 @@ export class AgentManager {
event: AgentStreamEvent,
): Promise<void> {
if (event.type === "provider_subagent") {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
this.dispatch({ type: "provider_subagent", event: update });
const update = this.applyProviderSubagentEvent(agent, event);
if (update) {
this.dispatch({ type: "provider_subagent", event: update });
}
return;
}
const turnId = getAgentStreamEventTurnId(event);
@@ -2892,6 +2949,16 @@ export class AgentManager {
);
}
private applyProviderSubagentEvent(
agent: ManagedAgent,
event: Extract<AgentStreamEvent, { type: "provider_subagent" }>,
): ProviderSubagentStoreEvent | null {
if (agent.dismissedProviderSubagentIds.has(event.event.id)) {
return null;
}
return this.providerSubagents.apply(agent.id, event.provider, event.event);
}
private async resolveInitialPersistedTitle(
agentId: string,
config: AgentSessionConfig,
@@ -3029,8 +3096,8 @@ export class AgentManager {
}
}
for (const event of providerSubagentEvents) {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
if (broadcast) {
const update = this.applyProviderSubagentEvent(agent, event);
if (broadcast && update) {
this.dispatch({ type: "provider_subagent", event: update });
}
}
@@ -3060,8 +3127,8 @@ export class AgentManager {
try {
for await (const event of agent.session.streamHistory()) {
if (event.type === "provider_subagent") {
const update = this.providerSubagents.apply(agent.id, event.provider, event.event);
if (broadcast) {
const update = this.applyProviderSubagentEvent(agent, event);
if (broadcast && update) {
this.dispatch({ type: "provider_subagent", event: update });
}
continue;
@@ -93,6 +93,7 @@ export function toStoredAgentRecord(
? agent.attention.attentionTimestamp.toISOString()
: null,
internal: options?.internal,
dismissedProviderSubagentIds: [...agent.dismissedProviderSubagentIds],
} satisfies StoredAgentRecord;
}
@@ -63,6 +63,7 @@ const STORED_AGENT_SCHEMA = z.object({
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
internal: z.boolean().optional(),
dismissedProviderSubagentIds: z.array(z.string()).default([]),
archivedAt: z.string().nullable().optional(),
});
@@ -5036,6 +5036,7 @@ function readClaudeSidechainHistory(historyPath: string): string[] {
}
interface ClaudeHistoricalSubagentToolCall {
name?: string;
subagentType?: string;
description?: string;
}
@@ -5057,9 +5058,11 @@ function readClaudeHistoricalSubagentToolCalls(
continue;
}
const input = toObjectRecord(block.input);
const name = readNonEmptyString(input?.name);
const subagentType = readNonEmptyString(input?.subagent_type);
const description = readNonEmptyString(input?.description);
toolCalls.set(block.id, {
...(name ? { name } : {}),
...(subagentType ? { subagentType } : {}),
...(description ? { description } : {}),
});
@@ -5121,6 +5124,12 @@ function buildClaudePersistedSidechainEvents(
return events;
}
function resolveClaudeHistoricalSubagentTitle(
toolCall: ClaudeHistoricalSubagentToolCall | undefined,
): string {
return toolCall?.name ?? toolCall?.subagentType ?? "Claude subagent";
}
function buildClaudePersistedSidechainAgentEvents(
agentId: string,
entries: ClaudeHistoryEntry[],
@@ -5139,7 +5148,7 @@ function buildClaudePersistedSidechainAgentEvents(
event: {
type: "upsert",
id,
title: toolCall?.subagentType ?? "Claude subagent",
title: resolveClaudeHistoricalSubagentTitle(toolCall),
description: toolCall?.description ?? null,
status: "running",
toolCallId: result?.toolCallId ?? null,
@@ -139,7 +139,11 @@ describe("ClaudeAgentSession history replay regression", () => {
type: "tool_use",
id: "history-task-call",
name: "Agent",
input: { description: "Inspect persisted history" },
input: {
name: "history_researcher",
subagent_type: "Explore",
description: "Inspect persisted history",
},
},
],
},
@@ -303,6 +307,16 @@ describe("ClaudeAgentSession history replay regression", () => {
timestamp: "2026-07-12T10:00:01.000Z",
},
});
expect(historyEvents).toContainEqual({
type: "provider_subagent",
provider: "claude",
event: expect.objectContaining({
type: "upsert",
id: "history-task-call",
title: "history_researcher",
status: "running",
}),
});
expect(historyEvents).toContainEqual({
type: "provider_subagent",
provider: "claude",
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk";
import { ClaudeSidechainTracker } from "./sidechain-tracker.js";
describe("ClaudeSidechainTracker", () => {
it("uses Claude's native agent name for the provider subagent title", () => {
const tracker = new ClaudeSidechainTracker({
getToolInput: () => ({
name: "repo_researcher",
subagent_type: "Explore",
description: "Inspect the repository",
}),
});
const events = tracker.handleMessage(
{
type: "assistant",
parent_tool_use_id: "task-1",
message: { content: [] },
} as unknown as SDKMessage,
"task-1",
);
expect(events[0]).toEqual({
type: "provider_subagent",
provider: "claude",
event: {
type: "upsert",
id: "task-1",
title: "repo_researcher",
description: "Inspect the repository",
status: "running",
toolCallId: "task-1",
},
});
});
});
@@ -22,6 +22,7 @@ interface SubAgentActionEntry {
}
interface SubAgentActivityState {
name?: string;
subAgentType?: string;
description?: string;
actions: SubAgentActionEntry[];
@@ -130,7 +131,7 @@ export class ClaudeSidechainTracker {
event: {
type: "upsert",
id: parentToolUseId,
title: state.subAgentType ?? "Claude subagent",
title: state.name ?? state.subAgentType ?? "Claude subagent",
description: state.description ?? null,
status: "running",
toolCallId: parentToolUseId,
@@ -163,7 +164,7 @@ export class ClaudeSidechainTracker {
event: {
type: "upsert",
id,
title: state.subAgentType ?? "Claude subagent",
title: state.name ?? state.subAgentType ?? "Claude subagent",
description: state.description ?? null,
status,
toolCallId: id,
@@ -185,7 +186,7 @@ export class ClaudeSidechainTracker {
event: {
type: "upsert",
id,
title: state.subAgentType ?? "Claude subagent",
title: state.name ?? state.subAgentType ?? "Claude subagent",
description: state.description ?? null,
status,
toolCallId: id,
@@ -267,10 +268,15 @@ export class ClaudeSidechainTracker {
parentToolUseId: string,
): boolean {
const taskInput = this.getToolInput(parentToolUseId);
const nextName = this.normalizeSubAgentText(taskInput?.name);
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
const nextDescription = this.normalizeSubAgentText(taskInput?.description);
let changed = false;
if (nextName && nextName !== state.name) {
state.name = nextName;
changed = true;
}
if (nextSubAgentType && nextSubAgentType !== state.subAgentType) {
state.subAgentType = nextSubAgentType;
changed = true;
@@ -234,7 +234,7 @@ describe("codex tool-call mapper", () => {
id: `activity-${kind}`,
kind,
agentThreadId: "child-thread-1",
agentPath: "/root/investigator",
agentPath: "/root/research/investigator",
});
expect(item).toEqual({
@@ -245,8 +245,8 @@ describe("codex tool-call mapper", () => {
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
description: "/root/investigator",
subAgentType: "research/investigator",
description: "/root/research/investigator",
log: "",
actions: [],
},
@@ -993,6 +993,7 @@ function mapCollabAgentToolCallItem(
function mapSubAgentActivityItem(
item: z.infer<typeof CodexSubAgentActivityItemSchema>,
): ToolCallTimelineItem {
const nativeName = item.agentPath.replace(/^\/root(?:\/|$)/, "") || "Sub-agent";
return {
type: "tool_call",
callId: item.id,
@@ -1001,7 +1002,7 @@ function mapSubAgentActivityItem(
error: null,
detail: {
type: "sub_agent",
subAgentType: "Sub-agent",
subAgentType: nativeName,
description: item.agentPath,
log: "",
actions: [],
@@ -110,6 +110,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
lastUserMessageAt: Date | null;
labels?: Record<string, string>;
workspaceId?: string;
dismissedProviderSubagentIds?: string[];
} {
return {
createdAt: new Date(record.createdAt),
@@ -117,6 +118,7 @@ export function extractTimestamps(record: StoredAgentRecord): {
lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null,
labels: record.labels,
workspaceId: record.workspaceId,
dismissedProviderSubagentIds: record.dismissedProviderSubagentIds,
};
}
+30
View File
@@ -1486,6 +1486,8 @@ export class Session {
return this.handleFetchAgentTimelineRequest(msg);
case "agent.provider_subagents.list.request":
return this.handleProviderSubagentListRequest(msg);
case "agent.subagents.archive_finished.request":
return this.handleArchiveFinishedSubagentsRequest(msg);
case "agent.provider_subagents.timeline.get.request":
return this.handleProviderSubagentTimelineRequest(msg);
case "agent.fork_context.request":
@@ -5448,6 +5450,34 @@ export class Session {
}
}
private async handleArchiveFinishedSubagentsRequest(
msg: Extract<SessionInboundMessage, { type: "agent.subagents.archive_finished.request" }>,
): Promise<void> {
try {
const result = await this.agentManager.archiveFinishedSubagents(msg.parentAgentId);
this.emit({
type: "agent.subagents.archive_finished.response",
payload: {
requestId: msg.requestId,
parentAgentId: msg.parentAgentId,
...result,
error: null,
},
});
} catch (error) {
this.emit({
type: "agent.subagents.archive_finished.response",
payload: {
requestId: msg.requestId,
parentAgentId: msg.parentAgentId,
archivedAgentIds: [],
dismissedProviderSubagentIds: [],
error: error instanceof Error ? error.message : String(error),
},
});
}
}
private async handleProviderSubagentTimelineRequest(
msg: Extract<SessionInboundMessage, { type: "agent.provider_subagents.timeline.get.request" }>,
): Promise<void> {
@@ -1262,6 +1262,8 @@ export class VoiceAssistantWebSocketServer {
agentForkContext: true,
// COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12.
providerSubagents: true,
// COMPAT(archiveFinishedSubagents): added in v0.1.108, remove gate after 2027-01-14.
archiveFinishedSubagents: true,
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
workspacePinning: true,
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.