* feat(app): archive finished subagents together Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local. * test(app): clarify subagent archive assertions
388 lines
10 KiB
TypeScript
388 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",
|
|
activeTurn: null,
|
|
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,
|
|
subtitle: "Codex worker · 4.2k tokens",
|
|
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"]);
|
|
expect(
|
|
selectProviderSubagentsForParent(useProviderSubagentStore.getState(), params, true)[0]
|
|
?.subtitle,
|
|
).toBe("Codex worker · 4.2k tokens");
|
|
});
|
|
|
|
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.hideFromTrack(SERVER_ID, "parent-a", ["provider-child"]);
|
|
|
|
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",
|
|
description: null,
|
|
subtitle: null,
|
|
status: "running",
|
|
requiresAttention: true,
|
|
createdAt,
|
|
},
|
|
]);
|
|
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
|
|
"createdAt",
|
|
"description",
|
|
"id",
|
|
"kind",
|
|
"provider",
|
|
"requiresAttention",
|
|
"status",
|
|
"subtitle",
|
|
"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,
|
|
),
|
|
);
|
|
});
|
|
});
|