Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/agent-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,7 +165,7 @@ Claude Code announces subagent lifecycle on the SDK stream (`task_started` / `ta

Archived Paseo subagents disappear from the track, by design. To remove one from the track without closing its tab, use the **archive button** on the row — it opens a confirm dialog and archives the subagent on confirm. Provider-owned rows have no individual Paseo lifecycle controls.

The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track.
The track header's **Archive finished** action covers every finished row. It archives idle or errored managed Paseo subagents one at a time, and hides completed, failed, or canceled provider-owned rows in the current app session. Native sessions and timelines are untouched. Running and initializing children remain in the track. If a hidden provider child starts running again, the app brings it back to the track.

To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot.

Expand All @@ -185,7 +185,7 @@ We considered universal decoupling (no tab close ever archives, archive is alway

### Subagent accumulation under long-lived parents

A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually. Finished provider-owned rows can be hidden together with **Archive finished**; this is app-local presentation state and resets when the app restarts.
A parent that spawns many subagents will see the track grow. Managed Paseo subagents can be archived individually or with **Archive finished**. That action hides finished provider-owned rows locally; this presentation state resets when the app restarts.

### Cross-client tab dismissal

Expand Down
92 changes: 92 additions & 0 deletions packages/app/e2e/browser/archive-finished-subagents.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { expect } from "@playwright/test";
import { test } from "../support/fixtures";
import { openAgentRoute } from "../support/helpers/mock-agent";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import {
archiveFinishedSubagents,
expectArchiveFinishedInProgress,
expectArchiveFinishedRetry,
expectSubagentRowGone,
expectSubagentRowVisible,
holdManagedSubagentArchiveRequest,
openSubagentsTrack,
rejectNextManagedSubagentArchiveRequest,
seedParentWithSubagent,
} from "../support/helpers/subagents";

test.describe("Archive finished subagents", () => {
let workspace: SeededWorkspace;

test.beforeAll(async () => {
workspace = await seedWorkspace({ repoPrefix: "archive-finished-subagents-" });
});

test.afterAll(async () => {
await workspace?.cleanup();
});

test("archives a finished managed child from the track", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Archive parent",
childTitle: "Finished child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);

const archiveGate = await holdManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);

await archiveFinishedSubagents(page);
await archiveGate.waitForRequest();

await expectSubagentRowGone(page, agents.child.id);
await expectArchiveFinishedInProgress(page, 0, 1);
await expect(workspace.client.fetchAgent({ agentId: agents.child.id })).resolves.toMatchObject({
agent: { id: agents.child.id, archivedAt: null },
Comment thread
boudra marked this conversation as resolved.
Outdated
});
archiveGate.release();
await expect
.poll(
async () =>
(await workspace.client.fetchAgent({ agentId: agents.child.id }))?.agent.archivedAt,
{ timeout: 30_000 },
)
.toEqual(expect.any(String));
});

test("restores a failed child and retries the archive", async ({ page }) => {
const agents = await seedParentWithSubagent(workspace, {
parentTitle: "Retry parent",
childTitle: "Retry child",
});
await workspace.client.waitForAgentUpsert(
agents.child.id,
(snapshot) => snapshot.status === "idle",
);

const rejection = await rejectNextManagedSubagentArchiveRequest(page, agents.child.id);
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
await openSubagentsTrack(page);
await expectSubagentRowVisible(page, agents.child.id);

await archiveFinishedSubagents(page);
await rejection.waitForRejection();

await expectSubagentRowVisible(page, agents.child.id);
await expectArchiveFinishedRetry(page, 1, 1);
await archiveFinishedSubagents(page);

await expectSubagentRowGone(page, agents.child.id);
await expect
.poll(
async () =>
(await workspace.client.fetchAgent({ agentId: agents.child.id }))?.agent.archivedAt,
{ timeout: 30_000 },
)
.toEqual(expect.any(String));
});
});
135 changes: 135 additions & 0 deletions packages/app/e2e/support/helpers/subagents.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,19 @@
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
import type { SeededWorkspace } from "./seed-client";

type WebSocketMessage = string | Buffer;

export interface ManagedSubagentArchiveGate {
release(): void;
waitForRequest(): Promise<void>;
}

export interface ManagedSubagentArchiveRejection {
waitForRejection(): Promise<void>;
}

export interface SeededSubagentPair {
parent: {
id: string;
Expand Down Expand Up @@ -132,6 +144,129 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
});
}

export async function archiveFinishedSubagents(page: Page): Promise<void> {
await page.getByRole("button", { name: "Archive finished subagents" }).click();
}

export async function expectArchiveFinishedInProgress(
page: Page,
completed: number,
total: number,
): Promise<void> {
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeDisabled();
await expect(page.getByText(`${completed}/${total}`, { exact: true })).toBeVisible();
}

export async function expectArchiveFinishedRetry(
page: Page,
failed: number,
total: number,
): Promise<void> {
await expect(page.getByText(`Retry (${failed}/${total})`, { exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Archive finished subagents" })).toBeEnabled();
}

export async function holdManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveGate> {
let released = false;
let resolveRequest: (() => void) | undefined;
const delayedForwards: Array<() => void> = [];
const request = new Promise<void>((resolve) => {
resolveRequest = resolve;
});

await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
if (isArchiveRequestForSubagent(message, subagentId) && !released) {
delayedForwards.push(() => server.send(message));
resolveRequest?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});

return {
release() {
released = true;
for (const forward of delayedForwards.splice(0)) forward();
},
waitForRequest: () => request,
};
}

export async function rejectNextManagedSubagentArchiveRequest(
page: Page,
subagentId: string,
): Promise<ManagedSubagentArchiveRejection> {
let resolveRejection: (() => void) | undefined;
const rejection = new Promise<void>((resolve) => {
resolveRejection = resolve;
});
let rejected = false;

await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const requestId = archiveRequestId(message, subagentId);
if (requestId && !rejected) {
rejected = true;
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
// correlated error travels back through the real browser client and mutation rollback.
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "archive_agent_request",
error: "Archive rejected for test",
code: "archive_rejected",
},
},
}),
);
resolveRejection?.();
return;
}
server.send(message);
});
server.onMessage((message) => ws.send(message));
});

return { waitForRejection: () => rejection };
}

function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
return archiveRequestId(message, subagentId) !== null;
}

function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as {
type?: unknown;
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
};
if (
envelope.type === "session" &&
envelope.message?.type === "archive_agent_request" &&
envelope.message.agentId === subagentId &&
typeof envelope.message.requestId === "string"
) {
return envelope.message.requestId;
}
return null;
} catch {
return null;
}
}

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 });
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/resources/ar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1617,6 +1617,7 @@ export const ar: TranslationResources = {
archiveTooltip: "أرشفة الوكيل الفرعي",
archiveFinishedAction: "أرشفة الوكلاء الفرعيين المكتملين",
archiveFinishedTooltip: "أرشفة المكتملين",
archiveFinishedRetry: "إعادة المحاولة ({{failed}}/{{total}})",
},
panels: {
draft: {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/resources/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1628,6 +1628,7 @@ export const en = {
archiveTooltip: "Archive subagent",
archiveFinishedAction: "Archive finished subagents",
archiveFinishedTooltip: "Archive finished",
archiveFinishedRetry: "Retry ({{failed}}/{{total}})",
},
panels: {
draft: {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/resources/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1660,6 +1660,7 @@ export const es: TranslationResources = {
archiveTooltip: "Subagente de archivo",
archiveFinishedAction: "Archivar subagentes finalizados",
archiveFinishedTooltip: "Archivar finalizados",
archiveFinishedRetry: "Reintentar ({{failed}}/{{total}})",
},
panels: {
draft: {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/resources/fr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1664,6 +1664,7 @@ export const fr: TranslationResources = {
archiveTooltip: "Sous-agent d'archivage",
archiveFinishedAction: "Archiver les sous-agents terminés",
archiveFinishedTooltip: "Archiver les terminés",
archiveFinishedRetry: "Réessayer ({{failed}}/{{total}})",
},
panels: {
draft: {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/resources/ja.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1633,6 +1633,7 @@ export const ja: TranslationResources = {
archiveTooltip: "サブエージェントをアーカイブ",
archiveFinishedAction: "完了したサブエージェントをアーカイブ",
archiveFinishedTooltip: "完了した項目をアーカイブ",
archiveFinishedRetry: "再試行 ({{failed}}/{{total}})",
},
panels: {
draft: {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/resources/ko.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1627,6 +1627,7 @@ export const ko: TranslationResources = {
archiveTooltip: "서브에이전트 보관",
archiveFinishedAction: "완료된 하위 에이전트 보관",
archiveFinishedTooltip: "아카이브 완료",
archiveFinishedRetry: "다시 시도 ({{failed}}/{{total}})",
},
panels: {
draft: {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/resources/pt-BR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1646,6 +1646,7 @@ export const ptBR: TranslationResources = {
archiveTooltip: "Arquivar subagente",
archiveFinishedAction: "Arquivar subagentes concluídos",
archiveFinishedTooltip: "Arquivar concluídos",
archiveFinishedRetry: "Tentar novamente ({{failed}}/{{total}})",
},
panels: {
draft: {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/resources/ru.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1651,6 +1651,7 @@ export const ru: TranslationResources = {
archiveTooltip: "Архивный субагент",
archiveFinishedAction: "Архивировать завершенные субагенты",
archiveFinishedTooltip: "Архивировать завершенные",
archiveFinishedRetry: "Повторить ({{failed}}/{{total}})",
},
panels: {
draft: {
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/i18n/resources/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1597,6 +1597,7 @@ export const zhCN: TranslationResources = {
archiveTooltip: "归档 subagent",
archiveFinishedAction: "归档已完成的 subagent",
archiveFinishedTooltip: "归档已完成项",
archiveFinishedRetry: "重试 ({{failed}}/{{total}})",
},
panels: {
draft: {
Expand Down
8 changes: 5 additions & 3 deletions packages/app/src/panels/agent-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import type { Theme } from "@/styles/theme";
import {
useHideFinishedProviderSubagents,
useArchiveFinishedSubagents,
useArchiveSubagent,
useDetachSubagent,
useSubagentsForParent,
Expand Down Expand Up @@ -1532,9 +1532,10 @@ function ActiveAgentComposer({
);
const handleArchiveSubagent = useArchiveSubagent({ serverId });
const handleDetachSubagent = useDetachSubagent({ serverId });
const handleHideFinishedProviderSubagents = useHideFinishedProviderSubagents({
const archiveFinishedSubagents = useArchiveFinishedSubagents({
serverId,
parentAgentId: agentId,
rows: subagentRows,
});
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
serverId,
Expand Down Expand Up @@ -1628,7 +1629,8 @@ function ActiveAgentComposer({
onOpenSubagent={handleOpenSubagent}
onOpenProviderSubagent={handleOpenProviderSubagent}
onArchiveSubagent={handleArchiveSubagent}
onArchiveFinished={handleHideFinishedProviderSubagents}
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
archiveFinishedStatus={archiveFinishedSubagents.status}
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
/>
<Composer
Expand Down
Loading
Loading