Files
paseo/packages/app/e2e/browser/provider-task-list.real.spec.ts
T
77798b2e46 Show and stop agent background work (#6220)
* feat(server): add an in-memory store for agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(claude): report background tasks as background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(server): track agent background work in the agent manager

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(protocol): add agent background work messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(server): serve and push agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(client): expose agent background work on agent handles

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sdk): document agent background work

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): cover background shells in background work end to end

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): resync background work subscriptions on every snapshot

subscribe() now lists the agent's background work each time the event
observation is established, so the listener starts with the current list
and catches up after a reconnect. A list answered after a newer update
is dropped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): answer background work lists without loading dormant agents

Background work lives only in memory, so a stored agent that is not
loaded has none. The list handler now answers [] from storage instead of
resuming the agent, and still rejects unknown, internal, and archived
agents. A new test pins that old apps never receive
agent.background_work.update.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(server): read Claude background tasks defensively

The user's claude binary can differ from the SDK typings, so a task's
description is read as unknown and an empty or missing one becomes null.
Drop the monitor kind: Claude Monitor watches report as local_bash, so
kind is "shell" or "other" today. The store now copies only the wire
fields of each input.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(server): prove internal background work was emitted before checking globals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* docs(sdk): describe background work resync and feature gating

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* test(claude): cover background work reported by a retired query

The daemon stops reading a Claude query it no longer owns, so work that a
retired process still had queued cannot reach the background work list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(client): let background work subscribers handle a failed list read

When the list read that follows each subscription snapshot failed, the
error was only logged and the caller got neither the list nor a failure.
subscribe() now takes an optional onError that receives it; without one
the error is still logged. The read is not retried, since the next
reconnect reads the list again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(agents): show background work as active

Keep background activity visible after a foreground turn ends while preserving independent send and interrupt behavior.

* fix(agents): protect background work subscriptions

Keep internal work out of public updates and restore observation after a subscription failure.

* feat(agents): show Codex and OpenCode background work

Keep provider-owned shell activity visible after a turn ends while leaving foreground turn controls independent.

* feat(agents): stop individual background work

Wait for provider confirmation so stopping one background item leaves sibling work and foreground turn controls independent.

* refactor(agents): name background stop parameters

* test(website): isolate browser tests from live metadata

* test(agents): clarify background stop journeys

* fix(sdk): report background work subscription failures

Report setup failures so subscribers can replace an observation that the host released.

* fix(app): restore background work after reconnect

Wait for the host handshake before selecting the retained client so an offline pane starts observing when the connection returns.

* fix(agents): retain background shells across rewind

Preserve known live shell ownership when conversation history is rewound, and revoke it when the provider runtime exits.

* fix(website): launch test seeder across platforms

Invoke Wrangler through Node so local website test startup does not depend on an executable npm command shim.

* feat(agents): clarify background task details and tracks

Keep launch descriptions and commands distinct so background work remains identifiable, and allow composer tracks to scroll when space is limited.

* fix(agents): preserve active background work during archive

Keep background-running children out of bulk cleanup and warn before an individual archive. Preserve first taps on composer tracks while the keyboard is open.

* fix(codex): retain background terminals across rewind

Keep live terminal inventories attached to their originating threads so a fork does not hide or misroute their stop actions.

* fix(codex): scope background terminal identities to threads

Prevent commands on separate threads with the same provider process ID from sharing a stop target.

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
2026-11-06 19:49:49 +07:00

544 lines
20 KiB
TypeScript

