mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-08 01:14:52 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f75bd144a |
+11
-30
@@ -183,42 +183,23 @@ Some providers can create their own child sessions inside one provider runtime.
|
||||
|
||||
The provider still owns the underlying runtime. Paseo keeps an agent record so the child can be opened, tracked, archived, and cascaded with the parent, but prompts and history hydration route through the provider adapter for that native child handle.
|
||||
|
||||
## Background visibility
|
||||
## Background workspaces
|
||||
|
||||
An agent is effectively background when it or its workspace is background:
|
||||
`agent.background || workspace.background`. Background controls discovery only. Background agents
|
||||
and the agents in background workspaces are ordinary durable agents: provider history, plugin
|
||||
hooks, parentage, finish notifications to a caller, archive, and restart recovery keep their
|
||||
normal behavior. Both flags are fixed at creation.
|
||||
`workspace.background` controls discovery only. Background workspaces contain ordinary durable
|
||||
agents and terminals: provider history, plugin hooks, parentage, archive, and restart recovery
|
||||
keep their normal behavior. Visibility is creation-only; selecting an existing workspace cannot
|
||||
change it. New workspaces default to false, inherit the caller workspace's value when an agent
|
||||
creates one without an override, and accept explicit true or false.
|
||||
|
||||
- **Agent.** `create_agent_request.background` marks the agent. Use it for helpers the user does
|
||||
not watch, such as a plugin's classifier in the user's open workspace. A background agent never
|
||||
raises attention, adds nothing to its workspace's status bucket or Last prompt time, and stays
|
||||
out of tabs, the parent's subagent track and agent lists in the app. A tab opened by id still
|
||||
works.
|
||||
- **Workspace.** `workspace.background` hides the workspace from the sidebar unless **Show
|
||||
background** is on.
|
||||
- **Inheritance.** When `background` is omitted, an agent created by a caller agent takes the
|
||||
caller's own `background`. A new workspace takes the caller's effective background. A request
|
||||
that creates its workspace applies `background` to both the agent and the workspace. Explicit
|
||||
true or false always wins.
|
||||
|
||||
`AgentDirectory` (`packages/server/src/server/agent/directory.ts`) owns visibility for lists.
|
||||
Default workspace and agent lists, including History requests, exclude background work;
|
||||
`includeBackground` includes it. Exact-ID operations bypass discovery filtering. The app
|
||||
replicates inclusively and filters at each surface.
|
||||
|
||||
Sources without the `background_agents` hello capability get workspace-only visibility on
|
||||
inclusive reads and subscriptions: agents in background workspaces, but no agent-level background
|
||||
agents. Older apps replicate inclusively and auto-open a tab for every root agent, so a classifier
|
||||
run would otherwise open a tab each turn. That visibility has its own directory sync generation.
|
||||
The daemon derives agent discovery from workspace ownership. Default workspace and agent lists,
|
||||
including History requests, exclude background work; `filter.includeBackground` includes it.
|
||||
Exact-ID operations bypass discovery filtering. The app replicates both kinds and filters only
|
||||
its sidebar with **Show background**, so direct navigation and restored tabs remain available.
|
||||
|
||||
Sequenced directories keep complete canonical collections. A filtered read must never replace
|
||||
those collections with its subset. Inclusive and default observations have distinct cursor
|
||||
scopes, so changing scope requires a fresh snapshot rather than reusing an incomplete cache.
|
||||
Clients gate background workspaces and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
They gate a background agent placed in an existing workspace or under a caller on
|
||||
`features.backgroundAgents`.
|
||||
Clients gate creation and inclusive discovery on `features.backgroundWorkspaces`.
|
||||
|
||||
### Private daemon helpers
|
||||
|
||||
|
||||
+1
-5
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
||||
|
||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||
|
||||
## Inverted timeline input
|
||||
## Inverted timeline selection
|
||||
|
||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||
@@ -183,10 +183,6 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
|
||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||
|
||||
Mouse wheel axes also bypass the inversion transform. The same module reverses the vertical
|
||||
wheel axis for inverted scroll views before delegating to Android, preserving native scroll
|
||||
bounds and React Native's scroll events and disabled-scroll behavior.
|
||||
|
||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
||||
|
||||
@@ -777,8 +777,6 @@ The built-in `claude` provider appends concrete model IDs from `~/.claude/settin
|
||||
|
||||
This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top.
|
||||
|
||||
Paseo's effort selector does not inherit `effortLevel` from Claude's settings.json. Select an effort in Paseo to choose a specific value.
|
||||
|
||||
### Gotcha: `extends: "claude"` with third-party endpoints
|
||||
|
||||
When a custom provider extends `"claude"` but points `ANTHROPIC_BASE_URL` at a non-Anthropic API (Z.AI, Alibaba/Qwen, proxies), the Claude Agent SDK may try to use Anthropic-only server-side tools like `WebSearch`. Third-party APIs don't support these tools, causing errors.
|
||||
|
||||
+1
-2
@@ -593,8 +593,7 @@ rerun passes only by landing elsewhere.
|
||||
|
||||
To confirm, fetch the URL from several runners and compare `content-length`, the `cf-ray` colo
|
||||
suffix, and the number of `attestations` entries (#7730 has the probe). To fix, remove or
|
||||
replace that version in the lockfile. If the dependent’s range cannot be changed, pin a clean
|
||||
version in root `overrides` and regenerate the lockfile. Report the URL to npm so they purge it.
|
||||
replace that version in the lockfile and report the URL to npm so they purge it.
|
||||
|
||||
## ACP provider catalog versions
|
||||
|
||||
|
||||
+1
-2
@@ -96,8 +96,7 @@ hidden workspace can overwrite the remembered workspace before Settings or
|
||||
History returns.
|
||||
|
||||
Plugin settings use the distinct `settings/hosts/[serverId]/plugins/[pluginId]/[screenId]` leaf;
|
||||
Back returns to that host's Plugins page. Its screen tabs change `screenId` with `router.setParams`,
|
||||
so switching tabs adds no stack entries.
|
||||
Back returns to that host's Plugins page.
|
||||
|
||||
Settings detail routes are separate siblings on purpose. Keep
|
||||
`settings/[section]`, the host routes, the projects index, and project detail as
|
||||
|
||||
@@ -28,7 +28,6 @@ Authoritative terminology. UI label wins. Don't invent synonyms; use what's here
|
||||
- **Workspace-owned state** — Per-workspace state that never leaks to a same-`cwd` sibling: tabs, agents, terminals, panes, title, plus review drafts, diff-mode overrides, composer attachments, and file-explorer open/expand state. Keyed by `workspaceId` (`cwd` only as a fallback for old payloads). See [architecture.md](architecture.md#right-sidebar-boundary-directory-backed-vs-workspace-owned).
|
||||
- **Last prompt** — Sidebar workspace sort by the most recent human prompt recorded by an agent in that workspace, including archived agents. Agent output, agent-to-agent messages, scheduled prompts, and opening a workspace do not count. A workspace without a human prompt uses its creation time.
|
||||
- **Workspace status bucket** — Aggregate activity signal for a workspace row. Same-`cwd` workspaces intentionally share agent and terminal status buckets, while tab, agent, and terminal visibility remains scoped by `workspaceId`.
|
||||
- **Background agent** — An agent hidden from default discovery and from app surfaces that announce agents: tabs, the subagent track, agent lists, workspace status and attention. It is otherwise an ordinary durable agent, addressable by id. An agent is effectively background when it or its workspace is background. Fixed at creation. Code: `background` on `AgentSnapshotPayload` and `create_agent_request`. See [agent-lifecycle.md](agent-lifecycle.md#background-visibility). Don't confuse with background work (provider shells and monitors) or the daemon's private internal helpers, which are never public ([agent-lifecycle.md](agent-lifecycle.md#private-daemon-helpers)).
|
||||
- **Agent session** — One running instance of an agent inside a workspace (one provider, one model, one cwd, one timeline). The conceptual unit; in the UI this opens as a tab. Moving toward this as the canonical term over "Agent". Code: `AgentSnapshotPayload` (`packages/protocol/src/messages.ts:608`).
|
||||
- **Session** — Two senses: (a) per-client connection to a daemon, internal; (b) user-facing agent session, see **Agent session**. Code: `Session` (`packages/server/src/server/session.ts`) for (a). Don't confuse with: provider-side agent session log.
|
||||
- **Profile** — Internal name for the persisted shape of a host. Code: `HostProfile` (`packages/app/src/types/host-connection.ts:37`). Never user-facing. Three other things are called "profile" in this codebase, so always qualify: **Agent profile**, **Terminal profile**, and the provider aliases in [custom-providers.md](custom-providers.md). Bare "profile" is forbidden in UI copy.
|
||||
|
||||
+1
-2
@@ -493,8 +493,7 @@ export default function contribute(server: PluginServerContext) {
|
||||
|
||||
Declare `command: ["agent", "serve"]` when your provider launches an executable. The daemon applies
|
||||
`agents.providers.<id>.command` and `env`, resolves the executable against the effective PATH, and
|
||||
removes parent-session variables and inherited/session daemon-control values; explicitly configured
|
||||
provider env survives. `connect({ launch })` receives
|
||||
removes parent-session and daemon-control environment variables. `connect({ launch })` receives
|
||||
`{ command, args, env }`: an executable path, effective arguments, and the complete sanitized
|
||||
environment. Spawn with those values; do not merge the plugin process's environment back in.
|
||||
The launch travels as data through the same provider protocol for built-in and subprocess plugins.
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
sha256-p97CaALgkY+x9XVvS4/ILqmbwYSX0fSbVUgQl35BjiU=
|
||||
sha256-VdM4lXdgNEXPpTfvHSFvF9nm302rK/ZbVkZUp9tyOfg=
|
||||
|
||||
Generated
+615
-707
File diff suppressed because it is too large
Load Diff
@@ -131,7 +131,6 @@
|
||||
"concurrently": "^9.2.1",
|
||||
"cross-env": "^10.1.0",
|
||||
"electron": "44.2.0",
|
||||
"electron-builder": "26.18.0",
|
||||
"expo-module-scripts": "^5.0.8",
|
||||
"get-port-cli": "^3.0.0",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -152,7 +151,6 @@
|
||||
"expo": "~57.0.30",
|
||||
"expo-router": "~57.0.28",
|
||||
"lightningcss": "1.30.1",
|
||||
"pkijs": "3.4.2",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-native": "0.86.3",
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import { test } from "../support/fixtures";
|
||||
import {
|
||||
expectWorkspaceShownIdle,
|
||||
finishAndArchiveAgent,
|
||||
startBackgroundAgent,
|
||||
} from "../support/helpers/background-agents";
|
||||
import { openAgentRoute } from "../support/helpers/mock-agent";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
import {
|
||||
expectSubagentRowGone,
|
||||
expectSubagentRowVisible,
|
||||
openSubagentsTrack,
|
||||
seedParentWithSubagent,
|
||||
seedSubagent,
|
||||
} from "../support/helpers/subagents";
|
||||
import { expectOnlyWorkspaceAgentTabsVisible } from "../support/helpers/workspace-tabs";
|
||||
|
||||
test.describe("Background agents", () => {
|
||||
let workspace: SeededWorkspace;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
workspace = await seedWorkspace({
|
||||
repoPrefix: "background-agents-",
|
||||
title: "Classifier workspace",
|
||||
});
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
await workspace?.cleanup();
|
||||
});
|
||||
|
||||
test("a background agent runs in an open workspace without announcing itself", async ({
|
||||
page,
|
||||
}) => {
|
||||
const agents = await seedParentWithSubagent(workspace, {
|
||||
parentTitle: "Feature agent",
|
||||
childTitle: "Visible helper",
|
||||
});
|
||||
await openAgentRoute(page, { workspaceId: agents.workspaceId, agentId: agents.parent.id });
|
||||
await openSubagentsTrack(page);
|
||||
await expectSubagentRowVisible(page, agents.child.id);
|
||||
|
||||
const classifier = await test.step("a plugin starts a background agent", () =>
|
||||
startBackgroundAgent(workspace, { parentId: agents.parent.id, title: "Classifier" }));
|
||||
// A visible child created after the classifier reaches the app on the same stream, so once
|
||||
// its row shows, the app has processed the classifier too.
|
||||
const marker = await seedSubagent(workspace, {
|
||||
parentId: agents.parent.id,
|
||||
title: "Ordering marker",
|
||||
});
|
||||
|
||||
await test.step("it stays out of the track, the tabs and the sidebar status", async () => {
|
||||
await expectSubagentRowVisible(page, marker.id);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectWorkspaceShownIdle(page, "Classifier workspace");
|
||||
});
|
||||
|
||||
await test.step("the plugin reads its finish and archives it", async () => {
|
||||
await finishAndArchiveAgent(workspace, classifier.id);
|
||||
await expectOnlyWorkspaceAgentTabsVisible(page, [agents.parent.id]);
|
||||
await expectSubagentRowGone(page, classifier.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -429,32 +429,6 @@ test("context details support keyboard entry and dismiss when the viewport moves
|
||||
await expect(details).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("context details stay open while the chat streams", async ({ page }) => {
|
||||
test.setTimeout(120_000);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
const session = await seedMockAgentWorkspace({
|
||||
repoPrefix: "context-window-streaming-",
|
||||
title: "Context details while streaming",
|
||||
model: "ten-second-stream",
|
||||
});
|
||||
try {
|
||||
await openAgentRoute(page, session);
|
||||
await expectComposerVisible(page);
|
||||
await submitMessageWithButton(page, "Stream while the context details are open.");
|
||||
const stop = page.getByRole("button", { name: /stop|cancel/i }).first();
|
||||
await expect(stop).toBeVisible();
|
||||
const details = await pressContextWindowMeter(page, /^Context window/);
|
||||
await expect(stop).toBeVisible();
|
||||
await qaScreenshot(page, "context-details-while-streaming");
|
||||
|
||||
await expect(stop).toHaveCount(0, { timeout: 30_000 });
|
||||
await expect(details).toBeVisible();
|
||||
await qaScreenshot(page, "context-details-after-streaming");
|
||||
} finally {
|
||||
await session.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("pointer hovering context details preserves composer focus", async ({ page, agent }) => {
|
||||
const usage = await scriptAgentUsage(page);
|
||||
await page.setViewportSize(DESKTOP);
|
||||
|
||||
@@ -10,7 +10,6 @@ test("plugin header and composer buttons share actions, menus, content, and upda
|
||||
await buttons.verifyObservationLifetime();
|
||||
await buttons.openWideMenusAndPopovers();
|
||||
await buttons.runAndUpdateActions();
|
||||
await buttons.keepTrackSpaceWithoutPills();
|
||||
await buttons.openCompactSheets();
|
||||
await buttons.useCompactWorkspaceMenu();
|
||||
await buttons.hideAndUnloadOpenButtons();
|
||||
|
||||
@@ -16,14 +16,6 @@ import {
|
||||
returnAndReopenSettings,
|
||||
returnToPluginsPage,
|
||||
expectSettingsUnavailable,
|
||||
openPluginsPage,
|
||||
expectActionsMenuWithoutSettings,
|
||||
expectNoSettingsCogWithoutScreens,
|
||||
openTabbedSettings,
|
||||
selectSettingsTab,
|
||||
openTabbedSettingsLink,
|
||||
expectSettingsTooltip,
|
||||
expectNamedPluginRow,
|
||||
} from "../support/helpers/plugin-settings";
|
||||
|
||||
test("display preferences survive a reload", async ({ page }) => {
|
||||
@@ -57,32 +49,6 @@ test("Back to plugins returns from a plugin settings screen", async ({ page }) =
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
|
||||
test("plugin settings open from the cog and show a tab per screen", async ({ page }) => {
|
||||
await openPluginsPage(page);
|
||||
await test.step("plugin rows show the display name", async () => {
|
||||
await expectNamedPluginRow(page);
|
||||
});
|
||||
await test.step("the cog explains itself", async () => {
|
||||
await expectSettingsTooltip(page);
|
||||
});
|
||||
await test.step("the actions menu keeps only plugin actions", async () => {
|
||||
await expectActionsMenuWithoutSettings(page);
|
||||
});
|
||||
await test.step("tabs switch between the plugin's settings screens", async () => {
|
||||
await openTabbedSettings(page);
|
||||
await selectSettingsTab(page, "Advanced");
|
||||
await selectSettingsTab(page, "General");
|
||||
await returnToPluginsPage(page);
|
||||
});
|
||||
await test.step("a plugin without settings screens shows no cog", async () => {
|
||||
await expectNoSettingsCogWithoutScreens(page);
|
||||
});
|
||||
});
|
||||
|
||||
test("a settings screen link opens its tab", async ({ page }) => {
|
||||
await openTabbedSettingsLink(page, "Advanced");
|
||||
});
|
||||
|
||||
test.describe("compact plugin settings", () => {
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import path from "node:path";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
import { expect, test } from "../support/fixtures";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import {
|
||||
BOTS_PLUGIN_ID,
|
||||
COMPACT,
|
||||
@@ -177,37 +176,6 @@ test.describe("Plugin sidebar items", () => {
|
||||
await cleanup();
|
||||
});
|
||||
|
||||
test("a short compact sidebar keeps workspaces usable while navigation scrolls", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 390, height: 600 });
|
||||
await gotoAppShell(page);
|
||||
await page.getByRole("button", { name: "Open menu", exact: true }).first().click();
|
||||
await expect(botRow(page, "status")).toBeAttached();
|
||||
const workspace = visibleTestId(page, `sidebar-workspace-row-${getServerId()}:${workspaceId}`);
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
|
||||
await test.step("scrolling navigation reaches its last row without moving workspaces", async () => {
|
||||
const before = await boxOf(workspace);
|
||||
await visibleTestId(page, "sidebar-global-new-workspace").hover();
|
||||
await page.mouse.wheel(0, 800);
|
||||
await expect(botRow(page, "status")).toBeInViewport({ ratio: 1 });
|
||||
await expect(workspace).toBeInViewport({ ratio: 1 });
|
||||
expect((await boxOf(workspace)).y).toBe(before.y);
|
||||
await botRow(page, "status").click();
|
||||
await expect(page.getByText("Bot status details", { exact: true })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Close bot status", exact: true }).click();
|
||||
});
|
||||
|
||||
await test.step("the workspace still opens and closes the sidebar", async () => {
|
||||
await workspace.click();
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Open menu", exact: true }).first(),
|
||||
).toBeVisible();
|
||||
await expect(workspace).not.toBeInViewport();
|
||||
});
|
||||
});
|
||||
|
||||
test("a restored plugin screen waits for its catalog before rendering", async ({ page }) => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await gotoWorkspace(page, workspaceId);
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
movePointerAwayFromWorkspaceHoverCard,
|
||||
openMobileAgentSidebar,
|
||||
pinWorkspaceFromSidebar,
|
||||
scrollWorkspaceList,
|
||||
} from "../support/helpers/sidebar";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { expectWorkspaceHeader } from "../support/helpers/workspace-ui";
|
||||
@@ -583,12 +582,6 @@ test.describe("Half-screen desktop layout", () => {
|
||||
await page.getByTestId("menu-button").click();
|
||||
await expect(page.getByTestId("sidebar-global-new-workspace")).toBeVisible();
|
||||
await expect(sidebarScroll).toHaveJSProperty("scrollTop", scrollTop);
|
||||
|
||||
await movePointerAwayFromWorkspaceHoverCard(page);
|
||||
await focusWorkspaceRowWithKeyboard(page, lastWorkspaceId);
|
||||
await expectWorkspaceHoverCardOpen(page);
|
||||
await scrollWorkspaceList(page);
|
||||
await expectWorkspaceHoverCardClosed(page);
|
||||
} finally {
|
||||
await workspace.cleanup();
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import type { SeededWorkspace } from "./seed-client";
|
||||
|
||||
/**
|
||||
* Starts a background agent the way a plugin helper does: in the workspace, under a parent,
|
||||
* streaming for about ten seconds. Resolves once the daemon reports it running.
|
||||
*/
|
||||
export async function startBackgroundAgent(
|
||||
workspace: Pick<SeededWorkspace, "client" | "repoPath" | "workspaceId">,
|
||||
input: { parentId: string; title: string },
|
||||
): Promise<{ id: string }> {
|
||||
const agent = await workspace.client.createAgent({
|
||||
provider: "mock",
|
||||
cwd: workspace.repoPath,
|
||||
workspaceId: workspace.workspaceId,
|
||||
title: input.title,
|
||||
modeId: "load-test",
|
||||
model: "ten-second-stream",
|
||||
initialPrompt: "classify the last turn",
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: input.parentId },
|
||||
background: true,
|
||||
});
|
||||
await workspace.client.waitForAgentUpsert(agent.id, (snapshot) => snapshot.status === "running");
|
||||
return { id: agent.id };
|
||||
}
|
||||
|
||||
/** Waits for the agent's turn to end and archives it, as the plugin does with a helper's reply. */
|
||||
export async function finishAndArchiveAgent(
|
||||
workspace: Pick<SeededWorkspace, "client">,
|
||||
agentId: string,
|
||||
): Promise<void> {
|
||||
const finished = await workspace.client.waitForFinish(agentId, 60_000);
|
||||
expect(finished.status).toBe("idle");
|
||||
await workspace.client.archiveAgent(agentId);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceShownIdle(page: Page, workspaceName: string): Promise<void> {
|
||||
const row = page.getByRole("button", {
|
||||
name: new RegExp(`^${escapeRegExp(workspaceName)}(?:,|$)`),
|
||||
});
|
||||
await expect(row).toBeVisible({ timeout: 30_000 });
|
||||
await expect(row.getByTestId("workspace-status-indicator-running")).toHaveCount(0);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
@@ -54,15 +54,12 @@ export async function leaveContextWindowMeter(page: Page): Promise<void> {
|
||||
await expect(contextWindowDetails(page)).toHaveCount(0);
|
||||
}
|
||||
|
||||
/** Clicking the meter opens its details in the active layout. */
|
||||
export async function pressContextWindowMeter(
|
||||
page: Page,
|
||||
name: string | RegExp = METER_NAME,
|
||||
): Promise<Locator> {
|
||||
/** Compact screens open the meter's details in a sheet. */
|
||||
export async function pressContextWindowMeter(page: Page, name = METER_NAME): Promise<Locator> {
|
||||
await page.getByRole("button", { name, exact: true }).click({ timeout: 30_000 });
|
||||
const details = contextWindowDetails(page);
|
||||
await expect(details).toBeVisible();
|
||||
return details;
|
||||
const sheet = contextWindowDetails(page);
|
||||
await expect(sheet).toBeVisible();
|
||||
return sheet;
|
||||
}
|
||||
|
||||
export async function closeContextWindowSheet(page: Page): Promise<void> {
|
||||
|
||||
@@ -4,9 +4,6 @@ import path from "node:path";
|
||||
import type { Locator, TestInfo } from "@playwright/test";
|
||||
import { test, expect, type Page } from "@playwright/test";
|
||||
import { gotoAppShell } from "./app";
|
||||
import { scrollAgentChatToBottom, waitForScrollableChat } from "./agent-bottom-anchor";
|
||||
import { expectTurnCopyButton } from "./agent-stream";
|
||||
import { submitMessage } from "./composer";
|
||||
import { buildAgentRoute } from "./mock-agent";
|
||||
import { openCommandCenter } from "./command-center";
|
||||
import {
|
||||
@@ -91,7 +88,7 @@ export default function contribute(client) {
|
||||
header("locked", { title: "Production locked", icon: "Lock", disabled: true, behavior: { kind: "action", onPress() {} } });
|
||||
const review = pill("review", { title: "Run review", icon: "Scan", label: "Review", behavior: { kind: "action", async onPress() { await new Promise((resolve) => { finishReview = resolve; }); review.update({ label: "Reviewed" }); } } });
|
||||
const pillMenu = pill("checks", { title: "Composer checks", icon: "ListChecks", label: "Checks", behavior: menu });
|
||||
const usage = pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
pill("usage", { title: "Composer status", icon: StatusIcon, label: "Status", behavior: { kind: "popover", Content: Details } });
|
||||
const command = (id, title, onSelect) => client.addCommandCenterItem({ id, title, icon: "Settings", context: "workspace", onSelect });
|
||||
command("finish", "Finish deployment", () => finish());
|
||||
command("finish-review", "Finish review", () => finishReview());
|
||||
@@ -105,16 +102,10 @@ export default function contribute(client) {
|
||||
command("hide-menu", "Hide composer menu", () => pillMenu.update({ visible: false }));
|
||||
command("show-menu", "Show composer menu", () => pillMenu.update({ visible: true }));
|
||||
const lifetime = new AbortController();
|
||||
let pillsHidden = false;
|
||||
const applyWorkspace = (workspace) => {
|
||||
if (workspace.id !== workspaceId) return;
|
||||
const hidePills = workspace.name === "No composer pills";
|
||||
deploy.update({ visible: workspace.name !== "Compact checks" });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" && !hidePills });
|
||||
if (hidePills === pillsHidden) return;
|
||||
pillsHidden = hidePills;
|
||||
review.update({ visible: !hidePills });
|
||||
usage.update({ visible: !hidePills });
|
||||
pillMenu.update({ visible: workspace.name !== "Hide composer menu" });
|
||||
};
|
||||
void client.paseo.workspaces.list({ subscribe: {}, signal: lifetime.signal }).then(({ subscription }) => {
|
||||
subscription.subscribe({
|
||||
@@ -253,38 +244,6 @@ async function captureSchemes(page: Page, info: TestInfo, name: string, subject:
|
||||
await page.emulateMedia({ colorScheme: "light" });
|
||||
}
|
||||
|
||||
const COMPOSER_PILLS = ["Run review", "Composer checks", "Composer status"] as const;
|
||||
|
||||
function chevronOf(page: Page, title: string): Locator {
|
||||
return page
|
||||
.getByRole("button", { name: title, exact: true })
|
||||
.getByTestId("plugin-button-chevron");
|
||||
}
|
||||
|
||||
/** Composer pills that open a menu or popover carry a chevron; a one-tap action does not. */
|
||||
async function expectComposerPillChevrons(page: Page) {
|
||||
await expect(chevronOf(page, "Composer checks")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Composer status")).toHaveCount(1);
|
||||
await expect(chevronOf(page, "Run review")).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function setComposerPillsShown(
|
||||
page: Page,
|
||||
showcase: { setTitle(title: string): Promise<unknown> },
|
||||
shown: boolean,
|
||||
) {
|
||||
await showcase.setTitle(shown ? "Button showcase" : "No composer pills");
|
||||
for (const title of COMPOSER_PILLS) {
|
||||
await expect(page.getByRole("button", { name: title, exact: true })).toHaveCount(shown ? 1 : 0);
|
||||
}
|
||||
}
|
||||
|
||||
async function lastTurnTop(page: Page): Promise<number> {
|
||||
const box = await page.getByRole("button", { name: "Copy turn" }).last().boundingBox();
|
||||
expect(box).not.toBeNull();
|
||||
return box!.y;
|
||||
}
|
||||
|
||||
async function expectHeaderTextTruncated(page: Page) {
|
||||
const title = page.getByTestId("workspace-header-title");
|
||||
const subtitle = page.getByTestId("workspace-header-subtitle");
|
||||
@@ -317,7 +276,6 @@ export async function withButtonShowcase(
|
||||
run: (buttons: {
|
||||
openWideMenusAndPopovers(): Promise<void>;
|
||||
runAndUpdateActions(): Promise<void>;
|
||||
keepTrackSpaceWithoutPills(): Promise<void>;
|
||||
openCompactSheets(): Promise<void>;
|
||||
useCompactWorkspaceMenu(): Promise<void>;
|
||||
hideAndUnloadOpenButtons(): Promise<void>;
|
||||
@@ -390,7 +348,11 @@ export async function withButtonShowcase(
|
||||
.getByRole("button", { name: "Header checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(1);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expect(
|
||||
page
|
||||
.getByRole("button", { name: "Composer checks", exact: true })
|
||||
.getByTestId("plugin-button-chevron"),
|
||||
).toHaveCount(0);
|
||||
await expectSurfaceVisible(page);
|
||||
await captureSchemes(
|
||||
page,
|
||||
@@ -514,31 +476,10 @@ export async function withButtonShowcase(
|
||||
"Review ready",
|
||||
);
|
||||
}),
|
||||
keepTrackSpaceWithoutPills: () =>
|
||||
test.step("the composer track keeps its space when every pill leaves and returns", async () => {
|
||||
await page.setViewportSize(WIDE);
|
||||
await submitMessage(page, "Stream a turn under the composer pills");
|
||||
await expectTurnCopyButton(page);
|
||||
await waitForScrollableChat(page, { minScrollableDistance: 200, timeout: 30_000 });
|
||||
for (const viewport of [WIDE, COMPACT]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await scrollAgentChatToBottom(page);
|
||||
const top = await lastTurnTop(page);
|
||||
await setComposerPillsShown(page, showcase, false);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
await setComposerPillsShown(page, showcase, true);
|
||||
expect(await lastTurnTop(page)).toBe(top);
|
||||
}
|
||||
await page.setViewportSize(WIDE);
|
||||
}),
|
||||
openCompactSheets: () =>
|
||||
test.step("compact header leaves plugin actions to the workspace menu while composer triggers open sheets", async () => {
|
||||
await page.setViewportSize(COMPACT);
|
||||
await expect(
|
||||
page.getByRole("toolbar", { name: "Workspace actions", exact: true }),
|
||||
).toHaveCount(0);
|
||||
await expectComposerPillChevrons(page);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(2);
|
||||
await expect(page.getByTestId("plugin-button-chevron")).toHaveCount(0);
|
||||
await expect(
|
||||
page.getByRole("button", { name: "Composer checks", exact: true }),
|
||||
).toContainText("Checks");
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { copyPluginExample, pluginRequirements } from "./plugin-fixture";
|
||||
import { copyPluginExample } from "./plugin-fixture";
|
||||
import { expect, test as base, type Page } from "../fixtures";
|
||||
import { gotoAppShell, openSettings } from "./app";
|
||||
import { goBackInSettings, openCompactSettings, openHostSection } from "./settings";
|
||||
@@ -12,54 +9,6 @@ import { connectNewWorkspaceDaemonClient } from "./new-workspace";
|
||||
|
||||
type SettingsClient = Awaited<ReturnType<typeof connectNewWorkspaceDaemonClient>>;
|
||||
|
||||
const TABBED_PLUGIN_ID = "settings-tabs";
|
||||
const TABBED_PLUGIN_NAME = "Tabbed settings";
|
||||
/** Two settings screens, so the settings page shows a tab per screen. */
|
||||
const TABBED_SOURCE = `import React from "react";
|
||||
import { Text, View } from "react-native";
|
||||
import { defineTranslations, useTranslation } from "@getpaseo/plugin/client";
|
||||
|
||||
const messages = defineTranslations({ resources: { en: { icon: "advanced-tab-icon" } } });
|
||||
|
||||
function General({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>General settings body</Text>;
|
||||
}
|
||||
|
||||
function Advanced({ theme }) {
|
||||
return <Text style={{ color: theme.colors.foreground }}>Advanced settings body</Text>;
|
||||
}
|
||||
|
||||
// Component icons run with the plugin's runtime; one that throws costs its tab only the icon.
|
||||
function TranslatedIcon({ color, size }) {
|
||||
const { t } = useTranslation(messages);
|
||||
return <View testID={t("icon")} aria-hidden style={{ width: size, height: size, borderRadius: size / 2, backgroundColor: color }} />;
|
||||
}
|
||||
|
||||
function BrokenIcon() {
|
||||
throw new Error("Settings tab icon exploded");
|
||||
}
|
||||
|
||||
export default function contribute(client) {
|
||||
client.addSettingsScreen({ id: "general", title: "General", icon: BrokenIcon, Component: General });
|
||||
client.addSettingsScreen({ id: "advanced", title: "Advanced", icon: TranslatedIcon, Component: Advanced });
|
||||
return () => {};
|
||||
}
|
||||
`;
|
||||
|
||||
async function writeTabbedPlugin() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), `paseo-${TABBED_PLUGIN_ID}-`));
|
||||
await writeFile(
|
||||
path.join(directory, "paseo-plugin.json"),
|
||||
JSON.stringify({
|
||||
id: TABBED_PLUGIN_ID,
|
||||
name: TABBED_PLUGIN_NAME,
|
||||
requirements: pluginRequirements,
|
||||
}),
|
||||
);
|
||||
await writeFile(path.join(directory, "index.client.tsx"), TABBED_SOURCE);
|
||||
return { directory, cleanup: () => rm(directory, { recursive: true, force: true }) };
|
||||
}
|
||||
|
||||
export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
settingsPlugin: [
|
||||
async ({ e2eWorker, page }, provide, testInfo) => {
|
||||
@@ -67,20 +16,16 @@ export const test = base.extend<{ settingsPlugin: SettingsClient }>({
|
||||
const client = await connectNewWorkspaceDaemonClient({ ownProjects: false });
|
||||
const previous = await client.getDaemonConfig();
|
||||
const example = await copyPluginExample("settings");
|
||||
const tabbed = await writeTabbedPlugin();
|
||||
try {
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(example.directory);
|
||||
await client.installDirectoryPlugin(tabbed.directory);
|
||||
await provide(client);
|
||||
await page.screenshot({ path: testInfo.outputPath("settings.png") });
|
||||
} finally {
|
||||
await client.removePlugin("settings-example");
|
||||
await client.removePlugin(TABBED_PLUGIN_ID);
|
||||
await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled });
|
||||
await client.close();
|
||||
await example.cleanup();
|
||||
await tabbed.cleanup();
|
||||
}
|
||||
},
|
||||
{ auto: true },
|
||||
@@ -99,88 +44,9 @@ export async function openCompactDisplaySettings(page: Page) {
|
||||
}
|
||||
async function openPluginScreen(page: Page) {
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
await openSettingsFromCog(page);
|
||||
}
|
||||
/** The cog on a plugin's row. Its label is "Settings"; the row tells plugins apart. */
|
||||
function settingsButton(page: Page, pluginId: string) {
|
||||
return page
|
||||
.getByTestId(`plugin-row-${pluginId}`)
|
||||
.filter({ visible: true })
|
||||
.getByRole("button", { name: "Settings", exact: true });
|
||||
}
|
||||
async function openSettingsFromCog(page: Page) {
|
||||
await settingsButton(page, "settings-example").click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
// A plugin with one settings screen shows no tabs, and the header names only the plugin.
|
||||
await expect(page.getByTestId("plugin-settings-tabs")).toHaveCount(0);
|
||||
await expectSettingsHeader(page, "settings-example");
|
||||
}
|
||||
export async function openPluginsPage(page: Page) {
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openHostSection(page, getServerId(), "plugins");
|
||||
}
|
||||
export async function expectActionsMenuWithoutSettings(page: Page) {
|
||||
await page.getByRole("button", { name: "Actions for settings-example", exact: true }).click();
|
||||
await expect(page.getByRole("menuitem", { name: "Reload", exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("menuitem", { name: "Display", exact: true })).toHaveCount(0);
|
||||
await page.keyboard.press("Escape");
|
||||
}
|
||||
export async function expectNoSettingsCogWithoutScreens(page: Page) {
|
||||
await page.getByRole("switch", { name: "settings-example: Disable" }).click();
|
||||
await expect(page.getByRole("switch", { name: "settings-example: Enable" })).toBeVisible();
|
||||
await expect(settingsButton(page, "settings-example")).toHaveCount(0);
|
||||
await page.getByRole("switch", { name: "settings-example: Enable" }).click();
|
||||
await expect(settingsButton(page, "settings-example")).toBeVisible();
|
||||
}
|
||||
export async function expectSettingsTooltip(page: Page) {
|
||||
await settingsButton(page, "settings-example").hover();
|
||||
await expect(page.getByRole("tooltip")).toHaveText("Settings");
|
||||
await page.mouse.move(0, 0);
|
||||
}
|
||||
/** A manifest `name` titles the row and its controls; the id stays visible beneath it. */
|
||||
export async function expectNamedPluginRow(page: Page) {
|
||||
const row = page.getByTestId(`plugin-row-${TABBED_PLUGIN_ID}`).filter({ visible: true });
|
||||
await expect(row.getByText(TABBED_PLUGIN_NAME, { exact: true })).toBeVisible();
|
||||
await expect(row.getByText(TABBED_PLUGIN_ID, { exact: true })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("switch", { name: `${TABBED_PLUGIN_NAME}: Disable`, exact: true }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("button", { name: `Actions for ${TABBED_PLUGIN_NAME}`, exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
export async function openTabbedSettings(page: Page) {
|
||||
await settingsButton(page, TABBED_PLUGIN_ID).click();
|
||||
await expectSettingsTab(page, "General");
|
||||
// The Advanced icon reads the plugin's translations; the General icon throws and is dropped.
|
||||
await expect(page.getByTestId("advanced-tab-icon")).toBeVisible();
|
||||
}
|
||||
function settingsTab(page: Page, title: string) {
|
||||
return page.getByTestId("plugin-settings-tabs").getByRole("button", { name: title, exact: true });
|
||||
}
|
||||
export async function selectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
await settingsTab(page, title).click();
|
||||
await expectSettingsTab(page, title);
|
||||
}
|
||||
export async function expectSettingsTab(page: Page, title: "General" | "Advanced") {
|
||||
const other = title === "General" ? "Advanced" : "General";
|
||||
await expect(settingsTab(page, title)).toHaveAttribute("aria-selected", "true");
|
||||
await expect(page.getByText(`${title} settings body`, { exact: true })).toBeVisible();
|
||||
await expect(page.getByText(`${other} settings body`, { exact: true })).toHaveCount(0);
|
||||
// The header names the plugin by its manifest name; the tab names the screen.
|
||||
await expectSettingsHeader(page, TABBED_PLUGIN_NAME);
|
||||
await expectAppRoute(
|
||||
page,
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
}
|
||||
export async function openTabbedSettingsLink(page: Page, title: "General" | "Advanced") {
|
||||
await gotoAppShell(page);
|
||||
await page.goto(
|
||||
`${buildSettingsHostSectionRoute(getServerId(), "plugins")}/${TABBED_PLUGIN_ID}/${title.toLowerCase()}`,
|
||||
);
|
||||
await expectSettingsTab(page, title);
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function groupByWorkspace(page: Page) {
|
||||
await page.getByRole("button", { name: /^Group agents by:/ }).click();
|
||||
@@ -235,7 +101,11 @@ export async function discardTitle(page: Page) {
|
||||
}
|
||||
export async function returnAndReopenSettings(page: Page) {
|
||||
await goBackInSettings(page);
|
||||
await openSettingsFromCog(page);
|
||||
const actions = page.getByRole("button", { name: "Actions for settings-example", exact: true });
|
||||
await expect(actions).toBeVisible();
|
||||
await actions.click();
|
||||
await page.getByRole("menuitem", { name: "Display", exact: true }).click();
|
||||
await expect(page.getByRole("switch", { name: "Show metadata" })).toBeVisible();
|
||||
}
|
||||
export async function returnToPluginsPage(page: Page) {
|
||||
await page.getByRole("button", { name: "Back to plugins", exact: true }).click();
|
||||
@@ -244,10 +114,6 @@ export async function returnToPluginsPage(page: Page) {
|
||||
page.getByRole("button", { name: "Actions for settings-example", exact: true }),
|
||||
).toBeVisible();
|
||||
}
|
||||
async function expectSettingsHeader(page: Page, title: string) {
|
||||
// Settings keeps the Plugins page mounted beneath, so read the visible title only.
|
||||
await expect(page.getByText(title, { exact: true }).filter({ visible: true })).toHaveCount(1);
|
||||
}
|
||||
export async function expectSettingsUnavailable(page: Page) {
|
||||
await expect(
|
||||
page.getByText("This plugin settings screen is unavailable.", { exact: true }),
|
||||
|
||||
@@ -118,7 +118,6 @@ export interface SeedDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
initialPrompt?: string;
|
||||
labels?: Record<string, string>;
|
||||
background?: boolean;
|
||||
}): Promise<{ id: string; status: string }>;
|
||||
fetchAgents(options?: { scope?: "active" }): Promise<{
|
||||
entries: Array<{
|
||||
|
||||
@@ -71,15 +71,6 @@ export async function focusWorkspaceRowWithKeyboard(
|
||||
await expect(row).toBeFocused();
|
||||
}
|
||||
|
||||
export async function scrollWorkspaceList(page: Page): Promise<void> {
|
||||
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
|
||||
const before = await list.evaluate((element) => element.scrollTop);
|
||||
await list.evaluate((element) =>
|
||||
element.scrollBy({ top: element.scrollTop > 0 ? -40 : 40, behavior: "instant" }),
|
||||
);
|
||||
await expect.poll(() => list.evaluate((element) => element.scrollTop)).not.toBe(before);
|
||||
}
|
||||
|
||||
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
|
||||
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
|
||||
}
|
||||
|
||||
+2
-60
@@ -4,13 +4,11 @@ import android.graphics.Rect
|
||||
import android.text.Selection
|
||||
import android.text.Spannable
|
||||
import android.view.View
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import android.os.SystemClock
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.TextView
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.facebook.react.bridge.BridgeReactContext
|
||||
import com.facebook.react.bridge.JavaOnlyArray
|
||||
import com.facebook.react.bridge.JavaOnlyMap
|
||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||
@@ -26,40 +24,6 @@ import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ScrollSelectionTest {
|
||||
@Test fun mouseWheelMovesInvertedViewportInVisualDirection() = withScroll(inverted = true) { scroll, _ ->
|
||||
val before = scroll.scrollY
|
||||
withWheel(1f) { event ->
|
||||
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||
assertTrue("Wheel up must move toward older messages: $before -> ${scroll.scrollY}", scroll.scrollY > before)
|
||||
assertEquals("The caller's event must not change", 1f, event.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
}
|
||||
val afterUp = scroll.scrollY
|
||||
withWheel(-1f) { event ->
|
||||
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||
assertTrue("Wheel down must move toward newer messages: $afterUp -> ${scroll.scrollY}", scroll.scrollY < afterUp)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun mouseWheelKeepsOrdinaryScrollDirection() = withScroll(inverted = false) { scroll, _ ->
|
||||
val before = scroll.scrollY
|
||||
withWheel(1f) { event ->
|
||||
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||
assertTrue(scroll.scrollY < before)
|
||||
}
|
||||
val afterUp = scroll.scrollY
|
||||
withWheel(-1f) { event ->
|
||||
assertTrue(scroll.dispatchGenericMotionEvent(event))
|
||||
assertTrue(scroll.scrollY > afterUp)
|
||||
}
|
||||
}
|
||||
|
||||
@Test fun mouseWheelRespectsDisabledScrolling() = withScroll(inverted = true) { scroll, _ ->
|
||||
scroll.setScrollEnabled(false)
|
||||
val before = scroll.scrollY
|
||||
withWheel(1f) { scroll.dispatchGenericMotionEvent(it) }
|
||||
assertEquals(before, scroll.scrollY)
|
||||
}
|
||||
|
||||
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
|
||||
val before = scroll.scrollY
|
||||
assertTrue(text.requestFocus())
|
||||
@@ -106,7 +70,7 @@ class ScrollSelectionTest {
|
||||
val appContext = instrumentation.targetContext
|
||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||
val reactContext = ScrollTestContext(appContext)
|
||||
val reactContext = BridgeReactContext(appContext)
|
||||
val context = ThemedReactContext(reactContext, appContext)
|
||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||
val scroll = manager.createViewInstance(context)
|
||||
@@ -134,26 +98,4 @@ class ScrollSelectionTest {
|
||||
}
|
||||
|
||||
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
||||
|
||||
private fun withWheel(axis: Float, check: (MotionEvent) -> Unit) {
|
||||
val time = SystemClock.uptimeMillis()
|
||||
val properties = MotionEvent.PointerProperties().apply {
|
||||
id = 0
|
||||
toolType = MotionEvent.TOOL_TYPE_MOUSE
|
||||
}
|
||||
val coordinates = MotionEvent.PointerCoords().apply {
|
||||
x = 150f
|
||||
y = 150f
|
||||
setAxisValue(MotionEvent.AXIS_VSCROLL, axis)
|
||||
}
|
||||
val event = MotionEvent.obtain(
|
||||
time, time, MotionEvent.ACTION_SCROLL, 1, arrayOf(properties), arrayOf(coordinates),
|
||||
0, 0, 1f, 1f, 0, 0, InputDevice.SOURCE_MOUSE, 0,
|
||||
)
|
||||
try {
|
||||
check(event)
|
||||
} finally {
|
||||
event.recycle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-19
@@ -1,19 +0,0 @@
|
||||
package sh.paseo.scroll;
|
||||
|
||||
import android.content.Context;
|
||||
import com.facebook.react.bridge.BridgeReactContext;
|
||||
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||
|
||||
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
|
||||
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||
ScrollTestContext(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public EventDispatcher getEventDispatcher() {
|
||||
return BlackHoleEventDispatcher.INSTANCE;
|
||||
}
|
||||
}
|
||||
+6
-42
@@ -2,8 +2,6 @@ package sh.paseo.scroll
|
||||
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Rect
|
||||
import android.view.InputDevice
|
||||
import android.view.MotionEvent
|
||||
import com.facebook.react.ReactPackage
|
||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||
import com.facebook.react.bridge.NativeModule
|
||||
@@ -32,47 +30,13 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
||||
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
||||
private val transformValues = FloatArray(9)
|
||||
|
||||
private fun isVerticallyInverted(): Boolean {
|
||||
// Android FlatList uses scale: -1, which RN decomposes into a 180-degree
|
||||
// rotation with positive scaleY. Read the resulting transform, not scaleY.
|
||||
matrix.getValues(transformValues)
|
||||
return transformValues[Matrix.MSCALE_Y] < 0
|
||||
}
|
||||
|
||||
override fun onGenericMotionEvent(event: MotionEvent): Boolean {
|
||||
if (!isVerticallyInverted() || event.actionMasked != MotionEvent.ACTION_SCROLL ||
|
||||
!event.isFromSource(InputDevice.SOURCE_CLASS_POINTER)) {
|
||||
return super.onGenericMotionEvent(event)
|
||||
}
|
||||
if (!scrollEnabled) return false
|
||||
|
||||
// Android applies wheel axes directly to scrollY; the inversion transform only
|
||||
// transforms pointer coordinates. Reverse the vertical axis before delegating so
|
||||
// platform scroll bounds and scroll events still apply.
|
||||
val properties = Array(event.pointerCount) { index ->
|
||||
MotionEvent.PointerProperties().also { event.getPointerProperties(index, it) }
|
||||
}
|
||||
val coordinates = Array(event.pointerCount) { index ->
|
||||
MotionEvent.PointerCoords().also {
|
||||
event.getPointerCoords(index, it)
|
||||
it.setAxisValue(MotionEvent.AXIS_VSCROLL, -it.getAxisValue(MotionEvent.AXIS_VSCROLL))
|
||||
}
|
||||
}
|
||||
val correctedEvent = MotionEvent.obtain(
|
||||
event.downTime, event.eventTime, event.action, event.pointerCount, properties, coordinates,
|
||||
event.metaState, event.buttonState, event.xPrecision, event.yPrecision,
|
||||
event.deviceId, event.edgeFlags, event.source, event.flags,
|
||||
)
|
||||
return try {
|
||||
super.onGenericMotionEvent(correctedEvent)
|
||||
} finally {
|
||||
correctedEvent.recycle()
|
||||
}
|
||||
}
|
||||
|
||||
// Both RN focus handling and Android selection visibility requests use this calculation.
|
||||
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
||||
// movement to user gestures and explicit scroll commands.
|
||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
|
||||
if (isVerticallyInverted()) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
||||
matrix.getValues(transformValues)
|
||||
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
|
||||
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,13 +12,12 @@ import { useTranslation } from "react-i18next";
|
||||
import { memo, useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
||||
import {
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet as RNStyleSheet,
|
||||
Text,
|
||||
useWindowDimensions,
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler";
|
||||
import { Gesture } from "react-native-gesture-handler";
|
||||
import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated";
|
||||
import { scheduleOnRN } from "react-native-worklets";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
@@ -42,7 +41,6 @@ import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-heade
|
||||
import { Shortcut } from "@/components/ui/shortcut";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { HEADER_INNER_HEIGHT, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { isNative as platformIsNative } from "@/constants/platform";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { useImportSession } from "@/hooks/use-import-session";
|
||||
import { useShortcutKeys } from "@/hooks/use-shortcut-keys";
|
||||
@@ -557,14 +555,6 @@ function MobileSidebar({
|
||||
[insetsTop, insetsBottom, theme.colors.surfaceSidebar],
|
||||
);
|
||||
|
||||
const navRows = (
|
||||
<SidebarNavRows
|
||||
style={styles.mobileNavRowsGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<MobilePanelOverlay
|
||||
panel="agent-list"
|
||||
@@ -573,29 +563,11 @@ function MobileSidebar({
|
||||
>
|
||||
<View style={styles.sidebarContent} pointerEvents="auto">
|
||||
<WindowChromeSafeArea placement="below" />
|
||||
{platformIsNative ? (
|
||||
<GHScrollView
|
||||
// Same contract as the workspace list: vertical drags scroll the nav
|
||||
// block, a clear horizontal intent still activates the close pan.
|
||||
simultaneousHandlers={closeGestureRef}
|
||||
style={styles.mobileNavScroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
alwaysBounceVertical={false}
|
||||
testID="sidebar-nav-scroll"
|
||||
>
|
||||
{navRows}
|
||||
</GHScrollView>
|
||||
) : (
|
||||
<ScrollView
|
||||
style={styles.mobileNavScroll}
|
||||
showsVerticalScrollIndicator={false}
|
||||
alwaysBounceVertical={false}
|
||||
testID="sidebar-nav-scroll"
|
||||
>
|
||||
{navRows}
|
||||
</ScrollView>
|
||||
)}
|
||||
<SidebarSeparator />
|
||||
<SidebarNavRows
|
||||
style={styles.sidebarHeaderGroup}
|
||||
onBeforeNavigate={closeSidebar}
|
||||
firstRowTrailing={closeButton}
|
||||
/>
|
||||
|
||||
{isInitialLoad && !hasActiveHostFilter ? (
|
||||
<SidebarAgentListSkeleton />
|
||||
@@ -867,18 +839,6 @@ const styles = StyleSheet.create((theme) => ({
|
||||
sidebarHeaderGroupBelowChrome: {
|
||||
paddingTop: 0,
|
||||
},
|
||||
// The nav block caps at half the sidebar; a long item list scrolls inside the
|
||||
// cap instead of crowding the workspace list off a short screen.
|
||||
mobileNavScroll: {
|
||||
flexGrow: 0,
|
||||
flexShrink: 1,
|
||||
maxHeight: "50%",
|
||||
},
|
||||
mobileNavRowsGroup: {
|
||||
paddingTop: theme.spacing[2],
|
||||
gap: 2,
|
||||
paddingBottom: theme.spacing[1.5],
|
||||
},
|
||||
sidebarContent: {
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
|
||||
@@ -208,9 +208,9 @@ function WebHoverCard({
|
||||
)
|
||||
keyPressed(event);
|
||||
};
|
||||
// Only a scroll that moves the trigger detaches the card. A streaming chat beside it does not.
|
||||
const scrolled = (event: Event) => {
|
||||
if ((event.target as Node).contains(trigger)) close();
|
||||
if (!(contentRef.current as unknown as HTMLElement | null)?.contains(event.target as Node))
|
||||
close();
|
||||
};
|
||||
document.addEventListener("focusin", focusEntered);
|
||||
document.addEventListener("focusout", focusLeft);
|
||||
|
||||
@@ -29,9 +29,8 @@ import { COMPOSER_PILL_CLEARANCE, composerPillStyles } from "./pill-styles";
|
||||
* on every platform.
|
||||
*
|
||||
* The bar floats over the transcript with no background, so content remains visible underneath.
|
||||
* Its host gives the scroll viewport a small bottom inset for as long as the bar is mounted, with
|
||||
* or without pills: that keeps the final footer clear, and pills that come and go every turn do not
|
||||
* move the transcript.
|
||||
* Its host gives the scroll viewport a small bottom inset only when the bar exists; that keeps
|
||||
* the final footer clear without turning the overlay into a layout band.
|
||||
*/
|
||||
export function ComposerTrackBar({ children }: { children: ReactNode }): ReactElement {
|
||||
return (
|
||||
|
||||
@@ -76,7 +76,7 @@ export function useAggregatedAgents(options?: {
|
||||
}
|
||||
const serverLabel = serverLabelById.get(serverId) ?? serverId;
|
||||
for (const agent of agents.values()) {
|
||||
if (agent.background || (!includeArchived && agent.archivedAt)) {
|
||||
if (!includeArchived && agent.archivedAt) {
|
||||
continue;
|
||||
}
|
||||
const nextAgent: AggregatedAgent = {
|
||||
|
||||
@@ -2384,7 +2384,7 @@ export const en = {
|
||||
installing: "Installing…",
|
||||
logs: {
|
||||
action: "Logs",
|
||||
title: "Logs: {{name}}",
|
||||
title: "Logs: {{id}}",
|
||||
loading: "Loading logs…",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing…",
|
||||
@@ -2393,8 +2393,7 @@ export const en = {
|
||||
},
|
||||
status: { running: "running", disabled: "disabled", failed: "failed" },
|
||||
actions: {
|
||||
menu: "Actions for {{name}}",
|
||||
settings: "Settings",
|
||||
menu: "Actions for {{id}}",
|
||||
reload: "Reload",
|
||||
reloading: "Reloading…",
|
||||
enable: "Enable",
|
||||
@@ -2405,16 +2404,16 @@ export const en = {
|
||||
removing: "Removing…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "Installed {{name}}",
|
||||
reload: "Reloaded {{name}}",
|
||||
enable: "Enabled {{name}}",
|
||||
disable: "Disabled {{name}}",
|
||||
remove: "Removed {{name}}",
|
||||
kept: "Kept {{name}}",
|
||||
installed: "Installed {{id}}",
|
||||
reload: "Reloaded {{id}}",
|
||||
enable: "Enabled {{id}}",
|
||||
disable: "Disabled {{id}}",
|
||||
remove: "Removed {{id}}",
|
||||
kept: "Kept {{id}}",
|
||||
globalEnabled: "Plugins enabled",
|
||||
globalDisabled: "Plugins disabled",
|
||||
},
|
||||
removeConfirmTitle: "Remove {{name}}?",
|
||||
removeConfirmTitle: "Remove {{id}}?",
|
||||
removeConfirmMessage: "This removes the configuration. The source directory is not deleted.",
|
||||
states: {
|
||||
offlineTitle: "Plugin host is offline",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
const shared = {
|
||||
logs: {
|
||||
action: "Logs",
|
||||
title: "Logs: {{name}}",
|
||||
title: "Logs: {{id}}",
|
||||
loading: "Loading logs…",
|
||||
refresh: "Refresh",
|
||||
refreshing: "Refreshing…",
|
||||
@@ -33,8 +33,7 @@ export const pluginSettings = {
|
||||
installing: "جارٍ التثبيت…",
|
||||
status: { running: "قيد التشغيل", disabled: "معطّلة", failed: "فشلت" },
|
||||
actions: {
|
||||
menu: "إجراءات {{name}}",
|
||||
settings: "الإعدادات",
|
||||
menu: "إجراءات {{id}}",
|
||||
reload: "إعادة تحميل",
|
||||
reloading: "جارٍ إعادة التحميل…",
|
||||
enable: "تفعيل",
|
||||
@@ -45,16 +44,16 @@ export const pluginSettings = {
|
||||
removing: "جارٍ الإزالة…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "تم تثبيت {{name}}",
|
||||
reload: "تمت إعادة تحميل {{name}}",
|
||||
enable: "تم تفعيل {{name}}",
|
||||
disable: "تم تعطيل {{name}}",
|
||||
remove: "تمت إزالة {{name}}",
|
||||
kept: "تم الإبقاء على {{name}}",
|
||||
installed: "تم تثبيت {{id}}",
|
||||
reload: "تمت إعادة تحميل {{id}}",
|
||||
enable: "تم تفعيل {{id}}",
|
||||
disable: "تم تعطيل {{id}}",
|
||||
remove: "تمت إزالة {{id}}",
|
||||
kept: "تم الإبقاء على {{id}}",
|
||||
globalEnabled: "تم تفعيل الإضافات",
|
||||
globalDisabled: "تم تعطيل الإضافات",
|
||||
},
|
||||
removeConfirmTitle: "إزالة {{name}}؟",
|
||||
removeConfirmTitle: "إزالة {{id}}؟",
|
||||
removeConfirmMessage: "يؤدي هذا إلى إزالة الإعداد فقط. لن يُحذف مجلد المصدر.",
|
||||
states: {
|
||||
offlineTitle: "مضيف الإضافات غير متصل",
|
||||
@@ -89,8 +88,7 @@ export const pluginSettings = {
|
||||
installing: "Instalando…",
|
||||
status: { running: "en ejecución", disabled: "desactivado", failed: "fallido" },
|
||||
actions: {
|
||||
menu: "Acciones para {{name}}",
|
||||
settings: "Configuración",
|
||||
menu: "Acciones para {{id}}",
|
||||
reload: "Recargar",
|
||||
reloading: "Recargando…",
|
||||
enable: "Activar",
|
||||
@@ -101,16 +99,16 @@ export const pluginSettings = {
|
||||
removing: "Eliminando…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} instalado",
|
||||
reload: "{{name}} recargado",
|
||||
enable: "{{name}} activado",
|
||||
disable: "{{name}} desactivado",
|
||||
remove: "{{name}} eliminado",
|
||||
kept: "Se conservó {{name}}",
|
||||
installed: "{{id}} instalado",
|
||||
reload: "{{id}} recargado",
|
||||
enable: "{{id}} activado",
|
||||
disable: "{{id}} desactivado",
|
||||
remove: "{{id}} eliminado",
|
||||
kept: "Se conservó {{id}}",
|
||||
globalEnabled: "Plugins activados",
|
||||
globalDisabled: "Plugins desactivados",
|
||||
},
|
||||
removeConfirmTitle: "¿Eliminar {{name}}?",
|
||||
removeConfirmTitle: "¿Eliminar {{id}}?",
|
||||
removeConfirmMessage: "Esto elimina la configuración. El directorio de origen no se borra.",
|
||||
states: {
|
||||
offlineTitle: "El host de plugins está sin conexión",
|
||||
@@ -145,8 +143,7 @@ export const pluginSettings = {
|
||||
installing: "Installation…",
|
||||
status: { running: "actif", disabled: "désactivé", failed: "échec" },
|
||||
actions: {
|
||||
menu: "Actions pour {{name}}",
|
||||
settings: "Paramètres",
|
||||
menu: "Actions pour {{id}}",
|
||||
reload: "Recharger",
|
||||
reloading: "Rechargement…",
|
||||
enable: "Activer",
|
||||
@@ -157,16 +154,16 @@ export const pluginSettings = {
|
||||
removing: "Suppression…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} installé",
|
||||
reload: "{{name}} rechargé",
|
||||
enable: "{{name}} activé",
|
||||
disable: "{{name}} désactivé",
|
||||
remove: "{{name}} supprimé",
|
||||
kept: "{{name}} conservé",
|
||||
installed: "{{id}} installé",
|
||||
reload: "{{id}} rechargé",
|
||||
enable: "{{id}} activé",
|
||||
disable: "{{id}} désactivé",
|
||||
remove: "{{id}} supprimé",
|
||||
kept: "{{id}} conservé",
|
||||
globalEnabled: "Plugins activés",
|
||||
globalDisabled: "Plugins désactivés",
|
||||
},
|
||||
removeConfirmTitle: "Supprimer {{name}} ?",
|
||||
removeConfirmTitle: "Supprimer {{id}} ?",
|
||||
removeConfirmMessage: "La configuration sera supprimée. Le dossier source ne sera pas effacé.",
|
||||
states: {
|
||||
offlineTitle: "L’hôte des plugins est hors ligne",
|
||||
@@ -201,8 +198,7 @@ export const pluginSettings = {
|
||||
installing: "インストール中…",
|
||||
status: { running: "実行中", disabled: "無効", failed: "失敗" },
|
||||
actions: {
|
||||
menu: "{{name}} のアクション",
|
||||
settings: "設定",
|
||||
menu: "{{id}} のアクション",
|
||||
reload: "再読み込み",
|
||||
reloading: "再読み込み中…",
|
||||
enable: "有効化",
|
||||
@@ -213,16 +209,16 @@ export const pluginSettings = {
|
||||
removing: "削除中…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} をインストールしました",
|
||||
reload: "{{name}} を再読み込みしました",
|
||||
enable: "{{name}} を有効にしました",
|
||||
disable: "{{name}} を無効にしました",
|
||||
remove: "{{name}} を削除しました",
|
||||
kept: "{{name}} を保持しました",
|
||||
installed: "{{id}} をインストールしました",
|
||||
reload: "{{id}} を再読み込みしました",
|
||||
enable: "{{id}} を有効にしました",
|
||||
disable: "{{id}} を無効にしました",
|
||||
remove: "{{id}} を削除しました",
|
||||
kept: "{{id}} を保持しました",
|
||||
globalEnabled: "プラグインを有効にしました",
|
||||
globalDisabled: "プラグインを無効にしました",
|
||||
},
|
||||
removeConfirmTitle: "{{name}} を削除しますか?",
|
||||
removeConfirmTitle: "{{id}} を削除しますか?",
|
||||
removeConfirmMessage: "設定のみを削除します。ソースディレクトリは削除されません。",
|
||||
states: {
|
||||
offlineTitle: "プラグインホストはオフラインです",
|
||||
@@ -256,8 +252,7 @@ export const pluginSettings = {
|
||||
installing: "설치 중…",
|
||||
status: { running: "실행 중", disabled: "비활성화", failed: "실패" },
|
||||
actions: {
|
||||
menu: "{{name}} 작업",
|
||||
settings: "설정",
|
||||
menu: "{{id}} 작업",
|
||||
reload: "다시 로드",
|
||||
reloading: "다시 로드 중…",
|
||||
enable: "활성화",
|
||||
@@ -268,16 +263,16 @@ export const pluginSettings = {
|
||||
removing: "제거 중…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} 설치됨",
|
||||
reload: "{{name}} 다시 로드됨",
|
||||
enable: "{{name}} 활성화됨",
|
||||
disable: "{{name}} 비활성화됨",
|
||||
remove: "{{name}} 제거됨",
|
||||
kept: "{{name}} 유지됨",
|
||||
installed: "{{id}} 설치됨",
|
||||
reload: "{{id}} 다시 로드됨",
|
||||
enable: "{{id}} 활성화됨",
|
||||
disable: "{{id}} 비활성화됨",
|
||||
remove: "{{id}} 제거됨",
|
||||
kept: "{{id}} 유지됨",
|
||||
globalEnabled: "플러그인 활성화됨",
|
||||
globalDisabled: "플러그인 비활성화됨",
|
||||
},
|
||||
removeConfirmTitle: "{{name}}을(를) 제거할까요?",
|
||||
removeConfirmTitle: "{{id}}을(를) 제거할까요?",
|
||||
removeConfirmMessage: "설정만 제거합니다. 소스 디렉터리는 삭제되지 않습니다.",
|
||||
states: {
|
||||
offlineTitle: "플러그인 호스트가 오프라인입니다",
|
||||
@@ -312,8 +307,7 @@ export const pluginSettings = {
|
||||
installing: "Instalando…",
|
||||
status: { running: "em execução", disabled: "desativado", failed: "falhou" },
|
||||
actions: {
|
||||
menu: "Ações para {{name}}",
|
||||
settings: "Configurações",
|
||||
menu: "Ações para {{id}}",
|
||||
reload: "Recarregar",
|
||||
reloading: "Recarregando…",
|
||||
enable: "Ativar",
|
||||
@@ -324,16 +318,16 @@ export const pluginSettings = {
|
||||
removing: "Removendo…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} instalado",
|
||||
reload: "{{name}} recarregado",
|
||||
enable: "{{name}} ativado",
|
||||
disable: "{{name}} desativado",
|
||||
remove: "{{name}} removido",
|
||||
kept: "{{name}} mantido",
|
||||
installed: "{{id}} instalado",
|
||||
reload: "{{id}} recarregado",
|
||||
enable: "{{id}} ativado",
|
||||
disable: "{{id}} desativado",
|
||||
remove: "{{id}} removido",
|
||||
kept: "{{id}} mantido",
|
||||
globalEnabled: "Plugins ativados",
|
||||
globalDisabled: "Plugins desativados",
|
||||
},
|
||||
removeConfirmTitle: "Remover {{name}}?",
|
||||
removeConfirmTitle: "Remover {{id}}?",
|
||||
removeConfirmMessage: "Isso remove a configuração. O diretório de origem não será excluído.",
|
||||
states: {
|
||||
offlineTitle: "O host de plugins está offline",
|
||||
@@ -368,8 +362,7 @@ export const pluginSettings = {
|
||||
installing: "Установка…",
|
||||
status: { running: "работает", disabled: "отключён", failed: "ошибка" },
|
||||
actions: {
|
||||
menu: "Действия для {{name}}",
|
||||
settings: "Настройки",
|
||||
menu: "Действия для {{id}}",
|
||||
reload: "Перезагрузить",
|
||||
reloading: "Перезагрузка…",
|
||||
enable: "Включить",
|
||||
@@ -380,16 +373,16 @@ export const pluginSettings = {
|
||||
removing: "Удаление…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "{{name}} установлен",
|
||||
reload: "{{name}} перезагружен",
|
||||
enable: "{{name}} включён",
|
||||
disable: "{{name}} отключён",
|
||||
remove: "{{name}} удалён",
|
||||
kept: "{{name}} сохранён",
|
||||
installed: "{{id}} установлен",
|
||||
reload: "{{id}} перезагружен",
|
||||
enable: "{{id}} включён",
|
||||
disable: "{{id}} отключён",
|
||||
remove: "{{id}} удалён",
|
||||
kept: "{{id}} сохранён",
|
||||
globalEnabled: "Плагины включены",
|
||||
globalDisabled: "Плагины отключены",
|
||||
},
|
||||
removeConfirmTitle: "Удалить {{name}}?",
|
||||
removeConfirmTitle: "Удалить {{id}}?",
|
||||
removeConfirmMessage: "Конфигурация будет удалена. Исходный каталог останется на месте.",
|
||||
states: {
|
||||
offlineTitle: "Хост плагинов не в сети",
|
||||
@@ -423,8 +416,7 @@ export const pluginSettings = {
|
||||
installing: "正在安装…",
|
||||
status: { running: "运行中", disabled: "已禁用", failed: "失败" },
|
||||
actions: {
|
||||
menu: "{{name}} 的操作",
|
||||
settings: "设置",
|
||||
menu: "{{id}} 的操作",
|
||||
reload: "重新加载",
|
||||
reloading: "正在重新加载…",
|
||||
enable: "启用",
|
||||
@@ -435,16 +427,16 @@ export const pluginSettings = {
|
||||
removing: "正在移除…",
|
||||
},
|
||||
feedback: {
|
||||
installed: "已安装 {{name}}",
|
||||
reload: "已重新加载 {{name}}",
|
||||
enable: "已启用 {{name}}",
|
||||
disable: "已禁用 {{name}}",
|
||||
remove: "已移除 {{name}}",
|
||||
kept: "已保留 {{name}}",
|
||||
installed: "已安装 {{id}}",
|
||||
reload: "已重新加载 {{id}}",
|
||||
enable: "已启用 {{id}}",
|
||||
disable: "已禁用 {{id}}",
|
||||
remove: "已移除 {{id}}",
|
||||
kept: "已保留 {{id}}",
|
||||
globalEnabled: "插件已启用",
|
||||
globalDisabled: "插件已禁用",
|
||||
},
|
||||
removeConfirmTitle: "移除 {{name}}?",
|
||||
removeConfirmTitle: "移除 {{id}}?",
|
||||
removeConfirmMessage: "这会移除配置,但不会删除源目录。",
|
||||
states: {
|
||||
offlineTitle: "插件主机离线",
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
useComposerFullscreen,
|
||||
type ComposerEditingSession,
|
||||
} from "@/composer/editing-session";
|
||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||
import {
|
||||
resolveComposerTrackControlClearance,
|
||||
resolveComposerTrackTailClearance,
|
||||
@@ -65,6 +66,7 @@ import { useAgentFactsLine } from "@/panels/agent-facts-line";
|
||||
import { PaneSubtitle } from "@/panels/pane-subtitle";
|
||||
import { definePanel } from "@/panels/panel-registry";
|
||||
import { RenderProfile } from "@/utils/render-profiler";
|
||||
import { useHasPluginComposerPills } from "@/plugins";
|
||||
import { buildDraftPanelDescriptor } from "@/panels/draft-panel-descriptor";
|
||||
import {
|
||||
type HostRuntimeConnectionStatus,
|
||||
@@ -79,7 +81,7 @@ import {
|
||||
deriveRouteBottomAnchorRequest,
|
||||
} from "@/screens/agent/agent-ready-screen-bottom-anchor";
|
||||
import { WorkspaceDraftAgentTab } from "@/composer/draft/workspace-tab";
|
||||
import { AgentTracks } from "@/panels/agent-tracks";
|
||||
import { AgentTracks, hasAgentTracks } from "@/panels/agent-tracks";
|
||||
import { ParentAgentCallout, useParentAgentId } from "@/subagents/parent";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys";
|
||||
@@ -1108,10 +1110,18 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
parentAgentId: agentId,
|
||||
rows: subagentRows,
|
||||
});
|
||||
const hasPluginComposerPills = useHasPluginComposerPills(serverId, workspaceId, agentId);
|
||||
const hasActiveComposer = !agentState.archivedAt && !isArchivingCurrentAgent;
|
||||
const parentAgentId = useParentAgentId(serverId, agentId);
|
||||
// With a composer the parent is a pill above it; without one, the box takes the composer's place.
|
||||
const parentPillAgentId = showsComposer ? parentAgentId : null;
|
||||
const hasVisibleAgentTracks = hasAgentTracks({
|
||||
parentAgentId: parentPillAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus: archiveFinishedSubagents.status,
|
||||
hasPluginComposerPills,
|
||||
});
|
||||
const rawAgentInputDraft = useAgentInputDraft({
|
||||
draftKey: buildDraftStoreKey({
|
||||
serverId,
|
||||
@@ -1189,11 +1199,13 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
<AgentStreamSection
|
||||
streamViewRef={streamViewRef}
|
||||
serverId={serverId}
|
||||
workspaceId={workspaceId}
|
||||
agentId={agentId}
|
||||
agent={effectiveAgent}
|
||||
routeBottomAnchorRequest={routeBottomAnchorRequest}
|
||||
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
|
||||
hasActiveComposer={hasActiveComposer}
|
||||
hasVisibleAgentTracks={hasVisibleAgentTracks}
|
||||
toast={toastApi}
|
||||
onOpenWorkspaceFile={onOpenWorkspaceFile}
|
||||
/>
|
||||
@@ -1208,6 +1220,7 @@ const ChatAgentReadyContent = memo(function ChatAgentReadyContent({
|
||||
tasks={tasks}
|
||||
archiveFinishedStatus={archiveFinishedSubagents.status}
|
||||
onArchiveFinished={archiveFinishedSubagents.archiveFinished}
|
||||
hasPluginComposerPills={hasPluginComposerPills}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
@@ -1353,31 +1366,36 @@ function SheetAgentFacts({ serverId, agentId }: { serverId: string; agentId: str
|
||||
const AgentStreamSection = memo(function AgentStreamSection({
|
||||
streamViewRef,
|
||||
serverId,
|
||||
workspaceId,
|
||||
agentId,
|
||||
agent,
|
||||
routeBottomAnchorRequest,
|
||||
hasAppliedAuthoritativeHistory,
|
||||
hasActiveComposer,
|
||||
hasVisibleAgentTracks,
|
||||
toast,
|
||||
onOpenWorkspaceFile,
|
||||
}: {
|
||||
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
agentId?: string;
|
||||
agent: AgentScreenAgent;
|
||||
routeBottomAnchorRequest: RouteBottomAnchorRequest;
|
||||
hasAppliedAuthoritativeHistory: boolean;
|
||||
hasActiveComposer: boolean;
|
||||
hasVisibleAgentTracks: boolean;
|
||||
toast: ReturnType<typeof useToastHost>["api"];
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const isCompactFormFactor = useIsCompactFormFactor();
|
||||
// The track bar's space is reserved whenever the bar is mounted, pills or not: pills come and go
|
||||
// every turn, and the transcript must not jump with them.
|
||||
const bottomOverlayTailClearance = hasActiveComposer
|
||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||
const hasVisibleComposerTracks =
|
||||
hasActiveComposer && (hasVisibleAgentTracks || hasWorkspaceDiffStat);
|
||||
const bottomOverlayTailClearance = hasVisibleComposerTracks
|
||||
? resolveComposerTrackTailClearance(isCompactFormFactor)
|
||||
: 0;
|
||||
const bottomOverlayControlClearance = hasActiveComposer
|
||||
const bottomOverlayControlClearance = hasVisibleComposerTracks
|
||||
? resolveComposerTrackControlClearance(isCompactFormFactor)
|
||||
: 0;
|
||||
const streamItemsRaw = useAgentStreamTail(serverId, agentId);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentAddress } from "@getpaseo/protocol/addresses";
|
||||
import { memo, useCallback, type ReactElement } from "react";
|
||||
import { WorkspaceDiffStatPill } from "@/composer/diff-stat-pill";
|
||||
import { useWorkspaceHasDiffStat } from "@/composer/workspace-diff-stat";
|
||||
import { AgentTaskList } from "@/composer/task-list";
|
||||
import { ComposerTrackBar } from "@/composer/tracks";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
@@ -39,6 +40,7 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
onArchiveFinished,
|
||||
hasPluginComposerPills,
|
||||
}: {
|
||||
serverId: string;
|
||||
workspaceId: string;
|
||||
@@ -48,8 +50,10 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
tasks: TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
onArchiveFinished: () => void;
|
||||
}): ReactElement {
|
||||
hasPluginComposerPills: boolean;
|
||||
}): ReactElement | null {
|
||||
const { tabId } = usePaneContext();
|
||||
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId });
|
||||
@@ -108,6 +112,19 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
});
|
||||
}, [isCompact, openInSidePane, workspaceKey]);
|
||||
|
||||
if (
|
||||
!hasWorkspaceDiffStat &&
|
||||
!hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills,
|
||||
})
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ComposerTrackBar>
|
||||
{parentAgentId ? <ParentAgentPill serverId={serverId} parentAgentId={parentAgentId} /> : null}
|
||||
@@ -137,3 +154,25 @@ export const AgentTracks = memo(function AgentTracks({
|
||||
</ComposerTrackBar>
|
||||
);
|
||||
});
|
||||
|
||||
export function hasAgentTracks({
|
||||
parentAgentId,
|
||||
subagentRows,
|
||||
tasks,
|
||||
archiveFinishedStatus,
|
||||
hasPluginComposerPills = false,
|
||||
}: {
|
||||
parentAgentId: string | null;
|
||||
subagentRows: readonly SubagentRow[];
|
||||
tasks: readonly TodoEntry[] | undefined;
|
||||
archiveFinishedStatus: ArchiveFinishedStatus;
|
||||
hasPluginComposerPills?: boolean;
|
||||
}): boolean {
|
||||
return (
|
||||
parentAgentId !== null ||
|
||||
subagentRows.length > 0 ||
|
||||
Boolean(tasks?.length) ||
|
||||
archiveFinishedStatus.kind !== "idle" ||
|
||||
hasPluginComposerPills
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from "react";
|
||||
import { Pressable, Text, View, useWindowDimensions } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { AlertCircle, ChevronDown, ChevronUp, MoreHorizontal } from "lucide-react-native";
|
||||
import { AlertCircle, ChevronDown, MoreHorizontal } from "lucide-react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
MenuRoot,
|
||||
@@ -247,11 +247,7 @@ function ButtonControl({ view }: { view: ButtonView }) {
|
||||
const composer = entry.placement === "composer";
|
||||
const disabled = button.disabled || entry.pending;
|
||||
const expanded = button.behavior.kind !== "action";
|
||||
// A chevron marks a button that opens something. Composer pills keep their label on compact and
|
||||
// open their surface above, so theirs points up and shows at every width; compact headers drop
|
||||
// the label and the chevron with it.
|
||||
const chevron = expanded && (composer || !props.layout.compact);
|
||||
const Chevron = composer ? ChevronUp : ChevronDown;
|
||||
const chevron = !composer && !props.layout.compact && expanded;
|
||||
let label = button.label;
|
||||
if (composer) label = button.label ?? button.title;
|
||||
else if (props.layout.compact) label = undefined;
|
||||
@@ -288,7 +284,7 @@ function ButtonControl({ view }: { view: ButtonView }) {
|
||||
) : null}
|
||||
{chevron ? (
|
||||
<View testID="plugin-button-chevron">
|
||||
<Chevron size={12} color={props.theme.colors.foregroundMuted} />
|
||||
<ChevronDown size={12} color={props.theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
) : null}
|
||||
</>
|
||||
@@ -563,6 +559,14 @@ function useButtons(serverId: string, workspaceId: string, agentId: string | nul
|
||||
);
|
||||
}
|
||||
|
||||
export function useHasPluginComposerPills(
|
||||
serverId: string,
|
||||
workspaceId: string,
|
||||
agentId: string,
|
||||
): boolean {
|
||||
return useButtons(serverId, workspaceId, agentId).length > 0;
|
||||
}
|
||||
|
||||
export function PluginComposerPills({
|
||||
serverId,
|
||||
workspaceId,
|
||||
|
||||
@@ -3,4 +3,9 @@ export { buildLegacyPluginSurfaceRedirectRoute } from "./routes";
|
||||
export { PluginSurfaceScreen } from "./surface-screen";
|
||||
export { usePluginAttachmentPicker } from "./attachments/picker";
|
||||
export { PluginResourceAttachmentPill } from "./attachments/pill";
|
||||
export { PluginComposerPills, PluginHeaderButtons, usePluginWorkspaceMenu } from "./buttons/view";
|
||||
export {
|
||||
PluginComposerPills,
|
||||
PluginHeaderButtons,
|
||||
useHasPluginComposerPills,
|
||||
usePluginWorkspaceMenu,
|
||||
} from "./buttons/view";
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { PluginListItem } from "@getpaseo/protocol/messages";
|
||||
import { useFetchQuery } from "@/data/query";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
|
||||
/** A host's configured plugins, as listed on Settings → Plugins. One cache entry per host. */
|
||||
export function usePluginList(serverId: string, options: { enabled?: boolean } = {}) {
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const connected = useHostRuntimeIsConnected(serverId);
|
||||
const supported = useHostFeature(serverId, "pluginManagement");
|
||||
return useFetchQuery({
|
||||
queryKey: ["plugins", serverId] as const,
|
||||
queryFn: async () => {
|
||||
if (!client) throw new Error("Plugin host is offline");
|
||||
return client.listPlugins();
|
||||
},
|
||||
enabled: (options.enabled ?? true) && Boolean(client && connected && supported),
|
||||
dataShape: "list",
|
||||
staleTimeMs: 1_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** The manifest's display name, or the plugin id when it has none. */
|
||||
export function pluginDisplayName(plugin: Pick<PluginListItem, "id" | "name">): string {
|
||||
return plugin.name ?? plugin.id;
|
||||
}
|
||||
|
||||
/** The display name of one configured plugin; its id until the list loads. */
|
||||
export function usePluginDisplayName(
|
||||
target: { serverId: string; pluginId: string } | null,
|
||||
): string | undefined {
|
||||
const plugins = usePluginList(target?.serverId ?? "", { enabled: target !== null });
|
||||
if (!target) return undefined;
|
||||
const plugin = plugins.data?.find((candidate) => candidate.id === target.pluginId);
|
||||
return plugin ? pluginDisplayName(plugin) : target.pluginId;
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Platform, ScrollView, Text, View } from "react-native";
|
||||
import { Platform, Text, View } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { router } from "expo-router";
|
||||
import { ArrowLeft, Settings } from "lucide-react-native";
|
||||
import { ArrowLeft } from "lucide-react-native";
|
||||
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ToolbarButton, paneContentToolbarIconSize } from "@/components/ui/pane-content-toolbar";
|
||||
import { SegmentedControl, type SegmentedControlOption } from "@/components/ui/segmented-control";
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import { PluginIconView } from "../icons";
|
||||
import { useInstalledPlugin } from "../registry";
|
||||
import type { InstalledPlugin } from "../types";
|
||||
import { PluginInstallationProvider } from "../installation-provider";
|
||||
import { SurfaceErrorBoundary } from "../surface-error-boundary";
|
||||
import { toPluginTheme } from "../theme";
|
||||
@@ -26,84 +23,45 @@ interface SettingsIdentity {
|
||||
screenId: string;
|
||||
}
|
||||
|
||||
const ThemedSettingsIcon = withUnistyles(Settings);
|
||||
const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
function PluginSettingsMenuItem({
|
||||
serverId,
|
||||
pluginId,
|
||||
screenId,
|
||||
title,
|
||||
disabled,
|
||||
}: SettingsIdentity & { title: string; disabled?: boolean }) {
|
||||
const open = useCallback(
|
||||
() => router.push(buildPluginSettingsRoute(serverId, pluginId, screenId)),
|
||||
[serverId, pluginId, screenId],
|
||||
);
|
||||
return (
|
||||
<DropdownMenuItem onSelect={open} disabled={disabled}>
|
||||
{title}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
/** Opens the plugin's settings at its first screen. Renders nothing when it has none. */
|
||||
export function PluginSettingsButton({ serverId, pluginId }: Omit<SettingsIdentity, "screenId">) {
|
||||
const { t } = useTranslation();
|
||||
export function PluginSettingsMenuItems({
|
||||
serverId,
|
||||
pluginId,
|
||||
disabled,
|
||||
}: Omit<SettingsIdentity, "screenId"> & { disabled?: boolean }) {
|
||||
const plugin = useInstalledPlugin(serverId, pluginId);
|
||||
const supported = useHostFeature(serverId, "pluginSettings");
|
||||
const firstScreenId = plugin?.settingsScreens[0]?.id;
|
||||
const open = useCallback(() => {
|
||||
if (firstScreenId) router.push(buildPluginSettingsRoute(serverId, pluginId, firstScreenId));
|
||||
}, [serverId, pluginId, firstScreenId]);
|
||||
const compact = useIsCompactFormFactor();
|
||||
if (!supported || !firstScreenId) return null;
|
||||
if (!supported || !plugin) return null;
|
||||
return (
|
||||
<ToolbarButton
|
||||
label={t("settings.plugins.actions.settings")}
|
||||
compact={compact}
|
||||
onPress={open}
|
||||
testID={`plugin-settings-${pluginId}`}
|
||||
>
|
||||
<ThemedSettingsIcon size={paneContentToolbarIconSize(compact)} uniProps={mutedIconMapping} />
|
||||
</ToolbarButton>
|
||||
);
|
||||
}
|
||||
|
||||
function renderNoIcon() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** A plugin-supplied icon component may throw; its tab then shows the label alone. */
|
||||
function SettingsTabIcon({
|
||||
plugin,
|
||||
icon,
|
||||
size,
|
||||
color,
|
||||
}: {
|
||||
plugin: InstalledPlugin;
|
||||
icon: InstalledPlugin["settingsScreens"][number]["icon"];
|
||||
size: number;
|
||||
color: string;
|
||||
}) {
|
||||
return (
|
||||
<SurfaceErrorBoundary installation={plugin} Surface={icon} renderError={renderNoIcon}>
|
||||
<PluginIconView icon={icon} size={size} color={color} />
|
||||
</SurfaceErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One tab per registered settings screen. A plugin with a single screen shows no tabs.
|
||||
* Render inside the installation's provider: tab icons may be plugin components.
|
||||
*/
|
||||
function SettingsTabs({ plugin, screenId }: { plugin: InstalledPlugin; screenId: string }) {
|
||||
const options = useMemo<SegmentedControlOption<string>[]>(
|
||||
() =>
|
||||
plugin.settingsScreens.map((screen) => ({
|
||||
value: screen.id,
|
||||
label: plugin.i18n.resolve(screen.title, plugin.locale),
|
||||
icon: ({ color, size }) => (
|
||||
<SettingsTabIcon plugin={plugin} icon={screen.icon} size={size} color={color} />
|
||||
),
|
||||
testID: `plugin-settings-tab-${screen.id}`,
|
||||
})),
|
||||
[plugin],
|
||||
);
|
||||
// Tabs swap the screen in place, so Back still returns to the Plugins page.
|
||||
const select = useCallback((id: string) => router.setParams({ screenId: id }), []);
|
||||
if (options.length < 2) return null;
|
||||
return (
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
style={styles.tabs}
|
||||
testID="plugin-settings-tabs"
|
||||
>
|
||||
<SegmentedControl options={options} value={screenId} onValueChange={select} />
|
||||
</ScrollView>
|
||||
<>
|
||||
{plugin.settingsScreens.map((screen) => (
|
||||
<PluginSettingsMenuItem
|
||||
key={screen.id}
|
||||
serverId={serverId}
|
||||
pluginId={pluginId}
|
||||
screenId={screen.id}
|
||||
title={plugin.i18n.resolve(screen.title, plugin.locale)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,19 +112,18 @@ function SettingsContent({
|
||||
return <Text style={styles.message}>{t("settings.plugins.screens.unavailable")}</Text>;
|
||||
const Component = screen.Component;
|
||||
return (
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<View>
|
||||
<SettingsTabs plugin={plugin} screenId={screen.id} />
|
||||
<SurfaceErrorBoundary
|
||||
installation={plugin}
|
||||
Surface={Component}
|
||||
resetKey={attempt}
|
||||
renderError={renderError}
|
||||
>
|
||||
<View>
|
||||
<SurfaceErrorBoundary
|
||||
installation={plugin}
|
||||
Surface={Component}
|
||||
resetKey={attempt}
|
||||
renderError={renderError}
|
||||
>
|
||||
<PluginInstallationProvider plugin={plugin}>
|
||||
<Component theme={theme} layout={layout} host={host} />
|
||||
</SurfaceErrorBoundary>
|
||||
</View>
|
||||
</PluginInstallationProvider>
|
||||
</PluginInstallationProvider>
|
||||
</SurfaceErrorBoundary>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
const ThemedSettingsContent = withUnistyles(SettingsContent);
|
||||
@@ -196,6 +153,5 @@ export function PluginSettingsContent({
|
||||
}
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
message: { color: theme.colors.foregroundMuted },
|
||||
tabs: { flexGrow: 0, marginBottom: theme.spacing[4] },
|
||||
backButton: { alignSelf: "flex-start", paddingHorizontal: 0, marginBottom: theme.spacing[4] },
|
||||
}));
|
||||
|
||||
@@ -513,23 +513,6 @@ describe("ReplicaCache", () => {
|
||||
expect(storage.changes[0]?.deletes).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps a background agent background across cache reopen", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
const helper = { ...agent("helper"), background: true };
|
||||
const visible = agent("visible");
|
||||
writer.commitDirectoryMutations(SERVER_ID, [
|
||||
{ kind: "agent", type: "upsert", id: helper.id, value: helper },
|
||||
{ kind: "agent", type: "upsert", id: visible.id, value: visible },
|
||||
]);
|
||||
await writer.flush();
|
||||
|
||||
const restored = await createCache(storage).readDirectory(SERVER_ID);
|
||||
|
||||
expect(restored.agents.get("helper")?.background).toBe(true);
|
||||
expect(restored.agents.get("visible")?.background).toBe(false);
|
||||
});
|
||||
|
||||
it("round-trips identified and anonymous open turns without changing protocol status", async () => {
|
||||
const storage = new MemoryStorage();
|
||||
const writer = createCache(storage);
|
||||
|
||||
@@ -247,8 +247,6 @@ const StoredAgentSnapshotSchema = z.strictObject({
|
||||
lastError: z.string().optional(),
|
||||
title: z.string().nullable(),
|
||||
labels: z.record(z.string(), z.string()),
|
||||
// Optional: clients without background_agents never received a background agent to cache.
|
||||
background: z.boolean().optional(),
|
||||
requiresAttention: z.boolean().optional(),
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: IsoDateSchema.nullable().optional(),
|
||||
@@ -614,31 +612,6 @@ function serializeAgentTurn(agent: Agent): NonNullable<StoredAgent["turn"]> {
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAgentCapabilities(
|
||||
capabilities: Agent["capabilities"],
|
||||
): StoredAgent["snapshot"]["capabilities"] {
|
||||
return {
|
||||
supportsStreaming: capabilities.supportsStreaming,
|
||||
supportsSessionPersistence: capabilities.supportsSessionPersistence,
|
||||
...(capabilities.supportsSessionListing !== undefined
|
||||
? { supportsSessionListing: capabilities.supportsSessionListing }
|
||||
: {}),
|
||||
supportsDynamicModes: capabilities.supportsDynamicModes,
|
||||
supportsMcpServers: capabilities.supportsMcpServers,
|
||||
supportsReasoningStream: capabilities.supportsReasoningStream,
|
||||
supportsToolInvocations: capabilities.supportsToolInvocations,
|
||||
...(capabilities.supportsRewindConversation !== undefined
|
||||
? { supportsRewindConversation: capabilities.supportsRewindConversation }
|
||||
: {}),
|
||||
...(capabilities.supportsRewindFiles !== undefined
|
||||
? { supportsRewindFiles: capabilities.supportsRewindFiles }
|
||||
: {}),
|
||||
...(capabilities.supportsRewindBoth !== undefined
|
||||
? { supportsRewindBoth: capabilities.supportsRewindBoth }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
function serializeAgent(agent: Agent): StoredAgent {
|
||||
const snapshot = {
|
||||
id: agent.id,
|
||||
@@ -659,7 +632,26 @@ function serializeAgent(agent: Agent): StoredAgent {
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
capabilities: serializeAgentCapabilities(agent.capabilities),
|
||||
capabilities: {
|
||||
supportsStreaming: agent.capabilities.supportsStreaming,
|
||||
supportsSessionPersistence: agent.capabilities.supportsSessionPersistence,
|
||||
...(agent.capabilities.supportsSessionListing !== undefined
|
||||
? { supportsSessionListing: agent.capabilities.supportsSessionListing }
|
||||
: {}),
|
||||
supportsDynamicModes: agent.capabilities.supportsDynamicModes,
|
||||
supportsMcpServers: agent.capabilities.supportsMcpServers,
|
||||
supportsReasoningStream: agent.capabilities.supportsReasoningStream,
|
||||
supportsToolInvocations: agent.capabilities.supportsToolInvocations,
|
||||
...(agent.capabilities.supportsRewindConversation !== undefined
|
||||
? { supportsRewindConversation: agent.capabilities.supportsRewindConversation }
|
||||
: {}),
|
||||
...(agent.capabilities.supportsRewindFiles !== undefined
|
||||
? { supportsRewindFiles: agent.capabilities.supportsRewindFiles }
|
||||
: {}),
|
||||
...(agent.capabilities.supportsRewindBoth !== undefined
|
||||
? { supportsRewindBoth: agent.capabilities.supportsRewindBoth }
|
||||
: {}),
|
||||
},
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: [],
|
||||
pendingPermissions: [],
|
||||
@@ -667,7 +659,6 @@ function serializeAgent(agent: Agent): StoredAgent {
|
||||
...(agent.lastError ? { lastError: agent.lastError } : {}),
|
||||
title: agent.title,
|
||||
labels: agent.labels,
|
||||
...(agent.background ? { background: true } : {}),
|
||||
requiresAttention: agent.requiresAttention ?? false,
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
||||
|
||||
@@ -114,7 +114,7 @@ import {
|
||||
HostTerminalsPage,
|
||||
} from "@/screens/settings/host-page";
|
||||
import { PluginSettingsContent } from "@/plugins/settings";
|
||||
import { usePluginDisplayName } from "@/plugins/plugin-list";
|
||||
import { useInstalledPlugins } from "@/plugins/registry";
|
||||
import { HostPluginsPage } from "@/screens/settings/plugins-page";
|
||||
import { MetadataGenerationPage } from "@/screens/settings/metadata-generation-page";
|
||||
import { HostVoicePage } from "@/screens/settings/voice/voice-page";
|
||||
@@ -1267,10 +1267,15 @@ export default function SettingsScreen({ view }: SettingsScreenProps) {
|
||||
returnFromSettings({ kind: "root" });
|
||||
}, []);
|
||||
|
||||
// The header names the plugin; its tabs name the open settings screen.
|
||||
const pluginDisplayName = usePluginDisplayName(view.kind === "plugin" ? view : null);
|
||||
const installedPlugins = useInstalledPlugins();
|
||||
const detailTitle = ((): string | undefined => {
|
||||
if (view.kind === "plugin") return pluginDisplayName;
|
||||
if (view.kind === "plugin") {
|
||||
const plugin = installedPlugins.find(
|
||||
(candidate) => candidate.serverId === view.serverId && candidate.id === view.pluginId,
|
||||
);
|
||||
const screen = plugin?.settingsScreens.find((candidate) => candidate.id === view.screenId);
|
||||
return `${view.pluginId} · ${plugin && screen ? plugin.i18n.resolve(screen.title, plugin.locale) : t("settings.title")}`;
|
||||
}
|
||||
if (view.kind === "host") {
|
||||
const item = HOST_SECTION_ITEMS.find((s) => s.id === view.section);
|
||||
return item ? t(item.labelKey) : undefined;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { formatPluginInstallation } from "@getpaseo/protocol/plugin-source-reference";
|
||||
import { PluginSettingsButton } from "@/plugins/settings";
|
||||
import { PluginSettingsMenuItems } from "@/plugins/settings";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View } from "react-native";
|
||||
@@ -29,10 +29,10 @@ import { SettingsSection } from "@/components/settings/headings/settings-section
|
||||
import { resolvePluginPageState } from "@/screens/settings/plugins-page-state";
|
||||
import { openPluginInstallForm } from "@/screens/settings/plugin-install-form-model";
|
||||
import { pluginRegistry, useInstalledPlugins } from "@/plugins/registry";
|
||||
import { pluginDisplayName, usePluginList } from "@/plugins/plugin-list";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
|
||||
const pluginQueryKey = (serverId: string) => ["plugins", serverId] as const;
|
||||
const PLUGIN_SOURCE_DOCS_URL = "https://paseo.sh/docs/plugins/reference#plugin-sources";
|
||||
type PluginRowAction = "reload" | "enable" | "disable" | "remove";
|
||||
|
||||
@@ -49,6 +49,7 @@ function pluginRowAction(action: string | undefined): PluginRowAction | undefine
|
||||
|
||||
function PluginActionsMenu({
|
||||
plugin,
|
||||
serverId,
|
||||
pending,
|
||||
pendingAction,
|
||||
onAction,
|
||||
@@ -56,18 +57,19 @@ function PluginActionsMenu({
|
||||
supportsLogs,
|
||||
}: {
|
||||
plugin: PluginListItem;
|
||||
serverId: string;
|
||||
pending: boolean;
|
||||
pendingAction?: PluginRowAction;
|
||||
onAction(action: PluginRowAction, plugin: PluginListItem): void;
|
||||
onOpenLogs(plugin: PluginListItem): void;
|
||||
onOpenLogs(pluginId: string): void;
|
||||
supportsLogs: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const reload = useCallback(() => onAction("reload", plugin), [onAction, plugin]);
|
||||
const remove = useCallback(() => onAction("remove", plugin), [onAction, plugin]);
|
||||
const openLogs = useCallback(() => onOpenLogs(plugin), [onOpenLogs, plugin]);
|
||||
const openLogs = useCallback(() => onOpenLogs(plugin.id), [onOpenLogs, plugin.id]);
|
||||
const removeIcon = useMemo(() => <Trash2 size={16} color={styles.dangerIcon.color} />, []);
|
||||
const menuLabel = t("settings.plugins.actions.menu", { name: pluginDisplayName(plugin) });
|
||||
const menuLabel = t("settings.plugins.actions.menu", { id: plugin.id });
|
||||
|
||||
return (
|
||||
<DropdownMenu compactMode="sheet">
|
||||
@@ -81,6 +83,7 @@ function PluginActionsMenu({
|
||||
<MoreHorizontal size={18} color={styles.menuIcon.color} />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" width={220} sheetTitle={menuLabel}>
|
||||
<PluginSettingsMenuItems serverId={serverId} pluginId={plugin.id} disabled={pending} />
|
||||
{supportsLogs ? (
|
||||
<DropdownMenuItem onSelect={openLogs} disabled={pending}>
|
||||
{t("settings.plugins.logs.action")}
|
||||
@@ -125,7 +128,7 @@ function PluginRow({
|
||||
pending: boolean;
|
||||
pendingAction?: PluginRowAction;
|
||||
onAction(action: PluginRowAction, plugin: PluginListItem): void;
|
||||
onOpenLogs(plugin: PluginListItem): void;
|
||||
onOpenLogs(pluginId: string): void;
|
||||
supportsLogs: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
@@ -142,14 +145,10 @@ function PluginRow({
|
||||
() => <StatusBadge label={statusLabel} variant={badgeVariant} />,
|
||||
[badgeVariant, statusLabel],
|
||||
);
|
||||
const name = pluginDisplayName(plugin);
|
||||
// A display name hides the id that the CLI and plugin routes use, so show it beneath.
|
||||
const shownId = name === plugin.id ? null : plugin.id;
|
||||
const hint = useMemo(
|
||||
() =>
|
||||
shownId || plugin.description || plugin.installation ? (
|
||||
plugin.description || plugin.installation ? (
|
||||
<View>
|
||||
{shownId ? <Text style={styles.pluginSource}>{shownId}</Text> : null}
|
||||
{plugin.description ? (
|
||||
<Text style={styles.pluginDescription}>{plugin.description}</Text>
|
||||
) : null}
|
||||
@@ -158,21 +157,20 @@ function PluginRow({
|
||||
) : null}
|
||||
</View>
|
||||
) : undefined,
|
||||
[shownId, plugin.description, plugin.installation],
|
||||
[plugin.description, plugin.installation],
|
||||
);
|
||||
const toggleLabel = `${name}: ${t(
|
||||
const toggleLabel = `${plugin.id}: ${t(
|
||||
plugin.enabled ? "settings.plugins.actions.disable" : "settings.plugins.actions.enable",
|
||||
)}`;
|
||||
return (
|
||||
<SettingsRow
|
||||
label={name}
|
||||
label={plugin.id}
|
||||
labelAccessory={statusBadge}
|
||||
hint={hint}
|
||||
error={clientError ?? plugin.error}
|
||||
testID={`plugin-row-${plugin.id}`}
|
||||
>
|
||||
<View style={styles.pluginControls} accessibilityLabel={`${name} ${statusLabel}`}>
|
||||
<PluginSettingsButton serverId={serverId} pluginId={plugin.id} />
|
||||
<View style={styles.pluginControls} accessibilityLabel={`${plugin.id} ${statusLabel}`}>
|
||||
<Switch
|
||||
value={plugin.enabled}
|
||||
onValueChange={toggle}
|
||||
@@ -181,6 +179,7 @@ function PluginRow({
|
||||
/>
|
||||
<PluginActionsMenu
|
||||
plugin={plugin}
|
||||
serverId={serverId}
|
||||
pending={pending}
|
||||
pendingAction={pendingAction}
|
||||
onAction={onAction}
|
||||
@@ -194,16 +193,15 @@ function PluginRow({
|
||||
|
||||
function PluginLogsSheet({
|
||||
client,
|
||||
plugin,
|
||||
pluginId,
|
||||
serverId,
|
||||
onClose,
|
||||
}: {
|
||||
client: NonNullable<ReturnType<typeof useHostRuntimeClient>>;
|
||||
plugin: PluginListItem;
|
||||
pluginId: string;
|
||||
serverId: string;
|
||||
onClose(): void;
|
||||
}) {
|
||||
const pluginId = plugin.id;
|
||||
const { t } = useTranslation();
|
||||
const logs = useFetchQuery({
|
||||
queryKey: ["plugin-logs", serverId, pluginId],
|
||||
@@ -216,7 +214,7 @@ function PluginLogsSheet({
|
||||
}, [logs]);
|
||||
const header = useMemo<SheetHeader>(
|
||||
() => ({
|
||||
title: t("settings.plugins.logs.title", { name: pluginDisplayName(plugin) }),
|
||||
title: t("settings.plugins.logs.title", { id: pluginId }),
|
||||
actions: (
|
||||
<Button variant="outline" onPress={refresh} disabled={logs.isFetching}>
|
||||
{logs.isFetching
|
||||
@@ -225,7 +223,7 @@ function PluginLogsSheet({
|
||||
</Button>
|
||||
),
|
||||
}),
|
||||
[logs.isFetching, plugin, refresh, t],
|
||||
[logs.isFetching, pluginId, refresh, t],
|
||||
);
|
||||
|
||||
let content = <Text style={styles.logsState}>{t("settings.plugins.logs.loading")}</Text>;
|
||||
@@ -278,6 +276,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
const { config, patchConfig } = useDaemonConfig(serverId);
|
||||
const refreshQueue = useRef(Promise.resolve());
|
||||
useInstalledPlugins();
|
||||
const queryKey = useMemo(() => pluginQueryKey(serverId), [serverId]);
|
||||
const [installForm] = useState(openPluginInstallForm);
|
||||
const installState = useSyncExternalStore(
|
||||
installForm.subscribe,
|
||||
@@ -294,11 +293,20 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
),
|
||||
[t],
|
||||
);
|
||||
const [logsPlugin, setLogsPlugin] = useState<PluginListItem | null>(null);
|
||||
const [logsPluginId, setLogsPluginId] = useState<string | null>(null);
|
||||
const [feedback, setFeedback] = useState<{ kind: "success" | "error"; message: string } | null>(
|
||||
null,
|
||||
);
|
||||
const plugins = usePluginList(serverId);
|
||||
const plugins = useFetchQuery({
|
||||
queryKey,
|
||||
queryFn: async () => {
|
||||
if (!client) throw new Error("Plugin host is offline");
|
||||
return client.listPlugins();
|
||||
},
|
||||
enabled: Boolean(client && connected && supported),
|
||||
dataShape: "list",
|
||||
staleTimeMs: 1_000,
|
||||
});
|
||||
const refetchPlugins = plugins.refetch;
|
||||
const refresh = useCallback(async () => {
|
||||
if (!client) return;
|
||||
@@ -348,28 +356,27 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
run: async () => {
|
||||
const installed = await client.installPluginSource(input);
|
||||
installForm.reset();
|
||||
return t("settings.plugins.feedback.installed", { name: pluginDisplayName(installed) });
|
||||
return t("settings.plugins.feedback.installed", { id: installed.id });
|
||||
},
|
||||
});
|
||||
}, [client, installForm, installState.canSubmit, mutation, sourceInstallSupported, t]);
|
||||
const action = useCallback(
|
||||
(name: PluginRowAction, plugin: PluginListItem) => {
|
||||
if (!client) return;
|
||||
const pluginName = pluginDisplayName(plugin);
|
||||
const run = async () => {
|
||||
if (name === "remove") {
|
||||
const confirmed = await confirmDialog({
|
||||
title: t("settings.plugins.removeConfirmTitle", { name: pluginName }),
|
||||
title: t("settings.plugins.removeConfirmTitle", { id: plugin.id }),
|
||||
message: t("settings.plugins.removeConfirmMessage"),
|
||||
confirmLabel: t("settings.plugins.actions.remove"),
|
||||
destructive: true,
|
||||
});
|
||||
if (!confirmed) return t("settings.plugins.feedback.kept", { name: pluginName });
|
||||
if (!confirmed) return t("settings.plugins.feedback.kept", { id: plugin.id });
|
||||
await client.removePlugin(plugin.id);
|
||||
} else {
|
||||
await client[`${name}Plugin`](plugin.id);
|
||||
}
|
||||
return t(`settings.plugins.feedback.${name}`, { name: pluginName });
|
||||
return t(`settings.plugins.feedback.${name}`, { id: plugin.id });
|
||||
};
|
||||
setFeedback(null);
|
||||
mutation.mutate({ action: name, pluginId: plugin.id, run });
|
||||
@@ -401,7 +408,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
const retry = useCallback(() => {
|
||||
void refetchPlugins();
|
||||
}, [refetchPlugins]);
|
||||
const closeLogs = useCallback(() => setLogsPlugin(null), []);
|
||||
const closeLogs = useCallback(() => setLogsPluginId(null), []);
|
||||
|
||||
if (pageState === "offline") {
|
||||
return (
|
||||
@@ -447,7 +454,7 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
pending={mutation.isPending}
|
||||
pendingAction={pending ? pluginRowAction(mutation.variables?.action) : undefined}
|
||||
onAction={action}
|
||||
onOpenLogs={setLogsPlugin}
|
||||
onOpenLogs={setLogsPluginId}
|
||||
supportsLogs={logsSupported}
|
||||
/>
|
||||
);
|
||||
@@ -521,10 +528,10 @@ export function HostPluginsPage({ serverId }: { serverId: string }) {
|
||||
) : null}
|
||||
{catalogContent}
|
||||
</SettingsSection>
|
||||
{client && logsPlugin ? (
|
||||
{client && logsPluginId ? (
|
||||
<PluginLogsSheet
|
||||
client={client}
|
||||
plugin={logsPlugin}
|
||||
pluginId={logsPluginId}
|
||||
serverId={serverId}
|
||||
onClose={closeLogs}
|
||||
/>
|
||||
|
||||
@@ -97,8 +97,6 @@ export interface Agent {
|
||||
attentionReason?: "finished" | "error" | "permission" | null;
|
||||
attentionTimestamp?: Date | null;
|
||||
archivedAt?: Date | null;
|
||||
/** Background agents stay out of tabs, the subagent track and agent lists. Open them by id. */
|
||||
background?: boolean;
|
||||
parentAgentId: string | null;
|
||||
labels: Record<string, string>;
|
||||
projectPlacement?: ProjectPlacementPayload | null;
|
||||
|
||||
@@ -246,22 +246,6 @@ describe("selectSubagentsForParent", () => {
|
||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
||||
});
|
||||
|
||||
it("excludes background children from the track", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent-a" }),
|
||||
makeAgent({ id: "child-a", parentAgentId: "parent-a" }),
|
||||
makeAgent({ id: "background-child", parentAgentId: "parent-a", background: true }),
|
||||
]);
|
||||
|
||||
const rows = selectSubagentsForParent(
|
||||
useSessionStore.getState(),
|
||||
{ serverId: SERVER_ID, parentAgentId: "parent-a" },
|
||||
EMPTY_PENDING_ARCHIVE_IDS,
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
|
||||
});
|
||||
|
||||
it("excludes siblings, unrelated agents, and grandchildren", () => {
|
||||
setAgents([
|
||||
makeAgent({ id: "parent-a" }),
|
||||
|
||||
@@ -82,7 +82,6 @@ export function selectSubagentsForParent(
|
||||
const parent = getParentAddressFromLabels(agent.labels, serverId);
|
||||
if (
|
||||
agent.archivedAt ||
|
||||
agent.background ||
|
||||
pendingArchiveIds.has(`${serverId}:${agent.id}`) ||
|
||||
parent?.serverId !== params.serverId ||
|
||||
parent.agentId !== params.parentAgentId
|
||||
|
||||
@@ -85,7 +85,6 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
|
||||
attentionReason: agent.attentionReason ?? null,
|
||||
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
|
||||
archivedAt: agent.archivedAt?.toISOString() ?? null,
|
||||
...(agent.background ? { background: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,7 +133,6 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
|
||||
attentionReason: snapshot.attentionReason ?? null,
|
||||
attentionTimestamp,
|
||||
archivedAt,
|
||||
background: snapshot.background === true,
|
||||
parentAgentId,
|
||||
labels: snapshot.labels,
|
||||
};
|
||||
|
||||
@@ -14,7 +14,6 @@ function agent(input: {
|
||||
pendingPermissionCount?: number;
|
||||
archivedAt?: string | null;
|
||||
parentAgentId?: string | null;
|
||||
background?: boolean;
|
||||
}): Agent {
|
||||
return {
|
||||
serverId: "host-a",
|
||||
@@ -62,7 +61,6 @@ function agent(input: {
|
||||
attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null,
|
||||
archivedAt: input.archivedAt ? new Date(input.archivedAt) : null,
|
||||
parentAgentId: input.parentAgentId ?? null,
|
||||
background: input.background ?? false,
|
||||
labels: {},
|
||||
};
|
||||
}
|
||||
@@ -160,33 +158,6 @@ describe("workspace agent activity index", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not let a running background agent change root workspace activity", () => {
|
||||
const index = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
[
|
||||
"root",
|
||||
agent({ id: "root", workspaceId: "workspace-a", updatedAt: "2026-06-01T10:00:00.000Z" }),
|
||||
],
|
||||
[
|
||||
"classifier",
|
||||
agent({
|
||||
id: "classifier",
|
||||
workspaceId: "workspace-a",
|
||||
status: "running",
|
||||
updatedAt: "2026-06-01T10:05:00.000Z",
|
||||
background: true,
|
||||
}),
|
||||
],
|
||||
]),
|
||||
);
|
||||
|
||||
expect(index.get("workspace-a")).toEqual({
|
||||
agentId: "root",
|
||||
status: "done",
|
||||
enteredAt: new Date("2026-06-01T10:00:00.000Z"),
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let archived or child agents change root workspace activity", () => {
|
||||
const index = buildWorkspaceAgentActivityIndex(
|
||||
new Map([
|
||||
|
||||
@@ -22,12 +22,7 @@ export function buildWorkspaceAgentActivityIndex(
|
||||
|
||||
for (const agent of agents.values()) {
|
||||
const parentAgent = agent.parentAgentId ? agents.get(agent.parentAgentId) : undefined;
|
||||
if (
|
||||
agent.archivedAt ||
|
||||
agent.background ||
|
||||
!agent.workspaceId ||
|
||||
!isWorkspaceRootAgent(agent, parentAgent)
|
||||
) {
|
||||
if (agent.archivedAt || !agent.workspaceId || !isWorkspaceRootAgent(agent, parentAgent)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ function makeAgent(input: {
|
||||
archivedAt?: Date | null;
|
||||
createdAt?: Date;
|
||||
lastActivityAt?: Date;
|
||||
background?: boolean;
|
||||
}): Agent {
|
||||
const createdAt = input.createdAt ?? new Date("2026-03-04T00:00:00.000Z");
|
||||
const lastActivityAt = input.lastActivityAt ?? createdAt;
|
||||
@@ -59,7 +58,6 @@ function makeAgent(input: {
|
||||
attentionReason: null,
|
||||
attentionTimestamp: null,
|
||||
archivedAt: input.archivedAt ?? null,
|
||||
background: input.background ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -133,33 +131,6 @@ describe("workspace agent visibility", () => {
|
||||
expect(restored.getState().pinnedAgentIdsByWorkspace["srv:ws-b"]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps a background agent active without auto-opening its tab", () => {
|
||||
const visible = makeAgent({ id: "visible", cwd: "/repo", workspaceId: WORKSPACE_ID });
|
||||
const classifier = makeAgent({
|
||||
id: "classifier",
|
||||
cwd: "/repo",
|
||||
workspaceId: WORKSPACE_ID,
|
||||
background: true,
|
||||
});
|
||||
const result = deriveWorkspaceAgentVisibility({
|
||||
workspaceId: WORKSPACE_ID,
|
||||
sessionAgents: new Map([
|
||||
[visible.id, visible],
|
||||
[classifier.id, classifier],
|
||||
]),
|
||||
});
|
||||
|
||||
expect(result.autoOpenAgentIds).toEqual(new Set([visible.id]));
|
||||
expect(result.activeAgentIds).toEqual(new Set([visible.id, classifier.id]));
|
||||
expect(
|
||||
shouldPruneWorkspaceAgentTab({
|
||||
agentId: classifier.id,
|
||||
agentsHydrated: true,
|
||||
activeAgentIds: result.activeAgentIds,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("uses directory ownership before historical details when identifying foreign agents", () => {
|
||||
const own = makeAgent({ id: "own", cwd: "/repo", workspaceId: WORKSPACE_ID });
|
||||
const foreign = makeAgent({ id: "foreign", cwd: "/repo", workspaceId: "ws-2" });
|
||||
|
||||
@@ -36,9 +36,11 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
continue;
|
||||
}
|
||||
if (!agent.archivedAt) {
|
||||
// A background agent stays active so a tab opened by id isn't pruned, but never auto-opens.
|
||||
activeAgentIds.add(agent.id);
|
||||
if (shouldAutoOpenAgent({ agent, sessionAgents, agentDetails })) {
|
||||
const parentAgent = agent.parentAgentId
|
||||
? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId))
|
||||
: undefined;
|
||||
if (isWorkspaceRootAgent(agent, parentAgent)) {
|
||||
autoOpenAgentIds.add(agent.id);
|
||||
}
|
||||
}
|
||||
@@ -55,21 +57,6 @@ export function deriveWorkspaceAgentVisibility(input: {
|
||||
return { activeAgentIds, autoOpenAgentIds, foreignAgentIds };
|
||||
}
|
||||
|
||||
function shouldAutoOpenAgent(input: {
|
||||
agent: Agent;
|
||||
sessionAgents: Map<string, Agent> | undefined;
|
||||
agentDetails: Map<string, Agent> | undefined;
|
||||
}): boolean {
|
||||
const { agent, sessionAgents, agentDetails } = input;
|
||||
if (agent.background) {
|
||||
return false;
|
||||
}
|
||||
const parentAgent = agent.parentAgentId
|
||||
? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId))
|
||||
: undefined;
|
||||
return isWorkspaceRootAgent(agent, parentAgent);
|
||||
}
|
||||
|
||||
interface AgentMapsSnapshot {
|
||||
sessions: Record<
|
||||
string,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { render } from "../../output/index.js";
|
||||
import { buildAgentLsFetchOptions, listAgents } from "./ls.js";
|
||||
|
||||
const daemonTarget = { kind: "endpoint" as const, host: "example.test:12345" };
|
||||
@@ -131,26 +130,4 @@ describe("runLsCommand", () => {
|
||||
|
||||
expect(result.data.map((item) => item.name)).toEqual(["agent 3", "agent 2", "agent 1"]);
|
||||
});
|
||||
|
||||
it("marks background agents in a BACKGROUND column only for inclusive listings", async () => {
|
||||
const client = createPagingClient();
|
||||
const fetchAgents: typeof client.fetchAgents = async (options) => {
|
||||
const page = await client.fetchAgents(options);
|
||||
return {
|
||||
...page,
|
||||
entries: page.entries.map(({ agent }) => ({
|
||||
agent: agent.title === "agent 2" ? { ...agent, background: true } : agent,
|
||||
})),
|
||||
};
|
||||
};
|
||||
const connect = async () => ({ ...client, fetchAgents });
|
||||
|
||||
const inclusive = await listAgents({ daemonTarget, all: true, global: true }, connect);
|
||||
const rows = render(inclusive, { noColor: true }).split("\n");
|
||||
expect(rows[0]).toMatch(/BACKGROUND\s*$/);
|
||||
expect(rows.slice(1).map((row) => row.trim().split(/\s+/).at(-1))).toEqual(["-", "yes", "-"]);
|
||||
|
||||
const ordinary = await listAgents({ daemonTarget, global: true }, connect);
|
||||
expect(render(ordinary, { noColor: true })).not.toContain("BACKGROUND");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@ export function addLsOptions(cmd: Command): Command {
|
||||
.description("List agents. By default excludes archived agents.")
|
||||
.option("-a, --all", "Include archived and background agents")
|
||||
.option("-g, --global", "List agents across all directories")
|
||||
.option("--background", "Include background agents and agents in background workspaces")
|
||||
.option("--background", "Include background workspace agents")
|
||||
.option(
|
||||
"--label <key=value>",
|
||||
"Filter by label (can be used multiple times)",
|
||||
@@ -35,7 +35,6 @@ export interface AgentListItem {
|
||||
status: string;
|
||||
cwd: string;
|
||||
created: string;
|
||||
background: boolean;
|
||||
}
|
||||
|
||||
/** Helper to get relative time string */
|
||||
@@ -90,15 +89,6 @@ export const agentLsSchema: OutputSchema<AgentListItem> = {
|
||||
],
|
||||
};
|
||||
|
||||
/** Inclusive listings mark background agents so they read apart from visible ones. */
|
||||
const agentLsInclusiveSchema: OutputSchema<AgentListItem> = {
|
||||
...agentLsSchema,
|
||||
columns: [
|
||||
...agentLsSchema.columns,
|
||||
{ header: "BACKGROUND", field: (item) => (item.background ? "yes" : "-"), width: 10 },
|
||||
],
|
||||
};
|
||||
|
||||
/** Transform agent snapshot to AgentListItem */
|
||||
function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
const model = normalizeModelId(agent.runtimeInfo?.model) ?? normalizeModelId(agent.model);
|
||||
@@ -111,7 +101,6 @@ function toListItem(agent: AgentSnapshotPayload): AgentListItem {
|
||||
status: agent.status,
|
||||
cwd: shortenPath(agent.cwd),
|
||||
created: relativeTime(agent.createdAt),
|
||||
background: agent.background === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -270,7 +259,7 @@ export async function listAgents(
|
||||
return {
|
||||
type: "list",
|
||||
data: items,
|
||||
schema: options.all || options.background ? agentLsInclusiveSchema : agentLsSchema,
|
||||
schema: agentLsSchema,
|
||||
};
|
||||
} catch (err) {
|
||||
await client.close().catch(() => {});
|
||||
|
||||
@@ -145,7 +145,6 @@ export const DEFAULT_CLIENT_CAPABILITIES = {
|
||||
[CLIENT_CAPS.workspaceSetupBlocked]: true,
|
||||
[CLIENT_CAPS.workspaceSetupObservation]: true,
|
||||
[CLIENT_CAPS.pluginBinaryPayloads]: true,
|
||||
[CLIENT_CAPS.backgroundAgents]: true,
|
||||
[CLIENT_CAPS.explicitEventSubscriptions]: true,
|
||||
} satisfies Record<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
|
||||
|
||||
|
||||
@@ -1110,7 +1110,6 @@ test("advertises client capabilities in hello", async () => {
|
||||
workspace_setup_blocked: true,
|
||||
workspace_setup_observation: true,
|
||||
hello_rejection: true,
|
||||
background_agents: true,
|
||||
browser_host: {
|
||||
supportedCommands: ["list_tabs"],
|
||||
hostKind: "desktop app",
|
||||
|
||||
@@ -504,7 +504,6 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
|
||||
git?: GitSetupOptions;
|
||||
worktree?: CreateAgentRequestMessage["worktree"];
|
||||
autoArchive?: CreateAgentRequestMessage["autoArchive"];
|
||||
/** Creates a background agent. A workspace this request creates inherits it. */
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
/** @deprecated Use createWorkspace({ source, agent, idempotencyKey }); rejected by createAgent. */
|
||||
worktreeName?: string;
|
||||
@@ -2892,28 +2891,6 @@ export class DaemonClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Finds one workspace by id across pages, background ones included when the host has them. */
|
||||
async findWorkspace(
|
||||
workspaceId: string,
|
||||
options?: { requestId?: string },
|
||||
): Promise<FetchWorkspacesEntry | null> {
|
||||
const filter = this.supportsBackgroundWorkspaces() ? { includeBackground: true } : {};
|
||||
let cursor: string | undefined;
|
||||
let requestId = options?.requestId;
|
||||
do {
|
||||
const result = await this.fetchWorkspaces({
|
||||
requestId,
|
||||
filter,
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
const match = result.entries.find((entry) => entry.id === workspaceId);
|
||||
if (match) return match;
|
||||
cursor = result.pageInfo.nextCursor ?? undefined;
|
||||
requestId = undefined;
|
||||
} while (cursor);
|
||||
return null;
|
||||
}
|
||||
|
||||
listWorkspaceLabels(
|
||||
options: {
|
||||
sync?: { generation: string; afterSeq: number };
|
||||
@@ -3316,24 +3293,13 @@ export class DaemonClient {
|
||||
return this.lastServerInfoMessage?.features?.backgroundWorkspaces === true;
|
||||
}
|
||||
|
||||
// Background on a request that creates its workspace predates background agents: older hosts
|
||||
// apply it to the new workspace. Placing a background agent in an existing workspace, or
|
||||
// under a caller, needs a host that knows background agents.
|
||||
private requireBackgroundCreation(options: CreateAgentRequestOptions): void {
|
||||
if (options.workspaceId === undefined && options.callerAgentId === undefined) {
|
||||
this.requireBackgroundWorkspaces();
|
||||
} else if (this.lastServerInfoMessage?.features?.backgroundAgents !== true) {
|
||||
throw new Error("Update the host to use background agents.");
|
||||
}
|
||||
}
|
||||
|
||||
async createAgent(options: CreateAgentRequestOptions): Promise<AgentSnapshotPayload> {
|
||||
if (options.git !== undefined || options.worktreeName !== undefined) {
|
||||
throw new Error(
|
||||
"Legacy git/worktreeName agent placement was removed. Use createWorkspace({ source, agent, idempotencyKey }) or create a workspace first and pass workspaceId.",
|
||||
);
|
||||
}
|
||||
if (options.background !== undefined) this.requireBackgroundCreation(options);
|
||||
if (options.background !== undefined) this.requireBackgroundWorkspaces();
|
||||
const result = await this.creations.createAgent({
|
||||
...options,
|
||||
config: resolveAgentConfig(options),
|
||||
|
||||
@@ -747,88 +747,6 @@ test("agents.create with internal fails before sending on a host without interna
|
||||
await client.close();
|
||||
});
|
||||
|
||||
async function createWorkspaceHandle(features: Record<string, boolean>) {
|
||||
const { client, ws } = await connectClient(features);
|
||||
const creating = client.workspaces.create({
|
||||
source: { kind: "directory", path: "/repo/sdk" },
|
||||
});
|
||||
const request = parseSentSessionMessage(ws.sent.at(-1));
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "workspace.create.response",
|
||||
payload: {
|
||||
requestId: request.requestId,
|
||||
workspace: createWorkspace({ id: "workspace_open" }),
|
||||
setupTerminalId: null,
|
||||
error: null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { client, ws, workspace: await creating };
|
||||
}
|
||||
|
||||
test("a background agent in an existing workspace fails before sending on a host without background agents", async () => {
|
||||
const { client, ws, workspace } = await createWorkspaceHandle({
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
backgroundWorkspaces: true,
|
||||
});
|
||||
const sentBefore = ws.sent.length;
|
||||
|
||||
await expect(
|
||||
workspace.agents.create({ config: { provider: "codex/gpt-5.4" }, background: true }),
|
||||
).rejects.toThrow("Update the host to use background agents.");
|
||||
expect(ws.sent).toHaveLength(sentBefore);
|
||||
|
||||
const creating = client.agents.create({
|
||||
config: { provider: "codex/gpt-5.4" },
|
||||
cwd: "/repo/sdk",
|
||||
background: true,
|
||||
});
|
||||
expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({
|
||||
type: "create_agent_request",
|
||||
background: true,
|
||||
});
|
||||
expect(parseSentSessionMessage(ws.sent.at(-1))).not.toHaveProperty("workspaceId");
|
||||
creating.catch(() => {});
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("a background agent in an existing workspace is sent to a host with background agents", async () => {
|
||||
const { client, ws, workspace } = await createWorkspaceHandle({
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
backgroundWorkspaces: true,
|
||||
backgroundAgents: true,
|
||||
});
|
||||
|
||||
const creating = workspace.agents.create({
|
||||
config: { provider: "codex/gpt-5.4" },
|
||||
background: true,
|
||||
});
|
||||
const request = parseSentSessionMessage(ws.sent.at(-1));
|
||||
expect(request).toMatchObject({
|
||||
type: "create_agent_request",
|
||||
workspaceId: "workspace_open",
|
||||
background: true,
|
||||
});
|
||||
ws.message(
|
||||
sessionMessage({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "agent_created",
|
||||
requestId: request.requestId,
|
||||
agentId: "agent_background",
|
||||
agent: createAgent({ id: "agent_background", workspaceId: "workspace_open" }),
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect((await creating).id).toBe("agent_background");
|
||||
await client.close();
|
||||
});
|
||||
|
||||
test("agent handles delegate create, send, timeline refetch, archive, and local updates", async () => {
|
||||
const { client, ws } = await connectClient();
|
||||
const createdAgent = createAgent();
|
||||
|
||||
@@ -264,11 +264,7 @@ export interface PaseoAgentCreateOptions {
|
||||
git?: CreateAgentRequestMessage["git"];
|
||||
worktree?: CreateAgentRequestMessage["worktree"];
|
||||
autoArchive?: CreateAgentRequestMessage["autoArchive"];
|
||||
/**
|
||||
* Creates a background agent: hidden from default lists, tabs, the parent's subagent track
|
||||
* and notifications, and still addressable by id. A workspace this call creates is
|
||||
* background too. With a parent and no value, the parent's own background applies.
|
||||
*/
|
||||
/** Visibility intent for a NEW workspace. Existing targets reject this option. */
|
||||
background?: CreateAgentRequestMessage["background"];
|
||||
requestId?: string;
|
||||
labels?: Record<string, string>;
|
||||
@@ -613,14 +609,12 @@ export function createPaseoApi(
|
||||
options: PaseoAgentCreateOptions,
|
||||
placement?: { workspaceId: string; cwd: string },
|
||||
) => {
|
||||
const serverId = daemonClient.getLastServerInfoMessage()?.serverId;
|
||||
const foreignParent =
|
||||
options.background === undefined ? foreignParentHandle(options.parent, serverId) : null;
|
||||
const resolved = foreignParent
|
||||
? await inheritParentBackground(options, foreignParent)
|
||||
: options;
|
||||
const agent = await daemonClient.createAgent(
|
||||
toDaemonAgentCreateOptions(resolved, placement, serverId),
|
||||
toDaemonAgentCreateOptions(
|
||||
options,
|
||||
placement,
|
||||
daemonClient.getLastServerInfoMessage()?.serverId,
|
||||
),
|
||||
);
|
||||
return createAgentHandle(agent);
|
||||
};
|
||||
@@ -825,7 +819,25 @@ function createWorkspaceHandleFactory(
|
||||
let current = typeof workspace === "string" ? null : workspace;
|
||||
|
||||
const refresh = async (options?: { requestId?: string }) => {
|
||||
current = await daemonClient.findWorkspace(id, options);
|
||||
let cursor: string | undefined;
|
||||
let requestId = options?.requestId;
|
||||
// A ref addresses one workspace by id, so a background one resolves too.
|
||||
const filter = daemonClient.supportsBackgroundWorkspaces() ? { includeBackground: true } : {};
|
||||
do {
|
||||
const result = await daemonClient.fetchWorkspaces({
|
||||
requestId,
|
||||
filter,
|
||||
page: { limit: 200, ...(cursor ? { cursor } : {}) },
|
||||
});
|
||||
const match = result.entries.find((entry) => entry.id === id);
|
||||
if (match) {
|
||||
current = match;
|
||||
return current;
|
||||
}
|
||||
cursor = result.pageInfo.nextCursor ?? undefined;
|
||||
requestId = undefined;
|
||||
} while (cursor);
|
||||
current = null;
|
||||
return current;
|
||||
};
|
||||
|
||||
@@ -1054,24 +1066,6 @@ function resolveWorkspaceId(workspace: string | PaseoWorkspaceHandle): string {
|
||||
return typeof workspace === "string" ? workspace : workspace.id;
|
||||
}
|
||||
|
||||
function foreignParentHandle(
|
||||
parent: PaseoAgentCreateOptions["parent"],
|
||||
serverId: string | undefined,
|
||||
): PaseoAgentHandle | null {
|
||||
if (!parent || typeof parent === "string" || !("address" in parent)) return null;
|
||||
return parent.address.serverId === serverId ? null : parent;
|
||||
}
|
||||
|
||||
// A host inherits background from a caller it can see. A parent on another host is invisible to
|
||||
// it, so a handle passes its own value on. A bare foreign address can't; pass background then.
|
||||
async function inheritParentBackground(
|
||||
options: PaseoAgentCreateOptions,
|
||||
parent: PaseoAgentHandle,
|
||||
): Promise<PaseoAgentCreateOptions> {
|
||||
const snapshot = parent.current() ?? (await parent.refresh())?.agent;
|
||||
return snapshot?.background ? { ...options, background: true } : options;
|
||||
}
|
||||
|
||||
function encodeParent(
|
||||
parent: string | AgentAddress | PaseoAgentHandle,
|
||||
serverId?: string,
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
"@types/node": "24.6.0",
|
||||
"@types/ws": "^8.5.14",
|
||||
"electron": "44.2.0",
|
||||
"electron-builder": "26.18.0",
|
||||
"typescript": "5.9.3",
|
||||
"unzip-crx-3": "^0.2.0",
|
||||
"vitest": "^4.1.6",
|
||||
|
||||
@@ -51,10 +51,6 @@ export const CLIENT_CAPS = {
|
||||
pluginBinaryPayloads: "plugin_binary_payloads",
|
||||
// COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0.
|
||||
workspaceSetupBlocked: "workspace_setup_blocked",
|
||||
// COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor >= v0.15.0.
|
||||
// Sources without it never receive agent-level background agents in inclusive lists or
|
||||
// update subscriptions, because older apps auto-open a tab for every replicated root agent.
|
||||
backgroundAgents: "background_agents",
|
||||
browserHost: "browser_host",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -891,9 +891,6 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
providerUnavailable: z.boolean().optional(),
|
||||
// Private helper marker, retained for exact-ID snapshots and older wire messages.
|
||||
internal: z.boolean().optional(),
|
||||
// Background agent: hidden from default discovery, tabs, tracks and notifications.
|
||||
// Fixed at creation. Effective background also includes the workspace's flag.
|
||||
background: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type AgentSnapshotPayload = z.infer<typeof AgentSnapshotPayloadSchema>;
|
||||
@@ -1805,8 +1802,7 @@ export const CreateAgentRequestMessageSchema = z.object({
|
||||
// in the config schema, which is reused for updates. Retained for wire parsing;
|
||||
// public internal creation is no longer supported.
|
||||
internal: z.boolean().optional(),
|
||||
// The created agent is a background agent. A workspace this request creates inherits it.
|
||||
// Omitted on agent-scoped creation, the caller agent's own value applies.
|
||||
// Workspace creation intent, rejected when selecting an existing workspace.
|
||||
background: z.boolean().optional(),
|
||||
labels: z.record(z.string(), z.string()).default({}),
|
||||
requestId: z.string(),
|
||||
@@ -3698,8 +3694,6 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
// COMPAT(internalWorkspaces): added in v0.9.0; remove gate after 2027-03-17.
|
||||
internalWorkspaces: z.boolean().optional(),
|
||||
backgroundWorkspaces: z.boolean().optional(),
|
||||
// COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03.
|
||||
backgroundAgents: z.boolean().optional(),
|
||||
providersSnapshot: z.boolean().optional(),
|
||||
connectedHosts: z.boolean().optional(),
|
||||
hostMultiplexing: z.boolean().optional(),
|
||||
@@ -7808,7 +7802,6 @@ export const WSHelloMessageSchema = z.object({
|
||||
[CLIENT_CAPS.providerSnapshotReferences]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.timelineReplacementInvalidation]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.timelineNotifications]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.backgroundAgents]: z.boolean().optional(),
|
||||
[CLIENT_CAPS.browserHost]: BrowserAutomationHostCapabilitySchema.optional(),
|
||||
})
|
||||
.passthrough()
|
||||
|
||||
@@ -90,7 +90,7 @@
|
||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||
"test": "npm run test:unit && npm run test:integration",
|
||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/persisted-creation-caller.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||
"test:integration:all": "npm run test:e2e",
|
||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||
|
||||
@@ -65,15 +65,11 @@ export async function probeExecutable(
|
||||
executablePath: string,
|
||||
options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS,
|
||||
): Promise<boolean> {
|
||||
const {
|
||||
probeTimeoutMs: timeoutMs = PROBE_TIMEOUT_MS,
|
||||
env,
|
||||
envMode,
|
||||
} = typeof options === "number" ? { probeTimeoutMs: options } : options;
|
||||
const { probeTimeoutMs: timeoutMs = PROBE_TIMEOUT_MS, env } =
|
||||
typeof options === "number" ? { probeTimeoutMs: options } : options;
|
||||
try {
|
||||
await execCommand(executablePath, ["--version"], {
|
||||
baseEnv: env,
|
||||
envMode,
|
||||
timeout: timeoutMs,
|
||||
killSignal: "SIGKILL",
|
||||
maxBuffer: 64 * 1024,
|
||||
@@ -144,19 +140,14 @@ export async function findExecutableWithoutProbing(
|
||||
export interface ExecutableResolutionOptions {
|
||||
probeTimeoutMs?: number;
|
||||
env?: ProcessEnvRecord;
|
||||
/** Preserve an environment already finalized by the caller's launch policy. */
|
||||
envMode?: "external" | "internal";
|
||||
}
|
||||
|
||||
export async function findExecutable(
|
||||
name: string,
|
||||
options: number | ExecutableResolutionOptions = PROBE_TIMEOUT_MS,
|
||||
): Promise<string | null> {
|
||||
const {
|
||||
probeTimeoutMs = PROBE_TIMEOUT_MS,
|
||||
env,
|
||||
envMode,
|
||||
} = typeof options === "number" ? { probeTimeoutMs: options } : options;
|
||||
const { probeTimeoutMs = PROBE_TIMEOUT_MS, env } =
|
||||
typeof options === "number" ? { probeTimeoutMs: options } : options;
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
@@ -166,19 +157,19 @@ export async function findExecutable(
|
||||
return windowsExecutableResolution.find(trimmed, {
|
||||
enumeratePathCandidates: (command) => enumerateCandidates(command, env),
|
||||
probeExecutable: (command, timeout) =>
|
||||
probeExecutable(command, { probeTimeoutMs: timeout, env, envMode }),
|
||||
probeExecutable(command, { probeTimeoutMs: timeout, env }),
|
||||
exists: existsSync,
|
||||
probeTimeoutMs,
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPathSeparator(trimmed)) {
|
||||
return (await probeExecutable(trimmed, { probeTimeoutMs, env, envMode })) ? trimmed : null;
|
||||
return (await probeExecutable(trimmed, { probeTimeoutMs, env })) ? trimmed : null;
|
||||
}
|
||||
|
||||
const candidates = await enumerateCandidates(trimmed, env);
|
||||
for (const candidate of candidates) {
|
||||
if (await probeExecutable(candidate, { probeTimeoutMs, env, envMode })) {
|
||||
if (await probeExecutable(candidate, { probeTimeoutMs, env })) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { expect, test } from "vitest";
|
||||
@@ -17,7 +17,6 @@ import type {
|
||||
AgentSessionConfig,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
||||
import { createDaemonTestContext } from "../test-utils/daemon-test-context.js";
|
||||
|
||||
test("loads archived records for history and active records with the interactive default", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-purpose-"));
|
||||
@@ -143,247 +142,60 @@ test("resuming a stored agent keeps its unread flag and its last-activity time",
|
||||
}
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"loads history after its working directory is removed (archived: %s)",
|
||||
async (archived) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-"));
|
||||
const worktree = path.join(root, "managed-worktree");
|
||||
await mkdir(worktree, { recursive: true });
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(root, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
test("loads an archived agent's history after its working directory is removed", async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-"));
|
||||
const worktree = path.join(root, "managed-worktree");
|
||||
await mkdir(worktree, { recursive: true });
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(root, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: createTestAgentClients(),
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const agentId = "00000000-0000-4000-8000-000000000501";
|
||||
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, {
|
||||
workspaceId: "workspace-worktree",
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "what did you change", logger, {});
|
||||
// Dispatching a run does not finish it: the provider appends the reply to its
|
||||
// history afterwards, and the turn is finalized only once that append lands.
|
||||
// Archive after the turn is finalized so the transcript this test reads back is
|
||||
// already on disk when the worktree goes away.
|
||||
const finished = await manager.waitForAgentEvent(agent.id);
|
||||
expect(finished.status).toBe("idle");
|
||||
await manager.archiveAgent(agent.id);
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
|
||||
// Archiving the workspace removes the worktree it owned. The agent's history is
|
||||
// persisted and reading it must not depend on that directory still being there.
|
||||
await rm(worktree, { recursive: true, force: true });
|
||||
|
||||
const loaded = await ensureAgentLoaded(agentId, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
});
|
||||
|
||||
const agentId = "00000000-0000-4000-8000-000000000501";
|
||||
|
||||
try {
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, {
|
||||
workspaceId: "workspace-worktree",
|
||||
});
|
||||
await startAgentRun(manager, agent.id, "what did you change", logger, {});
|
||||
// Dispatching a run does not finish it: the provider appends the reply to its
|
||||
// history afterwards, and the turn is finalized only once that append lands.
|
||||
// Archive after the turn is finalized so the transcript this test reads back is
|
||||
// already on disk when the worktree goes away.
|
||||
const finished = await manager.waitForAgentEvent(agent.id);
|
||||
expect(finished.status).toBe("idle");
|
||||
if (archived) await manager.archiveAgent(agent.id);
|
||||
await manager.closeAgent(agent.id);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
|
||||
// Archiving the workspace removes the worktree it owned. The agent's history is
|
||||
// persisted and reading it must not depend on that directory still being there.
|
||||
await rm(worktree, { recursive: true, force: true });
|
||||
|
||||
const loaded = await ensureAgentLoaded(agentId, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
purpose: "history",
|
||||
});
|
||||
|
||||
expect(loaded.id).toBe(agentId);
|
||||
// The transcript is replayed from the provider's persisted history, so the reply the
|
||||
// agent gave before the worktree went away is still readable.
|
||||
const replies = manager
|
||||
.getTimeline(agentId)
|
||||
.filter((item) => item.type === "assistant_message");
|
||||
expect(replies.length).toBeGreaterThan(0);
|
||||
expect(
|
||||
replies.every((item) => item.type === "assistant_message" && item.text.length > 0),
|
||||
).toBe(true);
|
||||
} finally {
|
||||
await manager.closeAgent(agentId).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("timeline requests read unarchived stored history with a deleted cwd", async () => {
|
||||
const ctx = await createDaemonTestContext();
|
||||
const cwd = await mkdtemp(path.join(tmpdir(), "timeline-deleted-cwd-"));
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({ provider: "codex", cwd });
|
||||
await ctx.client.sendMessage(agent.id, "remember this conversation");
|
||||
await ctx.client.waitForFinish(agent.id, 15000);
|
||||
const before = await ctx.client.fetchAgentTimeline(agent.id);
|
||||
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
|
||||
await ctx.daemon.daemon.agentManager.flush();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
const after = await ctx.client.fetchAgentTimeline(agent.id);
|
||||
expect(after.entries).toEqual(before.entries);
|
||||
expect(after.entries.length).toBeGreaterThan(0);
|
||||
await expect(ctx.client.sendMessage(agent.id, "continue")).rejects.toThrow(
|
||||
"Working directory does not exist",
|
||||
expect(loaded.id).toBe(agentId);
|
||||
// The transcript is replayed from the provider's persisted history, so the reply the
|
||||
// agent gave before the worktree went away is still readable.
|
||||
const replies = manager
|
||||
.getTimeline(agentId)
|
||||
.filter((item) => item.type === "assistant_message");
|
||||
expect(replies.length).toBeGreaterThan(0);
|
||||
expect(replies.every((item) => item.type === "assistant_message" && item.text.length > 0)).toBe(
|
||||
true,
|
||||
);
|
||||
await expect(ctx.client.createAgent({ provider: "codex", cwd })).rejects.toThrow(
|
||||
"Working directory does not exist",
|
||||
);
|
||||
await mkdir(cwd);
|
||||
await ctx.client.sendMessage(agent.id, "continue after restoring directory");
|
||||
expect((await ctx.client.waitForFinish(agent.id, 15000)).status).toBe("idle");
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
await manager.closeAgent(agentId).catch(() => undefined);
|
||||
await manager.flush().catch(() => undefined);
|
||||
await storage.flush().catch(() => undefined);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test("timeline reads keep runnable unarchived agents interactive for the next prompt", async () => {
|
||||
const clients = createTestAgentClients();
|
||||
const base = clients.codex!;
|
||||
const purposes: Array<AgentResumeSessionOptions | undefined> = [];
|
||||
const client: AgentClient = {
|
||||
provider: base.provider,
|
||||
capabilities: base.capabilities,
|
||||
createSession: (config, context) => base.createSession(config, context),
|
||||
resumeSession: (handle, overrides, context, options) => {
|
||||
purposes.push(options);
|
||||
return base.resumeSession(handle, overrides, context, options);
|
||||
},
|
||||
fetchCatalog: (options) => base.fetchCatalog(options),
|
||||
isAvailable: () => base.isAvailable(),
|
||||
};
|
||||
const ctx = await createDaemonTestContext({ agentClients: { codex: client } });
|
||||
const cwd = await mkdtemp(path.join(tmpdir(), "timeline-interactive-"));
|
||||
try {
|
||||
const agent = await ctx.client.createAgent({ provider: "codex", cwd });
|
||||
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
|
||||
await ctx.client.fetchAgentTimeline(agent.id);
|
||||
expect(purposes).toEqual([{ purpose: "interactive" }]);
|
||||
await ctx.client.sendMessage(agent.id, "continue after opening history");
|
||||
expect((await ctx.client.waitForFinish(agent.id, 15000)).status).toBe("idle");
|
||||
const timeline = await ctx.client.fetchAgentTimeline(agent.id);
|
||||
expect(timeline.entries.length).toBeGreaterThan(0);
|
||||
await ctx.daemon.daemon.agentManager.closeAgent(agent.id);
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
await ctx.client.fetchAgentTimeline(agent.id);
|
||||
await mkdir(cwd);
|
||||
const manager = ctx.daemon.daemon.agentManager;
|
||||
await Promise.all([
|
||||
manager.tryRunOutOfBand(agent.id, "first prompt"),
|
||||
manager.tryRunOutOfBand(agent.id, "second prompt"),
|
||||
]);
|
||||
expect(purposes).toEqual([
|
||||
{ purpose: "interactive" },
|
||||
{ purpose: "history" },
|
||||
undefined, // Reload uses the provider's interactive default.
|
||||
]);
|
||||
} finally {
|
||||
await ctx.cleanup();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
test.each(["mode", "thinking", "rewind"])(
|
||||
"restored OMP history accepts %s before starting an interactive turn",
|
||||
async (setting) => {
|
||||
const { OmpAgentClient } = await import("./providers/omp/agent.js");
|
||||
const { FakeOmp } = await import("./providers/omp/test-utils/fake-omp.js");
|
||||
const { sendPreparedPromptToAgent } = await import("./agent-prompt.js");
|
||||
const root = await mkdtemp(path.join(tmpdir(), "history-mode-"));
|
||||
const cwd = path.join(root, "removed-worktree");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
await writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
{ type: "session", id: "root", parentId: null },
|
||||
{
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: "root",
|
||||
message: { role: "user", content: "saved question" },
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
);
|
||||
const logger = createTestLogger();
|
||||
const storage = new AgentStorage(path.join(root, "agents"), logger);
|
||||
const runtime = new FakeOmp();
|
||||
const manager = new AgentManager({
|
||||
clients: {
|
||||
omp: new OmpAgentClient({
|
||||
logger,
|
||||
runtime,
|
||||
runtimeSettings: { command: { mode: "replace", argv: [process.execPath] } },
|
||||
}),
|
||||
},
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const agentId = "00000000-0000-4000-8000-000000000601";
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
await storage.upsert({
|
||||
id: agentId,
|
||||
provider: "omp",
|
||||
cwd,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
labels: {},
|
||||
lastStatus: "closed",
|
||||
archivedAt: null,
|
||||
persistence: {
|
||||
provider: "omp",
|
||||
sessionId: "history-mode",
|
||||
nativeHandle: sessionFile,
|
||||
metadata: { cwd },
|
||||
},
|
||||
});
|
||||
await ensureAgentLoaded(agentId, {
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
logger,
|
||||
purpose: "history",
|
||||
});
|
||||
expect(
|
||||
runtime.recordedLaunches.filter((launch) => launch.session === sessionFile),
|
||||
).toHaveLength(0);
|
||||
await mkdir(cwd);
|
||||
if (setting === "rewind") {
|
||||
await manager.rewind(agentId, "user-1", "conversation");
|
||||
expect(runtime.latestSession().branchRequests).toEqual(["user-1"]);
|
||||
}
|
||||
if (setting === "thinking") {
|
||||
await manager.setAgentThinkingOption(agentId, "high");
|
||||
expect(manager.getAgent(agentId)?.config.thinkingOptionId).toBe("high");
|
||||
expect(runtime.latestSession().setThinkingLevelRequests).toContain("high");
|
||||
}
|
||||
await sendPreparedPromptToAgent({
|
||||
agentManager: manager,
|
||||
agentStorage: storage,
|
||||
agentId,
|
||||
logger,
|
||||
prompt: "continue",
|
||||
sessionMode: "full",
|
||||
});
|
||||
expect(
|
||||
runtime.recordedLaunches.filter((launch) => launch.session === sessionFile),
|
||||
).toHaveLength(1);
|
||||
expect(manager.getAgent(agentId)?.currentModeId).toBe("full");
|
||||
expect(runtime.latestSession().prompts).toEqual([
|
||||
expect.objectContaining({ message: "continue" }),
|
||||
]);
|
||||
runtime.latestSession().finishTurn();
|
||||
await manager.waitForAgentEvent(agentId);
|
||||
await manager.archiveAgent(agentId);
|
||||
await manager.closeAgent(agentId);
|
||||
await ensureAgentLoaded(agentId, { agentManager: manager, agentStorage: storage, logger });
|
||||
const launchesBeforeModeChange = runtime.recordedLaunches.length;
|
||||
await expect(manager.setAgentMode(agentId, "full")).rejects.toThrow("Agent is archived");
|
||||
expect(runtime.recordedLaunches).toHaveLength(launchesBeforeModeChange);
|
||||
} finally {
|
||||
await manager.closeAgent(agentId).catch(() => undefined);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
30000,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -41,7 +41,6 @@ export interface EnsureAgentLoadedDeps {
|
||||
agentStorage: AgentStorage;
|
||||
validProviders?: Iterable<AgentProvider>;
|
||||
broadcastTimeline?: boolean;
|
||||
purpose?: "history" | "interactive";
|
||||
logger: Logger;
|
||||
}
|
||||
|
||||
@@ -119,7 +118,7 @@ export async function ensureAgentLoaded(
|
||||
buildConfigOverrides(record),
|
||||
agentId,
|
||||
{ ...extractTimestamps(record), attention: extractAttention(record) },
|
||||
deps.purpose ? { purpose: deps.purpose } : undefined,
|
||||
record.archivedAt ? { purpose: "history" } : undefined,
|
||||
);
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent resumed from persistence");
|
||||
} else {
|
||||
@@ -136,7 +135,6 @@ export async function ensureAgentLoaded(
|
||||
labels: record.labels,
|
||||
workspaceId: record.workspaceId,
|
||||
owner: record.owner,
|
||||
background: record.background,
|
||||
env: record.launchEnv,
|
||||
});
|
||||
deps.logger.info({ agentId, provider: record.provider }, "Agent created from stored config");
|
||||
|
||||
@@ -58,9 +58,6 @@ import type {
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
|
||||
import { OpenCodeV2AgentClient } from "./providers/opencode/v2/agent.js";
|
||||
import { V2Harness } from "./providers/opencode/test-utils/v2-harness.js";
|
||||
|
||||
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
|
||||
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
|
||||
|
||||
@@ -673,33 +670,6 @@ async function startAndSteerThroughManager(
|
||||
return { manager, agentId: agent.id, workdir };
|
||||
}
|
||||
|
||||
test("stops an OpenCode execution already active when its conversation is reopened", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-opencode-resume-"));
|
||||
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
|
||||
const harness = new V2Harness();
|
||||
harness.info.location.directory = workdir;
|
||||
harness.startExecution();
|
||||
const client = new OpenCodeV2AgentClient({ logger, runtime: harness.runtime });
|
||||
const manager = new AgentManager({ clients: { opencode: client }, logger });
|
||||
const agent = await manager.resumeAgentFromPersistence({
|
||||
provider: "opencode",
|
||||
sessionId: "session",
|
||||
nativeHandle: "session",
|
||||
metadata: { cwd: workdir },
|
||||
});
|
||||
try {
|
||||
expect(await manager.cancelAgentRun(agent.id)).toEqual({ status: "settled" });
|
||||
expect(harness.active).toBe(false);
|
||||
for await (const _event of manager.streamAgent(agent.id, "continue after restart")) {
|
||||
// Drain the next turn through the same interface used by callers.
|
||||
}
|
||||
expect(harness.prompts).toEqual(["continue after restart"]);
|
||||
} finally {
|
||||
await manager.closeAgent(agent.id);
|
||||
await client.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("uses an injected timeline store without making it a production requirement", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-timeline-store-"));
|
||||
const store = new RecordingTimelineStore();
|
||||
@@ -4092,7 +4062,7 @@ test("createAgent rejects a disabled provider without creating a session", async
|
||||
),
|
||||
).rejects.toThrow("Provider 'codex' is disabled");
|
||||
expect(disabledClient.createSessionCalls).toBe(0);
|
||||
expect(await storage.listAll()).toHaveLength(0);
|
||||
expect(await storage.list()).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("updateProviderRegistry re-enables a previously disabled provider", async () => {
|
||||
@@ -8273,7 +8243,7 @@ test("archiveAgent closes an internal agent without writing a record", async ()
|
||||
expect(Number.isNaN(Date.parse(archivedAt))).toBe(false);
|
||||
expect(manager.getAgent(internalAgentId)).toBeNull();
|
||||
expect(await storage.get(internalAgentId)).toBeNull();
|
||||
expect(await storage.listAll()).toEqual([]);
|
||||
expect(await storage.list()).toEqual([]);
|
||||
expect(manager.getRetiredInternalAgent(internalAgentId)).toEqual({
|
||||
record: expect.objectContaining({
|
||||
id: internalAgentId,
|
||||
@@ -9379,8 +9349,8 @@ test("archiveAgent re-reads a child before deciding whether to cascade", async (
|
||||
class ChildOpensAfterCascadeListStorage extends AgentStorage {
|
||||
childId: string | null = null;
|
||||
|
||||
override async listActive(): Promise<StoredAgentRecord[]> {
|
||||
const records = await super.listAll();
|
||||
override async list(): Promise<StoredAgentRecord[]> {
|
||||
const records = await super.list();
|
||||
const parentIsArchived = records.some(
|
||||
(record) => record.id !== this.childId && Boolean(record.archivedAt),
|
||||
);
|
||||
|
||||
@@ -171,23 +171,6 @@ async function assertUsableWorkingDirectory(cwd: string): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveResumePurpose(
|
||||
record: StoredAgentRecord | null,
|
||||
requested: AgentResumePurpose | undefined,
|
||||
cwd: string,
|
||||
): Promise<AgentResumePurpose> {
|
||||
if (!record) return requested ?? "interactive";
|
||||
if (record.archivedAt) return "history";
|
||||
if (requested !== "history") return "interactive";
|
||||
try {
|
||||
await stat(cwd);
|
||||
return "interactive";
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "history";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
interface PreparedSessionConfig {
|
||||
storedConfig: AgentSessionConfig;
|
||||
launchConfig: AgentSessionConfig;
|
||||
@@ -341,8 +324,6 @@ export interface CreateAgentOptions {
|
||||
// undefined is an explicit decision: the agent never appears in the sidebar.
|
||||
workspaceId: string | undefined;
|
||||
owner?: AgentOwner;
|
||||
/** Background agents stay out of discovery, tabs, tracks and attention. Fixed at creation. */
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
export interface AgentManagerOptions {
|
||||
@@ -406,11 +387,6 @@ export type AttentionState =
|
||||
attentionTimestamp: Date;
|
||||
};
|
||||
|
||||
/** Private helpers and background agents report to their creator, never to the user. */
|
||||
function raisesAttention(agent: Pick<ManagedAgent, "internal" | "background">): boolean {
|
||||
return !agent.internal && !agent.background;
|
||||
}
|
||||
|
||||
function resolveInitialAttention(input: AttentionState | undefined): AttentionState {
|
||||
if (input == null || !input.requiresAttention) {
|
||||
return { requiresAttention: false };
|
||||
@@ -476,11 +452,6 @@ interface ManagedAgentBase {
|
||||
* Internal agents are hidden from listings and don't trigger notifications.
|
||||
*/
|
||||
internal?: boolean;
|
||||
/**
|
||||
* Background agents are durable but hidden from default discovery and never raise attention.
|
||||
* Fixed at creation.
|
||||
*/
|
||||
background?: boolean;
|
||||
/**
|
||||
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
|
||||
*/
|
||||
@@ -800,7 +771,6 @@ export class AgentManager {
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
||||
private readonly historySessions = new WeakSet<AgentSession>();
|
||||
private readonly reloadedSessionCloses = new WeakMap<AgentSession, Promise<void>>();
|
||||
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
|
||||
private readonly collectingRuntimes = new Set<string>();
|
||||
@@ -1432,7 +1402,6 @@ export class AgentManager {
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
background: options.background,
|
||||
launchEnv: options.env ? { ...options.env } : undefined,
|
||||
historyPrimed: true,
|
||||
});
|
||||
@@ -1465,7 +1434,6 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1498,7 +1466,6 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1519,8 +1486,10 @@ export class AgentManager {
|
||||
// settled before the config is prepared, because a history load reads an archived
|
||||
// agent whose working directory may be gone.
|
||||
const record = this.registry ? await this.registry.get(resolvedAgentId) : null;
|
||||
const purpose = await resolveResumePurpose(record, resumeOptions?.purpose, mergedConfig.cwd);
|
||||
const currentResumeOptions = { purpose } satisfies AgentResumeSessionOptions;
|
||||
const currentResumeOptions = record
|
||||
? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) }
|
||||
: resumeOptions;
|
||||
const purpose = currentResumeOptions?.purpose ?? "interactive";
|
||||
|
||||
const launchEnv = record?.launchEnv;
|
||||
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
|
||||
@@ -1557,7 +1526,6 @@ export class AgentManager {
|
||||
launchContext,
|
||||
currentResumeOptions,
|
||||
);
|
||||
if (purpose === "history") this.historySessions.add(session);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
...options,
|
||||
@@ -1759,7 +1727,6 @@ export class AgentManager {
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
background: existing.background,
|
||||
launchEnv: existing.launchEnv,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
@@ -2082,7 +2049,7 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
const parentAgentId = parent.id;
|
||||
const records = await registry.listActive();
|
||||
const records = await registry.list();
|
||||
for (const record of records) {
|
||||
if (record.archivedAt) {
|
||||
continue;
|
||||
@@ -2214,14 +2181,13 @@ export class AgentManager {
|
||||
lastError: record.lastError ?? undefined,
|
||||
attention,
|
||||
internal: record.internal,
|
||||
background: record.background,
|
||||
labels: record.labels,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async setAgentMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null> {
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const notice = (await agent.session.setMode(modeId)) ?? null;
|
||||
await this.drainSessionEvents(agentId);
|
||||
const currentMode = (await agent.session.getCurrentMode()) ?? modeId;
|
||||
@@ -2237,7 +2203,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async setAgentModel(agentId: string, modelId: string | null): Promise<void> {
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const normalizedModelId =
|
||||
typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null;
|
||||
|
||||
@@ -2259,7 +2225,7 @@ export class AgentManager {
|
||||
agentId: string,
|
||||
thinkingOptionId: string | null,
|
||||
): Promise<AgentProviderNotice | null> {
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const normalizedThinkingOptionId =
|
||||
typeof thinkingOptionId === "string" && thinkingOptionId.trim().length > 0
|
||||
? thinkingOptionId
|
||||
@@ -2290,7 +2256,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const agent = this.requireAgent(agentId);
|
||||
|
||||
if (!agent.session.setFeature) {
|
||||
throw new Error("Agent session does not support setting features");
|
||||
@@ -2548,8 +2514,7 @@ export class AgentManager {
|
||||
|
||||
async unarchiveSnapshotByHandle(handle: AgentPersistenceHandle): Promise<void> {
|
||||
const registry = this.requireRegistry();
|
||||
// Restoring an archived provider session is an explicit recovery operation.
|
||||
const records = await registry.listAll();
|
||||
const records = await registry.list();
|
||||
const matched = records.find(
|
||||
(record) =>
|
||||
record.persistence?.provider === handle.provider &&
|
||||
@@ -2672,7 +2637,7 @@ export class AgentManager {
|
||||
logger: this.logger,
|
||||
});
|
||||
}
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
// Providers parse slash commands from the raw text, so a command another agent sent
|
||||
// must reach them without its sender envelope.
|
||||
const agentMessage = typeof prompt === "string" ? parseAgentMessage(prompt) : null;
|
||||
@@ -2801,34 +2766,6 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureInteractiveSession(agentId: string): Promise<ActiveManagedAgent> {
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
if (this.historySessions.has(agent.session)) {
|
||||
await this.trackAgentRegistrationOperation(
|
||||
this.runLifecycleMutation(agentId, async () => {
|
||||
const record = await this.registry?.get(agentId);
|
||||
if (record?.archivedAt) throw new Error(`Agent is archived: ${agentId}`);
|
||||
const current = this.requireSessionAgent(agentId);
|
||||
if (this.historySessions.has(current.session)) {
|
||||
// Recheck inside the lane: another prompt may already have upgraded it.
|
||||
// Reload validates the directory before releasing readable history.
|
||||
await this.reloadAgentSessionInternal(agentId);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
return this.requireSessionAgent(agentId);
|
||||
}
|
||||
|
||||
private async *streamAfterHistoryLoad(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
options?: AgentRunOptions,
|
||||
): AsyncGenerator<AgentStreamEvent> {
|
||||
await this.ensureInteractiveSession(agentId);
|
||||
yield* this.streamAgent(agentId, prompt, options);
|
||||
}
|
||||
|
||||
private async *streamAfterRuntimeCollection(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
@@ -2851,8 +2788,6 @@ export class AgentManager {
|
||||
if (this.collectingRuntimes.has(agentId) || (!this.agents.has(agentId) && this.registry))
|
||||
return this.streamAfterRuntimeCollection(agentId, prompt, options);
|
||||
const existingAgent = this.requireSessionAgent(agentId);
|
||||
if (this.historySessions.has(existingAgent.session))
|
||||
return this.streamAfterHistoryLoad(agentId, prompt, options);
|
||||
this.logger.trace(
|
||||
{
|
||||
agentId,
|
||||
@@ -3539,12 +3474,7 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async rewind(agentId: string, messageId: string, mode: RewindMode): Promise<void> {
|
||||
let agent = this.requireSessionAgent(agentId);
|
||||
if (this.historySessions.has(agent.session)) {
|
||||
agent = await this.ensureInteractiveSession(agentId);
|
||||
}
|
||||
// An interactive session must acquire its rewind lock without yielding.
|
||||
|
||||
const agent = this.requireSessionAgent(agentId);
|
||||
const submittedRow = this.timelineStore
|
||||
.getRows(agentId)
|
||||
.find(
|
||||
@@ -3877,7 +3807,6 @@ export class AgentManager {
|
||||
publishWhenReady?: boolean;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
launchEnv?: Record<string, string>;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
@@ -3907,7 +3836,6 @@ export class AgentManager {
|
||||
config,
|
||||
now,
|
||||
durableTimelineHasRows,
|
||||
background: options?.background === true,
|
||||
options,
|
||||
});
|
||||
|
||||
@@ -4052,7 +3980,6 @@ export class AgentManager {
|
||||
config: AgentSessionConfig;
|
||||
now: Date;
|
||||
durableTimelineHasRows: boolean;
|
||||
background: boolean;
|
||||
options:
|
||||
| {
|
||||
createdAt?: Date;
|
||||
@@ -4107,7 +4034,6 @@ export class AgentManager {
|
||||
lastError: options?.lastError,
|
||||
attention: resolveInitialAttention(options?.attention),
|
||||
internal: config.internal ?? false,
|
||||
background: params.background,
|
||||
labels: options?.labels ?? {},
|
||||
} as ActiveManagedAgent;
|
||||
}
|
||||
@@ -5041,7 +4967,7 @@ export class AgentManager {
|
||||
const hadPendingPermissions = agent.pendingPermissions.size > 0;
|
||||
agent.pendingPermissions.set(event.request.id, event.request);
|
||||
this.refreshSessionPersistence(agent);
|
||||
if (!hadPendingPermissions && raisesAttention(agent)) {
|
||||
if (!hadPendingPermissions && !agent.internal) {
|
||||
this.broadcastAgentAttention(agent, "permission");
|
||||
}
|
||||
this.emitState(agent);
|
||||
@@ -5296,7 +5222,8 @@ export class AgentManager {
|
||||
// Track the new status
|
||||
this.previousStatuses.set(agent.id, currentStatus);
|
||||
|
||||
if (!raisesAttention(agent)) {
|
||||
// Skip attention tracking for internal agents
|
||||
if (agent.internal) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -106,7 +106,6 @@ export function toStoredAgentRecord(
|
||||
? agent.attention.attentionTimestamp.toISOString()
|
||||
: null,
|
||||
internal: options?.internal,
|
||||
...(agent.background ? { background: true } : {}),
|
||||
owner: agent.owner,
|
||||
} satisfies StoredAgentRecord;
|
||||
}
|
||||
@@ -150,7 +149,7 @@ export function toAgentPayload(
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...visibilityFlags(agent),
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
};
|
||||
|
||||
const usage = sanitizeUsage(agent.lastUsage);
|
||||
@@ -207,17 +206,6 @@ function buildStoredPersistenceHandle(
|
||||
return toAgentPersistenceHandle(validProviders, record.persistence);
|
||||
}
|
||||
|
||||
/** The wire carries `internal` and `background` only when set. */
|
||||
function visibilityFlags(agent: {
|
||||
internal?: boolean;
|
||||
background?: boolean;
|
||||
}): Pick<AgentSnapshotPayload, "internal" | "background"> {
|
||||
return {
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
...(agent.background ? { background: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStoredAgentPayload(
|
||||
record: StoredAgentRecord,
|
||||
validProviders: Iterable<AgentProvider>,
|
||||
@@ -270,7 +258,7 @@ export function buildStoredAgentPayload(
|
||||
attentionReason: record.attentionReason ?? null,
|
||||
attentionTimestamp: record.attentionTimestamp ?? null,
|
||||
archivedAt: record.archivedAt ?? null,
|
||||
...visibilityFlags(record),
|
||||
...(record.internal ? { internal: true } : {}),
|
||||
labels: normalizeLabels(record.labels),
|
||||
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
|
||||
...(providerAvailable ? {} : { providerUnavailable: true }),
|
||||
|
||||
@@ -3,8 +3,6 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { Session as InspectorSession } from "node:inspector/promises";
|
||||
import type { HeapProfiler } from "node:inspector";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
@@ -150,101 +148,6 @@ describe("AgentStorage", () => {
|
||||
rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("workspace and active queries follow updates, removal, and reload", async () => {
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: tmpDir,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
labels: {},
|
||||
};
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "a",
|
||||
workspaceId: "one",
|
||||
lastUserMessageAt: base.createdAt,
|
||||
});
|
||||
await storage.upsert({ ...base, id: "b", workspaceId: "two", archivedAt: base.createdAt });
|
||||
expect((await storage.listActive()).map((r) => r.id)).toEqual(["a"]);
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "a",
|
||||
workspaceId: "two",
|
||||
lastUserMessageAt: base.updatedAt,
|
||||
});
|
||||
expect(await storage.listByWorkspaces(["one"])).toEqual([]);
|
||||
expect((await storage.listByWorkspaces(["two"])).map((r) => r.id)).toEqual(["a"]);
|
||||
expect(
|
||||
(await storage.listByWorkspaces(["two"], { includeArchived: true })).map((r) => r.id).sort(),
|
||||
).toEqual(["a", "b"]);
|
||||
expect(await storage.lastUserMessageAtByWorkspace(["one", "two"])).toEqual(
|
||||
new Map([["two", base.createdAt]]),
|
||||
);
|
||||
await storage.flush();
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
expect((await reloaded.listActive()).map((r) => r.id)).toEqual(["a"]);
|
||||
expect(
|
||||
await reloaded.lastUserMessageAtByWorkspace(["two"], { excludeAgentIds: ["a"] }),
|
||||
).toEqual(new Map());
|
||||
await reloaded.remove("a");
|
||||
expect(await reloaded.listActive()).toEqual([]);
|
||||
expect(await reloaded.lastUserMessageAtByWorkspace(["two"])).toEqual(new Map());
|
||||
});
|
||||
|
||||
test("single-agent lookups keep allocations bounded with a large stored history", async () => {
|
||||
await fs.mkdir(storagePath, { recursive: true });
|
||||
const record = {
|
||||
id: "history-0",
|
||||
provider: "codex",
|
||||
cwd: tmpDir,
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
labels: {},
|
||||
lastStatus: "idle",
|
||||
};
|
||||
for (let offset = 0; offset < 2048; offset += 64) {
|
||||
await Promise.all(
|
||||
Array.from({ length: 64 }, (_, index) => {
|
||||
const id = `history-${offset + index}`;
|
||||
return fs.writeFile(
|
||||
path.join(storagePath, `${id}.json`),
|
||||
JSON.stringify({ ...record, id }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
await storage.initialize();
|
||||
expect(await storage.listAll()).toHaveLength(2048);
|
||||
const expected = await storage.get(record.id);
|
||||
expect(expected).toMatchObject(record);
|
||||
|
||||
const inspector = new InspectorSession();
|
||||
inspector.connect();
|
||||
let allocatedBytes = 0;
|
||||
let matches = 0;
|
||||
try {
|
||||
await inspector.post("HeapProfiler.startSampling", {
|
||||
samplingInterval: 16384,
|
||||
includeObjectsCollectedByMajorGC: true,
|
||||
includeObjectsCollectedByMinorGC: true,
|
||||
});
|
||||
for (let index = 0; index < 1000; index++) {
|
||||
if ((await storage.get(record.id)) === expected) matches++;
|
||||
}
|
||||
const { profile } = await inspector.post("HeapProfiler.stopSampling");
|
||||
const sum = (node: HeapProfiler.SamplingHeapProfileNode): number =>
|
||||
node.selfSize + node.children.reduce((bytes, child) => bytes + sum(child), 0);
|
||||
allocatedBytes = sum(profile.head);
|
||||
} finally {
|
||||
inspector.disconnect();
|
||||
}
|
||||
|
||||
expect(matches).toBe(1000);
|
||||
// Copying this history on every lookup allocates >16 MiB. Allow generous
|
||||
// sampling/runtime overhead without relying on the host's execution speed.
|
||||
expect(allocatedBytes).toBeLessThan(4 * 1024 * 1024);
|
||||
});
|
||||
|
||||
test("applySnapshot persists configs and snapshot metadata", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
@@ -269,7 +172,7 @@ describe("AgentStorage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await storage.listAll();
|
||||
const records = await storage.list();
|
||||
expect(records).toHaveLength(1);
|
||||
const [record] = records;
|
||||
expect(record.provider).toBe("claude");
|
||||
@@ -286,8 +189,8 @@ describe("AgentStorage", () => {
|
||||
expect(record.lastModeId).toBe("coding");
|
||||
expect(record.lastStatus).toBe("idle");
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const [persisted] = await reloaded.listAll();
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const [persisted] = await reloaded.list();
|
||||
expect(persisted.cwd).toBe("/tmp/project");
|
||||
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
|
||||
});
|
||||
@@ -307,7 +210,7 @@ describe("AgentStorage", () => {
|
||||
const record = await storage.get("agent-feature-values");
|
||||
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-feature-values");
|
||||
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
|
||||
@@ -320,7 +223,7 @@ describe("AgentStorage", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-no-feature-values");
|
||||
expect(persisted?.config?.featureValues).toBeUndefined();
|
||||
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
|
||||
@@ -409,7 +312,7 @@ describe("AgentStorage", () => {
|
||||
const current = await storage.get("agent-2");
|
||||
expect(current?.title).toBe("Fix Login Bug");
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const persisted = await reloaded.get("agent-2");
|
||||
expect(persisted?.title).toBe("Fix Login Bug");
|
||||
});
|
||||
@@ -512,7 +415,7 @@ describe("AgentStorage", () => {
|
||||
);
|
||||
|
||||
// Registry should return all agents - filtering is done at the manager level
|
||||
const records = await storage.listAll();
|
||||
const records = await storage.list();
|
||||
expect(records).toHaveLength(2);
|
||||
});
|
||||
|
||||
@@ -583,12 +486,12 @@ describe("AgentStorage", () => {
|
||||
);
|
||||
|
||||
// Reload the registry from disk
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const record = await reloaded.get("internal-agent");
|
||||
expect(record?.internal).toBe(true);
|
||||
|
||||
// Registry returns all agents - filtering happens at manager level
|
||||
const records = await reloaded.listAll();
|
||||
const records = await reloaded.list();
|
||||
expect(records).toHaveLength(1);
|
||||
expect(records[0]?.internal).toBe(true);
|
||||
});
|
||||
@@ -640,8 +543,8 @@ describe("AgentStorage", () => {
|
||||
);
|
||||
|
||||
// Force a reload so the registry has to discover from disk (and may choose either copy).
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const before = await reloaded.listAll();
|
||||
const reloaded = new AgentStorage(storagePath, logger);
|
||||
const before = await reloaded.list();
|
||||
expect(before.map((r) => r.id)).toContain(agentId);
|
||||
|
||||
await reloaded.remove(agentId);
|
||||
@@ -668,8 +571,8 @@ describe("AgentStorage", () => {
|
||||
|
||||
expect(await hasAnyRecordFile()).toBe(false);
|
||||
|
||||
const afterReload = new AgentStorage(storagePath, createTestLogger());
|
||||
const after = await afterReload.listAll();
|
||||
const afterReload = new AgentStorage(storagePath, logger);
|
||||
const after = await afterReload.list();
|
||||
expect(after.some((r) => r.id === agentId)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -75,7 +75,6 @@ const STORED_AGENT_SCHEMA = z.object({
|
||||
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
|
||||
attentionTimestamp: z.string().nullable().optional(),
|
||||
internal: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
archivedAt: z.string().nullable().optional(),
|
||||
owner: AgentOwnerSchema.optional(),
|
||||
});
|
||||
@@ -105,14 +104,9 @@ export class AgentStorage {
|
||||
private deleting: Set<string> = new Set();
|
||||
private daemonAgentIdsByExecution: Map<string, string> = new Map();
|
||||
private daemonExecutionKeysByAgentId: Map<string, string> = new Map();
|
||||
private readonly idsByWorkspace = new Map<string, Set<string>>();
|
||||
private readonly workspaceById = new Map<string, string>();
|
||||
private readonly activeIds = new Set<string>();
|
||||
private readonly promptTimeByAgent = new Map<string, string>();
|
||||
private readonly promptTimes = new Map<string, string>();
|
||||
private loaded = false;
|
||||
private baseDir: string;
|
||||
private loadPromise: Promise<void> | null = null;
|
||||
private loadPromise: Promise<StoredAgentRecord[]> | null = null;
|
||||
private logger: Logger;
|
||||
|
||||
constructor(baseDir: string, logger: Logger) {
|
||||
@@ -124,119 +118,11 @@ export class AgentStorage {
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async listAll(): Promise<StoredAgentRecord[]> {
|
||||
async list(): Promise<StoredAgentRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
async listActive(options?: { includeArchived?: boolean }): Promise<StoredAgentRecord[]> {
|
||||
await this.load();
|
||||
return Array.from(
|
||||
options?.includeArchived ? this.cache.keys() : this.activeIds,
|
||||
(id) => this.cache.get(id)!,
|
||||
);
|
||||
}
|
||||
|
||||
async listByWorkspaces(
|
||||
workspaceIds: Iterable<string>,
|
||||
options?: { includeArchived?: boolean },
|
||||
): Promise<StoredAgentRecord[]> {
|
||||
await this.load();
|
||||
const records: StoredAgentRecord[] = [];
|
||||
for (const workspaceId of new Set(workspaceIds)) {
|
||||
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
|
||||
const record = this.cache.get(id)!;
|
||||
if (options?.includeArchived || !record.archivedAt) records.push(record);
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
async lastUserMessageAtByWorkspace(
|
||||
workspaceIds?: Iterable<string>,
|
||||
options?: { excludeAgentIds?: Iterable<string> },
|
||||
): Promise<ReadonlyMap<string, string>> {
|
||||
await this.load();
|
||||
const times = new Map<string, string>();
|
||||
const excluded = new Set(options?.excludeAgentIds);
|
||||
const affected = new Set(Array.from(excluded, (id) => this.workspaceById.get(id)));
|
||||
for (const id of workspaceIds ?? this.promptTimes.keys()) {
|
||||
let at = this.promptTimes.get(id);
|
||||
if (affected.has(id)) {
|
||||
at = undefined;
|
||||
for (const agentId of this.idsByWorkspace.get(id) ?? []) {
|
||||
const record = this.cache.get(agentId)!;
|
||||
if (
|
||||
!excluded.has(agentId) &&
|
||||
!record.internal &&
|
||||
!record.background &&
|
||||
record.lastUserMessageAt &&
|
||||
record.lastUserMessageAt > (at ?? "")
|
||||
) {
|
||||
at = record.lastUserMessageAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (at) times.set(id, at);
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
private indexWorkspace(record: StoredAgentRecord): void {
|
||||
const previous = this.workspaceById.get(record.id);
|
||||
if (previous) this.idsByWorkspace.get(previous)?.delete(record.id);
|
||||
this.workspaceById.delete(record.id);
|
||||
this.activeIds.delete(record.id);
|
||||
if (!record.archivedAt) this.activeIds.add(record.id);
|
||||
if (record.workspaceId) {
|
||||
const ids = this.idsByWorkspace.get(record.workspaceId) ?? new Set<string>();
|
||||
ids.add(record.id);
|
||||
this.idsByWorkspace.set(record.workspaceId, ids);
|
||||
this.workspaceById.set(record.id, record.workspaceId);
|
||||
}
|
||||
this.indexPromptTime(record, previous);
|
||||
}
|
||||
|
||||
private indexPromptTime(record: StoredAgentRecord, previous: string | undefined): void {
|
||||
const previousTime = this.promptTimeByAgent.get(record.id);
|
||||
const nextTime =
|
||||
!record.internal && !record.background ? (record.lastUserMessageAt ?? undefined) : undefined;
|
||||
if (nextTime) this.promptTimeByAgent.set(record.id, nextTime);
|
||||
else this.promptTimeByAgent.delete(record.id);
|
||||
if (previous === record.workspaceId && previousTime === nextTime) return;
|
||||
if (
|
||||
previous &&
|
||||
previousTime &&
|
||||
this.promptTimes.get(previous) === previousTime &&
|
||||
(previous !== record.workspaceId || !nextTime || nextTime < previousTime)
|
||||
) {
|
||||
this.refreshPromptTime(previous);
|
||||
}
|
||||
if (
|
||||
record.workspaceId &&
|
||||
nextTime &&
|
||||
nextTime > (this.promptTimes.get(record.workspaceId) ?? "")
|
||||
) {
|
||||
this.promptTimes.set(record.workspaceId, nextTime);
|
||||
}
|
||||
}
|
||||
|
||||
private refreshPromptTime(workspaceId: string): void {
|
||||
this.promptTimes.delete(workspaceId);
|
||||
let latest = "";
|
||||
for (const id of this.idsByWorkspace.get(workspaceId) ?? []) {
|
||||
const record = this.cache.get(id)!;
|
||||
if (
|
||||
!record.internal &&
|
||||
!record.background &&
|
||||
record.lastUserMessageAt &&
|
||||
record.lastUserMessageAt > latest
|
||||
)
|
||||
latest = record.lastUserMessageAt;
|
||||
}
|
||||
if (latest) this.promptTimes.set(workspaceId, latest);
|
||||
}
|
||||
|
||||
async get(agentId: string): Promise<StoredAgentRecord | null> {
|
||||
await this.load();
|
||||
return this.cache.get(agentId) ?? null;
|
||||
@@ -256,7 +142,8 @@ export class AgentStorage {
|
||||
}
|
||||
|
||||
async listByWorkspace(workspaceId: string): Promise<StoredAgentRecord[]> {
|
||||
return this.listByWorkspaces([workspaceId], { includeArchived: true });
|
||||
await this.load();
|
||||
return Array.from(this.cache.values()).filter((record) => record.workspaceId === workspaceId);
|
||||
}
|
||||
|
||||
async findByDaemonExecution(owner: DaemonAgentOwner): Promise<StoredAgentRecord | null> {
|
||||
@@ -346,14 +233,6 @@ export class AgentStorage {
|
||||
}),
|
||||
);
|
||||
|
||||
const workspaceId = this.workspaceById.get(agentId);
|
||||
this.workspaceById.delete(agentId);
|
||||
this.promptTimeByAgent.delete(agentId);
|
||||
this.activeIds.delete(agentId);
|
||||
if (workspaceId) {
|
||||
this.idsByWorkspace.get(workspaceId)?.delete(agentId);
|
||||
this.refreshPromptTime(workspaceId);
|
||||
}
|
||||
this.cache.delete(agentId);
|
||||
this.removeOwnerIndex(agentId);
|
||||
this.pathById.delete(agentId);
|
||||
@@ -382,8 +261,6 @@ export class AgentStorage {
|
||||
if (existing && existing.archivedAt !== undefined) {
|
||||
record.archivedAt = existing.archivedAt;
|
||||
}
|
||||
// Background is fixed at creation; a runtime opened without it cannot clear it.
|
||||
if (existing?.background) record.background = true;
|
||||
record.resumeOnStartup =
|
||||
existing?.resumeOnStartup && agent.lifecycle !== "idle" && agent.lifecycle !== "error";
|
||||
return record;
|
||||
@@ -406,9 +283,9 @@ export class AgentStorage {
|
||||
await Promise.allSettled(writes);
|
||||
}
|
||||
|
||||
private async load(): Promise<void> {
|
||||
private async load(): Promise<StoredAgentRecord[]> {
|
||||
if (this.loaded) {
|
||||
return;
|
||||
return Array.from(this.cache.values());
|
||||
}
|
||||
|
||||
if (!this.loadPromise) {
|
||||
@@ -418,7 +295,7 @@ export class AgentStorage {
|
||||
return this.loadPromise;
|
||||
}
|
||||
|
||||
private async doLoad(): Promise<void> {
|
||||
private async doLoad(): Promise<StoredAgentRecord[]> {
|
||||
this.cache.clear();
|
||||
this.pathById.clear();
|
||||
this.pathsById.clear();
|
||||
@@ -426,25 +303,28 @@ export class AgentStorage {
|
||||
this.daemonExecutionKeysByAgentId.clear();
|
||||
|
||||
try {
|
||||
await this.scanDisk();
|
||||
const records = await this.scanDisk();
|
||||
this.loaded = true;
|
||||
return records;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
this.loaded = true;
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
this.logger.error({ err: error }, "Failed to load agents");
|
||||
this.loaded = true;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private async scanDisk(): Promise<void> {
|
||||
private async scanDisk(): Promise<StoredAgentRecord[]> {
|
||||
const records: StoredAgentRecord[] = [];
|
||||
let entries: Dirent[] = [];
|
||||
try {
|
||||
entries = await fs.readdir(this.baseDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return;
|
||||
return [];
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -481,11 +361,14 @@ export class AgentStorage {
|
||||
for (const item of loaded) {
|
||||
if (!item) continue;
|
||||
const { record, filePath } = item;
|
||||
records.push(record);
|
||||
this.cache.set(record.id, record);
|
||||
this.indexOwner(record);
|
||||
this.pathById.set(record.id, filePath);
|
||||
this.addIndexedPath(record.id, filePath);
|
||||
}
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
private async readRecordFile(filePath: string): Promise<StoredAgentRecord | null> {
|
||||
@@ -522,7 +405,6 @@ export class AgentStorage {
|
||||
}
|
||||
|
||||
private indexOwner(record: StoredAgentRecord): void {
|
||||
this.indexWorkspace(record);
|
||||
this.removeOwnerIndex(record.id);
|
||||
if (record.owner?.kind === "daemon") {
|
||||
const key = daemonExecutionKey(record.owner);
|
||||
|
||||
@@ -21,17 +21,7 @@ import { resolveCreateAgentTitles } from "../create-agent-title.js";
|
||||
import { buildAgentPrompt } from "../prompt-attachments.js";
|
||||
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
|
||||
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
|
||||
import {
|
||||
inheritAgentBackground,
|
||||
inheritWorkspaceBackground,
|
||||
resolveCreateAgentIntent,
|
||||
} from "./intent.js";
|
||||
|
||||
/** How a workspace this creation makes inherits its creator's visibility. */
|
||||
interface McpWorkspaceIntent {
|
||||
background?: boolean;
|
||||
callerWorkspaceId?: string;
|
||||
}
|
||||
import { resolveCreateAgentIntent } from "./intent.js";
|
||||
|
||||
export interface CreateAgentCommandDependencies {
|
||||
finishSubscriptions?: FinishSubscriptions;
|
||||
@@ -50,7 +40,7 @@ export interface CreateAgentCommandDependencies {
|
||||
export type EnsureWorkspaceForCreate = (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
context?: { callerWorkspaceId?: string },
|
||||
) => Promise<string>;
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
@@ -59,7 +49,6 @@ export interface CreateAgentFromSessionInput {
|
||||
agentId?: string;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
background: boolean;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
clientMessageId?: string;
|
||||
@@ -88,8 +77,6 @@ export interface CreateAgentFromMcpInput {
|
||||
promptFailure?: CreateAgentPromptFailureMode;
|
||||
notifyOnFinish: boolean;
|
||||
internal?: boolean;
|
||||
/** Omitted with a caller, the caller agent's own background applies. */
|
||||
background?: boolean;
|
||||
detached?: boolean;
|
||||
owner?: AgentOwner;
|
||||
env?: Record<string, string>;
|
||||
@@ -268,7 +255,6 @@ async function resolveSessionCreateAgent(
|
||||
// provider session either. Same as the daemon's own ephemeral helpers.
|
||||
...(sessionConfig.internal ? { persistSession: false } : {}),
|
||||
workspaceId: requireResolvedWorkspaceId(workspaceId),
|
||||
background: input.background,
|
||||
},
|
||||
prompt: hasPromptContent ? prompt : undefined,
|
||||
source: input.source,
|
||||
@@ -291,16 +277,12 @@ async function resolveMcpCreateAgent(
|
||||
? requireParentAgent(dependencies.agentManager, input.callerAgentId)
|
||||
: null;
|
||||
const cwd = resolveMcpInitialCwd(input, parentAgent);
|
||||
const workspaceIntent = {
|
||||
background: inheritWorkspaceBackground(input.background, parentAgent),
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
};
|
||||
const { resolvedCwd, createdWorkspaceId, createdWorktree } = await resolveMcpCwd({
|
||||
dependencies,
|
||||
cwd,
|
||||
worktree: input.worktree,
|
||||
initialPrompt: input.initialPrompt ?? "",
|
||||
workspaceIntent,
|
||||
callerWorkspaceId: parentAgent?.workspaceId,
|
||||
});
|
||||
if (createdWorktree) input.onWorktreeCreated?.(createdWorktree);
|
||||
const intent = await resolveCreateAgentIntent({
|
||||
@@ -318,7 +300,7 @@ async function resolveMcpCreateAgent(
|
||||
dependencies,
|
||||
resolvedCwd,
|
||||
input.initialPrompt ?? "",
|
||||
workspaceIntent,
|
||||
parentAgent?.workspaceId,
|
||||
),
|
||||
),
|
||||
cwd: resolvedCwd,
|
||||
@@ -347,7 +329,6 @@ async function resolveMcpCreateAgent(
|
||||
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
|
||||
workspaceId: intent.workspaceId,
|
||||
owner: input.owner,
|
||||
background: inheritAgentBackground(input.background, parentAgent),
|
||||
env: input.env,
|
||||
},
|
||||
prompt: trimmedPrompt ? trimmedPrompt : undefined,
|
||||
@@ -429,12 +410,16 @@ async function ensureWorkspaceForMcpCreate(
|
||||
dependencies: CreateAgentCommandDependencies,
|
||||
cwd: string,
|
||||
initialPrompt: string,
|
||||
workspaceIntent: McpWorkspaceIntent,
|
||||
callerWorkspaceId?: string,
|
||||
): Promise<string | undefined> {
|
||||
if (!dependencies.ensureWorkspaceForCreate) {
|
||||
return undefined;
|
||||
}
|
||||
return dependencies.ensureWorkspaceForCreate(cwd, { prompt: initialPrompt }, workspaceIntent);
|
||||
return dependencies.ensureWorkspaceForCreate(
|
||||
cwd,
|
||||
{ prompt: initialPrompt },
|
||||
{ callerWorkspaceId },
|
||||
);
|
||||
}
|
||||
|
||||
async function sendInitialPrompt(
|
||||
@@ -527,7 +512,7 @@ async function resolveMcpCwd(params: {
|
||||
dependencies: CreateAgentCommandDependencies;
|
||||
cwd: string;
|
||||
initialPrompt: string;
|
||||
workspaceIntent: McpWorkspaceIntent;
|
||||
callerWorkspaceId?: string;
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}): Promise<{
|
||||
resolvedCwd: string;
|
||||
@@ -557,7 +542,7 @@ async function resolveMcpCwd(params: {
|
||||
const createdWorktree = await createMcpWorktree({
|
||||
input: {
|
||||
cwd: params.cwd,
|
||||
...params.workspaceIntent,
|
||||
callerWorkspaceId: params.callerWorkspaceId,
|
||||
worktreeSlug: worktree.worktreeName,
|
||||
branchName: worktree.branchName,
|
||||
refName: worktree.refName,
|
||||
|
||||
@@ -6,30 +6,6 @@ export interface CreateAgentCaller {
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
/** The agent on whose behalf something is created, as far as background inheritance goes. */
|
||||
export interface BackgroundCaller {
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
/** Omitted, an agent inherits its caller agent's own background; without a caller it is visible. */
|
||||
export function inheritAgentBackground(
|
||||
requested: boolean | undefined,
|
||||
caller: BackgroundCaller | null | undefined,
|
||||
): boolean {
|
||||
return requested ?? caller?.background === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Omitted, a new workspace inherits its caller's effective background. A background caller
|
||||
* agent decides it here; otherwise `undefined` lets provisioning apply the caller workspace's value.
|
||||
*/
|
||||
export function inheritWorkspaceBackground(
|
||||
requested: boolean | undefined,
|
||||
caller: BackgroundCaller | null | undefined,
|
||||
): boolean | undefined {
|
||||
return requested ?? (caller?.background === true ? true : undefined);
|
||||
}
|
||||
|
||||
export interface CreateAgentPlacement {
|
||||
workspaceId: string;
|
||||
cwd: string;
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { resolveWorkspaceRootAgent } from "../workspace-directory.js";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, expect, test } from "vitest";
|
||||
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { buildStoredAgentPayload } from "./agent-projections.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { AgentDirectory, type BackgroundVisibility } from "./directory.js";
|
||||
import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
|
||||
|
||||
const NOW = "2026-03-01T12:00:00.000Z";
|
||||
|
||||
let home: string;
|
||||
let storage: AgentStorage;
|
||||
let directory: AgentDirectory;
|
||||
|
||||
function workspace(workspaceId: string, background: boolean): PersistedWorkspaceRecord {
|
||||
return {
|
||||
workspaceId,
|
||||
projectId: "project",
|
||||
cwd: home,
|
||||
kind: "local_checkout",
|
||||
displayName: workspaceId,
|
||||
background,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
archivedAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
home = await mkdtemp(join(tmpdir(), "agent-directory-"));
|
||||
storage = new AgentStorage(home, createTestLogger());
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
labels: {},
|
||||
lastStatus: "idle" as const,
|
||||
};
|
||||
await storage.upsert({ ...base, id: "visible", workspaceId: "open" });
|
||||
await storage.upsert({ ...base, id: "background", workspaceId: "open", background: true });
|
||||
await storage.upsert({ ...base, id: "in-background-workspace", workspaceId: "hidden" });
|
||||
await storage.upsert({ ...base, id: "internal", workspaceId: "open", internal: true });
|
||||
const workspaces = [workspace("open", false), workspace("hidden", true)];
|
||||
directory = new AgentDirectory({
|
||||
storage,
|
||||
manager: { listAgents: () => [] },
|
||||
workspaces: {
|
||||
get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null,
|
||||
},
|
||||
projectLive: () => {
|
||||
throw new Error("No live agents in this suite");
|
||||
},
|
||||
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
|
||||
isProviderVisible: () => true,
|
||||
isStoredProviderAvailable: () => true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(home, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function listedIds(includeBackground: BackgroundVisibility): Promise<string[]> {
|
||||
return (await directory.list({ includeBackground })).map((agent) => agent.id).toSorted();
|
||||
}
|
||||
|
||||
test("default lists hide agents that are background or live in a background workspace", async () => {
|
||||
expect(await listedIds(false)).toEqual(["visible"]);
|
||||
});
|
||||
|
||||
test("inclusive lists show every background agent", async () => {
|
||||
expect(await listedIds(true)).toEqual(["background", "in-background-workspace", "visible"]);
|
||||
});
|
||||
|
||||
test("workspace-only visibility hides agent-level background agents from older clients", async () => {
|
||||
expect(await listedIds("workspaces")).toEqual(["in-background-workspace", "visible"]);
|
||||
});
|
||||
|
||||
test("includes applies the same visibility to a single agent", async () => {
|
||||
const background = { workspaceId: "open", background: true };
|
||||
const inBackgroundWorkspace = { workspaceId: "hidden" };
|
||||
expect(await directory.includes(background)).toBe(false);
|
||||
expect(await directory.includes(background, "workspaces")).toBe(false);
|
||||
expect(await directory.includes(background, true)).toBe(true);
|
||||
expect(await directory.includes(inBackgroundWorkspace)).toBe(false);
|
||||
expect(await directory.includes(inBackgroundWorkspace, "workspaces")).toBe(true);
|
||||
});
|
||||
|
||||
test("prompt recency ignores background agents", async () => {
|
||||
const base = {
|
||||
provider: "codex",
|
||||
cwd: home,
|
||||
workspaceId: "open",
|
||||
createdAt: NOW,
|
||||
updatedAt: NOW,
|
||||
labels: {},
|
||||
lastStatus: "idle" as const,
|
||||
};
|
||||
await storage.upsert({ ...base, id: "visible", lastUserMessageAt: "2026-03-02T00:00:00.000Z" });
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "background",
|
||||
background: true,
|
||||
lastUserMessageAt: "2026-03-03T00:00:00.000Z",
|
||||
});
|
||||
expect(await directory.workspaceLastUserMessageTimes()).toEqual(
|
||||
new Map([["open", "2026-03-02T00:00:00.000Z"]]),
|
||||
);
|
||||
});
|
||||
|
||||
test("scoped status queries retain cross-workspace parent context", async () => {
|
||||
const base = { provider: "codex", cwd: home, createdAt: NOW, updatedAt: NOW };
|
||||
await storage.upsert({ ...base, id: "parent", workspaceId: "open" });
|
||||
await storage.upsert({
|
||||
...base,
|
||||
id: "child",
|
||||
workspaceId: "hidden",
|
||||
labels: { "paseo.parent-agent-id": "parent" },
|
||||
lastStatus: "running",
|
||||
});
|
||||
const agents = await directory.list({
|
||||
workspaceIds: ["hidden"],
|
||||
includeBackground: true,
|
||||
includeAncestors: true,
|
||||
});
|
||||
const child = agents.find((agent) => agent.id === "child")!;
|
||||
expect(
|
||||
resolveWorkspaceRootAgent(child, new Map(agents.map((agent) => [agent.id, agent])))?.id,
|
||||
).toBe("child");
|
||||
expect(child.status).toBe("running");
|
||||
expect(
|
||||
(await directory.list({ workspaceIds: ["hidden"], includeBackground: true })).map((a) => a.id),
|
||||
).not.toContain("parent");
|
||||
});
|
||||
@@ -1,37 +1,15 @@
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type { AgentSnapshotPayload } from "../messages.js";
|
||||
import type { WorkspaceRegistry } from "../workspace-registry.js";
|
||||
import type { AgentManager, ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentStorage, StoredAgentRecord } from "./agent-storage.js";
|
||||
|
||||
/**
|
||||
* Which background agents a discovery read admits. An agent is effectively background when
|
||||
* it or its workspace is background.
|
||||
* - `false`: none, the default for lists.
|
||||
* - `true`: all of them.
|
||||
* - `"workspaces"`: only agents in background workspaces.
|
||||
* COMPAT(backgroundAgents): added in v0.15.0, remove after 2027-05-03 once client floor
|
||||
* >= v0.15.0. Inclusive reads from sources without `background_agents` use it: older apps
|
||||
* replicate inclusively and auto-open a tab for every root agent.
|
||||
*/
|
||||
export type BackgroundVisibility = boolean | "workspaces";
|
||||
|
||||
interface DiscoverableAgent {
|
||||
workspaceId?: string;
|
||||
internal?: boolean;
|
||||
background?: boolean;
|
||||
}
|
||||
|
||||
/** Public agent discovery owns background visibility for live and durable records. */
|
||||
/** Public agent discovery owns workspace visibility for live and durable records. */
|
||||
export class AgentDirectory {
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
manager: Pick<AgentManager, "listAgents">;
|
||||
storage: Pick<
|
||||
AgentStorage,
|
||||
"get" | "listActive" | "listByWorkspaces" | "lastUserMessageAtByWorkspace"
|
||||
>;
|
||||
workspaces: Pick<WorkspaceRegistry, "get">;
|
||||
storage: Pick<AgentStorage, "list">;
|
||||
workspaces: Pick<WorkspaceRegistry, "get" | "list">;
|
||||
projectLive(agent: ManagedAgent): Promise<AgentSnapshotPayload>;
|
||||
projectStored(record: StoredAgentRecord): AgentSnapshotPayload;
|
||||
isProviderVisible(provider: string): boolean;
|
||||
@@ -40,127 +18,70 @@ export class AgentDirectory {
|
||||
) {}
|
||||
|
||||
/** Human prompt recency includes archived/unavailable agents without projecting their UI payloads. */
|
||||
async workspaceLastUserMessageTimes(
|
||||
workspaceIds?: Iterable<string>,
|
||||
): Promise<Map<string, string>> {
|
||||
const requested = workspaceIds ? new Set(workspaceIds) : undefined;
|
||||
async workspaceLastUserMessageTimes(): Promise<Map<string, string>> {
|
||||
const live = this.deps.manager.listAgents();
|
||||
const times = new Map(
|
||||
await this.deps.storage.lastUserMessageAtByWorkspace(requested, {
|
||||
excludeAgentIds: live.map((agent) => agent.id),
|
||||
}),
|
||||
);
|
||||
const liveIds = new Set(live.map((agent) => agent.id));
|
||||
const times = new Map<string, string>();
|
||||
const record = (workspaceId: string | undefined, at: string | null | undefined) => {
|
||||
if (!workspaceId || !at) return;
|
||||
const previous = times.get(workspaceId);
|
||||
if (!previous || at > previous) times.set(workspaceId, at);
|
||||
};
|
||||
for (const agent of await this.deps.storage.list()) {
|
||||
if (!agent.internal && !liveIds.has(agent.id))
|
||||
record(agent.workspaceId, agent.lastUserMessageAt);
|
||||
}
|
||||
for (const agent of live) {
|
||||
if (agent.internal || agent.background || !agent.workspaceId || !agent.lastUserMessageAt)
|
||||
continue;
|
||||
if (requested && !requested.has(agent.workspaceId)) continue;
|
||||
const at = agent.lastUserMessageAt.toISOString();
|
||||
if (at > (times.get(agent.workspaceId) ?? "")) times.set(agent.workspaceId, at);
|
||||
if (!agent.internal) record(agent.workspaceId, agent.lastUserMessageAt?.toISOString());
|
||||
}
|
||||
return times;
|
||||
}
|
||||
|
||||
async includes(
|
||||
agent: DiscoverableAgent,
|
||||
visibility: BackgroundVisibility = false,
|
||||
agent: Pick<AgentSnapshotPayload, "workspaceId" | "internal">,
|
||||
includeBackground = false,
|
||||
): Promise<boolean> {
|
||||
const workspace = agent.workspaceId ? await this.deps.workspaces.get(agent.workspaceId) : null;
|
||||
return admits(agent, workspace?.background === true, visibility);
|
||||
}
|
||||
|
||||
private async appendAncestors(
|
||||
live: ManagedAgent[],
|
||||
selectedLive: ManagedAgent[],
|
||||
records: StoredAgentRecord[],
|
||||
): Promise<void> {
|
||||
const selectedIds = new Set([...selectedLive, ...records].map((agent) => agent.id));
|
||||
const liveById = new Map(live.map((agent) => [agent.id, agent]));
|
||||
const pending = [...selectedLive, ...records];
|
||||
for (const agent of pending) {
|
||||
const parentId = getParentAgentIdFromLabels(agent.labels);
|
||||
if (!parentId || selectedIds.has(parentId)) continue;
|
||||
selectedIds.add(parentId);
|
||||
const liveParent = liveById.get(parentId);
|
||||
if (liveParent) {
|
||||
pending.push(liveParent);
|
||||
selectedLive.push(liveParent);
|
||||
} else {
|
||||
const storedParent = await this.deps.storage.get(parentId);
|
||||
if (storedParent) {
|
||||
pending.push(storedParent);
|
||||
records.push(storedParent);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (agent.internal) return false;
|
||||
if (includeBackground || !agent.workspaceId) return true;
|
||||
return !(await this.deps.workspaces.get(agent.workspaceId))?.background;
|
||||
}
|
||||
|
||||
async list(filter?: {
|
||||
workspaceIds?: Iterable<string>;
|
||||
/** Include parent chains by ID for workspace root classification. */
|
||||
includeAncestors?: boolean;
|
||||
labels?: Record<string, string>;
|
||||
includeArchived?: boolean;
|
||||
includeBackground?: BackgroundVisibility;
|
||||
includeBackground?: boolean;
|
||||
includeUnavailablePersisted?: boolean;
|
||||
}): Promise<AgentSnapshotPayload[]> {
|
||||
const requested = filter?.workspaceIds ? new Set(filter.workspaceIds) : null;
|
||||
const live = this.deps.manager.listAgents();
|
||||
const liveIds = new Set(live.map((agent) => agent.id));
|
||||
const labels = Object.entries(filter?.labels ?? {});
|
||||
const visibility = filter?.includeBackground ?? false;
|
||||
const records = requested
|
||||
? await this.deps.storage.listByWorkspaces(requested, filter)
|
||||
: await this.deps.storage.listActive(filter);
|
||||
const selectedLive = requested
|
||||
? live.filter((agent) => requested.has(agent.workspaceId ?? ""))
|
||||
: live;
|
||||
if (filter?.includeAncestors && requested) {
|
||||
await this.appendAncestors(live, selectedLive, records);
|
||||
}
|
||||
|
||||
const workspaceIds = new Set(
|
||||
[...selectedLive, ...records].flatMap((agent) =>
|
||||
agent.workspaceId ? [agent.workspaceId] : [],
|
||||
),
|
||||
const backgroundIds = new Set(
|
||||
filter?.includeBackground
|
||||
? []
|
||||
: (await this.deps.workspaces.list())
|
||||
.filter((workspace) => workspace.background)
|
||||
.map((workspace) => workspace.workspaceId),
|
||||
);
|
||||
const backgroundWorkspaceIds = new Set<string>();
|
||||
if (visibility !== true) {
|
||||
for (const id of workspaceIds) {
|
||||
if ((await this.deps.workspaces.get(id))?.background) backgroundWorkspaceIds.add(id);
|
||||
}
|
||||
}
|
||||
const matches = (
|
||||
agent: DiscoverableAgent & { archivedAt?: string | null; labels?: Record<string, string> },
|
||||
) =>
|
||||
const matches = (agent: {
|
||||
archivedAt?: string | null;
|
||||
workspaceId?: string;
|
||||
labels?: Record<string, string>;
|
||||
}) =>
|
||||
(filter?.includeArchived || !agent.archivedAt) &&
|
||||
admits(
|
||||
agent,
|
||||
agent.workspaceId !== undefined && backgroundWorkspaceIds.has(agent.workspaceId),
|
||||
visibility,
|
||||
) &&
|
||||
(filter?.includeBackground || !agent.workspaceId || !backgroundIds.has(agent.workspaceId)) &&
|
||||
labels.every(([key, value]) => agent.labels?.[key] === value);
|
||||
const stored = records
|
||||
.filter((record) => !liveIds.has(record.id) && matches(record))
|
||||
const stored = (await this.deps.storage.list())
|
||||
.filter((record) => !record.internal && !liveIds.has(record.id) && matches(record))
|
||||
.filter(
|
||||
(record) =>
|
||||
filter?.includeUnavailablePersisted || this.deps.isStoredProviderAvailable(record),
|
||||
)
|
||||
.map((record) => this.deps.projectStored(record));
|
||||
const active = await Promise.all(
|
||||
selectedLive.filter(matches).map((agent) => this.deps.projectLive(agent)),
|
||||
live.filter(matches).map((agent) => this.deps.projectLive(agent)),
|
||||
);
|
||||
return [...active, ...stored]
|
||||
.filter(matches)
|
||||
.filter((agent) => this.deps.isProviderVisible(agent.provider));
|
||||
}
|
||||
}
|
||||
|
||||
function admits(
|
||||
agent: DiscoverableAgent,
|
||||
inBackgroundWorkspace: boolean,
|
||||
visibility: BackgroundVisibility,
|
||||
): boolean {
|
||||
if (agent.internal) return false;
|
||||
if (agent.background) return visibility === true;
|
||||
return !inBackgroundWorkspace || visibility !== false;
|
||||
}
|
||||
|
||||
@@ -1,149 +0,0 @@
|
||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
|
||||
import { createHostResolver, type HostOperations } from "./index.js";
|
||||
|
||||
type Handler = ((event?: unknown) => void) | null;
|
||||
|
||||
/** A connected host from before background agents: background workspaces, no background agents. */
|
||||
class OlderHostSocket {
|
||||
readyState = 1;
|
||||
readonly requests: Array<Record<string, unknown>> = [];
|
||||
onopen: Handler = null;
|
||||
onmessage: Handler = null;
|
||||
onclose: Handler = null;
|
||||
onerror: Handler = null;
|
||||
private readonly createSent = Promise.withResolvers<Record<string, unknown>>();
|
||||
|
||||
constructor() {
|
||||
queueMicrotask(() => this.onopen?.());
|
||||
}
|
||||
|
||||
createRequest(): Promise<Record<string, unknown>> {
|
||||
return this.createSent.promise;
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
const frame = JSON.parse(data);
|
||||
if (frame.type === "hello") {
|
||||
this.reply({
|
||||
type: "status",
|
||||
payload: {
|
||||
status: "server_info",
|
||||
serverId: "srv_older",
|
||||
hostname: null,
|
||||
version: null,
|
||||
features: {
|
||||
providerUsageList: true,
|
||||
providersSnapshotCwd: true,
|
||||
ownedSubscriptions: true,
|
||||
backgroundWorkspaces: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (frame.type !== "session") return;
|
||||
const message = frame.message;
|
||||
this.requests.push(message);
|
||||
if (message.type === "fetch_workspaces_request") {
|
||||
this.reply({
|
||||
type: "fetch_workspaces_response",
|
||||
payload: {
|
||||
requestId: message.requestId,
|
||||
entries: [workspace],
|
||||
pageInfo: emptyPage,
|
||||
},
|
||||
});
|
||||
}
|
||||
if (message.type === "create_agent_request") this.createSent.resolve(message);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = 3;
|
||||
this.onclose?.({ code: 1000, reason: "closed" });
|
||||
}
|
||||
|
||||
private reply(message: object): void {
|
||||
this.onmessage?.({ data: JSON.stringify({ type: "session", message }) });
|
||||
}
|
||||
}
|
||||
|
||||
const emptyPage = { nextCursor: null, prevCursor: null, hasMore: false };
|
||||
|
||||
const workspace = {
|
||||
id: "workspace_older",
|
||||
projectId: "project_older",
|
||||
projectDisplayName: "Older",
|
||||
projectRootPath: "/repo/older",
|
||||
workspaceDirectory: "/repo/older",
|
||||
projectKind: "git",
|
||||
workspaceKind: "directory",
|
||||
name: "older",
|
||||
archivingAt: null,
|
||||
status: "done",
|
||||
statusEnteredAt: null,
|
||||
activityAt: "2026-05-16T00:00:00.000Z",
|
||||
scripts: [],
|
||||
gitRuntime: null,
|
||||
githubRuntime: null,
|
||||
};
|
||||
|
||||
const clients: DaemonClient[] = [];
|
||||
afterEach(async () => {
|
||||
await Promise.all(clients.splice(0).map((client) => client.close()));
|
||||
});
|
||||
|
||||
async function olderHost() {
|
||||
const socket = new OlderHostSocket();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://older.test",
|
||||
clientId: "older-host-test",
|
||||
webSocketFactory: () => socket,
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
clients.push(client);
|
||||
await client.connect();
|
||||
return { client, socket };
|
||||
}
|
||||
|
||||
function resolverFor(client: DaemonClient, callerBackground: boolean) {
|
||||
return createHostResolver({
|
||||
serverId: "origin",
|
||||
callerAgentId: "caller",
|
||||
caller: () => ({ background: callerBackground }),
|
||||
local: {} as HostOperations,
|
||||
hosts: () => ({
|
||||
directory: async () => ({ self: { serverId: "origin", label: "Origin" }, hosts: [] }),
|
||||
use: (_serverId, action) => action(client),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
const createArgs = {
|
||||
workspaceId: workspace.id,
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Helper",
|
||||
initialPrompt: "Summarize",
|
||||
};
|
||||
|
||||
test("a visible caller still creates agents on a host without background agents", async () => {
|
||||
const { client, socket } = await olderHost();
|
||||
const creating = resolverFor(client, false).use("srv_older", (host) =>
|
||||
host.createAgent(createArgs, {} as never),
|
||||
);
|
||||
creating.catch(() => {});
|
||||
|
||||
const request = await socket.createRequest();
|
||||
expect(request).toMatchObject({ workspaceId: workspace.id });
|
||||
expect(request).not.toHaveProperty("background");
|
||||
});
|
||||
|
||||
test("a background caller's remote child asks the host to update instead of turning visible", async () => {
|
||||
const { client, socket } = await olderHost();
|
||||
|
||||
await expect(
|
||||
resolverFor(client, true).use("srv_older", (host) => host.createAgent(createArgs, {} as never)),
|
||||
).rejects.toThrow("Update the host to use background agents.");
|
||||
expect(socket.requests.map((request) => request.type)).not.toContain("create_agent_request");
|
||||
});
|
||||
@@ -16,11 +16,6 @@ import type { ConnectedHosts } from "../../connected-hosts/index.js";
|
||||
import { resolveRequiredProviderModel } from "../mcp-shared.js";
|
||||
import { selectAgentListItems } from "../agent-projections.js";
|
||||
import { summarizeAgentActivity } from "../activity-curator.js";
|
||||
import {
|
||||
inheritAgentBackground,
|
||||
inheritWorkspaceBackground,
|
||||
type BackgroundCaller,
|
||||
} from "../create-agent/intent.js";
|
||||
import { expandUserPath } from "../../path-utils.js";
|
||||
import type { PaseoToolExecutionContext, PaseoToolResult } from "../tools/types.js";
|
||||
|
||||
@@ -182,7 +177,6 @@ const remoteCreate = z
|
||||
workspaceId: z.string().min(1),
|
||||
labels: z.record(z.string(), z.string()).optional(),
|
||||
notifyOnFinish: z.boolean().optional(),
|
||||
background: z.boolean().optional(),
|
||||
idempotencyKey: z.string().optional(),
|
||||
settings: z
|
||||
.object({
|
||||
@@ -203,8 +197,6 @@ export function createHostResolver(input: {
|
||||
local: HostOperations;
|
||||
hosts: () => ToolHosts;
|
||||
callerAgentId?: string;
|
||||
/** The destination can't see the caller, so its background defaults resolve before routing. */
|
||||
caller: () => BackgroundCaller | null;
|
||||
lockedCwd?: string;
|
||||
}) {
|
||||
return {
|
||||
@@ -222,7 +214,9 @@ export function createHostResolver(input: {
|
||||
async createAgent(args) {
|
||||
const parsed = remoteCreate.parse(args);
|
||||
const { provider, model } = resolveRequiredProviderModel(parsed.provider);
|
||||
const workspace = await client.findWorkspace(parsed.workspaceId);
|
||||
const workspace = (await client.fetchWorkspaces({})).entries.find(
|
||||
(row) => row.id === parsed.workspaceId,
|
||||
);
|
||||
if (!workspace) throw new Error(`Workspace ${parsed.workspaceId} not found`);
|
||||
const labels = {
|
||||
...parsed.labels,
|
||||
@@ -241,10 +235,6 @@ export function createHostResolver(input: {
|
||||
featureValues: parsed.settings?.features,
|
||||
},
|
||||
workspaceId: parsed.workspaceId,
|
||||
// Older hosts reject any background with a workspaceId, so a visible create omits it.
|
||||
...(inheritAgentBackground(parsed.background, input.caller())
|
||||
? { background: true }
|
||||
: {}),
|
||||
labels,
|
||||
initialPrompt: parsed.initialPrompt,
|
||||
idempotencyKey: parsed.idempotencyKey,
|
||||
@@ -369,10 +359,7 @@ export function createHostResolver(input: {
|
||||
worktreeSlug: args.worktreeSlug as string | undefined,
|
||||
},
|
||||
title: args.title as string | undefined,
|
||||
background: inheritWorkspaceBackground(
|
||||
args.background as boolean | undefined,
|
||||
input.caller(),
|
||||
),
|
||||
background: args.background as boolean | undefined,
|
||||
});
|
||||
if (response.error || !response.workspace)
|
||||
throw new Error(response.error ?? "Workspace creation failed");
|
||||
|
||||
@@ -221,7 +221,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
listImportableSessions,
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">;
|
||||
const agentStorage = {
|
||||
listAll: async () => [
|
||||
list: async () => [
|
||||
{
|
||||
provider: "codex",
|
||||
persistence: {
|
||||
@@ -231,7 +231,7 @@ test("listImportableProviderSessions filters, sorts, limits, and projects import
|
||||
},
|
||||
} as StoredAgentRecord,
|
||||
],
|
||||
} satisfies Pick<AgentStorage, "listAll">;
|
||||
} satisfies Pick<AgentStorage, "list">;
|
||||
|
||||
const result = await listImportableProviderSessions({
|
||||
request: makeRequest({
|
||||
@@ -303,7 +303,7 @@ test("listImportableProviderSessions looks past already-imported rows to fill th
|
||||
listImportableSessions,
|
||||
},
|
||||
agentStorage: {
|
||||
listAll: async () => [
|
||||
list: async () => [
|
||||
{
|
||||
provider: "claude",
|
||||
persistence: { provider: "claude", sessionId: "already-imported" },
|
||||
@@ -360,7 +360,7 @@ test("listImportableProviderSessions requests a bounded deep scan for search res
|
||||
const result = await listImportableProviderSessions({
|
||||
request: makeRequest({ query: "INVOICE", limit: 10 }),
|
||||
agentManager: { listAgents: () => [], listImportableSessions },
|
||||
agentStorage: { listAll: async () => [] },
|
||||
agentStorage: { list: async () => [] },
|
||||
providerSnapshotManager: { getProviderLabel: (provider) => provider },
|
||||
});
|
||||
|
||||
@@ -397,7 +397,7 @@ test("listImportableProviderSessions includes a provider session after its Paseo
|
||||
listImportableSessions: async () => makeImportableSessionsResult([archivedSession]),
|
||||
},
|
||||
agentStorage: {
|
||||
listAll: async () => [
|
||||
list: async () => [
|
||||
{
|
||||
provider: "claude",
|
||||
archivedAt: "2026-04-30T12:01:00.000Z",
|
||||
@@ -441,7 +441,7 @@ test("listImportableProviderSessions includes an archived provider session still
|
||||
listImportableSessions: async () => makeImportableSessionsResult([archivedSession]),
|
||||
},
|
||||
agentStorage: {
|
||||
listAll: async () => [
|
||||
list: async () => [
|
||||
{
|
||||
id: agentId,
|
||||
provider: "claude",
|
||||
@@ -489,8 +489,8 @@ test("listImportableProviderSessions filters out metadata generation sessions",
|
||||
listImportableSessions: async () => makeImportableSessionsResult(sessions),
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
|
||||
agentStorage: {
|
||||
listAll: async () => [],
|
||||
} satisfies Pick<AgentStorage, "listAll">,
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
providerSnapshotManager: { getProviderLabel: () => "Codex" },
|
||||
});
|
||||
|
||||
@@ -525,8 +525,8 @@ test("listImportableProviderSessions keeps realpath-equivalent cwd matches", asy
|
||||
]),
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
|
||||
agentStorage: {
|
||||
listAll: async () => [],
|
||||
} satisfies Pick<AgentStorage, "listAll">,
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
providerSnapshotManager: { getProviderLabel: () => "Pi" },
|
||||
});
|
||||
|
||||
@@ -542,8 +542,8 @@ test("listImportableProviderSessions rejects invalid since values", async () =>
|
||||
listImportableSessions: async () => makeImportableSessionsResult([]),
|
||||
} satisfies Pick<AgentManager, "listAgents" | "listImportableSessions">,
|
||||
agentStorage: {
|
||||
listAll: async () => [],
|
||||
} satisfies Pick<AgentStorage, "listAll">,
|
||||
list: async () => [],
|
||||
} satisfies Pick<AgentStorage, "list">,
|
||||
providerSnapshotManager: { getProviderLabel: () => "" },
|
||||
}),
|
||||
).rejects.toMatchObject(
|
||||
|
||||
@@ -64,7 +64,7 @@ export class ImportSessionsRequestError extends Error {
|
||||
export interface ListImportableProviderSessionsInput {
|
||||
request: FetchRecentProviderSessionsRequestMessage;
|
||||
agentManager: Pick<AgentManager, "listAgents" | "listImportableSessions">;
|
||||
agentStorage: Pick<AgentStorage, "listAll">;
|
||||
agentStorage: Pick<AgentStorage, "list">;
|
||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "getProviderLabel">;
|
||||
}
|
||||
|
||||
@@ -342,13 +342,12 @@ function parseRecentProviderSessionsSince(since: string | undefined): number | n
|
||||
|
||||
async function collectImportedProviderSessions(
|
||||
agentManager: Pick<AgentManager, "listAgents">,
|
||||
agentStorage: Pick<AgentStorage, "listAll">,
|
||||
agentStorage: Pick<AgentStorage, "list">,
|
||||
providerFilter: Set<string> | undefined,
|
||||
): Promise<{ handles: Set<string>; count: number }> {
|
||||
const handles = new Set<string>();
|
||||
const sessions = new Set<string>();
|
||||
// Explicit provider-history import deduplicates against archived as well as active sessions.
|
||||
const records = await agentStorage.listAll();
|
||||
const records = await agentStorage.list();
|
||||
const storedRecordsById = new Map(records.map((record) => [record.id, record]));
|
||||
|
||||
const collect = (
|
||||
|
||||
@@ -1211,137 +1211,4 @@ describe("Suite E: Workspace Tools", () => {
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("a background agent's children and workspaces are background unless it overrides", async () => {
|
||||
const visibleAgentId = await createTopLevelAgent();
|
||||
const workspaceId = daemonHandle.daemon.agentManager.getAgent(visibleAgentId)?.workspaceId;
|
||||
const caller = await callToolStructured(topLevelClient, "create_agent", {
|
||||
workspaceId,
|
||||
title: "Background caller",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
background: true,
|
||||
});
|
||||
const callerId = str(caller.agentId);
|
||||
const background = (agentId: string) =>
|
||||
daemonHandle.daemon.agentManager.getAgent(agentId)?.background;
|
||||
expect(background(callerId)).toBe(true);
|
||||
const scoped = await createMcpClient(
|
||||
`http://127.0.0.1:${daemonHandle.port}/mcp/agents?callerAgentId=${callerId}`,
|
||||
);
|
||||
const ownedWorkspaces: string[] = [];
|
||||
try {
|
||||
const inheritedChild = await callToolStructured(scoped, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Inherited child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
notifyOnFinish: false,
|
||||
});
|
||||
expect(background(str(inheritedChild.agentId))).toBe(true);
|
||||
const visibleChild = await callToolStructured(scoped, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
title: "Visible child",
|
||||
provider: "claude/claude-test-model",
|
||||
initialPrompt: "Say done.",
|
||||
notifyOnFinish: false,
|
||||
background: false,
|
||||
});
|
||||
expect(background(str(visibleChild.agentId))).toBe(false);
|
||||
const inheritedWorkspace = await callToolStructured(scoped, "create_workspace", {
|
||||
isolation: "local",
|
||||
});
|
||||
ownedWorkspaces.push(str(inheritedWorkspace.workspaceId));
|
||||
expect(inheritedWorkspace.background).toBe(true);
|
||||
} finally {
|
||||
await scoped.close();
|
||||
await archiveAgentIfPresent(callerId);
|
||||
await archiveAgentIfPresent(visibleAgentId);
|
||||
for (const id of ownedWorkspaces)
|
||||
await topLevelClient
|
||||
.callTool({ name: "archive_workspace", args: { workspaceId: id } })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("a background subagent stays out of default lists, notifies its parent and archives with it", async () => {
|
||||
const callerPrompts: string[] = [];
|
||||
const daemon = await createTestPaseoDaemon({
|
||||
agentClients: createTestAgentClients({
|
||||
onStartTurn(prompt, config) {
|
||||
if (config.title === "Background parent" && typeof prompt === "string")
|
||||
callerPrompts.push(prompt);
|
||||
},
|
||||
}),
|
||||
});
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
let mcp: McpClient | undefined;
|
||||
try {
|
||||
await client.connect();
|
||||
const caller = await client.createAgent({
|
||||
provider: "claude",
|
||||
cwd: daemon.paseoHome,
|
||||
title: "Background parent",
|
||||
modeId: "bypassPermissions",
|
||||
});
|
||||
mcp = await createMcpClient(
|
||||
buildExpectedAgentMcpUrl({ host: "127.0.0.1", port: daemon.port, agentId: caller.id }),
|
||||
);
|
||||
const created = await callToolStructured(mcp, "create_agent", {
|
||||
relationship: { kind: "subagent" },
|
||||
workspace: { kind: "current" },
|
||||
provider: "codex/gpt-5.4",
|
||||
title: "Background child",
|
||||
initialPrompt: "say done and stop",
|
||||
notifyOnFinish: true,
|
||||
background: true,
|
||||
settings: { modeId: "full-access" },
|
||||
});
|
||||
const childId = str(created.agentId);
|
||||
expect((await client.fetchAgent(childId))?.agent).toMatchObject({
|
||||
background: true,
|
||||
workspaceId: caller.workspaceId,
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: caller.id },
|
||||
});
|
||||
expect((await client.fetchAgents()).entries.map((entry) => entry.agent.id)).not.toContain(
|
||||
childId,
|
||||
);
|
||||
expect(
|
||||
(await client.fetchAgents({ filter: { includeBackground: true } })).entries.map(
|
||||
(entry) => entry.agent.id,
|
||||
),
|
||||
).toContain(childId);
|
||||
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent finish notification",
|
||||
check: () => (callerPrompts.length > 0 ? true : null),
|
||||
});
|
||||
expect(parseAgentMessage(callerPrompts[0]).source).toMatchObject({
|
||||
kind: "agent-notification",
|
||||
agentId: childId,
|
||||
event: "finished",
|
||||
});
|
||||
expect((await client.fetchAgent(childId))?.agent.requiresAttention).toBe(false);
|
||||
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "parent idle",
|
||||
check: () =>
|
||||
daemon.daemon.agentManager.getAgent(caller.id)?.lifecycle === "idle" ? true : null,
|
||||
});
|
||||
await client.archiveAgent(caller.id);
|
||||
await waitFor({
|
||||
timeoutMs: 10000,
|
||||
label: "child archived with parent",
|
||||
check: async () => ((await client.fetchAgent(childId))?.agent.archivedAt ? true : null),
|
||||
});
|
||||
} finally {
|
||||
await mcp?.close();
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
}
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -292,9 +292,7 @@ function buildAgentStorageSpies() {
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
upsert: vi.fn().mockResolvedValue(undefined),
|
||||
applySnapshot: vi.fn(),
|
||||
listActive: vi.fn().mockResolvedValue([]),
|
||||
listByWorkspaces: vi.fn().mockResolvedValue([]),
|
||||
lastUserMessageAtByWorkspace: async () => new Map<string, string>(),
|
||||
list: vi.fn().mockResolvedValue([]),
|
||||
remove: vi.fn(),
|
||||
};
|
||||
}
|
||||
@@ -641,16 +639,7 @@ class BoundaryAgentManagerFake {
|
||||
}
|
||||
|
||||
class BoundaryAgentStorageFake {
|
||||
public async listActive(): Promise<StoredAgentRecord[]> {
|
||||
return [];
|
||||
}
|
||||
async listByWorkspaces() {
|
||||
return [];
|
||||
}
|
||||
async lastUserMessageAtByWorkspace() {
|
||||
return new Map<string, string>();
|
||||
}
|
||||
async listAll() {
|
||||
public async list(): Promise<StoredAgentRecord[]> {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -783,8 +772,7 @@ function createPaseoWorktreeForMcpTest(options: {
|
||||
initialize: async () => {},
|
||||
existsOnDisk: async () => true,
|
||||
get: async (workspaceId: string) => workspaces.get(workspaceId) ?? null,
|
||||
listAll: async () => Array.from(workspaces.values()),
|
||||
listActive: async () => Array.from(workspaces.values()).filter((w) => !w.archivedAt),
|
||||
list: async () => Array.from(workspaces.values()),
|
||||
update: async (workspaceId, updater) => {
|
||||
const workspace = workspaces.get(workspaceId);
|
||||
if (!workspace) return null;
|
||||
@@ -906,11 +894,7 @@ async function createAgentMcpServer(
|
||||
const server = await createAgentMcpServerWithDependencies({
|
||||
agentMessages: () => messages,
|
||||
finishSubscriptions: () => subscriptions,
|
||||
workspaceRegistry: {
|
||||
get: async () => null,
|
||||
listActive: async () => [],
|
||||
upsert: async () => {},
|
||||
},
|
||||
workspaceRegistry: { get: async () => null, list: async () => [], upsert: async () => {} },
|
||||
...options,
|
||||
});
|
||||
serverSubscriptionRecords.set(server, records);
|
||||
@@ -1663,11 +1647,11 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ cwd: existingCwd }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects public internal helpers", async () => {
|
||||
it("rejects public internal helpers and the old top-level execution background option", async () => {
|
||||
const workdir = await mkdtemp(join(tmpdir(), "mcp-public-helper-removal-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const agentManager = new AgentManager({
|
||||
@@ -1685,6 +1669,9 @@ describe("create_agent MCP tool", () => {
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
const input = { title: "Helper", provider: "codex/gpt-5.4", initialPrompt: "Summarize" };
|
||||
await expect(tool.handler({ ...input, internal: true })).rejects.toThrow(/Unrecognized key/);
|
||||
await expect(tool.handler({ ...input, background: true })).rejects.toThrow(
|
||||
/Unrecognized key/,
|
||||
);
|
||||
} finally {
|
||||
await removeAgentStateDir(agentManager, storage, workdir);
|
||||
}
|
||||
@@ -1806,7 +1793,7 @@ describe("create_agent MCP tool", () => {
|
||||
cwd: existingCwd,
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "wks_existing", background: false },
|
||||
{ workspaceId: "wks_existing" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1850,7 +1837,7 @@ describe("create_agent MCP tool", () => {
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2046,7 +2033,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth bug",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2081,7 +2068,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Fix auth",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2125,7 +2112,6 @@ describe("create_agent MCP tool", () => {
|
||||
{
|
||||
labels: { source: "mcp" },
|
||||
workspaceId: "workspace-created",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -2228,7 +2214,7 @@ describe("create_agent MCP tool", () => {
|
||||
title: "Directory agent",
|
||||
}),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-directory-auto-title", background: false },
|
||||
{ workspaceId: "workspace-directory-auto-title" },
|
||||
);
|
||||
expect(workspaceRecords.get("workspace-directory-auto-title")).toMatchObject({
|
||||
title: "Directory Workspace Title",
|
||||
@@ -2713,7 +2699,7 @@ describe("create_agent MCP tool", () => {
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
workspaceRegistry: {
|
||||
get: vi.fn(async () => workspace),
|
||||
listActive: vi.fn(async () => [workspace]),
|
||||
list: vi.fn(async () => [workspace]),
|
||||
upsert: vi.fn(async () => undefined),
|
||||
},
|
||||
logger,
|
||||
@@ -2746,7 +2732,7 @@ describe("create_agent MCP tool", () => {
|
||||
providerSnapshotManager: createOpenCodeManager().manager,
|
||||
workspaceRegistry: {
|
||||
get: vi.fn(async () => backgroundWorkspace),
|
||||
listActive: vi.fn(async () => [backgroundWorkspace]),
|
||||
list: vi.fn(async () => [backgroundWorkspace]),
|
||||
upsert: vi.fn(async () => undefined),
|
||||
},
|
||||
createDirectoryWorkspace: async (cwd, _title, _projectId, context) => {
|
||||
@@ -2842,13 +2828,12 @@ describe("create_agent MCP tool", () => {
|
||||
source: "voice",
|
||||
},
|
||||
workspaceId: "wks_voice",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
await rm(baseDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("defaults notify-on-finish on for caller agents", async () => {
|
||||
it("rejects background from caller agents and defaults notify-on-finish on", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.getAgent.mockReturnValue({
|
||||
id: "parent-agent",
|
||||
@@ -2868,6 +2853,16 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
const tool = registeredTool(server, "create_agent");
|
||||
await expect(
|
||||
tool.handler({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
provider: "codex/gpt-5.4",
|
||||
initialPrompt: "Do work",
|
||||
background: false,
|
||||
}),
|
||||
).rejects.toThrow(/Unrecognized key/);
|
||||
|
||||
const parsed = await tool.inputSchema.safeParseAsync({
|
||||
...subagentCurrentWorkspace(),
|
||||
title: "Child",
|
||||
@@ -3024,7 +3019,6 @@ describe("create_agent MCP tool", () => {
|
||||
source: "handoff",
|
||||
},
|
||||
workspaceId: "wks_parent",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3086,7 +3080,6 @@ describe("create_agent MCP tool", () => {
|
||||
[PARENT_AGENT_ID_LABEL]: "parent-agent",
|
||||
},
|
||||
workspaceId: "wks_parent",
|
||||
background: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3225,7 +3218,6 @@ describe("create_agent MCP tool", () => {
|
||||
expect(agentIdArg).toBeUndefined();
|
||||
expect(optionsArg).toEqual({
|
||||
workspaceId: "workspace-created",
|
||||
background: false,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3303,7 +3295,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "dynamic" }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3342,7 +3334,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -6342,7 +6334,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
createManagedAgent({ id: "in-child-cwd", cwd: "/tmp/workspace/packages/server" }),
|
||||
createManagedAgent({ id: "other-cwd", cwd: "/tmp/other" }),
|
||||
]);
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({
|
||||
id: "stored-in-cwd",
|
||||
cwd: "/tmp/workspace",
|
||||
@@ -6401,7 +6393,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
updatedAt: new Date(old),
|
||||
}),
|
||||
]);
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({ id: "recent-archived", cwd: TARGET_CWD, archivedAt: recent }),
|
||||
createStoredRecord({ id: "old-archived", cwd: TARGET_CWD, archivedAt: old }),
|
||||
createStoredRecord({
|
||||
@@ -6442,7 +6434,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
archivedAt: new Date(now - index * 60 * 1000).toISOString(),
|
||||
}),
|
||||
);
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
...recentArchivedRecords,
|
||||
createStoredRecord({
|
||||
id: "old-archived",
|
||||
@@ -6473,7 +6465,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
it("returns compact list items for stored archived agents", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const now = new Date().toISOString();
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({
|
||||
id: "stored-archived-compact",
|
||||
cwd: REPO_CWD,
|
||||
@@ -6592,7 +6584,7 @@ describe("agent snapshot MCP serialization", () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
const now = new Date().toISOString();
|
||||
spies.agentManager.listAgents.mockReturnValue([createManagedAgent()]);
|
||||
spies.agentStorage.listActive.mockResolvedValue([
|
||||
spies.agentStorage.list.mockResolvedValue([
|
||||
createStoredRecord({
|
||||
id: "stored-non-archived",
|
||||
updatedAt: now,
|
||||
|
||||
@@ -4,8 +4,7 @@ import { join } from "node:path";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
|
||||
import { relative } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { execFile, execFileSync } from "node:child_process";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type {
|
||||
ProviderConnection,
|
||||
@@ -1859,48 +1858,6 @@ describe("pending provider responses", () => {
|
||||
});
|
||||
|
||||
describe("plugin provider launch and status", () => {
|
||||
test("passes configured Electron mode to a real diagnostic child process", async () => {
|
||||
const logger = createTestLogger();
|
||||
const registry = new PluginAgentClientRegistry(logger);
|
||||
registry.replace([
|
||||
{
|
||||
id: "environment-probe",
|
||||
label: "Environment probe",
|
||||
command: [
|
||||
process.execPath,
|
||||
"-e",
|
||||
"console.log(JSON.stringify({ mode: process.env.ELECTRON_RUN_AS_NODE ?? null, internal: process.env.PASEO_NODE_ENV ?? null, parent: process.env.CLAUDECODE ?? null }))",
|
||||
],
|
||||
async status({ launch }) {
|
||||
if (!launch) throw new Error("Missing provider launch");
|
||||
const { stdout } = await promisify(execFile)(launch.command, launch.args, {
|
||||
env: launch.env,
|
||||
timeout: 5000,
|
||||
});
|
||||
return { available: true, diagnostic: stdout.trim() };
|
||||
},
|
||||
async connect() {
|
||||
throw new Error("A diagnostic does not need a provider connection");
|
||||
},
|
||||
},
|
||||
]);
|
||||
const definition = registry.definitions()["environment-probe"]!;
|
||||
const client = definition.createClient(
|
||||
logger,
|
||||
ProviderOverrideSchema.parse({ env: { ELECTRON_RUN_AS_NODE: "1" } }),
|
||||
);
|
||||
try {
|
||||
const diagnostic = await client.getDiagnostic!();
|
||||
expect(JSON.parse(diagnostic.diagnostic)).toEqual({
|
||||
mode: "1",
|
||||
internal: null,
|
||||
parent: null,
|
||||
});
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolves the executable against the provider's PATH override", async () => {
|
||||
const harness = createProviderHarness();
|
||||
const registration: ProviderRegistration = { ...harness.registration, command: ["node"] };
|
||||
@@ -2018,12 +1975,7 @@ describe("plugin provider launch and status", () => {
|
||||
try {
|
||||
const client = registry.definitions()[registration.id]!.createClient(logger, {
|
||||
command,
|
||||
env: {
|
||||
PLUGIN_SETTING: "custom",
|
||||
CLAUDECODE: "parent",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
ESBUILD_BINARY_PATH: "/provider/esbuild",
|
||||
},
|
||||
env: { PLUGIN_SETTING: "custom", CLAUDECODE: "parent" },
|
||||
});
|
||||
expect(await client.isAvailable()).toBe(true);
|
||||
expect(await client.getCatalogCacheKey!({ scope: "global" })).toBe("shared");
|
||||
@@ -2036,11 +1988,7 @@ describe("plugin provider launch and status", () => {
|
||||
expect(launch).toMatchObject({
|
||||
command: process.execPath,
|
||||
args: expectedArgs,
|
||||
env: {
|
||||
PLUGIN_SETTING: "custom",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
ESBUILD_BINARY_PATH: "/provider/esbuild",
|
||||
},
|
||||
env: { PLUGIN_SETTING: "custom" },
|
||||
});
|
||||
expect(launch!.env).not.toHaveProperty("CLAUDECODE");
|
||||
expect(launch!.env).not.toHaveProperty("PASEO_NODE_ENV");
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, test } from "vitest";
|
||||
import { execCommand } from "../../utils/spawn.js";
|
||||
|
||||
import {
|
||||
checkProviderLaunchAvailable,
|
||||
createProviderEnv,
|
||||
createProviderEnvSpec,
|
||||
migrateProviderSettings,
|
||||
ProviderOverrideSchema,
|
||||
resolveProviderLaunch,
|
||||
@@ -189,71 +187,6 @@ describe("resolveProviderLaunch", () => {
|
||||
});
|
||||
|
||||
describe("checkProviderLaunchAvailable", () => {
|
||||
test.each([
|
||||
{ lookup: "absolute", configured: true },
|
||||
{ lookup: "PATH", configured: true },
|
||||
{ lookup: "default", configured: true },
|
||||
{ lookup: "absolute", configured: false },
|
||||
{ lookup: "PATH", configured: false },
|
||||
{ lookup: "default", configured: false },
|
||||
])(
|
||||
"passes finalized provider environment to the $lookup probe (configured=$configured)",
|
||||
async ({ lookup, configured }) => {
|
||||
const dir = makeTempDir();
|
||||
const observed = path.join(dir, "probe.json");
|
||||
const script = path.join(dir, "probe.cjs");
|
||||
writeFileSync(
|
||||
script,
|
||||
`require("node:fs").writeFileSync(${JSON.stringify(observed)}, JSON.stringify({
|
||||
args: process.argv.slice(2),
|
||||
mode: process.env.ELECTRON_RUN_AS_NODE ?? null,
|
||||
inherited: process.env.PASEO_NODE_ENV ?? null,
|
||||
parent: process.env.CLAUDECODE ?? null,
|
||||
ordinary: process.env.CUSTOM
|
||||
}));`,
|
||||
);
|
||||
const command = process.platform === "win32" ? "provider.cmd" : "provider";
|
||||
const executable = path.join(dir, command);
|
||||
writeFileSync(
|
||||
executable,
|
||||
process.platform === "win32"
|
||||
? `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`
|
||||
: `#!/bin/sh\nexec '${process.execPath.replaceAll("'", "'\\''")}' '${script.replaceAll("'", "'\\''")}' "$@"\n`,
|
||||
);
|
||||
chmodSync(executable, 0o755);
|
||||
const env = createProviderEnv({
|
||||
baseEnv: {
|
||||
...process.env,
|
||||
ELECTRON_RUN_AS_NODE: "inherited",
|
||||
PASEO_NODE_ENV: "production",
|
||||
CLAUDECODE: "1",
|
||||
},
|
||||
runtimeSettings: { env: configured ? { ELECTRON_RUN_AS_NODE: "1" } : {} },
|
||||
overlays: [{ CUSTOM: "session", PASEO_NODE_ENV: "development" }],
|
||||
});
|
||||
env.PATH = `${dir}${path.delimiter}${env.PATH ?? ""}`;
|
||||
const launch = await resolveProviderLaunch({
|
||||
commandConfig:
|
||||
lookup === "default"
|
||||
? undefined
|
||||
: { mode: "replace", argv: [lookup === "absolute" ? executable : command] },
|
||||
defaultBinary: command,
|
||||
});
|
||||
|
||||
await expect(checkProviderLaunchAvailable({ ...launch, env }, { command })).resolves.toEqual({
|
||||
available: true,
|
||||
resolvedPath: executable,
|
||||
});
|
||||
expect(JSON.parse(readFileSync(observed, "utf8"))).toEqual({
|
||||
args: ["--version"],
|
||||
mode: configured ? "1" : null,
|
||||
inherited: null,
|
||||
parent: null,
|
||||
ordinary: "session",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
test("reports available with a resolved path", async () => {
|
||||
const binDir = makeTempDir();
|
||||
const binary = createExecutable(binDir, "default-provider");
|
||||
@@ -284,63 +217,6 @@ describe("checkProviderLaunchAvailable", () => {
|
||||
});
|
||||
|
||||
describe("createProviderEnv", () => {
|
||||
test("passes configured runtime controls through command execution", async () => {
|
||||
const result = await execCommand(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
"console.log(JSON.stringify({ ELECTRON_RUN_AS_NODE: process.env.ELECTRON_RUN_AS_NODE ?? null, PASEO_NODE_ENV: process.env.PASEO_NODE_ENV ?? null }))",
|
||||
],
|
||||
createProviderEnvSpec({
|
||||
baseEnv: { ELECTRON_RUN_AS_NODE: "inherited", PASEO_NODE_ENV: "production" },
|
||||
runtimeSettings: { env: { ELECTRON_RUN_AS_NODE: "1" } },
|
||||
overlays: [{ PASEO_NODE_ENV: "development" }],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
stdout: '{"ELECTRON_RUN_AS_NODE":"1","PASEO_NODE_ENV":null}\n',
|
||||
stderr: "",
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
"ELECTRON_RUN_AS_NODE",
|
||||
"ELECTRON_NO_ATTACH_CONSOLE",
|
||||
"PASEO_NODE_ENV",
|
||||
"PASEO_DESKTOP_MANAGED",
|
||||
"PASEO_SUPERVISED",
|
||||
"ESBUILD_BINARY_PATH",
|
||||
])("preserves explicitly configured %s while scrubbing inherited and session controls", (key) => {
|
||||
const baseEnv = {
|
||||
PATH: "/usr/bin",
|
||||
ELECTRON_RUN_AS_NODE: "inherited",
|
||||
PASEO_NODE_ENV: "inherited",
|
||||
ESBUILD_BINARY_PATH: "/inherited/esbuild",
|
||||
};
|
||||
const overlays = [
|
||||
{
|
||||
[key]: "session",
|
||||
PASEO_SUPERVISED: "session",
|
||||
CUSTOM: "session",
|
||||
REMOVED: undefined,
|
||||
},
|
||||
];
|
||||
const runtimeSettings = {
|
||||
env: { [key]: "configured", CUSTOM: "configured", REMOVED: "configured", CLAUDECODE: "1" },
|
||||
};
|
||||
|
||||
expect(createProviderEnv({ baseEnv, runtimeSettings, overlays })).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
CUSTOM: "session",
|
||||
[key]: "configured",
|
||||
});
|
||||
expect(createProviderEnv({ baseEnv, overlays })).toEqual({
|
||||
PATH: "/usr/bin",
|
||||
CUSTOM: "session",
|
||||
});
|
||||
});
|
||||
|
||||
test("merges provider env overrides", () => {
|
||||
const base = {
|
||||
PATH: "/usr/bin",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
} from "../../executable-resolution/executable-resolution.js";
|
||||
import {
|
||||
createExternalProcessEnv,
|
||||
RUNTIME_CONTROL_ENV_KEYS,
|
||||
type ProcessEnvRecord,
|
||||
type ExternalProcessEnv,
|
||||
} from "../paseo-env.js";
|
||||
@@ -44,8 +43,7 @@ export interface ResolvedProviderLaunch {
|
||||
command: string;
|
||||
args: string[];
|
||||
source: ProviderLaunchSource;
|
||||
/** Finalized provider environment from createProviderEnv(), ready for child execution. */
|
||||
env?: ExternalProcessEnv;
|
||||
env?: ProcessEnvRecord;
|
||||
}
|
||||
|
||||
export interface ProviderLaunchAvailability {
|
||||
@@ -68,8 +66,7 @@ function normalizeLaunchDefault(
|
||||
}
|
||||
|
||||
async function resolveLaunchPath(command: string, env?: ProcessEnvRecord): Promise<string | null> {
|
||||
// launch.env is finalized by the provider builder, including explicit runtime controls.
|
||||
const found = await findExecutable(command, { env, envMode: env ? "internal" : "external" });
|
||||
const found = await findExecutable(command, { env });
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
@@ -81,11 +78,10 @@ async function resolveLaunchPath(command: string, env?: ProcessEnvRecord): Promi
|
||||
|
||||
async function resolveDefaultLaunchPath(
|
||||
defaultBinary: ProviderLaunchDefault,
|
||||
env?: ProcessEnvRecord,
|
||||
): Promise<string | null> {
|
||||
return defaultBinary.resolvePath
|
||||
? await defaultBinary.resolvePath()
|
||||
: await resolveLaunchPath(defaultBinary.command, env);
|
||||
: await resolveLaunchPath(defaultBinary.command);
|
||||
}
|
||||
|
||||
export interface ResolveProviderLaunchOptions {
|
||||
@@ -124,7 +120,7 @@ export async function checkProviderLaunchAvailable(
|
||||
): Promise<ProviderLaunchAvailability> {
|
||||
const resolvedPath =
|
||||
defaultBinary && launch.source !== "override"
|
||||
? await resolveDefaultLaunchPath(defaultBinary, launch.env)
|
||||
? await resolveDefaultLaunchPath(defaultBinary)
|
||||
: await resolveLaunchPath(launch.command, launch.env);
|
||||
return {
|
||||
available: resolvedPath !== null,
|
||||
@@ -226,8 +222,8 @@ export interface ProviderEnvOptions {
|
||||
}
|
||||
|
||||
export interface ProviderEnvSpec {
|
||||
env: ExternalProcessEnv;
|
||||
envMode: "internal";
|
||||
baseEnv?: ProcessEnvRecord;
|
||||
envOverlay: ProcessEnvRecord;
|
||||
}
|
||||
|
||||
function collectProviderEnvOverlays(
|
||||
@@ -240,24 +236,20 @@ function collectProviderEnvOverlays(
|
||||
}
|
||||
|
||||
export function createProviderEnvSpec(options: ProviderEnvOptions = {}): ProviderEnvSpec {
|
||||
// Provider env is finalized here so spawning cannot scrub explicit user settings again.
|
||||
return { env: createProviderEnv(options), envMode: "internal" };
|
||||
const overlays = collectProviderEnvOverlays(options.runtimeSettings, options.overlays ?? []);
|
||||
const envOverlay: ProcessEnvRecord = Object.assign({}, ...overlays);
|
||||
for (const key of PARENT_SESSION_ENV_VARS) {
|
||||
envOverlay[key] = undefined;
|
||||
}
|
||||
return {
|
||||
...(options.baseEnv ? { baseEnv: options.baseEnv } : {}),
|
||||
envOverlay,
|
||||
};
|
||||
}
|
||||
|
||||
export function createProviderEnv(options: ProviderEnvOptions = {}): ExternalProcessEnv {
|
||||
const overlays = collectProviderEnvOverlays(options.runtimeSettings, options.overlays ?? []);
|
||||
const envOverlay: ProcessEnvRecord = Object.assign({}, ...overlays);
|
||||
const env = createExternalProcessEnv(options.baseEnv ?? process.env, envOverlay);
|
||||
for (const key of RUNTIME_CONTROL_ENV_KEYS) {
|
||||
const configured = options.runtimeSettings?.env?.[key];
|
||||
if (configured !== undefined) {
|
||||
env[key] = configured;
|
||||
}
|
||||
}
|
||||
for (const key of PARENT_SESSION_ENV_VARS) {
|
||||
delete env[key];
|
||||
}
|
||||
return env;
|
||||
const spec = createProviderEnvSpec(options);
|
||||
return createExternalProcessEnv(spec.baseEnv ?? process.env, spec.envOverlay);
|
||||
}
|
||||
|
||||
export async function isProviderCommandAvailable(
|
||||
|
||||
@@ -616,11 +616,9 @@ describe("createLoggedNdJsonStream", () => {
|
||||
describe("ACPAgentSession terminal tools", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("runs single-string terminal commands through the platform shell", async () => {
|
||||
vi.stubEnv("BASH_ENV", "/unexpected/bash-env");
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession();
|
||||
@@ -640,15 +638,13 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
shell.args,
|
||||
expect.objectContaining({
|
||||
cwd: "/repo",
|
||||
envMode: "internal",
|
||||
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
|
||||
shell: false,
|
||||
}),
|
||||
);
|
||||
expect(spawn.mock.calls[0]?.[2]?.env).not.toHaveProperty("BASH_ENV");
|
||||
});
|
||||
|
||||
test("preserves cmd semantics for single-string terminal commands on Windows", async () => {
|
||||
vi.stubEnv("BASH_ENV", "/unexpected/bash-env");
|
||||
const originalPlatform = process.platform;
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: "win32",
|
||||
@@ -670,11 +666,10 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
["/c", "echo %TEMP% && echo ok"],
|
||||
expect.objectContaining({
|
||||
cwd: "C:\\repo",
|
||||
envMode: "internal",
|
||||
envOverlay: expect.objectContaining({ BASH_ENV: undefined }),
|
||||
shell: false,
|
||||
}),
|
||||
);
|
||||
expect(spawn.mock.calls[0]?.[2]?.env).not.toHaveProperty("BASH_ENV");
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", {
|
||||
value: originalPlatform,
|
||||
@@ -787,7 +782,6 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
});
|
||||
|
||||
test("carries the agent's identity into single-string shell commands", async () => {
|
||||
vi.stubEnv("BASH_ENV", "/unexpected/bash-env");
|
||||
const child = createTerminalChildStub();
|
||||
const spawn = vi.spyOn(spawnUtils, "spawnProcess").mockReturnValue(child);
|
||||
const session = createSession({ launchEnv: { PASEO_AGENT_ID: "agent-1" } });
|
||||
@@ -802,13 +796,12 @@ describe("ACPAgentSession terminal tools", () => {
|
||||
expect.any(String),
|
||||
expect.any(Array),
|
||||
expect.objectContaining({
|
||||
env: expect.objectContaining({
|
||||
envOverlay: expect.objectContaining({
|
||||
PASEO_AGENT_ID: "agent-1",
|
||||
BASH_ENV: undefined,
|
||||
}),
|
||||
envMode: "internal",
|
||||
}),
|
||||
);
|
||||
expect(spawn.mock.calls[0]?.[2]?.env).not.toHaveProperty("BASH_ENV");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -42,13 +42,7 @@ describe("Claude SDK env", () => {
|
||||
const query = claudeQuery(
|
||||
{ prompt: "", options: {} },
|
||||
{
|
||||
runtimeSettings: {
|
||||
env: {
|
||||
CLAUDE_CODE_ENTRYPOINT: "sdk-ts",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
ESBUILD_BINARY_PATH: "/configured/esbuild",
|
||||
},
|
||||
},
|
||||
runtimeSettings: { env: { CLAUDE_CODE_ENTRYPOINT: "sdk-ts" } },
|
||||
launchEnv: entrypoint === undefined ? {} : { CLAUDE_CODE_ENTRYPOINT: entrypoint },
|
||||
queryFactory: ({ options }) => {
|
||||
if (!options.spawnClaudeCodeProcess) {
|
||||
@@ -58,7 +52,7 @@ describe("Claude SDK env", () => {
|
||||
command,
|
||||
args: [
|
||||
"-e",
|
||||
"process.stdout.write(JSON.stringify({ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, parentSession: process.env.CLAUDECODE ?? null, electronMode: process.env.ELECTRON_RUN_AS_NODE ?? null, esbuild: process.env.ESBUILD_BINARY_PATH ?? null }))",
|
||||
"process.stdout.write(JSON.stringify({ entrypoint: process.env.CLAUDE_CODE_ENTRYPOINT, parentSession: process.env.CLAUDECODE ?? null }))",
|
||||
],
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: "sdk-ts", CLAUDECODE: "1" },
|
||||
@@ -78,8 +72,6 @@ describe("Claude SDK env", () => {
|
||||
expect(JSON.parse(output)).toEqual({
|
||||
...(entrypoint === undefined ? {} : { entrypoint }),
|
||||
parentSession: null,
|
||||
electronMode: "1",
|
||||
esbuild: "/configured/esbuild",
|
||||
});
|
||||
} finally {
|
||||
query.close();
|
||||
|
||||
@@ -365,21 +365,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("appends concrete models from Claude settings.json with thinking options", async () => {
|
||||
const customModelThinkingOptions = [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
{ id: "max", label: "Max" },
|
||||
];
|
||||
const fableThinkingOptions = [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium" },
|
||||
{ id: "high", label: "High", isDefault: true },
|
||||
{ id: "xhigh", label: "Extra High" },
|
||||
{ id: "max", label: "Max" },
|
||||
{ id: CLAUDE_ULTRACODE_THINKING_OPTION_ID, label: "Ultra Code" },
|
||||
];
|
||||
it("appends concrete models from Claude settings.json", async () => {
|
||||
const configDir = await createClaudeConfigDir({
|
||||
model: "us.anthropic.claude-opus-4-7[1m]",
|
||||
env: {
|
||||
@@ -407,49 +393,42 @@ describe("ClaudeAgentClient.fetchCatalog", () => {
|
||||
id: "us.anthropic.claude-opus-4-7[1m]",
|
||||
label: "us.anthropic.claude-opus-4-7[1m]",
|
||||
description: "From Claude settings.json model",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "openrouter/anthropic/claude-sonnet-4.5",
|
||||
label: "openrouter/anthropic/claude-sonnet-4.5",
|
||||
description: "From Claude settings.json env.ANTHROPIC_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "ollama/qwen3-coder",
|
||||
label: "ollama/qwen3-coder",
|
||||
description: "From Claude settings.json env.ANTHROPIC_SMALL_FAST_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "claude-fable-5-1[1M]",
|
||||
label: "claude-fable-5-1[1M]",
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_FABLE_MODEL",
|
||||
thinkingOptions: fableThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "bedrock-opus-from-env",
|
||||
label: "bedrock-opus-from-env",
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "glm-5.1",
|
||||
label: "glm-5.1",
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
{
|
||||
provider: "claude",
|
||||
id: "glm-5",
|
||||
label: "glm-5",
|
||||
description: "From Claude settings.json env.ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||
thinkingOptions: customModelThinkingOptions,
|
||||
},
|
||||
]);
|
||||
expect(models.filter((model) => model.id === "claude-fable-5-1[1M]")).toHaveLength(1);
|
||||
|
||||
@@ -83,7 +83,7 @@ export async function getClaudeModelsWithSettings(
|
||||
const manifestModel = getClaudeModels(claudeCodeVersion).find(
|
||||
(candidate) => candidate.id === model.id,
|
||||
);
|
||||
models.push(resolveConfiguredClaudeModel({ ...manifestModel, ...model }));
|
||||
models.push({ ...manifestModel, ...model });
|
||||
}
|
||||
|
||||
return models;
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
createProviderEnvSpec,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../../provider-launch-config.js";
|
||||
import { buildSelfNodeCommand } from "../../../paseo-env.js";
|
||||
import { buildSelfNodeCommand, createExternalProcessEnv } from "../../../paseo-env.js";
|
||||
import { spawnProcess } from "../../../../utils/spawn.js";
|
||||
|
||||
// Keep the raw SDK query import in this module only. Claude process launch behavior
|
||||
@@ -75,10 +75,8 @@ function applyRuntimeSettingsToClaudeOptions(
|
||||
});
|
||||
// Session-open hooks may explicitly supply a client identity. Keep inherited
|
||||
// SDK/parent identities cleared while honoring this launch-only contribution.
|
||||
const providerEnv = providerEnvSpec.env;
|
||||
if (launchEnv?.CLAUDE_CODE_ENTRYPOINT !== undefined) {
|
||||
providerEnv.CLAUDE_CODE_ENTRYPOINT = launchEnv.CLAUDE_CODE_ENTRYPOINT;
|
||||
}
|
||||
providerEnvSpec.envOverlay.CLAUDE_CODE_ENTRYPOINT = launchEnv?.CLAUDE_CODE_ENTRYPOINT;
|
||||
const providerEnv = createExternalProcessEnv(spawnOptions.env, providerEnvSpec.envOverlay);
|
||||
const selfNodeCommand = isDefaultRuntime
|
||||
? buildSelfNodeCommand(resolved.args, providerEnv)
|
||||
: null;
|
||||
|
||||
@@ -7534,7 +7534,6 @@ export class CodexAppServerAgentClient implements AgentClient {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
baseEnv: options?.environment ?? buildCodexAppServerEnv(this.runtimeSettings, launchEnv),
|
||||
envMode: "internal",
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
return child;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { spawnProcess, type SpawnProcessOptions } from "../../../utils/spawn.js";
|
||||
import { spawnProcess } from "../../../utils/spawn.js";
|
||||
import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
|
||||
import { JsonlFrameDecoder, ProviderStreamLimitError } from "./jsonl-frame-decoder.js";
|
||||
export { supportsJsonlRpcProtocolV2 } from "./jsonl-frame-decoder.js";
|
||||
@@ -23,7 +23,6 @@ export interface JsonlRpcLaunch {
|
||||
args: string[];
|
||||
cwd: string;
|
||||
env?: Record<string, string>;
|
||||
envMode?: SpawnProcessOptions["envMode"];
|
||||
}
|
||||
|
||||
interface JsonlRpcResponse {
|
||||
@@ -66,9 +65,7 @@ function assertChildWithPipes(
|
||||
function spawnJsonlRpcProcess(launch: JsonlRpcLaunch): ChildProcessWithoutNullStreams {
|
||||
const child = spawnProcess(launch.command, launch.args, {
|
||||
cwd: launch.cwd,
|
||||
...(launch.envMode === "internal"
|
||||
? { env: launch.env, envMode: launch.envMode }
|
||||
: { envOverlay: launch.env }),
|
||||
envOverlay: launch.env,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
assertChildWithPipes(child);
|
||||
|
||||
@@ -121,38 +121,6 @@ function withoutRequestId(command: Record<string, unknown>): Record<string, unkn
|
||||
}
|
||||
|
||||
describe("OMP CLI runtime", () => {
|
||||
test("passes configured runtime controls to the spawned RPC process", async () => {
|
||||
const source = `
|
||||
const readline = require("node:readline");
|
||||
console.log(JSON.stringify({ type: "ready" }));
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const command = JSON.parse(line);
|
||||
const env = {
|
||||
ELECTRON_RUN_AS_NODE: process.env.ELECTRON_RUN_AS_NODE ?? null,
|
||||
PASEO_NODE_ENV: process.env.PASEO_NODE_ENV ?? null,
|
||||
};
|
||||
console.log(JSON.stringify({ type: "response", id: command.id, success: true,
|
||||
data: { commands: [{ name: "env", description: JSON.stringify(env) }] } }));
|
||||
});
|
||||
`;
|
||||
const runtime = new OmpCliRuntime({
|
||||
logger: pino({ level: "silent" }),
|
||||
command: [process.execPath, "-e", source, "--"],
|
||||
runtimeSettings: { env: { ELECTRON_RUN_AS_NODE: "1" } },
|
||||
});
|
||||
const session = await runtime.startSession({
|
||||
cwd: process.cwd(),
|
||||
env: { ELECTRON_RUN_AS_NODE: "session", PASEO_NODE_ENV: "development" },
|
||||
});
|
||||
try {
|
||||
expect(await session.getCommands()).toEqual([
|
||||
{ name: "env", description: '{"ELECTRON_RUN_AS_NODE":"1","PASEO_NODE_ENV":null}' },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("steer waits for OMP's response and surfaces rejection", async () => {
|
||||
const child = createOmpChild();
|
||||
let pending: Record<string, unknown> | null = null;
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { createExternalProcessEnv } from "../../../paseo-env.js";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
createProviderEnvSpec,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../../provider-launch-config.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import {
|
||||
JSONL_RPC_NO_TIMEOUT,
|
||||
JsonlRpcProcess,
|
||||
@@ -78,17 +76,13 @@ export class OmpCliRuntime implements OmpRuntime {
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
session: input,
|
||||
});
|
||||
const envSpec = createProviderEnvSpec({
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
overlays: [input.env],
|
||||
});
|
||||
launch.env = envSpec.env;
|
||||
launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {});
|
||||
const [command, ...args] = launch.argv;
|
||||
const processLaunch: JsonlRpcLaunch = {
|
||||
command,
|
||||
args,
|
||||
cwd: launch.cwd,
|
||||
...envSpec,
|
||||
env: launch.env,
|
||||
};
|
||||
const spawn = this.spawnProcess;
|
||||
const processOptions = {
|
||||
|
||||
@@ -94,7 +94,7 @@ test("bridge preserves tool failure messages and classifies invalid requests as
|
||||
const { tools } = await manifest.json();
|
||||
expect(
|
||||
tools.find((tool: { name: string }) => tool.name === "create_agent").inputSchema.properties,
|
||||
).toHaveProperty("background");
|
||||
).not.toHaveProperty("background");
|
||||
expect(
|
||||
tools.find((tool: { name: string }) => tool.name === "update_schedule").inputSchema
|
||||
.properties,
|
||||
|
||||
@@ -181,18 +181,13 @@ describe("OpenCodeBridge", () => {
|
||||
expect(createDefinition.inputSchema).toMatchObject({
|
||||
properties: { notifyOnFinish: { default: true } },
|
||||
});
|
||||
expect(createDefinition.inputSchema.properties).toHaveProperty("background");
|
||||
expect(createDefinition.inputSchema.properties).not.toHaveProperty("background");
|
||||
expect(sendDefinition.inputSchema).toMatchObject({
|
||||
properties: { notifyOnFinish: { default: true } },
|
||||
});
|
||||
await catalog.executeTool("create_agent", {
|
||||
...createInput,
|
||||
title: "Background child",
|
||||
background: true,
|
||||
});
|
||||
expect(
|
||||
agentManager.listAgents().find((agent) => agent.config.title === "Background child"),
|
||||
).toMatchObject({ background: true });
|
||||
await expect(
|
||||
catalog.executeTool("create_agent", { ...createInput, background: true }),
|
||||
).rejects.toThrow("background");
|
||||
|
||||
const v1Module = await import(plugin.pluginUrl);
|
||||
const hooks = await v1Module.default({}, plugin);
|
||||
|
||||
@@ -345,7 +345,6 @@ export class OpenCodeServerManager implements OpenCodeServerManagerLike {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
baseEnv: environment,
|
||||
envMode: "internal",
|
||||
});
|
||||
const managedProcessRecord = this.recordManagedServerProcess({
|
||||
process: serverProcess,
|
||||
|
||||
@@ -593,14 +593,15 @@ describe("OpenCode v2 session lifecycle", () => {
|
||||
const assertEnvironment = (index: number) => {
|
||||
const env = environments.get([first, second][index].info.id);
|
||||
expect(env?.PATH).toBe(process.env.PATH);
|
||||
expect(env).toMatchObject({
|
||||
...launches[index].env,
|
||||
PASEO_ENV_TEST: "configured",
|
||||
ELECTRON_RUN_AS_NODE: "1",
|
||||
PASEO_SUPERVISED: "1",
|
||||
});
|
||||
expect(env).toMatchObject({ ...launches[index].env, PASEO_ENV_TEST: "configured" });
|
||||
expect(env?.USER).toBe(process.env.USER);
|
||||
for (const key of ["CLAUDECODE", "ESBUILD_BINARY_PATH", "PASEO_NODE_ENV"]) {
|
||||
for (const key of [
|
||||
"ELECTRON_RUN_AS_NODE",
|
||||
"PASEO_SUPERVISED",
|
||||
"CLAUDECODE",
|
||||
"ESBUILD_BINARY_PATH",
|
||||
"PASEO_NODE_ENV",
|
||||
]) {
|
||||
expect(env).not.toHaveProperty(key);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -242,10 +242,6 @@ export class OpenCodeV2Session implements AgentSession {
|
||||
}
|
||||
subscribe(callback: (event: AgentStreamEvent) => void) {
|
||||
this.listeners.add(callback);
|
||||
// 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)
|
||||
callback({ type: "turn_started", provider: "opencode", turnId: this.turns.id });
|
||||
return () => {
|
||||
this.listeners.delete(callback);
|
||||
};
|
||||
|
||||
@@ -208,6 +208,52 @@ async function createSession(
|
||||
return { pi, session, events };
|
||||
}
|
||||
|
||||
test("close preserves a foreground tool's terminal update before releasing Pi", async () => {
|
||||
const { pi, session, events } = await createSession();
|
||||
const native = pi.latestSession();
|
||||
const stopped = Promise.withResolvers<void>();
|
||||
native.abortCompletion = stopped.promise;
|
||||
await session.startTurn("Run a foreground command");
|
||||
native.emit({
|
||||
type: "tool_execution_start",
|
||||
toolCallId: "foreground-bash",
|
||||
toolName: "bash",
|
||||
args: { command: "sleep 120" },
|
||||
});
|
||||
|
||||
const closing = session.close();
|
||||
await flushTurnScheduling();
|
||||
// Pi's abort acknowledgement follows its final tool result and durable transcript write.
|
||||
native.emit({
|
||||
type: "tool_execution_end",
|
||||
toolCallId: "foreground-bash",
|
||||
toolName: "bash",
|
||||
result: { content: [{ type: "text", text: "Command aborted" }] },
|
||||
isError: true,
|
||||
});
|
||||
stopped.resolve();
|
||||
await closing;
|
||||
|
||||
expect(
|
||||
events
|
||||
.timelineItems()
|
||||
.findLast((item) => item.type === "tool_call" && item.callId === "foreground-bash"),
|
||||
).toMatchObject({
|
||||
callId: "foreground-bash",
|
||||
status: "failed",
|
||||
});
|
||||
});
|
||||
|
||||
test("close releases Pi when stopping its active work fails", async () => {
|
||||
const { pi, session } = await createSession();
|
||||
const native = pi.latestSession();
|
||||
await session.startTurn("Run a foreground command");
|
||||
native.clearQueueError = new Error("Pi control plane is unavailable");
|
||||
|
||||
await expect(session.close()).resolves.toBeUndefined();
|
||||
expect(native.closeCalls).toBe(1);
|
||||
});
|
||||
|
||||
test("forwards launch-context env to the Pi process launch", async () => {
|
||||
const pi = new FakePi();
|
||||
const client = createClient(pi);
|
||||
@@ -2194,33 +2240,6 @@ describe("PiRpcAgentSession", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test.each([false, true])(
|
||||
"rejects new turns with Reload guidance after the Pi process exits (active: %s)",
|
||||
async (active) => {
|
||||
const { pi, session, events } = await createSession();
|
||||
try {
|
||||
if (active) {
|
||||
await session.startTurn("before crash");
|
||||
}
|
||||
const runtime = pi.latestSession();
|
||||
runtime.emit({ type: "process_exit", error: "Pi exited" });
|
||||
if (active) {
|
||||
await expect(events.nextTurnFailure()).resolves.toMatchObject({ error: "Pi exited" });
|
||||
}
|
||||
const promptCount = runtime.prompts.length;
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
await expect(session.startTurn("after crash")).rejects.toThrow(
|
||||
"Pi process exited. Reload agent before sending another message.",
|
||||
);
|
||||
}
|
||||
expect(runtime.prompts).toHaveLength(promptCount);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("fails an autonomous turn when the Pi process exits before settlement", async () => {
|
||||
const { pi, events } = await createSession();
|
||||
const fakeSession = pi.latestSession();
|
||||
|
||||
@@ -1223,7 +1223,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
private readonly logger: Logger;
|
||||
private readonly usagePoller: PiUsagePoller;
|
||||
private closed = false;
|
||||
private runtimeDead = false;
|
||||
private readonly closeController = new AbortController();
|
||||
private readonly pendingExtensionHydrations = new Set<Promise<void>>();
|
||||
private pendingOutput: Promise<void> | null = null;
|
||||
@@ -1306,9 +1305,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
async startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<StartTurnResult> {
|
||||
if (this.runtimeDead) {
|
||||
throw new Error("Pi process exited. Reload agent before sending another message.");
|
||||
}
|
||||
if (this.activeTurnId) {
|
||||
throw new Error("A Pi turn is already active");
|
||||
}
|
||||
@@ -1544,16 +1540,7 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.activeTurnId || this.activeTurnStarted ? { turnId, error: null } : null;
|
||||
this.interruptingTurn = interruption;
|
||||
try {
|
||||
try {
|
||||
await this.runtimeSession.clearQueue();
|
||||
} catch (error) {
|
||||
// COMPAT(piClearQueueFallback): added in v0.5.0, remove after 2027-03-01 once
|
||||
// the pi floor supports clear_queue (added in pi 0.84.4).
|
||||
if (!isPiMissingClearQueueRpc(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this.runtimeSession.abort();
|
||||
await this.abortRuntimeWork();
|
||||
} catch (error) {
|
||||
const terminalError = this.interruptingTurn === interruption ? interruption?.error : null;
|
||||
if (this.interruptingTurn === interruption) {
|
||||
@@ -1605,6 +1592,19 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
private async abortRuntimeWork(): Promise<void> {
|
||||
try {
|
||||
await this.runtimeSession.clearQueue();
|
||||
} catch (error) {
|
||||
// COMPAT(piClearQueueFallback): added in v0.5.0, remove after 2027-03-01 once
|
||||
// the pi floor supports clear_queue (added in pi 0.84.4).
|
||||
if (!isPiMissingClearQueueRpc(error)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
await this.runtimeSession.abort();
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
if (this.activeTurnId) {
|
||||
throw new Error("Cannot rewind the Pi conversation while a turn is active");
|
||||
@@ -1659,6 +1659,13 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
this.extensionHost.close();
|
||||
this.usagePoller.close();
|
||||
try {
|
||||
if (this.activeTurnId || this.activeTurnStarted) {
|
||||
// Pi persists terminal tool results before acknowledging abort. Keep its
|
||||
// event stream alive until then so restart history cannot strand the tool.
|
||||
await this.abortRuntimeWork().catch((err) => {
|
||||
this.logger.warn({ err }, "Failed to abort Pi work before closing its runtime");
|
||||
});
|
||||
}
|
||||
await this.runtimeSession.close();
|
||||
} finally {
|
||||
await Promise.all([this.pendingOutput, ...this.pendingExtensionHydrations]);
|
||||
@@ -2194,7 +2201,6 @@ export class PiRpcAgentSession implements AgentSession {
|
||||
}
|
||||
|
||||
private handleProcessExit(error: string): void {
|
||||
this.runtimeDead = true;
|
||||
this.rejectAllExtensionResults(new Error(error));
|
||||
this.interruptingTurn = null;
|
||||
if (!this.activeTurnId && !this.activeTurnStarted) {
|
||||
|
||||
@@ -131,37 +131,6 @@ function writePiResponse(
|
||||
}
|
||||
|
||||
describe("PiCliRuntime", () => {
|
||||
test("passes configured runtime controls to the spawned RPC process", async () => {
|
||||
const source = `
|
||||
const readline = require("node:readline");
|
||||
readline.createInterface({ input: process.stdin }).on("line", (line) => {
|
||||
const command = JSON.parse(line);
|
||||
const env = {
|
||||
ELECTRON_RUN_AS_NODE: process.env.ELECTRON_RUN_AS_NODE ?? null,
|
||||
PASEO_NODE_ENV: process.env.PASEO_NODE_ENV ?? null,
|
||||
};
|
||||
console.log(JSON.stringify({ type: "response", id: command.id, success: true,
|
||||
data: { commands: [{ name: "env", description: JSON.stringify(env) }] } }));
|
||||
});
|
||||
`;
|
||||
const runtime = new PiCliRuntime({
|
||||
logger: pino({ level: "silent" }),
|
||||
command: [process.execPath, "-e", source, "--"],
|
||||
runtimeSettings: { env: { ELECTRON_RUN_AS_NODE: "1" } },
|
||||
});
|
||||
const session = await runtime.startSession({
|
||||
cwd: process.cwd(),
|
||||
env: { ELECTRON_RUN_AS_NODE: "session", PASEO_NODE_ENV: "development" },
|
||||
});
|
||||
try {
|
||||
expect(await session.getCommands()).toEqual([
|
||||
{ name: "env", description: '{"ELECTRON_RUN_AS_NODE":"1","PASEO_NODE_ENV":null}' },
|
||||
]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("starts pi in rpc mode and resolves command responses", async () => {
|
||||
const child = createPiChild();
|
||||
replyToCommands(child, (command) =>
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { readPiTranscript, type PiTranscript } from "./transcript.js";
|
||||
import { createExternalProcessEnv } from "../../../paseo-env.js";
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import {
|
||||
createProviderEnvSpec,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "../../provider-launch-config.js";
|
||||
import type { ProviderRuntimeSettings } from "../../provider-launch-config.js";
|
||||
import {
|
||||
JSONL_RPC_NO_TIMEOUT,
|
||||
JsonlRpcProcess,
|
||||
@@ -60,17 +58,13 @@ export class PiCliRuntime implements PiRuntime {
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
session: input,
|
||||
});
|
||||
const envSpec = createProviderEnvSpec({
|
||||
runtimeSettings: this.options.runtimeSettings,
|
||||
overlays: [input.env],
|
||||
});
|
||||
launch.env = envSpec.env;
|
||||
launch.env = createExternalProcessEnv(globalThis.process.env, launch.env ?? {});
|
||||
const [command, ...args] = launch.argv;
|
||||
const processLaunch: JsonlRpcLaunch = {
|
||||
command,
|
||||
args,
|
||||
cwd: launch.cwd,
|
||||
...envSpec,
|
||||
env: launch.env,
|
||||
};
|
||||
const spawn = this.spawnProcess;
|
||||
const processOptions = {
|
||||
|
||||
@@ -153,6 +153,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
// Native user entries paired with the complete active-branch history.
|
||||
historyUserEntries: FakePiUserEntry[] = [];
|
||||
abortRequested = false;
|
||||
abortCompletion: Promise<void> | null = null;
|
||||
closeCalls = 0;
|
||||
readonly canceledExtensionUiRequests: string[] = [];
|
||||
readonly extensionUiResponses: Array<{
|
||||
@@ -295,6 +296,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
async abort(): Promise<void> {
|
||||
this.controlRequests.push("abort");
|
||||
this.abortRequested = true;
|
||||
await this.abortCompletion;
|
||||
}
|
||||
|
||||
async getState(): Promise<PiSessionState> {
|
||||
@@ -427,6 +429,7 @@ export class FakePiSession implements PiRuntimeSession {
|
||||
|
||||
async close(): Promise<void> {
|
||||
this.closeCalls += 1;
|
||||
this.subscribers.clear();
|
||||
}
|
||||
|
||||
emit(event: PiRuntimeEvent): void {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user