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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -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. 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
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -1370,6 +1370,8 @@ export const ar: TranslationResources = {
|
||||
detachTooltip: "فصل الوكيل الفرعي",
|
||||
archiveAction: "أرشيف{{label}}",
|
||||
archiveTooltip: "أرشفة الوكيل الفرعي",
|
||||
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
|
||||
archiveFinishedTooltip: "أرشفة المكتملين",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1387,6 +1387,8 @@ export const ja: TranslationResources = {
|
||||
detachTooltip: "サブエージェントを切り離す",
|
||||
archiveAction: "{{label}}をアーカイブ",
|
||||
archiveTooltip: "サブエージェントをアーカイブ",
|
||||
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
|
||||
archiveFinishedTooltip: "完了した項目をアーカイブ",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -1401,6 +1401,8 @@ export const ru: TranslationResources = {
|
||||
detachTooltip: "Отсоединить субагент",
|
||||
archiveAction: "Архив{{label}}",
|
||||
archiveTooltip: "Архивный субагент",
|
||||
archiveFinishedAction: "Архивировать завершенные субагенты",
|
||||
archiveFinishedTooltip: "Архивировать завершенные",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -1354,6 +1354,8 @@ export const zhCN: TranslationResources = {
|
||||
detachTooltip: "分离 subagent",
|
||||
archiveAction: "归档 {{label}}",
|
||||
archiveTooltip: "归档 subagent",
|
||||
archiveFinishedAction: "归档已完成的 subagent",
|
||||
archiveFinishedTooltip: "归档已完成项",
|
||||
},
|
||||
panels: {
|
||||
draft: {
|
||||
|
||||
@@ -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 {
|
||||
useHideFinishedProviderSubagents,
|
||||
useArchiveSubagent,
|
||||
useDetachSubagent,
|
||||
useSubagentsForParent,
|
||||
} from "@/subagents";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -1390,6 +1395,10 @@ function ActiveAgentComposer({
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
});
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -1490,6 +1499,7 @@ function ActiveAgentComposer({
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onOpenProviderSubagent={handleOpenProviderSubagent}
|
||||
onArchiveSubagent={handleArchiveSubagent}
|
||||
onArchiveFinished={handleHideFinishedProviderSubagents}
|
||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||
/>
|
||||
<Composer
|
||||
|
||||
@@ -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 {
|
||||
useHideFinishedProviderSubagents,
|
||||
type UseHideFinishedProviderSubagentsInput,
|
||||
} from "./use-hide-finished-provider-subagents";
|
||||
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
||||
export { isWorkspaceRootAgent } from "./workspace-root-policy";
|
||||
|
||||
@@ -6,7 +6,11 @@ const PARENT_ID = "parent-1";
|
||||
const SUBAGENT_ID = "child-1";
|
||||
|
||||
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", () => {
|
||||
@@ -129,6 +133,123 @@ describe("provider subagent client store", () => {
|
||||
).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", () => {
|
||||
const store = useProviderSubagentStore.getState();
|
||||
store.applyUpdate(SERVER_ID, {
|
||||
|
||||
@@ -32,6 +32,8 @@ export interface ProviderSubagentTimelineState {
|
||||
interface ProviderSubagentState {
|
||||
descriptors: Map<string, ProviderSubagentDescriptorPayload>;
|
||||
timelines: Map<string, ProviderSubagentTimelineState>;
|
||||
hiddenFromTrack: Set<string>;
|
||||
hideFinishedForParent(serverId: string, parentAgentId: string): void;
|
||||
replaceList(
|
||||
serverId: string,
|
||||
parentAgentId: string,
|
||||
@@ -193,14 +195,32 @@ function buildTimelineResponseRows(
|
||||
export const useProviderSubagentStore = create<ProviderSubagentState>((set) => ({
|
||||
descriptors: 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) {
|
||||
set((state) => {
|
||||
const prefix = parentPrefix(serverId, parentAgentId);
|
||||
const descriptors = new Map(
|
||||
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
|
||||
);
|
||||
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||
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 timelines = new Map(
|
||||
@@ -217,7 +237,7 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
);
|
||||
}
|
||||
}
|
||||
return { descriptors, timelines };
|
||||
return { descriptors, timelines, hiddenFromTrack };
|
||||
});
|
||||
},
|
||||
applyUpdate(serverId, payload) {
|
||||
@@ -229,8 +249,12 @@ export const useProviderSubagentStore = create<ProviderSubagentState>((set) => (
|
||||
payload.subagent.id,
|
||||
);
|
||||
const descriptors = new Map(state.descriptors);
|
||||
const hiddenFromTrack = new Set(state.hiddenFromTrack);
|
||||
const previous = descriptors.get(key);
|
||||
descriptors.set(key, payload.subagent);
|
||||
if (payload.subagent.status === "running") {
|
||||
hiddenFromTrack.delete(key);
|
||||
}
|
||||
let timelines = state.timelines;
|
||||
const current = state.timelines.get(key);
|
||||
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),
|
||||
);
|
||||
}
|
||||
return { descriptors, timelines };
|
||||
return { descriptors, timelines, hiddenFromTrack };
|
||||
}
|
||||
if (payload.kind === "remove") {
|
||||
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
|
||||
const descriptors = new Map(state.descriptors);
|
||||
const timelines = new Map(state.timelines);
|
||||
descriptors.delete(key);
|
||||
const timelines = new Map(state.timelines);
|
||||
timelines.delete(key);
|
||||
return { descriptors, timelines };
|
||||
}
|
||||
|
||||
@@ -59,7 +59,11 @@ function setAgents(agents: Agent[]): void {
|
||||
|
||||
afterEach(() => {
|
||||
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", () => {
|
||||
@@ -90,6 +94,34 @@ describe("selectSubagentsForParent", () => {
|
||||
).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", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent-a" }),
|
||||
|
||||
@@ -91,7 +91,7 @@ export function selectProviderSubagentsForParent(
|
||||
const rows: ProviderSubagentRow[] = [];
|
||||
const prefix = `${params.serverId}\0${params.parentAgentId}\0`;
|
||||
for (const [key, subagent] of state.descriptors) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
if (!key.startsWith(prefix) || state.hiddenFromTrack.has(key)) continue;
|
||||
rows.push({
|
||||
kind: "provider",
|
||||
id: subagent.id,
|
||||
|
||||
@@ -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 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", () => {
|
||||
it("returns null when title is not a string", () => {
|
||||
expect(resolveRowLabel(null as unknown as SubagentRow["title"])).toBe(null);
|
||||
|
||||
@@ -48,6 +48,10 @@ export function formatHeaderLabel(rows: readonly SubagentRow[]): string {
|
||||
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 {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
|
||||
@@ -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,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 {
|
||||
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;
|
||||
|
||||
@@ -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", () => {
|
||||
const session = createSession();
|
||||
const events: AgentStreamEvent[] = [];
|
||||
@@ -2500,7 +2554,7 @@ describe("Codex app-server provider", () => {
|
||||
status: "running",
|
||||
detail: {
|
||||
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"],
|
||||
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",
|
||||
id: "v2-spawn-history",
|
||||
@@ -2905,7 +2966,12 @@ describe("Codex app-server provider", () => {
|
||||
event.type === "provider_subagent" && event.event.type === "upsert" ? [event.event] : [],
|
||||
),
|
||||
).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" },
|
||||
]);
|
||||
expect(
|
||||
@@ -2940,12 +3006,16 @@ describe("Codex app-server provider", () => {
|
||||
{
|
||||
callId: "legacy-spawn-history",
|
||||
status: "completed",
|
||||
detail: { type: "sub_agent", description: "Legacy child" },
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
description: "Legacy child",
|
||||
subAgentType: "Sentinel child",
|
||||
},
|
||||
},
|
||||
{
|
||||
callId: "v2-spawn-history",
|
||||
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",
|
||||
detail: expect.objectContaining({
|
||||
type: "sub_agent",
|
||||
description: "/root/history-child",
|
||||
description: "history-child",
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -1668,14 +1668,22 @@ function updateHistoricalSubAgentActivity(
|
||||
timeline: PersistedTimelineEntry[],
|
||||
index: number,
|
||||
kind: CodexSubAgentActivity["kind"],
|
||||
subAgentType?: string,
|
||||
): void {
|
||||
const existing = timeline[index];
|
||||
if (existing?.item.type !== "tool_call") {
|
||||
return;
|
||||
}
|
||||
const settledItem = settleHistoricalSubAgentActivity(existing.item, kind);
|
||||
timeline[index] = {
|
||||
...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,
|
||||
);
|
||||
if (existingIndex !== undefined) {
|
||||
const activityTimelineItem = threadItemToTimeline(item, { cwd: params.cwd });
|
||||
updateHistoricalSubAgentActivity(
|
||||
timeline,
|
||||
existingIndex,
|
||||
historicalSubAgentActivity.kind,
|
||||
activityTimelineItem?.type === "tool_call" &&
|
||||
activityTimelineItem.detail.type === "sub_agent"
|
||||
? activityTimelineItem.detail.subAgentType
|
||||
: undefined,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
@@ -4843,6 +4856,21 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (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(
|
||||
callId,
|
||||
activity.kind === "interrupted" ? "canceled" : "running",
|
||||
|
||||
@@ -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: "research/investigator",
|
||||
log: "",
|
||||
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", () => {
|
||||
const item = mapCodexToolCallFromThreadItem({
|
||||
type: "collabAgentToolCall",
|
||||
|
||||
@@ -993,6 +993,24 @@ function mapCollabAgentToolCallItem(
|
||||
function mapSubAgentActivityItem(
|
||||
item: z.infer<typeof CodexSubAgentActivityItemSchema>,
|
||||
): 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 {
|
||||
type: "tool_call",
|
||||
callId: item.id,
|
||||
@@ -1001,8 +1019,8 @@ function mapSubAgentActivityItem(
|
||||
error: null,
|
||||
detail: {
|
||||
type: "sub_agent",
|
||||
subAgentType: "Sub-agent",
|
||||
description: item.agentPath,
|
||||
subAgentType: nativeName,
|
||||
description,
|
||||
log: "",
|
||||
actions: [],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user