From 1f5283f5a36926f31ec9cbf020072fe1dc35dfa7 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Tue, 14 Jul 2026 14:22:14 +0200 Subject: [PATCH] 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 --- docs/agent-lifecycle.md | 4 +- packages/app/e2e/helpers/subagents.ts | 65 +++++++++++++++++++ .../e2e/workspace-model-regressions.spec.ts | 41 ++++++++++++ .../workspace-subagents-integration.test.ts | 34 ++++++++++ .../app/src/subagents/auto-open-tab-policy.ts | 5 -- packages/app/src/subagents/index.ts | 2 +- packages/app/src/subagents/policies.ts | 2 +- .../src/subagents/workspace-root-policy.ts | 17 +++++ .../utils/workspace-agent-activity.test.ts | 46 +++++++++++++ .../app/src/utils/workspace-agent-activity.ts | 4 +- .../workspace-tabs/agent-visibility.test.ts | 26 ++++++++ .../src/workspace-tabs/agent-visibility.ts | 9 ++- .../src/server/workspace-directory.test.ts | 32 ++++++++- .../server/src/server/workspace-directory.ts | 46 ++++++------- 14 files changed, 295 insertions(+), 38 deletions(-) delete mode 100644 packages/app/src/subagents/auto-open-tab-policy.ts create mode 100644 packages/app/src/subagents/workspace-root-policy.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 2e2a2a2a1..2358b7e4d 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -61,13 +61,13 @@ Closing a tab on a **root agent** still archives — the tab is the agent's home Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`). -The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children. +The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached. ## Workspace activity Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running. -Workspace status is an aggregate activity signal computed **per `workspaceId`**: a workspace's status reflects only records whose `workspaceId === workspace.id`. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. A root agent contributes its normal state bucket to its owning workspace only. Running subagents contribute `running` to their root parent's owning workspace (by the parent agent's `workspaceId`), not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket. +Workspace status is an aggregate activity signal computed **per `workspaceId`**. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute `running` to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship. ## The subagents track diff --git a/packages/app/e2e/helpers/subagents.ts b/packages/app/e2e/helpers/subagents.ts index 46a227e66..b70797329 100644 --- a/packages/app/e2e/helpers/subagents.ts +++ b/packages/app/e2e/helpers/subagents.ts @@ -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, input: { parentTitle: string; childTitle: string }, @@ -51,6 +62,60 @@ export async function seedParentWithSubagent( }; } +export async function seedParentWithCrossWorkspaceSubagent( + workspace: Pick, + input: { parentTitle: string; childTitle: string }, +): Promise { + 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 { await page.getByTestId("subagents-track-header").click(); } diff --git a/packages/app/e2e/workspace-model-regressions.spec.ts b/packages/app/e2e/workspace-model-regressions.spec.ts index 20501ed15..5a8554d5a 100644 --- a/packages/app/e2e/workspace-model-regressions.spec.ts +++ b/packages/app/e2e/workspace-model-regressions.spec.ts @@ -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(); + } + }); }); diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 7bcb376ab..d3838dad8 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -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]); + }); }); diff --git a/packages/app/src/subagents/auto-open-tab-policy.ts b/packages/app/src/subagents/auto-open-tab-policy.ts deleted file mode 100644 index be11bd2d2..000000000 --- a/packages/app/src/subagents/auto-open-tab-policy.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Agent } from "@/stores/session-store"; - -export function shouldAutoOpenAgentTab(agent: Pick): boolean { - return !agent.parentAgentId; -} diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index a30e9d5e9..c71969b95 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -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"; diff --git a/packages/app/src/subagents/policies.ts b/packages/app/src/subagents/policies.ts index b8a29d07d..c255aa756 100644 --- a/packages/app/src/subagents/policies.ts +++ b/packages/app/src/subagents/policies.ts @@ -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"; diff --git a/packages/app/src/subagents/workspace-root-policy.ts b/packages/app/src/subagents/workspace-root-policy.ts new file mode 100644 index 000000000..8594c0c7a --- /dev/null +++ b/packages/app/src/subagents/workspace-root-policy.ts @@ -0,0 +1,17 @@ +import type { Agent } from "@/stores/session-store"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; + +type WorkspaceAgent = Pick; + +export function isWorkspaceRootAgent( + agent: WorkspaceAgent, + parentAgent: Pick | undefined, +): boolean { + if (!agent.parentAgentId) { + return true; + } + + const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId); + const parentWorkspaceId = normalizeWorkspaceOpaqueId(parentAgent?.workspaceId); + return Boolean(workspaceId && parentWorkspaceId && workspaceId !== parentWorkspaceId); +} diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 72c1fe869..7cea82191 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -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([ diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index 4f671025f..3e4355ee3 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -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(); 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; } diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index dec15997f..33b30f80d 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -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([ + [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", diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 268034279..f6ff8dbed 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -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(); const autoOpenAgentIds = new Set(); const knownAgentIds = new Set(); + const agentsById = new Map([ + ...(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); } } diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 46e9ccfa8..ae46e80e0 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -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", }); }); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index cc9007818..6e2193227 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -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; @@ -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, ): AgentSnapshotPayload | null { @@ -638,6 +635,9 @@ function resolveDelegationRootAgent( if (!parent) { return null; } + if (parent.workspaceId !== current.workspaceId) { + return current; + } seen.add(parentAgentId); current = parent; }