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
+1
View File
@@ -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<{
+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: "خلفية القائمة",
},
subagents: {
detachAction: "فصل {{label}}",
detachTooltip: "فصل الوكيل الفرعي",
archiveAction: "أرشيف{{label}}",
archiveTooltip: "أرشفة الوكيل الفرعي",
},
+2
View File
@@ -1314,6 +1314,8 @@ export const en = {
backdrop: "Menu backdrop",
},
subagents: {
detachAction: "Detach {{label}}",
detachTooltip: "Detach subagent",
archiveAction: "Archive {{label}}",
archiveTooltip: "Archive subagent",
},
+2
View File
@@ -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",
},
+2
View File
@@ -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",
},
+2
View File
@@ -1335,6 +1335,8 @@ export const ru: TranslationResources = {
backdrop: "Фон меню",
},
subagents: {
detachAction: "Отсоединить {{label}}",
detachTooltip: "Отсоединить субагент",
archiveAction: "Архив{{label}}",
archiveTooltip: "Архивный субагент",
},
+2
View File
@@ -1290,6 +1290,8 @@ export const zhCN: TranslationResources = {
backdrop: "菜单背景",
},
subagents: {
detachAction: "分离 {{label}}",
detachTooltip: "分离 subagent",
archiveAction: "归档 {{label}}",
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 { 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
View File
@@ -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";
+95 -35
View File
@@ -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],
);
}
+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 () => {
const logger = createMockLogger();
const mock = createMockTransport();
+13
View File
@@ -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> },
+4
View File
@@ -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;
+47
View File
@@ -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(
+17
View File
@@ -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");
+112 -21
View File
@@ -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: {
+143
View File
@@ -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}`,
+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 { 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();