From 77798b2e469fedf93bda02c1987c9a26adaf989b Mon Sep 17 00:00:00 2001 From: fxlelouarn <115778739+fxlelouarn@users.noreply.github.com> Date: Fri, 6 Nov 2026 13:49:49 +0100 Subject: [PATCH] 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 * feat(claude): report background tasks as background work Co-Authored-By: Claude Opus 5.5 * feat(server): track agent background work in the agent manager Co-Authored-By: Claude Opus 5.5 * feat(protocol): add agent background work messages Co-Authored-By: Claude Opus 5.5 * feat(server): serve and push agent background work Co-Authored-By: Claude Opus 5.5 * feat(client): expose agent background work on agent handles Co-Authored-By: Claude Opus 5.5 * docs(sdk): document agent background work Co-Authored-By: Claude Opus 5.5 * test(claude): cover background shells in background work end to end Co-Authored-By: Claude Opus 5.5 * 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 * 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 * 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 * test(server): prove internal background work was emitted before checking globals Co-Authored-By: Claude Opus 5.5 * docs(sdk): describe background work resync and feature gating Co-Authored-By: Claude Opus 5.5 * 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 * 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 * 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 Co-authored-by: Mohamed Boudra --- .../e2e/browser/composer-diff-stat.spec.ts | 92 ++- .../browser/provider-task-list.real.spec.ts | 433 +++++++++- .../app/e2e/support/helpers/seed-client.ts | 5 +- packages/app/src/agent-presentation/index.ts | 28 +- packages/app/src/background-work/host.test.ts | 97 +++ packages/app/src/background-work/host.ts | 115 +++ packages/app/src/background-work/index.tsx | 155 ++++ .../app/src/background-work/model.test.ts | 233 ++++++ packages/app/src/background-work/model.ts | 121 +++ .../src/background-work/presentation.test.ts | 20 + .../app/src/background-work/presentation.ts | 7 + packages/app/src/composer/tracks.tsx | 28 +- .../app/src/hooks/use-aggregated-agents.ts | 1 + packages/app/src/i18n/resources/ar.ts | 18 + packages/app/src/i18n/resources/en.ts | 18 + packages/app/src/i18n/resources/es.ts | 18 + packages/app/src/i18n/resources/fr.ts | 18 + packages/app/src/i18n/resources/ja.ts | 18 + packages/app/src/i18n/resources/ko.ts | 18 + packages/app/src/i18n/resources/pt-BR.ts | 18 + packages/app/src/i18n/resources/ru.ts | 18 + packages/app/src/i18n/resources/zh-CN.ts | 18 + packages/app/src/mobile-panels/gestures.ts | 12 + .../panels/agent-panel-descriptor.test.tsx | 23 + packages/app/src/panels/agent-tracks.tsx | 13 +- .../app/src/runtime/replica-cache/index.ts | 2 + packages/app/src/stores/session-store.ts | 1 + .../src/subagents/archive-finished.test.ts | 29 + .../app/src/subagents/archive-finished.ts | 11 +- .../src/subagents/archive-subagent.test.ts | 16 + .../app/src/subagents/archive-subagent.ts | 4 +- packages/app/src/subagents/select.test.ts | 3 + packages/app/src/subagents/select.ts | 2 + .../src/subagents/track-presentation.test.ts | 17 + .../app/src/subagents/track-presentation.ts | 12 +- packages/app/src/types/agent-directory.ts | 1 + packages/app/src/utils/agent-grouping.test.ts | 14 + packages/app/src/utils/agent-grouping.ts | 18 +- packages/app/src/utils/agent-snapshots.ts | 2 + .../app/src/utils/workspace-agent-activity.ts | 1 + .../src/connection/legacy-workspaces.ts | 1 + packages/client/src/daemon-client.ts | 55 ++ packages/client/src/index.test.ts | 253 ++++++ packages/client/src/index.ts | 76 ++ .../protocol/src/agent-state-bucket.test.ts | 24 + packages/protocol/src/agent-state-bucket.ts | 5 +- .../src/messages.background-work.test.ts | 103 +++ packages/protocol/src/messages.ts | 71 ++ .../src/server/agent/agent-manager.test.ts | 169 ++++ .../server/src/server/agent/agent-manager.ts | 61 ++ .../server/agent/agent-projections.test.ts | 9 + .../src/server/agent/agent-projections.ts | 1 + .../src/server/agent/agent-sdk-types.ts | 8 + .../agent/background-work/index.test.ts | 133 +++ .../src/server/agent/background-work/index.ts | 88 ++ .../src/server/agent/provider-registry.ts | 1 + .../claude/agent.background-subagent.test.ts | 86 +- .../providers/claude/agent.real.e2e.test.ts | 40 + .../claude/agent.runtime-exit.test.ts | 238 ++++++ .../agent/providers/claude/agent.test.ts | 9 +- .../server/agent/providers/claude/agent.ts | 56 +- .../providers/claude/background-work.test.ts | 21 + .../agent/providers/claude/background-work.ts | 36 + .../providers/claude/runtime-safety.test.ts | 48 ++ .../agent/providers/claude/runtime-safety.ts | 152 +++- .../providers/claude/subagents/live-source.ts | 4 +- .../providers/codex-app-server-agent.test.ts | 94 ++- .../agent/providers/codex-app-server-agent.ts | 21 + .../codex-interrupted-turn-terminals.test.ts | 332 +++++++- .../providers/codex/background-inventory.ts | 187 +++++ .../codex/test-utils/fake-app-server.ts | 1 + .../opencode/test-utils/v2-harness.ts | 1 + .../agent/providers/opencode/v2/agent.test.ts | 770 ++++++++++++++++++ .../server/agent/providers/opencode/v2/api.ts | 2 +- .../opencode/v2/background-inventory.ts | 308 +++++++ .../agent/providers/opencode/v2/session.ts | 23 + .../authorization/operation-permissions.ts | 5 + packages/server/src/server/session.test.ts | 249 ++++++ packages/server/src/server/session.ts | 117 ++- .../src/server/test-utils/session-stubs.ts | 1 + .../server/src/server/websocket-server.ts | 4 + .../server/src/server/wire-compat.test.ts | 4 + .../src/server/workspace-directory.test.ts | 13 + .../server/src/server/workspace-directory.ts | 4 +- .../terminal-session-controller.test.ts | 21 +- packages/website/e2e/agent-icons.spec.ts | 2 +- packages/website/e2e/download-thanks.spec.ts | 2 +- packages/website/e2e/fixtures.ts | 18 + packages/website/e2e/i18n.spec.ts | 2 +- packages/website/e2e/mockups.spec.ts | 2 +- packages/website/e2e/plugins.spec.ts | 2 +- packages/website/e2e/seed-cache.mjs | 54 ++ packages/website/e2e/sponsors.spec.ts | 2 +- packages/website/playwright.config.ts | 8 +- packages/website/vite.config.ts | 7 +- public-docs/sdk/events.md | 19 + public-docs/sdk/events.zh-CN.md | 19 + public-docs/sdk/reference.md | 55 +- public-docs/sdk/reference.zh-CN.md | 55 +- 99 files changed, 5681 insertions(+), 149 deletions(-) create mode 100644 packages/app/src/background-work/host.test.ts create mode 100644 packages/app/src/background-work/host.ts create mode 100644 packages/app/src/background-work/index.tsx create mode 100644 packages/app/src/background-work/model.test.ts create mode 100644 packages/app/src/background-work/model.ts create mode 100644 packages/app/src/background-work/presentation.test.ts create mode 100644 packages/app/src/background-work/presentation.ts create mode 100644 packages/protocol/src/messages.background-work.test.ts create mode 100644 packages/server/src/server/agent/background-work/index.test.ts create mode 100644 packages/server/src/server/agent/background-work/index.ts create mode 100644 packages/server/src/server/agent/providers/claude/background-work.test.ts create mode 100644 packages/server/src/server/agent/providers/claude/background-work.ts create mode 100644 packages/server/src/server/agent/providers/claude/runtime-safety.test.ts create mode 100644 packages/server/src/server/agent/providers/codex/background-inventory.ts create mode 100644 packages/server/src/server/agent/providers/opencode/v2/background-inventory.ts create mode 100644 packages/website/e2e/fixtures.ts create mode 100644 packages/website/e2e/seed-cache.mjs diff --git a/packages/app/e2e/browser/composer-diff-stat.spec.ts b/packages/app/e2e/browser/composer-diff-stat.spec.ts index 4eeada2f1f..5dd2b8ae1d 100644 --- a/packages/app/e2e/browser/composer-diff-stat.spec.ts +++ b/packages/app/e2e/browser/composer-diff-stat.spec.ts @@ -1,7 +1,9 @@ -import type { Locator } from "@playwright/test"; +import type { CDPSession, Locator } from "@playwright/test"; import { writeFile } from "node:fs/promises"; import path from "node:path"; import { expect, test, type Page } from "../support/fixtures"; +import { seedSubagent } from "../support/helpers/subagents"; +import { submitMessage } from "../support/helpers/composer"; import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent"; import { gateNextAgentMessage } from "../support/helpers/agent-message-gate"; import { @@ -860,3 +862,91 @@ async function expectReviewEditorBody(page: Page, body: string) { body, ); } + +async function swipeComposerTracks(page: Page, client: CDPSession, direction: "left" | "right") { + const track = page.getByTestId("composer-track-scroll"); + const box = await track.boundingBox(); + if (!box) throw new Error("Composer tracks are not visible"); + const start = direction === "left" ? box.x + box.width - 5 : box.x + 5; + const end = direction === "left" ? box.x + 5 : box.x + box.width - 5; + await swipeTouch(client, start, end, box.y + box.height / 2); +} + +async function swipeTouch(client: CDPSession, start: number, end: number, y: number) { + await client.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: start, y }], + }); + for (let step = 1; step <= 8; step++) { + await client.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: start + ((end - start) * step) / 8, y }], + }); + } + await client.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] }); +} + +async function trackScrollOffset(track: Locator): Promise { + return track.evaluate((node) => node.scrollLeft); +} + +test.describe("touch composer tracks", () => { + test.use({ hasTouch: true, isMobile: true }); + test("compact composer scrolls its task, subagent and changes tracks without squeezing them", async ({ + page, + }) => { + const workspace = await seedChangedAgent("composer-tracks-overflow-"); + const touch = await page.context().newCDPSession(page); + try { + await seedSubagent( + { client: workspace.client, repoPath: workspace.cwd, workspaceId: workspace.workspaceId }, + { parentId: workspace.agentId, title: "Track child" }, + ); + await page.setViewportSize({ width: 1000, height: 844 }); + await openAgentRoute(page, { + workspaceId: workspace.workspaceId, + agentId: workspace.agentId, + }); + await submitMessage(page, "emit 1 agent stream updates"); + const tasks = page.getByTestId("agent-task-list-header"); + const subagents = page.getByTestId("subagents-track-header"); + const changes = page.getByTestId("composer-diff-stat-pill"); + await expect(tasks).toBeVisible(); + await expect(subagents).toBeVisible(); + await expect(changes).toBeVisible(); + const wideWidths = await Promise.all( + [tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width), + ); + await page.setViewportSize({ width: 200, height: 844 }); + const narrowWidths = await Promise.all( + [tasks, subagents, changes].map(async (pill) => (await pill.boundingBox())!.width), + ); + for (const [index, width] of wideWidths.entries()) + expect(narrowWidths[index]).toBeGreaterThanOrEqual(width - 1); + const track = page.getByTestId("composer-track-scroll"); + expect(await track.evaluate((node) => node.scrollWidth)).toBeGreaterThan( + await track.evaluate((node) => node.clientWidth), + ); + await swipeComposerTracks(page, touch, "left"); + await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0); + await swipeComposerTracks(page, touch, "right"); + await expect.poll(() => trackScrollOffset(track)).toBeLessThanOrEqual(1); + await swipeComposerTracks(page, touch, "left"); + await expect.poll(() => trackScrollOffset(track)).toBeGreaterThan(0); + await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport(); + await expect(page.getByTestId("workspace-explorer-sidebar")).not.toBeInViewport(); + await page.getByRole("button", { name: "Open menu" }).click(); + await expect(page.getByTestId("sidebar-sessions")).toBeInViewport({ ratio: 1 }); + await page.getByTestId("sidebar-close").click(); + await expect(page.getByTestId("sidebar-sessions")).not.toBeInViewport(); + await expect(changes).toBeInViewport(); + await changes.click(); + await expect( + page.getByTestId("changes-header").filter({ visible: true }).first(), + ).toBeVisible(); + } finally { + await touch.detach(); + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/e2e/browser/provider-task-list.real.spec.ts b/packages/app/e2e/browser/provider-task-list.real.spec.ts index b388b7d2c7..9636f7c799 100644 --- a/packages/app/e2e/browser/provider-task-list.real.spec.ts +++ b/packages/app/e2e/browser/provider-task-list.real.spec.ts @@ -1,7 +1,8 @@ -import { mkdtempSync, realpathSync } from "node:fs"; +import { mkdirSync, mkdtempSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { expect, test } from "../support/fixtures"; +import type { Locator } from "@playwright/test"; +import { expect, test, type Page } from "../support/fixtures"; import { cleanupRewindFlow, launchAgent, @@ -14,7 +15,11 @@ import { expectTaskListEntries, openCompletedTaskList, } from "../support/helpers/task-list"; -import { submitMessage } from "../support/helpers/composer"; +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; @@ -114,3 +119,425 @@ test.describe("real provider composer task lists", () => { } }); }); + +async function openBackgroundInventory( + page: Page, + options: { compact: boolean; count: number; timeout: number }, +): Promise { + 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 { + 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 { + await expect(panel).toContainText(selected); + await expect(panel).toContainText(sibling); +} + +async function stopBackgroundItem(panel: Locator, selected: string): Promise { + 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 { + await expect(panel).not.toContainText(selected, { timeout: 40_000 }); + await expect(panel).toContainText(sibling); +} + +async function closeBackgroundWork(page: Page, compact: boolean): Promise { + 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 { + 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 { + await expect(page.getByTestId("assistant-message").filter({ hasText: text }).last()).toBeVisible({ + timeout: 120_000, + }); +} + +async function expectBackgroundPill(page: Page): Promise { + await expect(page.getByRole("button", { name: "1 background task" })).toBeVisible(); +} + +async function expectBackgroundWorkOnAnotherClient(page: Page): Promise { + 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 { + 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 { + 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 }); + } +}); diff --git a/packages/app/e2e/support/helpers/seed-client.ts b/packages/app/e2e/support/helpers/seed-client.ts index 2debc70406..41e676473f 100644 --- a/packages/app/e2e/support/helpers/seed-client.ts +++ b/packages/app/e2e/support/helpers/seed-client.ts @@ -157,8 +157,11 @@ export interface SeedDaemonClient { sendAgentMessage( agentId: string, text: string, - options?: { sourceAgentId?: string }, + options?: { sourceAgentId?: string; activeTurnBehavior?: "interrupt" | "steer" | "queue" }, ): Promise; + listAgentBackgroundWork( + agentId: string, + ): Promise; waitForFinish( agentId: string, timeout?: number, diff --git a/packages/app/src/agent-presentation/index.ts b/packages/app/src/agent-presentation/index.ts index f3c3f139f7..0afb1426d4 100644 --- a/packages/app/src/agent-presentation/index.ts +++ b/packages/app/src/agent-presentation/index.ts @@ -1,4 +1,6 @@ import { useShallow } from "zustand/shallow"; +import { useTranslation } from "react-i18next"; +import { i18n } from "@/i18n/i18next"; import { useProviderIcon } from "@/components/provider-icons"; import type { PanelDescriptor } from "@/panels/panel-registry"; import { useSessionStore, selectAgentTurnPresentation, type Agent } from "@/stores/session-store"; @@ -33,6 +35,7 @@ export function useAgentDescriptor( target: { kind: "agent"; agentId: string }, context: { serverId: string }, ): PanelDescriptor { + useTranslation(); const descriptorState = useSessionStore( useShallow((state) => { const session = state.sessions[context.serverId]; @@ -42,6 +45,7 @@ export function useAgentDescriptor( provider: agent?.provider ?? "codex", title: agent?.title ?? null, status: agent?.status ?? null, + backgroundWork: agent?.backgroundWork, pendingPermissionCount: agent?.pendingPermissions.length ?? 0, requiresAttention: agent?.requiresAttention ?? false, attentionReason: agent?.attentionReason ?? null, @@ -57,6 +61,7 @@ export function buildAgentDescriptor(input: { provider: Agent["provider"]; title: string | null; status: Agent["status"] | null; + backgroundWork?: Agent["backgroundWork"]; pendingPermissionCount: number; requiresAttention: boolean; attentionReason: Agent["attentionReason"]; @@ -65,20 +70,25 @@ export function buildAgentDescriptor(input: { }): PanelDescriptor { const provider = input.provider; const label = resolveAgentLabel(input.title); + const statusBucket = input.status + ? deriveSidebarStateBucket({ + status: input.isTurnActive ? "running" : input.status, + backgroundWork: input.backgroundWork, + pendingPermissionCount: input.pendingPermissionCount, + requiresAttention: input.requiresAttention, + attentionReason: input.attentionReason, + }) + : null; return { label: label ?? "", - subtitle: `${formatProviderLabel(provider)} agent`, + subtitle: + statusBucket === "running" && !input.isTurnActive && input.status !== "running" + ? i18n.t("message.backgroundWork.running") + : `${formatProviderLabel(provider)} agent`, tooltip: label ?? `${formatProviderLabel(provider)} agent`, titleState: label ? "ready" : "loading", icon: input.icon, - statusBucket: input.status - ? deriveSidebarStateBucket({ - status: input.isTurnActive ? "running" : input.status, - pendingPermissionCount: input.pendingPermissionCount, - requiresAttention: input.requiresAttention, - attentionReason: input.attentionReason, - }) - : null, + statusBucket, }; } diff --git a/packages/app/src/background-work/host.test.ts b/packages/app/src/background-work/host.test.ts new file mode 100644 index 0000000000..9142f56df4 --- /dev/null +++ b/packages/app/src/background-work/host.test.ts @@ -0,0 +1,97 @@ +import { expect, test } from "vitest"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { createHostBackgroundWorkModel } from "./host"; + +test("retries a failed observation and receives subsequent updates", async () => { + interface Observer { + snapshot(): void; + update(message: SessionOutboundMessage): void; + error(error: unknown): void; + } + const observers: Observer[] = []; + let releases = 0; + const item = { + id: "shell", + kind: "shell", + description: "sleep", + startedAt: "2026-01-01T00:00:00Z", + }; + const client = { + getLastServerInfoMessage: () => ({ features: { agentBackgroundWork: true } }), + stopAgentBackgroundWork: async () => {}, + listAgentBackgroundWork: async () => [item], + observeAgentBackgroundWork: () => ({ + subscribe: (observer: Observer) => { + observers.push(observer); + }, + release: async () => { + releases++; + }, + }), + }; + const model = createHostBackgroundWorkModel("host", "agent", { + getClient: () => client, + subscribe: () => () => {}, + }); + const unsubscribe = model.subscribe(() => {}); + observers[0].snapshot(); + await Promise.resolve(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observers[0].error(new Error("subscription failed")); + expect(model.getSnapshot()).toEqual({ status: "error", items: [item] }); + model.retry(); + expect(observers).toHaveLength(2); + expect(releases).toBe(1); + observers[1].snapshot(); + await Promise.resolve(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observers[0].error(new Error("late error")); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observers[1].update({ + type: "agent.background_work.update", + payload: { agentId: "agent", items: [] }, + }); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [] }); + unsubscribe(); +}); + +test("starts observing after the same client reconnects", async () => { + let connected = false; + let notify = () => {}; + let snapshot = () => {}; + const item = { + id: "shell", + kind: "shell", + description: "sleep", + startedAt: "2026-01-01T00:00:00Z", + }; + const client = { + getLastServerInfoMessage: () => + connected ? { features: { agentBackgroundWork: true } } : null, + stopAgentBackgroundWork: async () => {}, + listAgentBackgroundWork: async () => [item], + observeAgentBackgroundWork: () => ({ + subscribe: (observer: { snapshot(): void }) => { + snapshot = observer.snapshot; + }, + release: async () => {}, + }), + }; + const model = createHostBackgroundWorkModel("host", "agent", { + getClient: () => client, + subscribe: (listener) => { + notify = listener; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + connected = true; + notify(); + snapshot(); + await Promise.resolve(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + unsubscribe(); +}); diff --git a/packages/app/src/background-work/host.ts b/packages/app/src/background-work/host.ts new file mode 100644 index 0000000000..00134f18f0 --- /dev/null +++ b/packages/app/src/background-work/host.ts @@ -0,0 +1,115 @@ +import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { getHostRuntimeStore } from "@/runtime/host-runtime"; +import { createBackgroundWorkModel } from "./model"; + +interface BackgroundWorkClient { + getLastServerInfoMessage(): { + features?: { agentBackgroundWork?: boolean; agentBackgroundWorkStop?: boolean } | null; + } | null; + stopAgentBackgroundWork(agentId: string, item: PaseoAgentBackgroundWorkItem): Promise; + listAgentBackgroundWork(agentId: string): Promise; + observeAgentBackgroundWork(): { + subscribe(observer: { + snapshot(): void; + update(message: SessionOutboundMessage): void; + error(error: unknown): void; + }): unknown; + release(): Promise; + }; +} + +interface BackgroundWorkHost { + getClient(): BackgroundWorkClient | null; + subscribe(listener: () => void): () => void; +} + +function hostFor(serverId: string): BackgroundWorkHost { + const host = getHostRuntimeStore(); + return { + getClient: () => host.getSnapshot(serverId)?.client ?? null, + subscribe: (listener) => host.subscribe(serverId, listener), + }; +} + +export function createHostBackgroundWorkModel( + serverId: string, + agentId: string, + host: BackgroundWorkHost = hostFor(serverId), +) { + let client: BackgroundWorkClient | null = null; + const controls = (items: PaseoAgentBackgroundWorkItem[]) => { + // COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03. + if (client?.getLastServerInfoMessage()?.features?.agentBackgroundWorkStop === true) + return items; + return items.map(({ canStop: _canStop, stopToken: _stopToken, ...item }) => item); + }; + return createBackgroundWorkModel({ + stop: (item) => { + if (!client) return Promise.reject(new Error("Host disconnected")); + return client.stopAgentBackgroundWork(agentId, item); + }, + list: () => { + if (!client) return Promise.reject(new Error("Host disconnected")); + return client.listAgentBackgroundWork(agentId).then(controls); + }, + observe: ({ snapshot, update, disconnected, failed }) => { + let release: (() => void) | undefined; + let currentObservation: + | ReturnType + | undefined; + const sync = () => { + const candidate = host.getClient(); + // The runtime keeps the client across reconnects, but clears server info until handshake. + const next = candidate?.getLastServerInfoMessage() ? candidate : null; + if (next === client) return; + const previousRelease = release; + client = next; + release = undefined; + disconnected(); + previousRelease?.(); + if (!client) return; + // COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06. + if (client.getLastServerInfoMessage()?.features?.agentBackgroundWork !== true) { + failed(); + return; + } + const observedClient = client; + const observation = client.observeAgentBackgroundWork(); + currentObservation = observation; + observation.subscribe({ + snapshot: () => { + if (client === observedClient && currentObservation === observation) snapshot(); + }, + update: (message) => { + if ( + client === observedClient && + currentObservation === observation && + message.type === "agent.background_work.update" && + message.payload.agentId === agentId + ) + update(controls(message.payload.items)); + }, + error: () => { + if (client === observedClient && currentObservation === observation) failed(); + }, + }); + release = () => { + if (currentObservation === observation) currentObservation = undefined; + void observation + .release() + .catch((error: unknown) => + console.error("Background work subscription cleanup failed", error), + ); + }; + }; + const unsubscribe = host.subscribe(sync); + sync(); + return () => { + unsubscribe(); + client = null; + release?.(); + }; + }, + }); +} diff --git a/packages/app/src/background-work/index.tsx b/packages/app/src/background-work/index.tsx new file mode 100644 index 0000000000..34dfbb83ff --- /dev/null +++ b/packages/app/src/background-work/index.tsx @@ -0,0 +1,155 @@ +import { useCallback, useSyncExternalStore } from "react"; +import { Text, View } from "react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet } from "react-native-unistyles"; +import { ComposerTrackPill, ComposerTrackRow } from "@/composer/tracks"; +import { LiveElapsed } from "@/components/message"; +import { Button } from "@/components/ui/button"; +import type { PaseoAgentBackgroundWorkItem } from "@getpaseo/client"; +import { createHostBackgroundWorkModel } from "./host"; +import { backgroundWorkText } from "./presentation"; +export { createHostBackgroundWorkModel } from "./host"; + +function kindKey(kind: string): "shell" | "monitor" | "other" { + if (kind === "shell" || kind === "monitor") return kind; + return "other"; +} + +export function BackgroundWorkTrack({ + model, + count, +}: { + model: ReturnType; + count: number; +}) { + const { t } = useTranslation(); + const { items, status, stops } = useSyncExternalStore( + model.subscribe, + model.getSnapshot, + model.getSnapshot, + ); + const missing = Object.values(stops ?? {}).filter( + (entry) => !items?.some((item) => item.stopToken === entry.item.stopToken), + ); + const rows = [...(items ?? []), ...missing.map((entry) => entry.item)]; + if (count === 0 && rows.length === 0) return null; + const visibleCount = items?.length || count; + return ( + + {status === "loading" || status === "stale" ? ( + + {t("message.backgroundWork.waiting")} + + ) : null} + {status === "error" ? ( + <> + + {t("message.backgroundWork.error")} + + + {t("message.backgroundWork.retry")} + + + ) : null} + {rows.map((item) => ( + entry.stopToken === item.stopToken))} + ready={status === "ready"} + stopStatus={stops?.[item.stopToken ?? ""]?.status} + /> + ))} + + ); +} + +function BackgroundWorkRow({ + item, + model, + live, + ready, + stopStatus, +}: { + item: PaseoAgentBackgroundWorkItem; + model: ReturnType; + live: boolean; + ready: boolean; + stopStatus?: "pending" | "error"; +}) { + const { t } = useTranslation(); + const kind = t(`message.backgroundWork.${kindKey(item.kind)}`); + const stop = useCallback(() => { + void model.stopItem(item); + }, [model, item]); + const dismiss = useCallback( + () => model.dismissStop(item.stopToken ?? ""), + [model, item.stopToken], + ); + let detail = t("message.backgroundWork.notRunning"); + if (stopStatus === "pending") detail = t("message.backgroundWork.stopping"); + const { title, command } = backgroundWorkText(item, kind); + const action = item.canStop && ready && live; + const label = + stopStatus === "pending" + ? t("message.backgroundWork.stopping") + : t("message.backgroundWork.stop"); + return ( + + + {title} + {command ? {command} : null} + + {kind} ·{" "} + {live ? ( + + ) : ( + detail + )} + + {stopStatus === "error" ? ( + {t("message.backgroundWork.stopError")} + ) : null} + + {action ? ( + + ) : null} + {!live && stopStatus === "error" ? ( + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + row: { flexGrow: 1, flexShrink: 1, flexBasis: "auto", minWidth: 0 }, + title: { fontSize: theme.fontSize.base, color: theme.colors.foreground }, + facts: { fontSize: theme.fontSize.sm, color: theme.colors.foregroundMuted }, +})); diff --git a/packages/app/src/background-work/model.test.ts b/packages/app/src/background-work/model.test.ts new file mode 100644 index 0000000000..46fcaf8e95 --- /dev/null +++ b/packages/app/src/background-work/model.test.ts @@ -0,0 +1,233 @@ +import { expect, test } from "vitest"; +import { createBackgroundWorkModel } from "./model"; + +test("keeps a newer update when an earlier list resolves after it", async () => { + let resolve!: ( + value: Array<{ id: string; kind: string; description: string | null; startedAt: string }>, + ) => void; + let update!: ( + items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>, + ) => void; + let snapshot!: () => void; + const model = createBackgroundWorkModel({ + list: () => + new Promise((done) => { + resolve = done; + }), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + update = callbacks.update; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + expect(model.getSnapshot().items).toBeNull(); + snapshot(); + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + update([item]); + resolve([]); + await Promise.resolve(); + expect(model.getSnapshot().items).toEqual([item]); + unsubscribe(); +}); + +test("retains the last inventory while reconnecting and replaces it after resync", async () => { + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + const reads: Array<(value: (typeof item)[]) => void> = []; + let snapshot!: () => void; + const model = createBackgroundWorkModel({ + list: () => new Promise((resolve) => reads.push(resolve)), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + reads[0]([item]); + await Promise.resolve(); + snapshot(); + expect(model.getSnapshot().items).toEqual([item]); + reads[1]([]); + await Promise.resolve(); + expect(model.getSnapshot().items).toEqual([]); + unsubscribe(); +}); + +test("ignores a list response from a connection that was replaced", async () => { + let snapshot!: () => void; + let disconnected!: () => void; + let resolve!: ( + items: Array<{ id: string; kind: string; description: string | null; startedAt: string }>, + ) => void; + const model = createBackgroundWorkModel({ + list: () => + new Promise((done) => { + resolve = done; + }), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + disconnected = callbacks.disconnected; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + disconnected(); + resolve([{ id: "old", kind: "shell", description: null, startedAt: "2026-01-01T00:00:00Z" }]); + await Promise.resolve(); + expect(model.getSnapshot().items).toBeNull(); + unsubscribe(); +}); + +test("retains stale items on failure and retries the inventory", async () => { + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + let snapshot!: () => void; + let resolve!: (items: (typeof item)[]) => void; + let reject!: (error: Error) => void; + const model = createBackgroundWorkModel({ + list: () => + new Promise((done, fail) => { + resolve = done; + reject = fail; + }), + observe: (callbacks) => { + snapshot = callbacks.snapshot; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + resolve([item]); + await Promise.resolve(); + snapshot(); + expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] }); + reject(new Error("offline")); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "error", items: [item] }); + model.retry(); + expect(model.getSnapshot()).toEqual({ status: "stale", items: [item] }); + resolve([]); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [] }); + unsubscribe(); +}); + +test("reports an initial read failure without claiming an empty inventory", async () => { + let snapshot!: () => void; + const model = createBackgroundWorkModel({ + list: async () => { + throw new Error("offline"); + }, + observe: (callbacks) => { + snapshot = callbacks.snapshot; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + snapshot(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "error", items: null }); + unsubscribe(); +}); + +test("recreates a failed observation on retry and resumes live updates", async () => { + const item = { id: "1", kind: "shell", description: "sleep", startedAt: "2026-01-01T00:00:00Z" }; + let observation!: Parameters[0]["observe"]>[0]; + let starts = 0; + let releases = 0; + const model = createBackgroundWorkModel({ + list: async () => [item], + observe: (callbacks) => { + observation = callbacks; + starts++; + return () => { + releases++; + }; + }, + }); + const unsubscribe = model.subscribe(() => {}); + observation.failed(); + expect(model.getSnapshot()).toEqual({ status: "error", items: null }); + model.retry(); + expect(starts).toBe(2); + expect(releases).toBe(1); + observation.snapshot(); + await Promise.resolve(); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [item] }); + observation.update([]); + expect(model.getSnapshot()).toEqual({ status: "ready", items: [] }); + unsubscribe(); +}); + +test("keeps a stop pending across a roster removal and exposes a failed outcome", async () => { + const item = { + id: "1", + kind: "shell", + description: "sleep", + startedAt: "2026-01-01", + canStop: true, + stopToken: "generation-1", + }; + let update!: (items: (typeof item)[]) => void; + let reject!: (error: Error) => void; + const model = createBackgroundWorkModel({ + list: async () => [item], + stop: () => + new Promise((_, fail) => { + reject = fail; + }), + observe: (callbacks) => { + update = callbacks.update; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + update([item]); + const pending = model.stopItem(item); + update([]); + expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "pending", item }); + reject(new Error("connection lost")); + await pending; + expect(model.getSnapshot().stops?.["generation-1"]).toEqual({ status: "error", item }); + model.dismissStop("generation-1"); + expect(model.getSnapshot().stops).toEqual({}); + unsubscribe(); +}); + +test("ignores a stop response from a disconnected generation", async () => { + const first = { + id: "1", + kind: "shell", + description: "old", + startedAt: "2026-01-01", + canStop: true, + stopToken: "first", + }; + const replacement = { ...first, description: "new", stopToken: "second" }; + let update!: (items: (typeof first)[]) => void; + let disconnect!: () => void; + let resolve!: () => void; + const model = createBackgroundWorkModel({ + list: async () => [], + stop: () => + new Promise((done) => { + resolve = done; + }), + observe: (callbacks) => { + update = callbacks.update; + disconnect = callbacks.disconnected; + return () => {}; + }, + }); + const unsubscribe = model.subscribe(() => {}); + update([first]); + const pending = model.stopItem(first); + disconnect(); + update([replacement]); + resolve(); + await pending; + expect(model.getSnapshot().items).toEqual([replacement]); + expect(model.getSnapshot().stops?.first).toEqual({ status: "error", item: first }); + unsubscribe(); +}); diff --git a/packages/app/src/background-work/model.ts b/packages/app/src/background-work/model.ts new file mode 100644 index 0000000000..311abc92e7 --- /dev/null +++ b/packages/app/src/background-work/model.ts @@ -0,0 +1,121 @@ +import type { PaseoAgentBackgroundWorkItem as AgentBackgroundWorkItem } from "@getpaseo/client"; + +interface Port { + list(): Promise; + stop?(item: AgentBackgroundWorkItem): Promise; + observe(callbacks: { + snapshot(): void; + disconnected(): void; + failed(): void; + update(items: AgentBackgroundWorkItem[]): void; + }): () => void; +} + +interface BackgroundWorkState { + status: "loading" | "stale" | "ready" | "error"; + items: AgentBackgroundWorkItem[] | null; + stops?: Record; +} + +export function createBackgroundWorkModel(port: Port) { + let state: BackgroundWorkState = { status: "loading", items: null }; + let sequence = 0; + let stopGeneration = 0; + let stop: (() => void) | undefined; + let observationFailed = false; + const listeners = new Set<() => void>(); + const publish = (next: BackgroundWorkState) => { + state = + next.stops === undefined && state.stops === undefined + ? next + : { ...next, stops: next.stops ?? state.stops }; + for (const listener of listeners) listener(); + }; + const refresh = async () => { + const request = ++sequence; + publish({ status: state.items === null ? "loading" : "stale", items: state.items }); + try { + const items = await port.list(); + if (request === sequence) publish({ status: "ready", items }); + } catch { + if (request === sequence) publish({ status: "error", items: state.items }); + } + }; + const start = () => { + observationFailed = false; + stop = port.observe({ + snapshot: () => { + void refresh(); + }, + disconnected: () => { + sequence++; + stopGeneration++; + const stops = + state.stops && + Object.fromEntries( + Object.entries(state.stops).map(([id, entry]) => [ + id, + { ...entry, status: "error" as const }, + ]), + ); + publish({ status: state.items === null ? "loading" : "stale", items: state.items, stops }); + }, + failed: () => { + observationFailed = true; + sequence++; + publish({ status: "error", items: state.items }); + }, + update: (items) => { + sequence++; + publish({ status: "ready", items }); + }, + }); + }; + return { + getSnapshot: () => state, + dismissStop: (token: string) => { + const stops = { ...state.stops }; + delete stops[token]; + publish({ ...state, stops }); + }, + stopItem: async (item: AgentBackgroundWorkItem) => { + const token = item.stopToken; + if (!port.stop || !token || state.stops?.[token]?.status === "pending") return; + if (!state.items?.some((entry) => entry.stopToken === token)) return; + const generation = stopGeneration; + publish({ ...state, stops: { ...state.stops, [token]: { status: "pending", item } } }); + try { + await port.stop(item); + if (generation !== stopGeneration) return; + const stops = { ...state.stops }; + delete stops[token]; + publish({ ...state, stops }); + } catch { + if (generation === stopGeneration) + publish({ ...state, stops: { ...state.stops, [token]: { status: "error", item } } }); + } + }, + retry: () => { + if (observationFailed) { + stop?.(); + stop = undefined; + publish({ status: state.items === null ? "loading" : "stale", items: state.items }); + start(); + } else { + void refresh(); + } + }, + subscribe(listener: () => void) { + listeners.add(listener); + if (!stop) start(); + return () => { + listeners.delete(listener); + if (!listeners.size) { + sequence++; + stop?.(); + stop = undefined; + } + }; + }, + }; +} diff --git a/packages/app/src/background-work/presentation.test.ts b/packages/app/src/background-work/presentation.test.ts new file mode 100644 index 0000000000..738dde76ca --- /dev/null +++ b/packages/app/src/background-work/presentation.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "vitest"; +import { backgroundWorkText } from "./presentation"; + +test("shows descriptions and commands once, with useful fallbacks", () => { + expect( + backgroundWorkText({ description: "Watch build", command: "tail -f build.log" }, "Monitor"), + ).toEqual({ title: "Watch build", command: "tail -f build.log" }); + expect(backgroundWorkText({ description: null, command: "sleep 10" }, "Shell")).toEqual({ + title: "sleep 10", + command: null, + }); + expect(backgroundWorkText({ description: "sleep 10", command: "sleep 10" }, "Shell")).toEqual({ + title: "sleep 10", + command: null, + }); + expect(backgroundWorkText({ description: null }, "Shell")).toEqual({ + title: "Shell", + command: null, + }); +}); diff --git a/packages/app/src/background-work/presentation.ts b/packages/app/src/background-work/presentation.ts new file mode 100644 index 0000000000..4913289501 --- /dev/null +++ b/packages/app/src/background-work/presentation.ts @@ -0,0 +1,7 @@ +export function backgroundWorkText( + item: { description: string | null; command?: string }, + kind: string, +): { title: string; command: string | null } { + const title = item.description ?? item.command ?? kind; + return { title, command: item.command && item.command !== title ? item.command : null }; +} diff --git a/packages/app/src/composer/tracks.tsx b/packages/app/src/composer/tracks.tsx index 71ed312739..74a0b7f434 100644 --- a/packages/app/src/composer/tracks.tsx +++ b/packages/app/src/composer/tracks.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo, useState, type ReactElement, type ReactNode } from "react"; -import { Pressable, Text, View } from "react-native"; +import { Pressable, ScrollView, Text, View } from "react-native"; +import { GestureDetector } from "react-native-gesture-handler"; import { StyleSheet } from "react-native-unistyles"; import { MENU_ITEM_HEIGHT, @@ -16,6 +17,7 @@ import { StatusRing } from "@/components/status-ring"; import { bySizing } from "@/components/ui/control-geometry"; import { STATUS_RING_HALO_INSET } from "@/components/status-ring/geometry"; import { isWeb } from "@/constants/platform"; +import { usePanelAwareHorizontalScrollGesture } from "@/mobile-panels/gestures"; import { getStatusDotColor } from "@/utils/status-dot-color"; import { STATUS_INDICATOR_FILLED_DOT_SIZE } from "@/utils/status-indicator-geometry"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; @@ -35,10 +37,23 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles"; * move the transcript. */ export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement { + const scrollGesture = usePanelAwareHorizontalScrollGesture(); return ( - - {children} + + + + + {children} + + + ); @@ -371,9 +386,14 @@ const styles = StyleSheet.create((theme) => { md: COMPOSER_PILL_CLEARANCE.wide, }, }, - track: { + trackViewport: { width: "100%", maxWidth: theme.contentMaxWidth, + minWidth: 0, + flexGrow: 0, + flexShrink: 1, + }, + track: { flexDirection: "row", alignItems: "center", gap: theme.spacing[1], diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 5ea1eadc4d..910caf1d64 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -85,6 +85,7 @@ export function useAggregatedAgents(options?: { serverLabel, title: agent.title ?? null, status: agent.status, + backgroundWork: agent.backgroundWork, turn: agent.turn, lastActivityAt: agent.lastActivityAt, cwd: agent.cwd, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index fda62bd508..b731187128 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -379,6 +379,24 @@ export const ar: TranslationResources = { answerPlaceholder: "اكتب إجابتك...", otherPlaceholder: "آخر...", }, + backgroundWork: { + pillOne: "مهمة واحدة في الخلفية", + pillMany: "{{count}} مهمة في الخلفية", + notRunning: "لم يعد قيد التشغيل", + dismiss: "إغلاق", + stop: "إيقاف", + stopping: "جارٍ الإيقاف…", + stopError: "تعذر تأكيد الإيقاف.", + label: "في الخلفية", + title: "عمل الخلفية", + waiting: "بانتظار تفاصيل عمل الخلفية", + error: "تعذر تحميل عمل الخلفية", + retry: "إعادة المحاولة", + running: "عمل الخلفية قيد التشغيل", + shell: "الصدفة", + monitor: "المراقبة", + other: "عمل آخر", + }, todo: { title: "المهام", empty: "لا توجد مهام حتى الآن.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 79baa3a48c..bd148ca12b 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -376,6 +376,24 @@ export const en = { answerPlaceholder: "Type your answer...", otherPlaceholder: "Other...", }, + backgroundWork: { + pillOne: "1 background task", + pillMany: "{{count}} background tasks", + notRunning: "No longer running", + dismiss: "Dismiss", + stop: "Stop", + stopping: "Stopping…", + stopError: "Could not confirm stop.", + label: "Background", + title: "Background work", + waiting: "Waiting for background work details", + error: "Could not load background work", + retry: "Retry", + running: "Background work running", + shell: "Shell", + monitor: "Monitor", + other: "Other work", + }, todo: { title: "Tasks", empty: "No tasks yet.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 2841ec345f..e662f7624e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -384,6 +384,24 @@ export const es: TranslationResources = { answerPlaceholder: "Escribe tu respuesta...", otherPlaceholder: "Otro...", }, + backgroundWork: { + pillOne: "1 tarea en segundo plano", + pillMany: "{{count}} tareas en segundo plano", + notRunning: "Ya no está en ejecución", + dismiss: "Cerrar", + stop: "Detener", + stopping: "Deteniendo…", + stopError: "No se pudo confirmar la detención.", + label: "Segundo plano", + title: "Trabajo en segundo plano", + waiting: "Esperando los detalles", + error: "No se pudo cargar el trabajo en segundo plano", + retry: "Reintentar", + running: "Trabajo en segundo plano en curso", + shell: "Consola", + monitor: "Monitor", + other: "Otro trabajo", + }, todo: { title: "Tareas", empty: "Aún no hay tareas.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 9324fa84c8..3eee928d57 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -384,6 +384,24 @@ export const fr: TranslationResources = { answerPlaceholder: "Saisissez votre réponse…", otherPlaceholder: "Autre…", }, + backgroundWork: { + pillOne: "1 tâche en arrière-plan", + pillMany: "{{count}} tâches en arrière-plan", + notRunning: "N’est plus en cours", + dismiss: "Fermer", + stop: "Arrêter", + stopping: "Arrêt…", + stopError: "Impossible de confirmer l’arrêt.", + label: "Arrière-plan", + title: "Travail en arrière-plan", + waiting: "En attente des détails", + error: "Impossible de charger le travail en arrière-plan", + retry: "Réessayer", + running: "Travail en arrière-plan en cours", + shell: "Shell", + monitor: "Surveillance", + other: "Autre travail", + }, todo: { title: "Tâches", empty: "Aucune tâche pour l’instant.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 58c2ec59df..fbf3fd7ab8 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -384,6 +384,24 @@ export const ja: TranslationResources = { answerPlaceholder: "回答を入力...", otherPlaceholder: "その他...", }, + backgroundWork: { + pillOne: "バックグラウンドタスク1件", + pillMany: "バックグラウンドタスク{{count}}件", + notRunning: "実行は終了しました", + dismiss: "閉じる", + stop: "停止", + stopping: "停止中…", + stopError: "停止を確認できませんでした。", + label: "バックグラウンド", + title: "バックグラウンド作業", + waiting: "作業の詳細を待っています", + error: "バックグラウンド作業を読み込めませんでした", + retry: "再試行", + running: "バックグラウンド作業を実行中", + shell: "シェル", + monitor: "監視", + other: "その他の作業", + }, todo: { title: "タスク", empty: "タスクがまだありません。", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 12e2909ddc..94272f7143 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -380,6 +380,24 @@ export const ko: TranslationResources = { answerPlaceholder: "답변을 입력하세요...", otherPlaceholder: "기타...", }, + backgroundWork: { + pillOne: "백그라운드 작업 1개", + pillMany: "백그라운드 작업 {{count}}개", + notRunning: "더 이상 실행 중이 아님", + dismiss: "닫기", + stop: "중지", + stopping: "중지 중…", + stopError: "중지를 확인하지 못했습니다.", + label: "백그라운드", + title: "백그라운드 작업", + waiting: "작업 세부 정보를 기다리는 중", + error: "백그라운드 작업을 불러오지 못했습니다", + retry: "다시 시도", + running: "백그라운드 작업 실행 중", + shell: "셸", + monitor: "모니터", + other: "기타 작업", + }, todo: { title: "작업", empty: "아직 작업이 없습니다.", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 88f400907d..e8d3b166b8 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -383,6 +383,24 @@ export const ptBR: TranslationResources = { answerPlaceholder: "Digite sua resposta...", otherPlaceholder: "Outro...", }, + backgroundWork: { + pillOne: "1 tarefa em segundo plano", + pillMany: "{{count}} tarefas em segundo plano", + notRunning: "Não está mais em execução", + dismiss: "Dispensar", + stop: "Parar", + stopping: "Parando…", + stopError: "Não foi possível confirmar a parada.", + label: "Segundo plano", + title: "Trabalho em segundo plano", + waiting: "Aguardando os detalhes", + error: "Não foi possível carregar o trabalho em segundo plano", + retry: "Tentar novamente", + running: "Trabalho em segundo plano em andamento", + shell: "Shell", + monitor: "Monitor", + other: "Outro trabalho", + }, todo: { title: "Tarefas", empty: "Nenhuma tarefa ainda.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index abab6a8395..f9eda82e87 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -383,6 +383,24 @@ export const ru: TranslationResources = { answerPlaceholder: "Введите ответ...", otherPlaceholder: "Другой ответ...", }, + backgroundWork: { + pillOne: "1 фоновая задача", + pillMany: "Фоновые задачи: {{count}}", + notRunning: "Больше не выполняется", + dismiss: "Закрыть", + stop: "Остановить", + stopping: "Остановка…", + stopError: "Не удалось подтвердить остановку.", + label: "В фоне", + title: "Фоновые задачи", + waiting: "Ожидание сведений о задачах", + error: "Не удалось загрузить фоновые задачи", + retry: "Повторить", + running: "Фоновые задачи выполняются", + shell: "Оболочка", + monitor: "Монитор", + other: "Другая задача", + }, todo: { title: "Задачи", empty: "Задач пока нет.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 3a1c9bf29b..16b5e2cfa9 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -379,6 +379,24 @@ export const zhCN: TranslationResources = { answerPlaceholder: "输入你的回答...", otherPlaceholder: "其他...", }, + backgroundWork: { + pillOne: "1 个后台任务", + pillMany: "{{count}} 个后台任务", + notRunning: "已不再运行", + dismiss: "关闭", + stop: "停止", + stopping: "正在停止…", + stopError: "无法确认已停止。", + label: "后台", + title: "后台任务", + waiting: "正在等待后台任务详情", + error: "无法加载后台任务", + retry: "重试", + running: "后台任务运行中", + shell: "Shell", + monitor: "监控", + other: "其他任务", + }, todo: { title: "任务", empty: "还没有任务。", diff --git a/packages/app/src/mobile-panels/gestures.ts b/packages/app/src/mobile-panels/gestures.ts index c95c69aba4..c5261b298f 100644 --- a/packages/app/src/mobile-panels/gestures.ts +++ b/packages/app/src/mobile-panels/gestures.ts @@ -11,6 +11,18 @@ import { canBeginMobilePanelGesture, isMobilePanelGestureCurrent } from "./model import { useMobilePanelsRuntime } from "./provider"; import { resolveMobilePanelGestureIntent } from "./gesture-intent"; +/** A horizontal surface takes the current touch before either panel opens. */ +export function usePanelAwareHorizontalScrollGesture() { + const { leftOpenGestureRef, rightOpenGestureRef } = useMobilePanelsRuntime(); + return useMemo( + () => + Gesture.Native() + .blocksExternalGesture(leftOpenGestureRef, rightOpenGestureRef) + .disallowInterruption(true), + [leftOpenGestureRef, rightOpenGestureRef], + ); +} + function isCurrentSelection(startedRevision: number): boolean { return usePanelStore.getState().mobilePanel.revision === startedRevision; } diff --git a/packages/app/src/panels/agent-panel-descriptor.test.tsx b/packages/app/src/panels/agent-panel-descriptor.test.tsx index ac57778111..eb5aba735f 100644 --- a/packages/app/src/panels/agent-panel-descriptor.test.tsx +++ b/packages/app/src/panels/agent-panel-descriptor.test.tsx @@ -105,6 +105,29 @@ describe("shared agent descriptor", () => { expect(describeAgent({ isTurnActive: true }).statusBucket).toBe("running"); }); + it("shows background work as running without making the turn active", () => { + expect(describeAgent({ backgroundWork: { count: 1 } })).toMatchObject({ + statusBucket: "running", + subtitle: "Background work running", + }); + expect( + describeAgent({ + backgroundWork: { count: 1 }, + attentionReason: "error", + requiresAttention: true, + }).statusBucket, + ).toBe("failed"); + expect(describeAgent({ backgroundWork: { count: 1 }, status: "error" }).statusBucket).toBe( + "running", + ); + }); + + it("localizes the background work detail", async () => { + await i18n.changeLanguage("zh-CN"); + expect(describeAgent({ backgroundWork: { count: 1 } }).subtitle).toBe("后台任务运行中"); + await i18n.changeLanguage("en"); + }); + it("surfaces pending permissions even while the turn is running", () => { expect(describeAgent({ isTurnActive: true, pendingPermissionCount: 1 }).statusBucket).toBe( "needs_input", diff --git a/packages/app/src/panels/agent-tracks.tsx b/packages/app/src/panels/agent-tracks.tsx index ee1926565f..c4abe7a3fd 100644 --- a/packages/app/src/panels/agent-tracks.tsx +++ b/packages/app/src/panels/agent-tracks.tsx @@ -1,5 +1,6 @@ import type { AgentAddress } from "@getpaseo/protocol/addresses"; -import { memo, useCallback, type ReactElement } from "react"; +import { memo, useCallback, useMemo, type ReactElement } from "react"; +import { BackgroundWorkTrack, createHostBackgroundWorkModel } from "@/background-work"; import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill"; import { AgentTaskList } from "@/composer/task-list"; import { ComposerTrackBar } from "@/composer/tracks"; @@ -51,6 +52,15 @@ export const AgentTracks = memo(function AgentTracks({ rows: subagentRows, }); const { tabId } = usePaneContext(); + const backgroundWorkCount = useSessionStore((state) => { + const session = state.sessions[serverId]; + const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); + return agent?.backgroundWork?.count ?? 0; + }); + const backgroundModel = useMemo( + () => createHostBackgroundWorkModel(serverId, agentId), + [serverId, agentId], + ); const isCompact = useIsCompactFormFactor(); const openInSidePane = useSettings((settings) => settings.openInSidePane); const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); @@ -111,6 +121,7 @@ export const AgentTracks = memo(function AgentTracks({ return ( + {parentAgentId ? : null} { expect(descriptors.get("running")).toBe(running); }); }); + +it("excludes live background work and rechecks it before each archive", async () => { + const first = deferred(); + const active = { ...paseo("active"), backgroundWork: { count: 1 } }; + const current = new Map([ + ["first", managed("first")], + ["later", managed("later")], + ["active", managed("active", "idle", { backgroundWork: { count: 1 } })], + ]); + const calls: string[] = []; + const archive = createArchiveFinishedSubagents([paseo("first"), paseo("later"), active], { + parentAgentId: "parent", + getManagedSubagent: ({ agentId }) => current.get(agentId), + archiveManagedSubagent: ({ agentId }) => { + calls.push(agentId); + return first.promise; + }, + dismissProviderSubagents: () => undefined, + }); + expect(archive.getState().eligibleCount).toBe(2); + const operation = archive.archiveFinished(); + current.set("later", managed("later", "idle", { backgroundWork: { count: 1 } })); + first.resolve(); + expect(await operation).toMatchObject({ + archivedPaseoIds: ["first"], + skippedPaseoIds: ["later"], + }); + expect(calls).toEqual(["first"]); +}); diff --git a/packages/app/src/subagents/archive-finished.ts b/packages/app/src/subagents/archive-finished.ts index c79c3af6f9..1ad7a1bacd 100644 --- a/packages/app/src/subagents/archive-finished.ts +++ b/packages/app/src/subagents/archive-finished.ts @@ -19,7 +19,10 @@ export interface ArchiveFinishedOutcome { failures: Array<{ id: string; error: unknown }>; } -export type ManagedSubagentSnapshot = Pick; +export type ManagedSubagentSnapshot = Pick< + Agent, + "id" | "status" | "parentAgentId" | "archivedAt" | "backgroundWork" +>; export interface ArchiveFinishedSubagentsDeps { parentAgentId: string; @@ -36,7 +39,10 @@ export interface ArchiveFinishedSubagents { } export function isFinishedSubagent(row: SubagentRow): boolean { - if (row.kind === "paseo") return row.status === "idle" || row.status === "error"; + if (row.kind === "paseo") + return ( + (row.status === "idle" || row.status === "error") && (row.backgroundWork?.count ?? 0) === 0 + ); return row.status === "completed" || row.status === "failed" || row.status === "canceled"; } @@ -48,6 +54,7 @@ function canArchiveManagedSubagent( agent && !agent.archivedAt && agent.parentAgentId === parentAgentId && + (agent.backgroundWork?.count ?? 0) === 0 && (agent.status === "idle" || agent.status === "error"), ); } diff --git a/packages/app/src/subagents/archive-subagent.test.ts b/packages/app/src/subagents/archive-subagent.test.ts index 9314961b16..78a9463fea 100644 --- a/packages/app/src/subagents/archive-subagent.test.ts +++ b/packages/app/src/subagents/archive-subagent.test.ts @@ -203,3 +203,19 @@ describe("requestArchiveSubagent", () => { expect(env.recordedErrors).toEqual([error]); }); }); + +it("warns before archiving an idle subagent with live background work", async () => { + const env = createFakeEnv({ + initialSubagents: [ + { + id: "child", + snapshot: { title: "Review branch", status: "idle", backgroundWork: { count: 1 } }, + }, + ], + }); + await requestArchiveSubagent({ serverId: "server-1", subagentId: "child" }, env.deps); + expect(env.recordedConfirmInputs[0]).toMatchObject({ + title: "Archive running subagent?", + message: expect.stringContaining("will stop"), + }); +}); diff --git a/packages/app/src/subagents/archive-subagent.ts b/packages/app/src/subagents/archive-subagent.ts index 653dcb1cb2..aead4a41b7 100644 --- a/packages/app/src/subagents/archive-subagent.ts +++ b/packages/app/src/subagents/archive-subagent.ts @@ -4,6 +4,7 @@ import type { ConfirmDialogInput } from "@/utils/confirm-dialog"; export interface ResolveArchiveSubagentDialogInput { title: Agent["title"] | null | undefined; status: Agent["status"] | null | undefined; + backgroundWork?: Agent["backgroundWork"]; } function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null { @@ -24,7 +25,7 @@ export function resolveArchiveSubagentDialog( input: ResolveArchiveSubagentDialogInput, ): ConfirmDialogInput { const subagentLabel = resolveSubagentLabel(input.title) ?? "this subagent"; - const isRunning = input.status === "running"; + const isRunning = input.status === "running" || (input.backgroundWork?.count ?? 0) > 0; return { title: isRunning ? "Archive running subagent?" : "Archive subagent?", @@ -58,6 +59,7 @@ export async function requestArchiveSubagent( resolveArchiveSubagentDialog({ title: subagent?.title, status: subagent?.status, + backgroundWork: subagent?.backgroundWork, }), ); if (!confirmed) { diff --git a/packages/app/src/subagents/select.test.ts b/packages/app/src/subagents/select.test.ts index 9fd84a19e7..a9d8ce4152 100644 --- a/packages/app/src/subagents/select.test.ts +++ b/packages/app/src/subagents/select.test.ts @@ -354,6 +354,7 @@ describe("selectSubagentsForParent", () => { provider: "claude", title: "Review child", status: "running", + backgroundWork: { count: 1 }, requiresAttention: true, createdAt, model: "should-not-leak", @@ -380,6 +381,7 @@ describe("selectSubagentsForParent", () => { description: null, subtitle: null, status: "running", + backgroundWork: { count: 1 }, turn: { phase: "idle", cancellationRequestId: null }, requiresAttention: true, createdAt, @@ -387,6 +389,7 @@ describe("selectSubagentsForParent", () => { ]); expect(Object.keys(rows[0] ?? {}).sort()).toEqual([ "address", + "backgroundWork", "createdAt", "description", "id", diff --git a/packages/app/src/subagents/select.ts b/packages/app/src/subagents/select.ts index 58a883f1c3..33255e2e4e 100644 --- a/packages/app/src/subagents/select.ts +++ b/packages/app/src/subagents/select.ts @@ -18,6 +18,7 @@ export interface PaseoSubagentRow { description: null; subtitle: null; status: Agent["status"]; + backgroundWork?: Agent["backgroundWork"]; turn: Agent["turn"]; requiresAttention: Agent["requiresAttention"]; createdAt: Agent["createdAt"]; @@ -65,6 +66,7 @@ function toSubagentRow(agent: Agent): SubagentRow { description: null, subtitle: null, status: agent.status, + backgroundWork: agent.backgroundWork, turn: agent.turn, requiresAttention: agent.requiresAttention, createdAt: agent.createdAt, diff --git a/packages/app/src/subagents/track-presentation.test.ts b/packages/app/src/subagents/track-presentation.test.ts index 0397795541..7f1ae379df 100644 --- a/packages/app/src/subagents/track-presentation.test.ts +++ b/packages/app/src/subagents/track-presentation.test.ts @@ -26,6 +26,7 @@ function row( ? { phase: "open", turnId: null, startedAt: null, cancellationRequestId: null } : { phase: "idle", cancellationRequestId: null }), requiresAttention: overrides.requiresAttention ?? false, + backgroundWork: overrides.backgroundWork, createdAt: overrides.createdAt ?? new Date("2026-04-20T00:00:00.000Z"), }; } @@ -193,6 +194,22 @@ describe("buildSubagentRowPresentationData", () => { ); }); + it("shows a child with background work as running after its foreground turn ends", () => { + expect( + buildSubagentRowPresentationData( + row({ id: "a", status: "idle", backgroundWork: { count: 1 } }), + ).statusBucket, + ).toBe("running"); + }); + + it("keeps a failed child marked failed while it has background work", () => { + expect( + buildSubagentRowPresentationData( + row({ id: "a", status: "error", backgroundWork: { count: 1 } }), + ).statusBucket, + ).toBe("failed"); + }); + it("maps an idle row to the done status bucket so callers render the static provider icon", () => { expect(buildSubagentRowPresentationData(row({ id: "a", status: "idle" })).statusBucket).toBe( "done", diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index 5a1d671339..0bb5ef8dd5 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -19,9 +19,12 @@ function presentationStatus(row: SubagentRow) { * A child's mark reports what it is doing, not whether anyone read it: the track is where a * failed child stays visible after the parent's own attention is cleared. */ -export function deriveSubagentStatusBucket(status: AgentLifecycleStatus): SidebarStateBucket { +export function deriveSubagentStatusBucket( + status: AgentLifecycleStatus, + backgroundWork?: { count: number } | null, +): SidebarStateBucket { if (status === "error") return "failed"; - if (status === "running") return "running"; + if (status === "running" || (backgroundWork?.count ?? 0) > 0) return "running"; return "done"; } @@ -49,7 +52,10 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP label: label ?? "", subtitle: subtitle ?? "", titleState: label ? "ready" : "loading", - statusBucket: deriveSubagentStatusBucket(status), + statusBucket: deriveSubagentStatusBucket( + status, + row.kind === "paseo" ? row.backgroundWork : undefined, + ), }; } diff --git a/packages/app/src/types/agent-directory.ts b/packages/app/src/types/agent-directory.ts index 8ac1870a6f..9021c10786 100644 --- a/packages/app/src/types/agent-directory.ts +++ b/packages/app/src/types/agent-directory.ts @@ -6,6 +6,7 @@ export type AgentDirectoryEntry = Pick< | "serverId" | "title" | "status" + | "backgroundWork" | "turn" | "lastActivityAt" | "cwd" diff --git a/packages/app/src/utils/agent-grouping.test.ts b/packages/app/src/utils/agent-grouping.test.ts index f380dc1118..fdda9f2fda 100644 --- a/packages/app/src/utils/agent-grouping.test.ts +++ b/packages/app/src/utils/agent-grouping.test.ts @@ -10,6 +10,7 @@ function makeAgent(overrides: Partial = {}): AggregatedAgent { serverLabel: (overrides as { serverLabel?: string }).serverLabel ?? "server", title: overrides.title ?? null, status: overrides.status ?? ("running" as AggregatedAgent["status"]), + backgroundWork: overrides.backgroundWork, turn: overrides.turn ?? ({ @@ -71,6 +72,19 @@ describe("deriveProjectDisplayName", () => { }); describe("groupAgents", () => { + it("keeps an idle agent with long-running background work in the active group", () => { + const agent = makeAgent({ + status: "idle", + turn: { phase: "idle", cancellationRequestId: null }, + lastActivityAt: new Date("2020-01-01T00:00:00.000Z"), + backgroundWork: { count: 1 }, + }); + expect( + groupAgents([agent]) + .activeGroups.flatMap((group) => group.agents) + .map((item) => item.id), + ).toEqual([agent.id]); + }); it("uses turn liveness instead of protocol status for active grouping", () => { const old = new Date("2020-01-01T00:00:00.000Z"); const open = makeAgent({ id: "open", status: "idle", lastActivityAt: old }); diff --git a/packages/app/src/utils/agent-grouping.ts b/packages/app/src/utils/agent-grouping.ts index eaca2d1511..698ab6b5a4 100644 --- a/packages/app/src/utils/agent-grouping.ts +++ b/packages/app/src/utils/agent-grouping.ts @@ -1,4 +1,5 @@ import type { AggregatedAgent } from "@/hooks/use-aggregated-agents"; +import { isSidebarActiveAgent } from "@/utils/sidebar-agent-state"; /** * Derives the project key for grouping agents. @@ -262,12 +263,19 @@ const MAX_INACTIVE_PER_PROJECT = 5; * - All truly active agents (running/needs input/requires attention) are always shown * - Recently active (within grace period but not truly active) are limited to MAX_INACTIVE_PER_PROJECT */ +function activityStatus(agent: AggregatedAgent): AggregatedAgent["status"] { + if (agent.turn.phase === "open") return "running"; + return agent.status === "running" ? "idle" : agent.status; +} + function isAgentTrulyActive(agent: AggregatedAgent): boolean { - return ( - agent.turn.phase === "open" || - agent.requiresAttention || - (agent.pendingPermissionCount ?? 0) > 0 - ); + return isSidebarActiveAgent({ + status: activityStatus(agent), + backgroundWork: agent.backgroundWork, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason, + pendingPermissionCount: agent.pendingPermissionCount, + }); } function partitionAgentsByActivity( diff --git a/packages/app/src/utils/agent-snapshots.ts b/packages/app/src/utils/agent-snapshots.ts index df08cdddb7..11640d800b 100644 --- a/packages/app/src/utils/agent-snapshots.ts +++ b/packages/app/src/utils/agent-snapshots.ts @@ -70,6 +70,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload { updatedAt: agent.updatedAt.toISOString(), lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null, status: agent.status, + backgroundWork: agent.backgroundWork, ...projectActiveTurn(agent), capabilities: agent.capabilities, currentModeId: agent.currentModeId, @@ -111,6 +112,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: id: snapshot.id, provider: snapshot.provider, status: snapshot.status, + backgroundWork: snapshot.backgroundWork, turn, createdAt, updatedAt, diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index d42ddafbba..d3cfe77678 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -40,6 +40,7 @@ export function buildWorkspaceAgentActivityIndex( const status = deriveSidebarStateBucket({ status: workspaceAgentStatus(agent), + backgroundWork: agent.backgroundWork, pendingPermissionCount: agent.pendingPermissions.length, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, diff --git a/packages/client/src/connection/legacy-workspaces.ts b/packages/client/src/connection/legacy-workspaces.ts index 2cf90423b5..ce83f145a1 100644 --- a/packages/client/src/connection/legacy-workspaces.ts +++ b/packages/client/src/connection/legacy-workspaces.ts @@ -111,6 +111,7 @@ export class LegacyWorkspaces { pendingPermissionCount: agent.pendingPermissions.length, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason, + backgroundWork: agent.backgroundWork, }); const existing = workspaces.get(id); if ( diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 7b235040bf..017c302f7d 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -1049,6 +1049,9 @@ type CorrelatedResponsePayloads = { }; type CorrelatedResponsePayload = CorrelatedResponsePayloads[TType]; +export type AgentBackgroundWorkListPayload = + CorrelatedResponsePayload<"agent.background_work.list.response">; +export type AgentBackgroundWorkItem = AgentBackgroundWorkListPayload["items"][number]; type PluginCatalogResponseEntry = CorrelatedResponsePayload<"plugin.catalog.get.response">["plugins"][number]; @@ -3869,6 +3872,58 @@ export class DaemonClient { return payload; } + async listAgentBackgroundWork( + agentId: string, + options: { requestId?: string; timeout?: number } = {}, + ): Promise { + this.assertAgentBackgroundWorkSupported(); + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"agent.background_work.list.response">({ + requestId: options.requestId, + timeout: options.timeout, + message: { type: "agent.background_work.list.request", agentId }, + }); + if (payload.error) throw new Error(payload.error); + return payload.items; + } + + async stopAgentBackgroundWork( + agentId: string, + item: AgentBackgroundWorkItem, + options: { requestId?: string; timeout?: number } = {}, + ): Promise { + // COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03. + if (this.lastServerInfoMessage?.features?.agentBackgroundWorkStop !== true) + throw new Error("Update the host to stop background work."); + if (!item.canStop || !item.stopToken) throw new Error("Background work cannot be stopped."); + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"agent.background_work.stop.response">({ + requestId: options.requestId, + timeout: options.timeout ?? 30000, + message: { + type: "agent.background_work.stop.request", + agentId, + itemId: item.id, + stopToken: item.stopToken, + }, + }); + if (payload.error) throw new Error(payload.error); + } + + observeAgentBackgroundWork( + options: { signal?: AbortSignal } = {}, + ): OwnedSubscription> { + this.assertAgentBackgroundWorkSupported(); + return this.observeEvents(["agent.background_work.update"], options); + } + + private assertAgentBackgroundWorkSupported(): void { + // COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06. + if (this.lastServerInfoMessage?.features?.agentBackgroundWork !== true) { + throw new Error("Update the host to see an agent's background work."); + } + } + async fetchProviderSubagentTimeline( parentAgentId: string, subagentId: string, diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 5fd4d92958..63f523dc11 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -1938,3 +1938,256 @@ test("agent reload reopens through the daemon RPC then returns the observed snap await client.close(); } }); + +const BACKGROUND_WORK_FEATURES = { ownedSubscriptions: true, agentBackgroundWork: true }; +const SHELL_ITEM = { + id: "bash-1", + kind: "shell", + description: "sleep 20", + startedAt: "2026-10-06T10:00:00.000Z", +}; + +function answerBackgroundWorkList(ws: FakeWebSocket, items: unknown[]): void { + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ type: "agent.background_work.list.request", agentId: "agent-a" }); + ws.message( + sessionMessage({ + type: "agent.background_work.list.response", + payload: { requestId: request.requestId, agentId: "agent-a", items, error: null }, + }), + ); +} + +test("agent handles list background work", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + + const pending = client.agents.ref("agent-a").backgroundWork.list(); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ type: "agent.background_work.list.request", agentId: "agent-a" }); + ws.message( + sessionMessage({ + type: "agent.background_work.list.response", + payload: { + requestId: request.requestId, + agentId: "agent-a", + items: [SHELL_ITEM], + error: null, + }, + }), + ); + + await expect(pending).resolves.toEqual([SHELL_ITEM]); + await client.close(); +}); + +test("agent handles follow only their own background work", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + + const unsubscribe = client.agents + .ref("agent-a") + .backgroundWork.subscribe((items) => received.push(items)); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + events: ["agent.background_work.update"], + }); + acknowledgeObservation(ws, "background-work-sdk"); + answerBackgroundWorkList(ws, []); + await vi.waitFor(() => expect(received).toEqual([[]])); + + ws.message( + sessionMessage({ + type: "agent.background_work.update", + payload: { subscriptionId: "background-work-sdk", agentId: "agent-b", items: [SHELL_ITEM] }, + }), + ); + ws.message( + sessionMessage({ + type: "agent.background_work.update", + payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [SHELL_ITEM] }, + }), + ); + await vi.waitFor(() => expect(received).toEqual([[], [SHELL_ITEM]])); + + unsubscribe(); + ws.message( + sessionMessage({ + type: "agent.background_work.update", + payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [] }, + }), + ); + expect(received).toEqual([[], [SHELL_ITEM]]); + await client.close(); +}); + +test("background work subscriptions list the current work again after a reconnect", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + + client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items)); + acknowledgeObservation(ws, "background-work-first"); + answerBackgroundWorkList(ws, [SHELL_ITEM]); + await vi.waitFor(() => expect(received).toEqual([[SHELL_ITEM]])); + + ws.readyState = 3; + ws.onclose?.({ code: 1006, reason: "Connection lost" }); + const reconnecting = client.connect(); + const next = FakeWebSocket.instances[1]; + next.open(); + next.message( + sessionMessage({ + type: "status", + payload: { + status: "server_info", + serverId: "srv_sdk_test", + hostname: null, + version: null, + features: BACKGROUND_WORK_FEATURES, + }, + }), + ); + await reconnecting; + await vi.waitFor(() => + expect(parseSentSessionMessage(next.sent.at(-1))).toMatchObject({ + type: "session.events.set_subscription.request", + }), + ); + acknowledgeObservation(next, "background-work-second"); + answerBackgroundWorkList(next, []); + + await vi.waitFor(() => expect(received).toEqual([[SHELL_ITEM], []])); + await client.close(); +}); + +test("background work subscriptions keep an update that overtakes their list", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + + client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items)); + acknowledgeObservation(ws, "background-work-sdk"); + await vi.waitFor(() => + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "agent.background_work.list.request", + }), + ); + const listRequest = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "agent.background_work.update", + payload: { subscriptionId: "background-work-sdk", agentId: "agent-a", items: [] }, + }), + ); + ws.message( + sessionMessage({ + type: "agent.background_work.list.response", + payload: { + requestId: listRequest.requestId, + agentId: "agent-a", + items: [SHELL_ITEM], + error: null, + }, + }), + ); + + await vi.waitFor(() => expect(received).toEqual([[]])); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(received).toEqual([[]]); + await client.close(); +}); + +test("background work subscriptions report a failed setup to onError", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + const errors: unknown[] = []; + client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items), { + onError: (error) => errors.push(error), + }); + const request = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "session.events.set_subscription.response", + payload: { requestId: request.requestId }, + }), + ); + await vi.waitFor(() => expect(errors).toHaveLength(1)); + expect((errors[0] as Error).message).toContain("subscription ID"); + expect(received).toEqual([]); + await client.close(); +}); + +test("background work subscriptions report a failed list to onError", async () => { + const { client, ws } = await connectClient(BACKGROUND_WORK_FEATURES); + const received: unknown[] = []; + const errors: unknown[] = []; + + client.agents.ref("agent-a").backgroundWork.subscribe((items) => received.push(items), { + onError: (error) => errors.push(error), + }); + acknowledgeObservation(ws, "background-work-sdk"); + await vi.waitFor(() => + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "agent.background_work.list.request", + }), + ); + const listRequest = parseSentSessionMessage(ws.sent.at(-1)); + ws.message( + sessionMessage({ + type: "agent.background_work.list.response", + payload: { requestId: listRequest.requestId, agentId: "agent-a", items: [], error: "boom" }, + }), + ); + + await vi.waitFor(() => expect(errors).toHaveLength(1)); + expect(errors[0]).toBeInstanceOf(Error); + expect((errors[0] as Error).message).toContain("boom"); + expect(received).toEqual([]); + await client.close(); +}); + +test("background work asks for a host update when the daemon lacks it", async () => { + const { client } = await connectClient({ ownedSubscriptions: true }); + const handle = client.agents.ref("agent-a"); + + await expect(handle.backgroundWork.list()).rejects.toThrow("Update the host"); + expect(() => handle.backgroundWork.subscribe(() => {})).toThrow("Update the host"); + await client.close(); +}); + +test("background stop sends the live item token and waits for the response", async () => { + const { client, ws } = await connectClient({ + ...BACKGROUND_WORK_FEATURES, + agentBackgroundWorkStop: true, + }); + const item = { ...SHELL_ITEM, canStop: true, stopToken: "generation-1" }; + const pending = client.agents.ref("agent-a").backgroundWork.stop(item); + const request = parseSentSessionMessage(ws.sent.at(-1)); + expect(request).toMatchObject({ + type: "agent.background_work.stop.request", + agentId: "agent-a", + itemId: item.id, + stopToken: item.stopToken, + }); + ws.message( + sessionMessage({ + type: "agent.background_work.stop.response", + payload: { + requestId: request.requestId, + agentId: "agent-a", + itemId: item.id, + error: "rejected", + }, + }), + ); + await expect(pending).rejects.toThrow("rejected"); + await client.close(); +}); + +test("background stop rejects when the host does not advertise the operation", async () => { + const { client } = await connectClient(BACKGROUND_WORK_FEATURES); + await expect( + client.agents + .ref("agent-a") + .backgroundWork.stop({ ...SHELL_ITEM, canStop: true, stopToken: "generation" }), + ).rejects.toThrow("Update the host"); + await client.close(); +}); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index ead91e719c..79a2de50a1 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -54,6 +54,7 @@ export type { } from "./terminals/index.js"; import type { PluginTimelineItem } from "@getpaseo/protocol/agent-types"; import type { + AgentBackgroundWorkItem, FetchAgentsEntry, FetchAgentsOptions, FetchAgentsPageInfo, @@ -353,6 +354,36 @@ export interface PaseoAgentTimelineHandle { subscribe(handler: (event: PaseoAgentTimelineEvent) => void): PaseoAgentTimelineSubscription; } +export type PaseoAgentBackgroundWorkItem = AgentBackgroundWorkItem; + +export interface PaseoAgentBackgroundWorkHandle { + /** Stop one item and wait for provider confirmation. */ + stop( + item: PaseoAgentBackgroundWorkItem, + options?: { requestId?: string; timeout?: number }, + ): Promise; + /** Reads the agent's current background work once. */ + list(options?: { requestId?: string; timeout?: number }): Promise; + /** + * Calls `listener` with the agent's current list once the subscription is established, again + * after each reconnect, then with the full list each time it changes. Returns the unsubscribe + * function. + * + * When reading the current list fails, `options.onError` receives the error and `listener` is + * not called; without `onError` the error is logged. The read is not retried: the next + * reconnect reads the list again. + */ + subscribe( + listener: (items: PaseoAgentBackgroundWorkItem[]) => void, + options?: PaseoAgentBackgroundWorkSubscribeOptions, + ): () => void; +} + +export interface PaseoAgentBackgroundWorkSubscribeOptions { + /** Receives the error when reading the agent's current list fails. */ + onError?: (error: unknown) => void; +} + export interface PaseoAgentHandle { readonly id: string; readonly address: AgentAddress; @@ -376,6 +407,7 @@ export interface PaseoAgentHandle { readonly runtimeInfo: NonNullable | null; readonly archivedAt: NonNullable | null; readonly timeline: PaseoAgentTimelineHandle; + readonly backgroundWork: PaseoAgentBackgroundWorkHandle; current(): PaseoAgent | null; refresh(requestId?: string): Promise; /** @@ -612,6 +644,7 @@ export function createPaseoApi( daemonClient, listenAgents, (agentId, handler) => own(() => daemonClient.subscribeAgentTimeline(agentId, handler)), + () => own(() => daemonClient.observeAgentBackgroundWork()), ); const createAgent = async ( options: PaseoAgentCreateOptions, @@ -893,6 +926,7 @@ function createAgentHandleFactory( daemonClient: DaemonClient, listen: (handler: PaseoAgentUpdateHandler) => () => void, subscribeTimeline: DaemonClient["subscribeAgentTimeline"], + observeBackgroundWork: () => ReturnType, ): AgentHandleFactory { return (agent) => { const id = typeof agent === "string" ? agent : agent.id; @@ -939,6 +973,48 @@ function createAgentHandleFactory( } }), }, + backgroundWork: { + stop: (item, options) => daemonClient.stopAgentBackgroundWork(id, item, options), + list: (options) => daemonClient.listAgentBackgroundWork(id, options), + subscribe: (listener, options) => { + const observation = observeBackgroundWork(); + // Bumped by every delivery so a list answered after a newer update is dropped. + let sequence = 0; + let released = false; + const reportError = (error: unknown) => { + if (released) return; + if (options?.onError) options.onError(error); + else console.error("Background work subscription failed", error); + }; + observation.subscribe({ + error: reportError, + snapshot: () => { + const listed = ++sequence; + void daemonClient + .listAgentBackgroundWork(id) + .then((items) => { + if (!released && listed === sequence) listener(items); + return undefined; + }) + .catch(reportError); + }, + update: (message) => { + if (message.type !== "agent.background_work.update") return; + if (message.payload.agentId !== id) return; + sequence++; + listener(message.payload.items); + }, + }); + return () => { + released = true; + void observation + .release() + .catch((error) => + console.error("Background work subscription cleanup failed", error), + ); + }; + }, + }, get workspaceId() { return current?.workspaceId ?? null; }, diff --git a/packages/protocol/src/agent-state-bucket.test.ts b/packages/protocol/src/agent-state-bucket.test.ts index 89d1ef93ab..ef57394577 100644 --- a/packages/protocol/src/agent-state-bucket.test.ts +++ b/packages/protocol/src/agent-state-bucket.test.ts @@ -6,6 +6,30 @@ import { } from "./agent-state-bucket.js"; describe("deriveAgentStateBucket", () => { + it("shows idle agents with background work as running while preserving attention precedence", () => { + const backgroundWork = { count: 1 }; + expect( + deriveAgentStateBucket({ + status: "idle", + backgroundWork, + requiresAttention: true, + attentionReason: "finished", + }), + ).toBe("running"); + expect( + deriveAgentStateBucket({ status: "idle", backgroundWork, attentionReason: "permission" }), + ).toBe("needs_input"); + expect(deriveAgentStateBucket({ status: "error", backgroundWork })).toBe("running"); + expect( + deriveAgentStateBucket({ + status: "error", + backgroundWork, + requiresAttention: true, + attentionReason: "error", + }), + ).toBe("failed"); + expect(deriveAgentStateBucket({ status: "idle", backgroundWork: { count: 0 } })).toBe("done"); + }); it("prioritizes pending permissions as needs_input", () => { expect( deriveAgentStateBucket({ diff --git a/packages/protocol/src/agent-state-bucket.ts b/packages/protocol/src/agent-state-bucket.ts index 746d70a4e2..2e696c5bbe 100644 --- a/packages/protocol/src/agent-state-bucket.ts +++ b/packages/protocol/src/agent-state-bucket.ts @@ -9,6 +9,7 @@ export interface AgentStateBucketInput { pendingPermissionCount?: number; requiresAttention?: boolean; attentionReason?: AgentAttentionReason; + backgroundWork?: { count: number } | null; } const WORKSPACE_STATE_BUCKET_PRIORITY = { @@ -32,7 +33,7 @@ export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceS if (hasUnreadError(input)) { return "failed"; } - if (input.status === "running") { + if (input.status === "running" || (input.backgroundWork?.count ?? 0) > 0) { return "running"; } if (input.requiresAttention) { @@ -52,7 +53,7 @@ export function getAgentStatusPriority(input: AgentStateBucketInput): number { if (hasUnreadError(input)) { return 1; } - if (input.status === "running") { + if (input.status === "running" || (input.backgroundWork?.count ?? 0) > 0) { return 2; } if (input.status === "initializing") { diff --git a/packages/protocol/src/messages.background-work.test.ts b/packages/protocol/src/messages.background-work.test.ts new file mode 100644 index 0000000000..d8d8de8ad5 --- /dev/null +++ b/packages/protocol/src/messages.background-work.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from "vitest"; + +import { + ServerInfoStatusPayloadSchema, + SessionEventSubscriptionSchema, + SessionInboundMessageSchema, + SessionOutboundMessageSchema, +} from "./messages.js"; + +const ITEM = { + id: "bash-1", + kind: "shell", + description: "sleep 20", + startedAt: "2026-10-06T10:00:00.000Z", +}; + +describe("agent background work protocol", () => { + test("accepts a list request and its response", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "agent.background_work.list.request", + agentId: "agent-1", + requestId: "request-1", + }), + ).toMatchObject({ agentId: "agent-1", requestId: "request-1" }); + + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.background_work.list.response", + payload: { requestId: "request-1", agentId: "agent-1", items: [ITEM], error: null }, + }), + ).toMatchObject({ payload: { items: [ITEM] } }); + }); + + test("accepts an update with or without a subscription id", () => { + for (const payload of [ + { agentId: "agent-1", items: [] }, + { subscriptionId: "events-1", agentId: "agent-1", items: [ITEM] }, + ]) { + expect( + SessionOutboundMessageSchema.parse({ type: "agent.background_work.update", payload }), + ).toMatchObject({ payload }); + } + }); + + test("keeps kind an open string so new kinds parse on old clients", () => { + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.background_work.update", + payload: { agentId: "agent-1", items: [{ ...ITEM, kind: "future-kind" }] }, + }), + ).toMatchObject({ payload: { items: [{ kind: "future-kind" }] } }); + }); + + test("preserves an optional command while accepting older items", () => { + const withCommand = { ...ITEM, description: "Watch build", command: "tail -f build.log" }; + const parsed = SessionOutboundMessageSchema.parse({ + type: "agent.background_work.update", + payload: { agentId: "agent-1", items: [ITEM, withCommand] }, + }); + expect(parsed).toMatchObject({ payload: { items: [ITEM, withCommand] } }); + }); + + test("is a subscribable event", () => { + expect(SessionEventSubscriptionSchema.parse("agent.background_work.update")).toBe( + "agent.background_work.update", + ); + }); + + test("keeps the feature optional for older server info payloads", () => { + expect( + ServerInfoStatusPayloadSchema.parse({ + status: "server_info", + serverId: "server-1", + features: {}, + }).features?.agentBackgroundWork, + ).toBeUndefined(); + }); +}); + +test("stop requests carry an inventory token and preserve legacy item parsing", () => { + expect( + SessionInboundMessageSchema.parse({ + type: "agent.background_work.stop.request", + agentId: "a", + itemId: "x", + stopToken: "token", + requestId: "r", + }), + ).toMatchObject({ stopToken: "token" }); + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.background_work.stop.response", + payload: { requestId: "r", agentId: "a", itemId: "x", error: null }, + }), + ).toMatchObject({ payload: { error: null } }); + expect( + SessionOutboundMessageSchema.parse({ + type: "agent.background_work.update", + payload: { agentId: "a", items: [ITEM] }, + }), + ).toMatchObject({ payload: { items: [ITEM] } }); +}); diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index c5fc881240..2899bf56cb 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -873,6 +873,7 @@ export const AgentSnapshotPayloadSchema = z.object({ updatedAt: z.string(), lastUserMessageAt: z.string().nullable(), status: AgentStatusSchema, + backgroundWork: z.object({ count: z.number().int().nonnegative() }).optional(), activeTurn: AgentActiveTurnPayloadSchema.nullable().optional(), capabilities: AgentCapabilityFlagsSchema, currentModeId: z.string().nullable(), @@ -907,6 +908,7 @@ export const AgentListItemPayloadSchema = z.object({ thinkingOptionId: z.string().nullable().optional(), effectiveThinkingOptionId: z.string().nullable().optional(), status: AgentStatusSchema, + backgroundWork: z.object({ count: z.number().int().nonnegative() }).optional(), cwd: z.string(), createdAt: z.string(), updatedAt: z.string(), @@ -2001,6 +2003,20 @@ export const ProviderSubagentListRequestMessageSchema = z.object({ requestId: z.string(), }); +export const AgentBackgroundWorkListRequestMessageSchema = z.object({ + type: z.literal("agent.background_work.list.request"), + agentId: z.string(), + requestId: z.string(), +}); + +export const AgentBackgroundWorkStopRequestMessageSchema = z.object({ + type: z.literal("agent.background_work.stop.request"), + agentId: z.string(), + itemId: z.string(), + stopToken: z.string(), + requestId: z.string(), +}); + export const ProviderSubagentTimelineRequestMessageSchema = z.object({ type: z.literal("agent.provider_subagents.timeline.get.request"), parentAgentId: z.string(), @@ -3287,6 +3303,7 @@ export const SessionEventSubscriptionSchema = z.enum([ "script_status_update", "workspace_setup_progress", "agent.provider_subagents.update", + "agent.background_work.update", "terminal_attention_required", "status.server_info", "status.daemon_config_changed", @@ -3436,6 +3453,8 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ AgentTimelineSearchRequestMessageSchema, AgentTimelineListPromptsRequestMessageSchema, ProviderSubagentListRequestMessageSchema, + AgentBackgroundWorkListRequestMessageSchema, + AgentBackgroundWorkStopRequestMessageSchema, ProviderSubagentTimelineRequestMessageSchema, SetAgentTimelineSubscriptionRequestMessageSchema, AgentForkContextRequestMessageSchema, @@ -3863,6 +3882,10 @@ export const ServerInfoStatusPayloadSchema = z agentForkContextCursor: z.boolean().optional(), // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: z.boolean().optional(), + // COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06. + agentBackgroundWork: z.boolean().optional(), + // COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03. + agentBackgroundWorkStop: z.boolean().optional(), // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain wire field. projectedSubagentTimeline: z.boolean().optional(), wholeTurnTimeline: z.boolean().optional(), @@ -4953,6 +4976,51 @@ export const ProviderSubagentListResponseMessageSchema = z.object({ }), }); +export const AgentBackgroundWorkItemSchema = z.object({ + id: z.string(), + // Open string: known kinds include "shell" and "monitor". + // Clients treat unknown values as "other". + kind: z.string(), + description: z.string().nullable(), + command: z.string().optional(), + startedAt: z.string(), + canStop: z.boolean().optional(), + stopToken: z.string().optional(), +}); + +export type AgentBackgroundWorkItemPayload = z.infer; + +export const AgentBackgroundWorkListResponseMessageSchema = z.object({ + type: z.literal("agent.background_work.list.response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + items: z.array(AgentBackgroundWorkItemSchema), + error: z.string().nullable(), + }), +}); + +export const AgentBackgroundWorkStopResponseMessageSchema = z.object({ + type: z.literal("agent.background_work.stop.response"), + payload: z.object({ + requestId: z.string(), + agentId: z.string(), + itemId: z.string(), + error: z.string().nullable(), + }), +}); + +// Event push delivered through session.events.set_subscription; it has no .request/.response pair. +// Each update replaces the agent's whole list. +export const AgentBackgroundWorkUpdateMessageSchema = z.object({ + type: z.literal("agent.background_work.update"), + payload: z.object({ + subscriptionId: z.string().optional(), + agentId: z.string(), + items: z.array(AgentBackgroundWorkItemSchema), + }), +}); + export const ProviderSubagentTimelineResponseMessageSchema = z.object({ type: z.literal("agent.provider_subagents.timeline.get.response"), payload: z.object({ @@ -7212,6 +7280,9 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProviderSubagentListResponseMessageSchema, ProviderSubagentTimelineResponseMessageSchema, ProviderSubagentUpdateMessageSchema, + AgentBackgroundWorkListResponseMessageSchema, + AgentBackgroundWorkStopResponseMessageSchema, + AgentBackgroundWorkUpdateMessageSchema, SetAgentTimelineSubscriptionResponseMessageSchema, AgentAttentionRequiredMessageSchema, AgentForkContextResponseMessageSchema, diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 372074c659..5b77522473 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -13075,3 +13075,172 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime", expect(h.commands).toEqual(["/maintenance"]); expect(h.prompts).toEqual([]); }); + +test("folds provider background work and clears it when the agent closes", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-background-work-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let activeSession: TestAgentSession | null = null; + class BackgroundWorkClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + activeSession = new TestAgentSession(config); + return activeSession; + } + } + const manager = new AgentManager({ + clients: { codex: new BackgroundWorkClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000201", + }); + const snapshot = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { agentId: snapshot.id, replayState: false }); + + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [{ id: "bash-1", kind: "shell", description: "sleep 20" }], + }); + + await vi.waitFor(() => + expect(manager.listAgentBackgroundWork(snapshot.id)).toEqual([ + { id: "bash-1", kind: "shell", description: "sleep 20", startedAt: expect.any(String) }, + ]), + ); + expect(events).toContainEqual({ + type: "background_work", + agentId: snapshot.id, + items: [expect.objectContaining({ id: "bash-1" })], + }); + expect(manager.getAgent(snapshot.id)?.lifecycle).toBe("idle"); + expect(manager.getAgentBackgroundWorkSummary(snapshot.id)).toEqual({ count: 1 }); + expect( + events.some((event) => event.type === "agent_stream" && event.event.type === "background_work"), + ).toBe(false); + + await manager.closeAgent(snapshot.id); + + expect(events).toContainEqual({ type: "background_work", agentId: snapshot.id, items: [] }); + expect(manager.getAgentBackgroundWorkSummary(snapshot.id)).toBeUndefined(); +}); + +test("does not deliver an internal agent's background work to global subscribers", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-background-work-internal-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + let activeSession: TestAgentSession | null = null; + class BackgroundWorkClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + activeSession = new TestAgentSession(config); + return activeSession; + } + } + const manager = new AgentManager({ + clients: { codex: new BackgroundWorkClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000202", + }); + const events: AgentManagerEvent[] = []; + manager.subscribe((event) => events.push(event), { replayState: false }); + const agent = await manager.createAgent( + { provider: "codex", cwd: workdir, internal: true }, + undefined, + { workspaceId: undefined }, + ); + const agentEvents: AgentManagerEvent[] = []; + manager.subscribe((event) => agentEvents.push(event), { + agentId: agent.id, + replayState: false, + }); + + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [{ id: "bash-1", kind: "shell", description: null }], + }); + // The per-agent subscriber proves the event was emitted before the global one is checked. + await vi.waitFor(() => + expect(agentEvents).toContainEqual( + expect.objectContaining({ type: "background_work", agentId: agent.id }), + ), + ); + + expect(events.some((event) => event.type === "background_work")).toBe(false); + await expect( + manager.stopAgentBackgroundWork({ agentId: agent.id, itemId: "bash-1", stopToken: "token" }), + ).rejects.toThrow("Unknown agent"); +}); + +test("stops only a currently owned public background item with its current token", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-background-stop-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const stopped: string[] = []; + let activeSession: TestAgentSession | null = null; + class StoppableSession extends TestAgentSession { + async stopBackgroundWork(id: string) { + stopped.push(id); + } + } + class BackgroundWorkClient extends TestAgentClient { + override async createSession(config: AgentSessionConfig): Promise { + activeSession = new StoppableSession(config); + return activeSession; + } + } + const manager = new AgentManager({ + clients: { codex: new BackgroundWorkClient() }, + registry: storage, + logger, + idFactory: () => "00000000-0000-4000-8000-000000000204", + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [ + { id: "one", kind: "shell", description: null, canStop: true }, + { id: "two", kind: "other", description: null }, + ], + }); + await vi.waitFor(() => expect(manager.listAgentBackgroundWork(agent.id)).toHaveLength(2)); + const [one] = manager.listAgentBackgroundWork(agent.id); + await expect( + manager.stopAgentBackgroundWork({ agentId: agent.id, itemId: "one", stopToken: "stale-token" }), + ).rejects.toThrow(); + await expect( + manager.stopAgentBackgroundWork({ + agentId: agent.id, + itemId: "two", + stopToken: one.stopToken!, + }), + ).rejects.toThrow(); + await manager.stopAgentBackgroundWork({ + agentId: agent.id, + itemId: "one", + stopToken: one.stopToken!, + }); + expect(stopped).toEqual(["one"]); + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [{ id: "one", kind: "shell", description: null }], + }); + activeSession?.pushEvent({ + type: "background_work", + provider: "codex", + items: [{ id: "one", kind: "shell", description: null, canStop: true }], + }); + await expect( + manager.stopAgentBackgroundWork({ + agentId: agent.id, + itemId: "one", + stopToken: one.stopToken!, + }), + ).rejects.toThrow(); + expect(stopped).toEqual(["one"]); + await manager.closeAgent(agent.id); +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 81b5495377..fdecc4ad1c 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -98,6 +98,7 @@ import { type ProviderSubagentDescriptor, type ProviderSubagentStoreEvent, } from "./provider-subagents/store.js"; +import { AgentBackgroundWorkStore, type AgentBackgroundWorkItem } from "./background-work/index.js"; import { withTimeout } from "../../utils/promise-timeout.js"; import { extractAttention } from "../persistence-hooks.js"; @@ -171,6 +172,12 @@ async function assertUsableWorkingDirectory(cwd: string): Promise { } } +interface StopAgentBackgroundWorkOptions { + agentId: string; + itemId: string; + stopToken: string; +} + async function resolveResumePurpose( record: StoredAgentRecord | null, requested: AgentResumePurpose | undefined, @@ -255,6 +262,7 @@ export type AgentManagerEvent = | { type: "agent_state"; agent: ManagedAgent } | { type: "agent_removed"; agentId: string; workspaceId?: string; internal: boolean } | { type: "provider_subagent"; event: ProviderSubagentStoreEvent } + | { type: "background_work"; agentId: string; items: AgentBackgroundWorkItem[] } | { type: "timeline_replacement"; agentId: string; epoch: string } | { type: "agent_stream"; @@ -794,6 +802,7 @@ export class AgentManager { private readonly agents = new Map(); private readonly timelineStore: InMemoryAgentTimelineStore; private readonly providerSubagents = new ProviderSubagentStore(); + private readonly backgroundWork = new AgentBackgroundWorkStore(); private readonly agentsAwaitingInitialSnapshotPersist = new Set(); private readonly sessionEventTails = new Map>(); private readonly steerEventBarriers = new Map(); @@ -1354,6 +1363,38 @@ export class AgentManager { return this.providerSubagents.list(parentAgentId); } + listAgentBackgroundWork(agentId: string): AgentBackgroundWorkItem[] { + this.requirePublicAgent(agentId); + return this.backgroundWork.list(agentId); + } + + async stopAgentBackgroundWork({ + agentId, + itemId, + stopToken, + }: StopAgentBackgroundWorkOptions): Promise { + this.requirePublicAgent(agentId); + // Inventory events share the session queue; drain events already received before + // authorizing the token, including a reconnect's ownership revocation. + await this.sessionEventTails.get(agentId)?.catch(() => undefined); + const agent = this.requirePublicAgent(agentId); + if (!agent.session || !agent.session.stopBackgroundWork) + throw new Error("Background work cannot be stopped"); + const item = this.backgroundWork.list(agentId).find((entry) => entry.id === itemId); + if (!item?.canStop || item.stopToken !== stopToken) + throw new Error("Background work is no longer available to stop"); + await agent.session.stopBackgroundWork(itemId); + } + + getAgentBackgroundWorkSummary(agentId: string): { count: number } | undefined { + return this.backgroundWork.summary(agentId); + } + + private clearBackgroundWork(agentId: string): void { + const items = this.backgroundWork.clear(agentId); + if (items) this.dispatch({ type: "background_work", agentId, items }); + } + listProviderSubagentActivity(): ProviderSubagentDescriptor[] { const publicParentIds = new Set( Array.from(this.agents.values()) @@ -1740,6 +1781,7 @@ export class AgentManager { await this.closeReloadedSession(existing.session, agentId); await this.drainSessionEvents(agentId); this.cancelRunningProviderSubagents(agentId); + this.clearBackgroundWork(agentId); closedExisting = this.prepareAgentForClosure(existing, "agent reloaded"); await this.persistSnapshot(closedExisting); this.assertAcceptingAgentRegistrations(); @@ -1990,6 +2032,8 @@ export class AgentManager { await agent.session.close(); await this.drainSessionEvents(agentId); this.cancelRunningProviderSubagents(agentId); + // close() drops the session's subscribers before the provider could report an empty list. + this.clearBackgroundWork(agentId); const closedAgent = this.prepareAgentForClosure(agent, "agent closed"); let persistError: unknown; @@ -4182,6 +4226,7 @@ export class AgentManager { for (const event of this.providerSubagents.deleteParent(agentId)) { this.dispatch({ type: "provider_subagent", event }); } + this.clearBackgroundWork(agentId); } private emitClosedAgent(agent: ManagedAgentClosed, options?: { persist?: boolean }): void { @@ -4291,6 +4336,14 @@ export class AgentManager { this.dispatch({ type: "provider_subagent", event: update }); return; } + if (event.type === "background_work") { + const items = + event.items === null + ? this.backgroundWork.clear(agent.id) + : this.backgroundWork.apply(agent.id, event.items); + if (items) this.dispatch({ type: "background_work", agentId: agent.id, items }); + return; + } const turnId = getAgentStreamEventTurnId(event); const matchingWaiters = this.runs.getMatchingWaiters(agent, turnId); this.logger.trace( @@ -5510,6 +5563,13 @@ export class AgentManager { ) { continue; } + if ( + subscriber.agentId && + event.type === "background_work" && + subscriber.agentId !== event.agentId + ) { + continue; + } if ( subscriber.agentId && event.type === "agent_state" && @@ -5543,6 +5603,7 @@ export class AgentManager { if (event.type === "agent_state") return event.agent.internal === true; if (event.type === "agent_removed") return event.internal; if (event.type === "agent_stream") return this.agents.get(event.agentId)?.internal === true; + if (event.type === "background_work") return this.agents.get(event.agentId)?.internal === true; if (event.type !== "provider_subagent") return false; const parentAgentId = event.event.type === "upsert" diff --git a/packages/server/src/server/agent/agent-projections.test.ts b/packages/server/src/server/agent/agent-projections.test.ts index ec4186b022..e9c6289db5 100644 --- a/packages/server/src/server/agent/agent-projections.test.ts +++ b/packages/server/src/server/agent/agent-projections.test.ts @@ -4,6 +4,7 @@ import { AGENT_LIFECYCLE_STATUSES } from "./agent-manager.js"; import { buildStoredAgentPayload, toAgentPayload, + toAgentListItemPayload, toRecentProviderSessionDescriptorPayload, toStoredAgentRecord, type ManagedAgent, @@ -539,3 +540,11 @@ describe("toRecentProviderSessionDescriptorPayload", () => { }); }); }); + +it("omits unknown background work and preserves known summary in list items", () => { + const snapshot = toAgentPayload(createManagedAgent()); + expect(toAgentListItemPayload(snapshot)).not.toHaveProperty("backgroundWork"); + expect(toAgentListItemPayload({ ...snapshot, backgroundWork: { count: 2 } })).toMatchObject({ + backgroundWork: { count: 2 }, + }); +}); diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index 3229a70e0f..b11021a99c 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -287,6 +287,7 @@ export function toAgentListItemPayload(agent: AgentSnapshotPayload): AgentListIt thinkingOptionId: agent.thinkingOptionId, effectiveThinkingOptionId: agent.effectiveThinkingOptionId, status: agent.status, + ...(agent.backgroundWork ? { backgroundWork: agent.backgroundWork } : {}), cwd: agent.cwd, createdAt: agent.createdAt, updatedAt: agent.updatedAt, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 903bbe5c99..7604f1db76 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -468,6 +468,12 @@ export type AgentStreamEvent = type: "provider_subagent"; provider: AgentProvider; event: import("./provider-subagents/store.js").ProviderSubagentInputEvent; + } + | { + /** Full replacement of the agent's live background work (shells, monitors…). */ + type: "background_work"; + provider: AgentProvider; + items: import("./background-work/index.js").AgentBackgroundWorkInput[] | null; }; export function getAgentStreamEventTurnId(event: AgentStreamEvent): string | undefined { @@ -705,6 +711,8 @@ export interface AgentSession { * still uncertain. */ interrupt(): Promise; + /** Stop one currently owned background item and wait for provider confirmation. */ + stopBackgroundWork?(id: string): Promise; /** True only when releasing this runtime loses no background work or pending replies. * An optional additional veto: missing methods use daemon baseline eligibility; * implemented checks that reject or return false keep the runtime resident. */ diff --git a/packages/server/src/server/agent/background-work/index.test.ts b/packages/server/src/server/agent/background-work/index.test.ts new file mode 100644 index 0000000000..3a8f28dfea --- /dev/null +++ b/packages/server/src/server/agent/background-work/index.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from "vitest"; + +import { AgentBackgroundWorkStore } from "./index.js"; + +const T0 = "2026-10-06T10:00:00.000Z"; +const T1 = "2026-10-06T10:05:00.000Z"; + +describe("AgentBackgroundWorkStore", () => { + test("stamps new items and keeps their start time across replacements", () => { + const store = new AgentBackgroundWorkStore(); + + expect( + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0), + ).toEqual([{ id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 }]); + + expect( + store.apply( + "agent-a", + [ + { id: "bash-1", kind: "shell", description: "sleep 20" }, + { id: "task-1", kind: "other", description: null }, + ], + T1, + ), + ).toEqual([ + { id: "bash-1", kind: "shell", description: "sleep 20", startedAt: T0 }, + { id: "task-1", kind: "other", description: null, startedAt: T1 }, + ]); + }); + + test("returns null when the replacement changes nothing", () => { + const store = new AgentBackgroundWorkStore(); + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T0); + + expect( + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: "sleep 20" }], T1), + ).toBeNull(); + expect(store.apply("agent-b", [], T1)).toEqual([]); + expect(store.apply("agent-b", [], T1)).toBeNull(); + }); + + test("distinguishes an unknown inventory from an observed empty inventory", () => { + const store = new AgentBackgroundWorkStore(); + expect(store.summary("agent-a")).toBeUndefined(); + store.apply("agent-a", []); + expect(store.summary("agent-a")).toEqual({ count: 0 }); + store.clear("agent-a"); + expect(store.summary("agent-a")).toBeUndefined(); + }); + + test("keeps the first occurrence of a duplicated id", () => { + const store = new AgentBackgroundWorkStore(); + + expect( + store.apply( + "agent-a", + [ + { id: "bash-1", kind: "shell", description: "first" }, + { id: "bash-1", kind: "shell", description: "second" }, + ], + T0, + ), + ).toEqual([{ id: "bash-1", kind: "shell", description: "first", startedAt: T0 }]); + }); + + test("scopes lists per agent and clears one agent", () => { + const store = new AgentBackgroundWorkStore(); + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0); + store.apply("agent-b", [{ id: "bash-2", kind: "shell", description: null }], T0); + + expect(store.clear("agent-a")).toEqual([]); + expect(store.clear("agent-a")).toBeNull(); + expect(store.list("agent-a")).toEqual([]); + expect(store.list("agent-b")).toHaveLength(1); + }); + + test("keeps only the wire fields of each input", () => { + const store = new AgentBackgroundWorkStore(); + const input = { id: "bash-1", kind: "shell", description: null, pid: 4242 }; + + expect(store.apply("agent-a", [input], T0)).toEqual([ + { id: "bash-1", kind: "shell", description: null, startedAt: T0 }, + ]); + }); + + test("list returns a copy", () => { + const store = new AgentBackgroundWorkStore(); + store.apply("agent-a", [{ id: "bash-1", kind: "shell", description: null }], T0); + + store.list("agent-a").pop(); + + expect(store.list("agent-a")).toHaveLength(1); + }); +}); + +test("stop tokens follow a live item and change when an id is reused", () => { + const store = new AgentBackgroundWorkStore(); + const input = { id: "1", kind: "shell", description: null, canStop: true }; + const first = store.apply("agent", [input], T0)![0]; + expect(first.stopToken).toBeTruthy(); + expect(store.apply("agent", [input], T1)).toBeNull(); + store.apply("agent", []); + const second = store.apply("agent", [input], T0)![0]; + expect(second.stopToken).not.toBe(first.stopToken); +}); + +test("revokes a stop token while inventory ownership is being re-established", () => { + const store = new AgentBackgroundWorkStore(); + const item = { id: "1", kind: "shell", description: null, canStop: true }; + const first = store.apply("agent", [item])![0]; + const stale = store.apply("agent", [{ ...item, canStop: false }])![0]; + expect(stale.stopToken).toBeUndefined(); + expect(store.apply("agent", [item])![0].stopToken).not.toBe(first.stopToken); +}); + +test("publishes command changes without replacing a live item's identity", () => { + const store = new AgentBackgroundWorkStore(); + const item = { + id: "1", + kind: "monitor", + description: "Watch build", + command: "tail -f build.log", + canStop: true, + }; + const first = store.apply("agent", [item], T0)![0]; + expect(first.command).toBe("tail -f build.log"); + const changed = store.apply("agent", [{ ...item, command: "tail -f other.log" }], T1)![0]; + expect(changed).toMatchObject({ + command: "tail -f other.log", + startedAt: T0, + stopToken: first.stopToken, + }); +}); diff --git a/packages/server/src/server/agent/background-work/index.ts b/packages/server/src/server/agent/background-work/index.ts new file mode 100644 index 0000000000..3290f13503 --- /dev/null +++ b/packages/server/src/server/agent/background-work/index.ts @@ -0,0 +1,88 @@ +import { randomUUID } from "node:crypto"; + +export interface AgentBackgroundWorkInput { + id: string; + /** Provider-neutral kind; consumers treat unknown kinds as other work. */ + kind: string; + description: string | null; + command?: string; + canStop?: boolean; +} + +export interface AgentBackgroundWorkItem extends AgentBackgroundWorkInput { + startedAt: string; + stopToken?: string; +} + +/** + * Live background work per agent. Providers report the whole list on every change, so `apply` + * replaces it. `startedAt` is stamped the first time an id is seen and kept while it stays listed. + * Nothing is persisted; providers determine when their live inventory is no longer available. + */ +export class AgentBackgroundWorkStore { + private readonly items = new Map(); + + apply( + agentId: string, + inputs: readonly AgentBackgroundWorkInput[], + timestamp: string = new Date().toISOString(), + ): AgentBackgroundWorkItem[] | null { + const known = this.items.has(agentId); + const previous = this.items.get(agentId) ?? []; + const previousById = new Map(previous.map((item) => [item.id, item])); + const seen = new Set(); + const next: AgentBackgroundWorkItem[] = []; + for (const input of inputs) { + if (seen.has(input.id)) continue; + seen.add(input.id); + next.push({ + id: input.id, + kind: input.kind, + description: input.description, + ...(input.command ? { command: input.command } : {}), + ...(input.canStop + ? { canStop: true, stopToken: previousById.get(input.id)?.stopToken ?? randomUUID() } + : {}), + startedAt: previousById.get(input.id)?.startedAt ?? timestamp, + }); + } + if (known && sameItems(previous, next)) return null; + this.items.set(agentId, next); + return [...next]; + } + + list(agentId: string): AgentBackgroundWorkItem[] { + return [...(this.items.get(agentId) ?? [])]; + } + + summary(agentId: string): { count: number } | undefined { + const items = this.items.get(agentId); + return items ? { count: items.length } : undefined; + } + + clear(agentId: string): AgentBackgroundWorkItem[] | null { + if (!this.items.delete(agentId)) return null; + return []; + } +} + +function sameItems( + left: readonly AgentBackgroundWorkItem[], + right: readonly AgentBackgroundWorkItem[], +): boolean { + return ( + left.length === right.length && + left.every((item, index) => { + const other = right[index]; + return ( + item.id === other.id && + item.kind === other.kind && + item.description === other.description && + item.command === other.command && + item.canStop === other.canStop && + item.stopToken === other.stopToken && + item.startedAt === other.startedAt + ); + }) + ); +} diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 0f3c39868a..8616c5ea52 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -468,6 +468,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession respondToPermission: (requestId, response) => inner.respondToPermission(requestId, response), describePersistence: () => mapPersistenceHandle(provider, inner.describePersistence()), interrupt: () => inner.interrupt(), + stopBackgroundWork: inner.stopBackgroundWork?.bind(inner), close: () => inner.close(), canStopRuntime: inner.canStopRuntime?.bind(inner), listCommands: inner.listCommands?.bind(inner), diff --git a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts index 8aa15581ae..5c70f0a1fe 100644 --- a/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.background-subagent.test.ts @@ -712,19 +712,36 @@ describe("background Claude subagents", () => { test("Claude runtime safety protects silent shells, monitors and pending completion notifications", () => { const safety = new ClaudeRuntimeSafety(); const observe = (message: unknown) => safety.observe(message as SDKMessage); - observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" }); + expect(observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" })).toEqual([]); expect(safety.canStopRuntime()).toBe(true); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" }, + { task_id: "monitor", task_type: "local_bash", description: "watch build" }, + { task_id: "native-child", task_type: "local_agent", description: "research" }, + ], + }), + ).toEqual([ + { id: "quiet-shell", kind: "shell", description: "sleep 7200", canStop: true }, + { id: "monitor", kind: "shell", description: "watch build", canStop: true }, + ]); + expect(safety.canStopRuntime()).toBe(false); observe({ type: "system", - subtype: "background_tasks_changed", - tasks: [ - { task_id: "quiet-shell", task_type: "local_bash", description: "sleep 7200" }, - { task_id: "monitor", task_type: "local_bash", description: "watch build" }, - { task_id: "native-child", task_type: "local_agent", description: "research" }, - ], + subtype: "task_started", + task_id: "monitor", + tool_use_id: "tool-monitor", }); - expect(safety.canStopRuntime()).toBe(false); - observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); + expect( + observe({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "tool-monitor", name: "Monitor" }] }, + })?.find((item) => item.id === "monitor")?.kind, + ).toBe("monitor"); + expect(observe({ type: "system", subtype: "background_tasks_changed", tasks: [] })).toEqual([]); expect(safety.canStopRuntime()).toBe(false); for (const task_id of ["quiet-shell", "monitor"]) observe({ type: "system", subtype: "task_notification", task_id }); @@ -736,6 +753,57 @@ test("Claude runtime safety protects silent shells, monitors and pending complet expect(safety.canStopRuntime()).toBe(false); observe({ type: "system", subtype: "background_tasks_changed", tasks: [] }); expect(safety.canStopRuntime()).toBe(true); + safety.retire(); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "late", task_type: "local_bash" }], + }), + ).toBeUndefined(); + safety.reset(); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "new", task_type: "local_bash" }], + }), + ).toEqual([{ id: "new", kind: "shell", description: null, canStop: true }]); +}); + +test("emits inventory only when relevant observations change it", () => { + const safety = new ClaudeRuntimeSafety(); + const observe = (message: unknown) => safety.observe(message as SDKMessage); + expect(observe({ type: "system", subtype: "init", claude_code_version: "2.1.292" })).toEqual([]); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "m", task_type: "local_bash" }], + }), + ).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]); + expect( + observe({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "m", task_type: "local_bash" }], + }), + ).toBeUndefined(); + expect( + observe({ type: "system", subtype: "task_started", task_id: "m", tool_use_id: "tool" }), + ).toBeUndefined(); + expect( + observe({ + type: "assistant", + message: { content: [{ type: "tool_use", id: "tool", name: "Monitor" }] }, + }), + ).toEqual([{ id: "m", kind: "monitor", description: null, canStop: true }]); + expect( + observe({ type: "assistant", message: { content: [{ type: "text", text: "streamed" }] } }), + ).toBeUndefined(); + expect( + observe({ type: "system", subtype: "task_started", task_id: "m", tool_use_id: "other" }), + ).toEqual([{ id: "m", kind: "shell", description: null, canStop: true }]); }); test("Claude session runtime safety observes pumped background work and autonomous replies", async () => { diff --git a/packages/server/src/server/agent/providers/claude/agent.real.e2e.test.ts b/packages/server/src/server/agent/providers/claude/agent.real.e2e.test.ts index 74ff780aac..eaf6a68fbc 100644 --- a/packages/server/src/server/agent/providers/claude/agent.real.e2e.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.real.e2e.test.ts @@ -143,6 +143,13 @@ function getLatestCompletedBashCall(events: AgentStreamEvent[]): ToolCallTimelin .find((item) => item.status === "completed" && item.name.toLowerCase() === "bash"); } +function hasBackgroundShell(events: AgentStreamEvent[]): boolean { + return events.some( + (event) => + event.type === "background_work" && event.items?.some((item) => item.kind === "shell"), + ); +} + function getInternalQuery(session: AgentSession): unknown { return (session as AgentSession & { query?: unknown }).query ?? null; } @@ -419,6 +426,39 @@ describe("ClaudeAgentSession integration", () => { } }, 180_000); + test("lists a background shell as background work until it exits", async () => { + const handle = await createSession({ cwdPrefix: "claude-agent-background-work-" }); + try { + const liveEventsPromise = collectSubscribedUntil( + handle.session, + (event, events) => + event.type === "background_work" && + event.items?.length === 0 && + hasBackgroundShell(events), + 90_000, + ); + + await collectUntilTerminal( + streamSession( + handle.session, + [ + "Use the Bash tool with run_in_background.", + "Run exactly: sleep 8", + "Do not wait for the task result.", + "Reply immediately with exactly: SPAWNED", + ].join(" "), + ), + { timeoutMs: 90_000 }, + ); + + const liveEvents = await liveEventsPromise; + expect(hasBackgroundShell(liveEvents)).toBe(true); + expect(liveEvents.at(-1)).toMatchObject({ type: "background_work", items: [] }); + } finally { + await cleanupSession(handle); + } + }, 180_000); + test("surfaces permission requests and resumes after approval", async () => { const handle = await createSession({ cwdPrefix: "claude-agent-permission-", diff --git a/packages/server/src/server/agent/providers/claude/agent.runtime-exit.test.ts b/packages/server/src/server/agent/providers/claude/agent.runtime-exit.test.ts index 85bf450ab3..d70d068717 100644 --- a/packages/server/src/server/agent/providers/claude/agent.runtime-exit.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.runtime-exit.test.ts @@ -118,6 +118,22 @@ const RUNNING_WORKFLOW_TURN_EVENTS = [ COMPLETED_TURN_EVENTS[2], ]; +const BACKGROUND_SHELL_TURN_EVENTS = [ + COMPLETED_TURN_EVENTS[0], + { + type: "system", + subtype: "background_tasks_changed", + tasks: [ + { task_id: "bash-1", task_type: "local_bash", description: "sleep 20" }, + { task_id: "wf-1", task_type: "local_workflow", description: "Run the spec workflow" }, + ], + uuid: "00000000-0000-4000-8000-000000000001", + session_id: "claude-runtime-exit-session", + }, + COMPLETED_TURN_EVENTS[1], + COMPLETED_TURN_EVENTS[2], +]; + const MISSING_RESUMED_CONVERSATION_RESULT = { type: "result", subtype: "error_during_execution", @@ -272,6 +288,155 @@ describe("Claude runtime exit", () => { } }); + test("reports background tasks except subagents as background work", async () => { + const queryFactory = vi.fn(() => createQueryMock(BACKGROUND_SHELL_TURN_EVENTS)); + vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(createChildProcessStub()); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + + try { + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.run("start a background shell"); + + expect(events).toContainEqual( + expect.objectContaining({ + type: "background_work", + provider: "claude", + items: [{ id: "bash-1", kind: "shell", description: "sleep 20", canStop: true }], + }), + ); + } finally { + await session.close(); + } + }); + + test("empties background work when the process dies while idle", async () => { + let capturedOptions: Options | undefined; + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { + capturedOptions = options; + return createQueryMock(BACKGROUND_SHELL_TURN_EVENTS); + }); + const child = createChildProcessStub(); + vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + + try { + await session.run("start a background shell"); + capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + child.emit("exit", 1, null); + + expect(events).toContainEqual( + expect.objectContaining({ type: "background_work", provider: "claude", items: null }), + ); + } finally { + await session.close(); + } + }); + + test("empties background work when a query restart retires the process", async () => { + let capturedOptions: Options | undefined; + const child = createChildProcessStub(); + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { + capturedOptions = options; + return createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { + tail: new Promise(() => undefined), + onReturn: () => child.emit("exit", 0, null), + }); + }); + vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + + try { + await session.run("start a background shell"); + capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.setThinkingOption(null); + await session.listCommands(); + + expect(events).toContainEqual( + expect.objectContaining({ type: "background_work", provider: "claude", items: null }), + ); + } finally { + await session.close(); + } + }); + + test("ignores background work a retired query reports after its restart", async () => { + let capturedOptions: Options | undefined; + let deliverLateEvent: ((event: unknown) => void) | undefined; + let retiredQuery: Query | undefined; + const child = createChildProcessStub(); + const queryFactory = vi.fn(({ options }: ClaudeQueryInput) => { + capturedOptions = options; + if (retiredQuery) { + return createQueryMock([], { tail: new Promise(() => undefined) }); + } + retiredQuery = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { + tail: new Promise((resolve) => { + deliverLateEvent = resolve; + }), + onReturn: () => child.emit("exit", 0, null), + }); + return retiredQuery; + }); + vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child); + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + + try { + await session.run("start a background shell"); + capturedOptions?.spawnClaudeCodeProcess?.(SPAWN_OPTIONS); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + + await session.setThinkingOption(null); + await session.listCommands(); + expect(queryFactory).toHaveBeenCalledTimes(2); + + // The retired query still had this queued when the restart emptied the list. + deliverLateEvent?.(BACKGROUND_SHELL_TURN_EVENTS[1]); + // One next() per scripted event and one for the late event; the pump stops + // reading a query it no longer owns instead of routing that event. + await vi.waitFor(() => + expect(retiredQuery?.next).toHaveBeenCalledTimes(BACKGROUND_SHELL_TURN_EVENTS.length + 1), + ); + // Let a routed late event reach subscribers before checking the list. + await new Promise((resolve) => setImmediate(resolve)); + + const backgroundWork = events.filter((event) => event.type === "background_work"); + expect(backgroundWork.at(-1)).toEqual( + expect.objectContaining({ type: "background_work", provider: "claude", items: null }), + ); + } finally { + await session.close(); + } + }); + test("tree-kills the retired process when the resumed conversation is gone", async () => { let capturedOptions: Options | undefined; let deliverMissingConversation: ((event: unknown) => void) | undefined; @@ -348,4 +513,77 @@ describe("Claude runtime exit", () => { await session.close(); } }); + test("waits for a stopped task notification after the stop request is accepted", async () => { + let deliver!: (message: unknown) => void; + const tail = new Promise((resolve) => { + deliver = resolve; + }); + const query = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { tail }); + query.stopTask = async () => {}; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: () => query, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + try { + await session.run("start a background shell"); + let settled = false; + const pending = session.stopBackgroundWork!("bash-1").then(() => { + settled = true; + return undefined; + }); + await Promise.resolve(); + expect(settled).toBe(false); + deliver({ + type: "system", + subtype: "task_notification", + task_id: "bash-1", + status: "stopped", + output_file: "", + summary: "Stopped", + uuid: "00000000-0000-4000-8000-000000000002", + session_id: "claude-runtime-exit-session", + }); + await pending; + expect(settled).toBe(true); + await expect(session.stopBackgroundWork!("wf-1")).rejects.toThrow(); + } finally { + await session.close(); + } + }); + test.each(["completed", "failed"])( + "reports the task's %s outcome when it races a stop", + async (status) => { + let deliver!: (message: unknown) => void; + const tail = new Promise((resolve) => { + deliver = resolve; + }); + const query = createQueryMock(BACKGROUND_SHELL_TURN_EVENTS, { tail }); + query.stopTask = async () => {}; + const client = new ClaudeAgentClient({ + logger: createTestLogger(), + queryFactory: () => query, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ provider: "claude", cwd: process.cwd() }); + try { + await session.run("start a background shell"); + const result = session.stopBackgroundWork!("bash-1"); + deliver({ + type: "system", + subtype: "task_notification", + task_id: "bash-1", + status, + output_file: "", + summary: "Finished", + uuid: "00000000-0000-4000-8000-000000000003", + session_id: "claude-runtime-exit-session", + }); + await expect(result).rejects.toThrow(status); + } finally { + await session.close(); + } + }, + ); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index a5d15bdb24..a9f709ab1a 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -2908,8 +2908,13 @@ describe("ClaudeAgentSession context window usage", () => { await session.startTurn("turn", { clientMessageId: "client-message-1" }); await new Promise((resolve) => setTimeout(resolve, 0)); - expect(events.slice(0, 2).map((event) => event.type)).toEqual(["turn_started", "timeline"]); - expect(events[1]).toMatchObject({ + const started = events.findIndex((event) => event.type === "turn_started"); + const userMessage = events.findIndex( + (event) => event.type === "timeline" && event.item.type === "user_message", + ); + expect(started).toBeGreaterThanOrEqual(0); + expect(userMessage).toBeGreaterThan(started); + expect(events[userMessage]).toMatchObject({ type: "timeline", item: { type: "user_message", clientMessageId: "client-message-1" }, }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 9b560747fc..bc5ab24af4 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2294,6 +2294,7 @@ class ClaudeAgentSession implements AgentSession { private readonly resolveBinary: () => Promise; private query: Query | null = null; private readonly runtimeSafety = new ClaudeRuntimeSafety(); + private readonly backgroundStops = new Map void>(); private processingSdkMessages = 0; private readonly harnessEnvironment: Record; private readonly usageSessionKey = randomUUID(); @@ -2669,6 +2670,33 @@ class ClaudeAgentSession implements AgentSession { }; } + async stopBackgroundWork(id: string): Promise { + const query = this.query; + if (!query || !this.runtimeSafety.canStop(id)) + throw new Error("Background work is no longer available"); + if (this.backgroundStops.has(id)) throw new Error("Stop is already pending"); + let timer: ReturnType | undefined; + let finish!: (outcome: string) => void; + const outcome = new Promise((resolve) => { + finish = resolve; + this.backgroundStops.set(id, resolve); + }); + try { + const result = await Promise.race([ + Promise.all([query.stopTask(id), outcome]).then(([, value]) => value), + outcome.then((value) => (value === "stopped" ? new Promise(() => {}) : value)), + new Promise((resolve) => { + timer = setTimeout(() => resolve("timeout"), 15000); + }), + ]); + if (result !== "stopped") throw new Error(`Background work stop: ${result}`); + if (this.query !== query) throw new Error("Background work connection changed"); + } finally { + clearTimeout(timer); + if (this.backgroundStops.get(id) === finish) this.backgroundStops.delete(id); + } + } + async interrupt(): Promise { if (this.cancelCurrentTurn) { this.cancelCurrentTurn(); @@ -3478,6 +3506,7 @@ class ClaudeAgentSession implements AgentSession { const oldQuery = this.query; if (!oldQuery) return; const oldInput = this.input; + for (const finish of this.backgroundStops.values()) finish("connection lost"); // Null out query/input BEFORE awaiting the old iterator's return so the // old pump sees this.query !== activeQuery and skips failActiveTurns. this.query = null; @@ -3532,6 +3561,7 @@ class ClaudeAgentSession implements AgentSession { this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query"); this.input = input; this.runtimeSafety.reset(); + this.dispatchEvents([{ type: "background_work", provider: "claude", items: null }]); // A fresh Claude process has no turn of its own in flight. this.mainTurnInFlight = false; this.query = claudeQuery( @@ -4127,11 +4157,15 @@ class ClaudeAgentSession implements AgentSession { } private failRunningRuntimeTasks(): void { - this.dispatchEvents( - foldSubagentObservations(this.taskProtocolSource.failRunningTasks()).map( + for (const finish of this.backgroundStops.values()) finish("connection lost"); + this.runtimeSafety.retire(); + this.dispatchEvents([ + ...foldSubagentObservations(this.taskProtocolSource.failRunningTasks()).map( (event): AgentStreamEvent => ({ type: "provider_subagent", provider: "claude", event }), ), - ); + // The SDK resets its background task set when the CLI process (re)starts. + { type: "background_work", provider: "claude", items: null }, + ]); } private startQueryPump(): void { @@ -4199,12 +4233,19 @@ class ClaudeAgentSession implements AgentSession { if (!this.ownsQuery(activeQuery)) return true; this.processingSdkMessages++; try { - this.runtimeSafety.observe(message); + const backgroundWork = this.runtimeSafety.observe(message); + if (message.type === "system" && message.subtype === "task_notification") + this.backgroundStops.get(message.task_id)?.(message.status); logRawMessage(message); consecutiveInterruptAbortRecoveries = 0; if (await this.handleMissingResumedConversation(message, activeQuery)) return true; if (!this.ownsQuery(activeQuery)) return true; await this.routeSdkMessageFromPump(message); + if (!this.ownsQuery(activeQuery)) return true; + if (backgroundWork && this.runtimeSafety.isCurrentInventory(backgroundWork)) + this.dispatchEvents([ + { type: "background_work", provider: "claude", items: backgroundWork }, + ]); return false; } finally { this.processingSdkMessages--; @@ -4841,6 +4882,13 @@ class ClaudeAgentSession implements AgentSession { this.appendTaskNotificationEvents(message, events); return; } + if (message.subtype === "background_tasks_changed") { + this.logger.debug( + { tasks: message.tasks.map(({ task_id, task_type }) => ({ task_id, task_type })) }, + "claude.background_tasks_changed", + ); + return; + } if (message.subtype === "task_progress") { return; } diff --git a/packages/server/src/server/agent/providers/claude/background-work.test.ts b/packages/server/src/server/agent/providers/claude/background-work.test.ts new file mode 100644 index 0000000000..18c1265f28 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/background-work.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, test } from "vitest"; + +import { toBackgroundWorkInputs } from "./background-work.js"; + +describe("toBackgroundWorkInputs", () => { + test("maps shells, drops subagents and workflows", () => { + expect( + toBackgroundWorkInputs([ + { task_id: "bash-1", task_type: "local_bash", description: "sleep 20" }, + { task_id: "agent-1", task_type: "local_agent", description: "Explore" }, + { task_id: "wf-1", task_type: "local_workflow", description: "Run the spec workflow" }, + { task_id: "x-1", task_type: "remote_something", description: " " }, + { task_id: "bash-2", task_type: "local_bash" }, + ]), + ).toEqual([ + { id: "bash-1", kind: "shell", description: "sleep 20", canStop: true }, + { id: "x-1", kind: "other", description: null, canStop: false }, + { id: "bash-2", kind: "shell", description: null, canStop: true }, + ]); + }); +}); diff --git a/packages/server/src/server/agent/providers/claude/background-work.ts b/packages/server/src/server/agent/providers/claude/background-work.ts new file mode 100644 index 0000000000..9727fb994d --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/background-work.ts @@ -0,0 +1,36 @@ +import type { AgentBackgroundWorkInput } from "../../background-work/index.js"; +import { CLAUDE_SUBAGENT_TASK_TYPE, CLAUDE_WORKFLOW_TASK_TYPE } from "./subagents/live-source.js"; + +export interface ClaudeBackgroundTask { + task_id: string; + task_type: string; + /** The user's `claude` binary can differ from the SDK typings, so this is read defensively. */ + description?: unknown; +} + +/** Subagents and workflows already live in the subagents track. */ +const SUBAGENT_TASK_TYPES = new Set([CLAUDE_SUBAGENT_TASK_TYPE, CLAUDE_WORKFLOW_TASK_TYPE]); + +/** Maps `background_tasks_changed.tasks` to provider-agnostic background work. */ +export function toBackgroundWorkInputs( + tasks: readonly ClaudeBackgroundTask[], +): AgentBackgroundWorkInput[] { + return tasks + .filter((task) => !SUBAGENT_TASK_TYPES.has(task.task_type)) + .map((task) => ({ + id: task.task_id, + kind: toKind(task.task_type), + canStop: task.task_type === "local_bash", + description: readDescription(task.description), + })); +} + +/** The inventory owner refines shell entries when launch metadata identifies a monitor. */ +function toKind(taskType: string): string { + if (taskType === "local_bash") return "shell"; + return "other"; +} + +function readDescription(value: unknown): string | null { + return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; +} diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.test.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.test.ts new file mode 100644 index 0000000000..6651f3e909 --- /dev/null +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.test.ts @@ -0,0 +1,48 @@ +import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import { expect, test } from "vitest"; +import { ClaudeRuntimeSafety } from "./runtime-safety.js"; + +const message = (value: unknown) => value as SDKMessage; + +test("enriches only live tasks with their correlated launch metadata", () => { + const runtime = new ClaudeRuntimeSafety(); + runtime.observe( + message({ + type: "assistant", + message: { + content: [ + { + type: "tool_use", + id: "tool-1", + name: "Monitor", + input: { description: "Watch build", command: "tail -f build.log" }, + }, + { type: "tool_use", id: "old", name: "Bash", input: { command: "old command" } }, + ], + }, + }), + ); + runtime.observe( + message({ type: "system", subtype: "task_started", task_id: "task-1", tool_use_id: "tool-1" }), + ); + expect( + runtime.observe( + message({ + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "task-1", task_type: "local_bash", description: "Watch build" }], + }), + ), + ).toEqual([ + { + id: "task-1", + kind: "monitor", + description: "Watch build", + command: "tail -f build.log", + canStop: true, + }, + ]); + expect( + runtime.observe(message({ type: "system", subtype: "background_tasks_changed", tasks: [] })), + ).toEqual([]); +}); diff --git a/packages/server/src/server/agent/providers/claude/runtime-safety.ts b/packages/server/src/server/agent/providers/claude/runtime-safety.ts index 0c32804a57..a21bf908ea 100644 --- a/packages/server/src/server/agent/providers/claude/runtime-safety.ts +++ b/packages/server/src/server/agent/providers/claude/runtime-safety.ts @@ -1,42 +1,160 @@ import type { SDKMessage } from "@anthropic-ai/claude-agent-sdk"; +import type { AgentBackgroundWorkInput } from "../../background-work/index.js"; import { parseClaudeCodeVersion } from "./model-manifest.js"; +import { toBackgroundWorkInputs, type ClaudeBackgroundTask } from "./background-work.js"; /** Process-local background ownership. Completion delivery can follow an empty roster. */ export class ClaudeRuntimeSafety { - private supported = false; - private liveTasks = new Set(); + private inventoryState: "unknown" | "supported" | "retired" = "unknown"; + private liveTasks: readonly ClaudeBackgroundTask[] = []; + private published: AgentBackgroundWorkInput[] | undefined; private readonly awaitingNotifications = new Set(); + private readonly monitorTools = new Set(); + private readonly toolInputs = new Map(); + private readonly taskTools = new Map(); reset(): void { - this.supported = false; - this.liveTasks.clear(); + this.inventoryState = "unknown"; + this.liveTasks = []; + this.published = undefined; this.awaitingNotifications.clear(); + this.monitorTools.clear(); + this.toolInputs.clear(); + this.taskTools.clear(); } - observe(message: SDKMessage): void { - if (message.type !== "system") return; + retire(): void { + this.reset(); + this.inventoryState = "retired"; + } + + /** Returns a full replacement only when the visible inventory changes. */ + observe(message: SDKMessage): AgentBackgroundWorkInput[] | undefined { + if (this.inventoryState === "retired") return undefined; + const changed = this.observeTools(message); + const systemChanged = message.type === "system" ? this.observeSystem(message) : false; + if ((!changed && !systemChanged) || this.inventoryState !== "supported") return undefined; + const items = this.buildInventory(); + if (this.published && sameInventory(this.published, items)) return undefined; + this.published = items; + return items; + } + + private observeTools(message: SDKMessage): boolean { + if (message.type === "assistant") { + let relevant = false; + for (const block of message.message.content) { + if (block.type === "tool_use" && (block.name === "Monitor" || block.name === "Bash")) { + if (block.name === "Monitor") relevant = this.observeMonitor(block.id) || relevant; + relevant = this.observeInput(block.id, block.input) || relevant; + } + } + return relevant; + } + if (message.type === "stream_event" && message.event.type === "content_block_start") { + const block = message.event.content_block; + if (block.type === "tool_use" && block.name === "Monitor") + return this.observeMonitor(block.id); + } + return false; + } + + private observeInput(id: string, value: unknown): boolean { + const input = value as Record | null; + const description = typeof input?.description === "string" ? input.description.trim() : ""; + const command = typeof input?.command === "string" ? input.command.trim() : ""; + const previous = this.toolInputs.get(id); + if (description === (previous?.description ?? "") && command === (previous?.command ?? "")) + return false; + this.toolInputs.set(id, { + ...(description ? { description } : {}), + ...(command ? { command } : {}), + }); + return this.liveTasks.some((task) => this.taskTools.get(task.task_id) === id); + } + + private observeMonitor(id: string): boolean { + if (this.monitorTools.has(id)) return false; + this.monitorTools.add(id); + return this.liveTasks.some((task) => this.taskTools.get(task.task_id) === id); + } + + private observeSystem(message: Extract): boolean { + if (message.subtype === "task_started" && message.tool_use_id) { + const previous = this.taskTools.get(message.task_id); + this.taskTools.set(message.task_id, message.tool_use_id); + return ( + previous !== message.tool_use_id && + (this.monitorTools.has(message.tool_use_id) || + this.toolInputs.has(message.tool_use_id) || + (previous !== undefined && + (this.monitorTools.has(previous) || this.toolInputs.has(previous)))) && + this.liveTasks.some((task) => task.task_id === message.task_id) + ); + } if (message.subtype === "init") { - // 2.1.292 is a known roster-capable release. Older binaries can demonstrate - // support by sending a roster; a fresh process starts with an empty roster. const version = parseClaudeCodeVersion(message.claude_code_version ?? ""); if ( version && (version[0] > 2 || (version[0] === 2 && (version[1] > 1 || (version[1] === 1 && version[2] >= 292)))) - ) - this.supported = true; + ) { + const changed = this.inventoryState !== "supported"; + this.inventoryState = "supported"; + return changed; + } } if (message.subtype === "background_tasks_changed") { - this.supported = true; - this.liveTasks = new Set(message.tasks.map((task) => task.task_id)); - for (const id of this.liveTasks) this.awaitingNotifications.add(id); - } - if (message.subtype === "task_notification") { - this.awaitingNotifications.delete(message.task_id); + this.inventoryState = "supported"; + this.liveTasks = message.tasks; + for (const task of this.liveTasks) this.awaitingNotifications.add(task.task_id); + return true; } + if (message.subtype === "task_notification") this.awaitingNotifications.delete(message.task_id); + return false; } canStopRuntime(): boolean { - return this.supported && this.liveTasks.size === 0 && this.awaitingNotifications.size === 0; + return ( + this.inventoryState === "supported" && + this.liveTasks.length === 0 && + this.awaitingNotifications.size === 0 + ); + } + + canStop(id: string): boolean { + return this.buildInventory().some((item) => item.id === id && item.canStop); + } + + isCurrentInventory(items: AgentBackgroundWorkInput[]): boolean { + return this.inventoryState === "supported" && this.published === items; + } + + private buildInventory(): AgentBackgroundWorkInput[] { + return toBackgroundWorkInputs(this.liveTasks).map((item) => { + const tool = this.taskTools.get(item.id) ?? ""; + if (this.monitorTools.has(tool)) item.kind = "monitor"; + const input = this.toolInputs.get(tool); + if (input?.description) item.description = input.description; + if (input?.command) item.command = input.command; + return item; + }); } } + +function sameInventory( + left: readonly AgentBackgroundWorkInput[], + right: readonly AgentBackgroundWorkInput[], +): boolean { + return ( + left.length === right.length && + left.every( + (item, index) => + item.id === right[index].id && + item.kind === right[index].kind && + item.description === right[index].description && + item.command === right[index].command && + item.canStop === right[index].canStop, + ) + ); +} diff --git a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts index b7b3201897..f9fa3f8a19 100644 --- a/packages/server/src/server/agent/providers/claude/subagents/live-source.ts +++ b/packages/server/src/server/agent/providers/claude/subagents/live-source.ts @@ -49,9 +49,9 @@ interface TaskStartedMessage { } /** Task-tool subagents. Backgrounded shell commands announce as `local_bash`. */ -const CLAUDE_SUBAGENT_TASK_TYPE = "local_agent"; +export const CLAUDE_SUBAGENT_TASK_TYPE = "local_agent"; /** Workflow executions use the same announced task lifecycle as Task-tool subagents. */ -const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow"; +export const CLAUDE_WORKFLOW_TASK_TYPE = "local_workflow"; /** * Not every announced task belongs in the subagents track. Verified on the wire: diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts index bf453ad651..78df159fc0 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.test.ts @@ -702,6 +702,20 @@ function markdownImageSource(markdown: string): string { return source.startsWith("file://") ? fileURLToPath(source) : source; } +function latestBackgroundWork(events: AgentStreamEvent[]) { + return events.findLast((event) => event.type === "background_work"); +} + +function hasBackgroundListRequest(appServer: FakeCodexAppServer, threadId: string) { + return appServer + .requests() + .some( + (request) => + request.method === "thread/backgroundTerminals/list" && + (request.params as { threadId: string }).threadId === threadId, + ); +} + function emitCodexUserMessage( appServer: FakeCodexAppServer, input: { id: string; text: string; threadId?: string; turnId?: string }, @@ -2172,13 +2186,16 @@ describe("Codex app-server provider", () => { expect.objectContaining({ type: "user_message", messageId: "user-1", text: "Hello" }), expect.objectContaining({ type: "assistant_message", text: "Hi there." }), ]); - expect(codex.threadMethods(0)).toEqual([ + // Background terminal observation is independent of conversation history loading. + const historyMethods = (index: number) => + codex.threadMethods(index).filter((method) => method !== "thread/backgroundTerminals/list"); + expect(historyMethods(0)).toEqual([ "thread/loaded/list", "thread/resume", "thread/read", "thread/read", ]); - expect(codex.threadMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]); + expect(historyMethods(1)).toEqual(["thread/loaded/list", "thread/resume"]); expect(codex.manager.getTimeline(codex.agentId)).toEqual(projected); } finally { await codex.close(); @@ -2548,6 +2565,77 @@ describe("Codex app-server provider", () => { await session.close(); }); + test("keeps a background terminal controllable on its original thread after rewind", async () => { + let live = true; + const appServer = createFakeCodexAppServer({ + initialize: () => ({ userAgent: "paseo/0.159.0 (Ubuntu 26.4.0; x86_64) (paseo; 0)" }), + "thread/read": () => ({ thread: { id: "thread-1", historyMode: "legacy", turns: [] } }), + "thread/backgroundTerminals/list": (params) => { + const threadId = (params as { threadId: string }).threadId; + if (threadId === "thread-1" && live) + return { data: [{ processId: "12", command: "sleep 180" }], nextCursor: null }; + if (threadId === "forked-thread") + return { data: [{ processId: "12", command: "sleep 99" }], nextCursor: null }; + return { data: [], nextCursor: null }; + }, + "thread/backgroundTerminals/terminate": (params) => { + expect(params).toMatchObject({ threadId: "thread-1", processId: "12" }); + live = false; + return { terminated: true }; + }, + }); + const session = new CodexAppServerAgentSession( + createConfig({ cwd: "/workspace/project" }), + null, + createTestLogger(), + async () => appServer.child, + ); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await session.startTurn("first"); + emitCodexUserMessage(appServer, { id: "first", text: "first", turnId: "turn-first" }); + appServer.completeTurn(); + await vi.waitFor(() => + expect(latestBackgroundWork(events)).toMatchObject({ + items: [{ command: "sleep 180" }], + }), + ); + await new Promise((resolve) => setImmediate(resolve)); + await session.startTurn("second"); + emitCodexUserMessage(appServer, { id: "second", text: "second", turnId: "turn-second" }); + appServer.completeTurn(); + await new Promise((resolve) => setImmediate(resolve)); + await new Promise((resolve) => setImmediate(resolve)); + await session.revertConversation({ messageId: "second" }); + await session.startTurn("continue"); + appServer.startsTerminalCommand({ + threadId: "forked-thread", + turnId: "turn-next", + itemId: "new", + processId: "12", + command: "echo hello", + }); + await vi.waitFor(() => + expect(hasBackgroundListRequest(appServer, "forked-thread")).toBe(true), + ); + await vi.waitFor(() => + expect(latestBackgroundWork(events)).toMatchObject({ + items: [{ command: "sleep 180" }, { command: "sleep 99" }], + }), + ); + const items = latestBackgroundWork(events)?.items; + expect(items).toHaveLength(2); + expect(items![0].id).not.toBe(items![1].id); + await session.stopBackgroundWork(items![0].id); + expect(latestBackgroundWork(events)).toMatchObject({ + items: [{ command: "sleep 99" }], + }); + } finally { + await session.close(); + } + }); + test("rewinds a paginated conversation through the public session capability", async () => { const appServer = createFakeCodexAppServer({ ...fakeCodexThreadHistory({ @@ -7816,7 +7904,7 @@ describe("Codex interactive resume startup", () => { unsubscribe(); const laterEvents: AgentStreamEvent[] = []; session.subscribe((event) => laterEvents.push(event)); - expect(laterEvents).toEqual([]); + expect(laterEvents.filter((event) => event.type !== "background_work")).toEqual([]); } finally { await session.close(); appServer.assertNoErrors(); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index bf797949c7..b031ae410e 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -1,3 +1,4 @@ +import { CodexBackgroundInventory } from "./codex/background-inventory.js"; import { validateProviderOptions } from "../provider-options.js"; import { getAgentStreamEventTurnId, @@ -3494,6 +3495,7 @@ export class CodexAppServerAgentSession implements AgentSession { cancelRequested: boolean; } | null = null; private client: CodexAppServerClient | null = null; + private readonly background = new CodexBackgroundInventory((event) => this.emitEvent(event)); private threadRollbackAvailable = true; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private startupEvents: AgentStreamEvent[] | null = null; @@ -3717,6 +3719,7 @@ export class CodexAppServerAgentSession implements AgentSession { throw this.createClosedError(); } this.connectionState = "connected"; + this.background.start({ client, thread: this.currentThreadId }); } catch (error) { try { if (this.client === client) { @@ -3768,6 +3771,7 @@ export class CodexAppServerAgentSession implements AgentSession { private handleUnexpectedTermination(error: Error): void { this.connectionState = "disconnected"; + this.background.stop(); const hasActiveRootTurn = this.activeForegroundTurnId !== null || this.currentTurnId !== null; this.clearPendingPermissions({ preservePlanApprovals: !hasActiveRootTurn }); if (hasActiveRootTurn) { @@ -4634,6 +4638,8 @@ export class CodexAppServerAgentSession implements AgentSession { const startupEvents = this.startupEvents; this.startupEvents = null; for (const event of startupEvents ?? []) this.notifySubscribers(event); + const background = this.background.snapshot(); + if (background) callback(background); return () => { this.subscribers.delete(callback); }; @@ -5125,6 +5131,10 @@ export class CodexAppServerAgentSession implements AgentSession { } } + stopBackgroundWork(id: string): Promise { + return this.background.stopItem(id); + } + async canStopRuntime(): Promise { if ( !this.client || @@ -5286,6 +5296,7 @@ export class CodexAppServerAgentSession implements AgentSession { async close(): Promise { this.closed = true; + this.background.stop(); this.clearPendingPermissions(); this.pendingToolCallDetails.clear(); this.pendingSubAgentNotificationsByThreadId.clear(); @@ -5316,6 +5327,7 @@ export class CodexAppServerAgentSession implements AgentSession { } private async disposeClient(): Promise { + this.background.stop(); const client = this.client; this.connectionState = "disconnected"; this.currentTurnId = null; @@ -5714,6 +5726,14 @@ export class CodexAppServerAgentSession implements AgentSession { await this.dispatchSubAgentNotification(parsed, route.callId); return; } + if ( + parsed.kind === "turn_completed" || + parsed.kind === "exec_command_started" || + parsed.kind === "exec_command_completed" || + ((parsed.kind === "item_started" || parsed.kind === "item_completed") && + normalizeCodexThreadItemType(parsed.item.type) === "commandExecution") + ) + this.background.changed(this.currentThreadId); await this.dispatchParsedNotification(parsed); } @@ -6353,6 +6373,7 @@ export class CodexAppServerAgentSession implements AgentSession { parsed: Extract, ): void { this.currentThreadId = parsed.threadId; + this.background.changed(this.currentThreadId); this.emitEvent({ type: "thread_started", provider: CODEX_PROVIDER, diff --git a/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts b/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts index 3d2ea64f9a..d089bc0445 100644 --- a/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts +++ b/packages/server/src/server/agent/providers/codex-interrupted-turn-terminals.test.ts @@ -45,6 +45,7 @@ function createHarness( } = {}, ) { const handlers: Record = { + "thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }), "turn/interrupt": () => { const completeTurn = () => appServer.completeTurn({ threadId: THREAD, status: "interrupted" }); @@ -169,7 +170,11 @@ describe("an interrupted Codex turn's commands end before the next turn", () => expect( harness.appServer .requests() - .filter((message) => String(message.method).startsWith("thread/backgroundTerminals/")) + .filter( + (message) => + String(message.method).startsWith("thread/backgroundTerminals/") && + message.method !== "thread/backgroundTerminals/list", + ) .map((message) => message.method), ).toEqual([TERMINATE, TERMINATE]); harness.appServer.assertNoErrors(); @@ -372,3 +377,328 @@ describe("an interrupted Codex turn's commands end before the next turn", () => expect(harness.terminated()).toEqual(["201", "202"]); }, 15_000); }); + +test("publishes every page of the current thread's live background terminals", async () => { + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": (params) => { + const cursor = paramsOf(params).cursor; + return cursor + ? { data: [{ itemId: "item-2", processId: "2", command: "sleep 2" }], nextCursor: null } + : { data: [{ itemId: "item-1", processId: "1", command: "sleep 1" }], nextCursor: "next" }; + }, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [ + { kind: "shell", description: null, command: "sleep 1" }, + { kind: "shell", description: null, command: "sleep 2" }, + ], + }), + ); +}); + +test("discards a terminal list overtaken by a command event", async () => { + const first = deferred(); + let lists = 0; + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => + ++lists === 1 ? first.promise : { data: [], nextCursor: null }, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => expect(lists).toBe(1)); + const shown = waitForTimelineToolCall(session, "item-new"); + appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "turn", + itemId: "item-new", + processId: "new", + command: "sleep 1", + }); + await shown; + first.resolve({ + data: [{ itemId: "old", processId: "old", command: "sleep 2" }], + nextCursor: null, + }); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [], + }), + ); + expect(events.filter((event) => event.type === "background_work")).not.toContainEqual( + expect.objectContaining({ items: [expect.objectContaining({ command: "sleep 2" })] }), + ); +}); + +test("does not publish a terminal response from a lost runtime", async () => { + const first = deferred(); + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => first.promise, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", modeId: "auto", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await appServer.waitForRequest("thread/backgroundTerminals/list"); + appServer.disconnect(); + first.resolve({ + data: [{ itemId: "old", processId: "old", command: "sleep 2" }], + nextCursor: null, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events.filter((event) => event.type === "background_work")).not.toContainEqual( + expect.objectContaining({ items: [expect.objectContaining({ command: "sleep 2" })] }), + ); +}); + +test("accepts a slow terminal snapshot after the polling interval", async () => { + const pending = deferred(); + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => pending.promise, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await appServer.waitForRequest("thread/backgroundTerminals/list"); + await new Promise((resolve) => setTimeout(resolve, 5200)); + expect( + appServer.requests().filter((message) => message.method === "thread/backgroundTerminals/list"), + ).toHaveLength(1); + pending.resolve({ data: [{ processId: "slow", command: "sleep 120" }], nextCursor: null }); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 120" }], + }), + ); + expect( + appServer.requests().filter((message) => message.method === "thread/backgroundTerminals/list"), + ).toHaveLength(1); +}, 12000); + +test("does not retry an unsupported inventory on later command events", async () => { + let calls = 0; + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => { + calls++; + return { __jsonRpcError: { code: -32601, message: "Method not found" } }; + }, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + await session.connect(); + await vi.waitFor(() => expect(calls).toBe(1)); + const shown = waitForTimelineToolCall(session, "unsupported-item"); + appServer.startsTerminalCommand({ + threadId: THREAD, + turnId: "turn", + itemId: "unsupported-item", + processId: "1", + command: "sleep 1", + }); + await shown; + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(calls).toBe(1); +}); + +test("retries transient inventory failures and cancels retries on stop", async () => { + vi.useFakeTimers(); + try { + const { CodexBackgroundInventory } = await import("./codex/background-inventory.js"); + let calls = 0; + let fail = true; + const inventory = new CodexBackgroundInventory(() => {}); + inventory.start({ + client: { + request: async () => { + calls++; + if (fail) throw new Error("temporary"); + return { data: [{ processId: "live", command: "sleep 120" }], nextCursor: null }; + }, + }, + thread: THREAD, + }); + await vi.advanceTimersByTimeAsync(0); + expect(inventory.snapshot()).toBeNull(); + inventory.changed(THREAD); + await vi.advanceTimersByTimeAsync(0); + expect(calls).toBe(1); + fail = false; + await vi.advanceTimersByTimeAsync(5000); + expect(inventory.snapshot()?.items).toMatchObject([{ command: "sleep 120" }]); + fail = true; + await vi.advanceTimersByTimeAsync(5000); + expect(inventory.snapshot()?.items).toMatchObject([{ command: "sleep 120" }]); + inventory.stop(); + const stoppedCalls = calls; + await vi.advanceTimersByTimeAsync(15000); + expect(calls).toBe(stoppedCalls); + } finally { + vi.useRealTimers(); + } +}); + +test("does not retry a failure from a stopped inventory generation", async () => { + vi.useFakeTimers(); + try { + const { CodexBackgroundInventory } = await import("./codex/background-inventory.js"); + let reject!: (error: Error) => void; + let calls = 0; + const response = new Promise((_, fail) => { + reject = fail; + }); + const inventory = new CodexBackgroundInventory(() => {}); + inventory.start({ + client: { + request: () => { + calls++; + return response; + }, + }, + thread: THREAD, + }); + inventory.stop(); + reject(new Error("late failure")); + await vi.advanceTimersByTimeAsync(15000); + expect(calls).toBe(1); + expect(inventory.snapshot()).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); + +test("stops only the selected live background terminal and confirms the roster", async () => { + const live = new Set(["1", "2"]); + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => ({ + data: [...live].map((processId) => ({ processId, command: `sleep ${processId}` })), + nextCursor: null, + }), + "thread/backgroundTerminals/terminate": (params) => ({ + terminated: live.delete(String(paramsOf(params).processId)), + }), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 1" }, { command: "sleep 2" }], + }), + ); + const item = events + .findLast((event) => event.type === "background_work") + ?.items?.find((entry) => entry.command === "sleep 1"); + expect(item).toBeDefined(); + await session.stopBackgroundWork(item!.id); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 2" }], + }); + await expect(session.stopBackgroundWork(item!.id)).rejects.toThrow(); +}); + +test("reports a rejected background stop and keeps the item available", async () => { + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => ({ + data: [{ processId: "1", command: "sleep 1" }], + nextCursor: null, + }), + "thread/backgroundTerminals/terminate": () => ({ terminated: false }), + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 1" }], + }), + ); + const item = events.findLast((event) => event.type === "background_work")?.items?.[0]; + expect(item).toBeDefined(); + await expect(session.stopBackgroundWork(item!.id)).rejects.toThrow("not terminated"); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 1" }], + }); +}); + +test("does not confirm a stop after the provider connection is lost", async () => { + const response = deferred(); + const appServer = createFakeCodexAppServer({ + "thread/backgroundTerminals/list": () => ({ + data: [{ processId: "1", command: "sleep 1" }], + nextCursor: null, + }), + "thread/backgroundTerminals/terminate": () => response.promise, + }); + const session = new CodexAppServerAgentSession( + { provider: "codex", cwd: "/workspace/project", model: "gpt-5.4" }, + { sessionId: THREAD }, + createTestLogger(), + async () => appServer.child, + ); + sessions.push(session); + const events: import("../agent-sdk-types.js").AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await session.connect(); + await vi.waitFor(() => + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ command: "sleep 1" }], + }), + ); + const item = events.findLast((event) => event.type === "background_work")?.items?.[0]; + expect(item).toBeDefined(); + const pending = session.stopBackgroundWork(item!.id); + await appServer.waitForRequest("thread/backgroundTerminals/terminate"); + appServer.disconnect(); + response.resolve({ terminated: true }); + await expect(pending).rejects.toThrow(); +}); diff --git a/packages/server/src/server/agent/providers/codex/background-inventory.ts b/packages/server/src/server/agent/providers/codex/background-inventory.ts new file mode 100644 index 0000000000..856634dbcb --- /dev/null +++ b/packages/server/src/server/agent/providers/codex/background-inventory.ts @@ -0,0 +1,187 @@ +import { z } from "zod"; +import type { AgentStreamEvent } from "../../agent-sdk-types.js"; +import { CodexAppServerRpcError } from "./app-server-transport.js"; + +type InventoryEvent = Extract; +interface Client { + request(method: string, params?: unknown, timeoutMs?: number): Promise; +} + +export class CodexBackgroundInventory { + private generation = 0; + private client: Client | null = null; + private thread: string | null = null; + private readonly threads = new Set(); + private origins = new Map(); + private pending = false; + private dirty = false; + private unsupported = false; + private retrying = false; + private timer: ReturnType | null = null; + private current: InventoryEvent | null = null; + private readonly listeners = new Set<() => void>(); + + async stopItem(id: string): Promise { + const client = this.client; + const origin = this.origins.get(id); + const generation = this.generation; + if (!client || !origin || !this.current?.items?.some((item) => item.id === id)) + throw new Error("Background terminal is no longer available"); + const result = z + .object({ terminated: z.boolean() }) + .parse( + await client.request( + "thread/backgroundTerminals/terminate", + { threadId: origin.threadId, processId: origin.processId }, + 10000, + ), + ); + if (generation !== this.generation) throw new Error("Background terminal connection changed"); + if (!result.terminated) throw new Error("Background terminal was not terminated"); + await this.waitUntilAbsent(id, generation); + } + + private waitUntilAbsent(id: string, generation: number): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout( + () => finish(new Error("Background terminal confirmation timed out")), + 15000, + ); + const finish = (error?: Error) => { + clearTimeout(timer); + this.listeners.delete(check); + if (error) reject(error); + else resolve(); + }; + const check = () => { + if (generation !== this.generation) + finish(new Error("Background terminal connection changed")); + else if (this.current?.items && !this.current.items.some((item) => item.id === id)) + finish(); + }; + this.listeners.add(check); + this.changed(this.thread); + }); + } + private notify() { + for (const listener of this.listeners) listener(); + } + + constructor(private readonly emit: (event: InventoryEvent) => void) {} + + snapshot() { + return this.current; + } + start({ client, thread }: { client: Client; thread: string | null }) { + this.stop(); + this.client = client; + this.thread = thread; + this.unsupported = false; + this.changed(thread); + } + stop() { + this.generation++; + this.notify(); + this.client = null; + this.thread = null; + this.threads.clear(); + this.origins.clear(); + this.pending = false; + this.dirty = false; + this.retrying = false; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + if (this.current) this.emit({ type: "background_work", provider: "codex", items: null }); + this.current = null; + } + changed(thread: string | null) { + this.thread = thread; + if (thread) this.threads.add(thread); + if (!this.client || !thread || this.unsupported) return; + this.dirty = true; + if (!this.retrying) this.refresh(); + } + private refresh() { + if (this.pending || !this.client || !this.thread || this.unsupported) return; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.pending = true; + this.dirty = false; + const generation = this.generation; + const client = this.client; + const threads = [...this.threads]; + void (async () => { + let failed = false; + try { + const { items, origins, activeThreads } = await readInventories(client, threads); + if (generation !== this.generation || this.dirty) return; + this.origins = origins; + this.threads.clear(); + for (const threadId of activeThreads) this.threads.add(threadId); + if (this.thread) this.threads.add(this.thread); + this.current = { type: "background_work", provider: "codex", items }; + this.emit(this.current); + this.notify(); + } catch (error) { + failed = true; + if ( + generation === this.generation && + error instanceof CodexAppServerRpcError && + error.code === -32601 + ) + this.unsupported = true; + } finally { + if (generation === this.generation) { + this.pending = false; + if (this.dirty && !failed && !this.unsupported) this.refresh(); + else if (!this.unsupported && (failed || this.current?.items?.length)) { + this.retrying = failed; + this.timer = setTimeout(() => { + this.timer = null; + this.retrying = false; + this.refresh(); + }, 5000); + this.timer.unref(); + } + } + } + })(); + } +} + +async function readInventories(client: Client, threads: string[]) { + const items: NonNullable = []; + const origins = new Map(); + const activeThreads = new Set(); + for (const threadId of threads) { + let cursor: string | null = null; + do { + const page = z + .object({ + data: z.array(z.object({ processId: z.string(), command: z.string() })), + nextCursor: z.string().nullable(), + }) + .parse( + await client.request( + "thread/backgroundTerminals/list", + { threadId, ...(cursor ? { cursor } : {}) }, + 10_000, + ), + ); + for (const row of page.data) { + const id = JSON.stringify([threadId, row.processId]); + origins.set(id, { threadId, processId: row.processId }); + activeThreads.add(threadId); + items.push({ + id, + kind: "shell", + canStop: true, + description: null, + ...(row.command ? { command: row.command } : {}), + }); + } + cursor = page.nextCursor; + } while (cursor); + } + return { items, origins, activeThreads }; +} diff --git a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts index ec14a773e4..106c734753 100644 --- a/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts +++ b/packages/server/src/server/agent/providers/codex/test-utils/fake-app-server.ts @@ -175,6 +175,7 @@ export function createFakeCodexAppServer( "skills/list": () => ({ data: [] }), "thread/start": () => ({ thread: { id: "thread-1" } }), "thread/loaded/list": () => ({ data: [] }), + "thread/backgroundTerminals/list": () => ({ data: [], nextCursor: null }), "thread/resume": () => ({}), "turn/start": () => ({}), "thread/fork": (params) => ({ diff --git a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts index eb06928e63..66ee191707 100644 --- a/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts +++ b/packages/server/src/server/agent/providers/opencode/test-utils/v2-harness.ts @@ -91,6 +91,7 @@ export class V2Harness { readonly api: V2Api = { server: { info: unexpected }, shell: { + remove: unexpected, list: async () => ({ location: { directory: this.info.location.directory }, data: [] }), }, plugin: { diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index e8188730f5..6e1a45409c 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -1201,3 +1201,773 @@ test("OpenCode v2 runtime safety follows descendant locations before releasing t await session.close(); } }); + +test("reconstructs only promoted session-owned running shells and reconciles exits", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: { description: "Watch tests", command: "sleep ours" }, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "ours" }, + }, + }, + ], + }); + harness.api.message.list = async (input) => + input.cursor + ? { data: [...harness.history], cursor: {} } + : { data: [], cursor: { next: "second-page" } }; + let live = ["ours", "other"]; + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: live.map((id) => ({ + id, + status: "running" as const, + command: `sleep ${id}`, + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: id === "ours" ? harness.info.id : "other" }, + time: { started: 1 }, + })), + }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ + items: [{ id: "ours", kind: "shell", description: "Watch tests", command: "sleep ours" }], + }); + live = []; + harness.push({ id: "reconnect", created: 2, type: "server.connected", data: {} }); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [] }); + } finally { + await session.close(); + } +}); + +test("an exit received during shell listing cannot restore an old row", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "shell" }, + }, + }, + ], + }); + const shell = { + id: "shell", + status: "running" as const, + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }; + let listCount = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + harness.api.shell.list = async () => { + listCount++; + if (listCount === 2) { + await gate; + return { location: harness.info.location, data: [shell] }; + } + return { location: harness.info.location, data: [] }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + harness.push({ id: "created", created: 2, type: "shell.created", data: { info: shell } }); + await expect.poll(() => listCount).toBe(2); + harness.push({ + id: "exited", + created: 3, + type: "shell.exited", + data: { id: "shell", status: "exited" }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + release(); + await expect.poll(() => listCount).toBeGreaterThan(2); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [] }); + expect(events.filter((event) => event.type === "background_work")).not.toContainEqual( + expect.objectContaining({ items: [expect.objectContaining({ id: "shell" })] }), + ); + } finally { + release(); + await session.close(); + } +}); + +test("does not publish a shell list from a lost server generation", async () => { + const harness = new V2Harness(); + let exit!: (error: Error) => void; + Object.assign(harness.connection, { + exited: new Promise((resolve) => { + exit = resolve; + }), + }); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "old" }, + }, + }, + ], + }); + const shell = { + id: "old", + status: "running" as const, + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }; + let reads = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + harness.api.shell.list = async () => { + reads++; + if (reads === 2) { + await gate; + return { location: harness.info.location, data: [shell] }; + } + return { location: harness.info.location, data: [] }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + harness.push({ id: "created", created: 2, type: "shell.created", data: { info: shell } }); + await expect.poll(() => reads).toBe(2); + exit(new Error("server exited")); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: null }); + release(); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(events.filter((event) => event.type === "background_work")).not.toContainEqual( + expect.objectContaining({ items: [expect.objectContaining({ id: "old" })] }), + ); + } finally { + release(); + await session.close(); + } +}); + +test("polls a live shell without rereading conversation history", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "live" }, + }, + }, + ], + }); + let historyReads = 0; + let shellReads = 0; + const readHistory = harness.api.message.list; + harness.api.message.list = async (...args) => { + historyReads++; + return readHistory(...args); + }; + harness.api.shell.list = async () => { + shellReads++; + return { + location: harness.info.location, + data: [ + { + id: "live", + status: "running", + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }, + ], + }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [{ id: "live" }] }); + const initialHistoryReads = historyReads; + const initialShellReads = shellReads; + await expect.poll(() => shellReads, { timeout: 7000 }).toBeGreaterThan(initialShellReads); + expect(historyReads).toBe(initialHistoryReads); + } finally { + await session.close(); + } +}, 12000); + +test("buffers tool promotion and shell events during background history bootstrap", async () => { + const harness = new V2Harness(); + let historyReads = 0; + let release!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + harness.api.message.list = async () => { + historyReads++; + if (historyReads === 3) await gate; + return { data: [], cursor: {} }; + }; + const shell = { + id: "promoted", + status: "running" as const, + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }; + harness.api.shell.list = async () => ({ location: harness.info.location, data: [shell] }); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect.poll(() => historyReads).toBeGreaterThanOrEqual(3); + harness.push({ + id: "input", + created: 2, + type: "session.tool.input.started", + durable: { aggregateID: harness.info.id, seq: 1, version: 1 }, + data: { sessionID: harness.info.id, assistantMessageID: "msg", id: "tool", name: "shell" }, + }); + harness.push({ + id: "input-ended", + created: 3, + type: "session.tool.input.ended", + durable: { aggregateID: harness.info.id, seq: 2, version: 1 }, + data: { + sessionID: harness.info.id, + assistantMessageID: "msg", + id: "tool", + text: JSON.stringify({ description: "Watch tests", command: "sleep 120" }), + }, + }); + harness.push({ id: "created", created: 3, type: "shell.created", data: { info: shell } }); + harness.push({ + id: "success", + created: 4, + type: "session.tool.success", + durable: { aggregateID: harness.info.id, seq: 3, version: 2 }, + data: { + sessionID: harness.info.id, + assistantMessageID: "msg", + id: "tool", + executed: true, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "promoted" }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + expect(events.filter((event) => event.type === "background_work")).toEqual([]); + release(); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ + items: [{ id: "promoted", description: "Watch tests", command: "sleep 120" }], + }); + harness.push({ + id: "next-input", + created: 5, + type: "session.tool.input.started", + durable: { aggregateID: harness.info.id, seq: 4, version: 1 }, + data: { sessionID: harness.info.id, assistantMessageID: "next", id: "tool", name: "shell" }, + }); + harness.push({ + id: "next-success", + created: 6, + type: "session.tool.success", + durable: { aggregateID: harness.info.id, seq: 5, version: 2 }, + data: { + sessionID: harness.info.id, + assistantMessageID: "next", + id: "tool", + executed: true, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "second" }, + }, + }); + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: [shell, { ...shell, id: "second" }], + }); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [{ id: "promoted" }, { id: "second" }] }); + } finally { + release(); + await session.close(); + } +}); + +test("retries failed background bootstrap and roster without losing stale work", async () => { + const { vi } = await import("vitest"); + vi.useFakeTimers(); + try { + const { OpenCodeBackgroundInventory } = await import("./background-inventory.js"); + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "live" }, + }, + }, + ], + }); + let historyCalls = 0; + let shellCalls = 0; + let historyFails = true; + let shellFails = true; + harness.api.message.list = async () => { + historyCalls++; + if (historyFails) throw new Error("history unavailable"); + return { data: harness.history, cursor: {} }; + }; + harness.api.shell.list = async () => { + shellCalls++; + if (shellFails) throw new Error("roster unavailable"); + return { + location: harness.info.location, + data: [ + { + id: "live", + status: "running", + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }, + ], + }; + }; + const inventory = new OpenCodeBackgroundInventory({ + id: harness.info.id, + cwd: "/tmp/project", + emit: () => {}, + report: () => {}, + }); + inventory.start(harness.api); + await vi.advanceTimersByTimeAsync(0); + expect(inventory.snapshot()).toBeNull(); + historyFails = false; + await vi.advanceTimersByTimeAsync(5000); + expect(historyCalls).toBe(2); + expect(shellCalls).toBe(1); + expect(inventory.snapshot()).toBeNull(); + inventory.observe({ id: "deleted", created: 1, type: "shell.deleted", data: { id: "other" } }); + await vi.advanceTimersByTimeAsync(0); + expect(shellCalls).toBe(1); + shellFails = false; + await vi.advanceTimersByTimeAsync(5000); + expect(historyCalls).toBe(2); + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live", canStop: true }]); + historyFails = true; + inventory.start(harness.api); + await vi.advanceTimersByTimeAsync(0); + expect(inventory.snapshot()?.items).toEqual([ + { id: "live", kind: "shell", description: null, command: "sleep 120" }, + ]); + await expect(inventory.stopItem("live")).rejects.toThrow("no longer available"); + historyFails = false; + await vi.advanceTimersByTimeAsync(5000); + expect(historyCalls).toBe(4); + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live", canStop: true }]); + shellFails = true; + await vi.advanceTimersByTimeAsync(5000); + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live" }]); + inventory.disconnect(); + const stoppedCalls = shellCalls; + await vi.advanceTimersByTimeAsync(15000); + expect(shellCalls).toBe(stoppedCalls); + } finally { + vi.useRealTimers(); + } +}); + +test("does not retry a failed bootstrap from a disconnected generation", async () => { + const { vi } = await import("vitest"); + vi.useFakeTimers(); + try { + const { OpenCodeBackgroundInventory } = await import("./background-inventory.js"); + const harness = new V2Harness(); + let reject!: (error: Error) => void; + let calls = 0; + const response = new Promise>>( + (_, fail) => { + reject = fail; + }, + ); + harness.api.message.list = () => { + calls++; + return response; + }; + const inventory = new OpenCodeBackgroundInventory({ + id: harness.info.id, + cwd: "/tmp/project", + emit: () => {}, + report: () => {}, + }); + inventory.start(harness.api); + inventory.disconnect(); + reject(new Error("late failure")); + await vi.advanceTimersByTimeAsync(15000); + expect(calls).toBe(1); + expect(inventory.snapshot()).toBeNull(); + } finally { + vi.useRealTimers(); + } +}); + +test("stops a selected promoted shell and retains its sibling", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: ["one", "two"].map((id) => ({ + type: "tool" as const, + id, + name: "shell", + time: { created: 1 }, + state: { + status: "completed" as const, + input: {}, + content: [{ type: "text" as const, text: "" }], + metadata: { status: "running", shellID: id }, + }, + })), + }); + let live = ["one", "two"]; + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: live.map((id) => ({ + id, + status: "running" as const, + command: `sleep ${id}`, + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + })), + }); + harness.api.shell.remove = async ({ id }) => { + live = live.filter((entry) => entry !== id); + return { data: true }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [{ id: "one" }, { id: "two" }] }); + await session.stopBackgroundWork!("one"); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ id: "two" }], + }); + await expect(session.stopBackgroundWork!("one")).rejects.toThrow(); + } finally { + await session.close(); + } +}); + +test("closing during reconnect does not restart the background inventory", async () => { + const harness = new V2Harness(); + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [] }); + let unblock!: () => void; + let entered = false; + const blocked = new Promise((resolve) => { + unblock = resolve; + }); + harness.connection.bindSession = async () => { + entered = true; + await blocked; + }; + let reads = 0; + const original = harness.api.message.list; + harness.api.message.list = (...args) => { + reads++; + return original(...args); + }; + harness.push({ id: "reconnect-close", created: 2, type: "server.connected", data: {} }); + await expect.poll(() => entered).toBe(true); + const closing = session.close(); + unblock(); + await closing; + expect(reads).toBe(0); +}); + +test("keeps a shell available when its stop request times out", async () => { + const { vi } = await import("vitest"); + vi.useFakeTimers(); + try { + const { OpenCodeBackgroundInventory } = await import("./background-inventory.js"); + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "live" }, + }, + }, + ], + }); + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: [ + { + id: "live", + status: "running", + command: "sleep 120", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }, + ], + }); + harness.api.shell.remove = () => new Promise(() => {}); + const inventory = new OpenCodeBackgroundInventory({ + id: harness.info.id, + cwd: "/tmp/project", + emit: () => {}, + report: () => {}, + }); + inventory.start(harness.api); + await vi.advanceTimersByTimeAsync(0); + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live", canStop: true }]); + const result = expect(inventory.stopItem("live")).rejects.toThrow("timed out"); + await vi.advanceTimersByTimeAsync(10000); + await result; + expect(inventory.snapshot()?.items).toMatchObject([{ id: "live" }]); + inventory.disconnect(); + } finally { + vi.useRealTimers(); + } +}); + +test("keeps a live background shell visible and stoppable after its launch is rewound", async () => { + const harness = new V2Harness(); + harness.history.push({ + id: "assistant", + type: "assistant", + agent: "build", + model: { providerID: "test", modelID: "test" }, + time: { created: 1 }, + content: [ + { + type: "tool", + id: "tool", + name: "shell", + time: { created: 1 }, + state: { + status: "completed", + input: {}, + content: [{ type: "text", text: "" }], + metadata: { status: "running", shellID: "live" }, + }, + }, + ], + }); + let live = true; + harness.api.shell.list = async () => ({ + location: harness.info.location, + data: live + ? [ + { + id: "live", + status: "running", + command: "sleep 180", + cwd: "/tmp/project", + shell: "sh", + file: "sh", + metadata: { sessionID: harness.info.id }, + time: { started: 1 }, + }, + ] + : [], + }); + harness.api.shell.remove = async () => { + live = false; + return { data: true }; + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + try { + await expect + .poll(() => events.findLast((event) => event.type === "background_work")) + .toMatchObject({ items: [{ id: "live", canStop: true }] }); + harness.history.length = 0; + harness.push({ + id: "revert", + created: 2, + type: "session.revert.committed", + durable: { aggregateID: harness.info.id, seq: 1, version: 1 }, + data: { sessionID: harness.info.id, to: "earlier" }, + }); + await expect + .poll(() => events.filter((event) => event.type === "background_work").length) + .toBeGreaterThanOrEqual(3); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ id: "live", canStop: true }], + }); + harness.push({ id: "reconnect-after-rewind", created: 3, type: "server.connected", data: {} }); + await expect + .poll(() => events.filter((event) => event.type === "background_work").length) + .toBeGreaterThanOrEqual(5); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [{ id: "live", canStop: true }], + }); + await session.stopBackgroundWork!("live"); + expect(events.findLast((event) => event.type === "background_work")).toMatchObject({ + items: [], + }); + } finally { + await session.close(); + } +}); diff --git a/packages/server/src/server/agent/providers/opencode/v2/api.ts b/packages/server/src/server/agent/providers/opencode/v2/api.ts index bec40ffbcb..31754eb468 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/api.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/api.ts @@ -2,7 +2,7 @@ import type { OpenCodeClient } from "@opencode/client"; export interface V2Api { server: OpenCodeClient["server"]; - shell: Pick; + shell: Pick; session: Pick< OpenCodeClient["session"], | "create" diff --git a/packages/server/src/server/agent/providers/opencode/v2/background-inventory.ts b/packages/server/src/server/agent/providers/opencode/v2/background-inventory.ts new file mode 100644 index 0000000000..9c10a1ce86 --- /dev/null +++ b/packages/server/src/server/agent/providers/opencode/v2/background-inventory.ts @@ -0,0 +1,308 @@ +import type { OpenCodeEvent, SessionMessageInfo, ShellInfo } from "@opencode/client"; +import type { AgentStreamEvent } from "../../../agent-sdk-types.js"; +import type { V2Api } from "./api.js"; +import { messages } from "./history.js"; + +type InventoryEvent = Extract; +type RelevantEvent = Extract< + OpenCodeEvent, + { + type: + | "session.tool.input.started" + | "session.tool.input.ended" + | "session.tool.success" + | "shell.created" + | "shell.exited" + | "shell.deleted"; + } +>; + +export class OpenCodeBackgroundInventory { + private generation = 0; + private client: V2Api | null = null; + private loading = false; + private pending = false; + private dirty = false; + private retrying = false; + private events: RelevantEvent[] = []; + private names = new Map(); + private inputs = new Map(); + private descriptions = new Map(); + private promoted = new Set(); + private timer: ReturnType | null = null; + private current: InventoryEvent | null = null; + private readonly listeners = new Set<() => void>(); + async stopItem(id: string): Promise { + const client = this.client; + const generation = this.generation; + if (!client || !this.current?.items?.some((item) => item.id === id && item.canStop)) + throw new Error("Background shell is no longer available"); + let timer: ReturnType | undefined; + let disconnected!: () => void; + const lost = new Promise((_, reject) => { + disconnected = () => { + if (generation !== this.generation) + reject(new Error("Background shell connection changed")); + }; + this.listeners.add(disconnected); + timer = setTimeout(() => reject(new Error("Background shell stop timed out")), 10000); + }); + try { + await Promise.race([client.shell.remove({ id, location: { directory: this.cwd } }), lost]); + } finally { + clearTimeout(timer); + this.listeners.delete(disconnected); + } + if (generation !== this.generation) throw new Error("Background shell connection changed"); + await new Promise((resolve, reject) => { + const confirmationTimer = setTimeout( + () => finish(new Error("Background shell confirmation timed out")), + 15000, + ); + const finish = (error?: Error) => { + clearTimeout(confirmationTimer); + this.listeners.delete(check); + if (error) reject(error); + else resolve(); + }; + const check = () => { + if (generation !== this.generation) + finish(new Error("Background shell connection changed")); + else if (this.current?.items && !this.current.items.some((item) => item.id === id)) + finish(); + }; + this.listeners.add(check); + this.dirty = true; + this.refresh(); + }); + } + private notify() { + for (const listener of this.listeners) listener(); + } + constructor(options: { + id: string; + cwd: string; + emit: (event: InventoryEvent) => void; + report: (error: unknown) => void; + }) { + this.id = options.id; + this.cwd = options.cwd; + this.emit = options.emit; + this.report = options.report; + } + private readonly id: string; + private readonly cwd: string; + private readonly emit: (event: InventoryEvent) => void; + private readonly report: (error: unknown) => void; + snapshot() { + return this.current; + } + start(client: V2Api) { + this.disconnect(); + this.client = client; + this.loading = true; + this.events = []; + const generation = this.generation; + void this.bootstrap(client, generation); + } + disconnect(lost = false) { + this.generation++; + this.notify(); + this.client = null; + this.loading = false; + this.pending = false; + this.dirty = false; + this.retrying = false; + this.events = []; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + if (lost) { + this.promoted.clear(); + this.descriptions.clear(); + this.current = null; + this.emit({ type: "background_work", provider: "opencode", items: null }); + } else if (this.current?.items?.some((item) => item.canStop)) { + // Keep stale rows visible, but revoke their stop tokens until this generation's + // roster confirms ownership again. + const items = this.current.items.map((item) => ({ + id: item.id, + kind: item.kind, + description: item.description, + ...(item.command ? { command: item.command } : {}), + })); + this.current = { type: "background_work", provider: "opencode", items }; + this.emit(this.current); + } + } + private async bootstrap(client: V2Api, generation: number) { + try { + const history = await messages(client, this.id); + if (generation !== this.generation) return; + this.names = new Map(); + this.inputs = new Map(); + // Rewinds can remove launch messages without stopping the shell. Keep known + // promotions until the current provider roster confirms they are gone. + this.readHistory(history); + for (const event of this.events) this.apply(event); + this.events = []; + this.loading = false; + this.dirty = true; + this.refresh(); + } catch (error) { + if (generation === this.generation) { + this.report(error); + this.schedule(() => void this.bootstrap(client, generation), true); + } + } + } + private readHistory(history: readonly SessionMessageInfo[]) { + for (const message of history) { + if (message.type !== "assistant") continue; + for (const part of message.content) { + if (part.type !== "tool") continue; + if (part.state.status === "completed") { + if (part.name === "shell") this.promote(part.state.metadata, part.state.input); + } else if (part.state.status !== "error") { + const key = `${message.id}:${part.id}`; + this.names.set(key, part.name); + if (part.name === "shell") this.inputs.set(key, part.state.input); + } + } + } + } + private promote(metadata: { [key: string]: unknown } | undefined, input?: unknown) { + if (metadata?.status === "running" && typeof metadata.shellID === "string") { + this.promoted.add(metadata.shellID); + if ( + input && + typeof input === "object" && + "description" in input && + typeof input.description === "string" && + input.description.trim() + ) + this.descriptions.set(metadata.shellID, input.description.trim()); + } + } + observe(event: OpenCodeEvent) { + if (event.type === "session.revert.committed" && event.data.sessionID === this.id) { + if (this.client) this.start(this.client); + return; + } + if ( + event.type !== "shell.created" && + event.type !== "shell.exited" && + event.type !== "shell.deleted" && + event.type !== "session.tool.input.started" && + event.type !== "session.tool.input.ended" && + event.type !== "session.tool.success" + ) + return; + if ("sessionID" in event.data && event.data.sessionID !== this.id) return; + if (!this.client) return; + if (this.loading) { + this.events.push(event); + return; + } + this.apply(event); + if (event.type !== "session.tool.input.started" && event.type !== "session.tool.input.ended") { + this.dirty = true; + if (!this.retrying) this.refresh(); + } + } + private apply(event: RelevantEvent) { + if (event.type === "session.tool.input.started") + this.names.set(`${event.data.assistantMessageID}:${event.data.id}`, event.data.name); + if ( + event.type === "session.tool.input.ended" && + this.names.get(`${event.data.assistantMessageID}:${event.data.id}`) === "shell" + ) { + try { + this.inputs.set( + `${event.data.assistantMessageID}:${event.data.id}`, + JSON.parse(event.data.text), + ); + } catch { + /* A malformed tool input supplies no presentation metadata. */ + } + } + if ( + event.type === "session.tool.success" && + this.names.get(`${event.data.assistantMessageID}:${event.data.id}`) === "shell" + ) + this.promote( + event.data.metadata, + this.inputs.get(`${event.data.assistantMessageID}:${event.data.id}`), + ); + if (event.type === "session.tool.success") { + this.names.delete(`${event.data.assistantMessageID}:${event.data.id}`); + this.inputs.delete(`${event.data.assistantMessageID}:${event.data.id}`); + } + } + private refresh() { + if (!this.client || this.loading || this.pending) return; + if (this.timer) clearTimeout(this.timer); + this.timer = null; + this.pending = true; + this.dirty = false; + const generation = this.generation; + const client = this.client; + let failed = false; + void client.shell + .list({ location: { directory: this.cwd } }) + .then((result) => { + if (generation !== this.generation || this.dirty) return undefined; + this.publish(result.data); + return undefined; + }) + .catch((error: unknown) => { + if (generation === this.generation) { + failed = true; + this.report(error); + } + }) + .finally(() => { + if (generation !== this.generation) return; + this.pending = false; + if (this.dirty && !failed) this.refresh(); + else if (failed || this.current?.items?.length) this.schedule(() => this.refresh(), failed); + }); + } + private schedule(callback: () => void, retry = false) { + this.retrying = retry; + this.timer = setTimeout(() => { + this.timer = null; + this.retrying = false; + callback(); + }, 5000); + this.timer.unref(); + } + private publish(shells: ShellInfo[]) { + const live = new Set( + shells + .filter((shell) => shell.status === "running" && shell.metadata.sessionID === this.id) + .map((shell) => shell.id), + ); + this.promoted = new Set([...this.promoted].filter((id) => live.has(id))); + for (const id of this.descriptions.keys()) if (!live.has(id)) this.descriptions.delete(id); + this.current = { + type: "background_work", + provider: "opencode", + items: shells + .filter( + (shell) => + shell.status === "running" && + shell.metadata.sessionID === this.id && + this.promoted.has(shell.id), + ) + .map((shell) => ({ + id: shell.id, + kind: "shell", + description: this.descriptions.get(shell.id) ?? null, + command: shell.command || undefined, + canStop: true, + })), + }; + this.emit(this.current); + this.notify(); + } +} diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 9c1197fa21..58981c7086 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -1,3 +1,4 @@ +import { OpenCodeBackgroundInventory } from "./background-inventory.js"; import { SessionChildren } from "./children.js"; import { SessionTurns } from "./turns.js"; import { V2Timeline } from "./timeline.js"; @@ -57,6 +58,7 @@ export class OpenCodeV2Session implements AgentSession { private refreshTimer: ReturnType | null = null; private closed = false; private history: SessionMessageInfo[] = []; + private readonly background: OpenCodeBackgroundInventory; private modes: AgentMode[] = []; constructor( private connection: V2Connection, @@ -70,6 +72,16 @@ export class OpenCodeV2Session implements AgentSession { private readonly acquire?: () => Promise, private readonly moved?: (connection: V2Connection) => void, ) { + this.background = new OpenCodeBackgroundInventory({ + id: info.id, + cwd: config.cwd, + emit: (event) => this.emit(event), + report: (error) => + this.logger.warn( + { error: toDiagnosticErrorMessage(error) }, + "OpenCode background inventory failed", + ), + }); this.permissions = new SessionPermissions( () => this.client, info.id, @@ -143,6 +155,7 @@ export class OpenCodeV2Session implements AgentSession { void connection.exited.then((error) => { if (this.closed || this.connection !== connection) return undefined; this.exited = true; + this.background.disconnect(true); this.streamAbort.abort(error); this.turns.fail(error); return undefined; @@ -242,6 +255,8 @@ export class OpenCodeV2Session implements AgentSession { } subscribe(callback: (event: AgentStreamEvent) => void) { this.listeners.add(callback); + const background = this.background.snapshot(); + if (background) callback(background); // Reopening can adopt an execution during initialization, before its caller // can subscribe. Deliver that live turn so it remains visible and interruptible. if (this.turns.id) @@ -406,6 +421,9 @@ export class OpenCodeV2Session implements AgentSession { steerActiveTurn(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise { return this.turns.steerActiveTurn(prompt, options); } + stopBackgroundWork(id: string) { + return this.background.stopItem(id); + } interrupt() { return this.turns.interrupt(); } @@ -515,9 +533,12 @@ export class OpenCodeV2Session implements AgentSession { if (event.type === "server.connected") { this.timeline.resetStreams(); await this.reconcileConnection(); + signal.throwIfAborted(); + this.background.start(this.client); connected = true; ready(); } + this.background.observe(event); await this.children.observe(event); this.observeOwnEvent(event); } @@ -533,6 +554,7 @@ export class OpenCodeV2Session implements AgentSession { "OpenCode event stream interrupted; reconciling on reconnect", ); } + this.background.disconnect(); await delay(100, undefined, { signal }).catch(() => undefined); } } @@ -573,6 +595,7 @@ export class OpenCodeV2Session implements AgentSession { this.turns.close(); this.streamAbort.abort(); if (this.refreshTimer) clearTimeout(this.refreshTimer); + this.background.disconnect(); await this.stream; await this.reconnecting?.catch(() => undefined); await this.sync.catch(() => undefined); diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index c45d9a5c32..728057e6b7 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -19,6 +19,8 @@ const INBOUND_PERMISSION = { "agent.detach.request": "workspace.write", "agent.fork_context.request": "workspace.read", "agent.provider_subagents.list.request": "workspace.read", + "agent.background_work.list.request": "workspace.read", + "agent.background_work.stop.request": ["workspace.write", "hub.execute"], "agent.provider_subagents.timeline.get.request": "workspace.read", "agent.rewind.request": "workspace.write", "agent.skills.get_status.request": "daemon.read", @@ -251,6 +253,9 @@ const OUTBOUND_PERMISSION = { "agent.provider_subagents.list.response": "workspace.read", "agent.provider_subagents.timeline.get.response": "workspace.read", "agent.provider_subagents.update": "workspace.read", + "agent.background_work.list.response": "workspace.read", + "agent.background_work.stop.response": ["workspace.write", "hub.execute"], + "agent.background_work.update": "workspace.read", "agent.rewind.response": "workspace.write", "agent.skills.get_status.response": "daemon.read", "agent.skills.import_legacy_selection.response": "daemon.manage", diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 1b9c2e973d..36e9a2e961 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -411,6 +411,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { agentManager: asAgentManager({ listAgents: vi.fn(() => []), listProviderSubagentActivity: vi.fn(() => []), + getAgentBackgroundWorkSummary: () => undefined, subscribe: vi.fn(() => () => {}), ...options.agentManager, }), @@ -6105,3 +6106,251 @@ test("a failing direct source observes fire-and-forget file replies without unha rmSync(root, { recursive: true, force: true }); } }); + +test("answers an agent background work list request", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { + waitForAgentClose: vi.fn().mockResolvedValue(undefined), + getAgent: vi.fn(() => ({ id: "agent-a" })), + getAgentBackgroundWorkSummary: () => ({ count: 1 }), + listAgentBackgroundWork: vi.fn(() => [ + { + id: "bash-1", + kind: "shell", + description: "sleep 20", + startedAt: "2026-10-06T10:00:00.000Z", + }, + ]), + }, + }); + + await session.handleMessage({ + type: "agent.background_work.list.request", + agentId: "agent-a", + requestId: "list-1", + }); + + expect(messages).toContainEqual({ + type: "agent.background_work.list.response", + payload: { + requestId: "list-1", + agentId: "agent-a", + items: [ + { + id: "bash-1", + kind: "shell", + description: "sleep 20", + startedAt: "2026-10-06T10:00:00.000Z", + }, + ], + error: null, + }, + }); +}); + +test("reports an unavailable inventory for a stored agent without loading it", async () => { + const messages: SessionOutboundMessage[] = []; + const resumeAgentFromPersistence = vi.fn(); + const createAgent = vi.fn(); + const listAgentBackgroundWork = vi.fn(() => []); + const session = createSessionForTest({ + messages, + agentManager: { + waitForAgentClose: vi.fn().mockResolvedValue(undefined), + getAgent: () => undefined, + getRegisteredProviderIds: vi.fn(() => ["claude"]), + resumeAgentFromPersistence, + createAgent, + listAgentBackgroundWork, + }, + agentStorage: { + get: vi.fn().mockResolvedValue( + createStoredAgentRecord({ + id: "agent-a", + cwd: "/tmp/repo", + provider: "claude", + persistence: { provider: "claude", sessionId: "session-a" }, + }), + ), + }, + }); + + await session.handleMessage({ + type: "agent.background_work.list.request", + agentId: "agent-a", + requestId: "list-stored", + }); + + expect(messages).toContainEqual({ + type: "agent.background_work.list.response", + payload: { + requestId: "list-stored", + agentId: "agent-a", + items: [], + error: "Background work inventory is unavailable", + }, + }); + expect(resumeAgentFromPersistence).not.toHaveBeenCalled(); + expect(createAgent).not.toHaveBeenCalled(); + expect(listAgentBackgroundWork).not.toHaveBeenCalled(); +}); + +test.each([ + { + name: "an archived agent", + record: createStoredAgentRecord({ + id: "agent-a", + cwd: "/tmp/repo", + archivedAt: "2026-10-06T10:00:00.000Z", + }), + error: "Agent is archived: agent-a", + }, + { name: "an unknown agent", record: undefined, error: "Unknown agent 'agent-a'" }, + { + name: "an internal agent", + record: createStoredAgentRecord({ id: "agent-a", cwd: "/tmp/repo", internal: true }), + error: "Unknown agent 'agent-a'", + }, +])("rejects a background work list request for $name that is not loaded", async (scenario) => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForTest({ + messages, + agentManager: { + waitForAgentClose: vi.fn().mockResolvedValue(undefined), + getAgent: () => undefined, + getRegisteredProviderIds: vi.fn(() => ["codex"]), + resumeAgentFromPersistence: vi.fn(), + createAgent: vi.fn(), + }, + agentStorage: { get: vi.fn().mockResolvedValue(scenario.record) }, + }); + + await session.handleMessage({ + type: "agent.background_work.list.request", + agentId: "agent-a", + requestId: "list-rejected", + }); + + expect(messages).toContainEqual({ + type: "agent.background_work.list.response", + payload: { requestId: "list-rejected", agentId: "agent-a", items: [], error: scenario.error }, + }); +}); + +test("never pushes background work to an old app", async () => { + const messages: SessionOutboundMessage[] = []; + const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = []; + const agentEventListeners: Array<(event: AgentManagerEvent) => void> = []; + const session = createSessionForTest({ + messages, + targetedMessages, + agentManager: { + getAgent: vi.fn(() => undefined), + subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { + agentEventListeners.push(listener); + return () => {}; + }), + }, + }); + // provider_subagents makes subagent updates flow to this legacy socket, so the + // manager listener is installed for it. + const legacySocket = {}; + session.updateClientCapabilities({ provider_subagents: true }, legacySocket); + const listener = agentEventListeners[0]; + if (!listener) throw new Error("Agent event listener was not installed"); + + listener({ + type: "background_work", + agentId: "agent-a", + items: [ + { id: "bash-1", kind: "shell", description: null, startedAt: "2026-10-06T10:00:00.000Z" }, + ], + }); + + const pushed = [...messages, ...targetedMessages.map((entry) => entry.message)]; + expect(pushed.some((message) => message.type === "agent.background_work.update")).toBe(false); +}); + +test("pushes background work only to clients that subscribed to it", async () => { + const messages: SessionOutboundMessage[] = []; + const agentEventListeners: Array<(event: AgentManagerEvent) => void> = []; + const session = createSessionForTest({ + messages, + agentManager: { + getAgent: vi.fn(() => undefined), + subscribe: vi.fn((listener: (event: AgentManagerEvent) => void) => { + agentEventListeners.push(listener); + return () => {}; + }), + }, + }); + const forward = (event: AgentManagerEvent) => { + for (const listener of agentEventListeners) listener(event); + }; + const event: AgentManagerEvent = { + type: "background_work", + agentId: "agent-a", + items: [ + { id: "bash-1", kind: "shell", description: null, startedAt: "2026-10-06T10:00:00.000Z" }, + ], + }; + + // Subscribe to an unrelated event so the manager listener is installed, then prove + // a client that did not ask for background work never receives it. + await session.handleMessage({ + type: "session.events.set_subscription.request", + requestId: "events-1", + events: ["agent.provider_subagents.update"], + }); + if (agentEventListeners.length === 0) throw new Error("Agent event listener was not installed"); + forward(event); + expect(messages.some((message) => message.type === "agent.background_work.update")).toBe(false); + + await session.handleMessage({ + type: "session.events.set_subscription.request", + requestId: "events-2", + events: ["agent.background_work.update"], + }); + messages.length = 0; + + forward(event); + + expect(messages).toContainEqual( + expect.objectContaining({ + type: "agent.background_work.update", + payload: expect.objectContaining({ agentId: "agent-a", items: event.items }), + }), + ); +}); + +test("does not publish internal agents' background work to subscribed clients", async () => { + const messages: SessionOutboundMessage[] = []; + let listener: ((event: AgentManagerEvent) => void) | undefined; + const session = createSessionForTest({ + messages, + agentManager: { + getAgent: () => ({ id: "internal-agent", internal: true }) as never, + subscribe: (callback) => { + listener = callback; + return () => {}; + }, + }, + }); + await session.handleMessage({ + type: "session.events.set_subscription.request", + requestId: "subscribe-internal-background", + events: ["agent.background_work.update"], + }); + messages.length = 0; + if (!listener) throw new Error("Agent event listener was not installed"); + listener({ + type: "background_work", + agentId: "internal-agent", + items: [ + { id: "work", kind: "shell", description: "private", startedAt: "2026-10-06T10:00:00.000Z" }, + ], + }); + expect(messages.some((message) => message.type === "agent.background_work.update")).toBe(false); +}); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index c652f70908..83f030d971 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -22,6 +22,7 @@ import { isAbsolute } from "node:path"; import { CreationService, type CreatedAgent } from "./creation/index.js"; import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages"; import type { MessageReceipts } from "./message-receipts/index.js"; +import type { AgentBackgroundWorkItem } from "./agent/background-work/index.js"; import equal from "fast-deep-equal"; import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; @@ -1771,7 +1772,8 @@ export class Session { this.wantsEvent("agent_attention_required") || this.wantsEvent("agent_permission_request") || this.wantsEvent("agent_permission_resolved") || - this.wantsEvent("agent.provider_subagents.update"); + this.wantsEvent("agent.provider_subagents.update") || + this.wantsEvent("agent.background_work.update"); if (agents && !this.unsubscribeAgentEvents) this.subscribeToAgentEvents(); if (!agents) { this.unsubscribeAgentEvents?.(); @@ -2089,14 +2091,12 @@ export class Session { } if (event.type === "provider_subagent") { - // The subagents track of an internal parent stays hidden. - const parentAgentId = - event.event.type === "upsert" - ? event.event.subagent.parentAgentId - : event.event.parentAgentId; - if (this.agentManager.getAgent(parentAgentId)?.internal) return; - this.emitProviderSubagentWorkspaceUpdate(event.event); - this.forwardProviderSubagentUpdate(event.event); + this.forwardProviderSubagent(event.event); + return; + } + + if (event.type === "background_work") { + this.forwardBackgroundWork(event); return; } @@ -2171,6 +2171,26 @@ export class Session { ); } + private forwardProviderSubagent(event: ProviderSubagentManagerEvent): void { + const parentAgentId = + event.type === "upsert" ? event.subagent.parentAgentId : event.parentAgentId; + if (this.agentManager.getAgent(parentAgentId)?.internal) return; + this.emitProviderSubagentWorkspaceUpdate(event); + this.forwardProviderSubagentUpdate(event); + } + + private forwardBackgroundWork( + event: Extract, + ): void { + const agent = this.agentManager.getAgent(event.agentId); + if (agent?.internal) return; + this.emit({ + type: "agent.background_work.update", + payload: { agentId: event.agentId, items: event.items }, + }); + if (agent) void this.agentUpdates.forwardLiveAgent(agent); + } + private emitProviderSubagentWorkspaceUpdate(event: ProviderSubagentManagerEvent): void { if (event.type === "timeline") { return; @@ -2203,6 +2223,7 @@ export class Session { } private async enrichAgentPayload(payload: AgentSnapshotPayload): Promise { + payload.backgroundWork = this.agentManager.getAgentBackgroundWorkSummary(payload.id); const storedRecord = await this.agentStorage.get(payload.id); payload.title = storedRecord?.title ?? null; payload.archivedAt = storedRecord?.archivedAt ?? null; @@ -2967,6 +2988,10 @@ export class Session { return this.handleAgentTimelineListPromptsRequest(msg, source); case "agent.provider_subagents.list.request": return this.handleProviderSubagentListRequest(msg); + case "agent.background_work.list.request": + return this.handleAgentBackgroundWorkListRequest(msg); + case "agent.background_work.stop.request": + return this.handleAgentBackgroundWorkStopRequest(msg); case "agent.provider_subagents.timeline.get.request": return this.handleProviderSubagentTimelineRequest(msg, source); case "session.events.set_subscription.request": { @@ -5858,6 +5883,7 @@ export class Session { pendingPermissionCount: agent.pendingPermissions?.length ?? 0, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason ?? null, + backgroundWork: agent.backgroundWork, }); case "created_at": return Date.parse(agent.createdAt); @@ -8468,6 +8494,75 @@ export class Session { } } + private async handleAgentBackgroundWorkStopRequest( + msg: Extract, + ): Promise { + let error: string | null = null; + try { + await this.agentManager.stopAgentBackgroundWork({ + agentId: msg.agentId, + itemId: msg.itemId, + stopToken: msg.stopToken, + }); + } catch (cause) { + error = cause instanceof Error ? cause.message : String(cause); + } + this.emit({ + type: "agent.background_work.stop.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + itemId: msg.itemId, + error, + }, + }); + } + + private async handleAgentBackgroundWorkListRequest( + msg: Extract, + ): Promise { + try { + this.emit({ + type: "agent.background_work.list.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + items: await this.readAgentBackgroundWork(msg.agentId), + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "agent.background_work.list.response", + payload: { + requestId: msg.requestId, + agentId: msg.agentId, + items: [], + error: error instanceof Error ? error.message : String(error), + }, + }); + } + } + + // Background work lives only in memory, so an agent that is not loaded has none. + // Answering from storage keeps a read from resuming a dormant agent. + private async readAgentBackgroundWork(agentId: string): Promise { + if (this.agentManager.getAgent(agentId)) { + if (!this.agentManager.getAgentBackgroundWorkSummary(agentId)) { + throw new Error("Background work inventory is unavailable"); + } + return this.agentManager.listAgentBackgroundWork(agentId); + } + const record = await this.agentStorage.get(agentId); + if (!record || record.internal) { + throw new Error(`Unknown agent '${agentId}'`); + } + if (record.archivedAt) { + throw new Error(`Agent is archived: ${agentId}`); + } + throw new Error("Background work inventory is unavailable"); + } + private async handleProviderSubagentTimelineRequest( msg: Extract, source?: object, @@ -9189,6 +9284,7 @@ function sessionEventCategory(message: SessionOutboundMessage): SessionEventSubs case "script_status_update": case "workspace_setup_progress": case "agent.provider_subagents.update": + case "agent.background_work.update": case "terminal_attention_required": case "activity_log": case "hub.execution.agent.update": @@ -9227,6 +9323,9 @@ function legacyWantsEvent( return !capabilities.has(CLIENT_CAPS.explicitEventSubscriptions); case "agent.provider_subagents.update": return capabilities.has(CLIENT_CAPS.providerSubagents); + // Old apps parse outbound messages strictly and do not know this type. + case "agent.background_work.update": + return false; default: return true; } diff --git a/packages/server/src/server/test-utils/session-stubs.ts b/packages/server/src/server/test-utils/session-stubs.ts index da61f3495c..89b7fcbcb9 100644 --- a/packages/server/src/server/test-utils/session-stubs.ts +++ b/packages/server/src/server/test-utils/session-stubs.ts @@ -37,6 +37,7 @@ export function asAgentManager(stub: { return createStub({ listProviderSubagentActivity: () => [], getRetiredInternalAgent: () => null, + getAgentBackgroundWorkSummary: () => undefined, ...stub, }); } diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 74188b3d7a..1bbc171f4a 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2149,6 +2149,10 @@ export class VoiceAssistantWebSocketServer { agentForkContextCursor: true, // COMPAT(providerSubagents): added in v0.1.107, remove gate after 2027-01-12. providerSubagents: true, + // COMPAT(agentBackgroundWork): added after v0.11.0-beta.5, remove gate after 2027-04-06. + agentBackgroundWork: true, + // COMPAT(agentBackgroundWorkStop): added after v0.15.0, remove after 2027-05-03. + agentBackgroundWorkStop: true, // COMPAT(projectedSubagentTimeline): added after v0.8.0, remove gates after 2027-03-14; retain advertisement. projectedSubagentTimeline: true, wholeTurnTimeline: true, diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 9e563ec40b..a0de895810 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -114,6 +114,10 @@ class InMemoryAgentManager { }; } + getAgentBackgroundWorkSummary() { + return undefined; + } + fetchTimeline(_agentId: string, options?: AgentTimelineFetchOptions) { return this.timeline.fetch("agent-1", options); } diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 85e8aeb600..5e1cb88dc7 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -273,6 +273,7 @@ interface AgentState { pendingPermissionCount?: number; requiresAttention?: boolean; attentionReason?: AgentSnapshotPayload["attentionReason"]; + backgroundWork?: AgentSnapshotPayload["backgroundWork"]; background?: boolean; } @@ -293,6 +294,7 @@ function createAgent( updatedAt: NOW, lastUserMessageAt: input.lastUserMessageAt ?? null, status: input.status, + backgroundWork: input.backgroundWork, capabilities: { supportsStreaming: true, supportsSessionPersistence: true, @@ -324,6 +326,17 @@ function createAgent( } describe("WorkspaceDirectory", () => { + test("treats background work as running without changing foreground status", async () => { + const workspace = new WorkspaceStatus(); + workspace.hasRootAgent({ + id: "root-agent", + status: "idle", + backgroundWork: { count: 1 }, + requiresAttention: true, + attentionReason: "finished", + }); + await expect(workspace.workspaceStatus()).resolves.toBe("running"); + }); test("uses root agent activity, not delegated child activity, for workspace status", async () => { const workspace = new WorkspaceStatus(); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 64f544e6f8..ebab30916f 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -392,7 +392,7 @@ export class WorkspaceDirectory { continue; } const isWorkspaceRoot = workspaceAgent.id === agent.id; - if (!isWorkspaceRoot && agent.status !== "running") { + if (!isWorkspaceRoot && agent.status !== "running" && !agent.backgroundWork?.count) { continue; } const bucket = isWorkspaceRoot @@ -401,6 +401,7 @@ export class WorkspaceDirectory { pendingPermissionCount: agent.pendingPermissions?.length ?? 0, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason ?? null, + backgroundWork: agent.backgroundWork, }) : "running"; @@ -550,6 +551,7 @@ export class WorkspaceDirectory { pendingPermissionCount: agent.pendingPermissions?.length ?? 0, requiresAttention: agent.requiresAttention, attentionReason: agent.attentionReason ?? null, + backgroundWork: agent.backgroundWork, }); return derived === winningBucket; }) diff --git a/packages/server/src/terminal/terminal-session-controller.test.ts b/packages/server/src/terminal/terminal-session-controller.test.ts index ccb152aa23..d91eae6f06 100644 --- a/packages/server/src/terminal/terminal-session-controller.test.ts +++ b/packages/server/src/terminal/terminal-session-controller.test.ts @@ -93,6 +93,10 @@ function createLogger(): pino.Logger { } as unknown as pino.Logger; } +function decodedTerminalFrames(frames: TerminalStreamFrame[]): string { + return frames.map((frame) => new TextDecoder().decode(frame.payload)).join(""); +} + describe("terminal-session-controller restore", () => { test("delivers output produced while restore is in flight after the restore frame", async () => { let terminalListener: ((message: ServerMessage) => void) | null = null; @@ -172,7 +176,10 @@ describe("terminal-session-controller restore", () => { terminalListener?.({ type: "output", data: "restore-after\n", revision: 2 }); snapshot.resolve(terminalReplay("restore-before", 1, "\x1b[?1h\x1b[?2004h")); await snapshot.promise; - await new Promise((resolve) => setTimeout(resolve, 20)); + await vi.waitFor(() => { + const output = decodedTerminalFrames(binaryFrames.slice(1)); + expect(output).toBe("\x1b[?1h\x1b[?2004hrestore-after\n"); + }); expect(outboundMessages).toContainEqual({ type: "subscribe_terminal_response", @@ -183,14 +190,12 @@ describe("terminal-session-controller restore", () => { requestId: "req-1", }, }); - expect(binaryFrames.map((frame) => frame.opcode)).toEqual([ - TerminalStreamOpcode.Restore, - TerminalStreamOpcode.Output, - ]); + expect(binaryFrames[0]?.opcode).toBe(TerminalStreamOpcode.Restore); + const outputFrames = binaryFrames.slice(1); + expect(outputFrames.length).toBeGreaterThan(0); + expect(outputFrames.every((frame) => frame.opcode === TerminalStreamOpcode.Output)).toBe(true); expect(new TextDecoder().decode(binaryFrames[0]?.payload)).toContain("restore-before"); - expect(new TextDecoder().decode(binaryFrames[1]?.payload)).toBe( - "\x1b[?1h\x1b[?2004hrestore-after\n", - ); + expect(decodedTerminalFrames(outputFrames)).toBe("\x1b[?1h\x1b[?2004hrestore-after\n"); }); }); diff --git a/packages/website/e2e/agent-icons.spec.ts b/packages/website/e2e/agent-icons.spec.ts index 4bc828475e..27418a7c48 100644 --- a/packages/website/e2e/agent-icons.spec.ts +++ b/packages/website/e2e/agent-icons.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Page } from "./fixtures"; test("keeps unrelated agent artwork unchanged while adding the Hermes mark", async ({ page }) => { await openSupportedAgents(page); diff --git a/packages/website/e2e/download-thanks.spec.ts b/packages/website/e2e/download-thanks.spec.ts index bc33f8f087..dbbfe69a86 100644 --- a/packages/website/e2e/download-thanks.spec.ts +++ b/packages/website/e2e/download-thanks.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Page } from "./fixtures"; const APPLE_SILICON_DMG = /\/getpaseo\/paseo\/releases\/download\/v[^/]+\/Paseo-[^/]+-arm64\.dmg$/; diff --git a/packages/website/e2e/fixtures.ts b/packages/website/e2e/fixtures.ts new file mode 100644 index 0000000000..a0d0ed2e8f --- /dev/null +++ b/packages/website/e2e/fixtures.ts @@ -0,0 +1,18 @@ +import { test as base } from "playwright/test"; + +export { expect } from "playwright/test"; +export type { Locator, Page } from "playwright/test"; + +export const test = base.extend({ + page: async ({ page, baseURL }, run) => { + page.on("response", async (response) => { + if (response.status() < 500 || new URL(response.url()).origin !== new URL(baseURL!).origin) + return; + const body = await response.text().catch(() => ""); + console.error( + `Website HTTP ${response.status()} ${response.url()}\n${JSON.stringify(body.slice(0, 4000))}`, + ); + }); + await run(page); + }, +}); diff --git a/packages/website/e2e/i18n.spec.ts b/packages/website/e2e/i18n.spec.ts index d235c4209e..02d17e92cb 100644 --- a/packages/website/e2e/i18n.spec.ts +++ b/packages/website/e2e/i18n.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Page } from "playwright/test"; +import { expect, test, type Page } from "./fixtures"; function languageButton(page: Page) { return page.getByRole("button", { name: /^(Language|语言)/ }).first(); diff --git a/packages/website/e2e/mockups.spec.ts b/packages/website/e2e/mockups.spec.ts index 41570439bf..82a440fa1c 100644 --- a/packages/website/e2e/mockups.spec.ts +++ b/packages/website/e2e/mockups.spec.ts @@ -1,4 +1,4 @@ -import { expect, test, type Locator, type Page } from "playwright/test"; +import { expect, test, type Locator, type Page } from "./fixtures"; const desktopName = "Paseo desktop app with coding agents, a conversation, and a code diff open side by side"; diff --git a/packages/website/e2e/plugins.spec.ts b/packages/website/e2e/plugins.spec.ts index 22baf9e7f1..27d4a55c4a 100644 --- a/packages/website/e2e/plugins.spec.ts +++ b/packages/website/e2e/plugins.spec.ts @@ -1,5 +1,5 @@ import securityPlugin from "./overview.fixture.json" with { type: "json" }; -import { expect, test, type Page, type Locator } from "playwright/test"; +import { expect, test, type Page, type Locator } from "./fixtures"; import registry from "./registry.fixture.json" with { type: "json" }; import { CATEGORIES } from "../src/plugins/categories"; import copy from "../src/i18n/resources/en.json" with { type: "json" }; diff --git a/packages/website/e2e/seed-cache.mjs b/packages/website/e2e/seed-cache.mjs new file mode 100644 index 0000000000..67990be289 --- /dev/null +++ b/packages/website/e2e/seed-cache.mjs @@ -0,0 +1,54 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; + +const state = process.env.WEBSITE_E2E_PERSIST_PATH; +if (!state) throw new Error("WEBSITE_E2E_PERSIST_PATH is required"); +// Keep these local test records fresh for a full test run, even after the normal cache TTL. +const fixtureTimestamp = Date.now() + 24 * 60 * 60 * 1000; +const entries = [ + { key: "github-stars:v1", value: "12.3k" }, + { + key: "github-release:v2", + value: { + stable: { + version: "0.15.0", + linuxAppImageAsset: "Paseo-x86_64.AppImage", + windowsX64Asset: "Paseo-Setup-0.15.0-x64.exe", + windowsArm64Asset: "Paseo-Setup-0.15.0-arm64.exe", + }, + beta: null, + }, + }, +]; +mkdirSync(state, { recursive: true }); +const file = path.join(state, "fixtures.json"); +writeFileSync( + file, + JSON.stringify( + entries.map(({ key, value }) => ({ + key, + value: JSON.stringify({ fetchedAt: fixtureTimestamp, value }), + })), + ), +); +execFileSync( + process.execPath, + [ + path.join( + path.dirname(createRequire(import.meta.url).resolve("wrangler/package.json")), + "bin/wrangler.js", + ), + "kv", + "bulk", + "put", + file, + "--binding", + "WEBSITE_CACHE", + "--local", + "--persist-to", + state, + ], + { stdio: "inherit" }, +); diff --git a/packages/website/e2e/sponsors.spec.ts b/packages/website/e2e/sponsors.spec.ts index 7b42316d54..4aa15ec413 100644 --- a/packages/website/e2e/sponsors.spec.ts +++ b/packages/website/e2e/sponsors.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "playwright/test"; +import { expect, test } from "./fixtures"; const PAGES = ["/", "/docs/connectivity", "/changelog"]; diff --git a/packages/website/playwright.config.ts b/packages/website/playwright.config.ts index ebb29f0237..49e058ec32 100644 --- a/packages/website/playwright.config.ts +++ b/packages/website/playwright.config.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { defineConfig, devices } from "playwright/test"; const baseURL = process.env.WEBSITE_TEST_URL ?? "http://127.0.0.1:8187"; @@ -18,11 +19,16 @@ export default defineConfig({ url: "http://127.0.0.1:8188/index.json", }, { - command: "npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", + command: + "node e2e/seed-cache.mjs && npm run build && npm run preview -- --host 127.0.0.1 --port 8187 --strictPort", // Probe the server without running the homepage loader's live GitHub requests. url: new URL("/logo.svg", baseURL).href, stdout: "pipe", env: { + WEBSITE_E2E_PERSIST_PATH: path.resolve( + import.meta.dirname, + "../../.artifacts/website-e2e-cache", + ), CLOUDFLARE_INCLUDE_PROCESS_ENV: "true", PLUGINS_REGISTRY_URL: "http://127.0.0.1:8188", }, diff --git a/packages/website/vite.config.ts b/packages/website/vite.config.ts index 778378167b..fcdca7e610 100644 --- a/packages/website/vite.config.ts +++ b/packages/website/vite.config.ts @@ -129,7 +129,12 @@ export default defineConfig((): UserConfig => { }, }, plugins: [ - cloudflare({ viteEnvironment: { name: "ssr" } }), + cloudflare({ + viteEnvironment: { name: "ssr" }, + ...(process.env.WEBSITE_E2E_PERSIST_PATH + ? { persistState: { path: process.env.WEBSITE_E2E_PERSIST_PATH } } + : {}), + }), tsConfigPaths({ projects: [path.join(__dirname, "tsconfig.json")] }), tanstackStart({ router: { diff --git a/public-docs/sdk/events.md b/public-docs/sdk/events.md index 9762323dce..81f077a9e1 100644 --- a/public-docs/sdk/events.md +++ b/public-docs/sdk/events.md @@ -42,6 +42,25 @@ await directory.subscription.release(); `client.agents.subscribe()` and agent-handle `subscribe()` add local listeners to observations owned by that API instance. They do not request data. Use the returned subscription's callbacks when multiple filtered views need separate updates. +## Follow an agent's background work + +Background work is what an agent left running after its tool call returned: a Claude shell started with `run_in_background`, a `Monitor` watch, or another task that is not a subagent. Subagents and workflows stay in the agent's subagents. + +```ts +const agent = client.agents.ref(agentId); + +const unsubscribe = agent.backgroundWork.subscribe((items) => render(items)); + +// When this view closes: +unsubscribe(); +``` + +`subscribe()` calls the listener with the current list once the subscription is established, again after each reconnect, then with the whole list on every change. Use `list()` for a one-shot read. Each item has `id`, `kind` (`"shell"`, `"monitor"`, or `"other"`; treat unknown values as `"other"`), `description` (or `null`), optional `command`, and `startedAt`, the time Paseo first observed it. Inventory is scoped to the current provider session and is not persisted. When its runtime is lost, its old items are discarded. An unavailable inventory is reported as an error rather than an empty list. + +Pass `subscribe(listener, { onError })` to receive setup or list errors. After a setup failure, create a new subscription; after a list failure, the list is read again on the next reconnect. Without `onError`, the error is logged. + +`list()` rejects and `subscribe()` throws when the host does not advertise `features.agentBackgroundWork`; ask the user to update the host. For an item with `canStop`, call `backgroundWork.stop(item)` to wait for provider confirmation. Stop requires `features.agentBackgroundWorkStop`. + ## Follow timeline events ```ts diff --git a/public-docs/sdk/events.zh-CN.md b/public-docs/sdk/events.zh-CN.md index 190d4c9a48..69e8e8c5ad 100644 --- a/public-docs/sdk/events.zh-CN.md +++ b/public-docs/sdk/events.zh-CN.md @@ -40,6 +40,25 @@ await directory.subscription.release(); `client.agents.subscribe()` 和 Agent 句柄上的 `subscribe()` 会为该 API 实例持有的观察添加本地监听器。它们不会请求数据。当多个带过滤条件的视图需要各自的更新时,请使用返回的订阅上的回调。 +## 跟踪 Agent 的后台工作 {#follow-an-agents-background-work} + +后台工作是 Agent 的工具调用返回后仍在运行的工作,例如使用 `run_in_background` 启动的 Claude shell 或 `Monitor` 监控。子 Agent 和 workflow 显示在子 Agent 中。 + +```ts +const agent = client.agents.ref(agentId); + +const unsubscribe = agent.backgroundWork.subscribe((items) => render(items)); + +// When this view closes: +unsubscribe(); +``` + +订阅建立后,`subscribe()` 会向监听器投递当前列表;每次重连后和每次变化时,也会投递完整列表。用 `list()` 执行单次读取。每项包含 `id`、`kind`(`"shell"`、`"monitor"` 或 `"other"`,未知值按 `"other"` 处理)、可为 `null` 的 `description`、可选的 `command`,以及 Paseo 首次观察到该项的时间 `startedAt`。列表属于当前 Provider 会话,不会持久化。运行时丢失后,旧项会被清除。列表不可用时会报告错误,而不是返回空列表。 + +传入 `subscribe(listener, { onError })` 可接收读取当前列表失败的错误;此时不会调用监听器,下次重连时会重新读取。未提供 `onError` 时会记录错误。 + +主机未声明 `features.agentBackgroundWork` 时,`list()` 会 reject,`subscribe()` 会抛出错误;请让用户更新主机。对具有 `canStop` 的项目调用 `backgroundWork.stop(item)`,等待提供方确认。停止操作需要 `features.agentBackgroundWorkStop`。 + ## 跟踪时间线事件 {#follow-timeline-events} ```ts diff --git a/public-docs/sdk/reference.md b/public-docs/sdk/reference.md index 6919cc5aa0..5a095b2c2a 100644 --- a/public-docs/sdk/reference.md +++ b/public-docs/sdk/reference.md @@ -80,32 +80,35 @@ workspace the call creates is background too. With a `parent` and no value, the ### Agent handle -| Member | Result | Behavior | -| ------------------------------ | --------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `id` | `string` | Stable daemon agent ID. | -| `address` | `AgentAddress` | Complete `{ serverId, agentId }` identity; requires a connected host to establish it. | -| `workspaceId` | `string \| null` | Current workspace placement. | -| `cwd` | `string \| null` | Current working directory. | -| `status` | Agent status or `null` | Current lifecycle status. | -| `capabilities` | Capability flags or `null` | What the provider session supports. | -| `availableModes` | Agent modes or `null` | Modes the session can switch to. | -| `pendingPermissions` | Permission requests or `null` | Requests waiting on an answer. | -| `activeTurn` | Active turn or `null` | The turn in flight, with `turnId` and `startedAt`. | -| `lastUsage` | Usage or `null` | Token counts, cost, and context-window use from the last turn. | -| `lastError` | `string \| null` | Last error the daemon recorded for the agent. | -| `features` | Agent features or `null` | Provider feature toggles and selects with their current values. | -| `runtimeInfo` | Runtime info or `null` | Live provider, session ID, model, thinking option, and mode. | -| `archivedAt` | `string \| null` | Archive timestamp; `null` while the agent is active. | -| `current()` | `PaseoAgent \| null` | Current detailed value observed by this handle; never fetches. | -| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | Fetches the current agent and project placement. | -| `send(text, options?)` | `Promise` | Resolves when the daemon accepts the prompt. | -| `respondToPermission(options)` | `Promise` | Answers a pending permission by `requestId` with an allow or deny `response`. | -| `run(text, options?)` | `PaseoAgentRunResult` | Sends a prompt and waits for that turn. `timeoutMs` controls the wait; it defaults to 10 minutes. | -| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | Waits for the active turn, including an initial prompt. Default timeout: 10 minutes. | -| `commands(options?)` | `PaseoAgentCommandsResult` | Asks the live session for its slash commands and skills, including built-in ones. Options: `requestId`. | -| `subscribe(handler)` | Unsubscribe function | Filters agent-directory updates to this ID and refreshes the handle properties. | -| `archive()` | `{ archivedAt }` | Soft-deletes the agent and closes its runtime. | -| `detach()` | `Promise` | Removes the parent relationship without stopping the agent. | +| Member | Result | Behavior | +| ---------------------------------------------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `id` | `string` | Stable daemon agent ID. | +| `address` | `AgentAddress` | Complete `{ serverId, agentId }` identity; requires a connected host to establish it. | +| `workspaceId` | `string \| null` | Current workspace placement. | +| `cwd` | `string \| null` | Current working directory. | +| `status` | Agent status or `null` | Current lifecycle status. | +| `capabilities` | Capability flags or `null` | What the provider session supports. | +| `availableModes` | Agent modes or `null` | Modes the session can switch to. | +| `pendingPermissions` | Permission requests or `null` | Requests waiting on an answer. | +| `activeTurn` | Active turn or `null` | The turn in flight, with `turnId` and `startedAt`. | +| `lastUsage` | Usage or `null` | Token counts, cost, and context-window use from the last turn. | +| `lastError` | `string \| null` | Last error the daemon recorded for the agent. | +| `features` | Agent features or `null` | Provider feature toggles and selects with their current values. | +| `runtimeInfo` | Runtime info or `null` | Live provider, session ID, model, thinking option, and mode. | +| `archivedAt` | `string \| null` | Archive timestamp; `null` while the agent is active. | +| `current()` | `PaseoAgent \| null` | Current detailed value observed by this handle; never fetches. | +| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | Fetches the current agent and project placement. | +| `send(text, options?)` | `Promise` | Resolves when the daemon accepts the prompt. | +| `respondToPermission(options)` | `Promise` | Answers a pending permission by `requestId` with an allow or deny `response`. | +| `run(text, options?)` | `PaseoAgentRunResult` | Sends a prompt and waits for that turn. `timeoutMs` controls the wait; it defaults to 10 minutes. | +| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | Waits for the active turn, including an initial prompt. Default timeout: 10 minutes. | +| `commands(options?)` | `PaseoAgentCommandsResult` | Asks the live session for its slash commands and skills, including built-in ones. Options: `requestId`. | +| `subscribe(handler)` | Unsubscribe function | Filters agent-directory updates to this ID and refreshes the handle properties. | +| `archive()` | `{ archivedAt }` | Soft-deletes the agent and closes its runtime. | +| `detach()` | `Promise` | Removes the parent relationship without stopping the agent. | +| `backgroundWork.list(options?)` | `Promise` | Reads the agent's current background work once. Requires `features.agentBackgroundWork`. Options: `requestId`, `timeout`. | +| `backgroundWork.stop(item, options?)` | `Promise` | Stops a stoppable item and waits for provider confirmation. Pass the item returned by `list` or `subscribe`. Requires `features.agentBackgroundWorkStop`. | +| `backgroundWork.subscribe(listener, options?)` | `() => void` | Calls `listener` with the current list, again after each reconnect, then with the full list on every change. `onError` receives a failed list read. Requires `features.agentBackgroundWork`. Returns the unsubscribe function. | `workspaceId` through `archivedAt` mirror the last snapshot the handle observed. A handle from `ref()` reads `null` for all of them until `refresh()`, `run()`, `waitForFinish()`, a timeline refetch, or `subscribe()` delivers a snapshot. Optional values in an observed snapshot also read as `null`. Call `current()` when you need the whole snapshot or need to distinguish those states. diff --git a/public-docs/sdk/reference.zh-CN.md b/public-docs/sdk/reference.zh-CN.md index c7848240c4..5879e9cab9 100644 --- a/public-docs/sdk/reference.zh-CN.md +++ b/public-docs/sdk/reference.zh-CN.md @@ -76,32 +76,35 @@ nav: API 参考 ### Agent 句柄 {#agent-handle} -| 成员 | 结果 | 行为 | -| ------------------------------ | --------------------------------- | ----------------------------------------------------------------------- | -| `id` | `string` | 稳定的 daemon Agent ID。 | -| `address` | `AgentAddress` | 完整的 `{ serverId, agentId }` 身份;需要连接到主机才能确定。 | -| `workspaceId` | `string \| null` | 当前所在的工作区。 | -| `cwd` | `string \| null` | 当前工作目录。 | -| `status` | Agent 状态或 `null` | 当前的生命周期状态。 | -| `capabilities` | 能力标志或 `null` | Provider 会话支持的能力。 | -| `availableModes` | Agent 模式或 `null` | 会话可以切换到的模式。 | -| `pendingPermissions` | 权限请求或 `null` | 等待回应的请求。 | -| `activeTurn` | 当前轮次或 `null` | 正在进行的轮次,包含 `turnId` 和 `startedAt`。 | -| `lastUsage` | 用量或 `null` | 上一轮的 token 数、费用和上下文窗口用量。 | -| `lastError` | `string \| null` | daemon 为该 Agent 记录的最后一个错误。 | -| `features` | Agent 功能或 `null` | Provider 的功能开关和选择项及其当前值。 | -| `runtimeInfo` | 运行时信息或 `null` | 实时的 Provider、会话 ID、模型、思考选项和模式。 | -| `archivedAt` | `string \| null` | 归档时间戳;Agent 处于活动状态时为 `null`。 | -| `current()` | `PaseoAgent \| null` | 此句柄观察到的当前详细值;从不发起请求。 | -| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | 获取当前的 Agent 及其所在项目。 | -| `send(text, options?)` | `Promise` | 在 daemon 接受提示词后 resolve。 | -| `respondToPermission(options)` | `Promise` | 按 `requestId` 以允许或拒绝的 `response` 回应一个待处理的权限请求。 | -| `run(text, options?)` | `PaseoAgentRunResult` | 发送提示词并等待该轮结束。`timeoutMs` 控制等待时间,默认 10 分钟。 | -| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | 等待当前轮次结束,包括初始提示词。默认超时:10 分钟。 | -| `commands(options?)` | `PaseoAgentCommandsResult` | 向正在运行的会话查询它的斜杠命令和技能,包括内置的。选项:`requestId`。 | -| `subscribe(handler)` | 取消订阅函数 | 把 Agent 目录更新过滤到此 ID,并刷新句柄的属性。 | -| `archive()` | `{ archivedAt }` | 软删除 Agent 并关闭它的运行时。 | -| `detach()` | `Promise` | 解除父子关系,但不停止 Agent。 | +| 成员 | 结果 | 行为 | +| ---------------------------------------------- | ----------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | 稳定的 daemon Agent ID。 | +| `address` | `AgentAddress` | 完整的 `{ serverId, agentId }` 身份;需要连接到主机才能确定。 | +| `workspaceId` | `string \| null` | 当前所在的工作区。 | +| `cwd` | `string \| null` | 当前工作目录。 | +| `status` | Agent 状态或 `null` | 当前的生命周期状态。 | +| `capabilities` | 能力标志或 `null` | Provider 会话支持的能力。 | +| `availableModes` | Agent 模式或 `null` | 会话可以切换到的模式。 | +| `pendingPermissions` | 权限请求或 `null` | 等待回应的请求。 | +| `activeTurn` | 当前轮次或 `null` | 正在进行的轮次,包含 `turnId` 和 `startedAt`。 | +| `lastUsage` | 用量或 `null` | 上一轮的 token 数、费用和上下文窗口用量。 | +| `lastError` | `string \| null` | daemon 为该 Agent 记录的最后一个错误。 | +| `features` | Agent 功能或 `null` | Provider 的功能开关和选择项及其当前值。 | +| `runtimeInfo` | 运行时信息或 `null` | 实时的 Provider、会话 ID、模型、思考选项和模式。 | +| `archivedAt` | `string \| null` | 归档时间戳;Agent 处于活动状态时为 `null`。 | +| `current()` | `PaseoAgent \| null` | 此句柄观察到的当前详细值;从不发起请求。 | +| `refresh(requestId?)` | `PaseoAgentRefetchResult \| null` | 获取当前的 Agent 及其所在项目。 | +| `send(text, options?)` | `Promise` | 在 daemon 接受提示词后 resolve。 | +| `respondToPermission(options)` | `Promise` | 按 `requestId` 以允许或拒绝的 `response` 回应一个待处理的权限请求。 | +| `run(text, options?)` | `PaseoAgentRunResult` | 发送提示词并等待该轮结束。`timeoutMs` 控制等待时间,默认 10 分钟。 | +| `waitForFinish(timeoutMs?)` | `PaseoAgentRunResult` | 等待当前轮次结束,包括初始提示词。默认超时:10 分钟。 | +| `commands(options?)` | `PaseoAgentCommandsResult` | 向正在运行的会话查询它的斜杠命令和技能,包括内置的。选项:`requestId`。 | +| `subscribe(handler)` | 取消订阅函数 | 把 Agent 目录更新过滤到此 ID,并刷新句柄的属性。 | +| `archive()` | `{ archivedAt }` | 软删除 Agent 并关闭它的运行时。 | +| `detach()` | `Promise` | 解除父子关系,但不停止 Agent。 | +| `backgroundWork.list(options?)` | `Promise` | 单次读取 Agent 当前的后台工作。需要 `features.agentBackgroundWork`;选项为 `requestId` 和 `timeout`。 | +| `backgroundWork.stop(item, options?)` | `Promise` | 停止可停止的项目并等待提供方确认。传入 `list` 或 `subscribe` 返回的项目。需要 `features.agentBackgroundWorkStop`。 | +| `backgroundWork.subscribe(listener, options?)` | `() => void` | 向监听器投递当前列表,并在每次重连和变化时投递完整列表。`onError` 接收读取失败的错误;需要 `features.agentBackgroundWork`。返回取消订阅函数。 | 从 `workspaceId` 到 `archivedAt` 的属性反映句柄最近观察到的快照。由 `ref()` 得到的句柄在 `refresh()`、`run()`、`waitForFinish()`、时间线重新获取或 `subscribe()` 投递快照之前,这些属性都读作 `null`。已观察到的快照中的可选值也读作 `null`。当你需要整个快照,或需要区分这些状态时,请调用 `current()`。