mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
* perf(app): skip workspace agent visibility rebuild on stream ticks The workspace screen derived agent visibility on every session-store update: it copied every agent and agent detail into a new Map, walked all agents, and allocated two Sets, then compared them. Stream ticks update the store but leave the agent maps untouched, so this was pure waste at stream rate. createWorkspaceAgentVisibilitySelector returns the previous result until session.agents or session.agentDetails changes, and keeps it when a recompute yields the same sets. Parent lookups read the two maps directly instead of merging them. Fixes #4817 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(app): skip subagent row rebuild on stream ticks Every mounted agent panel selected its subagent rows by walking all session agents, allocating rows, sorting, and deep-comparing on every session-store update, including every stream tick. createSubagentsForParentSelector returns the previous rows until session.agents changes, and keeps them when a recompute yields equal rows. The hook rebuilds the selector when the parent or the pending archive ids change. Refs #4817 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * perf(app): check pending submissions without allocating selectAgentTurnPresentation runs from several session-store selectors and filtered the submission list only to read its length. Use some(). Refs #4817 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Ivan Malison <IvanMalison@gmail.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
600 lines
18 KiB
TypeScript
600 lines
18 KiB
TypeScript
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import {
|
|
createSubagentsForParentSelector,
|
|
selectProviderSubagentsForParent,
|
|
selectSubagentsForParent,
|
|
} from "./select";
|
|
import { createWorkspaceAgentVisibilitySelector } from "@/workspace-tabs/agent-visibility";
|
|
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",
|
|
turn: { phase: "idle", cancellationRequestId: 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("places nested provider children only beneath their direct provider parent", () => {
|
|
const store = useProviderSubagentStore.getState();
|
|
const base = {
|
|
parentAgentId: "parent-a",
|
|
provider: "claude" as const,
|
|
title: "general-purpose",
|
|
subtitle: null,
|
|
status: "running" as const,
|
|
createdAt: "2026-09-04T10:00:00.000Z",
|
|
updatedAt: "2026-09-04T10:00:00.000Z",
|
|
toolCallId: null,
|
|
};
|
|
store.applyUpdate(SERVER_ID, {
|
|
kind: "upsert",
|
|
subagent: { ...base, id: "direct", description: "Direct", parentSubagentId: null },
|
|
});
|
|
store.applyUpdate(SERVER_ID, {
|
|
kind: "upsert",
|
|
subagent: {
|
|
...base,
|
|
id: "nested",
|
|
description: "Nested",
|
|
parentSubagentId: "direct",
|
|
},
|
|
});
|
|
|
|
expect(
|
|
selectProviderSubagentsForParent(
|
|
useProviderSubagentStore.getState(),
|
|
{ serverId: SERVER_ID, parentAgentId: "parent-a" },
|
|
true,
|
|
true,
|
|
).map((row) => row.id),
|
|
).toEqual(["direct"]);
|
|
expect(
|
|
selectProviderSubagentsForParent(
|
|
useProviderSubagentStore.getState(),
|
|
{
|
|
serverId: SERVER_ID,
|
|
parentAgentId: "parent-a",
|
|
providerParentSubagentId: "direct",
|
|
},
|
|
true,
|
|
true,
|
|
).map((row) => row.id),
|
|
).toEqual(["nested"]);
|
|
});
|
|
|
|
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",
|
|
turn: { phase: "idle", cancellationRequestId: null },
|
|
requiresAttention: true,
|
|
createdAt,
|
|
},
|
|
]);
|
|
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
|
|
"createdAt",
|
|
"description",
|
|
"id",
|
|
"kind",
|
|
"provider",
|
|
"requiresAttention",
|
|
"status",
|
|
"subtitle",
|
|
"title",
|
|
"turn",
|
|
]);
|
|
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,
|
|
),
|
|
);
|
|
});
|
|
});
|
|
|
|
describe("createSubagentsForParentSelector", () => {
|
|
class IterationCountingMap<K, V> extends Map<K, V> {
|
|
iterations = 0;
|
|
override values() {
|
|
this.iterations += 1;
|
|
return super.values();
|
|
}
|
|
override entries() {
|
|
this.iterations += 1;
|
|
return super.entries();
|
|
}
|
|
override [Symbol.iterator]() {
|
|
this.iterations += 1;
|
|
return super[Symbol.iterator]();
|
|
}
|
|
}
|
|
|
|
const params = { serverId: SERVER_ID, parentAgentId: "parent" };
|
|
|
|
function streamTick(text: string): void {
|
|
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", {
|
|
head: [
|
|
{
|
|
kind: "assistant_message",
|
|
id: "message-1",
|
|
text,
|
|
timestamp: new Date("2026-03-08T10:05:00.000Z"),
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
it("returns the cached rows without walking agents when a stream tick leaves them unchanged", () => {
|
|
const agents = new IterationCountingMap<string, Agent>(
|
|
[
|
|
makeAgent({ id: "parent" }),
|
|
makeAgent({ id: "child", parentAgentId: "parent" }),
|
|
makeAgent({ id: "unrelated" }),
|
|
].map((agent) => [agent.id, agent]),
|
|
);
|
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
|
useSessionStore.getState().setAgents(SERVER_ID, agents);
|
|
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
|
|
const before = select(useSessionStore.getState());
|
|
const iterationsAfterFirstSelect = agents.iterations;
|
|
|
|
for (let tick = 0; tick < 100; tick += 1) {
|
|
streamTick(`token ${tick}`);
|
|
expect(select(useSessionStore.getState())).toBe(before);
|
|
}
|
|
|
|
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
|
|
expect(agents.iterations).toBe(iterationsAfterFirstSelect);
|
|
expect(before.map((row) => row.id)).toEqual(["child"]);
|
|
});
|
|
|
|
it("recomputes when the agents map changes", () => {
|
|
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child-a", parentAgentId: "parent" })]);
|
|
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
|
|
const before = select(useSessionStore.getState());
|
|
|
|
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
|
|
const child = makeAgent({
|
|
id: "child-b",
|
|
parentAgentId: "parent",
|
|
createdAt: new Date("2026-03-08T10:01:00.000Z"),
|
|
});
|
|
return new Map(current).set(child.id, child);
|
|
});
|
|
const after = select(useSessionStore.getState());
|
|
|
|
expect(after).not.toBe(before);
|
|
expect(after.map((row) => row.id)).toEqual(["child-a", "child-b"]);
|
|
});
|
|
|
|
it("keeps the previous rows when a changed agents map yields the same rows", () => {
|
|
setAgents([
|
|
makeAgent({ id: "parent" }),
|
|
makeAgent({ id: "child", parentAgentId: "parent" }),
|
|
makeAgent({ id: "unrelated" }),
|
|
]);
|
|
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
|
|
const before = select(useSessionStore.getState());
|
|
|
|
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
|
|
return new Map(current).set("unrelated", makeAgent({ id: "unrelated", status: "running" }));
|
|
});
|
|
|
|
expect(select(useSessionStore.getState())).toBe(before);
|
|
});
|
|
|
|
it("applies the pending archive ids it was created with", () => {
|
|
setAgents([
|
|
makeAgent({ id: "parent" }),
|
|
makeAgent({ id: "child-a", parentAgentId: "parent" }),
|
|
makeAgent({ id: "child-b", parentAgentId: "parent" }),
|
|
]);
|
|
|
|
const rows = createSubagentsForParentSelector(
|
|
params,
|
|
new Set(["child-b"]),
|
|
)(useSessionStore.getState());
|
|
|
|
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
|
});
|
|
it("does no agent map walks for 100 stream commits with unchanged source maps", () => {
|
|
const agents = new IterationCountingMap<string, Agent>(
|
|
Array.from({ length: 200 }, (_, i) => {
|
|
const agent = makeAgent({
|
|
id: `agent-${i}`,
|
|
workspaceId: "workspace",
|
|
parentAgentId: i < 3 ? null : `agent-${i % 3}`,
|
|
});
|
|
return [agent.id, agent] as const;
|
|
}),
|
|
);
|
|
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
|
|
useSessionStore.getState().setAgents(SERVER_ID, agents);
|
|
const selectVisibility = createWorkspaceAgentVisibilitySelector({
|
|
serverId: SERVER_ID,
|
|
workspaceId: "workspace",
|
|
});
|
|
const selectChildren = Array.from({ length: 3 }, (_, i) =>
|
|
createSubagentsForParentSelector(
|
|
{ serverId: SERVER_ID, parentAgentId: `agent-${i}` },
|
|
EMPTY_PENDING_ARCHIVE_IDS,
|
|
),
|
|
);
|
|
const select = (state: ReturnType<typeof useSessionStore.getState>) => {
|
|
selectVisibility(state);
|
|
selectChildren.forEach((selector) => selector(state));
|
|
};
|
|
select(useSessionStore.getState());
|
|
const before = agents.iterations;
|
|
const unsubscribe = useSessionStore.subscribe(select);
|
|
try {
|
|
for (let tick = 0; tick < 100; tick++) {
|
|
useSessionStore.getState().setAgentStreamState(SERVER_ID, "agent-0", {
|
|
head: [
|
|
{
|
|
kind: "assistant_message",
|
|
id: "message",
|
|
text: `token ${tick}`,
|
|
timestamp: AGENT_TIMESTAMP,
|
|
},
|
|
],
|
|
});
|
|
}
|
|
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
|
|
expect(agents.iterations - before).toBe(0);
|
|
} finally {
|
|
unsubscribe();
|
|
useSessionStore.getState().clearSession(SERVER_ID);
|
|
}
|
|
});
|
|
});
|