From c097678cdf7e4275a816b5c792e1eea71c3837ed Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 9 May 2026 15:39:13 +0800 Subject: [PATCH] Surface paseo subagents in a collapsible section above the composer (#532) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Surface paseo subagents in a collapsible section above the composer Subagents no longer auto-open as tabs. Each parent agent's pane shows a compact tab attached to the top of the composer, listing its direct children with name and status; clicking a row opens or focuses the subagent's tab. The section is provider-neutral so Claude Code Task and Codex subagent sources can feed the same UI later. Reconciliation now separates pruning (`activeAgentIds`) from auto-opening (`autoOpenAgentIds`) so manually opened subagent tabs survive. * Tighten subagents composer code from review Collapse duplicate helper in workspace reconciliation, swap a hand-rolled shallow comparator for fast-deep-equal (matching repo convention), drop an unused React default import and a single-use formatCount helper, and extract a shared PaneOverrides type in the agent-panel test. * Fix oxlint failures in subagents code Switch type aliases to interfaces, hoist inline JSX styles/handlers in SubagentsSection to satisfy react-perf rules, and lift the test makeAgent helpers onto AGENT_DEFAULTS so their cyclomatic complexity drops below the configured cap. * Add missing unistyles mock exports after rebase After rebasing onto main, SubagentsSection now imports both useUnistyles and (transitively, via WorkspaceTabIcon) withUnistyles. Two test mocks needed updating to expose them. Test drift fix: the implementation contract is correct; the local react-native-unistyles mocks were stale. * Tuck subagents tab under the composer Subagents tab now grows visually from beneath the composer's rounded top edge: the surface overlaps the composer by the corner radius so the rounded curve nests into the tab. Composer's top padding is removed so the tab sits flush against the input box with no click-dead gutter. The tab is full-width in both states, the header label drops the medium weight to follow design-system §3 (content text inside a surface is normal), and hover covers the entire visible tab when collapsed but stays scoped to the header row when expanded. * Decouple subagent close-tab from archive Closing a subagent's tab no longer archives the agent — the tab is removed from the per-client layout, the agent stays in the parent's track until explicitly archived. Track rows gain an archive button (X) to make the lifecycle gesture explicit. AgentManager.archiveAgent now cascades to children carrying the paseo.parent-agent-id label so subagent fleets don't outlive their orchestrator. Trade-offs documented in docs/agent-lifecycle.md, including the known limitation that handoff agents launched via the same MCP path get cascade-archived alongside true subagents until a richer relation model lands. * Stabilize subagent archive button hover Apply the sidebar workspace row pattern to fix two issues with the new archive button: the row no longer drops hover when the pointer moves onto the button, and the button slot reserves layout space when invisible so adding/removing it doesn't shift the row. The wrapper View tracks hover via onPointerEnter/onPointerLeave (workspace rows do the same), and the slot stays mounted with opacity 0 + pointerEvents="none" when the button is hidden. Also swap the X icon for the Archive icon to match the gesture's intent. * Match design language for subagent archive button Hover state now changes the icon color from foregroundMuted to foreground, matching the project-row trailing-action pattern in sidebar-workspace-list.tsx. Drops the surface3 background hover that didn't match the design language. Adds a tooltip ("Archive subagent") on the trigger using the canonical Tooltip + TooltipTrigger asChild pattern. Disabled when the slot is hidden so it never appears unbidden. * Slot subagents into one module + harden cascade Reshape the subagents-in-composer feature so it lives behind one module and integrates at a few explicit sites instead of being smeared across generic files. - Extract `PARENT_AGENT_ID_LABEL` to `packages/server/src/shared/agent-labels.ts`. Sweeps 16 literal sites across server, client, CLI, and tests. - `packages/app/src/subagents/` is now a deep module: `index.ts` is the only React entry (`SubagentsSection`, `useArchiveSubagent`, `useSubagentsForParent`, `selectSubagentsForParent`, `resolveCloseAgentTabPolicy`, `shouldAutoOpenAgentTab`); `policies.ts` is a second designed entry for non-RN data consumers. Internal files renamed (`section.tsx`, `select.ts`, `close-tab-policy.ts`, `auto-open-tab-policy.ts`, `use-archive-subagent.ts`). - Move the 22-line subagent archive flow out of `agent-panel.tsx` into `useArchiveSubagent`. Agent-panel becomes a one-line consumer. - Move the close-tab subagent branch out of `workspace-screen.tsx` into `resolveCloseAgentTabPolicy` (discriminated union). - Move the auto-open exclusion rule out of `workspace-agent-visibility.ts` into `shouldAutoOpenAgentTab`. - Theme chevrons in `section.tsx` via `withUnistyles`; drop the forbidden `useUnistyles()` call from the hot path. Server-side cascade hardening: - Single write path: extract `markRecordArchived` and route both `archiveAgent` and the off-memory cascade branch through it. Off-memory cascaded children now notify subscribers (previously silent). - Cascade no longer swallows child failures with `try/catch warn`. Failures propagate. - Cascade test extended from 1 weak assertion to 5 contract tests: full archive shape (archivedAt, normalized lastStatus, requiresAttention=false), running child runtime stop, off-memory branch, subscriber notification for in-memory and off-memory children, partial-failure surfacing. Net diff vs the start of this PR: -1037 / +444 lines. The feature now slots into the repo at four explicit sites: `agent-panel.tsx` (3 imports + a hook + the section), `workspace-screen.tsx` (one policy import), `workspace-agent-visibility.ts` (one predicate import), and `agent-manager.ts` (the cascade plus the shared label constant). --- CLAUDE.md | 39 +- docs/agent-lifecycle.md | 107 ++++++ docs/data-model.md | 46 +-- packages/app/src/components/agent-list.tsx | 1 + packages/app/src/components/composer.tsx | 3 +- .../contexts/session-status-tracking.test.ts | 1 + .../app/src/hooks/use-agent-history.test.tsx | 1 + .../use-agent-screen-state-machine.test.ts | 1 + .../app/src/hooks/use-all-agents-list.test.ts | 1 + .../app/src/hooks/use-archive-agent.test.ts | 1 + packages/app/src/panels/agent-panel.tsx | 20 +- packages/app/src/runtime/host-runtime.test.ts | 1 + .../workspace-agent-visibility.test.ts | 144 ++++++- .../workspace/workspace-agent-visibility.ts | 16 +- .../workspace/workspace-draft-agent-tab.tsx | 1 + .../screens/workspace/workspace-screen.tsx | 9 +- packages/app/src/stores/session-store.ts | 1 + .../src/stores/workspace-layout-actions.ts | 36 +- .../src/stores/workspace-layout-store.test.ts | 99 +++++ .../workspace-subagents-integration.test.ts | 218 +++++++++++ .../app/src/subagents/auto-open-tab-policy.ts | 5 + .../src/subagents/close-tab-policy.test.ts | 21 ++ .../app/src/subagents/close-tab-policy.ts | 13 + packages/app/src/subagents/index.ts | 7 + packages/app/src/subagents/policies.ts | 9 + packages/app/src/subagents/section.test.tsx | 291 +++++++++++++++ packages/app/src/subagents/section.tsx | 351 ++++++++++++++++++ packages/app/src/subagents/select.test.ts | 229 ++++++++++++ packages/app/src/subagents/select.ts | 65 ++++ .../subagents/use-archive-subagent.test.ts | 217 +++++++++++ .../app/src/subagents/use-archive-subagent.ts | 66 ++++ .../src/utils/agent-directory-sync.test.ts | 95 +++++ .../app/src/utils/agent-snapshots.test.ts | 74 ++++ packages/app/src/utils/agent-snapshots.ts | 7 + packages/cli/src/commands/agent/inspect.ts | 4 +- .../src/server/agent/agent-manager.test.ts | 285 ++++++++++++++ .../server/src/server/agent/agent-manager.ts | 138 ++++++- .../src/server/agent/mcp-parity.e2e.test.ts | 5 +- .../src/server/agent/mcp-server.test.ts | 3 +- .../server/src/server/agent/mcp-server.ts | 3 +- packages/server/src/server/exports.ts | 1 + packages/server/src/shared/agent-labels.ts | 1 + 42 files changed, 2540 insertions(+), 96 deletions(-) create mode 100644 docs/agent-lifecycle.md create mode 100644 packages/app/src/stores/workspace-subagents-integration.test.ts create mode 100644 packages/app/src/subagents/auto-open-tab-policy.ts create mode 100644 packages/app/src/subagents/close-tab-policy.test.ts create mode 100644 packages/app/src/subagents/close-tab-policy.ts create mode 100644 packages/app/src/subagents/index.ts create mode 100644 packages/app/src/subagents/policies.ts create mode 100644 packages/app/src/subagents/section.test.tsx create mode 100644 packages/app/src/subagents/section.tsx create mode 100644 packages/app/src/subagents/select.test.ts create mode 100644 packages/app/src/subagents/select.ts create mode 100644 packages/app/src/subagents/use-archive-subagent.test.ts create mode 100644 packages/app/src/subagents/use-archive-subagent.ts create mode 100644 packages/app/src/utils/agent-directory-sync.test.ts create mode 100644 packages/app/src/utils/agent-snapshots.test.ts create mode 100644 packages/server/src/shared/agent-labels.ts diff --git a/CLAUDE.md b/CLAUDE.md index e9d196f1a..bf39da886 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,25 +21,26 @@ This is an npm workspace monorepo: At the start of non-trivial work, list `docs/` and skim anything relevant to the task. When you learn something meta worth preserving — a gotcha, a convention, a workflow, a piece of system context that will outlive the current task — update an existing doc or propose a new one. Code-level facts belong in inline comments next to the code; system, process, and gotcha-level facts belong in `docs/`. -| Doc | What's in it | -| -------------------------------------------------------------- | --------------------------------------------------------------------------------- | -| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going | -| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow | -| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations | -| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms | -| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization | -| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons | -| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | -| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | -| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | -| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | -| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | -| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | -| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | -| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | -| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows | -| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist | -| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | +| Doc | What's in it | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| [docs/product.md](docs/product.md) | What Paseo is, who it's for, where it's going | +| [docs/architecture.md](docs/architecture.md) | System design, package layering, WebSocket protocol, agent lifecycle, data flow | +| [docs/agent-lifecycle.md](docs/agent-lifecycle.md) | Agent states, parent/child relationships, archive semantics, tabs vs archive, subagents track | +| [docs/data-model.md](docs/data-model.md) | File-based JSON persistence, Zod schemas, atomic writes, no migrations | +| [docs/glossary.md](docs/glossary.md) | Authoritative terminology — UI label wins, no synonyms | +| [docs/coding-standards.md](docs/coding-standards.md) | Type hygiene, error handling, state design, React patterns, file organization | +| [docs/design.md](docs/design.md) | Theme tokens — colors, fonts, spacing, radii, icons | +| [docs/unistyles.md](docs/unistyles.md) | Unistyles gotchas — `useUnistyles()` is forbidden, alternatives in order | +| [docs/file-icons.md](docs/file-icons.md) | Material icon theme integration for the file explorer | +| [docs/providers.md](docs/providers.md) | Adding a new agent provider end-to-end | +| [docs/custom-providers.md](docs/custom-providers.md) | Custom provider config: Z.AI, Alibaba/Qwen, ACP agents, profiles, custom binaries | +| [docs/development.md](docs/development.md) | Dev server, build sync gotchas, CLI reference, agent state, Playwright MCP | +| [docs/testing.md](docs/testing.md) | TDD workflow, determinism, real dependencies over mocks, test organization | +| [docs/mobile-testing.md](docs/mobile-testing.md) | Maestro and mobile test workflows | +| [docs/ad-hoc-daemon-testing.md](docs/ad-hoc-daemon-testing.md) | Isolated in-process daemon test harness | +| [docs/android.md](docs/android.md) | App variants, local/cloud builds, EAS workflows | +| [docs/release.md](docs/release.md) | Release playbook, draft releases, completion checklist | +| [SECURITY.md](SECURITY.md) | Relay threat model, E2E encryption, DNS rebinding, agent auth | ## Quick start diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md new file mode 100644 index 000000000..14a4ba393 --- /dev/null +++ b/docs/agent-lifecycle.md @@ -0,0 +1,107 @@ +# Agent lifecycle + +How an agent is created, runs, becomes a subagent, gets archived, and disappears from the UI. The model spans the daemon (lifecycle, archive) and the client (tabs, the subagents track). + +## States + +``` +initializing → idle → running → idle (or error → closed) + ↑ │ + └────────┘ (agent completes a turn, awaits next prompt) +``` + +Each agent in `AgentManager` carries a `lastStatus` of `initializing`, `idle`, `running`, `error`, or `closed`. State transitions persist to disk and stream to subscribed clients via WebSocket. + +## Relationships + +Agents can launch other agents via the `create_agent` MCP tool. When they do, the daemon stamps the new agent with a label `paseo.parent-agent-id` pointing back at the caller (`packages/server/src/server/agent/mcp-server.ts:804`). The client surfaces that as `agent.parentAgentId`. + +There is exactly one relationship type today: `parentAgentId`. The daemon does not distinguish between: + +- **Subagents** — children that exist as part of the parent's work (e.g. orchestration tasks the parent delegates and waits on) +- **Detached agents** — children launched to take over from the parent (e.g. handoffs, fire-and-forget delegations) + +Both look the same in storage. This is an accepted limitation — see [Limitations](#limitations). + +## Archive + +Archive is a **soft delete**: the agent record stays on disk with `archivedAt` set, the runtime is closed, and the agent disappears from active lists. Archive is **global** — it lives on the server and propagates to every connected client. + +Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/agent/agent-manager.ts`): + +1. Snapshot the current session into the registry +2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing` +3. Notify subscribers +4. Close the runtime (kills the process if still running) +5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively + +Cascade is what keeps subagent fleets from outliving their orchestrator. + +## Tabs vs archive + +These are two distinct concepts that used to be conflated: + +| Concept | Scope | Triggers | +| -------------------------- | ---------- | -------------------------- | +| **Tab** (workspace layout) | Per-client | User opens/closes a view | +| **Archive** (lifecycle) | Global | Explicit lifecycle gesture | + +Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident. + +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 subagents track + +The collapsible section above the composer in an agent's pane (`packages/app/src/subagents/subagents-section.tsx`). Membership rule (`packages/app/src/subagents/subagents.ts`): + +``` +parentAgentId === thisAgent.id AND !archivedAt +``` + +Archived subagents disappear from the track, by design. To remove a subagent from the track without closing its tab, use the **archive button (X)** on the row — it opens a confirm dialog and archives the subagent on confirm. That same archive shows the subagent leave the track on every connected client. + +## Why this shape + +The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally: + +- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on +- **Closing a tab on a subagent is layout-only** — fixes the lossy "click to read, close to dismiss view, lose the row" flow +- **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface +- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived + +We considered universal decoupling (no tab close ever archives, archive is always explicit) but rejected it: it changes a behavior root-agent users rely on. + +## Limitations + +### Detached agents are cascade-archived + +The daemon can't tell a "subagent" apart from a "detached agent" — both carry `paseo.parent-agent-id`. So when you archive an agent that previously launched a detached child (e.g. via `/paseo-handoff`), cascade will archive the detached child too, even though semantically it should outlive the originator. + +Until a richer relation model lands (e.g. a `relation: "subagent" | "detached"` field on creation, or a separate channel for handoff launches), this trade-off stands. Workaround: don't archive an agent whose work was handed off, or unarchive the detached child afterward. + +### Subagent accumulation under long-lived parents + +A parent that spawns many subagents will see the track grow. There's no automatic cleanup for completed subagents — the user prunes via the archive button on each row. A bulk gesture (e.g. "archive all idle children") could land later if this becomes a real problem. + +### Cross-client tab dismissal + +Closing a subagent's tab on one client doesn't affect other clients' layouts. This is the expected behavior of decoupled tabs and is consistent with how layouts have always worked. Archive remains the global gesture for cross-client cleanup. + +## Storage + +``` +$PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json +``` + +Each agent is a single JSON file. Fields relevant to this doc: + +| Field | Type | Meaning | +| --------------------------------- | ------------- | ------------------------------------------------------------- | +| `id` | `string` | Stable identifier | +| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | +| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically by `create_agent` MCP tool | +| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | + +See [`docs/data-model.md`](./data-model.md) for the full agent record. diff --git a/docs/data-model.md b/docs/data-model.md index bd129c30e..5964515b8 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -40,29 +40,29 @@ The `agents/{sanitized-cwd}/` directory name is derived from the agent's `cwd` b Each agent is stored as a separate JSON file, grouped by project directory. -| Field | Type | Description | -| -------------------- | ---------------------------------------- | ---------------------------------------------------------------------- | -| `id` | `string` | UUID, primary key | -| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | -| `cwd` | `string` | Working directory the agent operates in | -| `createdAt` | `string` (ISO 8601) | Creation timestamp | -| `updatedAt` | `string` (ISO 8601) | Last update timestamp | -| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | -| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | -| `title` | `string?` | User-visible title | -| `labels` | `Record` | Key-value labels (default `{}`) | -| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | -| `lastModeId` | `string?` | Last active mode ID | -| `config` | `SerializableConfig?` | Agent session configuration (see below) | -| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | -| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | -| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | -| `lastError` | `string?` (nullable) | Last error message, if any | -| `requiresAttention` | `boolean?` | Whether the agent needs user attention | -| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | -| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | -| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | -| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | +| Field | Type | Description | +| -------------------- | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | UUID, primary key | +| `provider` | `string` | Agent provider (`"claude"`, `"codex"`, `"opencode"`, etc.) | +| `cwd` | `string` | Working directory the agent operates in | +| `createdAt` | `string` (ISO 8601) | Creation timestamp | +| `updatedAt` | `string` (ISO 8601) | Last update timestamp | +| `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | +| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | +| `title` | `string?` | User-visible title | +| `labels` | `Record` | Key-value labels (default `{}`). `paseo.parent-agent-id` set automatically when launched via the `create_agent` MCP tool — see [agent-lifecycle.md](./agent-lifecycle.md) | +| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"` | +| `lastModeId` | `string?` | Last active mode ID | +| `config` | `SerializableConfig?` | Agent session configuration (see below) | +| `runtimeInfo` | `RuntimeInfo?` | Live runtime state (see below) | +| `features` | `AgentFeature[]?` | Provider-reported features (toggles/selects) | +| `persistence` | `PersistenceHandle?` | Handle for resuming sessions | +| `lastError` | `string?` (nullable) | Last error message, if any | +| `requiresAttention` | `boolean?` | Whether the agent needs user attention | +| `attentionReason` | `"finished" \| "error" \| "permission"?` | Why attention is needed | +| `attentionTimestamp` | `string?` (ISO 8601) | When attention was flagged | +| `internal` | `boolean?` | Whether this is a system-internal agent (loop workers, etc.) | +| `archivedAt` | `string?` (ISO 8601) | Soft-delete timestamp | ### Nested: SerializableConfig diff --git a/packages/app/src/components/agent-list.tsx b/packages/app/src/components/agent-list.tsx index 6539fe474..c481efb64 100644 --- a/packages/app/src/components/agent-list.tsx +++ b/packages/app/src/components/agent-list.tsx @@ -75,6 +75,7 @@ function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent { attentionTimestamp: agent.attentionTimestamp, archivedAt: agent.archivedAt, labels: agent.labels, + parentAgentId: null, }; } diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index 65f05a6eb..1b584d4ee 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -1621,7 +1621,8 @@ const styles = StyleSheet.create((theme: Theme) => ({ alignItems: "center", width: "100%", overflow: "visible", - padding: theme.spacing[4], + paddingHorizontal: theme.spacing[4], + paddingBottom: theme.spacing[4], }, inputAreaLocked: { opacity: 0.6, diff --git a/packages/app/src/contexts/session-status-tracking.test.ts b/packages/app/src/contexts/session-status-tracking.test.ts index d5e98ed3a..c5235df86 100644 --- a/packages/app/src/contexts/session-status-tracking.test.ts +++ b/packages/app/src/contexts/session-status-tracking.test.ts @@ -27,6 +27,7 @@ function createAgent(status: Agent["status"]): Agent { title: "Agent", cwd: "/tmp", model: null, + parentAgentId: null, labels: {}, projectPlacement: null, }; diff --git a/packages/app/src/hooks/use-agent-history.test.tsx b/packages/app/src/hooks/use-agent-history.test.tsx index a0520f001..dc1fbefda 100644 --- a/packages/app/src/hooks/use-agent-history.test.tsx +++ b/packages/app/src/hooks/use-agent-history.test.tsx @@ -150,6 +150,7 @@ function makeActiveAgent(): Agent { model: null, labels: {}, archivedAt: null, + parentAgentId: null, }; } diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index 59a9d165e..d47a6bf48 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -42,6 +42,7 @@ function createAgent(id: string): Agent { title: "Agent", cwd: "/repo", model: null, + parentAgentId: null, labels: {}, }; } diff --git a/packages/app/src/hooks/use-all-agents-list.test.ts b/packages/app/src/hooks/use-all-agents-list.test.ts index 21272dd14..b87fbe017 100644 --- a/packages/app/src/hooks/use-all-agents-list.test.ts +++ b/packages/app/src/hooks/use-all-agents-list.test.ts @@ -36,6 +36,7 @@ const AGENT_DEFAULTS: Agent = { attentionReason: null, attentionTimestamp: null, archivedAt: null, + parentAgentId: null, labels: {}, projectPlacement: null, }; diff --git a/packages/app/src/hooks/use-archive-agent.test.ts b/packages/app/src/hooks/use-archive-agent.test.ts index 12601e95a..977bf5a5a 100644 --- a/packages/app/src/hooks/use-archive-agent.test.ts +++ b/packages/app/src/hooks/use-archive-agent.test.ts @@ -31,6 +31,7 @@ function makeAgent(overrides: Partial = {}): Agent { title: "Agent 1", cwd: "/repo", model: null, + parentAgentId: null, labels: {}, archivedAt: null, ...overrides, diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 4d4be6485..a1fce2c1f 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -52,6 +52,7 @@ import { buildDraftStoreKey } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; import { type Agent, useSessionStore } from "@/stores/session-store"; import type { Theme } from "@/styles/theme"; +import { SubagentsSection, useArchiveSubagent, useSubagentsForParent } from "@/subagents"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; @@ -1253,7 +1254,19 @@ function ActiveAgentComposer({ }) { const insets = useSafeAreaInsets(); const isCompact = useIsCompactFormFactor(); - const { workspaceId } = usePaneContext(); + const paneContext = usePaneContext(); + const { workspaceId } = paneContext; + const subagentRows = useSubagentsForParent({ + serverId: paneContext.serverId, + parentAgentId: agentId, + }); + const handleOpenSubagent = useCallback( + (subagentId: string) => { + paneContext.openTab({ kind: "agent", agentId: subagentId }); + }, + [paneContext], + ); + const handleArchiveSubagent = useArchiveSubagent({ serverId }); const agentInputDraft = useAgentInputDraft({ draftKey: buildDraftStoreKey({ serverId, @@ -1298,6 +1311,11 @@ function ActiveAgentComposer({ return ( + { lastActivityAt: new Date(stale.updatedAt), archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null, attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null, + parentAgentId: null, }; return new Map([[stale.id, staleAgent]]); }); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts index cda77286f..f02dc4daa 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.test.ts @@ -9,6 +9,7 @@ import { function makeAgent(input: { id: string; cwd: string; + parentAgentId?: string | null; archivedAt?: Date | null; createdAt?: Date; lastActivityAt?: Date; @@ -44,6 +45,7 @@ function makeAgent(input: { cwd: input.cwd, model: null, thinkingOptionId: null, + parentAgentId: input.parentAgentId ?? null, labels: {}, requiresAttention: false, attentionReason: null, @@ -53,6 +55,75 @@ function makeAgent(input: { } describe("workspace agent visibility", () => { + it("keeps subagents active and known while excluding them from auto-open", () => { + const workspaceDirectory = "/repo/worktree"; + const parent = makeAgent({ + id: "parent-agent", + cwd: workspaceDirectory, + }); + const child = makeAgent({ + id: "child-agent", + cwd: workspaceDirectory, + parentAgentId: "parent-agent", + }); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents: new Map([ + [parent.id, parent], + [child.id, child], + ]), + workspaceDirectory, + }); + + expect(result.activeAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); + expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); + expect(result.knownAgentIds).toEqual(new Set(["parent-agent", "child-agent"])); + }); + + it("keeps archived subagents known but excludes them from active and auto-open", () => { + const workspaceDirectory = "/repo/worktree"; + const archivedChild = makeAgent({ + id: "archived-child", + cwd: workspaceDirectory, + parentAgentId: "parent-agent", + archivedAt: new Date("2026-03-04T00:01:00.000Z"), + }); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents: new Map([[archivedChild.id, archivedChild]]), + workspaceDirectory, + }); + + expect(result.activeAgentIds).toEqual(new Set()); + expect(result.autoOpenAgentIds).toEqual(new Set()); + expect(result.knownAgentIds).toEqual(new Set(["archived-child"])); + }); + + it("excludes a child from auto-open even when its snapshot arrives before the parent", () => { + const workspaceDirectory = "/repo/worktree"; + const child = makeAgent({ + id: "child-agent", + cwd: workspaceDirectory, + parentAgentId: "parent-agent", + }); + const parent = makeAgent({ + id: "parent-agent", + cwd: workspaceDirectory, + }); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents: new Map([ + [child.id, child], + [parent.id, parent], + ]), + workspaceDirectory, + }); + + expect(result.activeAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); + expect(result.autoOpenAgentIds).toEqual(new Set(["parent-agent"])); + expect(result.knownAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); + }); + it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { const workspaceDirectory = "/repo/worktree"; const visible = makeAgent({ @@ -83,6 +154,7 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["visible-agent"])); + expect(result.autoOpenAgentIds).toEqual(new Set(["visible-agent"])); expect(result.knownAgentIds.has("visible-agent")).toBe(true); expect(result.knownAgentIds.has("archived-agent")).toBe(true); expect(result.knownAgentIds.has("other-workspace-agent")).toBe(false); @@ -108,14 +180,12 @@ describe("workspace agent visibility", () => { }); it("prunes archived agent tabs so archiving on one client closes tabs on all clients", () => { - const knownAgentIds = new Set(["archived-agent"]); const activeAgentIds = new Set(); expect( shouldPruneWorkspaceAgentTab({ agentId: "archived-agent", agentsHydrated: true, - knownAgentIds, activeAgentIds, }), ).toBe(true); @@ -126,32 +196,28 @@ describe("workspace agent visibility", () => { shouldPruneWorkspaceAgentTab({ agentId: "archived-agent", agentsHydrated: true, - knownAgentIds: new Set(["archived-agent"]), activeAgentIds: new Set(), }), ).toBe(true); }); it("does not prune active agent tabs", () => { - const knownAgentIds = new Set(["active-agent"]); const activeAgentIds = new Set(["active-agent"]); expect( shouldPruneWorkspaceAgentTab({ agentId: "active-agent", agentsHydrated: true, - knownAgentIds, activeAgentIds, }), ).toBe(false); }); - it("prunes agent tabs once agents are hydrated and the agent is missing from knownAgentIds", () => { + it("prunes agent tabs once agents are hydrated and the agent is missing from activeAgentIds", () => { expect( shouldPruneWorkspaceAgentTab({ agentId: "missing-agent", agentsHydrated: true, - knownAgentIds: new Set(), activeAgentIds: new Set(), }), ).toBe(true); @@ -174,6 +240,7 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["slash-agent"])); + expect(result.autoOpenAgentIds).toEqual(new Set(["slash-agent"])); expect(result.knownAgentIds.has("slash-agent")).toBe(true); }); @@ -194,31 +261,78 @@ describe("workspace agent visibility", () => { }); expect(result.activeAgentIds).toEqual(new Set(["recent-agent"])); + expect(result.autoOpenAgentIds).toEqual(new Set(["recent-agent"])); expect(result.knownAgentIds).toEqual(new Set(["recent-agent"])); }); describe("workspaceAgentVisibilityEqual", () => { it("returns true for identical sets", () => { - const a = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) }; - const b = { activeAgentIds: new Set(["a", "b"]), knownAgentIds: new Set(["a", "b", "c"]) }; + const a = { + activeAgentIds: new Set(["a", "b"]), + autoOpenAgentIds: new Set(["a"]), + knownAgentIds: new Set(["a", "b", "c"]), + }; + const b = { + activeAgentIds: new Set(["a", "b"]), + autoOpenAgentIds: new Set(["a"]), + knownAgentIds: new Set(["a", "b", "c"]), + }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); it("returns false when activeAgentIds differ", () => { - const a = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a"]) }; - const b = { activeAgentIds: new Set(["b"]), knownAgentIds: new Set(["a"]) }; + const a = { + activeAgentIds: new Set(["a"]), + autoOpenAgentIds: new Set(["a"]), + knownAgentIds: new Set(["a"]), + }; + const b = { + activeAgentIds: new Set(["b"]), + autoOpenAgentIds: new Set(["a"]), + knownAgentIds: new Set(["a"]), + }; + expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); + }); + + it("returns false when autoOpenAgentIds differ", () => { + const a = { + activeAgentIds: new Set(["a", "b"]), + autoOpenAgentIds: new Set(["a"]), + knownAgentIds: new Set(["a", "b"]), + }; + const b = { + activeAgentIds: new Set(["a", "b"]), + autoOpenAgentIds: new Set(["b"]), + knownAgentIds: new Set(["a", "b"]), + }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); it("returns false when knownAgentIds differ", () => { - const a = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a"]) }; - const b = { activeAgentIds: new Set(["a"]), knownAgentIds: new Set(["a", "b"]) }; + const a = { + activeAgentIds: new Set(["a"]), + autoOpenAgentIds: new Set(["a"]), + knownAgentIds: new Set(["a"]), + }; + const b = { + activeAgentIds: new Set(["a"]), + autoOpenAgentIds: new Set(["a"]), + knownAgentIds: new Set(["a", "b"]), + }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(false); }); it("returns true for empty sets", () => { - const a = { activeAgentIds: new Set(), knownAgentIds: new Set() }; - const b = { activeAgentIds: new Set(), knownAgentIds: new Set() }; + const a = { + activeAgentIds: new Set(), + autoOpenAgentIds: new Set(), + knownAgentIds: new Set(), + }; + const b = { + activeAgentIds: new Set(), + autoOpenAgentIds: new Set(), + knownAgentIds: new Set(), + }; expect(workspaceAgentVisibilityEqual(a, b)).toBe(true); }); }); diff --git a/packages/app/src/screens/workspace/workspace-agent-visibility.ts b/packages/app/src/screens/workspace/workspace-agent-visibility.ts index 1707d633e..f0ad8c1fb 100644 --- a/packages/app/src/screens/workspace/workspace-agent-visibility.ts +++ b/packages/app/src/screens/workspace/workspace-agent-visibility.ts @@ -1,4 +1,5 @@ import type { Agent } from "@/stores/session-store"; +import { shouldAutoOpenAgentTab } from "@/subagents/policies"; import { normalizeWorkspacePath } from "@/utils/workspace-identity"; function normalizeWorkspaceId(value: string | null | undefined): string { @@ -7,6 +8,7 @@ function normalizeWorkspaceId(value: string | null | undefined): string { export interface WorkspaceAgentVisibility { activeAgentIds: Set; + autoOpenAgentIds: Set; knownAgentIds: Set; } @@ -20,11 +22,13 @@ export function deriveWorkspaceAgentVisibility(input: { if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) { return { activeAgentIds: new Set(), + autoOpenAgentIds: new Set(), knownAgentIds: new Set(), }; } const activeAgentIds = new Set(); + const autoOpenAgentIds = new Set(); const knownAgentIds = new Set(); for (const agent of sessionAgents?.values() ?? []) { if (normalizeWorkspaceId(agent.cwd) !== normalizedWorkspaceDirectory) { @@ -33,6 +37,9 @@ export function deriveWorkspaceAgentVisibility(input: { knownAgentIds.add(agent.id); if (!agent.archivedAt) { activeAgentIds.add(agent.id); + if (shouldAutoOpenAgentTab(agent)) { + autoOpenAgentIds.add(agent.id); + } } } for (const agent of agentDetails?.values() ?? []) { @@ -42,7 +49,7 @@ export function deriveWorkspaceAgentVisibility(input: { knownAgentIds.add(agent.id); } - return { activeAgentIds, knownAgentIds }; + return { activeAgentIds, autoOpenAgentIds, knownAgentIds }; } export function workspaceAgentVisibilityEqual( @@ -50,7 +57,9 @@ export function workspaceAgentVisibilityEqual( b: WorkspaceAgentVisibility, ): boolean { return ( - setsEqual(a.activeAgentIds, b.activeAgentIds) && setsEqual(a.knownAgentIds, b.knownAgentIds) + setsEqual(a.activeAgentIds, b.activeAgentIds) && + setsEqual(a.autoOpenAgentIds, b.autoOpenAgentIds) && + setsEqual(a.knownAgentIds, b.knownAgentIds) ); } @@ -66,12 +75,11 @@ function setsEqual(a: Set, b: Set): boolean { return true; } -// Prune agent tabs that are unknown (deleted) or archived. +// Prune agent tabs that are no longer active once agents are hydrated. // Archived agents get pruned so that archiving on one client closes the tab on all clients. export function shouldPruneWorkspaceAgentTab(input: { agentId: string; agentsHydrated: boolean; - knownAgentIds: Set; activeAgentIds: Set; }): boolean { if (!input.agentId.trim()) { diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 026f1437d..c8aca2261 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -265,6 +265,7 @@ function buildDraftAgentSnapshot(input: { model, features: composerState.statusControls.features, thinkingOptionId, + parentAgentId: null, labels: {}, }; } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index ea74d019c..099461ba0 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -150,6 +150,7 @@ import { classifyBulkClosableTabs, closeBulkWorkspaceTabs, } from "@/screens/workspace/workspace-bulk-close"; +import { resolveCloseAgentTabPolicy } from "@/subagents"; import { findAdjacentPane } from "@/utils/split-navigation"; import { isAbsolutePath } from "@/utils/path"; import { useIsCompactFormFactor, supportsDesktopPaneSplits } from "@/constants/layout"; @@ -1889,6 +1890,7 @@ function WorkspaceScreenContent({ agentsHydrated: hasHydratedAgents, terminalsHydrated: terminalsQuery.isSuccess, activeAgentIds: Array.from(workspaceAgentVisibility.activeAgentIds), + autoOpenAgentIds: Array.from(workspaceAgentVisibility.autoOpenAgentIds), knownAgentIds: Array.from(workspaceAgentVisibility.knownAgentIds), knownTerminalIds, standaloneTerminalIds, @@ -2273,9 +2275,10 @@ function WorkspaceScreenContent({ const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null; + const closePolicy = resolveCloseAgentTabPolicy(agent); const isRunning = agent?.status === "running" || agent?.status === "initializing"; - if (isRunning) { + if (isRunning && closePolicy.kind === "archive-on-close") { const confirmed = await confirmDialog({ title: "Archive running agent?", message: @@ -2298,6 +2301,10 @@ function WorkspaceScreenContent({ }); } + if (closePolicy.kind === "layout-only") { + return; + } + // Errors (e.g. timeout) are handled by the mutation's onSettled callback void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {}); }); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index fdffe15e3..fa50e2efe 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -104,6 +104,7 @@ export interface Agent { attentionReason?: "finished" | "error" | "permission" | null; attentionTimestamp?: Date | null; archivedAt?: Date | null; + parentAgentId: string | null; labels: Record; projectPlacement?: ProjectPlacementPayload | null; } diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 01b277c36..4ff3d36cf 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -197,6 +197,7 @@ export interface WorkspaceTabSnapshot { agentsHydrated: boolean; terminalsHydrated: boolean; activeAgentIds: Iterable; + autoOpenAgentIds: Iterable; knownAgentIds: Iterable; knownTerminalIds?: Iterable; standaloneTerminalIds: Iterable; @@ -1451,23 +1452,23 @@ interface EntityTabGroup { tabs: WorkspaceTab[]; } -function buildVisibleAgentIds(input: { - activeAgentIds: Set; +function applyPinnedAndHidden(input: { + baseAgentIds: Set; pinnedAgentIds: Set; hiddenAgentIds: Set; knownAgentIds: Set; }): Set { - const { activeAgentIds, pinnedAgentIds, hiddenAgentIds, knownAgentIds } = input; - const visibleAgentIds = new Set(activeAgentIds); + const { baseAgentIds, pinnedAgentIds, hiddenAgentIds, knownAgentIds } = input; + const result = new Set(baseAgentIds); for (const agentId of pinnedAgentIds) { if (knownAgentIds.has(agentId)) { - visibleAgentIds.add(agentId); + result.add(agentId); } } for (const agentId of hiddenAgentIds) { - visibleAgentIds.delete(agentId); + result.delete(agentId); } - return visibleAgentIds; + return result; } function buildEntityTabGroups(initialTabs: WorkspaceTab[]): Map { @@ -1527,13 +1528,13 @@ function collapseStaleEntityTabs(input: { function addMissingEntityTabs(input: { layout: WorkspaceLayout; - visibleAgentIds: Set; + autoOpenAgentIds: Set; representedAgentIds: Set; standaloneTerminalIds: Set; hasActivePendingDraftCreate: boolean; }): WorkspaceLayout { const { - visibleAgentIds, + autoOpenAgentIds, representedAgentIds, standaloneTerminalIds, hasActivePendingDraftCreate, @@ -1547,8 +1548,8 @@ function addMissingEntityTabs(input: { currentEntityTabs.filter(isTerminalTab).map((tab) => tab.target.terminalId), ); - const sortedVisibleAgentIds = [...visibleAgentIds].sort(); - for (const agentId of sortedVisibleAgentIds) { + const sortedAutoOpenAgentIds = [...autoOpenAgentIds].sort(); + for (const agentId of sortedAutoOpenAgentIds) { if (currentAgentIds.has(agentId)) { continue; } @@ -1587,13 +1588,20 @@ export function reconcileWorkspaceTabs( const pinnedAgentIds = new Set(state.pinnedAgentIds ?? []); const hiddenAgentIds = new Set(state.hiddenAgentIds ?? []); const activeAgentIds = normalizeStringSet(snapshot.activeAgentIds); + const autoOpenAgentIds = normalizeStringSet(snapshot.autoOpenAgentIds); const knownAgentIds = normalizeStringSet(snapshot.knownAgentIds); const standaloneTerminalIds = normalizeStringSet(snapshot.standaloneTerminalIds); const knownTerminalIds = snapshot.knownTerminalIds ? normalizeStringSet(snapshot.knownTerminalIds) : standaloneTerminalIds; - const visibleAgentIds = buildVisibleAgentIds({ - activeAgentIds, + const visibleAgentIds = applyPinnedAndHidden({ + baseAgentIds: activeAgentIds, + pinnedAgentIds, + hiddenAgentIds, + knownAgentIds, + }); + const autoOpenSet = applyPinnedAndHidden({ + baseAgentIds: autoOpenAgentIds, pinnedAgentIds, hiddenAgentIds, knownAgentIds, @@ -1645,7 +1653,7 @@ export function reconcileWorkspaceTabs( nextLayout = addMissingEntityTabs({ layout: nextLayout, - visibleAgentIds, + autoOpenAgentIds: autoOpenSet, representedAgentIds, standaloneTerminalIds, hasActivePendingDraftCreate: snapshot.hasActivePendingDraftCreate ?? false, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index 124320eb7..7fbe3f73f 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -1173,6 +1173,7 @@ describe("workspace-layout-store actions", () => { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], knownAgentIds: ["agent-1", "agent-2"], standaloneTerminalIds: ["term-1"], hasActivePendingDraftCreate: false, @@ -1210,6 +1211,7 @@ describe("workspace-layout-store actions", () => { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: ["agent-1"], + autoOpenAgentIds: ["agent-1"], knownAgentIds: ["agent-1"], standaloneTerminalIds: [], hasActivePendingDraftCreate: false, @@ -1218,6 +1220,101 @@ describe("workspace-layout-store actions", () => { expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); }); + it("reconcileTabs does not auto-open subagents omitted from autoOpenAgentIds", () => { + const workspaceKey = createWorkspaceKey(); + + useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["parent-agent", "child-agent"], + autoOpenAgentIds: ["parent-agent"], + knownAgentIds: ["parent-agent", "child-agent"], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); + + expect( + useWorkspaceLayoutStore + .getState() + .getWorkspaceTabs(workspaceKey) + .map((tab) => tab.tabId), + ).toEqual(["agent_parent-agent"]); + }); + + it("reconcileTabs keeps manually opened subagent tabs that remain active", () => { + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); + + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["parent-agent", "child-agent"], + autoOpenAgentIds: ["parent-agent"], + knownAgentIds: ["parent-agent", "child-agent"], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); + + expect( + useWorkspaceLayoutStore + .getState() + .getWorkspaceTabs(workspaceKey) + .map((tab) => tab.tabId), + ).toEqual(["agent_child-agent", "agent_parent-agent"]); + }); + + it("reconcileTabs prunes archived subagent tabs that are no longer active", () => { + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); + + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["parent-agent"], + autoOpenAgentIds: ["parent-agent"], + knownAgentIds: ["parent-agent", "child-agent"], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); + + expect( + useWorkspaceLayoutStore + .getState() + .getWorkspaceTabs(workspaceKey) + .map((tab) => tab.tabId), + ).toEqual(["agent_parent-agent"]); + }); + + it("openTabFocused reopens hidden subagent tabs and clears hidden intent", () => { + const workspaceKey = createWorkspaceKey(); + const store = useWorkspaceLayoutStore.getState(); + + store.hideAgent(workspaceKey, "child-agent"); + store.reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: ["child-agent"], + autoOpenAgentIds: [], + knownAgentIds: ["child-agent"], + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); + + expect(useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)).toEqual([]); + + store.openTabFocused(workspaceKey, { kind: "agent", agentId: "child-agent" }); + + const state = useWorkspaceLayoutStore.getState(); + expect(state.hiddenAgentIdsByWorkspace[workspaceKey]).toBeUndefined(); + expect(state.getWorkspaceTabs(workspaceKey).map((tab) => tab.tabId)).toEqual([ + "agent_child-agent", + ]); + }); + it("reconcileTabs auto-opens only standalone terminals while keeping explicitly opened live terminals", () => { const workspaceKey = createWorkspaceKey(); const store = useWorkspaceLayoutStore.getState(); @@ -1231,6 +1328,7 @@ describe("workspace-layout-store actions", () => { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: [], + autoOpenAgentIds: [], knownAgentIds: [], knownTerminalIds: ["term-script", "term-manual"], standaloneTerminalIds: ["term-manual"], @@ -1250,6 +1348,7 @@ describe("workspace-layout-store actions", () => { agentsHydrated: true, terminalsHydrated: true, activeAgentIds: [], + autoOpenAgentIds: [], knownAgentIds: [], knownTerminalIds: ["term-script"], standaloneTerminalIds: [], diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts new file mode 100644 index 000000000..49e85c835 --- /dev/null +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -0,0 +1,218 @@ +import type { DaemonClient } from "@server/client/daemon-client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + deriveWorkspaceAgentVisibility, + type WorkspaceAgentVisibility, +} from "@/screens/workspace/workspace-agent-visibility"; +import { selectSubagentsForParent } from "@/subagents"; +import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store"; +import { useSessionStore, type Agent } from "./session-store"; + +vi.mock("lucide-react-native", () => ({ + Archive: () => null, + Check: () => null, + ChevronDown: () => null, + ChevronRight: () => null, +})); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => + typeof factory === "function" + ? factory({ + spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, + borderWidth: { 1: 1 }, + borderRadius: { sm: 4, md: 6, lg: 8, "2xl": 16, full: 999 }, + fontSize: { xs: 11, sm: 13, base: 15 }, + fontWeight: { normal: "400", medium: "500" }, + iconSize: { sm: 14, md: 18 }, + colors: { + foreground: "#fff", + foregroundMuted: "#aaa", + surface0: "#000", + surface1: "#111", + surface2: "#222", + surface3: "#333", + border: "#444", + borderAccent: "#555", + accent: "#0a84ff", + palette: { + amber: { 500: "#ffbf00", 700: "#aa8000" }, + blue: { 500: "#0a84ff" }, + red: { 500: "#ff453a" }, + green: { 500: "#30d158" }, + }, + }, + }) + : factory, + }, + useUnistyles: () => ({ theme: { colors: {} } }), + withUnistyles: (component: T) => component, +})); + +vi.mock("@/components/provider-icons", () => ({ + getProviderIcon: () => null, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: unknown }) => children, + TooltipTrigger: ({ children }: { children: unknown }) => children, + TooltipContent: () => null, +})); + +vi.mock("@/screens/workspace/workspace-tab-presentation", () => ({ + WorkspaceTabIcon: () => null, +})); + +vi.mock("@react-native-async-storage/async-storage", () => { + const storage = new Map(); + return { + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + removeItem: vi.fn(async (key: string) => { + storage.delete(key); + }), + }, + }; +}); + +const SERVER_ID = "server-1"; +const WORKSPACE_ID = "ws-main"; +const WORKSPACE_DIRECTORY = "/repo/worktree"; + +const AGENT_TIMESTAMP = new Date("2026-04-21T10:00:00.000Z"); + +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: WORKSPACE_DIRECTORY, + model: null, + features: undefined, + thinkingOptionId: undefined, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + parentAgentId: null, + labels: {}, + projectPlacement: null, +}; + +function makeAgent(input: Partial & Pick): Agent { + return { ...AGENT_DEFAULTS, ...input }; +} + +function initializeAgents(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]))); +} + +function appendAgent(agent: Agent): void { + useSessionStore.getState().setAgents(SERVER_ID, (agents) => { + const nextAgents = new Map(agents); + nextAgents.set(agent.id, agent); + return nextAgents; + }); +} + +function deriveVisibilityFromSession(): WorkspaceAgentVisibility { + const sessionAgents = useSessionStore.getState().sessions[SERVER_ID]?.agents ?? new Map(); + return deriveWorkspaceAgentVisibility({ + sessionAgents, + workspaceDirectory: WORKSPACE_DIRECTORY, + }); +} + +function reconcileWorkspaceTabs(workspaceKey: string, visibility: WorkspaceAgentVisibility): void { + useWorkspaceLayoutStore.getState().reconcileTabs(workspaceKey, { + agentsHydrated: true, + terminalsHydrated: true, + activeAgentIds: visibility.activeAgentIds, + autoOpenAgentIds: visibility.autoOpenAgentIds, + knownAgentIds: visibility.knownAgentIds, + standaloneTerminalIds: [], + hasActivePendingDraftCreate: false, + }); +} + +function getWorkspaceTabIds(workspaceKey: string): string[] { + return useWorkspaceLayoutStore + .getState() + .getWorkspaceTabs(workspaceKey) + .map((tab) => tab.tabId); +} + +afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); + useWorkspaceLayoutStore.setState({ + layoutByWorkspace: {}, + splitSizesByWorkspace: {}, + pinnedAgentIdsByWorkspace: {}, + hiddenAgentIdsByWorkspace: {}, + }); +}); + +describe("workspace subagents integration", () => { + it("keeps a child ingested before its parent out of auto-tabs, then exposes it in the parent section", () => { + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + expect(workspaceKey).toBeTruthy(); + + const child = makeAgent({ + id: "child-agent", + parentAgentId: "parent-agent", + title: "Child agent", + }); + const parent = makeAgent({ + id: "parent-agent", + title: "Parent agent", + }); + + initializeAgents([child]); + + reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); + + expect(getWorkspaceTabIds(workspaceKey!)).toEqual([]); + + appendAgent(parent); + + reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); + + expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]); + expect( + selectSubagentsForParent(useSessionStore.getState(), { + serverId: SERVER_ID, + parentAgentId: "parent-agent", + }).map((row) => row.id), + ).toEqual(["child-agent"]); + }); +}); diff --git a/packages/app/src/subagents/auto-open-tab-policy.ts b/packages/app/src/subagents/auto-open-tab-policy.ts new file mode 100644 index 000000000..be11bd2d2 --- /dev/null +++ b/packages/app/src/subagents/auto-open-tab-policy.ts @@ -0,0 +1,5 @@ +import type { Agent } from "@/stores/session-store"; + +export function shouldAutoOpenAgentTab(agent: Pick): boolean { + return !agent.parentAgentId; +} diff --git a/packages/app/src/subagents/close-tab-policy.test.ts b/packages/app/src/subagents/close-tab-policy.test.ts new file mode 100644 index 000000000..cdce429b0 --- /dev/null +++ b/packages/app/src/subagents/close-tab-policy.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; +import { resolveCloseAgentTabPolicy } from "./close-tab-policy"; + +describe("resolveCloseAgentTabPolicy", () => { + it("archives root agents when their tab closes", () => { + expect(resolveCloseAgentTabPolicy({ parentAgentId: null })).toEqual({ + kind: "archive-on-close", + }); + }); + + it("keeps subagent tab close layout-only", () => { + expect(resolveCloseAgentTabPolicy({ parentAgentId: "parent-agent" })).toEqual({ + kind: "layout-only", + }); + }); + + it("preserves the existing archive fallback when the agent is missing", () => { + expect(resolveCloseAgentTabPolicy(null)).toEqual({ kind: "archive-on-close" }); + expect(resolveCloseAgentTabPolicy(undefined)).toEqual({ kind: "archive-on-close" }); + }); +}); diff --git a/packages/app/src/subagents/close-tab-policy.ts b/packages/app/src/subagents/close-tab-policy.ts new file mode 100644 index 000000000..fa82e738b --- /dev/null +++ b/packages/app/src/subagents/close-tab-policy.ts @@ -0,0 +1,13 @@ +import type { Agent } from "@/stores/session-store"; + +export type CloseAgentTabPolicy = { kind: "archive-on-close" } | { kind: "layout-only" }; + +export function resolveCloseAgentTabPolicy( + agent: Pick | null | undefined, +): CloseAgentTabPolicy { + if (agent?.parentAgentId) { + return { kind: "layout-only" }; + } + + return { kind: "archive-on-close" }; +} diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts new file mode 100644 index 000000000..86f48c3be --- /dev/null +++ b/packages/app/src/subagents/index.ts @@ -0,0 +1,7 @@ +export { SubagentsSection } from "./section"; +export type { SubagentsSectionProps } from "./section"; +export type { SubagentRow } from "./select"; +export { selectSubagentsForParent, useSubagentsForParent } from "./select"; +export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; +export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy"; +export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy"; diff --git a/packages/app/src/subagents/policies.ts b/packages/app/src/subagents/policies.ts new file mode 100644 index 000000000..ae7597691 --- /dev/null +++ b/packages/app/src/subagents/policies.ts @@ -0,0 +1,9 @@ +// Pure-data entry point for callers that don't want React Native deps in +// their dependency graph (e.g. workspace-agent-visibility.ts is plain JS +// data derivation and its tests run without an RN environment). +// +// The full module entry `@/subagents` re-exports these too, alongside the +// 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"; diff --git a/packages/app/src/subagents/section.test.tsx b/packages/app/src/subagents/section.test.tsx new file mode 100644 index 000000000..dfc9ecd77 --- /dev/null +++ b/packages/app/src/subagents/section.test.tsx @@ -0,0 +1,291 @@ +/** + * @vitest-environment jsdom + */ +import React from "react"; +import { act } from "@testing-library/react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { SubagentsSection } from "./section"; +import type { SubagentRow } from "./select"; + +const { theme } = vi.hoisted(() => ({ + theme: { + colorScheme: "dark", + spacing: { 0: 0, 1: 4, 2: 8, 3: 12, 4: 16 }, + borderWidth: { 1: 1 }, + borderRadius: { sm: 4, md: 6, lg: 8, "2xl": 16, full: 999 }, + fontSize: { xs: 11, sm: 13, base: 15 }, + fontWeight: { normal: "400", medium: "500" }, + iconSize: { sm: 14, md: 18 }, + colors: { + foreground: "#fff", + foregroundMuted: "#aaa", + surface0: "#000", + surface1: "#111", + surface2: "#222", + surface3: "#333", + border: "#444", + borderAccent: "#555", + accent: "#0a84ff", + palette: { + amber: { 500: "#ffbf00", 700: "#aa8000" }, + blue: { 500: "#0a84ff" }, + red: { 500: "#ff453a" }, + green: { 500: "#30d158" }, + }, + }, + }, +})); + +vi.hoisted(() => { + (globalThis as unknown as { __DEV__: boolean }).__DEV__ = false; +}); + +vi.mock("react-native-unistyles", () => ({ + StyleSheet: { + create: (factory: unknown) => (typeof factory === "function" ? factory(theme) : factory), + }, + useUnistyles: () => ({ theme, rt: { breakpoint: "lg" } }), + withUnistyles: (component: T) => component, +})); + +vi.mock("@/panels/register-panels", () => ({ + ensurePanelsRegistered: () => {}, +})); + +vi.mock("@/components/ui/tooltip", () => ({ + Tooltip: ({ children }: { children: React.ReactNode }) => children, + TooltipTrigger: ({ children }: { children: React.ReactNode }) => children, + TooltipContent: () => null, +})); + +vi.mock("react-native-reanimated", () => ({ + default: { + View: "div", + }, + runOnJS: (fn: (...args: unknown[]) => unknown) => fn, + useSharedValue: (value: unknown) => ({ value }), + useAnimatedStyle: (factory: () => unknown) => factory(), + withTiming: (value: unknown) => value, + withRepeat: (value: unknown) => value, + cancelAnimation: () => {}, + Easing: { + inOut: () => () => 0, + linear: () => 0, + ease: () => 0, + bezier: () => () => 0, + }, + ReduceMotion: { System: "system", Never: "never", Always: "always" }, +})); + +vi.mock("@/components/provider-icons", () => ({ + getProviderIcon: (provider: unknown) => + function ProviderIconStub(props: { size: number; color: string }) { + return React.createElement("span", { + "data-testid": "subagents-provider-icon", + "data-provider": String(provider ?? "none"), + "data-size": String(props.size), + }); + }, +})); + +vi.mock("@/components/synced-loader", () => ({ + SyncedLoader: (props: { size: number; color: string }) => + React.createElement("span", { + "data-testid": "subagents-synced-loader", + "data-size": String(props.size), + }), +})); + +vi.mock("lucide-react-native", () => { + const createIcon = (name: string) => (props: Record) => + React.createElement("span", { ...props, "data-icon": name }); + return { + Archive: createIcon("Archive"), + Check: createIcon("Check"), + ChevronDown: createIcon("ChevronDown"), + ChevronRight: createIcon("ChevronRight"), + }; +}); + +function row(overrides: Partial & Pick): SubagentRow { + return { + id: overrides.id, + provider: overrides.provider ?? "codex", + title: overrides.title ?? `Agent ${overrides.id}`, + status: overrides.status ?? "idle", + requiresAttention: overrides.requiresAttention ?? false, + createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), + }; +} + +function click(element: Element): void { + act(() => { + element.dispatchEvent(new window.MouseEvent("click", { bubbles: true })); + }); +} + +function queryByTestId(testID: string): HTMLElement | null { + return document.querySelector(`[data-testid="${testID}"]`); +} + +function queryRowIds(): string[] { + return Array.from( + document.querySelectorAll('[data-testid^="subagents-section-row-"]'), + ).map((node) => node.getAttribute("data-testid")?.replace("subagents-section-row-", "") ?? ""); +} + +describe("SubagentsSection", () => { + let container: HTMLElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + vi.stubGlobal("React", React); + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class ResizeObserver { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + } + root = null; + container?.remove(); + container = null; + vi.unstubAllGlobals(); + }); + + function render( + rows: SubagentRow[], + onOpenSubagent: ReturnType = vi.fn(), + onArchiveSubagent: ReturnType = vi.fn(), + ): ReturnType { + act(() => { + root?.render( + , + ); + }); + return onOpenSubagent; + } + + it("renders nothing when rows is empty", () => { + render([]); + expect(queryByTestId("subagents-section")).toBeNull(); + expect(queryByTestId("subagents-section-header")).toBeNull(); + expect(queryRowIds()).toEqual([]); + }); + + it("shows only the collapsed header and no rows initially", () => { + render([row({ id: "child-a" }), row({ id: "child-b" })]); + expect(queryByTestId("subagents-section-header")).not.toBeNull(); + expect(queryRowIds()).toEqual([]); + }); + + it("expands rows in the given order when the header is pressed", () => { + render([row({ id: "child-b" }), row({ id: "child-a" }), row({ id: "child-c" })]); + click(queryByTestId("subagents-section-header")!); + expect(queryRowIds()).toEqual(["child-b", "child-a", "child-c"]); + }); + + it("calls onOpenSubagent with the row id when a row is pressed", () => { + const onOpenSubagent = render([row({ id: "child-a" }), row({ id: "child-b" })]); + click(queryByTestId("subagents-section-header")!); + click(queryByTestId("subagents-section-row-child-b")!); + expect(onOpenSubagent).toHaveBeenCalledTimes(1); + expect(onOpenSubagent).toHaveBeenCalledWith("child-b"); + }); + + describe("header copy", () => { + it("renders '2 subagents' when two rows have no running and no attention state", () => { + render([row({ id: "child-a" }), row({ id: "child-b" })]); + expect(queryByTestId("subagents-section-header")?.textContent).toBe("2 subagents"); + }); + + it("renders '3 subagents · 1 running' with a single running row", () => { + render([ + row({ id: "child-a", status: "running" }), + row({ id: "child-b" }), + row({ id: "child-c" }), + ]); + expect(queryByTestId("subagents-section-header")?.textContent).toBe( + "3 subagents · 1 running", + ); + }); + + it("renders '1 subagent · 1 needs attention' for a single attention row", () => { + render([row({ id: "child-a", requiresAttention: true })]); + expect(queryByTestId("subagents-section-header")?.textContent).toBe( + "1 subagent · 1 needs attention", + ); + }); + + it("renders '5 subagents · 2 running · 1 needs attention' when both suffixes apply", () => { + render([ + row({ id: "a", status: "running" }), + row({ id: "b", status: "running" }), + row({ id: "c", requiresAttention: true }), + row({ id: "d" }), + row({ id: "e" }), + ]); + expect(queryByTestId("subagents-section-header")?.textContent).toBe( + "5 subagents · 2 running · 1 needs attention", + ); + }); + }); + + it("counts attention only from amber (idle + requiresAttention) rows", () => { + render([ + row({ id: "a", status: "error", requiresAttention: false }), + row({ id: "b", status: "idle", requiresAttention: false }), + row({ id: "c", status: "idle", requiresAttention: true }), + ]); + expect(queryByTestId("subagents-section-header")?.textContent).toBe( + "3 subagents · 1 needs attention", + ); + }); + + it("excludes errored or running rows from the needs attention count", () => { + render([ + row({ id: "a", status: "error", requiresAttention: true }), + row({ id: "b", status: "running", requiresAttention: true }), + row({ id: "c", status: "idle", requiresAttention: true }), + ]); + expect(queryByTestId("subagents-section-header")?.textContent).toBe( + "3 subagents · 1 running · 1 needs attention", + ); + }); + + it("renders each row through the shared workspace tab icon primitives", () => { + render([ + row({ id: "idle-child", status: "idle", provider: "codex" }), + row({ id: "running-child", status: "running", provider: "claude-code" }), + ]); + click(queryByTestId("subagents-section-header")!); + + const idleRow = queryByTestId("subagents-section-row-idle-child"); + expect(idleRow).not.toBeNull(); + expect(idleRow!.querySelectorAll('[data-testid="subagents-provider-icon"]').length).toBe(1); + expect(idleRow!.querySelectorAll('[data-testid="subagents-synced-loader"]').length).toBe(0); + + const runningRow = queryByTestId("subagents-section-row-running-child"); + expect(runningRow).not.toBeNull(); + expect(runningRow!.querySelectorAll('[data-testid="subagents-synced-loader"]').length).toBe(1); + expect(runningRow!.querySelectorAll('[data-testid="subagents-provider-icon"]').length).toBe(0); + }); +}); diff --git a/packages/app/src/subagents/section.tsx b/packages/app/src/subagents/section.tsx new file mode 100644 index 000000000..f12f6f72e --- /dev/null +++ b/packages/app/src/subagents/section.tsx @@ -0,0 +1,351 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; +import { Archive, ChevronDown, ChevronRight } from "lucide-react-native"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { getProviderIcon } from "@/components/provider-icons"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout"; +import { isNative } from "@/constants/platform"; +import { + WorkspaceTabIcon, + type WorkspaceTabPresentation, +} from "@/screens/workspace/workspace-tab-presentation"; +import type { Theme } from "@/styles/theme"; +import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import type { SubagentRow } from "./select"; + +const ThemedArchive = withUnistyles(Archive); +const ThemedChevronDown = withUnistyles(ChevronDown); +const ThemedChevronRight = withUnistyles(ChevronRight); + +const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +export interface SubagentsSectionProps { + rows: SubagentRow[]; + onOpenSubagent: (id: string) => void; + onArchiveSubagent: (id: string) => void; +} + +const SUBAGENTS_LIST_MAX_HEIGHT = 200; + +function formatHeaderLabel(rows: SubagentRow[]): string { + let runningCount = 0; + let attentionCount = 0; + for (const row of rows) { + if (row.status === "running") { + runningCount += 1; + } + const bucket = deriveSidebarStateBucket({ + status: row.status, + requiresAttention: row.requiresAttention, + }); + if (bucket === "attention") { + attentionCount += 1; + } + } + + const parts = [`${rows.length} ${rows.length === 1 ? "subagent" : "subagents"}`]; + if (runningCount > 0) { + parts.push(`${runningCount} running`); + } + if (attentionCount > 0) { + parts.push(`${attentionCount} needs attention`); + } + return parts.join(" · "); +} + +function resolveRowLabel(title: SubagentRow["title"]): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} + +function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation { + const label = resolveRowLabel(row.title); + return { + key: `subagent_${row.id}`, + kind: "agent", + label: label ?? "", + subtitle: "", + titleState: label ? "ready" : "loading", + icon: getProviderIcon(row.provider), + statusBucket: deriveSidebarStateBucket({ + status: row.status, + requiresAttention: row.requiresAttention, + }), + }; +} + +export function SubagentsSection({ + rows, + onOpenSubagent, + onArchiveSubagent, +}: SubagentsSectionProps): ReactElement | null { + const [expanded, setExpanded] = useState(false); + + const toggleExpanded = useCallback(() => { + setExpanded((current) => !current); + }, []); + + const surfaceStyle = useMemo( + () => [styles.surface, expanded && styles.surfaceExpanded], + [expanded], + ); + + const headerStyle = useCallback( + ({ hovered, pressed }: PressableStateCallbackType) => [ + styles.header, + expanded ? styles.headerDivider : styles.headerCollapsed, + (hovered || pressed) && styles.headerActive, + ], + [expanded], + ); + + if (rows.length === 0) { + return null; + } + + const headerLabel = formatHeaderLabel(rows); + + return ( + + + + + {expanded ? ( + + ) : ( + + )} + + {headerLabel} + + + {expanded ? ( + + {rows.map((row) => ( + + ))} + + ) : null} + + + + ); +} + +interface SubagentsSectionRowProps { + row: SubagentRow; + onOpenSubagent: (id: string) => void; + onArchiveSubagent: (id: string) => void; +} + +function SubagentsSectionRow({ + row, + onOpenSubagent, + onArchiveSubagent, +}: SubagentsSectionRowProps): ReactElement { + const isCompact = useIsCompactFormFactor(); + const [hovered, setHovered] = useState(false); + const presentation = useMemo(() => buildRowPresentation(row), [row]); + const displayLabel = presentation.titleState === "loading" ? "Loading..." : presentation.label; + const handlePress = useCallback(() => { + onOpenSubagent(row.id); + }, [onOpenSubagent, row.id]); + const handleArchivePress = useCallback(() => { + onArchiveSubagent(row.id); + }, [onArchiveSubagent, row.id]); + const handlePointerEnter = useCallback(() => setHovered(true), []); + const handlePointerLeave = useCallback(() => setHovered(false), []); + const archiveAlwaysVisible = isNative || isCompact; + const archiveVisible = archiveAlwaysVisible || hovered; + + return ( + // Wrapper View handles hover so moving the pointer between the row and + // the archive button doesn't drop the hover state — the same pattern + // used by sidebar workspace rows. + + + {({ pressed }) => ( + + + + {displayLabel} + + + + )} + + + ); +} + +function SubagentArchiveButton({ + rowId, + displayLabel, + visible, + onPress, +}: { + rowId: string; + displayLabel: string; + visible: boolean; + onPress: () => void; +}): ReactElement { + return ( + + + + + {({ hovered, pressed }) => ( + + )} + + + + Archive subagent + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + outer: { + width: "100%", + alignItems: "center", + paddingHorizontal: theme.spacing[4], + }, + track: { + width: "100%", + maxWidth: MAX_CONTENT_WIDTH, + marginBottom: -theme.spacing[4], + }, + surface: { + alignSelf: "stretch", + backgroundColor: theme.colors.surface1, + borderWidth: theme.borderWidth[1], + borderColor: theme.colors.borderAccent, + borderBottomWidth: 0, + borderTopLeftRadius: theme.borderRadius["2xl"], + borderTopRightRadius: theme.borderRadius["2xl"], + overflow: "hidden", + }, + surfaceExpanded: { + paddingBottom: theme.spacing[4], + }, + header: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + headerCollapsed: { + paddingBottom: theme.spacing[6], + }, + headerActive: { + backgroundColor: theme.colors.surface2, + }, + headerDivider: { + borderBottomWidth: theme.borderWidth[1], + borderBottomColor: theme.colors.border, + }, + headerLabel: { + flexShrink: 1, + minWidth: 0, + fontSize: theme.fontSize.xs, + color: theme.colors.foregroundMuted, + }, + scroll: { + maxHeight: SUBAGENTS_LIST_MAX_HEIGHT, + }, + scrollContent: { + paddingVertical: theme.spacing[1], + }, + row: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + }, + rowActive: { + flexDirection: "row", + alignItems: "center", + gap: theme.spacing[2], + paddingHorizontal: theme.spacing[3], + paddingVertical: theme.spacing[2], + backgroundColor: theme.colors.surface2, + }, + rowLabel: { + flex: 1, + minWidth: 0, + fontSize: theme.fontSize.sm, + color: theme.colors.foreground, + }, + archiveSlotVisible: { + opacity: 1, + }, + archiveSlotHidden: { + opacity: 0, + }, + archiveButton: { + padding: theme.spacing[1], + alignItems: "center", + justifyContent: "center", + }, + tooltipText: { + fontSize: theme.fontSize.xs, + color: theme.colors.foreground, + }, +})); diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts new file mode 100644 index 000000000..b8b316bb3 --- /dev/null +++ b/packages/app/src/subagents/select.test.ts @@ -0,0 +1,229 @@ +import type { DaemonClient } from "@server/client/daemon-client"; +import { afterEach, describe, expect, it } from "vitest"; +import { selectSubagentsForParent } from "./select"; +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 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 & Pick): 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); +}); + +describe("selectSubagentsForParent", () => { + 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", + }); + + 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", + }); + + 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", + }); + const childRows = selectSubagentsForParent(useSessionStore.getState(), { + serverId: SERVER_ID, + parentAgentId: "child", + }); + + 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", + }); + + 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", + }); + + expect(rows).toEqual([ + { + id: "child", + provider: "claude", + title: "Review child", + status: "running", + requiresAttention: true, + createdAt, + }, + ]); + expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ + "createdAt", + "id", + "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", + }).map((row) => row.id), + ).toEqual(["child"]); + expect( + selectSubagentsForParent(useSessionStore.getState(), { + serverId: SERVER_ID, + parentAgentId: "parent-b", + }).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", + }).map((row) => row.id), + ).toEqual([]); + expect( + selectSubagentsForParent(useSessionStore.getState(), { + serverId: SERVER_ID, + parentAgentId: "parent-b", + }).map((row) => row.id), + ).toEqual(["child"]); + }); +}); diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts new file mode 100644 index 000000000..a58e32e16 --- /dev/null +++ b/packages/app/src/subagents/select.ts @@ -0,0 +1,65 @@ +import equal from "fast-deep-equal"; +import { useStoreWithEqualityFn } from "zustand/traditional"; +import { useSessionStore, type Agent } from "@/stores/session-store"; + +export interface SubagentRow { + id: Agent["id"]; + provider: Agent["provider"]; + title: Agent["title"]; + status: Agent["status"]; + requiresAttention: Agent["requiresAttention"]; + createdAt: Agent["createdAt"]; +} + +type SessionStoreSnapshot = ReturnType; + +interface SelectSubagentsParams { + serverId: string; + parentAgentId: string; +} + +const EMPTY_SUBAGENT_ROWS: SubagentRow[] = []; + +function toSubagentRow(agent: Agent): SubagentRow { + return { + id: agent.id, + provider: agent.provider, + title: agent.title, + status: agent.status, + requiresAttention: agent.requiresAttention, + createdAt: agent.createdAt, + }; +} + +export function selectSubagentsForParent( + state: SessionStoreSnapshot, + params: SelectSubagentsParams, +): SubagentRow[] { + const agents = state.sessions[params.serverId]?.agents; + if (!agents || agents.size === 0) { + return EMPTY_SUBAGENT_ROWS; + } + + const rows: SubagentRow[] = []; + for (const agent of agents.values()) { + if (agent.archivedAt || agent.parentAgentId !== params.parentAgentId) { + continue; + } + rows.push(toSubagentRow(agent)); + } + + if (rows.length === 0) { + return EMPTY_SUBAGENT_ROWS; + } + + rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime()); + return rows; +} + +export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] { + return useStoreWithEqualityFn( + useSessionStore, + (state) => selectSubagentsForParent(state, params), + equal, + ); +} diff --git a/packages/app/src/subagents/use-archive-subagent.test.ts b/packages/app/src/subagents/use-archive-subagent.test.ts new file mode 100644 index 000000000..dbc03fa67 --- /dev/null +++ b/packages/app/src/subagents/use-archive-subagent.test.ts @@ -0,0 +1,217 @@ +/** + * @vitest-environment jsdom + */ +import { act, renderHook } from "@testing-library/react"; +import type { DaemonClient } from "@server/client/daemon-client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useArchiveSubagent } from "@/subagents"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { resolveArchiveSubagentDialog } from "./use-archive-subagent"; + +const { archiveAgentMock, confirmDialogMock } = vi.hoisted(() => ({ + archiveAgentMock: vi.fn(), + confirmDialogMock: vi.fn(), +})); + +vi.mock("@/hooks/use-archive-agent", () => ({ + useArchiveAgent: () => ({ + archiveAgent: archiveAgentMock, + }), +})); + +vi.mock("@/utils/confirm-dialog", () => ({ + confirmDialog: confirmDialogMock, +})); + +vi.mock("./section", () => ({ + SubagentsSection: () => null, +})); + +const SERVER_ID = "server-1"; + +function makeAgent(input: { id: string; title?: Agent["title"]; status?: Agent["status"] }): Agent { + const createdAt = new Date("2026-03-04T00:00:00.000Z"); + return { + serverId: SERVER_ID, + id: input.id, + provider: "codex", + status: input.status ?? "idle", + createdAt, + updatedAt: createdAt, + lastUserMessageAt: null, + lastActivityAt: createdAt, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider: "codex", + sessionId: null, + }, + title: input.title ?? null, + cwd: "/repo/worktree", + model: null, + thinkingOptionId: null, + parentAgentId: "parent-agent", + labels: {}, + requiresAttention: false, + attentionReason: null, + attentionTimestamp: null, + archivedAt: null, + }; +} + +function seedSubagent(subagent: Agent): void { + const client = { archiveAgent: vi.fn() } as unknown as DaemonClient; + useSessionStore.getState().initializeSession(SERVER_ID, client); + useSessionStore.getState().setAgents(SERVER_ID, new Map([[subagent.id, subagent]])); +} + +describe("resolveArchiveSubagentDialog", () => { + it("uses running copy for running subagents", () => { + expect( + resolveArchiveSubagentDialog({ + title: "Review branch", + status: "running", + }), + ).toEqual({ + title: "Archive running subagent?", + message: + "Review branch is still running. Archiving it will stop the subagent and remove it from the track.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); + + it("uses running copy for initializing subagents", () => { + expect( + resolveArchiveSubagentDialog({ + title: "Starting child", + status: "initializing", + }), + ).toEqual({ + title: "Archive running subagent?", + message: + "Starting child is still running. Archiving it will stop the subagent and remove it from the track.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); + + it("uses idle copy for non-running subagents", () => { + expect( + resolveArchiveSubagentDialog({ + title: "Review branch", + status: "idle", + }), + ).toEqual({ + title: "Archive subagent?", + message: "Remove Review branch from the track. The subagent will be archived.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); + + it("falls back to this subagent when the title is not displayable", () => { + expect( + resolveArchiveSubagentDialog({ + title: "New Agent", + status: null, + }), + ).toEqual({ + title: "Archive subagent?", + message: "Remove this subagent from the track. The subagent will be archived.", + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }); + }); +}); + +describe("useArchiveSubagent", () => { + beforeEach(() => { + archiveAgentMock.mockReset(); + archiveAgentMock.mockResolvedValue(undefined); + confirmDialogMock.mockReset(); + useSessionStore.getState().clearSession(SERVER_ID); + }); + + afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); + }); + + it("archives the subagent with the server id when the user confirms", async () => { + const subagent = makeAgent({ + id: "child-agent", + title: "Review branch", + status: "running", + }); + seedSubagent(subagent); + confirmDialogMock.mockResolvedValue(true); + + const { result } = renderHook(() => useArchiveSubagent({ serverId: SERVER_ID })); + + await act(async () => { + await (result.current as (subagentId: string) => Promise)(subagent.id); + }); + + expect(archiveAgentMock).toHaveBeenCalledTimes(1); + expect(archiveAgentMock).toHaveBeenCalledWith({ + serverId: SERVER_ID, + agentId: subagent.id, + }); + }); + + it("does not archive the subagent when the user cancels", async () => { + const subagent = makeAgent({ + id: "child-agent", + title: "Review branch", + status: "idle", + }); + seedSubagent(subagent); + confirmDialogMock.mockResolvedValue(false); + + const { result } = renderHook(() => useArchiveSubagent({ serverId: SERVER_ID })); + + await act(async () => { + await (result.current as (subagentId: string) => Promise)(subagent.id); + }); + + expect(archiveAgentMock).not.toHaveBeenCalled(); + }); + + it("passes the resolved dialog input for the subagent to confirmDialog", async () => { + const subagent = makeAgent({ + id: "child-agent", + title: "Review branch", + status: "running", + }); + seedSubagent(subagent); + confirmDialogMock.mockResolvedValue(false); + + const { result } = renderHook(() => useArchiveSubagent({ serverId: SERVER_ID })); + + await act(async () => { + await (result.current as (subagentId: string) => Promise)(subagent.id); + }); + + expect(confirmDialogMock).toHaveBeenCalledTimes(1); + expect(confirmDialogMock).toHaveBeenCalledWith( + resolveArchiveSubagentDialog({ + title: subagent.title, + status: subagent.status, + }), + ); + }); +}); diff --git a/packages/app/src/subagents/use-archive-subagent.ts b/packages/app/src/subagents/use-archive-subagent.ts new file mode 100644 index 000000000..da6188f2f --- /dev/null +++ b/packages/app/src/subagents/use-archive-subagent.ts @@ -0,0 +1,66 @@ +import { useCallback } from "react"; +import { useArchiveAgent } from "@/hooks/use-archive-agent"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { confirmDialog, type ConfirmDialogInput } from "@/utils/confirm-dialog"; + +export interface UseArchiveSubagentInput { + serverId: string; +} + +interface ResolveArchiveSubagentDialogInput { + title: Agent["title"] | null | undefined; + status: Agent["status"] | null | undefined; +} + +function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null { + if (typeof title !== "string") { + return null; + } + const normalized = title.trim(); + if (!normalized) { + return null; + } + if (normalized.toLowerCase() === "new agent") { + return null; + } + return normalized; +} + +export function resolveArchiveSubagentDialog( + input: ResolveArchiveSubagentDialogInput, +): ConfirmDialogInput { + const subagentLabel = resolveSubagentLabel(input.title) ?? "this subagent"; + const isRunning = input.status === "running" || input.status === "initializing"; + + return { + title: isRunning ? "Archive running subagent?" : "Archive subagent?", + message: isRunning + ? `${subagentLabel} is still running. Archiving it will stop the subagent and remove it from the track.` + : `Remove ${subagentLabel} from the track. The subagent will be archived.`, + confirmLabel: "Archive", + cancelLabel: "Cancel", + destructive: true, + }; +} + +export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void { + const { archiveAgent } = useArchiveAgent(); + const { serverId } = input; + + return useCallback( + async (subagentId: string) => { + const subagent = useSessionStore.getState().sessions[serverId]?.agents?.get(subagentId); + const confirmed = await confirmDialog( + resolveArchiveSubagentDialog({ + title: subagent?.title, + status: subagent?.status, + }), + ); + if (!confirmed) { + return; + } + void archiveAgent({ serverId, agentId: subagentId }).catch(() => {}); + }, + [archiveAgent, serverId], + ); +} diff --git a/packages/app/src/utils/agent-directory-sync.test.ts b/packages/app/src/utils/agent-directory-sync.test.ts new file mode 100644 index 000000000..61a4e4456 --- /dev/null +++ b/packages/app/src/utils/agent-directory-sync.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it } from "vitest"; +import type { DaemonClient, FetchAgentsEntry } from "@server/client/daemon-client"; +import type { AgentSnapshotPayload } from "@server/shared/messages"; +import { PARENT_AGENT_ID_LABEL } from "@server/shared/agent-labels"; +import { useSessionStore } from "@/stores/session-store"; +import { replaceFetchedAgentDirectory } from "./agent-directory-sync"; + +function createAgentPayload( + input: Partial> & { + id: string; + labels?: Record; + }, +): AgentSnapshotPayload { + return { + id: input.id, + provider: input.provider ?? "codex", + cwd: input.cwd ?? "/repo", + model: input.model ?? null, + createdAt: input.createdAt ?? "2026-04-20T00:00:00.000Z", + updatedAt: input.updatedAt ?? "2026-04-20T00:01:00.000Z", + lastUserMessageAt: input.lastUserMessageAt ?? null, + status: input.status ?? "idle", + capabilities: input.capabilities ?? { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: input.currentModeId ?? null, + availableModes: input.availableModes ?? [], + pendingPermissions: input.pendingPermissions ?? [], + persistence: input.persistence ?? null, + title: input.title ?? null, + labels: input.labels ?? {}, + }; +} + +function createEntry(agent: AgentSnapshotPayload): FetchAgentsEntry { + return { + agent, + project: { + projectKey: agent.cwd, + projectName: "repo", + checkout: { + cwd: agent.cwd, + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }, + }; +} + +describe("replaceFetchedAgentDirectory", () => { + it("re-derives parentAgentId every time an agent snapshot is ingested", () => { + const serverId = "server-1"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + + replaceFetchedAgentDirectory({ + serverId, + entries: [ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" }, + }), + ), + ], + }); + + replaceFetchedAgentDirectory({ + serverId, + entries: [ + createEntry( + createAgentPayload({ + id: "child-1", + labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" }, + }), + ), + ], + }); + + expect( + useSessionStore.getState().sessions[serverId]?.agents.get("child-1")?.parentAgentId, + ).toBe("parent-b"); + + store.clearSession(serverId); + }); +}); diff --git a/packages/app/src/utils/agent-snapshots.test.ts b/packages/app/src/utils/agent-snapshots.test.ts new file mode 100644 index 000000000..ee0b24449 --- /dev/null +++ b/packages/app/src/utils/agent-snapshots.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import type { AgentSnapshotPayload } from "@server/shared/messages"; +import { PARENT_AGENT_ID_LABEL } from "@server/shared/agent-labels"; +import { normalizeAgentSnapshot } from "./agent-snapshots"; + +function createSnapshot( + input: Partial> & { + labels?: Record; + } = {}, +): AgentSnapshotPayload { + return { + id: input.id ?? "agent-1", + provider: input.provider ?? "codex", + cwd: input.cwd ?? "/repo", + model: input.model ?? null, + createdAt: input.createdAt ?? "2026-04-20T00:00:00.000Z", + updatedAt: input.updatedAt ?? "2026-04-20T00:01:00.000Z", + lastUserMessageAt: input.lastUserMessageAt ?? null, + status: input.status ?? "idle", + capabilities: input.capabilities ?? { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: input.currentModeId ?? null, + availableModes: input.availableModes ?? [], + pendingPermissions: input.pendingPermissions ?? [], + persistence: input.persistence ?? null, + title: input.title ?? null, + labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"], + }; +} + +describe("normalizeAgentSnapshot", () => { + it("derives parentAgentId from the parent label while preserving labels", () => { + const labels = { + [PARENT_AGENT_ID_LABEL]: "parent-1", + "custom.label": "still-here", + }; + + const agent = normalizeAgentSnapshot(createSnapshot({ labels }), "server-1"); + + expect(agent.parentAgentId).toBe("parent-1"); + expect(agent.labels).toEqual(labels); + }); + + it("trims whitespace around the parent label", () => { + const agent = normalizeAgentSnapshot( + createSnapshot({ labels: { [PARENT_AGENT_ID_LABEL]: " parent-1 \n" } }), + "server-1", + ); + + expect(agent.parentAgentId).toBe("parent-1"); + }); + + it("maps missing, empty, and non-string parent labels to null", () => { + const missing = normalizeAgentSnapshot(createSnapshot(), "server-1"); + const empty = normalizeAgentSnapshot( + createSnapshot({ labels: { [PARENT_AGENT_ID_LABEL]: " " } }), + "server-1", + ); + const nonString = normalizeAgentSnapshot( + createSnapshot({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } }), + "server-1", + ); + + expect(missing.parentAgentId).toBeNull(); + expect(empty.parentAgentId).toBeNull(); + expect(nonString.parentAgentId).toBeNull(); + }); +}); diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index 341273a98..1933886c6 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -1,5 +1,6 @@ import type { AgentSnapshotPayload } from "@server/shared/messages"; import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-types"; +import { PARENT_AGENT_ID_LABEL } from "@server/shared/agent-labels"; export function derivePendingPermissionKey( agentId: string, @@ -25,6 +26,11 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: ? new Date(snapshot.attentionTimestamp) : null; const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null; + const parentAgentLabel = snapshot.labels?.[PARENT_AGENT_ID_LABEL]; + const parentAgentId = + typeof parentAgentLabel === "string" && parentAgentLabel.trim().length > 0 + ? parentAgentLabel.trim() + : null; return { serverId, @@ -52,6 +58,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: attentionReason: snapshot.attentionReason ?? null, attentionTimestamp, archivedAt, + parentAgentId, labels: snapshot.labels, }; } diff --git a/packages/cli/src/commands/agent/inspect.ts b/packages/cli/src/commands/agent/inspect.ts index d3fbd007b..3ad3992c1 100644 --- a/packages/cli/src/commands/agent/inspect.ts +++ b/packages/cli/src/commands/agent/inspect.ts @@ -1,5 +1,5 @@ import type { Command } from "commander"; -import type { AgentSnapshotPayload } from "@getpaseo/server"; +import { PARENT_AGENT_ID_LABEL, type AgentSnapshotPayload } from "@getpaseo/server"; import { connectToDaemon, getDaemonHost } from "../../utils/client.js"; import type { CommandOptions, ListResult, OutputSchema, CommandError } from "../../output/index.js"; @@ -149,7 +149,7 @@ function toInspectData(snapshot: AgentSnapshotPayload): AgentInspect { tool: p.name ?? "unknown", })), Worktree: snapshot.labels?.["paseo.worktree"] ?? null, - ParentAgentId: snapshot.labels?.["paseo.parent-agent-id"] ?? null, + ParentAgentId: snapshot.labels?.[PARENT_AGENT_ID_LABEL] ?? null, }; } diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index eb53ef650..72d7a7dd6 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -7,6 +7,8 @@ import { randomUUID } from "node:crypto"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; import { AgentStorage } from "./agent-storage.js"; +import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js"; +import type { StoredAgentRecord } from "./agent-storage.js"; import type { AgentClient, AgentCreateSessionOptions, @@ -81,6 +83,20 @@ function createPersistedDescriptor(args: { }; } +function expectArchivedAgentRecord( + record: StoredAgentRecord | null, + expectedLastStatus: "closed" | "idle", +): void { + expect(record).not.toBeNull(); + expect(record?.archivedAt).toEqual( + expect.stringMatching(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/), + ); + expect(record?.lastStatus).toBe(expectedLastStatus); + expect(record?.requiresAttention).toBe(false); + expect(record?.attentionReason).toBeNull(); + expect(record?.attentionTimestamp).toBeNull(); +} + class TestAgentClient implements AgentClient { readonly provider = "codex" as const; readonly capabilities = TEST_CAPABILITIES; @@ -3762,6 +3778,275 @@ test("archiveAgent persists archivedAt and updatedAt before emitting closed stat expect(lifecycles.slice(-2)).toEqual(["idle", "closed"]); }); +test("archiveAgent cascade archives in-memory children with the full archive contract", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-contract-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + + const parent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Parent", + }); + const child = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Child", + }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + ); + const unrelated = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Unrelated", + }); + + await manager.archiveAgent(parent.id); + + const storedParent = await storage.get(parent.id); + const storedChild = await storage.get(child.id); + const storedUnrelated = await storage.get(unrelated.id); + + expectArchivedAgentRecord(storedParent, "closed"); + expectArchivedAgentRecord(storedChild, "closed"); + expect(storedUnrelated?.archivedAt).toBeUndefined(); +}); + +test("archiveAgent cascade closes a running child runtime", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-running-child-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const finishRun = deferred(); + + class RunningChildSession extends TestAgentSession { + closeCalled = false; + + override async startTurn(): Promise<{ turnId: string }> { + const turnId = "running-child-turn"; + void (async () => { + this.pushEvent({ type: "turn_started", provider: this.provider, turnId }); + await finishRun.promise; + this.pushEvent({ type: "turn_completed", provider: this.provider, turnId }); + })(); + return { turnId }; + } + + override async close(): Promise { + this.closeCalled = true; + } + } + + class RunningChildClient extends TestAgentClient { + readonly sessions: RunningChildSession[] = []; + + override async createSession(config: AgentSessionConfig): Promise { + const session = new RunningChildSession(config); + this.sessions.push(session); + return session; + } + } + + const client = new RunningChildClient(); + const manager = new AgentManager({ + clients: { + codex: client, + }, + registry: storage, + logger, + }); + const parent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Parent", + }); + const child = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Running Child", + }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + ); + const childSession = client.sessions[1]; + const childLifecycleEvents: string[] = []; + const unsubscribe = manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === child.id) { + childLifecycleEvents.push(event.agent.lifecycle); + } + }, + { agentId: child.id, replayState: false }, + ); + const childRun = manager.streamAgent(child.id, "keep running"); + const drainChildRun = (async () => { + for await (const _event of childRun) { + // Drain the foreground turn while archive closes it. + } + })(); + + await manager.waitForAgentRunStart(child.id); + + await manager.archiveAgent(parent.id); + finishRun.resolve(); + await drainChildRun; + unsubscribe(); + + expect(childSession?.closeCalled).toBe(true); + expect(manager.getAgent(child.id)).toBeNull(); + expect(childLifecycleEvents).toContain("closed"); +}); + +test("archiveAgent cascade archives off-memory children with the full archive contract", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-off-memory-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + const parent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Parent", + }); + const child = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Off-memory Child", + }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + ); + const managerInternals = manager as unknown as { + agents: Map; + }; + managerInternals.agents.delete(child.id); + + await manager.archiveAgent(parent.id); + + expectArchivedAgentRecord(await storage.get(child.id), "idle"); +}); + +test("archiveAgent cascade notifies subscribers for in-memory and off-memory children", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-notifications-")); + const storagePath = join(workdir, "agents"); + const storage = new AgentStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + const parent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Parent", + }); + const inMemoryChild = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "In-memory Child", + }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + ); + const offMemoryChild = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Off-memory Child", + }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + ); + const managerInternals = manager as unknown as { + agents: Map; + }; + managerInternals.agents.delete(offMemoryChild.id); + const cascadedChildEvents: string[] = []; + const unsubscribe = manager.subscribe( + (event) => { + if (event.type !== "agent_state") { + return; + } + if (event.agent.id === inMemoryChild.id || event.agent.id === offMemoryChild.id) { + cascadedChildEvents.push(event.agent.id); + } + }, + { replayState: false }, + ); + + await manager.archiveAgent(parent.id); + unsubscribe(); + + expect({ + inMemoryChildNotified: cascadedChildEvents.includes(inMemoryChild.id), + offMemoryChildNotified: cascadedChildEvents.includes(offMemoryChild.id), + }).toEqual({ + inMemoryChildNotified: true, + offMemoryChildNotified: true, + }); +}); + +test("archiveAgent cascade surfaces partial child archive failures", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-partial-failure-")); + const storagePath = join(workdir, "agents"); + let failingChildId: string | null = null; + + class FailingChildArchiveStorage extends AgentStorage { + override async upsert(record: StoredAgentRecord): Promise { + if (record.id === failingChildId && record.archivedAt) { + throw new Error(`Injected cascade archive failure for ${record.id}`); + } + await super.upsert(record); + } + } + + const storage = new FailingChildArchiveStorage(storagePath, logger); + const manager = new AgentManager({ + clients: { + codex: new TestAgentClient(), + }, + registry: storage, + logger, + }); + const parent = await manager.createAgent({ + provider: "codex", + cwd: workdir, + title: "Parent", + }); + const child = await manager.createAgent( + { + provider: "codex", + cwd: workdir, + title: "Failing Child", + }, + undefined, + { labels: { [PARENT_AGENT_ID_LABEL]: parent.id } }, + ); + failingChildId = child.id; + + await expect(manager.archiveAgent(parent.id)).rejects.toThrow( + `Injected cascade archive failure for ${child.id}`, + ); +}); + test("turn_failed emits a system error assistant timeline message and keeps error lifecycle", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-turn-failed-")); const storagePath = join(workdir, "agents"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fc04e3b99..614d136ff 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -5,6 +5,7 @@ import { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus, } from "../../shared/agent-lifecycle.js"; +import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js"; import type { Logger } from "pino"; import { z } from "zod"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; @@ -54,8 +55,17 @@ import { getAgentProviderDefinition } from "./provider-manifest.js"; const RELOAD_SESSION_CLOSE_TIMEOUT_MS = 3_000; const INTERRUPT_SESSION_TIMEOUT_MS = 2_000; +const STORED_AGENT_CAPABILITIES: AgentCapabilityFlags = { + supportsStreaming: false, + supportsSessionPersistence: true, + supportsDynamicModes: false, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, +}; type TimeoutResult = "completed" | "timed_out"; +type ArchivedStoredAgentRecord = StoredAgentRecord & { archivedAt: string }; interface TimeoutOptions { operation: Promise; @@ -67,6 +77,31 @@ function formatProviderList(providers: readonly string[]): string { return providers.length > 0 ? providers.join(", ") : "none"; } +function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig { + const config: AgentSessionConfig = { + provider: record.provider, + cwd: record.cwd, + }; + if (!record.config) { + return config; + } + if (record.config.title != null) config.title = record.config.title; + if (record.config.modeId != null) config.modeId = record.config.modeId; + if (record.config.model != null) config.model = record.config.model; + if (record.config.thinkingOptionId != null) { + config.thinkingOptionId = record.config.thinkingOptionId; + } + if (record.config.featureValues != null) { + config.featureValues = record.config.featureValues; + } + if (record.config.extra != null) config.extra = record.config.extra; + if (record.config.systemPrompt != null) { + config.systemPrompt = record.config.systemPrompt; + } + if (record.config.mcpServers != null) config.mcpServers = record.config.mcpServers; + return config; +} + export { AGENT_LIFECYCLE_STATUSES, type AgentLifecycleStatus }; export type { AgentTimelineCursor, @@ -960,28 +995,107 @@ export class AgentManager { throw new Error(`Agent ${agentId} not found in storage after snapshot`); } + const { archivedAt } = await this.markRecordArchived(stored); + await this.closeAgent(agentId); + + await this.cascadeArchiveChildren(agentId); + + return { archivedAt }; + } + + // Children created via the MCP `create_agent` tool carry the parent-agent-id + // label pointing back at the caller. Archiving the parent cascades to those + // children so subagent fleets don't outlive their orchestrator. Handoff agents + // launched the same way are caught by this cascade — see docs/agent-lifecycle.md + // for the accepted limitation. + private async cascadeArchiveChildren(parentAgentId: string): Promise { + const registry = this.registry; + if (!registry) { + return; + } + const records = await registry.list(); + for (const record of records) { + if (record.archivedAt) { + continue; + } + if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) { + continue; + } + if (this.agents.has(record.id)) { + await this.archiveAgent(record.id); + } else { + await this.markRecordArchived(record); + await this.cascadeArchiveChildren(record.id); + } + } + } + + private async markRecordArchived(record: StoredAgentRecord): Promise { + const registry = this.requireRegistry(); const archivedAt = new Date().toISOString(); const normalizedStatus = - stored.lastStatus === "running" || stored.lastStatus === "initializing" + record.lastStatus === "running" || record.lastStatus === "initializing" ? "idle" - : stored.lastStatus; - - await this.registry.upsert({ - ...stored, + : record.lastStatus; + const archivedRecord: ArchivedStoredAgentRecord = { + ...record, archivedAt, updatedAt: archivedAt, lastStatus: normalizedStatus, requiresAttention: false, attentionReason: null, attentionTimestamp: null, + }; + + await registry.upsert(archivedRecord); + + await this.archiveNativeSessionBestEffort(record.provider, record.persistence); + + if (this.agents.has(record.id)) { + this.notifyAgentState(record.id); + } else if (!archivedRecord.internal) { + this.dispatchArchivedStoredAgent(archivedRecord); + } + + return archivedRecord; + } + + private dispatchArchivedStoredAgent(record: StoredAgentRecord): void { + const updatedAt = new Date(record.updatedAt); + this.dispatch({ + type: "agent_state", + agent: { + id: record.id, + provider: record.provider, + cwd: record.cwd, + session: null, + capabilities: STORED_AGENT_CAPABILITIES, + config: buildStoredAgentConfig(record), + runtimeInfo: undefined, + lifecycle: "closed", + createdAt: new Date(record.createdAt), + updatedAt, + availableModes: [], + features: record.features, + currentModeId: record.lastModeId ?? null, + pendingPermissions: new Map(), + bufferedPermissionResolutions: new Map(), + inFlightPermissionResponses: new Set(), + pendingReplacement: false, + activeForegroundTurnId: null, + foregroundTurnWaiters: new Set(), + finalizedForegroundTurnIds: new Set(), + unsubscribeSession: null, + persistence: record.persistence ?? null, + historyPrimed: true, + lastUserMessageAt: record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null, + lastUsage: undefined, + lastError: record.lastError ?? undefined, + attention: { requiresAttention: false }, + internal: record.internal, + labels: record.labels, + }, }); - - await this.archiveNativeSessionBestEffort(agent.provider, stored.persistence); - - this.notifyAgentState(agentId); - await this.closeAgent(agentId); - - return { archivedAt }; } async setAgentMode(agentId: string, modeId: string): Promise { diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 0a0ab4ba7..cf2b7dae0 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -9,6 +9,7 @@ import { z } from "zod"; import { AGENT_WAIT_TIMEOUT_MS } from "./mcp-shared.js"; import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js"; +import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js"; interface StructuredContent { [key: string]: unknown; @@ -261,13 +262,13 @@ describe("Suite A: Core Fixes", () => { expect(AGENT_WAIT_TIMEOUT_MS).toBe(30_000); }); - test("create_agent with callerAgentId sets paseo.parent-agent-id label", async () => { + test("create_agent with callerAgentId sets the parent agent label", async () => { let agentId: string | null = null; try { agentId = await createChildAgent(); const snapshot = daemonHandle.daemon.agentManager.getAgent(agentId); expect(snapshot?.labels).toMatchObject({ - "paseo.parent-agent-id": parentAgentId, + [PARENT_AGENT_ID_LABEL]: parentAgentId, }); } finally { await archiveAgentIfPresent(agentId); diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 3c9a53533..65a6609d3 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -24,6 +24,7 @@ import type { CreatePaseoWorktreeWorkflowFn } from "../worktree-session.js"; import { WorkspaceGitServiceImpl } from "../workspace-git-service.js"; import type { GitHubService } from "../../services/github-service.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; +import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js"; const REPO_CWD = resolvePath("/tmp/repo"); const TARGET_CWD = resolvePath("/tmp/target"); @@ -1246,7 +1247,7 @@ describe("create_agent MCP tool", () => { undefined, { labels: { - "paseo.parent-agent-id": "voice-agent", + [PARENT_AGENT_ID_LABEL]: "voice-agent", source: "voice", }, }, diff --git a/packages/server/src/server/agent/mcp-server.ts b/packages/server/src/server/agent/mcp-server.ts index 0261d2574..39c4f13fc 100644 --- a/packages/server/src/server/agent/mcp-server.ts +++ b/packages/server/src/server/agent/mcp-server.ts @@ -38,6 +38,7 @@ import { WaitForAgentTracker } from "./wait-for-agent-tracker.js"; import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js"; import type { VoiceCallerContext, VoiceSpeakHandler } from "../voice-types.js"; import { expandUserPath, isSameOrDescendantPath, resolvePathFromBase } from "../path-utils.js"; +import { PARENT_AGENT_ID_LABEL } from "../../shared/agent-labels.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { AgentWorktreeSetupContinuation, @@ -822,7 +823,7 @@ export async function createAgentMcpServer(options: AgentMcpServerOptions): Prom const childAgentDefaultLabels = callerContext?.childAgentDefaultLabels; const mergedLabels = { - ...(callerAgentId ? { "paseo.parent-agent-id": callerAgentId } : {}), + ...(callerAgentId ? { [PARENT_AGENT_ID_LABEL]: callerAgentId } : {}), ...childAgentDefaultLabels, ...labels, }; diff --git a/packages/server/src/server/exports.ts b/packages/server/src/server/exports.ts index b221ec5d1..82b68a24f 100644 --- a/packages/server/src/server/exports.ts +++ b/packages/server/src/server/exports.ts @@ -32,6 +32,7 @@ export { parseConnectionUri, shouldUseTlsForDefaultHostedRelay, } from "../shared/daemon-endpoints.js"; +export { PARENT_AGENT_ID_LABEL } from "../shared/agent-labels.js"; export { DirectTcpHostConnectionSchema, type DirectTcpHostConnection, diff --git a/packages/server/src/shared/agent-labels.ts b/packages/server/src/shared/agent-labels.ts new file mode 100644 index 000000000..9cc4180d4 --- /dev/null +++ b/packages/server/src/shared/agent-labels.ts @@ -0,0 +1 @@ +export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";