Surface cross-workspace subagents in their workspaces (#2061)
* fix(workspaces): show provider subagent activity Provider subagent lifecycle lives outside managed agent snapshots, so workspace aggregation dropped it after the parent turn finished. Count running provider subagents against the delegation root and emit workspace updates when their status changes. * fix(workspaces): surface cross-workspace subagents
This commit is contained in:
@@ -14,6 +14,17 @@ export interface SeededSubagentPair {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
export interface SeededCrossWorkspaceSubagentPair {
|
||||
parent: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
child: {
|
||||
id: string;
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedParentWithSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentTitle: string; childTitle: string },
|
||||
@@ -51,6 +62,60 @@ export async function seedParentWithSubagent(
|
||||
};
|
||||
}
|
||||
|
||||
export async function seedParentWithCrossWorkspaceSubagent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId" | "projectId">,
|
||||
input: { parentTitle: string; childTitle: string },
|
||||
): Promise<SeededCrossWorkspaceSubagentPair> {
|
||||
const parent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.parentTitle,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
const createdWorkspace = await workspace.client.createWorkspace({
|
||||
source: {
|
||||
kind: "directory",
|
||||
path: workspace.repoPath,
|
||||
projectId: workspace.projectId,
|
||||
},
|
||||
title: "Subagent workspace",
|
||||
});
|
||||
if (!createdWorkspace.workspace) {
|
||||
throw new Error(createdWorkspace.error ?? "Failed to create subagent workspace");
|
||||
}
|
||||
|
||||
const child = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
title: input.childTitle,
|
||||
modeId: "load-test",
|
||||
model: "five-minute-stream",
|
||||
initialPrompt: "stay running",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
},
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(
|
||||
child.id,
|
||||
(snapshot) => snapshot.status === "running",
|
||||
15_000,
|
||||
);
|
||||
|
||||
return {
|
||||
parent: {
|
||||
id: parent.id,
|
||||
workspaceId: workspace.workspaceId,
|
||||
},
|
||||
child: {
|
||||
id: child.id,
|
||||
workspaceId: createdWorkspace.workspace.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function openSubagentsTrack(page: Page): Promise<void> {
|
||||
await page.getByTestId("subagents-track-header").click();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { test, expect } from "./fixtures";
|
||||
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
|
||||
import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer";
|
||||
import { clickNewChat, gotoWorkspace } from "./helpers/launcher";
|
||||
import {
|
||||
@@ -12,6 +13,11 @@ import {
|
||||
} from "./helpers/new-workspace";
|
||||
import { getServerId } from "./helpers/server-id";
|
||||
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
|
||||
import {
|
||||
expectSubagentRowVisible,
|
||||
openSubagentsTrack,
|
||||
seedParentWithCrossWorkspaceSubagent,
|
||||
} from "./helpers/subagents";
|
||||
import { expectWorkspaceHeader, waitForSidebarHydration } from "./helpers/workspace-ui";
|
||||
import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs";
|
||||
|
||||
@@ -527,4 +533,39 @@ test.describe("Workspace model regressions", () => {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("cross-workspace subagent opens in its workspace and keeps its parent relationship", async ({
|
||||
page,
|
||||
}) => {
|
||||
const serverId = getServerId();
|
||||
const seeded = await seedWorkspace({ repoPrefix: "workspace-cross-subagent-" });
|
||||
|
||||
try {
|
||||
const agents = await seedParentWithCrossWorkspaceSubagent(seeded, {
|
||||
parentTitle: "Parent agent",
|
||||
childTitle: "Cross-workspace subagent",
|
||||
});
|
||||
|
||||
await gotoWorkspace(page, agents.child.workspaceId);
|
||||
await waitForSidebarHydration(page);
|
||||
await expectWorkspaceTabVisible(page, agents.child.id);
|
||||
|
||||
const parentRowTestId = `sidebar-workspace-row-${serverId}:${agents.parent.workspaceId}`;
|
||||
const childRowTestId = `sidebar-workspace-row-${serverId}:${agents.child.workspaceId}`;
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: childRowTestId,
|
||||
indicator: "running",
|
||||
});
|
||||
await expectWorkspaceRowHasOnlyIndicator(page, {
|
||||
rowTestId: parentRowTestId,
|
||||
indicator: "done",
|
||||
});
|
||||
|
||||
await gotoWorkspace(page, agents.parent.workspaceId);
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
} finally {
|
||||
await seeded.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -215,4 +215,38 @@ describe("workspace subagents integration", () => {
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("auto-opens a cross-workspace child while retaining it in the parent section", () => {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
expect(workspaceKey).toBeTruthy();
|
||||
|
||||
const parent = makeAgent({
|
||||
id: "parent-agent",
|
||||
workspaceId: "ws-parent",
|
||||
title: "Parent agent",
|
||||
});
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
parentAgentId: parent.id,
|
||||
title: "Cross-workspace child",
|
||||
});
|
||||
|
||||
initializeAgents([parent, child]);
|
||||
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
||||
|
||||
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_child-agent"]);
|
||||
expect(
|
||||
selectSubagentsForParent(
|
||||
useSessionStore.getState(),
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: parent.id,
|
||||
},
|
||||
new Set(),
|
||||
).map((row) => row.id),
|
||||
).toEqual([child.id]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
export function shouldAutoOpenAgentTab(agent: Pick<Agent, "parentAgentId">): boolean {
|
||||
return !agent.parentAgentId;
|
||||
}
|
||||
@@ -3,4 +3,4 @@ export { selectSubagentsForParent, useSubagentsForParent } from "./select";
|
||||
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
|
||||
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
|
||||
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
||||
export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy";
|
||||
export { isWorkspaceRootAgent } from "./workspace-root-policy";
|
||||
|
||||
@@ -6,4 +6,4 @@
|
||||
// UI surface. Use `@/subagents/policies` only when the caller is
|
||||
// non-RN infrastructure code; otherwise prefer `@/subagents`.
|
||||
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
||||
export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy";
|
||||
export { isWorkspaceRootAgent } from "./workspace-root-policy";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
|
||||
type WorkspaceAgent = Pick<Agent, "parentAgentId" | "workspaceId">;
|
||||
|
||||
export function isWorkspaceRootAgent(
|
||||
agent: WorkspaceAgent,
|
||||
parentAgent: Pick<Agent, "workspaceId"> | undefined,
|
||||
): boolean {
|
||||
if (!agent.parentAgentId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId);
|
||||
const parentWorkspaceId = normalizeWorkspaceOpaqueId(parentAgent?.workspaceId);
|
||||
return Boolean(workspaceId && parentWorkspaceId && workspaceId !== parentWorkspaceId);
|
||||
}
|
||||
@@ -155,6 +155,52 @@ describe("workspace agent activity index", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a cross-workspace subagent as activity in its own workspace", () => {
|
||||
const index = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"parent",
|
||||
agent({
|
||||
id: "parent",
|
||||
workspaceId: "workspace-a",
|
||||
updatedAt: "2026-06-01T10:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
[
|
||||
"child",
|
||||
agent({
|
||||
id: "child",
|
||||
workspaceId: "workspace-b",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:03:00.000Z",
|
||||
parentAgentId: "parent",
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(index).toEqual(
|
||||
new Map([
|
||||
[
|
||||
"workspace-a",
|
||||
{
|
||||
agentId: "parent",
|
||||
status: "done",
|
||||
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
|
||||
},
|
||||
],
|
||||
[
|
||||
"workspace-b",
|
||||
{
|
||||
agentId: "child",
|
||||
status: "running",
|
||||
enteredAt: new Date("2026-06-01T10:03:00.000Z"),
|
||||
},
|
||||
],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves the activity index while the same agent remains in the same status", () => {
|
||||
const previous = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
|
||||
import { isWorkspaceRootAgent } from "@/subagents/policies";
|
||||
import { deriveSidebarStateBucket } from "./sidebar-agent-state";
|
||||
|
||||
export interface WorkspaceAgentActivity {
|
||||
@@ -15,7 +16,8 @@ export function buildWorkspaceAgentActivityIndex(
|
||||
const latestActivityAtByWorkspaceId = new Map<string, Date>();
|
||||
|
||||
for (const agent of agents.values()) {
|
||||
if (agent.archivedAt || agent.parentAgentId || !agent.workspaceId) {
|
||||
const parentAgent = agent.parentAgentId ? agents.get(agent.parentAgentId) : undefined;
|
||||
if (agent.archivedAt || !agent.workspaceId || !isWorkspaceRootAgent(agent, parentAgent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +131,32 @@ describe("workspace agent visibility", () => {
|
||||
expect(result.knownAgentIds).toEqual(new Set(["child-agent", "parent-agent"]));
|
||||
});
|
||||
|
||||
it("auto-opens a subagent whose parent belongs to another workspace", () => {
|
||||
const parent = makeAgent({
|
||||
id: "parent-agent",
|
||||
cwd: "/repo",
|
||||
workspaceId: "ws-parent",
|
||||
});
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
cwd: "/repo/worktree",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
parentAgentId: parent.id,
|
||||
});
|
||||
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
sessionAgents: new Map<string, Agent>([
|
||||
[parent.id, parent],
|
||||
[child.id, child],
|
||||
]),
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(result.activeAgentIds).toEqual(new Set(["child-agent"]));
|
||||
expect(result.autoOpenAgentIds).toEqual(new Set(["child-agent"]));
|
||||
expect(result.knownAgentIds).toEqual(new Set(["child-agent"]));
|
||||
});
|
||||
|
||||
it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => {
|
||||
const visible = makeAgent({
|
||||
id: "visible-agent",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions";
|
||||
import { shouldAutoOpenAgentTab } from "@/subagents/policies";
|
||||
import { isWorkspaceRootAgent } from "@/subagents/policies";
|
||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
||||
|
||||
export interface WorkspaceAgentVisibility {
|
||||
@@ -31,6 +31,10 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
const activeAgentIds = new Set<string>();
|
||||
const autoOpenAgentIds = new Set<string>();
|
||||
const knownAgentIds = new Set<string>();
|
||||
const agentsById = new Map<string, Agent>([
|
||||
...(agentDetails?.entries() ?? []),
|
||||
...(sessionAgents?.entries() ?? []),
|
||||
]);
|
||||
for (const agent of sessionAgents?.values() ?? []) {
|
||||
if (!agentBelongsToWorkspace(agent, workspaceId)) {
|
||||
continue;
|
||||
@@ -38,7 +42,8 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
knownAgentIds.add(agent.id);
|
||||
if (!agent.archivedAt) {
|
||||
activeAgentIds.add(agent.id);
|
||||
if (shouldAutoOpenAgentTab(agent)) {
|
||||
const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined;
|
||||
if (isWorkspaceRootAgent(agent, parentAgent)) {
|
||||
autoOpenAgentIds.add(agent.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -375,7 +375,16 @@ describe("WorkspaceDirectory", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("running delegated child contributes running to the parent workspace, not its worktree", async () => {
|
||||
test("running same-workspace subagent contributes running to its parent workspace", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasRootAgent({ id: "parent-agent", status: "idle" });
|
||||
workspace.hasDelegatedAgent({ id: "child-agent", status: "running" });
|
||||
|
||||
await expect(workspace.workspaceStatus()).resolves.toBe("running");
|
||||
});
|
||||
|
||||
test("running cross-workspace subagent contributes to its own workspace", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasWorktreeWorkspace();
|
||||
@@ -383,8 +392,25 @@ describe("WorkspaceDirectory", () => {
|
||||
workspace.hasDelegatedAgentInWorktree({ id: "child-agent", status: "running" });
|
||||
|
||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||
"workspace-1": "running",
|
||||
"workspace-worktree": "done",
|
||||
"workspace-1": "done",
|
||||
"workspace-worktree": "running",
|
||||
});
|
||||
});
|
||||
|
||||
test("cross-workspace subagent contributes its full status bucket to its own workspace", async () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
|
||||
workspace.hasWorktreeWorkspace();
|
||||
workspace.hasRootAgent({ id: "parent-agent", status: "idle" });
|
||||
workspace.hasDelegatedAgentInWorktree({
|
||||
id: "child-agent",
|
||||
status: "idle",
|
||||
pendingPermissionCount: 1,
|
||||
});
|
||||
|
||||
await expect(workspace.workspaceStatuses()).resolves.toEqual({
|
||||
"workspace-1": "done",
|
||||
"workspace-worktree": "needs_input",
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getWorkspaceStateBucketPriority,
|
||||
type WorkspaceStateBucket,
|
||||
} from "@getpaseo/protocol/agent-state-bucket";
|
||||
import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import { SortablePager } from "./pagination/sortable-pager.js";
|
||||
import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js";
|
||||
import { resolveProjectDisplayName } from "./workspace-registry.js";
|
||||
@@ -273,8 +273,9 @@ export class WorkspaceDirectory {
|
||||
|
||||
// Aggregate each agent's state bucket into its owning workspace descriptor,
|
||||
// keeping the highest-priority bucket. A record's owner IS its `workspaceId`;
|
||||
// status never fans out to same-cwd siblings. Delegated agents contribute to
|
||||
// their delegation root's workspace; their own status is ignored unless running.
|
||||
// status never fans out to same-cwd siblings. A subagent in another workspace
|
||||
// is a root for that workspace. Same-workspace descendants contribute only
|
||||
// running activity to the nearest ancestor in that workspace.
|
||||
private applyAgentBucketContributions(params: {
|
||||
activeAgents: AgentSnapshotPayload[];
|
||||
descriptorsByWorkspaceId: Map<string, WorkspaceDescriptorPayload>;
|
||||
@@ -283,26 +284,22 @@ export class WorkspaceDirectory {
|
||||
const activeAgentsById = new Map(activeAgents.map((agent) => [agent.id, agent] as const));
|
||||
|
||||
for (const agent of activeAgents) {
|
||||
let workspaceAgent = agent;
|
||||
let bucket: WorkspaceDescriptorPayload["status"];
|
||||
if (isDelegatedAgent(agent)) {
|
||||
if (agent.status !== "running") {
|
||||
continue;
|
||||
}
|
||||
const parentAgent = resolveDelegationRootAgent(agent, activeAgentsById);
|
||||
if (!parentAgent) {
|
||||
continue;
|
||||
}
|
||||
workspaceAgent = parentAgent;
|
||||
bucket = "running";
|
||||
} else {
|
||||
bucket = deriveAgentStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions?.length ?? 0,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
});
|
||||
const workspaceAgent = resolveWorkspaceRootAgent(agent, activeAgentsById);
|
||||
if (!workspaceAgent) {
|
||||
continue;
|
||||
}
|
||||
const isWorkspaceRoot = workspaceAgent.id === agent.id;
|
||||
if (!isWorkspaceRoot && agent.status !== "running") {
|
||||
continue;
|
||||
}
|
||||
const bucket = isWorkspaceRoot
|
||||
? deriveAgentStateBucket({
|
||||
status: agent.status,
|
||||
pendingPermissionCount: agent.pendingPermissions?.length ?? 0,
|
||||
requiresAttention: agent.requiresAttention,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
})
|
||||
: "running";
|
||||
|
||||
const workspaceId = workspaceAgent.workspaceId;
|
||||
if (!workspaceId) {
|
||||
@@ -619,7 +616,7 @@ function groupAgentsByWorkspaceId(
|
||||
return byWorkspaceId;
|
||||
}
|
||||
|
||||
function resolveDelegationRootAgent(
|
||||
function resolveWorkspaceRootAgent(
|
||||
agent: AgentSnapshotPayload,
|
||||
activeAgentsById: ReadonlyMap<string, AgentSnapshotPayload>,
|
||||
): AgentSnapshotPayload | null {
|
||||
@@ -638,6 +635,9 @@ function resolveDelegationRootAgent(
|
||||
if (!parent) {
|
||||
return null;
|
||||
}
|
||||
if (parent.workspaceId !== current.workspaceId) {
|
||||
return current;
|
||||
}
|
||||
seen.add(parentAgentId);
|
||||
current = parent;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user