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:
@@ -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.
|
||||
- **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.
|
||||
|
||||
## 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.
|
||||
|
||||
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
|
||||
|
||||
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 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
|
||||
- **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
|
||||
|
||||
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.
|
||||
|
||||
@@ -82,6 +82,7 @@ export interface SeedDaemonClient {
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1307,6 +1307,8 @@ export const ar: TranslationResources = {
|
||||
backdrop: "خلفية القائمة",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "فصل {{label}}",
|
||||
detachTooltip: "فصل الوكيل الفرعي",
|
||||
archiveAction: "أرشيف{{label}}",
|
||||
archiveTooltip: "أرشفة الوكيل الفرعي",
|
||||
},
|
||||
|
||||
@@ -1314,6 +1314,8 @@ export const en = {
|
||||
backdrop: "Menu backdrop",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Detach {{label}}",
|
||||
detachTooltip: "Detach subagent",
|
||||
archiveAction: "Archive {{label}}",
|
||||
archiveTooltip: "Archive subagent",
|
||||
},
|
||||
|
||||
@@ -1343,6 +1343,8 @@ export const es: TranslationResources = {
|
||||
backdrop: "Fondo del menú",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Separar {{label}}",
|
||||
detachTooltip: "Separar subagente",
|
||||
archiveAction: "Archivo{{label}}",
|
||||
archiveTooltip: "Subagente de archivo",
|
||||
},
|
||||
|
||||
@@ -1346,6 +1346,8 @@ export const fr: TranslationResources = {
|
||||
backdrop: "Toile de fond du menu",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Detacher {{label}}",
|
||||
detachTooltip: "Detacher le sous-agent",
|
||||
archiveAction: "Archiver{{label}}",
|
||||
archiveTooltip: "Sous-agent d'archivage",
|
||||
},
|
||||
|
||||
@@ -1335,6 +1335,8 @@ export const ru: TranslationResources = {
|
||||
backdrop: "Фон меню",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Отсоединить {{label}}",
|
||||
detachTooltip: "Отсоединить субагент",
|
||||
archiveAction: "Архив{{label}}",
|
||||
archiveTooltip: "Архивный субагент",
|
||||
},
|
||||
|
||||
@@ -1290,6 +1290,8 @@ export const zhCN: TranslationResources = {
|
||||
backdrop: "菜单背景",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "分离 {{label}}",
|
||||
detachTooltip: "分离 subagent",
|
||||
archiveAction: "归档 {{label}}",
|
||||
archiveTooltip: "归档 subagent",
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@ import { type Agent, useSessionStore } from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { useArchiveSubagent, useSubagentsForParent } from "@/subagents";
|
||||
import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -1442,6 +1442,9 @@ function ActiveAgentComposer({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
});
|
||||
const canDetachSubagents = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
||||
);
|
||||
const handleOpenSubagent = useCallback(
|
||||
(subagentId: string) => {
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
@@ -1449,6 +1452,7 @@ function ActiveAgentComposer({
|
||||
[serverId],
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -1545,6 +1549,7 @@ function ActiveAgentComposer({
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onArchiveSubagent={handleArchiveSubagent}
|
||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||
/>
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
type WorkspaceAgentVisibility,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { selectSubagentsForParent } from "@/subagents";
|
||||
import { selectSubagentsForParent } from "@/subagents/select";
|
||||
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useSessionStore, type Agent } from "./session-store";
|
||||
|
||||
@@ -167,4 +167,52 @@ describe("workspace subagents integration", () => {
|
||||
).map((row) => row.id),
|
||||
).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;
|
||||
recordedArchives: RecordedArchive[];
|
||||
recordedConfirmInputs: ConfirmDialogInput[];
|
||||
recordedErrors: unknown[];
|
||||
setSubagent(id: string, snapshot: ResolveArchiveSubagentDialogInput | undefined): void;
|
||||
}
|
||||
|
||||
@@ -31,10 +32,12 @@ function createFakeEnv(
|
||||
}
|
||||
const recordedArchives: RecordedArchive[] = [];
|
||||
const recordedConfirmInputs: ConfirmDialogInput[] = [];
|
||||
const recordedErrors: unknown[] = [];
|
||||
|
||||
return {
|
||||
recordedArchives,
|
||||
recordedConfirmInputs,
|
||||
recordedErrors,
|
||||
setSubagent(id, snapshot) {
|
||||
subagents.set(id, snapshot);
|
||||
},
|
||||
@@ -47,6 +50,9 @@ function createFakeEnv(
|
||||
archiveAgent: async (input) => {
|
||||
recordedArchives.push(input);
|
||||
},
|
||||
reportError: (error) => {
|
||||
recordedErrors.push(error);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -176,7 +182,7 @@ describe("requestArchiveSubagent", () => {
|
||||
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({
|
||||
confirmResult: true,
|
||||
initialSubagents: [
|
||||
@@ -186,12 +192,14 @@ describe("requestArchiveSubagent", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const error = new Error("daemon offline");
|
||||
env.deps.archiveAgent = async () => {
|
||||
throw new Error("daemon offline");
|
||||
throw error;
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
|
||||
).resolves.toBeUndefined();
|
||||
expect(env.recordedErrors).toEqual([error]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface ArchiveSubagentDeps {
|
||||
getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined;
|
||||
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
|
||||
archiveAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
|
||||
reportError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface RequestArchiveSubagentInput {
|
||||
@@ -62,5 +63,9 @@ export async function requestArchiveSubagent(
|
||||
if (!confirmed) {
|
||||
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,5 +1,6 @@
|
||||
export type { SubagentRow } from "./select";
|
||||
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
|
||||
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
|
||||
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
|
||||
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
||||
export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
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 { getProviderIcon } from "@/components/provider-icons";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -18,6 +18,7 @@ import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-pre
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
const ThemedUnlink = withUnistyles(Unlink);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -28,6 +29,7 @@ export interface SubagentsTrackProps {
|
||||
rows: SubagentRow[];
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
|
||||
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
|
||||
@@ -43,6 +45,7 @@ export function SubagentsTrack({
|
||||
rows,
|
||||
onOpenSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackProps): ReactElement | null {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@@ -103,6 +106,7 @@ export function SubagentsTrack({
|
||||
row={row}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
@@ -117,12 +121,14 @@ interface SubagentsTrackRowProps {
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
|
||||
function SubagentsTrackRow({
|
||||
row,
|
||||
onOpenSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -136,10 +142,13 @@ function SubagentsTrackRow({
|
||||
const handleArchivePress = useCallback(() => {
|
||||
onArchiveSubagent(row.id);
|
||||
}, [onArchiveSubagent, row.id]);
|
||||
const handleDetachPress = useCallback(() => {
|
||||
onDetachSubagent?.(row.id);
|
||||
}, [onDetachSubagent, row.id]);
|
||||
const handlePointerEnter = useCallback(() => setHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setHovered(false), []);
|
||||
const archiveAlwaysVisible = isNative || isCompact;
|
||||
const archiveVisible = archiveAlwaysVisible || hovered;
|
||||
const actionsAlwaysVisible = isNative || isCompact;
|
||||
const actionsVisible = actionsAlwaysVisible || hovered;
|
||||
|
||||
return (
|
||||
// Wrapper View handles hover so moving the pointer between the row and
|
||||
@@ -158,11 +167,12 @@ function SubagentsTrackRow({
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
<SubagentArchiveButton
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
visible={archiveVisible}
|
||||
onPress={handleArchivePress}
|
||||
visible={actionsVisible}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -171,49 +181,93 @@ function SubagentsTrackRow({
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentArchiveButton({
|
||||
function SubagentRowActions({
|
||||
rowId,
|
||||
displayLabel,
|
||||
visible,
|
||||
onPress,
|
||||
onDetachPress,
|
||||
onArchivePress,
|
||||
}: {
|
||||
rowId: string;
|
||||
displayLabel: string;
|
||||
visible: boolean;
|
||||
onPress: () => void;
|
||||
onDetachPress?: () => void;
|
||||
onArchivePress: () => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={visible ? styles.archiveSlotVisible : styles.archiveSlotHidden}
|
||||
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
||||
testID={`subagents-track-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}>{t("subagents.archiveTooltip")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onDetachPress ? (
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
|
||||
testID={`subagents-track-detach-${rowId}`}
|
||||
tooltipLabel={t("subagents.detachTooltip")}
|
||||
icon="detach"
|
||||
visible={visible}
|
||||
onPress={onDetachPress}
|
||||
/>
|
||||
) : null}
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
||||
testID={`subagents-track-archive-${rowId}`}
|
||||
tooltipLabel={t("subagents.archiveTooltip")}
|
||||
icon="archive"
|
||||
visible={visible}
|
||||
onPress={onArchivePress}
|
||||
/>
|
||||
</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) => ({
|
||||
outer: {
|
||||
width: "100%",
|
||||
@@ -288,13 +342,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
archiveSlotVisible: {
|
||||
actionClusterVisible: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
opacity: 1,
|
||||
},
|
||||
archiveSlotHidden: {
|
||||
actionClusterHidden: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
opacity: 0,
|
||||
},
|
||||
archiveButton: {
|
||||
actionButton: {
|
||||
padding: theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent";
|
||||
|
||||
export { resolveArchiveSubagentDialog, requestArchiveSubagent } from "./archive-subagent";
|
||||
@@ -18,6 +20,7 @@ export interface UseArchiveSubagentInput {
|
||||
export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void {
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const { serverId } = input;
|
||||
const toast = useToast();
|
||||
|
||||
return useCallback(
|
||||
(subagentId: string) => {
|
||||
@@ -28,9 +31,12 @@ export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId:
|
||||
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
|
||||
confirm: confirmDialog,
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -2098,6 +2098,19 @@ export class DaemonClient {
|
||||
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(
|
||||
agentId: string,
|
||||
updates: { name?: string; labels?: Record<string, string> },
|
||||
|
||||
@@ -229,6 +229,7 @@ export interface PaseoAgentHandle {
|
||||
refetch(requestId?: string): Promise<PaseoAgentRefetchResult | null>;
|
||||
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
|
||||
archive(): Promise<{ archivedAt: string }>;
|
||||
detach(): Promise<void>;
|
||||
subscribe(handler: (update: PaseoAgentUpdate) => void): () => void;
|
||||
}
|
||||
|
||||
@@ -483,6 +484,9 @@ function createAgentHandleFactory(daemonClient: DaemonClient): AgentHandleFactor
|
||||
}
|
||||
return result;
|
||||
},
|
||||
detach: async () => {
|
||||
await daemonClient.detachAgent(id);
|
||||
},
|
||||
subscribe: (handler) =>
|
||||
daemonClient.on("agent_update", (message) => {
|
||||
const update = message.payload;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
FileExplorerRequestSchema,
|
||||
PaseoWorktreeArchiveRequestSchema,
|
||||
parseServerInfoStatusPayload,
|
||||
SessionInboundMessageSchema,
|
||||
SessionOutboundMessageSchema,
|
||||
} 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", () => {
|
||||
test("acceptBinary is optional for old clients and accepted for new clients", () => {
|
||||
expect(
|
||||
|
||||
@@ -1308,6 +1308,17 @@ export const SetAgentFeatureResponseMessageSchema = z.object({
|
||||
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 AgentRewindRequestMessageSchema = z.object({
|
||||
@@ -2021,6 +2032,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModelRequestMessageSchema,
|
||||
SetAgentThinkingRequestMessageSchema,
|
||||
SetAgentFeatureRequestMessageSchema,
|
||||
AgentDetachRequestMessageSchema,
|
||||
AgentRewindRequestMessageSchema,
|
||||
AgentPermissionResponseMessageSchema,
|
||||
CheckoutStatusRequestSchema,
|
||||
@@ -2280,6 +2292,8 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
projectRemove: z.boolean().optional(),
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
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(),
|
||||
})
|
||||
@@ -4019,6 +4033,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
SetAgentModelResponseMessageSchema,
|
||||
SetAgentThinkingResponseMessageSchema,
|
||||
SetAgentFeatureResponseMessageSchema,
|
||||
AgentDetachResponseMessageSchema,
|
||||
AgentRewindResponseMessageSchema,
|
||||
UpdateAgentResponseMessageSchema,
|
||||
ProjectRenameResponseSchema,
|
||||
@@ -4166,6 +4181,7 @@ export type SetAgentModeResponseMessage = z.infer<typeof SetAgentModeResponseMes
|
||||
export type SetAgentModelResponseMessage = z.infer<typeof SetAgentModelResponseMessageSchema>;
|
||||
export type SetAgentThinkingResponseMessage = z.infer<typeof SetAgentThinkingResponseMessageSchema>;
|
||||
export type SetAgentFeatureResponseMessage = z.infer<typeof SetAgentFeatureResponseMessageSchema>;
|
||||
export type AgentDetachResponseMessage = z.infer<typeof AgentDetachResponseMessageSchema>;
|
||||
export type AgentRewindResponseMessage = z.infer<typeof AgentRewindResponseMessageSchema>;
|
||||
export type UpdateAgentResponseMessage = z.infer<typeof UpdateAgentResponseMessageSchema>;
|
||||
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 SetAgentThinkingRequestMessage = z.infer<typeof SetAgentThinkingRequestMessageSchema>;
|
||||
export type SetAgentFeatureRequestMessage = z.infer<typeof SetAgentFeatureRequestMessageSchema>;
|
||||
export type AgentDetachRequestMessage = z.infer<typeof AgentDetachRequestMessageSchema>;
|
||||
export type AgentPermissionResponseMessage = z.infer<typeof AgentPermissionResponseMessageSchema>;
|
||||
export type CheckoutStatusRequest = z.infer<typeof CheckoutStatusRequestSchema>;
|
||||
export type CheckoutStatusResponse = z.infer<typeof CheckoutStatusResponseSchema>;
|
||||
|
||||
@@ -1847,18 +1847,23 @@ test("updateAgentMetadata bumps updatedAt for stored agents", async () => {
|
||||
});
|
||||
await manager.closeAgent(snapshot.id);
|
||||
|
||||
const before = await storage.get(snapshot.id);
|
||||
expect(before).not.toBeNull();
|
||||
const closed = await storage.get(snapshot.id);
|
||||
expect(closed).not.toBeNull();
|
||||
const before = { ...closed!, labels: { surface: "mobile" } };
|
||||
await storage.upsert(before);
|
||||
expect(manager.getAgent(snapshot.id)).toBeNull();
|
||||
|
||||
const upsertSpy = vi.spyOn(storage, "upsert");
|
||||
|
||||
await manager.updateAgentMetadata(snapshot.id, {
|
||||
title: "Stored title",
|
||||
labels: { role: "worker" },
|
||||
});
|
||||
|
||||
expect(upsertSpy).toHaveBeenCalledTimes(1);
|
||||
const after = await storage.get(snapshot.id);
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -5,7 +5,11 @@ import {
|
||||
AGENT_LIFECYCLE_STATUSES,
|
||||
type AgentLifecycleStatus,
|
||||
} 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 { z } from "zod";
|
||||
import type { TerminalManager } from "../../terminal/terminal-manager.js";
|
||||
@@ -345,6 +349,17 @@ type ActiveManagedAgent =
|
||||
| ManagedAgentError;
|
||||
|
||||
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]";
|
||||
|
||||
@@ -403,6 +418,21 @@ function validateAgentId(agentId: string, source: string): string {
|
||||
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(
|
||||
now: Date,
|
||||
options:
|
||||
@@ -1151,9 +1181,8 @@ export class AgentManager {
|
||||
|
||||
// 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.
|
||||
// children so subagent fleets don't outlive their orchestrator. Detached
|
||||
// handoff agents omit this label, so they stand outside the cascade.
|
||||
private async cascadeArchiveChildren(parentAgentId: string): Promise<void> {
|
||||
const registry = this.registry;
|
||||
if (!registry) {
|
||||
@@ -1333,10 +1362,83 @@ export class AgentManager {
|
||||
|
||||
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
agent.labels = { ...agent.labels, ...labels };
|
||||
this.touchUpdatedAt(agent);
|
||||
await this.persistSnapshot(agent);
|
||||
this.emitState(agent, { persist: false });
|
||||
await this.writeLabels(agent.id, labels);
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -1433,23 +1535,12 @@ export class AgentManager {
|
||||
await this.setTitle(agentId, updates.title);
|
||||
}
|
||||
if (updates.labels) {
|
||||
await this.setLabels(agentId, updates.labels);
|
||||
await this.writeLabels(agentId, updates.labels);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const registry = this.requireRegistry();
|
||||
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),
|
||||
});
|
||||
await this.writeStoredMetadata(agentId, updates);
|
||||
}
|
||||
|
||||
async runAgent(
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
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 type { StoredAgentRecord } from "./agent-storage.js";
|
||||
import {
|
||||
archiveAgentCommand,
|
||||
cancelAgentRunCommand,
|
||||
detachAgentCommand,
|
||||
setAgentModeCommand,
|
||||
updateAgentCommand,
|
||||
type LifecycleAgentSnapshot,
|
||||
@@ -39,6 +41,7 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
|
||||
readonly labelUpdates: Array<{ agentId: string; labels: Record<string, string> }> = [];
|
||||
readonly notifiedAgentIds: string[] = [];
|
||||
readonly modeUpdates: Array<{ agentId: string; modeId: string }> = [];
|
||||
readonly detachedAgentIds: string[] = [];
|
||||
inFlightAgentIds = new Set<string>();
|
||||
|
||||
constructor(private readonly storage: FakeLifecycleAgentStorage) {}
|
||||
@@ -94,6 +97,39 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
|
||||
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 {
|
||||
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 () => {
|
||||
const storage = new FakeLifecycleAgentStorage();
|
||||
const manager = new FakeLifecycleAgentManager(storage);
|
||||
|
||||
@@ -14,6 +14,11 @@ export interface LifecycleAgentManager {
|
||||
archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord>;
|
||||
closeAgent(agentId: 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;
|
||||
setAgentMode(agentId: string, modeId: string): Promise<void>;
|
||||
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(
|
||||
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
|
||||
input: {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { join, resolve as resolvePath } from "path";
|
||||
import pino from "pino";
|
||||
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 {
|
||||
decodeFileTransferFrame,
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
} from "@getpaseo/protocol/binary-frames/index";
|
||||
import { Session } from "./session.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 { SessionOptions } from "./session.js";
|
||||
import type {
|
||||
@@ -199,6 +201,8 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
|
||||
});
|
||||
|
||||
interface SessionForTestOptions {
|
||||
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
|
||||
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
|
||||
github?: Partial<GitHubService>;
|
||||
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
|
||||
workspaceGitService?: {
|
||||
@@ -264,9 +268,11 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
|
||||
agentManager: asAgentManager({
|
||||
listAgents: vi.fn(() => []),
|
||||
subscribe: vi.fn(() => () => {}),
|
||||
...options.agentManager,
|
||||
}),
|
||||
agentStorage: asAgentStorage({
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
...options.agentStorage,
|
||||
}),
|
||||
projectRegistry: options.projectRegistry ?? {
|
||||
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) {
|
||||
return {
|
||||
projectId: `project:${rootPath}`,
|
||||
|
||||
@@ -92,6 +92,7 @@ import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-sto
|
||||
import type { DaemonConfigStore } from "./daemon-config-store.js";
|
||||
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
|
||||
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type {
|
||||
WorkspaceGitRuntimeSnapshot,
|
||||
WorkspaceGitService,
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
archiveAgentCommand,
|
||||
cancelAgentRunCommand,
|
||||
closeAgentCommand,
|
||||
detachAgentCommand,
|
||||
setAgentModeCommand,
|
||||
updateAgentCommand,
|
||||
} 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?: {
|
||||
snapshotUpdatedAtByAgentId?: Map<string, number>;
|
||||
}): void {
|
||||
@@ -1816,6 +1857,7 @@ export class Session {
|
||||
const promise =
|
||||
this.dispatchVoiceAndControlMessage(msg) ??
|
||||
this.dispatchAgentRewindMessage(msg) ??
|
||||
this.dispatchAgentRelationshipMessage(msg) ??
|
||||
this.dispatchAgentLifecycleMessage(msg) ??
|
||||
this.dispatchAgentConfigMessage(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(
|
||||
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
|
||||
): Promise<void> {
|
||||
@@ -2454,35 +2505,7 @@ export class Session {
|
||||
);
|
||||
|
||||
if (this.agentUpdatesSubscription) {
|
||||
const payload = this.buildStoredAgentPayload(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,
|
||||
});
|
||||
}
|
||||
const payload = await this.emitStoredAgentUpdate(archivedRecord);
|
||||
if (payload.workspaceId) {
|
||||
await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
|
||||
}
|
||||
@@ -2491,6 +2514,59 @@ export class Session {
|
||||
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> {
|
||||
const archiveResults = await Promise.allSettled(
|
||||
msg.agentIds.map((agentId) => this.archiveAgentForClose(agentId)),
|
||||
@@ -6554,6 +6630,33 @@ export class Session {
|
||||
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<
|
||||
Map<string, ProjectPlacementPayload>
|
||||
> {
|
||||
|
||||
@@ -1161,6 +1161,8 @@ export class VoiceAssistantWebSocketServer {
|
||||
projectRemove: true,
|
||||
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
|
||||
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"]> {
|
||||
const entries = await this.directory.listFetchEntries({
|
||||
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 () => {
|
||||
const workspace = new WorkspaceStatus();
|
||||
const changedAt = new Date(NOW).getTime();
|
||||
|
||||
Reference in New Issue
Block a user