Surface paseo subagents in a collapsible section above the composer (#532)

* 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).
This commit is contained in:
Mohamed Boudra
2026-05-09 15:39:13 +08:00
committed by GitHub
parent 0364e8dc5e
commit c097678cdf
42 changed files with 2540 additions and 96 deletions

View File

@@ -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

107
docs/agent-lifecycle.md Normal file
View File

@@ -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.

View File

@@ -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<string, string>` | 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<string, string>` | 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

View File

@@ -75,6 +75,7 @@ function buildHistoricalAgentDetail(agent: AggregatedAgent): Agent {
attentionTimestamp: agent.attentionTimestamp,
archivedAt: agent.archivedAt,
labels: agent.labels,
parentAgentId: null,
};
}

View File

@@ -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,

View File

@@ -27,6 +27,7 @@ function createAgent(status: Agent["status"]): Agent {
title: "Agent",
cwd: "/tmp",
model: null,
parentAgentId: null,
labels: {},
projectPlacement: null,
};

View File

@@ -150,6 +150,7 @@ function makeActiveAgent(): Agent {
model: null,
labels: {},
archivedAt: null,
parentAgentId: null,
};
}

View File

@@ -42,6 +42,7 @@ function createAgent(id: string): Agent {
title: "Agent",
cwd: "/repo",
model: null,
parentAgentId: null,
labels: {},
};
}

View File

@@ -36,6 +36,7 @@ const AGENT_DEFAULTS: Agent = {
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
labels: {},
projectPlacement: null,
};

View File

@@ -31,6 +31,7 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
title: "Agent 1",
cwd: "/repo",
model: null,
parentAgentId: null,
labels: {},
archivedAt: null,
...overrides,

View File

@@ -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 (
<View style={inputAreaStyle}>
<SubagentsSection
rows={subagentRows}
onOpenSubagent={handleOpenSubagent}
onArchiveSubagent={handleArchiveSubagent}
/>
<Composer
agentId={agentId}
serverId={serverId}

View File

@@ -1466,6 +1466,7 @@ describe("HostRuntimeStore", () => {
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]]);
});

View File

@@ -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<string, Agent>([
[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<string, Agent>([[archivedChild.id, archivedChild]]),
workspaceDirectory,
});
expect(result.activeAgentIds).toEqual(new Set<string>());
expect(result.autoOpenAgentIds).toEqual(new Set<string>());
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<string, Agent>([
[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<string>();
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<string>(),
}),
).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<string>(),
activeAgentIds: new Set<string>(),
}),
).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<string>(), knownAgentIds: new Set<string>() };
const b = { activeAgentIds: new Set<string>(), knownAgentIds: new Set<string>() };
const a = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
knownAgentIds: new Set<string>(),
};
const b = {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
knownAgentIds: new Set<string>(),
};
expect(workspaceAgentVisibilityEqual(a, b)).toBe(true);
});
});

View File

@@ -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<string>;
autoOpenAgentIds: Set<string>;
knownAgentIds: Set<string>;
}
@@ -20,11 +22,13 @@ export function deriveWorkspaceAgentVisibility(input: {
if ((!sessionAgents && !agentDetails) || !normalizedWorkspaceDirectory) {
return {
activeAgentIds: new Set<string>(),
autoOpenAgentIds: new Set<string>(),
knownAgentIds: new Set<string>(),
};
}
const activeAgentIds = new Set<string>();
const autoOpenAgentIds = new Set<string>();
const knownAgentIds = new Set<string>();
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<string>, b: Set<string>): 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<string>;
activeAgentIds: Set<string>;
}): boolean {
if (!input.agentId.trim()) {

View File

@@ -265,6 +265,7 @@ function buildDraftAgentSnapshot(input: {
model,
features: composerState.statusControls.features,
thinkingOptionId,
parentAgentId: null,
labels: {},
};
}

View File

@@ -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(() => {});
});

View File

@@ -104,6 +104,7 @@ export interface Agent {
attentionReason?: "finished" | "error" | "permission" | null;
attentionTimestamp?: Date | null;
archivedAt?: Date | null;
parentAgentId: string | null;
labels: Record<string, string>;
projectPlacement?: ProjectPlacementPayload | null;
}

View File

@@ -197,6 +197,7 @@ export interface WorkspaceTabSnapshot {
agentsHydrated: boolean;
terminalsHydrated: boolean;
activeAgentIds: Iterable<string>;
autoOpenAgentIds: Iterable<string>;
knownAgentIds: Iterable<string>;
knownTerminalIds?: Iterable<string>;
standaloneTerminalIds: Iterable<string>;
@@ -1451,23 +1452,23 @@ interface EntityTabGroup {
tabs: WorkspaceTab[];
}
function buildVisibleAgentIds(input: {
activeAgentIds: Set<string>;
function applyPinnedAndHidden(input: {
baseAgentIds: Set<string>;
pinnedAgentIds: Set<string>;
hiddenAgentIds: Set<string>;
knownAgentIds: Set<string>;
}): Set<string> {
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<string, EntityTabGroup> {
@@ -1527,13 +1528,13 @@ function collapseStaleEntityTabs(input: {
function addMissingEntityTabs(input: {
layout: WorkspaceLayout;
visibleAgentIds: Set<string>;
autoOpenAgentIds: Set<string>;
representedAgentIds: Set<string>;
standaloneTerminalIds: Set<string>;
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,

View File

@@ -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: [],

View File

@@ -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: <T>(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<string, string>();
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<Agent> & Pick<Agent, "id">): 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"]);
});
});

View File

@@ -0,0 +1,5 @@
import type { Agent } from "@/stores/session-store";
export function shouldAutoOpenAgentTab(agent: Pick<Agent, "parentAgentId">): boolean {
return !agent.parentAgentId;
}

View File

@@ -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" });
});
});

View File

@@ -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<Agent, "parentAgentId"> | null | undefined,
): CloseAgentTabPolicy {
if (agent?.parentAgentId) {
return { kind: "layout-only" };
}
return { kind: "archive-on-close" };
}

