Archive finished subagents across the whole track (#3368)
* feat(app): archive finished subagents together Make the track action clean up finished managed and provider-owned children as one operation while preserving running work. Managed archives remain sequential and provider-owned dismissal stays client-local. * test(app): clarify subagent archive assertions
This commit is contained in:
@@ -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;
|
||||
@@ -132,6 +144,147 @@ export async function expectSubagentRowGone(page: Page, childId: string): Promis
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectManagedSubagentUnarchived(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
childId: string,
|
||||
): Promise<void> {
|
||||
await expect(workspace.client.fetchAgent({ agentId: childId })).resolves.toMatchObject({
|
||||
agent: { id: childId, archivedAt: null },
|
||||
});
|
||||
}
|
||||
|
||||
export async function expectManagedSubagentArchived(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
childId: string,
|
||||
): Promise<void> {
|
||||
await expect
|
||||
.poll(() => workspace.client.fetchAgent({ agentId: childId }), { timeout: 30_000 })
|
||||
.toMatchObject({ agent: { id: childId, archivedAt: expect.any(String) } });
|
||||
}
|
||||
|
||||
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 });
|
||||
|
||||
Reference in New Issue
Block a user