* 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
378 lines
10 KiB
TypeScript
378 lines
10 KiB
TypeScript
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { selectProviderSubagentsForParent, selectSubagentsForParent } from "./select";
|
|
import { useProviderSubagentStore } from "./provider-store";
|
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
|
|
|
const SERVER_ID = "server-1";
|
|
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
|
|
const EMPTY_PENDING_ARCHIVE_IDS = new Set<string>();
|
|
|
|
const AGENT_DEFAULTS: Agent = {
|
|
serverId: SERVER_ID,
|
|
id: "agent",
|
|
provider: "codex",
|
|
status: "idle",
|
|
createdAt: AGENT_TIMESTAMP,
|
|
updatedAt: AGENT_TIMESTAMP,
|
|
lastUserMessageAt: null,
|
|
lastActivityAt: AGENT_TIMESTAMP,
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
runtimeInfo: undefined,
|
|
lastUsage: undefined,
|
|
lastError: null,
|
|
title: "Agent",
|
|
cwd: "/tmp/project",
|
|
model: null,
|
|
features: undefined,
|
|
thinkingOptionId: undefined,
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
attentionTimestamp: null,
|
|
archivedAt: null,
|
|
parentAgentId: null,
|
|
labels: {},
|
|
projectPlacement: null,
|
|
};
|
|
|
|
function makeAgent(input: Partial<Agent> & Pick<Agent, "id">): Agent {
|
|
return { ...AGENT_DEFAULTS, ...input };
|
|
}
|
|
|
|
function setAgents(agents: Agent[]): void {
|
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
|
useSessionStore
|
|
.getState()
|
|
.setAgents(SERVER_ID, new Map(agents.map((agent) => [agent.id, agent])));
|
|
}
|
|
|
|
afterEach(() => {
|
|
useSessionStore.getState().clearSession(SERVER_ID);
|
|
useProviderSubagentStore.setState({
|
|
descriptors: new Map(),
|
|
timelines: new Map(),
|
|
hiddenFromTrack: new Set(),
|
|
});
|
|
});
|
|
|
|
describe("selectSubagentsForParent", () => {
|
|
it("hides cached provider children when the host does not support them", () => {
|
|
useProviderSubagentStore.getState().applyUpdate(SERVER_ID, {
|
|
kind: "upsert",
|
|
subagent: {
|
|
id: "provider-child",
|
|
parentAgentId: "parent-a",
|
|
provider: "codex",
|
|
title: "Provider child",
|
|
description: null,
|
|
status: "completed",
|
|
createdAt: "2026-03-08T10:01:00.000Z",
|
|
updatedAt: "2026-03-08T10:02:00.000Z",
|
|
toolCallId: "call-1",
|
|
},
|
|
});
|
|
const params = { serverId: SERVER_ID, parentAgentId: "parent-a" };
|
|
|
|
expect(
|
|
selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, false),
|
|
).toEqual([]);
|
|
expect(
|
|
selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, true).map(
|
|
(row) => row.id,
|
|
),
|
|
).toEqual(["provider-child"]);
|
|
});
|
|
|
|
it("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" }),
|
|
makeAgent({ id: "child-a", parentAgentId: "parent-a" }),
|
|
makeAgent({
|
|
id: "archived-child",
|
|
parentAgentId: "parent-a",
|
|
archivedAt: new Date("2026-03-08T12:00:00.000Z"),
|
|
}),
|
|
]);
|
|
|
|
const rows = selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent-a",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
);
|
|
|
|
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
|
});
|
|
|
|
it("excludes siblings, unrelated agents, and grandchildren", () => {
|
|
setAgents([
|
|
makeAgent({ id: "parent-a" }),
|
|
makeAgent({ id: "parent-b" }),
|
|
makeAgent({ id: "child-a", parentAgentId: "parent-a" }),
|
|
makeAgent({ id: "sibling-b", parentAgentId: "parent-b" }),
|
|
makeAgent({ id: "grandchild-a", parentAgentId: "child-a" }),
|
|
makeAgent({ id: "unrelated" }),
|
|
]);
|
|
|
|
const rows = selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent-a",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
);
|
|
|
|
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
|
});
|
|
|
|
it("shows only direct children for each parent", () => {
|
|
setAgents([
|
|
makeAgent({ id: "parent" }),
|
|
makeAgent({ id: "child", parentAgentId: "parent" }),
|
|
makeAgent({ id: "grandchild", parentAgentId: "child" }),
|
|
]);
|
|
|
|
const parentRows = selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
);
|
|
const childRows = selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "child",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
);
|
|
|
|
expect(parentRows.map((row) => row.id)).toEqual(["child"]);
|
|
expect(childRows.map((row) => row.id)).toEqual(["grandchild"]);
|
|
});
|
|
|
|
it("sorts by createdAt ascending", () => {
|
|
setAgents([
|
|
makeAgent({ id: "parent" }),
|
|
makeAgent({
|
|
id: "third",
|
|
parentAgentId: "parent",
|
|
createdAt: new Date("2026-03-08T10:03:00.000Z"),
|
|
}),
|
|
makeAgent({
|
|
id: "first",
|
|
parentAgentId: "parent",
|
|
createdAt: new Date("2026-03-08T10:01:00.000Z"),
|
|
}),
|
|
makeAgent({
|
|
id: "second",
|
|
parentAgentId: "parent",
|
|
createdAt: new Date("2026-03-08T10:02:00.000Z"),
|
|
}),
|
|
]);
|
|
|
|
const rows = selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
);
|
|
|
|
expect(rows.map((row) => row.id)).toEqual(["first", "second", "third"]);
|
|
});
|
|
|
|
it("maps only row-rendered fields and does not expose onOpen", () => {
|
|
const createdAt = new Date("2026-03-08T10:01:00.000Z");
|
|
setAgents([
|
|
makeAgent({ id: "parent" }),
|
|
makeAgent({
|
|
id: "child",
|
|
parentAgentId: "parent",
|
|
provider: "claude",
|
|
title: "Review child",
|
|
status: "running",
|
|
requiresAttention: true,
|
|
createdAt,
|
|
model: "should-not-leak",
|
|
cwd: "/private/project",
|
|
}),
|
|
]);
|
|
|
|
const rows = selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
);
|
|
|
|
expect(rows).toEqual([
|
|
{
|
|
kind: "paseo",
|
|
id: "child",
|
|
provider: "claude",
|
|
title: "Review child",
|
|
status: "running",
|
|
requiresAttention: true,
|
|
createdAt,
|
|
},
|
|
]);
|
|
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
|
|
"createdAt",
|
|
"id",
|
|
"kind",
|
|
"provider",
|
|
"requiresAttention",
|
|
"status",
|
|
"title",
|
|
]);
|
|
expect(rows[0]).not.toHaveProperty("onOpen");
|
|
expect(rows[0]).not.toHaveProperty("model");
|
|
expect(rows[0]).not.toHaveProperty("cwd");
|
|
});
|
|
|
|
it("moves a child when parentAgentId changes", () => {
|
|
const child = makeAgent({ id: "child", parentAgentId: "parent-a" });
|
|
setAgents([makeAgent({ id: "parent-a" }), makeAgent({ id: "parent-b" }), child]);
|
|
|
|
expect(
|
|
selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent-a",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
).map((row) => row.id),
|
|
).toEqual(["child"]);
|
|
expect(
|
|
selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent-b",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
).map((row) => row.id),
|
|
).toEqual([]);
|
|
|
|
setAgents([
|
|
makeAgent({ id: "parent-a" }),
|
|
makeAgent({ id: "parent-b" }),
|
|
{ ...child, parentAgentId: "parent-b" },
|
|
]);
|
|
|
|
expect(
|
|
selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent-a",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
).map((row) => row.id),
|
|
).toEqual([]);
|
|
expect(
|
|
selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent-b",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
).map((row) => row.id),
|
|
).toEqual(["child"]);
|
|
});
|
|
|
|
it("excludes children whose archive is pending", () => {
|
|
setAgents([
|
|
makeAgent({ id: "parent" }),
|
|
makeAgent({ id: "child-a", parentAgentId: "parent" }),
|
|
makeAgent({ id: "child-b", parentAgentId: "parent" }),
|
|
]);
|
|
|
|
const rows = selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent",
|
|
},
|
|
new Set(["child-b"]),
|
|
);
|
|
|
|
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
|
});
|
|
|
|
it("returns the shared empty array when pending archive hides the last child", () => {
|
|
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child", parentAgentId: "parent" })]);
|
|
|
|
const rows = selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent",
|
|
},
|
|
new Set(["child"]),
|
|
);
|
|
|
|
expect(rows).toEqual([]);
|
|
expect(rows).toBe(
|
|
selectSubagentsForParent(
|
|
useSessionStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "missing-parent",
|
|
},
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
),
|
|
);
|
|
});
|
|
});
|