View File

@@ -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";

View File

@@ -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";

View File

@@ -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: <T,>(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<string, unknown>) =>
React.createElement("span", { ...props, "data-icon": name });
return {
Archive: createIcon("Archive"),
Check: createIcon("Check"),
ChevronDown: createIcon("ChevronDown"),
ChevronRight: createIcon("ChevronRight"),
};
});
function row(overrides: Partial<SubagentRow> & Pick<SubagentRow, "id">): 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<HTMLElement>('[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<typeof vi.fn> = vi.fn(),
onArchiveSubagent: ReturnType<typeof vi.fn> = vi.fn(),
): ReturnType<typeof vi.fn> {
act(() => {
root?.render(
<SubagentsSection
rows={rows}
onOpenSubagent={onOpenSubagent}
onArchiveSubagent={onArchiveSubagent}
/>,
);
});
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);
});
});

View File

@@ -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 (
<View style={styles.outer} testID="subagents-section">
<View style={styles.track}>
<View style={surfaceStyle}>
<Pressable
accessibilityRole="button"
accessibilityLabel={headerLabel}
testID="subagents-section-header"
onPress={toggleExpanded}
style={headerStyle}
>
{expanded ? (
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
) : (
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
)}
<Text style={styles.headerLabel} numberOfLines={1}>
{headerLabel}
</Text>
</Pressable>
{expanded ? (
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
showsVerticalScrollIndicator={false}
nestedScrollEnabled
>
{rows.map((row) => (
<SubagentsSectionRow
key={row.id}
row={row}
onOpenSubagent={onOpenSubagent}
onArchiveSubagent={onArchiveSubagent}
/>
))}
</ScrollView>
) : null}
</View>
</View>
</View>
);
}
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.
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
<Pressable
accessibilityRole="button"
accessibilityLabel={displayLabel}
testID={`subagents-section-row-${row.id}`}
onPress={handlePress}
>
{({ pressed }) => (
<View style={hovered || pressed ? styles.rowActive : styles.row}>
<WorkspaceTabIcon presentation={presentation} />
<Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel}
</Text>
<SubagentArchiveButton
rowId={row.id}
displayLabel={displayLabel}
visible={archiveVisible}
onPress={handleArchivePress}
/>
</View>
)}
</Pressable>
</View>
);
}
function SubagentArchiveButton({
rowId,
displayLabel,
visible,
onPress,
}: {
rowId: string;
displayLabel: string;
visible: boolean;
onPress: () => void;
}): ReactElement {
return (
<View
style={visible ? styles.archiveSlotVisible : styles.archiveSlotHidden}
pointerEvents={visible ? "auto" : "none"}
>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Archive ${displayLabel}`}
testID={`subagents-section-archive-${rowId}`}
onPress={onPress}
style={styles.archiveButton}
hitSlop={8}
>
{({ hovered, pressed }) => (
<ThemedArchive
size={14}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
/>
)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>Archive subagent</Text>
</TooltipContent>
</Tooltip>
</View>
);
}
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,
},
}));

View File

@@ -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<Agent> & Pick<Agent, "id">): Agent {
return { ...AGENT_DEFAULTS, ...input };
}
function setAgents(agents: Agent[]): void {
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore
.getState()
.setAgents(SERVER_ID, new Map(agents.map((agent) => [agent.id, agent])));
}
afterEach(() => {
useSessionStore.getState().clearSession(SERVER_ID);
});
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"]);
});
});

View File

@@ -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<typeof useSessionStore.getState>;
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,
);
}

View File

@@ -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<void>)(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<void>)(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<void>)(subagent.id);
});
expect(confirmDialogMock).toHaveBeenCalledTimes(1);
expect(confirmDialogMock).toHaveBeenCalledWith(
resolveArchiveSubagentDialog({
title: subagent.title,
status: subagent.status,
}),
);
});
});

View File

@@ -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],
);
}

View File

@@ -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<Omit<AgentSnapshotPayload, "labels">> & {
id: string;
labels?: Record<string, string>;
},
): 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);
});
});

View File

@@ -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<Omit<AgentSnapshotPayload, "labels">> & {
labels?: Record<string, unknown>;
} = {},
): 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();
});
});

View File

@@ -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,
};
}

View File

@@ -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,
};
}

View File

@@ -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<void>();
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<void> {
this.closeCalled = true;
}
}
class RunningChildClient extends TestAgentClient {
readonly sessions: RunningChildSession[] = [];
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
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<string, unknown>;
};
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<string, unknown>;
};
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<void> {
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");

View File

@@ -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<void>;
@@ -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<void> {
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<ArchivedStoredAgentRecord> {
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<void> {

View File

@@ -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);

View File

@@ -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",
},
},

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -0,0 +1 @@
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";