Detach subagents without archiving them (#1612)

* Add detach action for subagents

* Translate detached subagent connection error

* Address subagent detach review feedback

* Fix subagent integration test import

* Focus detached subagents as tabs
This commit is contained in:
Mohamed Boudra
2026-06-19 17:19:05 +08:00
committed by GitHub
parent cda66ae5f3
commit 7af92120fe
33 changed files with 1359 additions and 94 deletions
+5
View File
@@ -21,6 +21,8 @@ Agent-scoped `create_agent` accepts `detached: true` for agents that should stan
- **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it. - **Subagents** — created with `detached: false` or omitted. They exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it.
- **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it. - **Detached agents** — created with `detached: true`. They take over as sibling/root agents (e.g. handoffs, fire-and-forget delegations), do not appear in the creating agent's subagent track, and are not archived with it.
Users can also detach an existing subagent from the subagents track. Detach removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive.
`notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents. `notifyOnFinish` defaults to `true` for agent-scoped creation because most subagents are delegated work the creating agent needs to hear back from. Set it to `false` only for truly fire-and-forget agents.
## Archive ## Archive
@@ -70,6 +72,8 @@ 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. 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.
To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.
## Why this shape ## Why this shape
The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally: The decision was to **decouple "close tab" from "archive" only for subagents**, rather than universally:
@@ -77,6 +81,7 @@ The decision was to **decouple "close tab" from "archive" only for subagents**,
- **Closing a tab on a root agent still archives** — preserves the existing UX users are trained on - **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 - **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 - **Archive button on track rows** — gives subagents an explicit lifecycle gesture in their home surface
- **Detach button on track rows** — lets a subagent continue independently without killing its work
- **Cascade archive on parent** — keeps subagents from leaking when the parent is archived - **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. 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.
+1
View File
@@ -82,6 +82,7 @@ export interface SeedDaemonClient {
thinkingOptionId?: string; thinkingOptionId?: string;
featureValues?: Record<string, unknown>; featureValues?: Record<string, unknown>;
initialPrompt?: string; initialPrompt?: string;
labels?: Record<string, string>;
}): Promise<{ id: string; status: string }>; }): Promise<{ id: string; status: string }>;
fetchAgents(options?: { scope?: "active" }): Promise<{ fetchAgents(options?: { scope?: "active" }): Promise<{
entries: Array<{ entries: Array<{
+83
View File
@@ -0,0 +1,83 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import type { SeededWorkspace } from "./seed-client";
export interface SeededSubagentPair {
parent: {
id: string;
title: string;
};
child: {
id: string;
title: string;
};
workspaceId: string;
}
export async function seedParentWithSubagent(
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
input: { parentTitle: string; childTitle: string },
): Promise<SeededSubagentPair> {
const parent = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: input.parentTitle,
modeId: "load-test",
model: "ten-second-stream",
});
const child = await workspace.client.createAgent({
provider: "mock",
cwd: workspace.repoPath,
workspaceId: workspace.workspaceId,
title: input.childTitle,
modeId: "load-test",
model: "ten-second-stream",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
},
});
return {
parent: {
id: parent.id,
title: input.parentTitle,
},
child: {
id: child.id,
title: input.childTitle,
},
workspaceId: workspace.workspaceId,
};
}
export async function openSubagentsTrack(page: Page): Promise<void> {
await page.getByTestId("subagents-track-header").click();
}
export async function expectSubagentRowVisible(page: Page, childId: string): Promise<void> {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toBeVisible({
timeout: 30_000,
});
}
export async function expectSubagentRowGone(page: Page, childId: string): Promise<void> {
await expect(page.getByTestId(`subagents-track-row-${childId}`)).toHaveCount(0, {
timeout: 30_000,
});
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
const row = page.getByTestId(`subagents-track-row-${childId}`);
await expect(row).toBeVisible({ timeout: 30_000 });
await row.hover();
page.once("dialog", (dialog) => {
expect(dialog.message()).toContain("Detach subagent?");
void dialog.accept();
});
const detachButton = page.getByTestId(`subagents-track-detach-${childId}`);
await expect(detachButton).toBeVisible({ timeout: 30_000 });
await detachButton.click();
}
+44
View File
@@ -0,0 +1,44 @@
import { test } from "./fixtures";
import { expectWorkspaceTabVisible } from "./helpers/archive-tab";
import { expectAgentTabActive } from "./helpers/launcher";
import { openAgentRoute } from "./helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client";
import {
detachSubagentFromTrack,
expectSubagentRowGone,
expectSubagentRowVisible,
openSubagentsTrack,
seedParentWithSubagent,
} from "./helpers/subagents";
test.describe("Subagent detach", () => {
let workspace: SeededWorkspace;
test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "subagent-detach-" });
});
test.afterAll(async () => {
await workspace?.cleanup();
});
test("detaching a subagent focuses it as a workspace tab", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Detach parent",
childTitle: "Detached child",
});
await openAgentRoute(page, {
workspaceId: agents.workspaceId,
agentId: agents.parent.id,
});
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);
await detachSubagentFromTrack(page, agents.child.id);
await expectSubagentRowGone(page, agents.child.id);
await expectWorkspaceTabVisible(page, agents.child.id);
await expectAgentTabActive(page, agents.child.id);
});
});
+2
View File
@@ -1307,6 +1307,8 @@ export const ar: TranslationResources = {
backdrop: "خلفية القائمة", backdrop: "خلفية القائمة",
}, },
subagents: { subagents: {
detachAction: "فصل {{label}}",
detachTooltip: "فصل الوكيل الفرعي",
archiveAction: "أرشيف{{label}}", archiveAction: "أرشيف{{label}}",
archiveTooltip: "أرشفة الوكيل الفرعي", archiveTooltip: "أرشفة الوكيل الفرعي",
}, },
+2
View File
@@ -1314,6 +1314,8 @@ export const en = {
backdrop: "Menu backdrop", backdrop: "Menu backdrop",
}, },
subagents: { subagents: {
detachAction: "Detach {{label}}",
detachTooltip: "Detach subagent",
archiveAction: "Archive {{label}}", archiveAction: "Archive {{label}}",
archiveTooltip: "Archive subagent", archiveTooltip: "Archive subagent",
}, },
+2
View File
@@ -1343,6 +1343,8 @@ export const es: TranslationResources = {
backdrop: "Fondo del menú", backdrop: "Fondo del menú",
}, },
subagents: { subagents: {
detachAction: "Separar {{label}}",
detachTooltip: "Separar subagente",
archiveAction: "Archivo{{label}}", archiveAction: "Archivo{{label}}",
archiveTooltip: "Subagente de archivo", archiveTooltip: "Subagente de archivo",
}, },
+2
View File
@@ -1346,6 +1346,8 @@ export const fr: TranslationResources = {
backdrop: "Toile de fond du menu", backdrop: "Toile de fond du menu",
}, },
subagents: { subagents: {
detachAction: "Detacher {{label}}",
detachTooltip: "Detacher le sous-agent",
archiveAction: "Archiver{{label}}", archiveAction: "Archiver{{label}}",
archiveTooltip: "Sous-agent d'archivage", archiveTooltip: "Sous-agent d'archivage",
}, },
+2
View File
@@ -1335,6 +1335,8 @@ export const ru: TranslationResources = {
backdrop: "Фон меню", backdrop: "Фон меню",
}, },
subagents: { subagents: {
detachAction: "Отсоединить {{label}}",
detachTooltip: "Отсоединить субагент",
archiveAction: "Архив{{label}}", archiveAction: "Архив{{label}}",
archiveTooltip: "Архивный субагент", archiveTooltip: "Архивный субагент",
}, },
+2
View File
@@ -1290,6 +1290,8 @@ export const zhCN: TranslationResources = {
backdrop: "菜单背景", backdrop: "菜单背景",
}, },
subagents: { subagents: {
detachAction: "分离 {{label}}",
detachTooltip: "分离 subagent",
archiveAction: "归档 {{label}}", archiveAction: "归档 {{label}}",
archiveTooltip: "归档 subagent", archiveTooltip: "归档 subagent",
}, },
+6 -1
View File
@@ -77,7 +77,7 @@ import { type Agent, useSessionStore } from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
import type { Theme } from "@/styles/theme"; import type { Theme } from "@/styles/theme";
import { useArchiveSubagent, useSubagentsForParent } from "@/subagents"; import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents";
import { SubagentsTrack } from "@/subagents/track"; import { SubagentsTrack } from "@/subagents/track";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
import type { StreamItem } from "@/types/stream"; import type { StreamItem } from "@/types/stream";
@@ -1442,6 +1442,9 @@ function ActiveAgentComposer({
serverId, serverId,
parentAgentId: agentId, parentAgentId: agentId,
}); });
const canDetachSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
);
const handleOpenSubagent = useCallback( const handleOpenSubagent = useCallback(
(subagentId: string) => { (subagentId: string) => {
navigateToAgent({ serverId, agentId: subagentId }); navigateToAgent({ serverId, agentId: subagentId });
@@ -1449,6 +1452,7 @@ function ActiveAgentComposer({
[serverId], [serverId],
); );
const handleArchiveSubagent = useArchiveSubagent({ serverId }); const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId });
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({ const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId, serverId,
cwd, cwd,
@@ -1545,6 +1549,7 @@ function ActiveAgentComposer({
rows={subagentRows} rows={subagentRows}
onOpenSubagent={handleOpenSubagent} onOpenSubagent={handleOpenSubagent}
onArchiveSubagent={handleArchiveSubagent} onArchiveSubagent={handleArchiveSubagent}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/> />
<Composer <Composer
agentId={agentId} agentId={agentId}
@@ -5,7 +5,7 @@ import {
deriveWorkspaceAgentVisibility, deriveWorkspaceAgentVisibility,
type WorkspaceAgentVisibility, type WorkspaceAgentVisibility,
} from "@/workspace-tabs/agent-visibility"; } from "@/workspace-tabs/agent-visibility";
import { selectSubagentsForParent } from "@/subagents"; import { selectSubagentsForParent } from "@/subagents/select";
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store"; import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
import { useSessionStore, type Agent } from "./session-store"; import { useSessionStore, type Agent } from "./session-store";
@@ -167,4 +167,52 @@ describe("workspace subagents integration", () => {
).map((row) => row.id), ).map((row) => row.id),
).toEqual(["child-agent"]); ).toEqual(["child-agent"]);
}); });
it("moves a detached child out of the parent section and back into normal workspace tabs", () => {
const workspaceKey = buildWorkspaceTabPersistenceKey({
serverId: SERVER_ID,
workspaceId: WORKSPACE_ID,
});
expect(workspaceKey).toBeTruthy();
const parent = makeAgent({
id: "parent-agent",
title: "Parent agent",
});
const child = makeAgent({
id: "child-agent",
parentAgentId: "parent-agent",
title: "Child agent",
});
initializeAgents([parent, child]);
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-agent",
},
new Set(),
).map((row) => row.id),
).toEqual(["child-agent"]);
appendAgent({ ...child, parentAgentId: null, labels: {} });
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent", "agent_child-agent"]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-agent",
},
new Set(),
),
).toEqual([]);
});
}); });
@@ -16,6 +16,7 @@ interface FakeArchiveSubagentEnv {
deps: ArchiveSubagentDeps; deps: ArchiveSubagentDeps;
recordedArchives: RecordedArchive[]; recordedArchives: RecordedArchive[];
recordedConfirmInputs: ConfirmDialogInput[]; recordedConfirmInputs: ConfirmDialogInput[];
recordedErrors: unknown[];
setSubagent(id: string, snapshot: ResolveArchiveSubagentDialogInput | undefined): void; setSubagent(id: string, snapshot: ResolveArchiveSubagentDialogInput | undefined): void;
} }
@@ -31,10 +32,12 @@ function createFakeEnv(
} }
const recordedArchives: RecordedArchive[] = []; const recordedArchives: RecordedArchive[] = [];
const recordedConfirmInputs: ConfirmDialogInput[] = []; const recordedConfirmInputs: ConfirmDialogInput[] = [];
const recordedErrors: unknown[] = [];
return { return {
recordedArchives, recordedArchives,
recordedConfirmInputs, recordedConfirmInputs,
recordedErrors,
setSubagent(id, snapshot) { setSubagent(id, snapshot) {
subagents.set(id, snapshot); subagents.set(id, snapshot);
}, },
@@ -47,6 +50,9 @@ function createFakeEnv(
archiveAgent: async (input) => { archiveAgent: async (input) => {
recordedArchives.push(input); recordedArchives.push(input);
}, },
reportError: (error) => {
recordedErrors.push(error);
},
}, },
}; };
} }
@@ -176,7 +182,7 @@ describe("requestArchiveSubagent", () => {
expect(env.recordedArchives).toEqual([]); expect(env.recordedArchives).toEqual([]);
}); });
it("swallows archive errors so the caller never sees them", async () => { it("reports archive errors after the user confirms", async () => {
const env = createFakeEnv({ const env = createFakeEnv({
confirmResult: true, confirmResult: true,
initialSubagents: [ initialSubagents: [
@@ -186,12 +192,14 @@ describe("requestArchiveSubagent", () => {
}, },
], ],
}); });
const error = new Error("daemon offline");
env.deps.archiveAgent = async () => { env.deps.archiveAgent = async () => {
throw new Error("daemon offline"); throw error;
}; };
await expect( await expect(
requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps), requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
expect(env.recordedErrors).toEqual([error]);
}); });
}); });
@@ -41,6 +41,7 @@ export interface ArchiveSubagentDeps {
getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined; getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined;
confirm: (input: ConfirmDialogInput) => Promise<boolean>; confirm: (input: ConfirmDialogInput) => Promise<boolean>;
archiveAgent: (input: { serverId: string; agentId: string }) => Promise<void>; archiveAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
reportError: (error: unknown) => void;
} }
export interface RequestArchiveSubagentInput { export interface RequestArchiveSubagentInput {
@@ -62,5 +63,9 @@ export async function requestArchiveSubagent(
if (!confirmed) { if (!confirmed) {
return; return;
} }
void deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId }).catch(() => {}); try {
await deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId });
} catch (error) {
deps.reportError(error);
}
} }
@@ -0,0 +1,153 @@
import { describe, expect, it } from "vitest";
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
import {
requestDetachSubagent,
resolveDetachSubagentDialog,
type DetachSubagentDeps,
type ResolveDetachSubagentDialogInput,
} from "./detach-subagent";
interface RecordedDetach {
serverId: string;
agentId: string;
}
interface RecordedOpen {
serverId: string;
agentId: string;
}
interface FakeDetachSubagentEnv {
deps: DetachSubagentDeps;
recordedDetaches: RecordedDetach[];
recordedOpens: RecordedOpen[];
recordedConfirmInputs: ConfirmDialogInput[];
recordedErrors: unknown[];
}
function createFakeEnv(
options: {
confirmResult?: boolean;
initialSubagents?: Array<{ id: string; snapshot: ResolveDetachSubagentDialogInput }>;
} = {},
): FakeDetachSubagentEnv {
const subagents = new Map<string, ResolveDetachSubagentDialogInput | undefined>();
for (const entry of options.initialSubagents ?? []) {
subagents.set(entry.id, entry.snapshot);
}
const recordedDetaches: RecordedDetach[] = [];
const recordedOpens: RecordedOpen[] = [];
const recordedConfirmInputs: ConfirmDialogInput[] = [];
const recordedErrors: unknown[] = [];
return {
recordedDetaches,
recordedOpens,
recordedConfirmInputs,
recordedErrors,
deps: {
getSubagent: (id) => subagents.get(id),
confirm: async (dialog) => {
recordedConfirmInputs.push(dialog);
return options.confirmResult ?? false;
},
detachAgent: async (input) => {
recordedDetaches.push(input);
},
openDetachedAgent: (input) => {
recordedOpens.push(input);
},
reportError: (error) => {
recordedErrors.push(error);
},
},
};
}
describe("resolveDetachSubagentDialog", () => {
it("uses non-destructive copy for named subagents", () => {
expect(resolveDetachSubagentDialog({ title: "Review branch" })).toEqual({
title: "Detach subagent?",
message: "Review branch will leave this track and continue as a standalone agent.",
confirmLabel: "Detach",
cancelLabel: "Cancel",
destructive: false,
});
});
it("falls back to this subagent when the title is not displayable", () => {
expect(resolveDetachSubagentDialog({ title: "New Agent" })).toEqual({
title: "Detach subagent?",
message: "This subagent will leave this track and continue as a standalone agent.",
confirmLabel: "Detach",
cancelLabel: "Cancel",
destructive: false,
});
});
});
describe("requestDetachSubagent", () => {
it("detaches the subagent with the server id when the user confirms", async () => {
const env = createFakeEnv({
confirmResult: true,
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
});
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
expect(env.recordedDetaches).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
});
it("opens the detached subagent after detach succeeds", async () => {
const env = createFakeEnv({
confirmResult: true,
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
});
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
expect(env.recordedOpens).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
});
it("does not detach the subagent when the user cancels", async () => {
const env = createFakeEnv({
confirmResult: false,
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
});
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
expect(env.recordedDetaches).toEqual([]);
expect(env.recordedOpens).toEqual([]);
});
it("asks for confirmation using the resolved dialog for the subagent", async () => {
const env = createFakeEnv({
confirmResult: false,
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
});
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
expect(env.recordedConfirmInputs).toEqual([
resolveDetachSubagentDialog({ title: "Review branch" }),
]);
});
it("reports detach errors after the user confirms", async () => {
const env = createFakeEnv({
confirmResult: true,
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
});
const error = new Error("daemon offline");
env.deps.detachAgent = async () => {
throw error;
};
await expect(
requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
).resolves.toBeUndefined();
expect(env.recordedErrors).toEqual([error]);
expect(env.recordedOpens).toEqual([]);
});
});
@@ -0,0 +1,68 @@
import type { Agent } from "@/stores/session-store";
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
export interface ResolveDetachSubagentDialogInput {
title: Agent["title"] | 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 resolveDetachSubagentDialog(
input: ResolveDetachSubagentDialogInput,
): ConfirmDialogInput {
const subagentLabel = resolveSubagentLabel(input.title) ?? "This subagent";
return {
title: "Detach subagent?",
message: `${subagentLabel} will leave this track and continue as a standalone agent.`,
confirmLabel: "Detach",
cancelLabel: "Cancel",
destructive: false,
};
}
export interface DetachSubagentDeps {
getSubagent: (subagentId: string) => ResolveDetachSubagentDialogInput | undefined;
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
detachAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
openDetachedAgent: (input: { serverId: string; agentId: string }) => void;
reportError: (error: unknown) => void;
}
export interface RequestDetachSubagentInput {
serverId: string;
subagentId: string;
}
export async function requestDetachSubagent(
input: RequestDetachSubagentInput,
deps: DetachSubagentDeps,
): Promise<void> {
const subagent = deps.getSubagent(input.subagentId);
const confirmed = await deps.confirm(
resolveDetachSubagentDialog({
title: subagent?.title,
}),
);
if (!confirmed) {
return;
}
try {
await deps.detachAgent({ serverId: input.serverId, agentId: input.subagentId });
deps.openDetachedAgent({ serverId: input.serverId, agentId: input.subagentId });
} catch (error) {
deps.reportError(error);
}
}
+1
View File
@@ -1,5 +1,6 @@
export type { SubagentRow } from "./select"; export type { SubagentRow } from "./select";
export { selectSubagentsForParent, useSubagentsForParent } from "./select"; export { selectSubagentsForParent, useSubagentsForParent } from "./select";
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy"; export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy"; export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy";
+95 -35
View File
@@ -1,7 +1,7 @@
import { useCallback, useMemo, useState, type ReactElement } from "react"; import { useCallback, useMemo, useState, type ReactElement } from "react";
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native"; import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Archive, ChevronDown, ChevronRight } from "lucide-react-native"; import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles"; import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { getProviderIcon } from "@/components/provider-icons"; import { getProviderIcon } from "@/components/provider-icons";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
@@ -18,6 +18,7 @@ import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-pre
const ThemedArchive = withUnistyles(Archive); const ThemedArchive = withUnistyles(Archive);
const ThemedChevronDown = withUnistyles(ChevronDown); const ThemedChevronDown = withUnistyles(ChevronDown);
const ThemedChevronRight = withUnistyles(ChevronRight); const ThemedChevronRight = withUnistyles(ChevronRight);
const ThemedUnlink = withUnistyles(Unlink);
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground }); const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
const foregroundMutedColorMapping = (theme: Theme) => ({ const foregroundMutedColorMapping = (theme: Theme) => ({
@@ -28,6 +29,7 @@ export interface SubagentsTrackProps {
rows: SubagentRow[]; rows: SubagentRow[];
onOpenSubagent: (id: string) => void; onOpenSubagent: (id: string) => void;
onArchiveSubagent: (id: string) => void; onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void;
} }
const SUBAGENTS_LIST_MAX_HEIGHT = 200; const SUBAGENTS_LIST_MAX_HEIGHT = 200;
@@ -43,6 +45,7 @@ export function SubagentsTrack({
rows, rows,
onOpenSubagent, onOpenSubagent,
onArchiveSubagent, onArchiveSubagent,
onDetachSubagent,
}: SubagentsTrackProps): ReactElement | null { }: SubagentsTrackProps): ReactElement | null {
const [expanded, setExpanded] = useState(false); const [expanded, setExpanded] = useState(false);
@@ -103,6 +106,7 @@ export function SubagentsTrack({
row={row} row={row}
onOpenSubagent={onOpenSubagent} onOpenSubagent={onOpenSubagent}
onArchiveSubagent={onArchiveSubagent} onArchiveSubagent={onArchiveSubagent}
onDetachSubagent={onDetachSubagent}
/> />
))} ))}
</ScrollView> </ScrollView>
@@ -117,12 +121,14 @@ interface SubagentsTrackRowProps {
row: SubagentRow; row: SubagentRow;
onOpenSubagent: (id: string) => void; onOpenSubagent: (id: string) => void;
onArchiveSubagent: (id: string) => void; onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void;
} }
function SubagentsTrackRow({ function SubagentsTrackRow({
row, row,
onOpenSubagent, onOpenSubagent,
onArchiveSubagent, onArchiveSubagent,
onDetachSubagent,
}: SubagentsTrackRowProps): ReactElement { }: SubagentsTrackRowProps): ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
@@ -136,10 +142,13 @@ function SubagentsTrackRow({
const handleArchivePress = useCallback(() => { const handleArchivePress = useCallback(() => {
onArchiveSubagent(row.id); onArchiveSubagent(row.id);
}, [onArchiveSubagent, row.id]); }, [onArchiveSubagent, row.id]);
const handleDetachPress = useCallback(() => {
onDetachSubagent?.(row.id);
}, [onDetachSubagent, row.id]);
const handlePointerEnter = useCallback(() => setHovered(true), []); const handlePointerEnter = useCallback(() => setHovered(true), []);
const handlePointerLeave = useCallback(() => setHovered(false), []); const handlePointerLeave = useCallback(() => setHovered(false), []);
const archiveAlwaysVisible = isNative || isCompact; const actionsAlwaysVisible = isNative || isCompact;
const archiveVisible = archiveAlwaysVisible || hovered; const actionsVisible = actionsAlwaysVisible || hovered;
return ( return (
// Wrapper View handles hover so moving the pointer between the row and // Wrapper View handles hover so moving the pointer between the row and
@@ -158,11 +167,12 @@ function SubagentsTrackRow({
<Text style={styles.rowLabel} numberOfLines={1}> <Text style={styles.rowLabel} numberOfLines={1}>
{displayLabel} {displayLabel}
</Text> </Text>
<SubagentArchiveButton <SubagentRowActions
rowId={row.id} rowId={row.id}
displayLabel={displayLabel} displayLabel={displayLabel}
visible={archiveVisible} visible={actionsVisible}
onPress={handleArchivePress} onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
onArchivePress={handleArchivePress}
/> />
</View> </View>
)} )}
@@ -171,49 +181,93 @@ function SubagentsTrackRow({
); );
} }
function SubagentArchiveButton({ function SubagentRowActions({
rowId, rowId,
displayLabel, displayLabel,
visible, visible,
onPress, onDetachPress,
onArchivePress,
}: { }: {
rowId: string; rowId: string;
displayLabel: string; displayLabel: string;
visible: boolean; visible: boolean;
onPress: () => void; onDetachPress?: () => void;
onArchivePress: () => void;
}): ReactElement { }): ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<View <View
style={visible ? styles.archiveSlotVisible : styles.archiveSlotHidden} style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
pointerEvents={visible ? "auto" : "none"} pointerEvents={visible ? "auto" : "none"}
> >
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> {onDetachPress ? (
<TooltipTrigger asChild disabled={!visible}> <SubagentActionButton
<Pressable accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
accessibilityRole="button" testID={`subagents-track-detach-${rowId}`}
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })} tooltipLabel={t("subagents.detachTooltip")}
testID={`subagents-track-archive-${rowId}`} icon="detach"
onPress={onPress} visible={visible}
style={styles.archiveButton} onPress={onDetachPress}
hitSlop={8} />
> ) : null}
{({ hovered, pressed }) => ( <SubagentActionButton
<ThemedArchive accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
size={14} testID={`subagents-track-archive-${rowId}`}
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping} tooltipLabel={t("subagents.archiveTooltip")}
/> icon="archive"
)} visible={visible}
</Pressable> onPress={onArchivePress}
</TooltipTrigger> />
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{t("subagents.archiveTooltip")}</Text>
</TooltipContent>
</Tooltip>
</View> </View>
); );
} }
type SubagentActionIcon = "archive" | "detach";
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
if (icon === "detach") {
return <ThemedUnlink size={14} uniProps={uniProps} />;
}
return <ThemedArchive size={14} uniProps={uniProps} />;
}
function SubagentActionButton({
accessibilityLabel,
testID,
tooltipLabel,
icon,
visible,
onPress,
}: {
accessibilityLabel: string;
testID: string;
tooltipLabel: string;
icon: SubagentActionIcon;
visible: boolean;
onPress: () => void;
}): ReactElement {
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild disabled={!visible}>
<Pressable
accessibilityRole="button"
accessibilityLabel={accessibilityLabel}
testID={testID}
onPress={onPress}
style={styles.actionButton}
hitSlop={8}
>
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
</Pressable>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
</TooltipContent>
</Tooltip>
);
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
outer: { outer: {
width: "100%", width: "100%",
@@ -288,13 +342,19 @@ const styles = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
color: theme.colors.foreground, color: theme.colors.foreground,
}, },
archiveSlotVisible: { actionClusterVisible: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
opacity: 1, opacity: 1,
}, },
archiveSlotHidden: { actionClusterHidden: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
opacity: 0, opacity: 0,
}, },
archiveButton: { actionButton: {
padding: theme.spacing[1], padding: theme.spacing[1],
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@@ -1,7 +1,9 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useToast } from "@/contexts/toast-context";
import { useArchiveAgent } from "@/hooks/use-archive-agent"; import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { toErrorMessage } from "@/utils/error-messages";
import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent"; import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent";
export { resolveArchiveSubagentDialog, requestArchiveSubagent } from "./archive-subagent"; export { resolveArchiveSubagentDialog, requestArchiveSubagent } from "./archive-subagent";
@@ -18,6 +20,7 @@ export interface UseArchiveSubagentInput {
export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void { export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void {
const { archiveAgent } = useArchiveAgent(); const { archiveAgent } = useArchiveAgent();
const { serverId } = input; const { serverId } = input;
const toast = useToast();
return useCallback( return useCallback(
(subagentId: string) => { (subagentId: string) => {
@@ -28,9 +31,12 @@ export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId:
useSessionStore.getState().sessions[serverId]?.agents?.get(id), useSessionStore.getState().sessions[serverId]?.agents?.get(id),
confirm: confirmDialog, confirm: confirmDialog,
archiveAgent, archiveAgent,
reportError: (error) => {
toast.error(toErrorMessage(error));
},
}, },
); );
}, },
[archiveAgent, serverId], [archiveAgent, serverId, toast],
); );
} }
@@ -0,0 +1,51 @@
import { useCallback } from "react";
import { useToast } from "@/contexts/toast-context";
import { i18n } from "@/i18n/i18next";
import { useSessionStore } from "@/stores/session-store";
import { confirmDialog } from "@/utils/confirm-dialog";
import { toErrorMessage } from "@/utils/error-messages";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { requestDetachSubagent, type ResolveDetachSubagentDialogInput } from "./detach-subagent";
export { resolveDetachSubagentDialog, requestDetachSubagent } from "./detach-subagent";
export type {
DetachSubagentDeps,
RequestDetachSubagentInput,
ResolveDetachSubagentDialogInput,
} from "./detach-subagent";
export interface UseDetachSubagentInput {
serverId: string;
}
export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: string) => void {
const { serverId } = input;
const toast = useToast();
return useCallback(
(subagentId: string) => {
void requestDetachSubagent(
{ serverId, subagentId },
{
getSubagent: (id): ResolveDetachSubagentDialogInput | undefined =>
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
confirm: confirmDialog,
detachAgent: async ({ serverId: targetServerId, agentId }) => {
const client = useSessionStore.getState().sessions[targetServerId]?.client;
if (!client) {
throw new Error(i18n.t("workspaceSetup.errors.hostDisconnected"));
}
await client.detachAgent(agentId);
},
openDetachedAgent: ({ serverId: targetServerId, agentId }) => {
navigateToAgent({ serverId: targetServerId, agentId });
},
reportError: (error) => {
toast.error(toErrorMessage(error));
},
},
);
},
[serverId, toast],
);
}
+42
View File
@@ -2762,6 +2762,48 @@ test("fetches agents via RPC with filters, sort, and pagination", async () => {
}); });
}); });
test("detaches an agent through the namespaced detach RPC", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen();
await connectPromise;
const promise = client.detachAgent("child-agent");
expect(mock.sent).toHaveLength(1);
const request = parseSentFrame(mock.sent[0]);
expect(request).toMatchObject({
type: "agent.detach.request",
agentId: "child-agent",
});
expect(typeof request.requestId).toBe("string");
mock.triggerMessage(
wrapSessionMessage({
type: "agent.detach.response",
payload: {
requestId: request.requestId,
agentId: "child-agent",
accepted: true,
error: null,
},
}),
);
await expect(promise).resolves.toBeUndefined();
});
test("sends active-scoped fetch_agents_request", async () => { test("sends active-scoped fetch_agents_request", async () => {
const logger = createMockLogger(); const logger = createMockLogger();
const mock = createMockTransport(); const mock = createMockTransport();
+13
View File
@@ -2098,6 +2098,19 @@ export class DaemonClient {
return { archivedAt: result.archivedAt }; return { archivedAt: result.archivedAt };
} }
async detachAgent(agentId: string): Promise<void> {
const payload = await this.sendNamespacedCorrelatedSessionRequest<"agent.detach.response">({
message: {
type: "agent.detach.request",
agentId,
},
timeout: 10000,
});
if (!payload.accepted) {
throw new Error(payload.error ?? "detachAgent rejected");
}
}
async updateAgent( async updateAgent(
agentId: string, agentId: string,
updates: { name?: string; labels?: Record<string, string> }, updates: { name?: string; labels?: Record<string, string> },
+4
View File
@@ -229,6 +229,7 @@ export interface PaseoAgentHandle {
refetch(requestId?: string): Promise<PaseoAgentRefetchResult | null>; refetch(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>; send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
archive(): Promise<{ archivedAt: string }>; archive(): Promise<{ archivedAt: string }>;
detach(): Promise<void>;
subscribe(handler: (update: PaseoAgentUpdate) => void): () => void; subscribe(handler: (update: PaseoAgentUpdate) => void): () => void;
} }
@@ -483,6 +484,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
} }
return result; return result;
}, },
detach: async () => {
await daemonClient.detachAgent(id);
},
subscribe: (handler) => subscribe: (handler) =>
daemonClient.on("agent_update", (message) => { daemonClient.on("agent_update", (message) => {
const update = message.payload; const update = message.payload;
+47
View File
@@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest";
import { import {
FileExplorerRequestSchema, FileExplorerRequestSchema,
PaseoWorktreeArchiveRequestSchema, PaseoWorktreeArchiveRequestSchema,
parseServerInfoStatusPayload,
SessionInboundMessageSchema,
SessionOutboundMessageSchema, SessionOutboundMessageSchema,
} from "./messages.js"; } from "./messages.js";
@@ -131,6 +133,51 @@ describe("workspace descriptor message compatibility", () => {
}); });
}); });
describe("agent detach RPC", () => {
test("parses the namespaced detach request", () => {
const parsed = SessionInboundMessageSchema.parse({
type: "agent.detach.request",
agentId: "child-agent",
requestId: "req-detach",
});
expect(parsed).toEqual({
type: "agent.detach.request",
agentId: "child-agent",
requestId: "req-detach",
});
});
test("parses the namespaced detach response", () => {
const parsed = SessionOutboundMessageSchema.parse({
type: "agent.detach.response",
payload: {
requestId: "req-detach",
agentId: "child-agent",
accepted: true,
error: null,
},
});
expect(parsed.type).toBe("agent.detach.response");
});
test("parses the agentDetach server feature gate", () => {
const parsed = parseServerInfoStatusPayload({
status: "server_info",
serverId: "srv-test",
features: {
agentDetach: true,
},
});
if (!parsed) {
throw new Error("Expected server info payload to parse");
}
expect(parsed.features?.agentDetach).toBe(true);
});
});
describe("file explorer request compatibility", () => { describe("file explorer request compatibility", () => {
test("acceptBinary is optional for old clients and accepted for new clients", () => { test("acceptBinary is optional for old clients and accepted for new clients", () => {
expect( expect(
+17
View File
@@ -1308,6 +1308,17 @@ export const SetAgentFeatureResponseMessageSchema = z.object({
payload: AgentActionResponsePayloadSchema, payload: AgentActionResponsePayloadSchema,
}); });
export const AgentDetachRequestMessageSchema = z.object({
type: z.literal("agent.detach.request"),
agentId: z.string(),
requestId: z.string(),
});
export const AgentDetachResponseMessageSchema = z.object({
type: z.literal("agent.detach.response"),
payload: AgentActionResponsePayloadSchema,
});
export const AgentRewindModeSchema = z.enum(["conversation", "files", "both"]); export const AgentRewindModeSchema = z.enum(["conversation", "files", "both"]);
export const AgentRewindRequestMessageSchema = z.object({ export const AgentRewindRequestMessageSchema = z.object({
@@ -2021,6 +2032,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModelRequestMessageSchema, SetAgentModelRequestMessageSchema,
SetAgentThinkingRequestMessageSchema, SetAgentThinkingRequestMessageSchema,
SetAgentFeatureRequestMessageSchema, SetAgentFeatureRequestMessageSchema,
AgentDetachRequestMessageSchema,
AgentRewindRequestMessageSchema, AgentRewindRequestMessageSchema,
AgentPermissionResponseMessageSchema, AgentPermissionResponseMessageSchema,
CheckoutStatusRequestSchema, CheckoutStatusRequestSchema,
@@ -2280,6 +2292,8 @@ export const ServerInfoStatusPayloadSchema = z
projectRemove: z.boolean().optional(), projectRemove: z.boolean().optional(),
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: z.boolean().optional(), worktreeRestore: z.boolean().optional(),
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: z.boolean().optional(),
}) })
.optional(), .optional(),
}) })
@@ -4019,6 +4033,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
SetAgentModelResponseMessageSchema, SetAgentModelResponseMessageSchema,
SetAgentThinkingResponseMessageSchema, SetAgentThinkingResponseMessageSchema,
SetAgentFeatureResponseMessageSchema, SetAgentFeatureResponseMessageSchema,
AgentDetachResponseMessageSchema,
AgentRewindResponseMessageSchema, AgentRewindResponseMessageSchema,
UpdateAgentResponseMessageSchema, UpdateAgentResponseMessageSchema,
ProjectRenameResponseSchema, ProjectRenameResponseSchema,
@@ -4166,6 +4181,7 @@ export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMes
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>; export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>; export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>; export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
export type AgentDetachResponseMessage = z.infer<typeof AgentDetachResponseMessageSchema>;
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>; export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>; export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>; export type ProjectRenameResponse = z.infer<typeof ProjectRenameResponseSchema>;
@@ -4299,6 +4315,7 @@ export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessa
export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>; export type SetAgentModelRequestMessage = z.infer<typeof SetAgentModelRequestMessageSchema>;
export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>; export type SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureRequestMessageSchema>; export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureRequestMessageSchema>;
export type AgentDetachRequestMessage = z.infer<typeof AgentDetachRequestMessageSchema>;
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>; export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>; export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>; export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
@@ -1847,18 +1847,23 @@ test("updateAgentMetadata bumps updatedAt for stored agents", async () => {
}); });
await manager.closeAgent(snapshot.id); await manager.closeAgent(snapshot.id);
const before = await storage.get(snapshot.id); const closed = await storage.get(snapshot.id);
expect(before).not.toBeNull(); expect(closed).not.toBeNull();
const before = { ...closed!, labels: { surface: "mobile" } };
await storage.upsert(before);
expect(manager.getAgent(snapshot.id)).toBeNull(); expect(manager.getAgent(snapshot.id)).toBeNull();
const upsertSpy = vi.spyOn(storage, "upsert");
await manager.updateAgentMetadata(snapshot.id, { await manager.updateAgentMetadata(snapshot.id, {
title: "Stored title", title: "Stored title",
labels: { role: "worker" }, labels: { role: "worker" },
}); });
expect(upsertSpy).toHaveBeenCalledTimes(1);
const after = await storage.get(snapshot.id); const after = await storage.get(snapshot.id);
expect(after?.title).toBe("Stored title"); expect(after?.title).toBe("Stored title");
expect(after?.labels).toEqual({ role: "worker" }); expect(after?.labels).toEqual({ surface: "mobile", role: "worker" });
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt)); expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
}); });
@@ -2002,6 +2007,134 @@ test("setLabels merges and persists labels", async () => {
}); });
}); });
test("detachAgent removes only the parent label from a live agent and emits state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-"));
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,
team: "infra",
},
},
);
const emittedLabels: Array<Record<string, string>> = [];
const unsubscribe = manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === child.id) {
emittedLabels.push(event.agent.labels);
}
},
{ agentId: child.id, replayState: false },
);
const result = await manager.detachAgent(child.id);
await manager.flush();
unsubscribe();
expect(result.previousParentAgentId).toBe(parent.id);
expect(result.live).toBe(true);
expect(result.record.labels).toEqual({ team: "infra" });
expect(manager.getAgent(child.id)?.labels).toEqual({ team: "infra" });
expect((await storage.get(child.id))?.labels).toEqual({ team: "infra" });
expect(emittedLabels).toContainEqual({ team: "infra" });
});
test("detachAgent removes the parent label from a stored-only agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-"));
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: "Stored child",
},
undefined,
{
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
role: "reviewer",
},
},
);
await manager.closeAgent(child.id);
const result = await manager.detachAgent(child.id);
expect(result.previousParentAgentId).toBe(parent.id);
expect(result.live).toBe(false);
expect(result.record.labels).toEqual({ role: "reviewer" });
expect((await storage.get(child.id))?.labels).toEqual({ role: "reviewer" });
});
test("archiveAgent does not cascade to a detached former child", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-cascade-"));
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 } },
);
await manager.detachAgent(child.id);
await manager.archiveAgent(parent.id);
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
expect((await storage.get(child.id))?.archivedAt).toBeFalsy();
});
test("runAgent persists finished attention and idle status without an external snapshot subscriber", async () => { test("runAgent persists finished attention and idle status without an external snapshot subscriber", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
+112 -21
View File
@@ -5,7 +5,11 @@ import {
AGENT_LIFECYCLE_STATUSES, AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus, type AgentLifecycleStatus,
} from "@getpaseo/protocol/agent-lifecycle"; } from "@getpaseo/protocol/agent-lifecycle";
import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import {
getParentAgentIdFromLabels,
isDelegatedAgent,
PARENT_AGENT_ID_LABEL,
} from "@getpaseo/protocol/agent-labels";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { z } from "zod"; import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js"; import type { TerminalManager } from "../../terminal/terminal-manager.js";
@@ -345,6 +349,17 @@ type ActiveManagedAgent =
| ManagedAgentError; | ManagedAgentError;
type LiveManagedAgent = ActiveManagedAgent; type LiveManagedAgent = ActiveManagedAgent;
type AgentLabelPatch = Record<string, string | null>;
interface WriteLabelsResult {
record: StoredAgentRecord | null;
live: boolean;
}
interface AgentMetadataPatch {
title?: string;
labels?: AgentLabelPatch;
}
const SYSTEM_ERROR_PREFIX = "[System Error]"; const SYSTEM_ERROR_PREFIX = "[System Error]";
@@ -403,6 +418,21 @@ function validateAgentId(agentId: string, source: string): string {
return result.data; return result.data;
} }
function applyLabelPatch(
labels: Record<string, string>,
patch: AgentLabelPatch,
): Record<string, string> {
const nextLabels = { ...labels };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete nextLabels[key];
} else {
nextLabels[key] = value;
}
}
return nextLabels;
}
function buildExplicitTimelineSeedForRegister( function buildExplicitTimelineSeedForRegister(
now: Date, now: Date,
options: options:
@@ -1151,9 +1181,8 @@ export class AgentManager {
// Children created via the MCP `create_agent` tool carry the parent-agent-id // 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 // label pointing back at the caller. Archiving the parent cascades to those
// children so subagent fleets don't outlive their orchestrator. Handoff agents // children so subagent fleets don't outlive their orchestrator. Detached
// launched the same way are caught by this cascade — see docs/agent-lifecycle.md // handoff agents omit this label, so they stand outside the cascade.
// for the accepted limitation.
private async cascadeArchiveChildren(parentAgentId: string): Promise<void> { private async cascadeArchiveChildren(parentAgentId: string): Promise<void> {
const registry = this.registry; const registry = this.registry;
if (!registry) { if (!registry) {
@@ -1333,10 +1362,83 @@ export class AgentManager {
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> { async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels }; await this.writeLabels(agent.id, labels);
this.touchUpdatedAt(agent); }
await this.persistSnapshot(agent);
this.emitState(agent, { persist: false }); private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
this.touchUpdatedAt(liveAgent);
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
const record = this.registry ? await this.registry.get(agentId) : null;
return { record, live: true };
}
const nextRecord = await this.writeStoredMetadata(agentId, { labels: patch });
return { record: nextRecord, live: false };
}
private async writeStoredMetadata(
agentId: string,
patch: AgentMetadataPatch,
): Promise<StoredAgentRecord> {
const registry = this.requireRegistry();
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const nextRecord = {
...record,
...(patch.title ? { title: patch.title } : {}),
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
updatedAt: this.nextStoredUpdatedAt(record),
};
await registry.upsert(nextRecord);
return nextRecord;
}
async detachAgent(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
const registry = this.requireRegistry();
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
const previousParentAgentId = getParentAgentIdFromLabels(liveAgent.labels);
if (!previousParentAgentId) {
await this.persistSnapshot(liveAgent);
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
return { record, live: true, previousParentAgentId: null };
}
const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
return { record, live: true, previousParentAgentId };
}
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const previousParentAgentId = getParentAgentIdFromLabels(record.labels);
if (!previousParentAgentId) {
return { record, live: false, previousParentAgentId: null };
}
const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
if (!result.record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
return { record: result.record, live: false, previousParentAgentId };
} }
notifyAgentState(agentId: string): void { notifyAgentState(agentId: string): void {
@@ -1433,23 +1535,12 @@ export class AgentManager {
await this.setTitle(agentId, updates.title); await this.setTitle(agentId, updates.title);
} }
if (updates.labels) { if (updates.labels) {
await this.setLabels(agentId, updates.labels); await this.writeLabels(agentId, updates.labels);
} }
return; return;
} }
const registry = this.requireRegistry(); await this.writeStoredMetadata(agentId, updates);
const existing = await registry.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
await registry.upsert({
...existing,
...(updates.title ? { title: updates.title } : {}),
...(updates.labels ? { labels: { ...existing.labels, ...updates.labels } } : {}),
updatedAt: this.nextStoredUpdatedAt(existing),
});
} }
async runAgent( async runAgent(
@@ -1,10 +1,12 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { getParentAgentIdFromLabels, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import type { StoredAgentRecord } from "./agent-storage.js"; import type { StoredAgentRecord } from "./agent-storage.js";
import { import {
archiveAgentCommand, archiveAgentCommand,
cancelAgentRunCommand, cancelAgentRunCommand,
detachAgentCommand,
setAgentModeCommand, setAgentModeCommand,
updateAgentCommand, updateAgentCommand,
type LifecycleAgentSnapshot, type LifecycleAgentSnapshot,
@@ -39,6 +41,7 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
readonly labelUpdates: Array<{ agentId: string; labels: Record<string, string> }> = []; readonly labelUpdates: Array<{ agentId: string; labels: Record<string, string> }> = [];
readonly notifiedAgentIds: string[] = []; readonly notifiedAgentIds: string[] = [];
readonly modeUpdates: Array<{ agentId: string; modeId: string }> = []; readonly modeUpdates: Array<{ agentId: string; modeId: string }> = [];
readonly detachedAgentIds: string[] = [];
inFlightAgentIds = new Set<string>(); inFlightAgentIds = new Set<string>();
constructor(private readonly storage: FakeLifecycleAgentStorage) {} constructor(private readonly storage: FakeLifecycleAgentStorage) {}
@@ -94,6 +97,39 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
this.labelUpdates.push({ agentId, labels }); this.labelUpdates.push({ agentId, labels });
} }
async detachAgent(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
this.detachedAgentIds.push(agentId);
const existing = this.storage.records.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
const previousParentAgentId = getParentAgentIdFromLabels(existing.labels);
if (!previousParentAgentId) {
return {
record: existing,
live: this.liveAgents.has(agentId),
previousParentAgentId: null,
};
}
const labels = { ...existing.labels };
delete labels[PARENT_AGENT_ID_LABEL];
const record = {
...existing,
labels,
updatedAt: "2026-05-10T10:30:00.000Z",
};
this.storage.records.set(agentId, record);
return {
record,
live: this.liveAgents.has(agentId),
previousParentAgentId,
};
}
notifyAgentState(agentId: string): void { notifyAgentState(agentId: string): void {
this.notifiedAgentIds.push(agentId); this.notifiedAgentIds.push(agentId);
} }
@@ -206,6 +242,44 @@ describe("agent lifecycle commands", () => {
]); ]);
}); });
test("detaches an agent by clearing only the parent relationship", async () => {
const storage = new FakeLifecycleAgentStorage();
storage.records.set("agent-1", {
...storedAgent("agent-1"),
labels: {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
team: "infra",
},
});
const manager = new FakeLifecycleAgentManager(storage);
await expect(detachAgentCommand({ agentManager: manager }, "agent-1")).resolves.toEqual({
agentId: "agent-1",
live: false,
previousParentAgentId: "parent-agent",
record: {
...storedAgent("agent-1"),
labels: { team: "infra" },
updatedAt: "2026-05-10T10:30:00.000Z",
},
});
expect(manager.detachedAgentIds).toEqual(["agent-1"]);
});
test("detach is accepted when the agent is already detached", async () => {
const storage = new FakeLifecycleAgentStorage();
storage.records.set("agent-1", storedAgent("agent-1"));
const manager = new FakeLifecycleAgentManager(storage);
await expect(detachAgentCommand({ agentManager: manager }, "agent-1")).resolves.toEqual({
agentId: "agent-1",
live: false,
previousParentAgentId: null,
record: storedAgent("agent-1"),
});
});
test("sets an agent mode and returns the accepted mode", async () => { test("sets an agent mode and returns the accepted mode", async () => {
const storage = new FakeLifecycleAgentStorage(); const storage = new FakeLifecycleAgentStorage();
const manager = new FakeLifecycleAgentManager(storage); const manager = new FakeLifecycleAgentManager(storage);
@@ -14,6 +14,11 @@ export interface LifecycleAgentManager {
archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord>; archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord>;
closeAgent(agentId: string): Promise<void>; closeAgent(agentId: string): Promise<void>;
setLabels(agentId: string, labels: Record<string, string>): Promise<void>; setLabels(agentId: string, labels: Record<string, string>): Promise<void>;
detachAgent(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}>;
notifyAgentState(agentId: string): void; notifyAgentState(agentId: string): void;
setAgentMode(agentId: string, modeId: string): Promise<void>; setAgentMode(agentId: string, modeId: string): Promise<void>;
updateAgentMetadata( updateAgentMetadata(
@@ -161,6 +166,24 @@ export async function updateAgentCommand(
}; };
} }
export interface DetachAgentResult {
agentId: string;
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}
export async function detachAgentCommand(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
agentId: string,
): Promise<DetachAgentResult> {
const result = await dependencies.agentManager.detachAgent(agentId);
return {
agentId,
...result,
};
}
export async function setAgentModeCommand( export async function setAgentModeCommand(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">, dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
input: { input: {
+143
View File
@@ -6,6 +6,7 @@ import { join, resolve as resolvePath } from "path";
import pino from "pino"; import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest"; import { afterEach, describe, expect, test, vi } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import { import {
decodeFileTransferFrame, decodeFileTransferFrame,
@@ -13,6 +14,7 @@ import {
} from "@getpaseo/protocol/binary-frames/index"; } from "@getpaseo/protocol/binary-frames/index";
import { Session } from "./session.js"; import { Session } from "./session.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js"; import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js"; import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { SessionOptions } from "./session.js"; import type { SessionOptions } from "./session.js";
import type { import type {
@@ -199,6 +201,8 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
}); });
interface SessionForTestOptions { interface SessionForTestOptions {
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
github?: Partial<GitHubService>; github?: Partial<GitHubService>;
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> }; checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
workspaceGitService?: { workspaceGitService?: {
@@ -264,9 +268,11 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
agentManager: asAgentManager({ agentManager: asAgentManager({
listAgents: vi.fn(() => []), listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}), subscribe: vi.fn(() => () => {}),
...options.agentManager,
}), }),
agentStorage: asAgentStorage({ agentStorage: asAgentStorage({
list: vi.fn().mockResolvedValue([]), list: vi.fn().mockResolvedValue([]),
...options.agentStorage,
}), }),
projectRegistry: options.projectRegistry ?? { projectRegistry: options.projectRegistry ?? {
list: vi.fn().mockResolvedValue([]), list: vi.fn().mockResolvedValue([]),
@@ -596,6 +602,143 @@ describe("file explorer binary responses", () => {
}); });
}); });
function createStoredAgentRecord(
overrides: Pick<StoredAgentRecord, "id" | "cwd"> & Partial<StoredAgentRecord>,
): StoredAgentRecord {
return {
id: overrides.id,
provider: overrides.provider ?? "codex",
cwd: overrides.cwd,
workspaceId: overrides.workspaceId,
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
lastUserMessageAt: overrides.lastUserMessageAt ?? null,
title: overrides.title ?? null,
labels: overrides.labels ?? {},
lastStatus: overrides.lastStatus ?? "idle",
lastModeId: overrides.lastModeId ?? null,
config: overrides.config ?? null,
runtimeInfo: overrides.runtimeInfo,
features: overrides.features,
persistence: overrides.persistence ?? null,
lastError: overrides.lastError,
requiresAttention: overrides.requiresAttention,
attentionReason: overrides.attentionReason,
attentionTimestamp: overrides.attentionTimestamp,
internal: overrides.internal,
archivedAt: overrides.archivedAt ?? null,
};
}
describe("agent detach RPC", () => {
test("detaches a stored subagent and emits the updated standalone agent", async () => {
const messages: unknown[] = [];
const childBefore = createStoredAgentRecord({
id: "child-agent",
cwd: "/tmp/child",
workspaceId: "workspace-child",
title: "Child",
labels: {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
topic: "handoff",
},
});
const childAfter = createStoredAgentRecord({
...childBefore,
updatedAt: "2026-01-01T00:00:01.000Z",
labels: { topic: "handoff" },
});
const workspace = {
workspaceId: "workspace-child",
projectId: "project-child",
cwd: "/tmp/child",
kind: "worktree" as const,
displayName: "Child workspace",
title: null,
branch: "child",
baseBranch: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
};
const project = {
projectId: "project-child",
rootPath: "/tmp/child",
kind: "git" as const,
displayName: "Project",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
};
const detachAgent = vi.fn().mockResolvedValue({
record: childAfter,
live: false,
previousParentAgentId: "parent-agent",
});
const getAgent = vi.fn(() => null);
const session = createSessionForTest({
messages,
agentManager: {
getAgent,
detachAgent,
},
agentStorage: {
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
},
workspaceRegistry: {
get: vi.fn().mockResolvedValue(workspace),
list: vi.fn().mockResolvedValue([workspace]),
},
projectRegistry: {
get: vi.fn().mockResolvedValue(project),
list: vi.fn().mockResolvedValue([project]),
},
});
await session.handleMessage({
type: "fetch_agents_request",
requestId: "subscribe-agents",
subscribe: { subscriptionId: "agents-sub" },
});
messages.splice(0);
await session.handleMessage({
type: "agent.detach.request",
agentId: childBefore.id,
requestId: "detach-1",
});
expect(detachAgent).toHaveBeenCalledWith("child-agent");
expect(messages).toContainEqual({
type: "agent_update",
payload: {
kind: "upsert",
agent: expect.objectContaining({
id: "child-agent",
labels: { topic: "handoff" },
workspaceId: "workspace-child",
}),
project: expect.objectContaining({
projectKey: "project-child",
workspaceName: "Child workspace",
}),
},
});
expect(messages).toContainEqual({
type: "agent.detach.response",
payload: {
requestId: "detach-1",
agentId: "child-agent",
accepted: true,
error: null,
},
});
});
});
function createProjectRecord(rootPath: string, archivedAt: string | null = null) { function createProjectRecord(rootPath: string, archivedAt: string | null = null) {
return { return {
projectId: `project:${rootPath}`, projectId: `project:${rootPath}`,
+132 -29
View File
@@ -92,6 +92,7 @@ import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-sto
import type { DaemonConfigStore } from "./daemon-config-store.js"; import type { DaemonConfigStore } from "./daemon-config-store.js";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import type { import type {
WorkspaceGitRuntimeSnapshot, WorkspaceGitRuntimeSnapshot,
WorkspaceGitService, WorkspaceGitService,
@@ -112,6 +113,7 @@ import {
archiveAgentCommand, archiveAgentCommand,
cancelAgentRunCommand, cancelAgentRunCommand,
closeAgentCommand, closeAgentCommand,
detachAgentCommand,
setAgentModeCommand, setAgentModeCommand,
updateAgentCommand, updateAgentCommand,
} from "./agent/lifecycle-command.js"; } from "./agent/lifecycle-command.js";
@@ -1621,6 +1623,45 @@ export class Session {
}); });
} }
private async emitStoredAgentUpdate(record: StoredAgentRecord): Promise<AgentSnapshotPayload> {
const payload = this.buildStoredAgentPayload(record);
const subscription = this.agentUpdatesSubscription;
if (!subscription) {
return payload;
}
const project = payload.workspaceId
? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
if (!project) {
this.bufferOrEmitAgentUpdate(subscription, {
kind: "remove",
agentId: payload.id,
});
return payload;
}
const matches = this.matchesAgentFilter({
agent: payload,
project,
filter: subscription.filter,
});
this.bufferOrEmitAgentUpdate(
subscription,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId: payload.id,
},
);
return payload;
}
private flushBootstrappedAgentUpdates(options?: { private flushBootstrappedAgentUpdates(options?: {
snapshotUpdatedAtByAgentId?: Map<string, number>; snapshotUpdatedAtByAgentId?: Map<string, number>;
}): void { }): void {
@@ -1816,6 +1857,7 @@ export class Session {
const promise = const promise =
this.dispatchVoiceAndControlMessage(msg) ?? this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ?? this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentRelationshipMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ?? this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ?? this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ?? this.dispatchCheckoutMessage(msg) ??
@@ -1886,6 +1928,15 @@ export class Session {
} }
} }
private dispatchAgentRelationshipMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "agent.detach.request":
return this.handleDetachAgentRequest(msg.agentId, msg.requestId);
default:
return undefined;
}
}
private async handleDictationStreamStart( private async handleDictationStreamStart(
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>, msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
): Promise<void> { ): Promise<void> {
@@ -2454,35 +2505,7 @@ export class Session {
); );
if (this.agentUpdatesSubscription) { if (this.agentUpdatesSubscription) {
const payload = this.buildStoredAgentPayload(archivedRecord); const payload = await this.emitStoredAgentUpdate(archivedRecord);
const project = payload.workspaceId
? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
if (project) {
const matches = this.matchesAgentFilter({
agent: payload,
project,
filter: this.agentUpdatesSubscription.filter,
});
this.bufferOrEmitAgentUpdate(
this.agentUpdatesSubscription,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId,
},
);
} else {
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
kind: "remove",
agentId,
});
}
if (payload.workspaceId) { if (payload.workspaceId) {
await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId); await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
} }
@@ -2491,6 +2514,59 @@ export class Session {
return { agentId, archivedAt }; return { agentId, archivedAt };
} }
private async handleDetachAgentRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.info({ agentId, requestId }, "Detaching agent from parent");
try {
const result = await detachAgentCommand({ agentManager: this.agentManager }, agentId);
const affectedWorkspaceIds = new Set<string>();
if (!result.live) {
const payload = await this.emitStoredAgentUpdate(result.record);
if (payload.workspaceId) {
affectedWorkspaceIds.add(payload.workspaceId);
}
} else if (result.record.workspaceId) {
affectedWorkspaceIds.add(result.record.workspaceId);
}
if (result.previousParentAgentId) {
const rootWorkspaceId = await this.resolveDelegationRootWorkspaceId(
result.previousParentAgentId,
);
if (rootWorkspaceId) {
affectedWorkspaceIds.add(rootWorkspaceId);
}
}
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds, {
skipReconcile: true,
});
this.emit({
type: "agent.detach.response",
payload: {
requestId,
agentId,
accepted: true,
error: null,
},
});
} catch (error) {
const message = getErrorMessageOr(error, "Failed to detach agent");
this.sessionLogger.error({ err: error, agentId, requestId }, "Failed to detach agent");
this.emit({
type: "agent.detach.response",
payload: {
requestId,
agentId,
accepted: false,
error: message,
},
});
}
}
private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise<void> { private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise<void> {
const archiveResults = await Promise.allSettled( const archiveResults = await Promise.allSettled(
msg.agentIds.map((agentId) => this.archiveAgentForClose(agentId)), msg.agentIds.map((agentId) => this.archiveAgentForClose(agentId)),
@@ -6554,6 +6630,33 @@ export class Session {
return this.isProviderVisibleToClient(payload.provider) ? payload : null; return this.isProviderVisibleToClient(payload.provider) ? payload : null;
} }
private async resolveDelegationRootWorkspaceId(agentId: string): Promise<string | null> {
const seen = new Set<string>();
let currentAgentId = agentId;
while (true) {
if (seen.has(currentAgentId)) {
return null;
}
seen.add(currentAgentId);
const live = this.agentManager.getAgent(currentAgentId);
const source = live ?? (await this.agentStorage.get(currentAgentId));
if (!source) {
return null;
}
if ("archivedAt" in source && source.archivedAt) {
return null;
}
const parentAgentId = getParentAgentIdFromLabels(source.labels);
if (!parentAgentId) {
return source.workspaceId ?? null;
}
currentAgentId = parentAgentId;
}
}
private async buildActiveProjectPlacementsByWorkspaceId(): Promise< private async buildActiveProjectPlacementsByWorkspaceId(): Promise<
Map<string, ProjectPlacementPayload> Map<string, ProjectPlacementPayload>
> { > {
@@ -1161,6 +1161,8 @@ export class VoiceAssistantWebSocketServer {
projectRemove: true, projectRemove: true,
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97 // COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: true, worktreeRestore: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: true,
}, },
}; };
} }
@@ -139,6 +139,16 @@ class WorkspaceStatus {
); );
} }
hasDetachedAgentInWorktree(input: AgentState): void {
this.agents.push(
createAgent({
...input,
cwd: this.worktreeWorkspace.cwd,
workspaceId: this.worktreeWorkspace.workspaceId,
}),
);
}
async workspaceStatus(): Promise<WorkspaceDescriptorPayload["status"]> { async workspaceStatus(): Promise<WorkspaceDescriptorPayload["status"]> {
const entries = await this.directory.listFetchEntries({ const entries = await this.directory.listFetchEntries({
type: "fetch_workspaces_request", type: "fetch_workspaces_request",
@@ -378,6 +388,19 @@ describe("WorkspaceDirectory", () => {
}); });
}); });
test("running detached child contributes running to its own workspace", async () => {
const workspace = new WorkspaceStatus();
workspace.hasWorktreeWorkspace();
workspace.hasRootAgent({ id: "parent-agent", status: "idle" });
workspace.hasDetachedAgentInWorktree({ id: "child-agent", status: "running" });
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "done",
"workspace-worktree": "running",
});
});
test("working terminal contributes running status, beating done", async () => { test("working terminal contributes running status, beating done", async () => {
const workspace = new WorkspaceStatus(); const workspace = new WorkspaceStatus();
const changedAt = new Date(NOW).getTime(); const changedAt = new Date(NOW).getTime();