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