mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Show friendly native subagent names and hide finished work (#2073)
* 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. * fix(subagents): cover persistence and naming edge cases * fix(subagents): preserve active descendants and native names * fix(subagents): retain dismissed timelines and trim paths * fix(subagents): retain history across hydration * test(subagents): expect normalized native descriptions * fix(subagents): separate hidden rows from retained history * fix(subagents): preserve restored hidden tabs safely * fix(subagents): integrate archive with run state * test(codex): expect normalized child name * fix(subagents): retain dismissed tabs on reload * fix(subagents): hide finished native agents locally Keep the header action presentation-only so it does not mutate agent lifecycle or expand the runtime protocol. * fix(subagents): humanize native agent names * fix(subagents): preserve names in Codex history * fix(subagents): retain hidden rows across reloads
This commit is contained in:
@@ -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.
|
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 hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
|
||||||
|
|
||||||
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
|
||||||
|
|
||||||
@@ -109,7 +111,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway
|
|||||||
|
|
||||||
### Subagent accumulation under long-lived parents
|
### 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. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
|
||||||
|
|
||||||
### Cross-client tab dismissal
|
### Cross-client tab dismissal
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { openSubagentsTrack } from "./helpers/subagents";
|
|||||||
interface ProviderSubagentCase {
|
interface ProviderSubagentCase {
|
||||||
provider: RewindFlowProvider;
|
provider: RewindFlowProvider;
|
||||||
sentinel: string;
|
sentinel: string;
|
||||||
|
expectedName: string;
|
||||||
prompt: string;
|
prompt: string;
|
||||||
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
|
||||||
}
|
}
|
||||||
@@ -22,20 +23,23 @@ const cases: ProviderSubagentCase[] = [
|
|||||||
{
|
{
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sentinel: "CLAUDE_CHILD_SENTINEL",
|
sentinel: "CLAUDE_CHILD_SENTINEL",
|
||||||
|
expectedName: "sentinel_child",
|
||||||
providerConfig: { model: "opus" },
|
providerConfig: { model: "opus" },
|
||||||
prompt:
|
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",
|
provider: "codex",
|
||||||
sentinel: "CODEX_CHILD_SENTINEL",
|
sentinel: "CODEX_CHILD_SENTINEL",
|
||||||
|
expectedName: "Sentinel child",
|
||||||
providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } },
|
providerConfig: { extra: { codex: { features: { multi_agent_v2: true } } } },
|
||||||
prompt:
|
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",
|
provider: "opencode",
|
||||||
sentinel: "OPENCODE_CHILD_SENTINEL",
|
sentinel: "OPENCODE_CHILD_SENTINEL",
|
||||||
|
expectedName: "Explore",
|
||||||
prompt:
|
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.",
|
"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-"]');
|
const rows = page.locator('[data-testid^="subagents-track-row-"]');
|
||||||
await expect(rows).toHaveCount(1, { timeout: 60_000 });
|
await expect(rows).toHaveCount(1, { timeout: 60_000 });
|
||||||
|
await expect(rows.first()).toContainText(scenario.expectedName);
|
||||||
await rows.first().click();
|
await rows.first().click();
|
||||||
|
|
||||||
const panel = page.getByTestId("provider-subagent-panel");
|
const panel = page.getByTestId("provider-subagent-panel");
|
||||||
@@ -76,6 +81,15 @@ test.describe("real provider subagent timelines", () => {
|
|||||||
await expect(
|
await expect(
|
||||||
panel.getByText("Start chatting with this agent...", { exact: true }),
|
panel.getByText("Start chatting with this agent...", { exact: true }),
|
||||||
).toHaveCount(0);
|
).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 {
|
} finally {
|
||||||
await cleanupRewindFlow({ handle, cwd });
|
await cleanupRewindFlow({ handle, cwd });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1370,6 +1370,8 @@ export const ar: TranslationResources = {
|
|||||||
detachTooltip: "فصل الوكيل الفرعي",
|
detachTooltip: "فصل الوكيل الفرعي",
|
||||||
archiveAction: "أرشيف{{label}}",
|
archiveAction: "أرشيف{{label}}",
|
||||||
archiveTooltip: "أرشفة الوكيل الفرعي",
|
archiveTooltip: "أرشفة الوكيل الفرعي",
|
||||||
|
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
|
||||||
|
archiveFinishedTooltip: "أرشفة المكتملين",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1378,6 +1378,8 @@ export const en = {
|
|||||||
detachTooltip: "Detach subagent",
|
detachTooltip: "Detach subagent",
|
||||||
archiveAction: "Archive {{label}}",
|
archiveAction: "Archive {{label}}",
|
||||||
archiveTooltip: "Archive subagent",
|
archiveTooltip: "Archive subagent",
|
||||||
|
archiveFinishedAction: "Archive finished subagents",
|
||||||
|
archiveFinishedTooltip: "Archive finished",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1409,6 +1409,8 @@ export const es: TranslationResources = {
|
|||||||
detachTooltip: "Separar subagente",
|
detachTooltip: "Separar subagente",
|
||||||
archiveAction: "Archivo{{label}}",
|
archiveAction: "Archivo{{label}}",
|
||||||
archiveTooltip: "Subagente de archivo",
|
archiveTooltip: "Subagente de archivo",
|
||||||
|
archiveFinishedAction: "Archivar subagentes finalizados",
|
||||||
|
archiveFinishedTooltip: "Archivar finalizados",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1412,6 +1412,8 @@ export const fr: TranslationResources = {
|
|||||||
detachTooltip: "Detacher le sous-agent",
|
detachTooltip: "Detacher le sous-agent",
|
||||||
archiveAction: "Archiver{{label}}",
|
archiveAction: "Archiver{{label}}",
|
||||||
archiveTooltip: "Sous-agent d'archivage",
|
archiveTooltip: "Sous-agent d'archivage",
|
||||||
|
archiveFinishedAction: "Archiver les sous-agents terminés",
|
||||||
|
archiveFinishedTooltip: "Archiver les terminés",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1387,6 +1387,8 @@ export const ja: TranslationResources = {
|
|||||||
detachTooltip: "サブエージェントを切り離す",
|
detachTooltip: "サブエージェントを切り離す",
|
||||||
archiveAction: "{{label}}をアーカイブ",
|
archiveAction: "{{label}}をアーカイブ",
|
||||||
archiveTooltip: "サブエージェントをアーカイブ",
|
archiveTooltip: "サブエージェントをアーカイブ",
|
||||||
|
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
|
||||||
|
archiveFinishedTooltip: "完了した項目をアーカイブ",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1395,6 +1395,8 @@ export const ptBR: TranslationResources = {
|
|||||||
detachTooltip: "Desanexar subagente",
|
detachTooltip: "Desanexar subagente",
|
||||||
archiveAction: "Arquivar {{label}}",
|
archiveAction: "Arquivar {{label}}",
|
||||||
archiveTooltip: "Arquivar subagente",
|
archiveTooltip: "Arquivar subagente",
|
||||||
|
archiveFinishedAction: "Arquivar subagentes concluídos",
|
||||||
|
archiveFinishedTooltip: "Arquivar concluídos",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1401,6 +1401,8 @@ export const ru: TranslationResources = {
|
|||||||
detachTooltip: "Отсоединить субагент",
|
detachTooltip: "Отсоединить субагент",
|
||||||
archiveAction: "Архив{{label}}",
|
archiveAction: "Архив{{label}}",
|
||||||
archiveTooltip: "Архивный субагент",
|
archiveTooltip: "Архивный субагент",
|
||||||
|
archiveFinishedAction: "Архивировать завершенные субагенты",
|
||||||
|
archiveFinishedTooltip: "Архивировать завершенные",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -1354,6 +1354,8 @@ export const zhCN: TranslationResources = {
|
|||||||
detachTooltip: "分离 subagent",
|
detachTooltip: "分离 subagent",
|
||||||
archiveAction: "归档 {{label}}",
|
archiveAction: "归档 {{label}}",
|
||||||
archiveTooltip: "归档 subagent",
|
archiveTooltip: "归档 subagent",
|
||||||
|
archiveFinishedAction: "归档已完成的 subagent",
|
||||||
|
archiveFinishedTooltip: "归档已完成项",
|
||||||
},
|
},
|
||||||
panels: {
|
panels: {
|
||||||
draft: {
|
draft: {
|
||||||
|
|||||||
@@ -68,7 +68,12 @@ import { type Agent, useSessionStore } from "@/stores/session-store";
|
|||||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents";
|
import {
|
||||||
|
useHideFinishedProviderSubagents,
|
||||||
|
useArchiveSubagent,
|
||||||
|
useDetachSubagent,
|
||||||
|
useSubagentsForParent,
|
||||||
|
} from "@/subagents";
|
||||||
import { SubagentsTrack } from "@/subagents/track";
|
import { SubagentsTrack } from "@/subagents/track";
|
||||||
import type { PendingPermission } from "@/types/shared";
|
import type { PendingPermission } from "@/types/shared";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
@@ -1390,6 +1395,10 @@ function ActiveAgentComposer({
|
|||||||
);
|
);
|
||||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||||
|
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
|
||||||
|
serverId,
|
||||||
|
parentAgentId: agentId,
|
||||||
|
});
|
||||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||||
serverId,
|
serverId,
|
||||||
cwd,
|
cwd,
|
||||||
@@ -1490,6 +1499,7 @@ function ActiveAgentComposer({
|
|||||||
onOpenSubagent={handleOpenSubagent}
|
onOpenSubagent={handleOpenSubagent}
|
||||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||||
onArchiveSubagent={handleArchiveSubagent}
|
onArchiveSubagent={handleArchiveSubagent}
|
||||||
|
onArchiveFinished={handleHideFinishedProviderSubagents}
|
||||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||||
/>
|
/>
|
||||||
<Composer
|
<Composer
|
||||||
|
|||||||
@@ -2,5 +2,9 @@ export type { SubagentRow } from "./select";
|
|||||||
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
|
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
|
||||||
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
|
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
|
||||||
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
|
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
|
||||||
|
export {
|
||||||
|
useHideFinishedProviderSubagents,
|
||||||
|
type UseHideFinishedProviderSubagentsInput,
|
||||||
|
} from "./use-hide-finished-provider-subagents";
|
||||||
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
||||||
export { isWorkspaceRootAgent } from "./workspace-root-policy";
|
export { isWorkspaceRootAgent } from "./workspace-root-policy";
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ const PARENT_ID = "parent-1";
|
|||||||
const SUBAGENT_ID = "child-1";
|
const SUBAGENT_ID = "child-1";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
|
useProviderSubagentStore.setState({
|
||||||
|
descriptors: new Map(),
|
||||||
|
timelines: new Map(),
|
||||||
|
hiddenFromTrack: new Set(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("provider subagent client store", () => {
|
describe("provider subagent client store", () => {
|
||||||
@@ -129,6 +133,123 @@ describe("provider subagent client store", () => {
|
|||||||
).toBe(false);
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("hides finished children locally without removing their timelines", () => {
|
||||||
|
const store = useProviderSubagentStore.getState();
|
||||||
|
store.applyUpdate(SERVER_ID, {
|
||||||
|
kind: "upsert",
|
||||||
|
subagent: {
|
||||||
|
id: SUBAGENT_ID,
|
||||||
|
parentAgentId: PARENT_ID,
|
||||||
|
provider: "codex",
|
||||||
|
title: "Finished child",
|
||||||
|
description: null,
|
||||||
|
status: "completed",
|
||||||
|
createdAt: "2026-07-12T10:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.applyUpdate(SERVER_ID, {
|
||||||
|
kind: "timeline",
|
||||||
|
parentAgentId: PARENT_ID,
|
||||||
|
subagentId: SUBAGENT_ID,
|
||||||
|
provider: "codex",
|
||||||
|
epoch: "epoch-1",
|
||||||
|
seq: 1,
|
||||||
|
timestamp: "2026-07-12T10:00:01.000Z",
|
||||||
|
item: { type: "assistant_message", text: "Finished output." },
|
||||||
|
});
|
||||||
|
|
||||||
|
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
|
||||||
|
|
||||||
|
const state = useProviderSubagentStore.getState();
|
||||||
|
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||||
|
expect(state.descriptors.get(key)?.title).toBe("Finished child");
|
||||||
|
expect(state.hiddenFromTrack.has(key)).toBe(true);
|
||||||
|
expect(state.timelines.get(key)?.tail).toEqual([
|
||||||
|
expect.objectContaining({ kind: "assistant_message", text: "Finished output." }),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("reveals a hidden child when the provider reports it running again", () => {
|
||||||
|
const store = useProviderSubagentStore.getState();
|
||||||
|
const completed = {
|
||||||
|
id: SUBAGENT_ID,
|
||||||
|
parentAgentId: PARENT_ID,
|
||||||
|
provider: "codex" as const,
|
||||||
|
title: "Finished child",
|
||||||
|
description: null,
|
||||||
|
status: "completed" as const,
|
||||||
|
createdAt: "2026-07-12T10:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
};
|
||||||
|
store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed });
|
||||||
|
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
|
||||||
|
store.replaceList(SERVER_ID, PARENT_ID, [completed]);
|
||||||
|
|
||||||
|
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||||
|
expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(true);
|
||||||
|
|
||||||
|
store.applyUpdate(SERVER_ID, {
|
||||||
|
kind: "upsert",
|
||||||
|
subagent: { ...completed, status: "running", updatedAt: "2026-07-12T10:01:00.000Z" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps hidden state when a child temporarily disappears from the provider list", () => {
|
||||||
|
const store = useProviderSubagentStore.getState();
|
||||||
|
store.applyUpdate(SERVER_ID, {
|
||||||
|
kind: "upsert",
|
||||||
|
subagent: {
|
||||||
|
id: SUBAGENT_ID,
|
||||||
|
parentAgentId: PARENT_ID,
|
||||||
|
provider: "codex",
|
||||||
|
title: "Finished child",
|
||||||
|
description: null,
|
||||||
|
status: "completed",
|
||||||
|
createdAt: "2026-07-12T10:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
|
||||||
|
|
||||||
|
store.replaceList(SERVER_ID, PARENT_ID, []);
|
||||||
|
|
||||||
|
const state = useProviderSubagentStore.getState();
|
||||||
|
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||||
|
expect(state.descriptors.has(key)).toBe(false);
|
||||||
|
expect(state.hiddenFromTrack.has(key)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keeps a finished child hidden across remove and history replay", () => {
|
||||||
|
const store = useProviderSubagentStore.getState();
|
||||||
|
const completed = {
|
||||||
|
id: SUBAGENT_ID,
|
||||||
|
parentAgentId: PARENT_ID,
|
||||||
|
provider: "codex" as const,
|
||||||
|
title: "Finished child",
|
||||||
|
description: null,
|
||||||
|
status: "completed" as const,
|
||||||
|
createdAt: "2026-07-12T10:00:00.000Z",
|
||||||
|
updatedAt: "2026-07-12T10:00:02.000Z",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
};
|
||||||
|
store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed });
|
||||||
|
store.hideFinishedForParent(SERVER_ID, PARENT_ID);
|
||||||
|
store.applyUpdate(SERVER_ID, {
|
||||||
|
kind: "remove",
|
||||||
|
parentAgentId: PARENT_ID,
|
||||||
|
subagentId: SUBAGENT_ID,
|
||||||
|
});
|
||||||
|
store.applyUpdate(SERVER_ID, { kind: "upsert", subagent: completed });
|
||||||
|
|
||||||
|
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
|
||||||
|
expect(useProviderSubagentStore.getState().hiddenFromTrack.has(key)).toBe(true);
|
||||||
|
});
|
||||||
test("applies terminal list status to a timeline received before its descriptor", () => {
|
test("applies terminal list status to a timeline received before its descriptor", () => {
|
||||||
const store = useProviderSubagentStore.getState();
|
const store = useProviderSubagentStore.getState();
|
||||||
store.applyUpdate(SERVER_ID, {
|
store.applyUpdate(SERVER_ID, {
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ export interface ProviderSubagentTimelineState {
|
|||||||
interface ProviderSubagentState {
|
interface ProviderSubagentState {
|
||||||
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
|
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
|
||||||
timelines: Map<string, ProviderSubagentTimelineState>;
|
timelines: Map<string, ProviderSubagentTimelineState>;
|
||||||
|
hiddenFromTrack: Set<string>;
|
||||||
|
hideFinishedForParent(serverId: string, parentAgentId: string): void;
|
||||||
replaceList(
|
replaceList(
|
||||||
serverId: string,
|
serverId: string,
|
||||||
parentAgentId: string,
|
parentAgentId: string,
|
||||||
@@ -193,14 +195,32 @@ function buildTimelineResponseRows(
|
|||||||
export const useProviderSubagentStore = create<ProviderSubagentState>((set) => ({
|
export const useProviderSubagentStore = create<ProviderSubagentState>((set) => ({
|
||||||
descriptors: new Map(),
|
descriptors: new Map(),
|
||||||
timelines: new Map(),
|
timelines: new Map(),
|
||||||
|
hiddenFromTrack: new Set(),
|
||||||
|
hideFinishedForParent(serverId, parentAgentId) {
|
||||||
|
set((state) => {
|
||||||
|
const prefix = parentPrefix(serverId, parentAgentId);
|
||||||
|
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||||
|
for (const [key, subagent] of state.descriptors) {
|
||||||
|
if (key.startsWith(prefix) && subagent.status !== "running") {
|
||||||
|
hiddenFromTrack.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { hiddenFromTrack };
|
||||||
|
});
|
||||||
|
},
|
||||||
replaceList(serverId, parentAgentId, subagents) {
|
replaceList(serverId, parentAgentId, subagents) {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const prefix = parentPrefix(serverId, parentAgentId);
|
const prefix = parentPrefix(serverId, parentAgentId);
|
||||||
const descriptors = new Map(
|
const descriptors = new Map(
|
||||||
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
|
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
|
||||||
);
|
);
|
||||||
|
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||||
for (const subagent of subagents) {
|
for (const subagent of subagents) {
|
||||||
descriptors.set(providerSubagentKey(serverId, parentAgentId, subagent.id), subagent);
|
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
|
||||||
|
descriptors.set(key, subagent);
|
||||||
|
if (subagent.status === "running") {
|
||||||
|
hiddenFromTrack.delete(key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const retainedKeys = new Set(descriptors.keys());
|
const retainedKeys = new Set(descriptors.keys());
|
||||||
const timelines = new Map(
|
const timelines = new Map(
|
||||||
@@ -217,7 +237,7 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { descriptors, timelines };
|
return { descriptors, timelines, hiddenFromTrack };
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
applyUpdate(serverId, payload) {
|
applyUpdate(serverId, payload) {
|
||||||
@@ -229,8 +249,12 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
|||||||
payload.subagent.id,
|
payload.subagent.id,
|
||||||
);
|
);
|
||||||
const descriptors = new Map(state.descriptors);
|
const descriptors = new Map(state.descriptors);
|
||||||
|
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||||
const previous = descriptors.get(key);
|
const previous = descriptors.get(key);
|
||||||
descriptors.set(key, payload.subagent);
|
descriptors.set(key, payload.subagent);
|
||||||
|
if (payload.subagent.status === "running") {
|
||||||
|
hiddenFromTrack.delete(key);
|
||||||
|
}
|
||||||
let timelines = state.timelines;
|
let timelines = state.timelines;
|
||||||
const current = state.timelines.get(key);
|
const current = state.timelines.get(key);
|
||||||
if (current && previous?.status !== payload.subagent.status) {
|
if (current && previous?.status !== payload.subagent.status) {
|
||||||
@@ -240,13 +264,13 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
|||||||
buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder),
|
buildTimelineState(current.rows, current.epoch, payload.subagent, current.hasOlder),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { descriptors, timelines };
|
return { descriptors, timelines, hiddenFromTrack };
|
||||||
}
|
}
|
||||||
if (payload.kind === "remove") {
|
if (payload.kind === "remove") {
|
||||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||||
const descriptors = new Map(state.descriptors);
|
const descriptors = new Map(state.descriptors);
|
||||||
const timelines = new Map(state.timelines);
|
|
||||||
descriptors.delete(key);
|
descriptors.delete(key);
|
||||||
|
const timelines = new Map(state.timelines);
|
||||||
timelines.delete(key);
|
timelines.delete(key);
|
||||||
return { descriptors, timelines };
|
return { descriptors, timelines };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,11 @@ function setAgents(agents: Agent[]): void {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
useSessionStore.getState().clearSession(SERVER_ID);
|
useSessionStore.getState().clearSession(SERVER_ID);
|
||||||
useProviderSubagentStore.setState({ descriptors: new Map(), timelines: new Map() });
|
useProviderSubagentStore.setState({
|
||||||
|
descriptors: new Map(),
|
||||||
|
timelines: new Map(),
|
||||||
|
hiddenFromTrack: new Set(),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("selectSubagentsForParent", () => {
|
describe("selectSubagentsForParent", () => {
|
||||||
@@ -90,6 +94,34 @@ describe("selectSubagentsForParent", () => {
|
|||||||
).toEqual(["provider-child"]);
|
).toEqual(["provider-child"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("hides locally dismissed provider children while retaining their descriptor", () => {
|
||||||
|
const store = useProviderSubagentStore.getState();
|
||||||
|
store.applyUpdate(SERVER_ID, {
|
||||||
|
kind: "upsert",
|
||||||
|
subagent: {
|
||||||
|
id: "provider-child",
|
||||||
|
parentAgentId: "parent-a",
|
||||||
|
provider: "codex",
|
||||||
|
title: "Provider child",
|
||||||
|
description: null,
|
||||||
|
status: "completed",
|
||||||
|
createdAt: "2026-03-08T10:01:00.000Z",
|
||||||
|
updatedAt: "2026-03-08T10:02:00.000Z",
|
||||||
|
toolCallId: "call-1",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
store.hideFinishedForParent(SERVER_ID, "parent-a");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
selectProviderSubagentsForParent(
|
||||||
|
useProviderSubagentStore.getState(),
|
||||||
|
{ serverId: SERVER_ID, parentAgentId: "parent-a" },
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
).toEqual([]);
|
||||||
|
expect(useProviderSubagentStore.getState().descriptors.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns only non-archived children for the requested parent", () => {
|
it("returns only non-archived children for the requested parent", () => {
|
||||||
setAgents([
|
setAgents([
|
||||||
makeAgent({ id: "parent-a" }),
|
makeAgent({ id: "parent-a" }),
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ export function selectProviderSubagentsForParent(
|
|||||||
const rows: ProviderSubagentRow[] = [];
|
const rows: ProviderSubagentRow[] = [];
|
||||||
const prefix = `${params.serverId}\0${params.parentAgentId}\0`;
|
const prefix = `${params.serverId}\0${params.parentAgentId}\0`;
|
||||||
for (const [key, subagent] of state.descriptors) {
|
for (const [key, subagent] of state.descriptors) {
|
||||||
if (!key.startsWith(prefix)) continue;
|
if (!key.startsWith(prefix) || state.hiddenFromTrack.has(key)) continue;
|
||||||
rows.push({
|
rows.push({
|
||||||
kind: "provider",
|
kind: "provider",
|
||||||
id: subagent.id,
|
id: subagent.id,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { PaseoSubagentRow, SubagentRow } from "./select";
|
import type { PaseoSubagentRow, SubagentRow } from "./select";
|
||||||
import {
|
import {
|
||||||
buildSubagentRowPresentationData,
|
buildSubagentRowPresentationData,
|
||||||
|
countFinishedSubagents,
|
||||||
formatHeaderLabel,
|
formatHeaderLabel,
|
||||||
resolveRowLabel,
|
resolveRowLabel,
|
||||||
} from "./track-presentation";
|
} from "./track-presentation";
|
||||||
@@ -72,6 +73,41 @@ describe("formatHeaderLabel", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("countFinishedSubagents", () => {
|
||||||
|
it("counts only terminal provider-owned 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(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("resolveRowLabel", () => {
|
describe("resolveRowLabel", () => {
|
||||||
it("returns null when title is not a string", () => {
|
it("returns null when title is not a string", () => {
|
||||||
expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null);
|
expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null);
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string {
|
|||||||
return parts.join(" · ");
|
return parts.join(" · ");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function countFinishedSubagents(rows: readonly SubagentRow[]): number {
|
||||||
|
return rows.filter((row) => row.kind === "provider" && row.status !== "running").length;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveRowLabel(title: SubagentRow["title"]): string | null {
|
export function resolveRowLabel(title: SubagentRow["title"]): string | null {
|
||||||
if (typeof title !== "string") {
|
if (typeof title !== "string") {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -13,7 +13,11 @@ import {
|
|||||||
} from "@/screens/workspace/workspace-tab-presentation";
|
} from "@/screens/workspace/workspace-tab-presentation";
|
||||||
import type { Theme } from "@/styles/theme";
|
import type { Theme } from "@/styles/theme";
|
||||||
import type { SubagentRow } from "./select";
|
import type { SubagentRow } from "./select";
|
||||||
import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-presentation";
|
import {
|
||||||
|
buildSubagentRowPresentationData,
|
||||||
|
countFinishedSubagents,
|
||||||
|
formatHeaderLabel,
|
||||||
|
} from "./track-presentation";
|
||||||
|
|
||||||
const ThemedArchive = withUnistyles(Archive);
|
const ThemedArchive = withUnistyles(Archive);
|
||||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||||
@@ -30,6 +34,7 @@ export interface SubagentsTrackProps {
|
|||||||
onOpenSubagent: (id: string) => void;
|
onOpenSubagent: (id: string) => void;
|
||||||
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
||||||
onArchiveSubagent: (id: string) => void;
|
onArchiveSubagent: (id: string) => void;
|
||||||
|
onArchiveFinished?: () => void;
|
||||||
onDetachSubagent?: (id: string) => void;
|
onDetachSubagent?: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,8 +52,10 @@ export function SubagentsTrack({
|
|||||||
onOpenSubagent,
|
onOpenSubagent,
|
||||||
onOpenProviderSubagent,
|
onOpenProviderSubagent,
|
||||||
onArchiveSubagent,
|
onArchiveSubagent,
|
||||||
|
onArchiveFinished,
|
||||||
onDetachSubagent,
|
onDetachSubagent,
|
||||||
}: SubagentsTrackProps): ReactElement | null {
|
}: SubagentsTrackProps): ReactElement | null {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
|
||||||
const toggleExpanded = useCallback(() => {
|
const toggleExpanded = useCallback(() => {
|
||||||
@@ -62,10 +69,13 @@ export function SubagentsTrack({
|
|||||||
|
|
||||||
const headerStyle = useCallback(
|
const headerStyle = useCallback(
|
||||||
({ hovered, pressed }: PressableStateCallbackType) => [
|
({ hovered, pressed }: PressableStateCallbackType) => [
|
||||||
styles.header,
|
styles.headerToggle,
|
||||||
expanded ? styles.headerDivider : styles.headerCollapsed,
|
|
||||||
(hovered || pressed) && styles.headerActive,
|
(hovered || pressed) && styles.headerActive,
|
||||||
],
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const headerContainerStyle = useMemo(
|
||||||
|
() => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed],
|
||||||
[expanded],
|
[expanded],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -74,27 +84,42 @@ export function SubagentsTrack({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headerLabel = formatHeaderLabel(rows);
|
const headerLabel = formatHeaderLabel(rows);
|
||||||
|
const finishedCount = countFinishedSubagents(rows);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.outer} testID="subagents-track">
|
<View style={styles.outer} testID="subagents-track">
|
||||||
<View style={styles.track}>
|
<View style={styles.track}>
|
||||||
<View style={surfaceStyle}>
|
<View style={surfaceStyle}>
|
||||||
<Pressable
|
<View style={headerContainerStyle}>
|
||||||
accessibilityRole="button"
|
<Pressable
|
||||||
accessibilityLabel={headerLabel}
|
accessibilityRole="button"
|
||||||
testID="subagents-track-header"
|
accessibilityLabel={headerLabel}
|
||||||
onPress={toggleExpanded}
|
testID="subagents-track-header"
|
||||||
style={headerStyle}
|
onPress={toggleExpanded}
|
||||||
>
|
style={headerStyle}
|
||||||
{expanded ? (
|
>
|
||||||
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
|
{expanded ? (
|
||||||
) : (
|
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
|
||||||
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
|
) : (
|
||||||
)}
|
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
|
||||||
<Text style={styles.headerLabel} numberOfLines={1}>
|
)}
|
||||||
{headerLabel}
|
<Text style={styles.headerLabel} numberOfLines={1}>
|
||||||
</Text>
|
{headerLabel}
|
||||||
</Pressable>
|
</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 ? (
|
{expanded ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
style={styles.scroll}
|
style={styles.scroll}
|
||||||
@@ -306,12 +331,22 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
header: {
|
header: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
headerToggle: {
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingLeft: theme.spacing[3],
|
||||||
|
paddingRight: theme.spacing[1],
|
||||||
paddingVertical: theme.spacing[2],
|
paddingVertical: theme.spacing[2],
|
||||||
},
|
},
|
||||||
|
headerAction: {
|
||||||
|
paddingRight: theme.spacing[2],
|
||||||
|
},
|
||||||
headerCollapsed: {
|
headerCollapsed: {
|
||||||
paddingBottom: theme.spacing[6],
|
paddingBottom: theme.spacing[4],
|
||||||
},
|
},
|
||||||
headerActive: {
|
headerActive: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { useProviderSubagentStore } from "./provider-store";
|
||||||
|
|
||||||
|
export interface UseHideFinishedProviderSubagentsInput {
|
||||||
|
serverId: string;
|
||||||
|
parentAgentId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHideFinishedProviderSubagents({
|
||||||
|
serverId,
|
||||||
|
parentAgentId,
|
||||||
|
}: UseHideFinishedProviderSubagentsInput): () => void {
|
||||||
|
return useCallback(() => {
|
||||||
|
useProviderSubagentStore.getState().hideFinishedForParent(serverId, parentAgentId);
|
||||||
|
}, [parentAgentId, serverId]);
|
||||||
|
}
|
||||||
@@ -5036,6 +5036,7 @@ function readClaudeSidechainHistory(historyPath: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface ClaudeHistoricalSubagentToolCall {
|
interface ClaudeHistoricalSubagentToolCall {
|
||||||
|
name?: string;
|
||||||
subagentType?: string;
|
subagentType?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
@@ -5057,9 +5058,11 @@ function readClaudeHistoricalSubagentToolCalls(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const input = toObjectRecord(block.input);
|
const input = toObjectRecord(block.input);
|
||||||
|
const name = readNonEmptyString(input?.name);
|
||||||
const subagentType = readNonEmptyString(input?.subagent_type);
|
const subagentType = readNonEmptyString(input?.subagent_type);
|
||||||
const description = readNonEmptyString(input?.description);
|
const description = readNonEmptyString(input?.description);
|
||||||
toolCalls.set(block.id, {
|
toolCalls.set(block.id, {
|
||||||
|
...(name ? { name } : {}),
|
||||||
...(subagentType ? { subagentType } : {}),
|
...(subagentType ? { subagentType } : {}),
|
||||||
...(description ? { description } : {}),
|
...(description ? { description } : {}),
|
||||||
});
|
});
|
||||||
@@ -5121,6 +5124,12 @@ function buildClaudePersistedSidechainEvents(
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveClaudeHistoricalSubagentTitle(
|
||||||
|
toolCall: ClaudeHistoricalSubagentToolCall | undefined,
|
||||||
|
): string {
|
||||||
|
return toolCall?.name ?? toolCall?.subagentType ?? "Claude subagent";
|
||||||
|
}
|
||||||
|
|
||||||
function buildClaudePersistedSidechainAgentEvents(
|
function buildClaudePersistedSidechainAgentEvents(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
entries: ClaudeHistoryEntry[],
|
entries: ClaudeHistoryEntry[],
|
||||||
@@ -5139,7 +5148,7 @@ function buildClaudePersistedSidechainAgentEvents(
|
|||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id,
|
id,
|
||||||
title: toolCall?.subagentType ?? "Claude subagent",
|
title: resolveClaudeHistoricalSubagentTitle(toolCall),
|
||||||
description: toolCall?.description ?? null,
|
description: toolCall?.description ?? null,
|
||||||
status: "running",
|
status: "running",
|
||||||
toolCallId: result?.toolCallId ?? null,
|
toolCallId: result?.toolCallId ?? null,
|
||||||
|
|||||||
@@ -139,7 +139,11 @@ describe("ClaudeAgentSession history replay regression", () => {
|
|||||||
type: "tool_use",
|
type: "tool_use",
|
||||||
id: "history-task-call",
|
id: "history-task-call",
|
||||||
name: "Agent",
|
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",
|
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({
|
expect(historyEvents).toContainEqual({
|
||||||
type: "provider_subagent",
|
type: "provider_subagent",
|
||||||
provider: "claude",
|
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 {
|
interface SubAgentActivityState {
|
||||||
|
name?: string;
|
||||||
subAgentType?: string;
|
subAgentType?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
actions: SubAgentActionEntry[];
|
actions: SubAgentActionEntry[];
|
||||||
@@ -130,7 +131,7 @@ export class ClaudeSidechainTracker {
|
|||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id: parentToolUseId,
|
id: parentToolUseId,
|
||||||
title: state.subAgentType ?? "Claude subagent",
|
title: state.name ?? state.subAgentType ?? "Claude subagent",
|
||||||
description: state.description ?? null,
|
description: state.description ?? null,
|
||||||
status: "running",
|
status: "running",
|
||||||
toolCallId: parentToolUseId,
|
toolCallId: parentToolUseId,
|
||||||
@@ -163,7 +164,7 @@ export class ClaudeSidechainTracker {
|
|||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id,
|
id,
|
||||||
title: state.subAgentType ?? "Claude subagent",
|
title: state.name ?? state.subAgentType ?? "Claude subagent",
|
||||||
description: state.description ?? null,
|
description: state.description ?? null,
|
||||||
status,
|
status,
|
||||||
toolCallId: id,
|
toolCallId: id,
|
||||||
@@ -185,7 +186,7 @@ export class ClaudeSidechainTracker {
|
|||||||
event: {
|
event: {
|
||||||
type: "upsert",
|
type: "upsert",
|
||||||
id,
|
id,
|
||||||
title: state.subAgentType ?? "Claude subagent",
|
title: state.name ?? state.subAgentType ?? "Claude subagent",
|
||||||
description: state.description ?? null,
|
description: state.description ?? null,
|
||||||
status,
|
status,
|
||||||
toolCallId: id,
|
toolCallId: id,
|
||||||
@@ -267,10 +268,15 @@ export class ClaudeSidechainTracker {
|
|||||||
parentToolUseId: string,
|
parentToolUseId: string,
|
||||||
): boolean {
|
): boolean {
|
||||||
const taskInput = this.getToolInput(parentToolUseId);
|
const taskInput = this.getToolInput(parentToolUseId);
|
||||||
|
const nextName = this.normalizeSubAgentText(taskInput?.name);
|
||||||
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
|
const nextSubAgentType = this.normalizeSubAgentText(taskInput?.subagent_type);
|
||||||
const nextDescription = this.normalizeSubAgentText(taskInput?.description);
|
const nextDescription = this.normalizeSubAgentText(taskInput?.description);
|
||||||
|
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
if (nextName && nextName !== state.name) {
|
||||||
|
state.name = nextName;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
if (nextSubAgentType && nextSubAgentType !== state.subAgentType) {
|
if (nextSubAgentType && nextSubAgentType !== state.subAgentType) {
|
||||||
state.subAgentType = nextSubAgentType;
|
state.subAgentType = nextSubAgentType;
|
||||||
changed = true;
|
changed = true;
|
||||||
|
|||||||
@@ -1847,6 +1847,60 @@ describe("Codex app-server provider", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("updates a registered child with its later native activity name", () => {
|
||||||
|
const session = createSession();
|
||||||
|
const events: AgentStreamEvent[] = [];
|
||||||
|
session.subscribe((event) => events.push(event));
|
||||||
|
|
||||||
|
asInternals(session).handleNotification("item/completed", {
|
||||||
|
threadId: "test-thread",
|
||||||
|
item: {
|
||||||
|
type: "collabAgentToolCall",
|
||||||
|
id: "call-native-name-later",
|
||||||
|
tool: "spawnAgent",
|
||||||
|
status: "completed",
|
||||||
|
prompt: "Inspect the repository.",
|
||||||
|
receiverThreadIds: ["child-native-name-later"],
|
||||||
|
agentsStates: {
|
||||||
|
"child-native-name-later": { status: "pendingInit", message: null },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
asInternals(session).handleNotification("item/started", {
|
||||||
|
threadId: "test-thread",
|
||||||
|
item: {
|
||||||
|
type: "subAgentActivity",
|
||||||
|
id: "activity-native-name-later",
|
||||||
|
kind: "started",
|
||||||
|
agentThreadId: "child-native-name-later",
|
||||||
|
agentPath: "/root/research/investigator",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "provider_subagent",
|
||||||
|
provider: "codex",
|
||||||
|
event: expect.objectContaining({
|
||||||
|
type: "upsert",
|
||||||
|
id: "child-native-name-later",
|
||||||
|
title: "Research / Investigator",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(events.at(-1)).toMatchObject({
|
||||||
|
type: "timeline",
|
||||||
|
item: {
|
||||||
|
callId: "call-native-name-later",
|
||||||
|
detail: {
|
||||||
|
type: "sub_agent",
|
||||||
|
subAgentType: "Research / Investigator",
|
||||||
|
description: "Inspect the repository.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("renders child MCP image results in the provider subagent timeline", () => {
|
test("renders child MCP image results in the provider subagent timeline", () => {
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
const events: AgentStreamEvent[] = [];
|
const events: AgentStreamEvent[] = [];
|
||||||
@@ -2500,7 +2554,7 @@ describe("Codex app-server provider", () => {
|
|||||||
status: "running",
|
status: "running",
|
||||||
detail: {
|
detail: {
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
description: "/root/legacy-only-child",
|
description: "legacy-only-child",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -2879,6 +2933,13 @@ describe("Codex app-server provider", () => {
|
|||||||
receiverThreadIds: ["legacy-child-thread"],
|
receiverThreadIds: ["legacy-child-thread"],
|
||||||
agentsStates: { "legacy-child-thread": { status: "completed" } },
|
agentsStates: { "legacy-child-thread": { status: "completed" } },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
type: "subAgentActivity",
|
||||||
|
id: "legacy-native-name-history",
|
||||||
|
kind: "started",
|
||||||
|
agentThreadId: "legacy-child-thread",
|
||||||
|
agentPath: "/root/sentinel_child",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "subAgentActivity",
|
type: "subAgentActivity",
|
||||||
id: "v2-spawn-history",
|
id: "v2-spawn-history",
|
||||||
@@ -2905,7 +2966,12 @@ describe("Codex app-server provider", () => {
|
|||||||
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
|
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
|
||||||
),
|
),
|
||||||
).toMatchObject([
|
).toMatchObject([
|
||||||
{ type: "upsert", id: "legacy-child-thread", status: "completed" },
|
{
|
||||||
|
type: "upsert",
|
||||||
|
id: "legacy-child-thread",
|
||||||
|
status: "completed",
|
||||||
|
title: "Sentinel child",
|
||||||
|
},
|
||||||
{ type: "upsert", id: "v2-child-thread", status: "completed" },
|
{ type: "upsert", id: "v2-child-thread", status: "completed" },
|
||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
@@ -2940,12 +3006,16 @@ describe("Codex app-server provider", () => {
|
|||||||
{
|
{
|
||||||
callId: "legacy-spawn-history",
|
callId: "legacy-spawn-history",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
detail: { type: "sub_agent", description: "Legacy child" },
|
detail: {
|
||||||
|
type: "sub_agent",
|
||||||
|
description: "Legacy child",
|
||||||
|
subAgentType: "Sentinel child",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
callId: "v2-spawn-history",
|
callId: "v2-spawn-history",
|
||||||
status: "completed",
|
status: "completed",
|
||||||
detail: { type: "sub_agent", description: "/root/v2-child" },
|
detail: { type: "sub_agent", description: "v2-child" },
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -3067,7 +3137,7 @@ describe("Codex app-server provider", () => {
|
|||||||
status: "canceled",
|
status: "canceled",
|
||||||
detail: expect.objectContaining({
|
detail: expect.objectContaining({
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
description: "/root/history-child",
|
description: "history-child",
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1668,14 +1668,22 @@ function updateHistoricalSubAgentActivity(
|
|||||||
timeline: PersistedTimelineEntry[],
|
timeline: PersistedTimelineEntry[],
|
||||||
index: number,
|
index: number,
|
||||||
kind: CodexSubAgentActivity["kind"],
|
kind: CodexSubAgentActivity["kind"],
|
||||||
|
subAgentType?: string,
|
||||||
): void {
|
): void {
|
||||||
const existing = timeline[index];
|
const existing = timeline[index];
|
||||||
if (existing?.item.type !== "tool_call") {
|
if (existing?.item.type !== "tool_call") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const settledItem = settleHistoricalSubAgentActivity(existing.item, kind);
|
||||||
timeline[index] = {
|
timeline[index] = {
|
||||||
...existing,
|
...existing,
|
||||||
item: settleHistoricalSubAgentActivity(existing.item, kind),
|
item:
|
||||||
|
subAgentType && settledItem.detail.type === "sub_agent"
|
||||||
|
? {
|
||||||
|
...settledItem,
|
||||||
|
detail: { ...settledItem.detail, subAgentType },
|
||||||
|
}
|
||||||
|
: settledItem,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1869,10 +1877,15 @@ async function loadCodexThreadHistoryTimeline(params: {
|
|||||||
historicalSubAgentActivity.agentThreadId,
|
historicalSubAgentActivity.agentThreadId,
|
||||||
);
|
);
|
||||||
if (existingIndex !== undefined) {
|
if (existingIndex !== undefined) {
|
||||||
|
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
|
||||||
updateHistoricalSubAgentActivity(
|
updateHistoricalSubAgentActivity(
|
||||||
timeline,
|
timeline,
|
||||||
existingIndex,
|
existingIndex,
|
||||||
historicalSubAgentActivity.kind,
|
historicalSubAgentActivity.kind,
|
||||||
|
activityTimelineItem?.type === "tool_call" &&
|
||||||
|
activityTimelineItem.detail.type === "sub_agent"
|
||||||
|
? activityTimelineItem.detail.subAgentType
|
||||||
|
: undefined,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -4843,6 +4856,21 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (activity.id) {
|
if (activity.id) {
|
||||||
state.activityItemIds.add(activity.id);
|
state.activityItemIds.add(activity.id);
|
||||||
}
|
}
|
||||||
|
const activityToolCall = mapCodexToolCallFromThreadItem(rawItem, {
|
||||||
|
cwd: this.config.cwd ?? null,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
activityToolCall?.detail.type === "sub_agent" &&
|
||||||
|
state.toolCall.detail.type === "sub_agent"
|
||||||
|
) {
|
||||||
|
state.toolCall = {
|
||||||
|
...state.toolCall,
|
||||||
|
detail: {
|
||||||
|
...state.toolCall.detail,
|
||||||
|
subAgentType: activityToolCall.detail.subAgentType,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
this.emitSubAgentActivityUpdate(
|
this.emitSubAgentActivityUpdate(
|
||||||
callId,
|
callId,
|
||||||
activity.kind === "interrupted" ? "canceled" : "running",
|
activity.kind === "interrupted" ? "canceled" : "running",
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ describe("codex tool-call mapper", () => {
|
|||||||
id: `activity-${kind}`,
|
id: `activity-${kind}`,
|
||||||
kind,
|
kind,
|
||||||
agentThreadId: "child-thread-1",
|
agentThreadId: "child-thread-1",
|
||||||
agentPath: "/root/investigator",
|
agentPath: "/root/research/investigator",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(item).toEqual({
|
expect(item).toEqual({
|
||||||
@@ -245,8 +245,8 @@ describe("codex tool-call mapper", () => {
|
|||||||
error: null,
|
error: null,
|
||||||
detail: {
|
detail: {
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "Sub-agent",
|
subAgentType: "Research / Investigator",
|
||||||
description: "/root/investigator",
|
description: "research/investigator",
|
||||||
log: "",
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
},
|
},
|
||||||
@@ -267,6 +267,60 @@ describe("codex tool-call mapper", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("humanizes a subagent task name for display", () => {
|
||||||
|
const item = mapCodexToolCallFromThreadItem({
|
||||||
|
type: "subAgentActivity",
|
||||||
|
id: "activity-human-name",
|
||||||
|
kind: "started",
|
||||||
|
agentThreadId: "child-thread-human-name",
|
||||||
|
agentPath: "/root/hello_one",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
detail: {
|
||||||
|
type: "sub_agent",
|
||||||
|
subAgentType: "Hello one",
|
||||||
|
description: "hello_one",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only the final segment of a subAgentActivity path outside the root namespace", () => {
|
||||||
|
const item = mapCodexToolCallFromThreadItem({
|
||||||
|
type: "subAgentActivity",
|
||||||
|
id: "activity-external-path",
|
||||||
|
kind: "started",
|
||||||
|
agentThreadId: "child-thread-external-path",
|
||||||
|
agentPath: "/tmp/native/investigator",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
detail: {
|
||||||
|
type: "sub_agent",
|
||||||
|
subAgentType: "Investigator",
|
||||||
|
description: "investigator",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses only the final segment of a Windows subAgentActivity path", () => {
|
||||||
|
const item = mapCodexToolCallFromThreadItem({
|
||||||
|
type: "subAgentActivity",
|
||||||
|
id: "activity-windows-path",
|
||||||
|
kind: "started",
|
||||||
|
agentThreadId: "child-thread-windows-path",
|
||||||
|
agentPath: "C:\\Users\\dev\\agents\\investigator",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
detail: {
|
||||||
|
type: "sub_agent",
|
||||||
|
subAgentType: "Investigator",
|
||||||
|
description: "investigator",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("does not fail a collabAgentToolCall from child error state alone", () => {
|
it("does not fail a collabAgentToolCall from child error state alone", () => {
|
||||||
const item = mapCodexToolCallFromThreadItem({
|
const item = mapCodexToolCallFromThreadItem({
|
||||||
type: "collabAgentToolCall",
|
type: "collabAgentToolCall",
|
||||||
|
|||||||
@@ -993,6 +993,24 @@ function mapCollabAgentToolCallItem(
|
|||||||
function mapSubAgentActivityItem(
|
function mapSubAgentActivityItem(
|
||||||
item: z.infer<typeof CodexSubAgentActivityItemSchema>,
|
item: z.infer<typeof CodexSubAgentActivityItemSchema>,
|
||||||
): ToolCallTimelineItem {
|
): ToolCallTimelineItem {
|
||||||
|
let nativeName = item.agentPath;
|
||||||
|
if (nativeName === "/root") {
|
||||||
|
nativeName = "";
|
||||||
|
} else if (nativeName.startsWith("/root/")) {
|
||||||
|
nativeName = nativeName.slice("/root/".length);
|
||||||
|
} else if (/[\\/]/.test(nativeName)) {
|
||||||
|
nativeName = nativeName.slice(
|
||||||
|
Math.max(nativeName.lastIndexOf("/"), nativeName.lastIndexOf("\\")) + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const description = nativeName;
|
||||||
|
nativeName = nativeName
|
||||||
|
.split("/")
|
||||||
|
.map((segment) => segment.replace(/[_-]+/g, " ").trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((segment) => segment[0]?.toUpperCase() + segment.slice(1))
|
||||||
|
.join(" / ");
|
||||||
|
nativeName ||= "Sub-agent";
|
||||||
return {
|
return {
|
||||||
type: "tool_call",
|
type: "tool_call",
|
||||||
callId: item.id,
|
callId: item.id,
|
||||||
@@ -1001,8 +1019,8 @@ function mapSubAgentActivityItem(
|
|||||||
error: null,
|
error: null,
|
||||||
detail: {
|
detail: {
|
||||||
type: "sub_agent",
|
type: "sub_agent",
|
||||||
subAgentType: "Sub-agent",
|
subAgentType: nativeName,
|
||||||
description: item.agentPath,
|
description,
|
||||||
log: "",
|
log: "",
|
||||||
actions: [],
|
actions: [],
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user