import { mkdirSync, mkdtempSync, realpathSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import type { Locator } from "@playwright/test";
import { expect, test, type Page } from "../support/fixtures";
import {
cleanupRewindFlow,
launchAgent,
sendMessage,
type AgentHandle,
type RewindFlowProvider,
} from "../support/helpers/rewind-flow";
import {
expectCompletedTaskActivity,
expectTaskListEntries,
openCompletedTaskList,
} from "../support/helpers/task-list";
import { submitMessage, submitMessageWithButton } from "../support/helpers/composer";
import { waitForSettledPosition } from "../support/helpers/sheet-layout";
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
import { getServerId } from "../support/helpers/server-id";
import { gotoAppShell } from "../support/helpers/app";
interface TaskScenario {
provider: RewindFlowProvider;
entries: [string, string];
prompt: string;
providerConfig?: Parameters<typeof launchAgent>[0]["providerConfig"];
}
const scenarios: TaskScenario[] = [
{
provider: "claude",
entries: ["Claude inventory alpha", "Claude inventory beta"],
providerConfig: { model: "opus" },
prompt:
"Use TaskCreate to create exactly two tasks named Claude inventory alpha and Claude inventory beta. Use TaskUpdate to mark each in_progress and then completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
},
{
provider: "codex",
entries: ["Codex inventory alpha", "Codex inventory beta"],
prompt:
"Use update_plan to track exactly two steps named Codex inventory alpha and Codex inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
},
{
provider: "opencode",
entries: ["OpenCode inventory alpha", "OpenCode inventory beta"],
prompt:
"Use TodoWrite to track exactly two tasks named OpenCode inventory alpha and OpenCode inventory beta. Move each through in_progress and completed. Do not use subagents, shell commands, or edit files. Finish with TASKS_COMPLETE.",
},
];
test.describe("real provider composer task lists", () => {
test.setTimeout(600_000);
for (const scenario of scenarios) {
test(`${scenario.provider} shows native task progress above the composer`, async ({
page,
}, testInfo) => {
const cwd = realpathSync(
mkdtempSync(path.join(tmpdir(), `paseo-task-list-${scenario.provider}-`)),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await sendMessage(handle, scenario.prompt);
await openCompletedTaskList(page, scenario.entries.length);
await expectTaskListEntries(page, scenario.entries);
await expectCompletedTaskActivity(page, scenario.entries);
await expect(page.getByText("TaskUpdate", { exact: true })).toHaveCount(0);
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
const screenshot = testInfo.outputPath(`${scenario.provider}-composer-tasks.png`);
await page.screenshot({ path: screenshot });
await testInfo.attach(`${scenario.provider} composer tasks`, {
path: screenshot,
contentType: "image/png",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
test("Codex genuine Plan mode keeps the existing plan card", async ({ page }, testInfo) => {
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-genuine-codex-plan-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
providerConfig: { featureValues: { plan_mode: true } },
});
await submitMessage(
page,
"Produce a concise implementation plan with exactly these steps: Inspect genuine plan boundary; Implement genuine plan boundary; Verify genuine plan boundary. Do not implement it.",
);
const planCard = page.getByTestId("permission-plan-card");
await expect(planCard).toBeVisible({ timeout: 120_000 });
await expect(planCard).toContainText("Inspect genuine plan boundary");
await expect(page.getByTestId("timeline-plan-card")).toHaveCount(0);
const screenshot = testInfo.outputPath("codex-genuine-plan.png");
await page.screenshot({ path: screenshot });
await testInfo.attach("Codex genuine plan", { path: screenshot, contentType: "image/png" });
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
});
async function openBackgroundInventory(
page: Page,
options: { compact: boolean; count: number; timeout: number },
): Promise<Locator> {
const pill = page.getByRole("button", {
name: options.count === 1 ? "1 background task" : `${options.count} background tasks`,
});
await expect(pill).toBeVisible({ timeout: options.timeout });
await pill.click();
const panel = page.getByTestId(
options.compact ? "background-work-panel-content" : "background-work-panel",
);
if (options.compact) await waitForSettledPosition(panel);
return panel;
}
async function openBackgroundWork(page: Page, compact: boolean): Promise<void> {
const panel = await openBackgroundInventory(page, { compact, count: 1, timeout: 30_000 });
await expect(panel).toContainText(/sleep 120/);
}
async function expectBackgroundItems(
panel: Locator,
selected: string,
sibling: string,
): Promise<void> {
await expect(panel).toContainText(selected);
await expect(panel).toContainText(sibling);
}
async function stopBackgroundItem(panel: Locator, selected: string): Promise<void> {
const escaped = selected.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
await panel.getByRole("button", { name: new RegExp(`Stop .*${escaped}`) }).click();
}
async function expectStoppedItemAndSibling(
panel: Locator,
selected: string,
sibling: string,
): Promise<void> {
await expect(panel).not.toContainText(selected, { timeout: 40_000 });
await expect(panel).toContainText(sibling);
}
async function closeBackgroundWork(page: Page, compact: boolean): Promise<void> {
const panel = page.getByTestId(
compact ? "background-work-panel-content" : "background-work-panel",
);
if (compact) {
await page
.getByRole("button", { name: "Bottom sheet backdrop" })
.click({ position: { x: 12, y: 12 } });
} else {
await page.keyboard.press("Escape");
}
await expect(panel).toBeHidden();
}
async function expectBackgroundAgentRunning(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-status-indicator-running").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toHaveCount(0);
}
async function expectAssistantReply(page: Page, text: string): Promise<void> {
await expect(page.getByTestId("assistant-message").filter({ hasText: text }).last()).toBeVisible({
timeout: 120_000,
});
}
async function expectBackgroundPill(page: Page): Promise<void> {
await expect(page.getByRole("button", { name: "1 background task" })).toBeVisible();
}
async function expectBackgroundWorkOnAnotherClient(page: Page): Promise<void> {
const another = await page.context().newPage();
try {
await another.goto(page.url());
await openBackgroundWork(another, false);
} finally {
await another.close();
}
}
async function startBackgroundWorkWithoutOpeningPane(workspace: SeededWorkspace): Promise<void> {
const agent = await workspace.client.createAgent({
provider: "claude",
model: "haiku",
modeId: "bypassPermissions",
cwd: workspace.workspaceDirectory,
workspaceId: workspace.workspaceId,
title: "Background work before opening",
});
await workspace.client.sendAgentMessage(
agent.id,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
const result = await workspace.client.waitForFinish(agent.id, 120_000);
expect(result.status).toBe("idle");
}
async function expectWorkspaceRunning(page: Page, workspaceId: string): Promise<void> {
const row = page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspaceId}`);
await expect(row.getByTestId("workspace-status-indicator-running")).toBeVisible({
timeout: 30_000,
});
}
test("Claude keeps background work visible while accepting another turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-work-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await sendMessage(
handle,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase1-claude-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude background work remains readable in dark mode", async ({ page }) => {
test.setTimeout(300_000);
await page.addInitScript(() => {
localStorage.setItem("@paseo:app-settings", JSON.stringify({ theme: "pureBlack" }));
});
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-dark-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await sendMessage(
handle,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/review-background-dark.png"),
animations: "disabled",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude background work opens in a compact sheet", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 390, height: 844 });
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-background-compact-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({ page, provider: "claude", cwd, mode: "full-access" });
await submitMessageWithButton(
page,
"Use Bash with run_in_background to run exactly sleep 120. Do not wait for it. Reply SPAWNED immediately.",
);
await expectAssistantReply(page, "SPAWNED");
await openBackgroundWork(page, true);
await page.screenshot({
path: path.resolve("../../.artifacts/phase1-claude-compact.png"),
animations: "disabled",
});
await closeBackgroundWork(page, true);
await submitMessageWithButton(
page,
"Reply exactly HELLO without stopping the background command.",
);
await expectAssistantReply(page, "HELLO");
await expectBackgroundPill(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("background work updates the sidebar before the agent is opened", async ({ page }) => {
test.setTimeout(300_000);
const workspace = await seedWorkspace({ repoPrefix: "background-unopened-" });
try {
await gotoAppShell(page);
await expect(
page.getByTestId(`sidebar-workspace-row-${getServerId()}:${workspace.workspaceId}`),
).toBeVisible();
await startBackgroundWorkWithoutOpeningPane(workspace);
await expectWorkspaceRunning(page, workspace.workspaceId);
} finally {
await workspace.cleanup();
}
});
test("Codex keeps a live command visible after its turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-codex-background-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "codex",
cwd,
mode: "full-access",
providerConfig: { model: "gpt-6-astra" },
});
await sendMessage(
handle,
"Use exec_command to run sleep 120 with yield_time_ms 1000. Do not wait for or stop it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase2-codex-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("OpenCode keeps a live command visible after its turn", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(mkdtempSync(path.join(tmpdir(), "paseo-opencode-background-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "opencode",
cwd,
mode: "full-access",
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
});
await sendMessage(
handle,
"Use the shell tool with background true to run sleep 120. Do not wait or stop it. Reply SPAWNED immediately.",
);
await expectBackgroundAgentRunning(page);
await openBackgroundWork(page, false);
await page.screenshot({
path: path.resolve("../../.artifacts/phase2-opencode-background.png"),
animations: "disabled",
});
await closeBackgroundWork(page, false);
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
await expectBackgroundWorkOnAnotherClient(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
for (const scenario of [
{
provider: "claude" as const,
prompt:
"Use Bash with run_in_background to start sleep 180 and sleep 181 as two separate background commands. Do not wait for them. Reply SPAWNED immediately.",
providerConfig: { model: "haiku" },
},
{
provider: "codex" as const,
prompt:
"Use exec_command twice to start sleep 180 and sleep 181 separately with yield_time_ms 1000. Do not wait for or stop either process. Reply SPAWNED immediately.",
providerConfig: { model: "gpt-6-astra" },
},
{
provider: "opencode" as const,
prompt:
"Use the shell tool twice with background true: start sleep 180, then start sleep 181. Do not wait for or stop either. Reply SPAWNED immediately.",
providerConfig: { model: "openrouter/google/gemini-3.6-flash" },
},
]) {
test(`${scenario.provider} stops selected background work after the turn`, async ({ page }) => {
test.setTimeout(300_000);
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-qa-workspace-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: scenario.provider,
cwd,
mode: "full-access",
providerConfig: scenario.providerConfig,
});
await sendMessage(handle, scenario.prompt);
const panel = await openBackgroundInventory(page, {
compact: false,
count: 2,
timeout: 60_000,
});
await expectBackgroundItems(panel, "sleep 180", "sleep 181");
await stopBackgroundItem(panel, "sleep 180");
await expectStoppedItemAndSibling(panel, "sleep 180", "sleep 181");
await page.screenshot({
path: path.resolve(`../../.artifacts/phase3-${scenario.provider}-stop.png`),
animations: "disabled",
});
await page.keyboard.press("Escape");
await sendMessage(handle, "Reply exactly HELLO without stopping the background command.");
await expectBackgroundPill(page);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
}
test("Claude stops a selected monitor in the compact sheet", async ({ page }) => {
test.setTimeout(300_000);
await page.setViewportSize({ width: 390, height: 844 });
mkdirSync(path.resolve("../../.artifacts"), { recursive: true });
const cwd = realpathSync(mkdtempSync(path.resolve("../../.artifacts/phase3-monitor-workspace-")));
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "opus" },
});
await submitMessageWithButton(
page,
'Use exactly two separate Monitor tool calls, each with command `sleep 150`, timeout_ms 150000, persistent false, descriptions "probe monitor one" and "probe monitor two". Launch both independently, do not wait, then reply SPAWNED. Do not use any other tools.',
);
await expectAssistantReply(page, "SPAWNED");
const panel = await openBackgroundInventory(page, { compact: true, count: 2, timeout: 30_000 });
await expectBackgroundItems(panel, "probe monitor one", "probe monitor two");
await expect(panel.getByText("sleep 150", { exact: true })).toHaveCount(2);
await page.screenshot({
path: path.resolve("../../.artifacts/refinement-monitor-compact.png"),
animations: "disabled",
});
await stopBackgroundItem(panel, "probe monitor one");
await expectStoppedItemAndSibling(panel, "probe monitor one", "probe monitor two");
await page.screenshot({
path: path.resolve("../../.artifacts/phase3-claude-monitor-compact.png"),
animations: "disabled",
});
await closeBackgroundWork(page, true);
await submitMessageWithButton(
page,
"Reply exactly HELLO without stopping the background monitor.",
);
await expectAssistantReply(page, "HELLO");
await expectBackgroundPill(page);
await handle.client.sendAgentMessage(
handle.agentId,
"Use Bash to run sleep 20 in the foreground, then reply WAITED.",
);
await expect(page.getByRole("button", { name: "Stop agent", exact: true })).toBeVisible({
timeout: 30_000,
});
await handle.client.sendAgentMessage(
handle.agentId,
"Reply exactly INTERRUPTED without stopping the background monitor.",
{ activeTurnBehavior: "interrupt" },
);
expect((await handle.client.waitForFinish(handle.agentId, 120_000)).status).toBe("idle");
await expectBackgroundPill(page);
await expect
.poll(async () => handle!.client.listAgentBackgroundWork(handle!.agentId))
.toEqual(
expect.arrayContaining([
expect.objectContaining({ description: "probe monitor two", kind: "monitor" }),
]),
);
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});
test("Claude shows monitor description and command in the wide track", async ({ page }) => {
test.setTimeout(300_000);
const cwd = realpathSync(
mkdtempSync(path.resolve("../../.artifacts/refinement-wide-workspace-")),
);
let handle: AgentHandle | undefined;
try {
handle = await launchAgent({
page,
provider: "claude",
cwd,
mode: "full-access",
providerConfig: { model: "opus" },
});
await sendMessage(
handle,
'Use Monitor with command `sleep 150`, timeout_ms 150000, persistent false, description "Watch the build". Do not wait; reply SPAWNED. Do not use other tools.',
);
await expectBackgroundAgentRunning(page);
const workspaceRow = page.getByTestId(
`sidebar-workspace-row-${getServerId()}:${handle.workspaceId}`,
);
await workspaceRow.scrollIntoViewIfNeeded();
await expect(workspaceRow.getByTestId("workspace-status-indicator-running")).toBeVisible();
const panel = await openBackgroundInventory(page, {
compact: false,
count: 1,
timeout: 30_000,
});
await expect(panel).toContainText("Watch the build");
await expect(panel).toContainText("sleep 150");
await expect(panel.getByRole("button", { name: "Stop Watch the build" })).toBeVisible();
await page.screenshot({
path: path.resolve("../../.artifacts/refinement-monitor-wide.png"),
animations: "disabled",
});
} finally {
await cleanupRewindFlow({ handle, cwd });
}
});