mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-07 08:54:42 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
151b705b47 | ||
|
|
d7b941680a |
+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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
+3
-47
@@ -30,50 +30,6 @@ at session startup. See [provider configuration](custom-providers.md#provider-op
|
||||
for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
|
||||
for native keys and examples.
|
||||
|
||||
### Options flow
|
||||
|
||||
`options` on the agent snapshot is the only option state. Controls describe how to draw an
|
||||
option and read `options`; they hold no value of their own. Any option can be read and set with
|
||||
or without a control.
|
||||
|
||||
- **Create and resume.** The provider opens with the options it is given and emits nothing about
|
||||
them while opening. `openOptionsPatch` carries every option the session ended up with that
|
||||
differs from what it was given, including a setting nobody asked for (a native default such as
|
||||
Cursor opening with Fast on). It is set before `createSession`/`resumeSession` resolves and the
|
||||
daemon stores it before the agent is published. A reopened agent resumes that state, so a
|
||||
native default the provider later changes does not follow. `options` on the snapshot is what
|
||||
the session opened with, and afterwards it moves only through an apply or an `options_changed`.
|
||||
Editing a provider default in `config.json` affects agents the next time they open.
|
||||
- **Live apply.** `applyOptions` receives the keys the patch touches, resolved against the
|
||||
`config.json` defaults. A removal arrives as the default the key falls back to, or `null` when
|
||||
there is none; the provider returns the setting to its own default if it can, or throws to
|
||||
reject. A key the running session cannot apply (one the provider reads only when it opens) is
|
||||
rejected too, so a stored option always describes what runs. Claude reads most options when it
|
||||
starts its query, and replacing a started query would end its background work, so after the
|
||||
first prompt only Fast can change. Codex fixes its approval policy and sandbox when the thread
|
||||
starts, so it applies only `service_tier` and `plan_mode` live. A session with no
|
||||
`applyOptions` rejects every patch; the daemon enforces this. The provider returns the options
|
||||
it ended up with when they differ from the request. The daemon
|
||||
stores the patch and that result in one step after the call resolves, onto the agent's options
|
||||
as they are at that moment, so the last write wins. Throwing rejects the whole patch and
|
||||
none of it is stored. Emit nothing from inside the call. If the provider's own state moved
|
||||
before it refused, it reports the moved values once as `options_changed` after the call has
|
||||
ended, because the stored options must match what runs.
|
||||
- **Old data converts in the agent manager.** Codex used to take `fast_mode`; it takes
|
||||
`service_tier` now. `AgentManager` runs `convertLegacyOptions` (`option-controls.ts`) on an
|
||||
agent's own options when it creates, resumes, imports, reloads or applies to it, and on draft
|
||||
controls, before defaults merge and before anything is stored (`true` is `"fast"`, `false` is
|
||||
`"default"`, and a `service_tier` in the same input wins). The manager converts for the adapter
|
||||
behind the provider id, following `derivedFromProviderId`, so a custom provider with
|
||||
`extends: "codex"` is converted too. Configured defaults convert in the provider registry. A
|
||||
stored record keeps `fast_mode` until the agent is next opened or applied to, and is stored
|
||||
converted from then on. Nothing past the manager sees `fast_mode` for Codex. Codex resolves
|
||||
`"fast"` to the model's Fast tier and reports the id it runs through `openOptionsPatch` or the
|
||||
`applyOptions` result like any other value. The `featureValues` to `options` fold is separate
|
||||
and provider-agnostic; it stays at the protocol and legacy edges.
|
||||
- **Provider-initiated changes.** A setting the provider changes on its own is an
|
||||
`options_changed` event. The daemon stores and forwards it and does not push the old value back.
|
||||
|
||||
Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail
|
||||
closed for Hub unattended execution until it can approve one exact injected MCP
|
||||
server and tool identity without approving native tools.
|
||||
@@ -159,7 +115,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl
|
||||
|
||||
Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading.
|
||||
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state.
|
||||
Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state.
|
||||
|
||||
Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready.
|
||||
|
||||
@@ -522,7 +478,7 @@ interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
readonly controls?: AgentOptionControl[];
|
||||
readonly features?: AgentFeature[];
|
||||
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
|
||||
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
|
||||
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
|
||||
@@ -544,7 +500,7 @@ interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
applyOptions?(options: ProviderOptions): Promise<void>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
tryHandleOutOfBand?(prompt: AgentPromptInput): {
|
||||
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
|
||||
} | null;
|
||||
|
||||
+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();
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -21,10 +21,7 @@ export const AgentProfileSchema = z
|
||||
model: z.string().optional(),
|
||||
modeId: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read as `options`.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
/** Provider options applied when the profile creates an agent. */
|
||||
options: z.record(z.string(), z.unknown()).optional(),
|
||||
/** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */
|
||||
notes: z.string().optional(),
|
||||
})
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import type { AgentMessage } from "./agent-message.js";
|
||||
import type {
|
||||
AgentAttachment,
|
||||
AgentOptionChoiceSchema,
|
||||
AgentOptionControlSchema,
|
||||
AgentOptionSelectControlSchema,
|
||||
AgentOptionToggleControlSchema,
|
||||
} from "./messages.js";
|
||||
import type { z } from "zod";
|
||||
import type { AgentAttachment } from "./messages.js";
|
||||
|
||||
export type AgentProvider = string;
|
||||
|
||||
@@ -161,20 +154,6 @@ export interface AgentFeatureSelect {
|
||||
|
||||
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
|
||||
|
||||
export type AgentOptionChoice = z.infer<typeof AgentOptionChoiceSchema>;
|
||||
|
||||
/** `key` is the top-level provider option key the control reads and writes. */
|
||||
export type AgentOptionToggleControl = z.infer<typeof AgentOptionToggleControlSchema>;
|
||||
|
||||
/** `defaultValue` is the choice an unset option stands for. */
|
||||
export type AgentOptionSelectControl = z.infer<typeof AgentOptionSelectControlSchema>;
|
||||
|
||||
/**
|
||||
* A provider's description of a composer control over one top-level option key.
|
||||
* The control carries no value: it is read from the agent's effective options.
|
||||
*/
|
||||
export type AgentOptionControl = z.infer<typeof AgentOptionControlSchema>;
|
||||
|
||||
export interface AgentCapabilityFlags {
|
||||
[capability: string]: boolean | undefined;
|
||||
supportsStreaming: boolean;
|
||||
@@ -544,10 +523,6 @@ export interface AgentSessionConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
/**
|
||||
* @deprecated COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Folded into `providerOptions` at the daemon's edge; the daemon never reads it internally.
|
||||
*/
|
||||
featureValues?: Record<string, unknown>;
|
||||
title?: string | null;
|
||||
providerOptions?: ProviderOptions;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -361,39 +361,6 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
|
||||
AgentFeatureSelectSchema,
|
||||
]);
|
||||
|
||||
export const AgentOptionChoiceSchema = z.object({
|
||||
value: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const AgentOptionToggleControlSchema = z.object({
|
||||
type: z.literal("toggle"),
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
defaultValue: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const AgentOptionSelectControlSchema = z.object({
|
||||
type: z.literal("select"),
|
||||
key: z.string(),
|
||||
label: z.string(),
|
||||
description: z.string().optional(),
|
||||
tooltip: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
desktopTrigger: z.enum(["icon", "label"]).optional(),
|
||||
defaultValue: z.string().optional(),
|
||||
choices: z.array(AgentOptionChoiceSchema),
|
||||
});
|
||||
|
||||
export const AgentOptionControlSchema = z.discriminatedUnion("type", [
|
||||
AgentOptionToggleControlSchema,
|
||||
AgentOptionSelectControlSchema,
|
||||
]);
|
||||
|
||||
const AgentModelDefinitionSchema = z.object({
|
||||
provider: AgentProviderSchema,
|
||||
id: z.string(),
|
||||
@@ -523,7 +490,6 @@ const AgentSessionConfigSchema = z.object({
|
||||
modeId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
|
||||
providerOptions: ProviderOptionsSchema.optional(),
|
||||
@@ -900,11 +866,7 @@ export const AgentSnapshotPayloadSchema = z.object({
|
||||
cwd: z.string(),
|
||||
workspaceId: z.string().optional(),
|
||||
model: z.string().nullable(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Built from controls + options.
|
||||
features: z.array(AgentFeatureSchema).optional(),
|
||||
/** Effective provider options: `config.json` defaults deep-merged with the agent's own. */
|
||||
options: ProviderOptionsSchema.optional(),
|
||||
controls: z.array(AgentOptionControlSchema).optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
effectiveThinkingOptionId: z.string().nullable().optional(),
|
||||
createdAt: z.string(),
|
||||
@@ -929,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>;
|
||||
@@ -1843,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(),
|
||||
@@ -2114,10 +2072,7 @@ export const AgentConfigApplySchema = z.object({
|
||||
modelId: z.string().nullable().optional(),
|
||||
modeId: z.string().optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Maps to an options patch.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
/** Patch of the agent's own options: replaces top-level keys; `null` removes the key. */
|
||||
options: ProviderOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export type AgentConfigApply = z.infer<typeof AgentConfigApplySchema>;
|
||||
@@ -3033,9 +2988,7 @@ const ListCommandsDraftConfigSchema = z.object({
|
||||
modeId: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
providerOptions: ProviderOptionsSchema.optional(),
|
||||
});
|
||||
|
||||
export const ListProviderFeaturesRequestMessageSchema = z.object({
|
||||
@@ -3044,12 +2997,6 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ProviderControlsListRequestMessageSchema = z.object({
|
||||
type: z.literal("provider.controls.list.request"),
|
||||
draftConfig: ListCommandsDraftConfigSchema,
|
||||
requestId: z.string(),
|
||||
});
|
||||
|
||||
export const ListCommandsRequestSchema = z.object({
|
||||
type: z.literal("list_commands_request"),
|
||||
agentId: z.string(),
|
||||
@@ -3227,7 +3174,6 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
modeId: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
providerOptions: ProviderOptionsSchema.optional(),
|
||||
toolPolicy: ToolPolicySchema.optional(),
|
||||
@@ -3430,7 +3376,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModelsRequestMessageSchema,
|
||||
ListProviderModesRequestMessageSchema,
|
||||
ListProviderFeaturesRequestMessageSchema,
|
||||
ProviderControlsListRequestMessageSchema,
|
||||
ListAvailableProvidersRequestMessageSchema,
|
||||
GetProvidersSnapshotRequestMessageSchema,
|
||||
RefreshProvidersSnapshotRequestMessageSchema,
|
||||
@@ -3749,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(),
|
||||
@@ -3916,7 +3859,6 @@ export const ServerInfoStatusPayloadSchema = z
|
||||
selectiveAgentTimeline: z.boolean().optional(),
|
||||
explicitEventSubscriptions: z.boolean().optional(),
|
||||
ownedSubscriptions: z.boolean().optional(),
|
||||
providerOptionControls: z.boolean().optional(),
|
||||
binaryChunkingV1: z.boolean().optional(),
|
||||
workspaceSetupObservation: z.boolean().optional(),
|
||||
workspaceSortTimes: z.boolean().optional(),
|
||||
@@ -6402,19 +6344,6 @@ export const ListProviderModesResponseMessageSchema = z.object({
|
||||
}),
|
||||
});
|
||||
|
||||
export const ProviderControlsListResponseMessageSchema = z.object({
|
||||
type: z.literal("provider.controls.list.response"),
|
||||
payload: z.object({
|
||||
provider: AgentProviderSchema,
|
||||
controls: z.array(AgentOptionControlSchema).optional(),
|
||||
/** Effective options for the draft: `config.json` defaults merged with the draft's own. */
|
||||
options: ProviderOptionsSchema.optional(),
|
||||
error: z.string().nullable().optional(),
|
||||
fetchedAt: z.string(),
|
||||
requestId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ListProviderFeaturesResponseMessageSchema = z.object({
|
||||
type: z.literal("list_provider_features_response"),
|
||||
payload: z.object({
|
||||
@@ -7327,7 +7256,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
|
||||
ListProviderModelsResponseMessageSchema,
|
||||
ListProviderModesResponseMessageSchema,
|
||||
ListProviderFeaturesResponseMessageSchema,
|
||||
ProviderControlsListResponseMessageSchema,
|
||||
ListAvailableProvidersResponseSchema,
|
||||
GetProvidersSnapshotResponseMessageSchema,
|
||||
ProvidersSnapshotUpdateMessageSchema,
|
||||
@@ -7585,12 +7513,6 @@ export type ListProviderModelsRequestMessage = z.infer<
|
||||
typeof ListProviderModelsRequestMessageSchema
|
||||
>;
|
||||
export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>;
|
||||
export type ProviderControlsListRequestMessage = z.infer<
|
||||
typeof ProviderControlsListRequestMessageSchema
|
||||
>;
|
||||
export type ProviderControlsListResponseMessage = z.infer<
|
||||
typeof ProviderControlsListResponseMessageSchema
|
||||
>;
|
||||
export type ListProviderFeaturesRequestMessage = z.infer<
|
||||
typeof ListProviderFeaturesRequestMessageSchema
|
||||
>;
|
||||
@@ -7880,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()
|
||||
|
||||
@@ -34,7 +34,6 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [
|
||||
isolation: z.enum(["local", "worktree"]).optional(),
|
||||
title: z.string().trim().min(1).nullable().optional(),
|
||||
providerOptions: z.record(z.string(), z.unknown()).optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
|
||||
featureValues: z.record(z.string(), z.unknown()).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
mcpServers: z.record(z.string(), z.unknown()).optional(),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -23,7 +23,6 @@ import { AgentStorage } from "./agent-storage.js";
|
||||
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
|
||||
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { projectTimelineRows } from "./timeline-projection.js";
|
||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { startAgentRun } from "./agent-prompt.js";
|
||||
@@ -39,7 +38,7 @@ import type {
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentPromptInput,
|
||||
AgentProvider,
|
||||
@@ -58,7 +57,6 @@ import type {
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
import { mergeProviderOptions } from "./provider-options.js";
|
||||
|
||||
import { OpenCodeV2AgentClient } from "./providers/opencode/v2/agent.js";
|
||||
import { V2Harness } from "./providers/opencode/test-utils/v2-harness.js";
|
||||
@@ -187,8 +185,13 @@ class RecordingTimelineStore implements AgentTimelineStore {
|
||||
}
|
||||
}
|
||||
|
||||
function createControl(args: { key: string; label: string }): AgentOptionControl {
|
||||
return { type: "toggle", key: args.key, label: args.label };
|
||||
function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: args.id,
|
||||
label: args.label,
|
||||
value: args.value,
|
||||
};
|
||||
}
|
||||
|
||||
function expectArchivedAgentRecord(
|
||||
@@ -2359,7 +2362,7 @@ test("listDraftCommands uses explicit model config without default model fetchin
|
||||
]);
|
||||
});
|
||||
|
||||
test("listDraftControls does not start a fallback session without a model", async () => {
|
||||
test("listDraftFeatures does not start a fallback session without a model", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
@@ -2392,21 +2395,18 @@ test("listDraftControls does not start a fallback session without a model", asyn
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(manager.listDraftControls({ provider: "codex", cwd: workdir })).resolves.toEqual({
|
||||
controls: [],
|
||||
options: {},
|
||||
});
|
||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
|
||||
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
expect(client.availabilityCalls).toBe(0);
|
||||
});
|
||||
|
||||
test("listDraftControls uses client feature listing without a model", async () => {
|
||||
test("listDraftFeatures uses client feature listing without a model", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const draftControl = createControl({ key: "auto_accept", label: "Auto Accept" });
|
||||
const draftFeature = createFeature({ id: "auto_accept", label: "Auto Accept", value: false });
|
||||
class DraftFeatureClient extends TestAgentClient {
|
||||
fetchCatalogCalls = 0;
|
||||
createSessionCalls = 0;
|
||||
@@ -2428,9 +2428,9 @@ test("listDraftControls uses client feature listing without a model", async () =
|
||||
return await super.createSession(config);
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
this.featureConfigs.push(config);
|
||||
return [draftControl];
|
||||
return [draftFeature];
|
||||
}
|
||||
}
|
||||
const client = new DraftFeatureClient();
|
||||
@@ -2442,13 +2442,9 @@ test("listDraftControls uses client feature listing without a model", async () =
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(
|
||||
manager.listDraftControls({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { auto_accept: true },
|
||||
}),
|
||||
).resolves.toEqual({ controls: [draftControl], options: { auto_accept: true } });
|
||||
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
|
||||
draftFeature,
|
||||
]);
|
||||
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
@@ -2457,28 +2453,15 @@ test("listDraftControls uses client feature listing without a model", async () =
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { auto_accept: true },
|
||||
},
|
||||
]);
|
||||
|
||||
// Options without a control come back too.
|
||||
await expect(
|
||||
manager.listDraftControls({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { auto_accept: true, sandbox_mode: "workspace-write" },
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
controls: [draftControl],
|
||||
options: { auto_accept: true, sandbox_mode: "workspace-write" },
|
||||
});
|
||||
});
|
||||
|
||||
test("listDraftControls uses explicit model config without default model fetching", async () => {
|
||||
test("listDraftFeatures uses explicit model config without default model fetching", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const draftControl = createControl({ key: "web_search", label: "Fast mode" });
|
||||
const draftFeature = createFeature({ id: "fast_mode", label: "Fast mode", value: false });
|
||||
class DraftFeatureClient extends TestAgentClient {
|
||||
fetchCatalogCalls = 0;
|
||||
createSessionCalls = 0;
|
||||
@@ -2494,9 +2477,9 @@ test("listDraftControls uses explicit model config without default model fetchin
|
||||
return await super.createSession(config);
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
this.featureConfigs.push(config);
|
||||
return [draftControl];
|
||||
return [draftFeature];
|
||||
}
|
||||
}
|
||||
const client = new DraftFeatureClient();
|
||||
@@ -2508,13 +2491,13 @@ test("listDraftControls uses explicit model config without default model fetchin
|
||||
logger,
|
||||
});
|
||||
|
||||
const draft = await manager.listDraftControls({
|
||||
const features = await manager.listDraftFeatures({
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
model: "gpt-5.4",
|
||||
});
|
||||
|
||||
expect(draft).toEqual({ controls: [draftControl], options: {} });
|
||||
expect(features).toEqual([draftFeature]);
|
||||
expect(client.fetchCatalogCalls).toBe(0);
|
||||
expect(client.createSessionCalls).toBe(0);
|
||||
expect(client.featureConfigs).toEqual([
|
||||
@@ -4109,7 +4092,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 () => {
|
||||
@@ -4597,46 +4580,6 @@ test("resumeAgentFromPersistence keeps metadata config, applies overrides, and p
|
||||
});
|
||||
});
|
||||
|
||||
test("importProviderSession reports the defaults the session launched with, not defaults edited during the import", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-options-"));
|
||||
let tier = "default";
|
||||
class ImportClient extends TestAgentClient {
|
||||
effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
|
||||
return { service_tier: tier, ...own };
|
||||
}
|
||||
|
||||
async importSession(input: ImportProviderSessionInput) {
|
||||
// The user edits the default in config.json after the provider has launched.
|
||||
tier = "fast";
|
||||
return {
|
||||
session: new TestAgentSession({ provider: "codex", cwd: workdir }),
|
||||
config: { provider: "codex" as const, cwd: workdir },
|
||||
persistence: {
|
||||
provider: "codex" as const,
|
||||
sessionId: input.providerHandleId,
|
||||
nativeHandle: input.providerHandleId,
|
||||
metadata: { provider: "codex", cwd: workdir },
|
||||
},
|
||||
timeline: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new ImportClient() },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
|
||||
const imported = await manager.importProviderSession({
|
||||
provider: "codex",
|
||||
providerHandleId: "thread-selected",
|
||||
cwd: workdir,
|
||||
workspaceId: "ws-imported",
|
||||
});
|
||||
|
||||
expect(imported.effectiveOptions).toEqual({ service_tier: "default" });
|
||||
});
|
||||
|
||||
test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
@@ -8330,7 +8273,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,
|
||||
@@ -9436,8 +9379,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),
|
||||
);
|
||||
@@ -10184,13 +10127,16 @@ test("respondToPermission updates currentModeId after plan approval", async () =
|
||||
expect(client.resumedModeId).toBe("acceptEdits");
|
||||
});
|
||||
|
||||
test("respondToPermission applies provider option changes and refreshes runtime info after provider-managed plan approval", async () => {
|
||||
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class RefreshingPermissionSession extends TestAgentSession {
|
||||
private planMode = true;
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: true }),
|
||||
];
|
||||
private modeId = "auto";
|
||||
private pending = [
|
||||
{
|
||||
@@ -10202,11 +10148,8 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
},
|
||||
];
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [
|
||||
createControl({ key: "web_search", label: "Fast" }),
|
||||
createControl({ key: "plan_mode", label: "Plan" }),
|
||||
];
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
@@ -10215,7 +10158,7 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
sessionId: this.id,
|
||||
model: "gpt-5.4",
|
||||
modeId: this.modeId,
|
||||
extra: { collaborationMode: this.planMode ? "Plan" : "Code" },
|
||||
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10230,12 +10173,10 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
override async respondToPermission(): Promise<void> {
|
||||
this.modeId = "auto";
|
||||
this.pending = [];
|
||||
this.planMode = false;
|
||||
this.pushEvent({
|
||||
type: "options_changed",
|
||||
provider: this.provider,
|
||||
patch: { plan_mode: false },
|
||||
});
|
||||
this.featureState = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10258,7 +10199,6 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { web_search: true, plan_mode: true },
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
@@ -10283,14 +10223,20 @@ test("respondToPermission applies provider option changes and refreshes runtime
|
||||
|
||||
const updated = manager.getAgent(snapshot.id);
|
||||
expect(updated?.pendingPermissions.size).toBe(0);
|
||||
expect(updated?.effectiveOptions).toEqual({ web_search: true, plan_mode: false });
|
||||
expect(updated?.features).toEqual([
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
]);
|
||||
expect(updated?.runtimeInfo).toMatchObject({
|
||||
model: "gpt-5.4",
|
||||
extra: { collaborationMode: "Code" },
|
||||
});
|
||||
|
||||
const persisted = await storage.get(snapshot.id);
|
||||
expect(persisted?.config?.providerOptions).toEqual({ web_search: true, plan_mode: false });
|
||||
expect(persisted?.features).toEqual([
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
createFeature({ id: "plan_mode", label: "Plan", value: false }),
|
||||
]);
|
||||
});
|
||||
|
||||
test("respondToPermission emits refreshed state before permission_resolved", async () => {
|
||||
@@ -10299,6 +10245,9 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
|
||||
class OrderedPermissionSession extends TestAgentSession {
|
||||
private featureState: AgentFeature[] = [
|
||||
createFeature({ id: "fast_mode", label: "Fast", value: true }),
|
||||
];
|
||||
private modeId = "plan";
|
||||
private pending = [
|
||||
{
|
||||
@@ -10310,8 +10259,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
},
|
||||
];
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
return [createControl({ key: "web_search", label: "Fast" })];
|
||||
get features(): AgentFeature[] {
|
||||
return this.featureState;
|
||||
}
|
||||
|
||||
override async getRuntimeInfo() {
|
||||
@@ -10332,11 +10281,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
}
|
||||
|
||||
override async respondToPermission(): Promise<void> {
|
||||
this.pushEvent({
|
||||
type: "options_changed",
|
||||
provider: this.provider,
|
||||
patch: { web_search: false },
|
||||
});
|
||||
this.pushEvent({
|
||||
type: "permission_resolved",
|
||||
provider: this.provider,
|
||||
@@ -10344,6 +10288,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
resolution: { behavior: "allow" },
|
||||
});
|
||||
this.modeId = "acceptEdits";
|
||||
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
|
||||
this.pending = [];
|
||||
}
|
||||
}
|
||||
@@ -10367,7 +10312,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
{
|
||||
provider: "codex",
|
||||
cwd: workdir,
|
||||
providerOptions: { web_search: true },
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
@@ -10379,8 +10323,9 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
|
||||
return;
|
||||
}
|
||||
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
|
||||
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
|
||||
seen.push(
|
||||
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.web_search)}`,
|
||||
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -12975,478 +12920,3 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
|
||||
expect(h.commands).toEqual(["/maintenance"]);
|
||||
expect(h.prompts).toEqual([]);
|
||||
});
|
||||
|
||||
describe("agent options", () => {
|
||||
class OptionsSession extends TestAgentSession {
|
||||
readonly applied: ProviderOptions[] = [];
|
||||
rejectWith: Error | null = null;
|
||||
applyGate: Promise<void> | null = null;
|
||||
/** What the session says it ended up with after the next apply. */
|
||||
ended: Record<string, unknown> = {};
|
||||
openOptionsPatch: Record<string, unknown> = {};
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||
if (this.rejectWith) throw this.rejectWith;
|
||||
await this.applyGate;
|
||||
this.applied.push(options);
|
||||
return this.ended;
|
||||
}
|
||||
}
|
||||
|
||||
class OptionsClient extends TestAgentClient {
|
||||
session: OptionsSession | null = null;
|
||||
openOptionsPatch: Record<string, unknown> = {};
|
||||
|
||||
// Stands in for the `config.json` provider defaults the registry wrapper merges in.
|
||||
effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
|
||||
return { service_tier: "default", ...own };
|
||||
}
|
||||
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
this.session = new OptionsSession(config);
|
||||
this.session.openOptionsPatch = this.openOptionsPatch;
|
||||
return this.session;
|
||||
}
|
||||
}
|
||||
|
||||
async function createOptionsAgent(
|
||||
providerOptions?: ProviderOptions,
|
||||
openOptionsPatch: Record<string, unknown> = {},
|
||||
) {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new OptionsClient();
|
||||
client.openOptionsPatch = openOptionsPatch;
|
||||
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, ...(providerOptions ? { providerOptions } : {}) },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
return { manager, client, storage, agentId: agent.id };
|
||||
}
|
||||
|
||||
test("config.json defaults show as effective options without entering the agent's own options", async () => {
|
||||
const { manager, agentId } = await createOptionsAgent({ web_search: true });
|
||||
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.config.providerOptions).toEqual({ web_search: true });
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
|
||||
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", web_search: true });
|
||||
});
|
||||
|
||||
test("setAgentOptions hands the provider only the keys the patch sets", async () => {
|
||||
const { manager, client, storage, agentId } = await createOptionsAgent({ web_search: true });
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: "fast" });
|
||||
|
||||
expect(client.session?.applied).toEqual([{ service_tier: "fast" }]);
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.config.providerOptions).toEqual({ web_search: true, service_tier: "fast" });
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "fast", web_search: true });
|
||||
await manager.flush();
|
||||
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
|
||||
web_search: true,
|
||||
service_tier: "fast",
|
||||
});
|
||||
});
|
||||
|
||||
test("a patch key is resolved against the configured defaults, merging nested objects", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const client = new OptionsClient();
|
||||
client.effectiveOptions = (own) =>
|
||||
mergeProviderOptions({ sandbox: { enabled: true, failIfUnavailable: true } }, own) ?? {};
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
await manager.setAgentOptions(agent.id, {
|
||||
sandbox: { network: { allowedDomains: ["a.test"] } },
|
||||
});
|
||||
|
||||
expect(client.session?.applied).toEqual([
|
||||
{
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
network: { allowedDomains: ["a.test"] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(manager.getAgent(agent.id)?.config.providerOptions).toEqual({
|
||||
sandbox: { network: { allowedDomains: ["a.test"] } },
|
||||
});
|
||||
// The snapshot shows what the provider was given, defaults included, and keeps showing it.
|
||||
await manager.markAgentUnread(agent.id);
|
||||
expect(manager.getAgent(agent.id)?.effectiveOptions).toEqual({
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
network: { allowedDomains: ["a.test"] },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("setAgentOptions sends a removal to the provider like any change, as the default it falls back to", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent({ service_tier: "fast" });
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: null });
|
||||
|
||||
expect(client.session?.applied).toEqual([{ service_tier: "default" }]);
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.config.providerOptions).toBeUndefined();
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default" });
|
||||
});
|
||||
|
||||
test("a removal with no default is sent as null", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
|
||||
|
||||
await manager.setAgentOptions(agentId, { web_search: null });
|
||||
|
||||
expect(client.session?.applied).toEqual([{ web_search: null }]);
|
||||
expect(manager.getAgent(agentId)?.config.providerOptions).toBeUndefined();
|
||||
});
|
||||
|
||||
test("a value the provider kept through a removal is stored", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
|
||||
client.session!.ended = { web_search: true };
|
||||
|
||||
await manager.setAgentOptions(agentId, { web_search: null });
|
||||
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.config.providerOptions).toEqual({ web_search: true });
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
|
||||
});
|
||||
|
||||
test("what the provider ended up with is stored together with the patch", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent();
|
||||
client.session!.ended = { service_tier: "slow" };
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: "fast", plan_mode: true });
|
||||
|
||||
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
|
||||
service_tier: "slow",
|
||||
plan_mode: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("the difference a session opened with is stored before the agent is published", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const client = new OptionsClient();
|
||||
client.openOptionsPatch = { service_tier: "slow", web_search: null };
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
const published: Array<ProviderOptions | undefined> = [];
|
||||
manager.subscribe((event) => {
|
||||
if (event.type === "agent_state") published.push(event.agent.config.providerOptions);
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: true } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
expect(agent.config.providerOptions).toEqual({ service_tier: "slow" });
|
||||
expect(published.length).toBeGreaterThan(0);
|
||||
expect(published.every((options) => options?.service_tier === "slow")).toBe(true);
|
||||
});
|
||||
|
||||
test("a default edited while the session is opening does not change the opening snapshot", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
|
||||
const client = new OptionsClient();
|
||||
let tier = "default";
|
||||
client.effectiveOptions = (own) => ({ service_tier: tier, ...own });
|
||||
const open = client.createSession.bind(client);
|
||||
client.createSession = async (config) => {
|
||||
const session = await open(config);
|
||||
// The user edits the default in config.json after the provider has launched.
|
||||
tier = "fast";
|
||||
return session;
|
||||
};
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
|
||||
const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
expect(agent.effectiveOptions).toEqual({ service_tier: "default" });
|
||||
});
|
||||
|
||||
test("an option the session dropped when opening does not come back from a configured default", async () => {
|
||||
const { manager, agentId } = await createOptionsAgent(undefined, { service_tier: null });
|
||||
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.effectiveOptions).toEqual({});
|
||||
await manager.markAgentUnread(agentId);
|
||||
expect(toAgentPayload(manager.getAgent(agentId)!).options).toEqual({});
|
||||
});
|
||||
|
||||
test("an option without a control is settable through the patch and readable in the snapshot", async () => {
|
||||
const { manager, client, storage, agentId } = await createOptionsAgent();
|
||||
|
||||
await manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
|
||||
|
||||
expect(client.session?.applied).toEqual([{ sandbox_mode: "read-only" }]);
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.config.providerOptions).toEqual({ sandbox_mode: "read-only" });
|
||||
expect(toAgentPayload(agent!).options).toEqual({
|
||||
service_tier: "default",
|
||||
sandbox_mode: "read-only",
|
||||
});
|
||||
await manager.flush();
|
||||
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
|
||||
sandbox_mode: "read-only",
|
||||
});
|
||||
});
|
||||
|
||||
test("concurrent patches both land: each is applied to the options as they are when its provider call resolves", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent();
|
||||
let release!: () => void;
|
||||
client.session!.applyGate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const slow = manager.setAgentOptions(agentId, { sandbox_mode: "read-only" });
|
||||
client.session!.applyGate = null;
|
||||
await manager.setAgentOptions(agentId, { web_search: true });
|
||||
release();
|
||||
await slow;
|
||||
|
||||
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({
|
||||
sandbox_mode: "read-only",
|
||||
web_search: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("a stored agent that is not loaded still reports its effective options", async () => {
|
||||
const { manager, agentId } = await createOptionsAgent({ web_search: true });
|
||||
await manager.closeAgent(agentId);
|
||||
const states: ManagedAgent[] = [];
|
||||
manager.subscribe(
|
||||
(event) => {
|
||||
if (event.type === "agent_state" && event.agent.id === agentId) states.push(event.agent);
|
||||
},
|
||||
{ replayState: false },
|
||||
);
|
||||
|
||||
await manager.markAgentUnread(agentId);
|
||||
|
||||
expect(states.at(-1)?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
|
||||
});
|
||||
|
||||
test("editing a provider default does not change the options of a loaded agent", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent({ web_search: true });
|
||||
expect(manager.getAgent(agentId)?.effectiveOptions).toEqual({
|
||||
service_tier: "default",
|
||||
web_search: true,
|
||||
});
|
||||
|
||||
// The user edits the default in config.json while the agent is running.
|
||||
client.effectiveOptions = (own) => ({ service_tier: "fast", ...own });
|
||||
await manager.markAgentUnread(agentId);
|
||||
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", web_search: true });
|
||||
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", web_search: true });
|
||||
});
|
||||
|
||||
test("a provider that rejects an option leaves the stored options unchanged", async () => {
|
||||
const { manager, client, agentId } = await createOptionsAgent({ web_search: false });
|
||||
client.session!.rejectWith = new Error("fast mode unavailable");
|
||||
|
||||
await expect(manager.setAgentOptions(agentId, { web_search: true })).rejects.toThrow(
|
||||
"fast mode unavailable",
|
||||
);
|
||||
|
||||
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({ web_search: false });
|
||||
});
|
||||
|
||||
test("a provider whose session cannot apply options rejects a patch and stores nothing", async () => {
|
||||
// Like a provider with no live option support: the session has no `applyOptions`.
|
||||
class NoOptionsSession extends TestAgentSession {
|
||||
constructor(config: AgentSessionConfig) {
|
||||
super(config);
|
||||
Object.defineProperty(this, "applyOptions", { value: undefined });
|
||||
}
|
||||
}
|
||||
class NoOptionsClient extends TestAgentClient {
|
||||
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
|
||||
return new NoOptionsSession(config);
|
||||
}
|
||||
}
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-no-options-"));
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new NoOptionsClient() },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
});
|
||||
const agent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
await expect(manager.setAgentOptions(agent.id, { web_search: true })).rejects.toThrow(
|
||||
/web_search.*cannot change on a running session/,
|
||||
);
|
||||
|
||||
expect(manager.getAgent(agent.id)?.config.providerOptions).toEqual({ web_search: false });
|
||||
});
|
||||
|
||||
test("agent.create.before hook featureValues fold into providerOptions", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new OptionsClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
pluginLifecycle: {
|
||||
emit: () => {},
|
||||
before: async (_name, request) => ({
|
||||
...request,
|
||||
config: { ...request.config, featureValues: { web_search: true } },
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
|
||||
workspaceId: undefined,
|
||||
});
|
||||
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ web_search: true });
|
||||
expect(manager.getAgent(created.id)?.config).not.toHaveProperty("featureValues");
|
||||
});
|
||||
|
||||
test("agent.create.before hook featureValues edit overrides an option the create already set", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new OptionsClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
pluginLifecycle: {
|
||||
emit: () => {},
|
||||
// A plugin written against the previous SDK: flips one value, leaves the rest.
|
||||
before: async (name, request) =>
|
||||
name === "agent.create.before"
|
||||
? {
|
||||
...request,
|
||||
config: {
|
||||
...request.config,
|
||||
featureValues: { ...request.config.featureValues, web_search: true },
|
||||
},
|
||||
}
|
||||
: request,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false, plan_mode: true } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
|
||||
web_search: true,
|
||||
plan_mode: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("agent.create.before hook leaving featureValues untouched keeps providerOptions edits", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const client = new OptionsClient();
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: client },
|
||||
registry: storage,
|
||||
logger,
|
||||
pluginLifecycle: {
|
||||
emit: () => {},
|
||||
before: async (name, request) =>
|
||||
name === "agent.create.before"
|
||||
? {
|
||||
...request,
|
||||
config: {
|
||||
...request.config,
|
||||
providerOptions: { ...request.config.providerOptions, plan_mode: false },
|
||||
},
|
||||
}
|
||||
: request,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: false, plan_mode: true } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
|
||||
web_search: false,
|
||||
plan_mode: false,
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
{
|
||||
name: "deletes a key",
|
||||
edit: (values: Record<string, unknown> | undefined) => {
|
||||
const { web_search: _removed, ...rest } = values ?? {};
|
||||
return { featureValues: rest };
|
||||
},
|
||||
expected: { plan_mode: true },
|
||||
},
|
||||
{
|
||||
name: "empties featureValues",
|
||||
edit: () => ({ featureValues: {} }),
|
||||
expected: {},
|
||||
},
|
||||
{
|
||||
name: "drops featureValues",
|
||||
edit: () => ({ featureValues: undefined }),
|
||||
expected: {},
|
||||
},
|
||||
])("agent.create.before hook that $name", ({ edit, expected }) => {
|
||||
test("removes those options from the new agent", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new OptionsClient() },
|
||||
registry: new AgentStorage(join(workdir, "agents"), logger),
|
||||
logger,
|
||||
pluginLifecycle: {
|
||||
emit: () => {},
|
||||
before: async (name, request) =>
|
||||
name === "agent.create.before"
|
||||
? {
|
||||
...request,
|
||||
config: { ...request.config, ...edit(request.config.featureValues) },
|
||||
}
|
||||
: request,
|
||||
},
|
||||
});
|
||||
|
||||
const created = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, providerOptions: { web_search: true, plan_mode: true } },
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
|
||||
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
|
||||
import { ProviderOptionsValidationError } from "./provider-options.js";
|
||||
import type { AgentToolResult } from "@getpaseo/plugin/server";
|
||||
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
|
||||
import { ensureAgentLoaded } from "./agent-loading.js";
|
||||
@@ -34,7 +33,7 @@ import {
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentResumePurpose,
|
||||
type AgentResumeSessionOptions,
|
||||
type AgentOptionControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentSlashCommand,
|
||||
type AgentMode,
|
||||
@@ -60,16 +59,7 @@ import {
|
||||
type ListImportableSessionsOptions,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import {
|
||||
applyProviderOptionsPatch,
|
||||
exposeFeatureValuesToHook,
|
||||
convertLegacyOptions,
|
||||
resolveAdapter,
|
||||
foldHookFeatureValues,
|
||||
type ProviderOptionsPatch,
|
||||
} from "./option-controls.js";
|
||||
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
|
||||
import type { AgentOwner } from "./agent-owner.js";
|
||||
import {
|
||||
@@ -181,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;
|
||||
@@ -237,6 +210,9 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
|
||||
if (record.config.thinkingOptionId != null) {
|
||||
config.thinkingOptionId = record.config.thinkingOptionId;
|
||||
}
|
||||
if (record.config.featureValues != null) {
|
||||
config.featureValues = record.config.featureValues;
|
||||
}
|
||||
if (record.config.providerOptions != null) {
|
||||
config.providerOptions = record.config.providerOptions;
|
||||
}
|
||||
@@ -348,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 {
|
||||
@@ -413,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 };
|
||||
@@ -459,9 +428,7 @@ interface ManagedAgentBase {
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
availableModes: AgentMode[];
|
||||
controls?: AgentOptionControl[];
|
||||
/** `config.json` defaults deep-merged with `config.providerOptions`; refreshed on every state emit. */
|
||||
effectiveOptions?: ProviderOptions;
|
||||
features?: AgentFeature[];
|
||||
currentModeId: string | null;
|
||||
pendingPermissions: Map<string, AgentPermissionRequest>;
|
||||
bufferedPermissionResolutions: Map<
|
||||
@@ -485,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" }).
|
||||
*/
|
||||
@@ -809,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>();
|
||||
@@ -1282,17 +1243,11 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
async listDraftControls(
|
||||
config: AgentSessionConfig,
|
||||
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }> {
|
||||
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
|
||||
const client = this.requireClient(normalizedConfig.provider);
|
||||
const options = this.effectiveOptionsFor(
|
||||
normalizedConfig.provider,
|
||||
normalizedConfig.providerOptions,
|
||||
);
|
||||
if (!normalizedConfig.model && !client.listControls) {
|
||||
return { controls: [], options };
|
||||
if (!normalizedConfig.model && !client.listFeatures) {
|
||||
return [];
|
||||
}
|
||||
const available = await client.isAvailable();
|
||||
if (!available) {
|
||||
@@ -1301,18 +1256,13 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
if (client.listControls) {
|
||||
return { controls: await client.listControls(normalizedConfig), options };
|
||||
if (client.listFeatures) {
|
||||
return await client.listFeatures(normalizedConfig);
|
||||
}
|
||||
|
||||
const session = await client.createSession(normalizedConfig);
|
||||
try {
|
||||
// The options a session opens with, as create would report them, so the controls'
|
||||
// selection matches what the first turn runs with.
|
||||
return {
|
||||
controls: session.controls ?? [],
|
||||
options: this.openedEffectiveOptions({ session, launchedOptions: options }),
|
||||
};
|
||||
return session.features ?? [];
|
||||
} finally {
|
||||
try {
|
||||
await session.close();
|
||||
@@ -1410,23 +1360,12 @@ export class AgentManager {
|
||||
): Promise<ManagedAgent> {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
|
||||
config = this.withConvertedOptions(config);
|
||||
if (this.pluginLifecycle && !config.internal) {
|
||||
const sent = exposeFeatureValuesToHook(config);
|
||||
const request = await this.pluginLifecycle.before("agent.create.before", {
|
||||
config: sent,
|
||||
config,
|
||||
env: options.env,
|
||||
});
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
|
||||
// against the previous SDK edits `config.featureValues`.
|
||||
config = {
|
||||
...foldHookFeatureValues({
|
||||
returned: request.config,
|
||||
sent,
|
||||
convert: (values) => this.convertLegacyOptions(request.config.provider, values),
|
||||
}),
|
||||
internal: config.internal,
|
||||
};
|
||||
config = { ...request.config, internal: config.internal };
|
||||
options = { ...options, env: request.env };
|
||||
}
|
||||
await this.deleteAgentState(resolvedAgentId);
|
||||
@@ -1456,16 +1395,13 @@ export class AgentManager {
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
const createOptions = this.buildCreateSessionOptions(options);
|
||||
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
||||
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
launchedOptions,
|
||||
labels: options.labels,
|
||||
initialTitle: options.initialTitle,
|
||||
workspaceId: options.workspaceId,
|
||||
owner: options.owner,
|
||||
background: options.background,
|
||||
launchEnv: options.env ? { ...options.env } : undefined,
|
||||
historyPrimed: true,
|
||||
});
|
||||
@@ -1498,7 +1434,6 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1531,7 +1466,6 @@ export class AgentManager {
|
||||
labels?: Record<string, string>;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
attention?: AttentionState;
|
||||
},
|
||||
resumeOptions?: AgentResumeSessionOptions,
|
||||
@@ -1552,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(
|
||||
@@ -1584,17 +1520,14 @@ export class AgentManager {
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
||||
const session = await client.resumeSession(
|
||||
handle,
|
||||
providerLaunchConfig,
|
||||
launchContext,
|
||||
currentResumeOptions,
|
||||
);
|
||||
if (purpose === "history") this.historySessions.add(session);
|
||||
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
|
||||
return this.registerSession(session, storedConfig, resolvedAgentId, {
|
||||
launchedOptions,
|
||||
...options,
|
||||
launchEnv,
|
||||
persistence: handle,
|
||||
@@ -1650,7 +1583,6 @@ export class AgentManager {
|
||||
},
|
||||
);
|
||||
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
|
||||
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
||||
const imported = await client.importSession(
|
||||
{
|
||||
providerHandleId: input.providerHandleId,
|
||||
@@ -1669,7 +1601,6 @@ export class AgentManager {
|
||||
|
||||
handedToRegistration = true;
|
||||
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
|
||||
launchedOptions,
|
||||
labels: input.labels,
|
||||
workspaceId: input.workspaceId,
|
||||
timelineRows,
|
||||
@@ -1775,7 +1706,6 @@ export class AgentManager {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
|
||||
this.paseoToolPolicies.set(agentId, paseoToolPolicy);
|
||||
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
|
||||
session = handle
|
||||
? await client.resumeSession(handle, providerLaunchConfig, launchContext)
|
||||
: await client.createSession(providerLaunchConfig, launchContext);
|
||||
@@ -1794,11 +1724,9 @@ export class AgentManager {
|
||||
// Preserve existing labels and timeline during reload.
|
||||
handedToRegistration = true;
|
||||
return this.registerSession(session, storedConfig, agentId, {
|
||||
launchedOptions,
|
||||
labels: existing.labels,
|
||||
workspaceId: existing.workspaceId,
|
||||
owner: existing.owner,
|
||||
background: existing.background,
|
||||
launchEnv: existing.launchEnv,
|
||||
createdAt: existing.createdAt,
|
||||
updatedAt: existing.updatedAt,
|
||||
@@ -2121,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;
|
||||
@@ -2234,11 +2162,7 @@ export class AgentManager {
|
||||
createdAt: new Date(record.createdAt),
|
||||
updatedAt,
|
||||
availableModes: [],
|
||||
controls: record.controls,
|
||||
effectiveOptions: this.effectiveOptionsFor(
|
||||
record.provider,
|
||||
record.config?.providerOptions ?? undefined,
|
||||
),
|
||||
features: record.features,
|
||||
currentModeId: record.lastModeId ?? null,
|
||||
pendingPermissions: new Map(),
|
||||
bufferedPermissionResolutions: new Map(),
|
||||
@@ -2257,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;
|
||||
@@ -2280,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;
|
||||
|
||||
@@ -2302,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
|
||||
@@ -2332,109 +2255,20 @@ export class AgentManager {
|
||||
return notice;
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an options patch to a running agent (see `AgentSession.applyOptions`). The provider
|
||||
* gets each key the patch touches, resolved against the `config.json` defaults, with `null`
|
||||
* where nothing is set. The patch and whatever the provider says it ended up with are stored in
|
||||
* one step once the provider accepts, over the options as they are at that moment, so concurrent
|
||||
* writes to one agent resolve by arrival order.
|
||||
*/
|
||||
async setAgentOptions(agentId: string, requested: ProviderOptionsPatch): Promise<void> {
|
||||
const agent = await this.ensureInteractiveSession(agentId);
|
||||
const patch = this.convertLegacyOptions(agent.provider, requested);
|
||||
let ended: ProviderOptionsPatch = {};
|
||||
let sent: ProviderOptionsPatch = {};
|
||||
if (Object.keys(patch).length > 0) {
|
||||
// A provider that cannot apply options to a running agent must not store them either.
|
||||
if (!agent.session.applyOptions) {
|
||||
throw new ProviderOptionsValidationError(
|
||||
agent.provider,
|
||||
Object.keys(patch).map((key) => ({
|
||||
path: [key],
|
||||
message: "cannot change on a running session; it applies when the agent is opened",
|
||||
})),
|
||||
);
|
||||
}
|
||||
const set = Object.fromEntries(Object.entries(patch).filter(([, value]) => value != null));
|
||||
const resolved = this.effectiveOptionsFor(agent.provider, set);
|
||||
sent = Object.fromEntries(Object.keys(patch).map((key) => [key, resolved[key] ?? null]));
|
||||
ended = (await agent.session.applyOptions(sent)) ?? {};
|
||||
// Changes the provider made on its own while applying are older than its final answer.
|
||||
await this.drainSessionEvents(agentId);
|
||||
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
|
||||
if (!agent.session.setFeature) {
|
||||
throw new Error("Agent session does not support setting features");
|
||||
}
|
||||
this.storeOptionsPatch({ agent, patch: { ...patch, ...ended } });
|
||||
this.updateEffectiveOptions({ agent, sent, reported: ended });
|
||||
|
||||
await agent.session.setFeature(featureId, value);
|
||||
await this.drainSessionEvents(agentId);
|
||||
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
|
||||
this.touchUpdatedAt(agent);
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
/** The config as the opened session left it: its own difference from the options it was given. */
|
||||
private withOpenedOptions(input: {
|
||||
config: AgentSessionConfig;
|
||||
session: AgentSession;
|
||||
}): AgentSessionConfig {
|
||||
const { config } = input;
|
||||
const opened = input.session.openOptionsPatch;
|
||||
if (!opened || Object.keys(opened).length === 0) return config;
|
||||
const own = applyProviderOptionsPatch({ options: config.providerOptions, patch: opened });
|
||||
return { ...config, providerOptions: Object.keys(own).length > 0 ? own : undefined };
|
||||
}
|
||||
|
||||
private storeOptionsPatch(input: { agent: ManagedAgent; patch: ProviderOptionsPatch }): void {
|
||||
const { agent } = input;
|
||||
const own = applyProviderOptionsPatch({
|
||||
options: agent.config.providerOptions,
|
||||
patch: input.patch,
|
||||
});
|
||||
agent.config.providerOptions = Object.keys(own).length > 0 ? own : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the running session has: the options it opened with, moved only by what an apply sent
|
||||
* it (defaults merged in, `null` where a removal left nothing) and by what the provider reports
|
||||
* back. The agent's own stored options keep only the raw patch.
|
||||
*/
|
||||
private updateEffectiveOptions(input: {
|
||||
agent: ManagedAgent;
|
||||
sent: ProviderOptionsPatch;
|
||||
reported: ProviderOptionsPatch;
|
||||
}): void {
|
||||
input.agent.effectiveOptions = applyProviderOptionsPatch({
|
||||
options: input.agent.effectiveOptions,
|
||||
patch: { ...input.sent, ...input.reported },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Defaults merge into what the session was given; what it reports it ended up with comes
|
||||
* after, so an option it dropped does not come back from a default.
|
||||
*/
|
||||
private openedEffectiveOptions(input: {
|
||||
session: AgentSession;
|
||||
launchedOptions: ProviderOptions;
|
||||
}): ProviderOptions {
|
||||
return applyProviderOptionsPatch({
|
||||
options: input.launchedOptions,
|
||||
patch: input.session.openOptionsPatch ?? {},
|
||||
});
|
||||
}
|
||||
|
||||
/** The options a client opens with: its own defaults merged under the agent's. */
|
||||
private launchOptionsFor(input: {
|
||||
client: AgentClient;
|
||||
own: ProviderOptions | undefined;
|
||||
}): ProviderOptions {
|
||||
return input.client.effectiveOptions?.(input.own) ?? input.own ?? {};
|
||||
}
|
||||
|
||||
private effectiveOptionsFor(
|
||||
provider: AgentProvider,
|
||||
own: ProviderOptions | undefined,
|
||||
): ProviderOptions {
|
||||
const client = this.clients.get(provider);
|
||||
return client ? this.launchOptionsFor({ client, own }) : (own ?? {});
|
||||
}
|
||||
|
||||
async setTitle(agentId: string, title: string): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
const normalizedTitle = title.trim();
|
||||
@@ -2680,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 &&
|
||||
@@ -2804,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;
|
||||
@@ -2933,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,
|
||||
@@ -2983,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,
|
||||
@@ -3671,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(
|
||||
@@ -3984,9 +3782,9 @@ export class AgentManager {
|
||||
|
||||
private async registerSession(
|
||||
session: AgentSession,
|
||||
openedConfig: AgentSessionConfig,
|
||||
config: AgentSessionConfig,
|
||||
agentId: string,
|
||||
options: {
|
||||
options?: {
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
lastUserMessageAt?: Date | null;
|
||||
@@ -4004,25 +3802,17 @@ export class AgentManager {
|
||||
* attention come from what was already recorded, and installing the session is not
|
||||
* activity in it.
|
||||
*/
|
||||
/**
|
||||
* The effective options the opening resolved before the session started. Registration
|
||||
* starts from these rather than reading the defaults again, which may have been edited
|
||||
* while the provider was opening.
|
||||
*/
|
||||
launchedOptions: ProviderOptions;
|
||||
restoring?: boolean;
|
||||
initialTitle?: string | null;
|
||||
publishWhenReady?: boolean;
|
||||
workspaceId?: string;
|
||||
owner?: AgentOwner;
|
||||
background?: boolean;
|
||||
launchEnv?: Record<string, string>;
|
||||
},
|
||||
): Promise<ManagedAgent> {
|
||||
let registered = false;
|
||||
try {
|
||||
this.assertAcceptingAgentRegistrations();
|
||||
const config = this.withOpenedOptions({ config: openedConfig, session });
|
||||
const resolvedAgentId = validateAgentId(agentId, "registerSession");
|
||||
if (this.agents.has(resolvedAgentId)) {
|
||||
throw new Error(`Agent with id ${resolvedAgentId} already exists`);
|
||||
@@ -4044,13 +3834,8 @@ export class AgentManager {
|
||||
resolvedAgentId,
|
||||
session,
|
||||
config,
|
||||
effectiveOptions: this.openedEffectiveOptions({
|
||||
session,
|
||||
launchedOptions: options.launchedOptions,
|
||||
}),
|
||||
now,
|
||||
durableTimelineHasRows,
|
||||
background: options?.background === true,
|
||||
options,
|
||||
});
|
||||
|
||||
@@ -4193,10 +3978,8 @@ export class AgentManager {
|
||||
resolvedAgentId: string;
|
||||
session: AgentSession;
|
||||
config: AgentSessionConfig;
|
||||
effectiveOptions: ProviderOptions;
|
||||
now: Date;
|
||||
durableTimelineHasRows: boolean;
|
||||
background: boolean;
|
||||
options:
|
||||
| {
|
||||
createdAt?: Date;
|
||||
@@ -4214,15 +3997,7 @@ export class AgentManager {
|
||||
}
|
||||
| undefined;
|
||||
}): ActiveManagedAgent {
|
||||
const {
|
||||
resolvedAgentId,
|
||||
session,
|
||||
config,
|
||||
effectiveOptions,
|
||||
now,
|
||||
durableTimelineHasRows,
|
||||
options,
|
||||
} = params;
|
||||
const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params;
|
||||
return {
|
||||
id: resolvedAgentId,
|
||||
provider: config.provider,
|
||||
@@ -4233,8 +4008,6 @@ export class AgentManager {
|
||||
session,
|
||||
capabilities: session.capabilities,
|
||||
config,
|
||||
// What the session opened with. Provider defaults edited later reach it when it next opens.
|
||||
effectiveOptions,
|
||||
runtimeInfo: undefined,
|
||||
lifecycle: "initializing",
|
||||
createdAt: options?.createdAt ?? now,
|
||||
@@ -4261,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;
|
||||
}
|
||||
@@ -4541,7 +4313,7 @@ export class AgentManager {
|
||||
agent.pendingPermissions.clear();
|
||||
}
|
||||
|
||||
this.syncControls(agent);
|
||||
this.syncFeaturesFromSession(agent);
|
||||
await this.refreshRuntimeInfo(agent, options);
|
||||
}
|
||||
|
||||
@@ -4887,18 +4659,6 @@ export class AgentManager {
|
||||
);
|
||||
}
|
||||
|
||||
private onStreamOptionsChanged(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
patch: ProviderOptionsPatch;
|
||||
flags: StreamEventFlags;
|
||||
}): void {
|
||||
const { agent, patch, flags } = params;
|
||||
this.storeOptionsPatch({ agent, patch });
|
||||
this.updateEffectiveOptions({ agent, sent: {}, reported: patch });
|
||||
flags.shouldDispatchEvent = false;
|
||||
this.emitState(agent);
|
||||
}
|
||||
|
||||
private dispatchStreamEventByType(params: {
|
||||
agent: ActiveManagedAgent;
|
||||
event: AgentStreamEvent;
|
||||
@@ -4950,9 +4710,6 @@ export class AgentManager {
|
||||
flags.shouldDispatchEvent = false;
|
||||
this.emitState(agent);
|
||||
return undefined;
|
||||
case "options_changed":
|
||||
this.onStreamOptionsChanged({ agent, patch: event.patch, flags });
|
||||
return undefined;
|
||||
case "timeline":
|
||||
return this.onStreamTimelineEvent({ agent, event, options, flags });
|
||||
case "turn_completed":
|
||||
@@ -5210,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);
|
||||
@@ -5430,7 +5187,7 @@ export class AgentManager {
|
||||
this.enqueueBackgroundPersist(agent);
|
||||
}
|
||||
|
||||
this.syncControls(agent);
|
||||
this.syncFeaturesFromSession(agent);
|
||||
|
||||
this.logger.trace(
|
||||
{
|
||||
@@ -5452,9 +5209,9 @@ export class AgentManager {
|
||||
});
|
||||
}
|
||||
|
||||
private syncControls(agent: ManagedAgent): void {
|
||||
if ("session" in agent && agent.session?.controls) {
|
||||
agent.controls = agent.session.controls;
|
||||
private syncFeaturesFromSession(agent: ManagedAgent): void {
|
||||
if ("session" in agent && agent.session?.features) {
|
||||
agent.features = agent.session.features;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5465,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;
|
||||
}
|
||||
|
||||
@@ -5700,7 +5458,7 @@ export class AgentManager {
|
||||
config: AgentSessionConfig,
|
||||
options: NormalizeConfigOptions = {},
|
||||
): Promise<AgentSessionConfig> {
|
||||
const normalized: AgentSessionConfig = { ...this.withConvertedOptions(config) };
|
||||
const normalized: AgentSessionConfig = { ...config };
|
||||
|
||||
// Always resolve cwd to absolute path for consistent history file lookup
|
||||
if (normalized.cwd) {
|
||||
@@ -5772,30 +5530,6 @@ export class AgentManager {
|
||||
}
|
||||
}
|
||||
|
||||
/** The adapter a provider id runs on: a custom provider is named by the one it extends. */
|
||||
private adapterOf(provider: AgentProvider): AgentProvider {
|
||||
return resolveAdapter({
|
||||
provider,
|
||||
extendsOf: (id) => this.providerDefinitions.get(id)?.derivedFromProviderId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Options older data carries are converted for the adapter the provider runs on, before
|
||||
* defaults merge and before anything is stored; nothing past the manager sees the old keys.
|
||||
*/
|
||||
private convertLegacyOptions<T extends ProviderOptions | undefined>(
|
||||
provider: AgentProvider,
|
||||
options: T,
|
||||
): T {
|
||||
return convertLegacyOptions({ provider: this.adapterOf(provider), options }) as T;
|
||||
}
|
||||
|
||||
private withConvertedOptions(config: AgentSessionConfig): AgentSessionConfig {
|
||||
const providerOptions = this.convertLegacyOptions(config.provider, config.providerOptions);
|
||||
return providerOptions === config.providerOptions ? config : { ...config, providerOptions };
|
||||
}
|
||||
|
||||
private async prepareSessionConfig(
|
||||
config: AgentSessionConfig,
|
||||
agentId: string,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "./agent-projections.js";
|
||||
import type { AgentSession } from "./agent-sdk-types.js";
|
||||
import type {
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
ImportableProviderSession,
|
||||
AgentPermissionRequest,
|
||||
AgentPersistenceHandle,
|
||||
@@ -128,6 +128,16 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
|
||||
return { ...base, ...overrides };
|
||||
}
|
||||
|
||||
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("toStoredAgentRecord", () => {
|
||||
it("captures lifecycle metadata, config, and persistence", () => {
|
||||
const agent = createManagedAgent({
|
||||
@@ -464,44 +474,13 @@ describe("toAgentPayload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("projects controls, effective options, and legacy features from the same source", () => {
|
||||
const controls: AgentOptionControl[] = [
|
||||
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
];
|
||||
// `config.json` supplies fast_mode; the agent itself sets nothing for service_tier.
|
||||
// Options without a control are part of the snapshot too.
|
||||
const agent = createManagedAgent({
|
||||
controls,
|
||||
effectiveOptions: { fast_mode: true, sandbox_mode: "workspace-write" },
|
||||
});
|
||||
it("includes features in the snapshot payload", () => {
|
||||
const features = [createFeature()];
|
||||
const agent = createManagedAgent({ features });
|
||||
|
||||
const payload = toAgentPayload(agent);
|
||||
|
||||
expect(payload.controls).toEqual(controls);
|
||||
expect(payload.options).toEqual({ fast_mode: true, sandbox_mode: "workspace-write" });
|
||||
expect(payload.features).toEqual([
|
||||
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
|
||||
{
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
value: "default",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
expect(payload.features).toEqual(features);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
|
||||
import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
AgentMetadata,
|
||||
AgentMode,
|
||||
AgentPermissionRequest,
|
||||
@@ -18,9 +18,7 @@ import type {
|
||||
AgentUsage,
|
||||
ImportableProviderSession,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import { featuresFromControls } from "./option-controls.js";
|
||||
import type { JsonValue } from "../json-utils.js";
|
||||
import {
|
||||
isStoredAgentProviderAvailable,
|
||||
@@ -99,7 +97,7 @@ export function toStoredAgentRecord(
|
||||
config: config ?? null,
|
||||
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
|
||||
runtimeInfo,
|
||||
controls: normalizeControls(agent.controls),
|
||||
features: normalizeFeatures(agent.features),
|
||||
persistence,
|
||||
lastError: agent.lastError ?? undefined,
|
||||
requiresAttention: agent.attention.requiresAttention,
|
||||
@@ -108,7 +106,6 @@ export function toStoredAgentRecord(
|
||||
? agent.attention.attentionTimestamp.toISOString()
|
||||
: null,
|
||||
internal: options?.internal,
|
||||
...(agent.background ? { background: true } : {}),
|
||||
owner: agent.owner,
|
||||
} satisfies StoredAgentRecord;
|
||||
}
|
||||
@@ -146,18 +143,13 @@ export function toAgentPayload(
|
||||
capabilities: cloneCapabilities(agent.capabilities),
|
||||
currentModeId: agent.currentModeId,
|
||||
availableModes: cloneAvailableModes(agent.availableModes),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
features: featuresFromControls({ controls: agent.controls, options: agent.effectiveOptions }),
|
||||
...(agent.effectiveOptions
|
||||
? { options: sanitizeEffectiveOptions(agent.effectiveOptions) }
|
||||
: {}),
|
||||
controls: normalizeControls(agent.controls),
|
||||
features: normalizeFeatures(agent.features),
|
||||
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
|
||||
persistence: projectPersistenceHandleForWire(agent.persistence),
|
||||
title: options?.title ?? null,
|
||||
labels: agent.labels,
|
||||
archivedAt: options?.archivedAt ?? null,
|
||||
...visibilityFlags(agent),
|
||||
...(agent.internal ? { internal: true } : {}),
|
||||
};
|
||||
|
||||
const usage = sanitizeUsage(agent.lastUsage);
|
||||
@@ -214,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>,
|
||||
@@ -277,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 }),
|
||||
@@ -394,6 +375,12 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
|
||||
if (config.thinkingOptionId) {
|
||||
serializable.thinkingOptionId = config.thinkingOptionId;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(config, "featureValues")) {
|
||||
const featureValues = sanitizeMetadata(config.featureValues);
|
||||
if (featureValues !== undefined) {
|
||||
serializable.featureValues = featureValues;
|
||||
}
|
||||
}
|
||||
if (config.providerOptions !== undefined) {
|
||||
const providerOptions = sanitizeOptionalJson(config.providerOptions);
|
||||
if (providerOptions && isJsonObject(providerOptions)) {
|
||||
@@ -469,15 +456,8 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
|
||||
return modes.map((mode) => ({ ...mode }));
|
||||
}
|
||||
|
||||
function normalizeControls(
|
||||
controls: AgentOptionControl[] | null | undefined,
|
||||
): AgentOptionControl[] {
|
||||
return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : [];
|
||||
}
|
||||
|
||||
function sanitizeEffectiveOptions(options: ProviderOptions): ProviderOptions {
|
||||
const sanitized = sanitizeOptionalJson(options);
|
||||
return sanitized && isJsonObject(sanitized) ? sanitized : {};
|
||||
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
|
||||
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
|
||||
}
|
||||
|
||||
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
AgentOptionToggleControl,
|
||||
AgentFeature,
|
||||
AgentFeatureSelect,
|
||||
AgentFeatureToggle,
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
JsonValue,
|
||||
@@ -13,9 +13,9 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
|
||||
export type {
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
AgentOptionToggleControl,
|
||||
AgentFeature,
|
||||
AgentFeatureSelect,
|
||||
AgentFeatureToggle,
|
||||
AgentProviderNotice,
|
||||
AgentTaskItem,
|
||||
};
|
||||
@@ -148,7 +148,7 @@ export interface AgentCreateConfigParent {
|
||||
export interface ResolveAgentCreateConfigInput {
|
||||
provider: AgentProvider;
|
||||
requestedMode: string | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
parent: AgentCreateConfigParent | null;
|
||||
unattended: boolean;
|
||||
availableModes: AgentMode[] | undefined;
|
||||
@@ -156,13 +156,13 @@ export interface ResolveAgentCreateConfigInput {
|
||||
|
||||
export interface ResolveAgentCreateConfigResult {
|
||||
modeId: string | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
export interface AgentCreateConfigUnattendedInput {
|
||||
modeId: string | null;
|
||||
config: AgentSessionConfig;
|
||||
controls?: AgentOptionControl[];
|
||||
features?: AgentFeature[];
|
||||
availableModes: AgentMode[];
|
||||
}
|
||||
|
||||
@@ -429,14 +429,6 @@ export type AgentStreamEvent =
|
||||
provider: AgentProvider;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
/**
|
||||
* The provider changed options on its own: after `createSession`/`resumeSession` or
|
||||
* `applyOptions` it ended up different from what it was given, or later it changed a setting
|
||||
* itself. The daemon stores the patch and forwards it; it does not undo it. `null` removes the
|
||||
* agent's own value. Only for changes that happen on the provider's own initiative: the result
|
||||
* of opening goes in `openOptionsPatch` and the result of `applyOptions` is its return value.
|
||||
*/
|
||||
| { type: "options_changed"; provider: AgentProvider; patch: Record<string, unknown> }
|
||||
| {
|
||||
type: "turn_failed";
|
||||
provider: AgentProvider;
|
||||
@@ -618,6 +610,7 @@ export interface AgentSessionConfig {
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
featureValues?: Record<string, unknown>;
|
||||
title?: string | null;
|
||||
providerOptions?: ProviderOptions;
|
||||
toolPolicy?: ToolPolicy;
|
||||
@@ -686,14 +679,7 @@ export interface AgentSession {
|
||||
readonly provider: AgentProvider;
|
||||
readonly id: string | null;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
/** Composer controls over top-level option keys. Values come from the agent's effective options. */
|
||||
readonly controls?: AgentOptionControl[];
|
||||
/**
|
||||
* How the session ended up different from the options it was opened with, as a patch (`null`
|
||||
* removes a key). Set by the time `createSession`/`resumeSession` resolves; the daemon stores it
|
||||
* before the agent is published.
|
||||
*/
|
||||
readonly openOptionsPatch?: Record<string, unknown>;
|
||||
readonly features?: AgentFeature[];
|
||||
/** New provider-owned rows to commit on registration. streamHistory must also
|
||||
* replay them at their original timestamps; restored sessions omit old rows. */
|
||||
readonly initialTimeline?: ImportedTimelineEntry[];
|
||||
@@ -728,20 +714,7 @@ export interface AgentSession {
|
||||
listCommands?(): Promise<AgentSlashCommand[]>;
|
||||
setModel?(modelId: string | null): Promise<void>;
|
||||
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
|
||||
/**
|
||||
* Apply an options patch. `options` holds only the keys the patch touches, resolved against
|
||||
* the `config.json` defaults; `null` means nothing is set for the key, so return it to the
|
||||
* session's own default if that can be done. Leave every other setting alone. Throw to reject a
|
||||
* value, or a key the running session cannot apply (one the provider reads only when it
|
||||
* opens): the daemon stores the patch only when this resolves, so a stored option always
|
||||
* describes what runs, and the call is all-or-nothing.
|
||||
* Emit nothing from here. Return the options the session ended up with when they differ from
|
||||
* the request (a patch, `null` removes a key), for example a value it kept because it could not
|
||||
* be reverted; the daemon stores it together with the patch. When a throw leaves the session's
|
||||
* state moved anyway, report the moved values once as `options_changed` after the call has
|
||||
* ended, so the stored options still match what runs.
|
||||
*/
|
||||
applyOptions?(options: ProviderOptions): Promise<Record<string, unknown> | void>;
|
||||
setFeature?(featureId: string, value: unknown): Promise<void>;
|
||||
revertConversation?(input: { messageId: string }): Promise<void>;
|
||||
revertFiles?(input: { messageId: string }): Promise<void>;
|
||||
revertBoth?(input: { messageId: string }): Promise<void>;
|
||||
@@ -791,17 +764,11 @@ export interface ResolveAgentDefaultModeInput {
|
||||
export interface AgentClient {
|
||||
readonly provider: AgentProvider;
|
||||
readonly capabilities: AgentCapabilityFlags;
|
||||
/**
|
||||
* Create the agent with `config.providerOptions` (defaults already merged in). Emit nothing
|
||||
* about options while creating. If the agent ended up different from the options, say so in the
|
||||
* session's `openOptionsPatch`.
|
||||
*/
|
||||
createSession(
|
||||
config: AgentSessionConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
options?: AgentCreateSessionOptions,
|
||||
): Promise<AgentSession>;
|
||||
/** Same contract as `createSession`: open with the options, report any difference in `openOptionsPatch`. */
|
||||
resumeSession(
|
||||
handle: AgentPersistenceHandle,
|
||||
overrides?: Partial<AgentSessionConfig>,
|
||||
@@ -830,9 +797,7 @@ export interface AgentClient {
|
||||
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
|
||||
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
|
||||
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
|
||||
listControls?(config: AgentSessionConfig): Promise<AgentOptionControl[]>;
|
||||
/** Effective options: provider defaults from `config.json` deep-merged with the agent's own. */
|
||||
effectiveOptions?(own: ProviderOptions | undefined): ProviderOptions;
|
||||
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
|
||||
listImportableSessions?(
|
||||
options?: ListImportableSessionsOptions,
|
||||
): Promise<ImportableProviderSession[]>;
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { describe, expect, test, beforeEach, afterEach } from "vitest";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { existsSync, mkdtempSync, readdirSync, rmSync } from "node:fs";
|
||||
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";
|
||||
@@ -46,6 +44,9 @@ function buildManagedAgentConfig(
|
||||
systemPrompt: configOverrides.systemPrompt,
|
||||
mcpServers: configOverrides.mcpServers,
|
||||
};
|
||||
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
|
||||
config.featureValues = configOverrides.featureValues;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
@@ -131,14 +132,6 @@ function createManagedAgent(overrides: ManagedAgentOverrides = {}): ManagedAgent
|
||||
};
|
||||
}
|
||||
|
||||
function findRecordFile(baseDir: string, agentId: string): string {
|
||||
for (const entry of readdirSync(baseDir, { withFileTypes: true })) {
|
||||
const candidate = path.join(baseDir, entry.name, `${agentId}.json`);
|
||||
if (entry.isDirectory() && existsSync(candidate)) return candidate;
|
||||
}
|
||||
throw new Error(`No stored record for ${agentId}`);
|
||||
}
|
||||
|
||||
describe("AgentStorage", () => {
|
||||
let tmpDir: string;
|
||||
let storagePath: string;
|
||||
@@ -155,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({
|
||||
@@ -274,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");
|
||||
@@ -291,81 +189,66 @@ 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"] });
|
||||
});
|
||||
|
||||
test("applySnapshot stores and reloads composer values as providerOptions", async () => {
|
||||
test("applySnapshot stores and reloads featureValues when present", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-provider-options",
|
||||
config: { providerOptions: { fast_mode: true } },
|
||||
id: "agent-feature-values",
|
||||
config: {
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const persisted = await reloaded.get("agent-provider-options");
|
||||
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true });
|
||||
expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true });
|
||||
expect(buildConfigOverrides(persisted!)).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
const record = await storage.get("agent-feature-values");
|
||||
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
test("a record stored with featureValues loads them in providerOptions", async () => {
|
||||
await storage.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
|
||||
const file = findRecordFile(storagePath, "agent-legacy");
|
||||
const stored = JSON.parse(await fs.readFile(file, "utf8"));
|
||||
stored.config.providerOptions = { allowedTools: ["Read"], fast_mode: false };
|
||||
stored.config.featureValues = { fast_mode: true, service_tier: "fast" };
|
||||
delete stored.controls;
|
||||
stored.features = [
|
||||
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
|
||||
{
|
||||
type: "select",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
value: "fast",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "fast", label: "Fast" },
|
||||
],
|
||||
test("applySnapshot keeps featureValues absent when they were never set", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-no-feature-values",
|
||||
}),
|
||||
);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test("buildConfigOverrides includes featureValues when present in stored config", async () => {
|
||||
await storage.applySnapshot(
|
||||
createManagedAgent({
|
||||
id: "agent-resume-overrides",
|
||||
config: {
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const record = await storage.get("agent-resume-overrides");
|
||||
expect(record).not.toBeNull();
|
||||
expect(buildConfigOverrides(record!)).toMatchObject({
|
||||
cwd: "/tmp/project",
|
||||
featureValues: {
|
||||
fast_mode: true,
|
||||
},
|
||||
];
|
||||
await fs.writeFile(file, JSON.stringify(stored));
|
||||
|
||||
const reloaded = new AgentStorage(storagePath, createTestLogger());
|
||||
const persisted = await reloaded.get("agent-legacy");
|
||||
|
||||
// An explicit providerOptions key wins over the same featureValues key.
|
||||
expect(persisted?.config?.providerOptions).toEqual({
|
||||
allowedTools: ["Read"],
|
||||
fast_mode: false,
|
||||
service_tier: "fast",
|
||||
});
|
||||
expect(persisted?.config).not.toHaveProperty("featureValues");
|
||||
expect(persisted).not.toHaveProperty("features");
|
||||
expect(persisted?.controls).toEqual([
|
||||
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
|
||||
{
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
label: "Speed",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "fast", label: "Fast" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await reloaded.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
|
||||
const rewritten = JSON.parse(await fs.readFile(file, "utf8"));
|
||||
expect(rewritten.config).not.toHaveProperty("featureValues");
|
||||
expect(rewritten).not.toHaveProperty("features");
|
||||
});
|
||||
|
||||
test("applySnapshot preserves original createdAt timestamp", async () => {
|
||||
@@ -429,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");
|
||||
});
|
||||
@@ -532,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);
|
||||
});
|
||||
|
||||
@@ -603,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);
|
||||
});
|
||||
@@ -660,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);
|
||||
@@ -688,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,19 +4,17 @@ import { z } from "zod";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { writeJsonFileAtomic } from "../atomic-file.js";
|
||||
import { AgentFeatureSchema, AgentOptionControlSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { AgentFeatureSchema, AgentStatusSchema } from "../messages.js";
|
||||
import { toStoredAgentRecord } from "./agent-projections.js";
|
||||
import type { ManagedAgent } from "./agent-manager.js";
|
||||
import type { AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
|
||||
import { controlsFromFeatures, foldFeatureValues } from "./option-controls.js";
|
||||
|
||||
const SERIALIZABLE_CONFIG_SCHEMA = z
|
||||
.object({
|
||||
modeId: z.string().nullable().optional(),
|
||||
model: z.string().nullable().optional(),
|
||||
thinkingOptionId: z.string().nullable().optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; folded on parse.
|
||||
featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||
toolPolicy: z
|
||||
@@ -44,7 +42,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z
|
||||
.nullable()
|
||||
.optional();
|
||||
|
||||
const RAW_STORED_AGENT_SCHEMA = z.object({
|
||||
const STORED_AGENT_SCHEMA = z.object({
|
||||
id: z.string(),
|
||||
provider: z.string(),
|
||||
cwd: z.string(),
|
||||
@@ -70,8 +68,6 @@ const RAW_STORED_AGENT_SCHEMA = z.object({
|
||||
extra: z.record(z.string(), z.unknown()).optional(),
|
||||
})
|
||||
.optional(),
|
||||
controls: z.array(AgentOptionControlSchema).optional(),
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; becomes controls.
|
||||
features: z.array(AgentFeatureSchema).optional(),
|
||||
persistence: PERSISTENCE_HANDLE_SCHEMA,
|
||||
lastError: z.string().nullable().optional(),
|
||||
@@ -79,7 +75,6 @@ const RAW_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(),
|
||||
});
|
||||
@@ -89,32 +84,14 @@ export type SerializableAgentConfig = Pick<
|
||||
| "modeId"
|
||||
| "model"
|
||||
| "thinkingOptionId"
|
||||
| "featureValues"
|
||||
| "providerOptions"
|
||||
| "toolPolicy"
|
||||
| "systemPrompt"
|
||||
| "mcpServers"
|
||||
>;
|
||||
|
||||
type RawStoredAgentRecord = z.infer<typeof RAW_STORED_AGENT_SCHEMA>;
|
||||
type RawStoredAgentConfig = NonNullable<RawStoredAgentRecord["config"]>;
|
||||
|
||||
/** Records carry `providerOptions` and `controls` only; legacy fields are folded when read. */
|
||||
export type StoredAgentRecord = Omit<RawStoredAgentRecord, "config" | "features"> & {
|
||||
config?: Omit<RawStoredAgentConfig, "featureValues"> | null;
|
||||
};
|
||||
|
||||
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
function foldLegacyRecordFields(record: RawStoredAgentRecord): StoredAgentRecord {
|
||||
const { features, config, controls, ...rest } = record;
|
||||
return {
|
||||
...rest,
|
||||
...(config ? { config: foldFeatureValues(config) } : { config }),
|
||||
...((controls ?? features) ? { controls: controls ?? controlsFromFeatures(features) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const STORED_AGENT_SCHEMA = RAW_STORED_AGENT_SCHEMA.transform(foldLegacyRecordFields);
|
||||
|
||||
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
||||
export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
|
||||
return STORED_AGENT_SCHEMA.parse(value);
|
||||
}
|
||||
@@ -127,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) {
|
||||
@@ -146,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;
|
||||
@@ -278,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> {
|
||||
@@ -368,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);
|
||||
@@ -404,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;
|
||||
@@ -428,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) {
|
||||
@@ -440,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();
|
||||
@@ -448,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;
|
||||
}
|
||||
@@ -503,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> {
|
||||
@@ -544,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);
|
||||
|
||||
@@ -94,7 +94,7 @@ export function resolveDefaultAgentCreateConfig(
|
||||
availableModes: availableModeIds,
|
||||
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id,
|
||||
}),
|
||||
providerOptions: input.providerOptions,
|
||||
featureValues: input.featureValues,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,7 @@ test("session create applies the resolved mode from the provider create config",
|
||||
const stub = createProviderSnapshotManagerStub();
|
||||
stub.resolveCreateConfig.mockResolvedValue({
|
||||
modeId: "build",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const dependencies: Parameters<typeof createAgentCommand>[0] = {
|
||||
agentManager: {
|
||||
@@ -171,7 +171,7 @@ test("session create applies the resolved mode from the provider create config",
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modeId: "build",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
}),
|
||||
undefined,
|
||||
expect.anything(),
|
||||
|
||||
@@ -21,19 +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";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { foldFeatureValues, type LegacyFeatureValues } from "../option-controls.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;
|
||||
@@ -52,17 +40,15 @@ export interface CreateAgentCommandDependencies {
|
||||
export type EnsureWorkspaceForCreate = (
|
||||
cwd: string,
|
||||
firstAgentContext?: FirstAgentContext,
|
||||
context?: { background?: boolean; callerWorkspaceId?: string },
|
||||
context?: { callerWorkspaceId?: string },
|
||||
) => Promise<string>;
|
||||
|
||||
export interface CreateAgentFromSessionInput {
|
||||
kind: "session";
|
||||
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
|
||||
agentId?: string;
|
||||
/** Wire configs from older clients can still carry `featureValues`; they are folded on entry. */
|
||||
config: AgentSessionConfig & LegacyFeatureValues;
|
||||
config: AgentSessionConfig;
|
||||
workspaceId: string;
|
||||
background: boolean;
|
||||
initialPrompt?: string;
|
||||
source?: AgentPromptSource;
|
||||
clientMessageId?: string;
|
||||
@@ -84,15 +70,13 @@ export interface CreateAgentFromMcpInput {
|
||||
cwd?: string;
|
||||
workspaceId?: string;
|
||||
thinking?: string;
|
||||
providerOptions?: ProviderOptions;
|
||||
features?: Record<string, unknown>;
|
||||
labels?: Record<string, string>;
|
||||
mode?: string;
|
||||
unattended?: boolean;
|
||||
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>;
|
||||
@@ -234,19 +218,19 @@ async function resolveSessionCreateAgent(
|
||||
input: CreateAgentFromSessionInput,
|
||||
): Promise<ResolvedCreateAgent> {
|
||||
const trimmedPrompt = input.initialPrompt?.trim();
|
||||
const builtSessionConfig = foldFeatureValues(input.config);
|
||||
const builtSessionConfig = input.config;
|
||||
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
cwd: builtSessionConfig.cwd,
|
||||
provider: builtSessionConfig.provider,
|
||||
requestedMode: builtSessionConfig.modeId,
|
||||
providerOptions: builtSessionConfig.providerOptions,
|
||||
featureValues: builtSessionConfig.featureValues,
|
||||
parent: null,
|
||||
unattended: false,
|
||||
});
|
||||
const sessionConfig: AgentSessionConfig = {
|
||||
...builtSessionConfig,
|
||||
modeId: resolvedCreateConfig.modeId,
|
||||
providerOptions: resolvedCreateConfig.providerOptions,
|
||||
featureValues: resolvedCreateConfig.featureValues,
|
||||
};
|
||||
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
|
||||
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
|
||||
@@ -271,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,
|
||||
@@ -294,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({
|
||||
@@ -321,7 +300,7 @@ async function resolveMcpCreateAgent(
|
||||
dependencies,
|
||||
resolvedCwd,
|
||||
input.initialPrompt ?? "",
|
||||
workspaceIntent,
|
||||
parentAgent?.workspaceId,
|
||||
),
|
||||
),
|
||||
cwd: resolvedCwd,
|
||||
@@ -344,13 +323,12 @@ async function resolveMcpCreateAgent(
|
||||
resolvedCwd: intent.cwd,
|
||||
trimmedPrompt,
|
||||
resolvedMode: resolvedCreateConfig.modeId,
|
||||
resolvedProviderOptions: resolvedCreateConfig.providerOptions,
|
||||
resolvedFeatures: resolvedCreateConfig.featureValues,
|
||||
}),
|
||||
createOptions: {
|
||||
...(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,
|
||||
@@ -383,23 +361,18 @@ async function resolveMcpProviderCreateConfig(params: {
|
||||
provider: string;
|
||||
resolvedCwd: string;
|
||||
parentAgent: ManagedAgent | null;
|
||||
}): Promise<{ modeId?: string; providerOptions?: ProviderOptions }> {
|
||||
}): Promise<{ modeId?: string; featureValues?: Record<string, unknown> }> {
|
||||
const passthroughConfig = params.input.config;
|
||||
return params.dependencies.providerSnapshotManager.resolveCreateConfig({
|
||||
cwd: params.resolvedCwd,
|
||||
provider: params.provider,
|
||||
requestedMode: params.input.mode ?? passthroughConfig?.modeId,
|
||||
providerOptions: mergeMcpProviderOptions(params.input),
|
||||
featureValues: params.input.features ?? passthroughConfig?.featureValues,
|
||||
parent: params.parentAgent,
|
||||
unattended: params.input.unattended ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
function mergeMcpProviderOptions(input: CreateAgentFromMcpInput): ProviderOptions | undefined {
|
||||
if (!input.providerOptions) return input.config?.providerOptions;
|
||||
return { ...input.config?.providerOptions, ...input.providerOptions };
|
||||
}
|
||||
|
||||
function buildMcpSessionConfig(params: {
|
||||
input: CreateAgentFromMcpInput;
|
||||
resolvedProviderModel: ResolvedProviderModel;
|
||||
@@ -407,14 +380,14 @@ function buildMcpSessionConfig(params: {
|
||||
resolvedCwd: string;
|
||||
trimmedPrompt: string;
|
||||
resolvedMode?: string;
|
||||
resolvedProviderOptions?: ProviderOptions;
|
||||
resolvedFeatures?: Record<string, unknown>;
|
||||
}): AgentSessionConfig {
|
||||
const passthroughConfig = params.input.config;
|
||||
const { provisionalTitle } = resolveCreateAgentTitles({
|
||||
configTitle: passthroughConfig?.title ?? params.input.title,
|
||||
initialPrompt: params.trimmedPrompt,
|
||||
});
|
||||
const providerOptions = params.resolvedProviderOptions ?? mergeMcpProviderOptions(params.input);
|
||||
const featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues;
|
||||
const config: AgentSessionConfig = {
|
||||
...passthroughConfig,
|
||||
provider: params.provider,
|
||||
@@ -427,8 +400,8 @@ function buildMcpSessionConfig(params: {
|
||||
if (provisionalTitle) {
|
||||
config.title = provisionalTitle;
|
||||
}
|
||||
if (providerOptions) {
|
||||
config.providerOptions = providerOptions;
|
||||
if (featureValues) {
|
||||
config.featureValues = featureValues;
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -437,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(
|
||||
@@ -535,7 +512,7 @@ async function resolveMcpCwd(params: {
|
||||
dependencies: CreateAgentCommandDependencies;
|
||||
cwd: string;
|
||||
initialPrompt: string;
|
||||
workspaceIntent: McpWorkspaceIntent;
|
||||
callerWorkspaceId?: string;
|
||||
worktree: CreateAgentFromMcpInput["worktree"];
|
||||
}): Promise<{
|
||||
resolvedCwd: string;
|
||||
@@ -565,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 = (
|
||||
|
||||
@@ -186,8 +186,8 @@ function createRecordingAgentClients(): Record<AgentProvider, AgentClient> {
|
||||
if (client.listCommands) {
|
||||
wrappedClient.listCommands = async (config) => await client.listCommands!(config);
|
||||
}
|
||||
if (client.listControls) {
|
||||
wrappedClient.listControls = async (config) => await client.listControls!(config);
|
||||
if (client.listFeatures) {
|
||||
wrappedClient.listFeatures = async (config) => await client.listFeatures!(config);
|
||||
}
|
||||
if (client.listImportableSessions) {
|
||||
wrappedClient.listImportableSessions = async (options) =>
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -257,7 +257,7 @@ function buildAgentManagerSpies() {
|
||||
setAgentMode: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentModel: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentOptions: vi.fn().mockResolvedValue(undefined),
|
||||
setAgentFeature: vi.fn().mockResolvedValue(undefined),
|
||||
setLabels: vi.fn().mockResolvedValue(undefined),
|
||||
setTitle: vi.fn().mockResolvedValue(undefined),
|
||||
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -282,7 +282,7 @@ function buildAgentManagerSpies() {
|
||||
cancelAgentRun: vi.fn(),
|
||||
getPendingPermissions: vi.fn(),
|
||||
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
|
||||
listDraftControls: vi.fn(),
|
||||
listDraftFeatures: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -474,9 +472,9 @@ function configureOpenCodeProviderStub(
|
||||
const opts = input as {
|
||||
provider: AgentProvider;
|
||||
requestedMode: string | undefined;
|
||||
providerOptions: Record<string, unknown> | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
};
|
||||
return { modeId: opts.requestedMode, providerOptions: opts.providerOptions };
|
||||
return { modeId: opts.requestedMode, featureValues: opts.featureValues };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1818,7 +1805,7 @@ describe("create_agent MCP tool", () => {
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Feature test", providerOptions: { fast_mode: true } },
|
||||
config: { title: "Feature test", featureValues: { fast_mode: true } },
|
||||
} as ManagedAgent);
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -1847,10 +1834,10 @@ describe("create_agent MCP tool", () => {
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
providerOptions: { fast_mode: true },
|
||||
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,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -3045,12 +3039,12 @@ describe("create_agent MCP tool", () => {
|
||||
lifecycle: "idle",
|
||||
currentModeId: null,
|
||||
availableModes: [],
|
||||
config: { title: "Child", providerOptions: { fast_mode: true } },
|
||||
config: { title: "Child", featureValues: { fast_mode: true } },
|
||||
} as ManagedAgent);
|
||||
const providerSnapshot = createOpenCodeManager();
|
||||
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||
const opts = input as { providerOptions: Record<string, unknown> | undefined };
|
||||
return { modeId: undefined, providerOptions: opts.providerOptions };
|
||||
const opts = input as { featureValues: Record<string, unknown> | undefined };
|
||||
return { modeId: undefined, featureValues: opts.featureValues };
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -3078,7 +3072,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect.objectContaining({
|
||||
provider: "codex",
|
||||
model: "gpt-5.4",
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
}),
|
||||
undefined,
|
||||
{
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3281,7 +3273,7 @@ describe("create_agent MCP tool", () => {
|
||||
provStub.resolveCreateConfig.mockImplementation(async (input) => {
|
||||
const opts = input as { requestedMode: string | undefined };
|
||||
expect(opts.requestedMode).toBe("dynamic");
|
||||
return { modeId: "dynamic", providerOptions: undefined };
|
||||
return { modeId: "dynamic", featureValues: undefined };
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
@@ -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" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3315,12 +3307,12 @@ describe("create_agent MCP tool", () => {
|
||||
lifecycle: "idle",
|
||||
currentModeId: "build",
|
||||
availableModes: [],
|
||||
config: { title: "Child", providerOptions: { auto_accept: true } },
|
||||
config: { title: "Child", featureValues: { auto_accept: true } },
|
||||
} as ManagedAgent);
|
||||
const providerSnapshot = createOpenCodeManager();
|
||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||
modeId: "build",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
@@ -3340,9 +3332,9 @@ describe("create_agent MCP tool", () => {
|
||||
});
|
||||
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ modeId: "build", providerOptions: { auto_accept: true } }),
|
||||
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
|
||||
undefined,
|
||||
{ workspaceId: "workspace-created", background: false },
|
||||
{ workspaceId: "workspace-created" },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -3368,7 +3360,7 @@ describe("create_agent MCP tool", () => {
|
||||
const providerSnapshot = createOpenCodeManager();
|
||||
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
|
||||
modeId: "resolver-mode",
|
||||
providerOptions: { resolver_feature: true },
|
||||
featureValues: { resolver_feature: true },
|
||||
});
|
||||
|
||||
const server = await createAgentMcpServer({
|
||||
@@ -3392,7 +3384,7 @@ describe("create_agent MCP tool", () => {
|
||||
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
modeId: "resolver-mode",
|
||||
providerOptions: { resolver_feature: true },
|
||||
featureValues: { resolver_feature: true },
|
||||
}),
|
||||
undefined,
|
||||
expect.any(Object),
|
||||
@@ -4725,7 +4717,7 @@ describe("update_agent MCP tool", () => {
|
||||
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
|
||||
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
|
||||
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
|
||||
expect(spies.agentManager.setAgentOptions).toHaveBeenCalledWith("agent-1", { fast_mode: true });
|
||||
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
|
||||
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
|
||||
title: "Updated agent",
|
||||
labels: { role: "worker" },
|
||||
@@ -4750,12 +4742,12 @@ describe("update_agent MCP tool", () => {
|
||||
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentOptions).not.toHaveBeenCalled();
|
||||
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not update metadata when runtime settings fail", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.setAgentOptions.mockRejectedValue(new Error("unsupported feature"));
|
||||
spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
|
||||
const server = await createAgentMcpServer({
|
||||
agentManager,
|
||||
agentStorage,
|
||||
@@ -5073,7 +5065,7 @@ describe("create_schedule MCP tool", () => {
|
||||
config: {
|
||||
title: "Parent agent",
|
||||
model: "openai/gpt-5.5",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
} as ManagedAgent);
|
||||
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
|
||||
@@ -5101,7 +5093,7 @@ describe("create_schedule MCP tool", () => {
|
||||
cwd: REPO_CWD,
|
||||
modeId: "build",
|
||||
model: "openai/gpt-5.5",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -5875,7 +5867,7 @@ describe("agent profile listing MCP tool", () => {
|
||||
model: "claude-test-model",
|
||||
modeId: "bypassPermissions",
|
||||
thinkingOptionId: "high",
|
||||
options: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
||||
},
|
||||
];
|
||||
@@ -5890,21 +5882,7 @@ describe("agent profile listing MCP tool", () => {
|
||||
|
||||
const response = await tool.handler({});
|
||||
|
||||
// The config store holds profiles with `options` only; the tool reports them as stored.
|
||||
expect(response.structuredContent).toEqual({
|
||||
profiles: [
|
||||
{
|
||||
id: "ui-profile",
|
||||
name: "UI work",
|
||||
provider: "claude",
|
||||
model: "claude-test-model",
|
||||
modeId: "bypassPermissions",
|
||||
thinkingOptionId: "high",
|
||||
options: { fast_mode: true },
|
||||
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(response.structuredContent).toEqual({ profiles });
|
||||
});
|
||||
|
||||
it("returns an empty array when no profiles are configured", async () => {
|
||||
@@ -5956,10 +5934,14 @@ describe("provider MCP tools", () => {
|
||||
|
||||
it("inspects provider features for a draft agent configuration", async () => {
|
||||
const { agentManager, agentStorage, spies } = createTestDeps();
|
||||
spies.agentManager.listDraftControls.mockResolvedValue({
|
||||
controls: [{ type: "toggle", key: "fast_mode", label: "Fast mode" }],
|
||||
options: { fast_mode: true },
|
||||
});
|
||||
spies.agentManager.listDraftFeatures.mockResolvedValue([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
const provStub = createProviderSnapshotManagerStub();
|
||||
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
|
||||
const codexEntry = buildSnapshotEntry({
|
||||
@@ -5992,13 +5974,13 @@ describe("provider MCP tools", () => {
|
||||
|
||||
const response = await tool.handler(input);
|
||||
|
||||
expect(spies.agentManager.listDraftControls).toHaveBeenCalledWith({
|
||||
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
|
||||
provider: "codex",
|
||||
cwd: expect.stringContaining("repo"),
|
||||
modeId: "full-access",
|
||||
model: "gpt-5.4",
|
||||
thinkingOptionId: "high",
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
expect(response.structuredContent).toEqual({
|
||||
provider: "codex",
|
||||
@@ -6013,7 +5995,7 @@ describe("provider MCP tools", () => {
|
||||
type: "toggle",
|
||||
id: "fast_mode",
|
||||
label: "Fast mode",
|
||||
value: true,
|
||||
value: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -6229,8 +6211,14 @@ describe("agent snapshot MCP serialization", () => {
|
||||
description: "Default coding mode",
|
||||
},
|
||||
],
|
||||
controls: [{ type: "toggle", key: "web-search", label: "Web search" }],
|
||||
effectiveOptions: { "web-search": true },
|
||||
features: [
|
||||
{
|
||||
type: "toggle",
|
||||
id: "web-search",
|
||||
label: "Web search",
|
||||
value: true,
|
||||
},
|
||||
],
|
||||
pendingPermissions: new Map(),
|
||||
persistence: {
|
||||
provider: "codex",
|
||||
@@ -6346,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",
|
||||
@@ -6405,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({
|
||||
@@ -6446,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",
|
||||
@@ -6477,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,
|
||||
@@ -6596,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,
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { convertLegacyOptions, foldHookFeatureValues } from "./option-controls.js";
|
||||
|
||||
describe("convertLegacyOptions rejects a malformed Codex fast_mode", () => {
|
||||
test.each(["false", 1, {}])("%j", (value) => {
|
||||
expect(() =>
|
||||
convertLegacyOptions({ provider: "codex", options: { fast_mode: value } }),
|
||||
).toThrow(/fast_mode must be true, false or null/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("convertLegacyOptions", () => {
|
||||
test.each([
|
||||
{ legacy: true, tier: "fast" },
|
||||
{ legacy: false, tier: "default" },
|
||||
{ legacy: null, tier: null },
|
||||
])("Codex fast_mode: $legacy becomes service_tier: $tier", ({ legacy, tier }) => {
|
||||
expect(
|
||||
convertLegacyOptions({ provider: "codex", options: { fast_mode: legacy, plan_mode: true } }),
|
||||
).toEqual({ plan_mode: true, service_tier: tier });
|
||||
});
|
||||
|
||||
test("a service_tier in the same input wins", () => {
|
||||
expect(
|
||||
convertLegacyOptions({
|
||||
provider: "codex",
|
||||
options: { service_tier: "default", fast_mode: true },
|
||||
}),
|
||||
).toEqual({ service_tier: "default" });
|
||||
});
|
||||
|
||||
test("other providers keep fast_mode", () => {
|
||||
const options = { fast_mode: true };
|
||||
expect(convertLegacyOptions({ provider: "claude", options })).toBe(options);
|
||||
});
|
||||
});
|
||||
|
||||
describe("foldHookFeatureValues", () => {
|
||||
const convert = (options: Record<string, unknown>) =>
|
||||
convertLegacyOptions({ provider: "codex", options });
|
||||
|
||||
test("a hook that edits featureValues.fast_mode sets service_tier", () => {
|
||||
const sent = { providerOptions: {}, featureValues: {} };
|
||||
const returned = { providerOptions: {}, featureValues: { fast_mode: true } };
|
||||
|
||||
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
|
||||
service_tier: "fast",
|
||||
});
|
||||
});
|
||||
|
||||
test("a hook that replaces the speed it was sent with fast_mode selects Fast", () => {
|
||||
const sent = {
|
||||
providerOptions: { service_tier: "default" },
|
||||
featureValues: { service_tier: "default" },
|
||||
};
|
||||
const returned = { ...sent, featureValues: { fast_mode: true } };
|
||||
|
||||
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
|
||||
service_tier: "fast",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,304 +0,0 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type {
|
||||
AgentFeature,
|
||||
AgentOptionControl,
|
||||
AgentOptionSelectControl,
|
||||
ProviderOptions,
|
||||
} from "@getpaseo/protocol/agent-types";
|
||||
|
||||
/**
|
||||
* A patch over an agent's own options: top-level keys replace, `null` removes the key so the
|
||||
* `config.json` default (or the provider's own default) applies again.
|
||||
*/
|
||||
export type ProviderOptionsPatch = Record<string, unknown>;
|
||||
|
||||
export function applyProviderOptionsPatch(input: {
|
||||
options: ProviderOptions | undefined;
|
||||
patch: ProviderOptionsPatch;
|
||||
}): ProviderOptions {
|
||||
const { options, patch } = input;
|
||||
const next: ProviderOptions = { ...options };
|
||||
for (const [key, value] of Object.entries(patch)) {
|
||||
if (value === null) {
|
||||
delete next[key];
|
||||
} else if (value !== undefined) {
|
||||
next[key] = value;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. */
|
||||
export interface LegacyFeatureValues {
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast
|
||||
* preferences have migrated. Codex used to take `fast_mode`; it takes `service_tier` now. Old
|
||||
* data (stored records, config.json defaults, profiles, older clients, hooks) is converted here
|
||||
* as it enters, so nothing past this point sees `fast_mode` for Codex. A `service_tier` in the
|
||||
* same input wins. A patch's `null` stays `null`, which removes the speed.
|
||||
*/
|
||||
interface LegacyOptionsInput<O> {
|
||||
provider: string | undefined;
|
||||
options: O;
|
||||
}
|
||||
|
||||
export function convertLegacyOptions(
|
||||
input: LegacyOptionsInput<Record<string, unknown>>,
|
||||
): Record<string, unknown>;
|
||||
export function convertLegacyOptions(
|
||||
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
|
||||
): Record<string, unknown> | undefined;
|
||||
export function convertLegacyOptions(
|
||||
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
|
||||
): Record<string, unknown> | undefined {
|
||||
const { provider, options } = input;
|
||||
if (provider !== "codex" || !options || !("fast_mode" in options)) return options;
|
||||
const { fast_mode: legacy, ...rest } = options;
|
||||
if (legacy !== undefined && legacy !== null && typeof legacy !== "boolean") {
|
||||
throw new Error("Codex fast_mode must be true, false or null");
|
||||
}
|
||||
if ("service_tier" in rest || legacy === undefined) return rest;
|
||||
if (legacy === null) return { ...rest, service_tier: null };
|
||||
return { ...rest, service_tier: legacy ? "fast" : "default" };
|
||||
}
|
||||
|
||||
/**
|
||||
* The built-in adapter a provider id runs on: a custom provider is named by the one it
|
||||
* `extends`, followed until a provider that extends nothing. `extendsOf` is where the caller
|
||||
* reads that link (the registry's definitions, or `config.json`'s `providers` section).
|
||||
*/
|
||||
export function resolveAdapter(input: {
|
||||
provider: string;
|
||||
extendsOf: (provider: string) => string | null | undefined;
|
||||
}): string {
|
||||
const seen = new Set<string>();
|
||||
let current = input.provider;
|
||||
for (;;) {
|
||||
seen.add(current);
|
||||
const base = input.extendsOf(current);
|
||||
if (!base || seen.has(base)) return current;
|
||||
current = base;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Older clients, stored records, profiles, and plugin hooks carry composer values in
|
||||
* `featureValues`. This is the one place they become `providerOptions`; an explicit
|
||||
* `providerOptions` key wins over a `featureValues` key of the same name.
|
||||
*/
|
||||
export function foldFeatureValues<
|
||||
T extends {
|
||||
providerOptions?: ProviderOptions | null;
|
||||
featureValues?: Record<string, unknown> | null;
|
||||
},
|
||||
>(config: T): Omit<T, "featureValues"> {
|
||||
const { featureValues, ...rest } = config;
|
||||
if (!featureValues || Object.keys(featureValues).length === 0) return rest;
|
||||
return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } };
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* A plugin written against the previous SDK reads and edits `config.featureValues`, so a
|
||||
* hook sees a copy of the config's options there. What the hook did to that copy (set,
|
||||
* replaced or deleted a key, or dropped the field) is applied as a patch over the options it
|
||||
* returned; keys it left alone change nothing.
|
||||
*/
|
||||
export function exposeFeatureValuesToHook<
|
||||
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
|
||||
>(config: T): T {
|
||||
if (!config.providerOptions || Object.keys(config.providerOptions).length === 0) return config;
|
||||
return { ...config, featureValues: { ...config.providerOptions } };
|
||||
}
|
||||
|
||||
export function foldHookFeatureValues<
|
||||
T extends {
|
||||
providerOptions?: ProviderOptions | null;
|
||||
featureValues?: Record<string, unknown>;
|
||||
},
|
||||
>(input: {
|
||||
returned: T;
|
||||
sent: T;
|
||||
/** Brings what the hook returned to the form of the copy it was sent. */
|
||||
convert: (options: Record<string, unknown>) => Record<string, unknown>;
|
||||
}): Omit<T, "featureValues"> {
|
||||
const { returned, sent, convert } = input;
|
||||
const { featureValues, ...rest } = returned;
|
||||
const before = sent.featureValues;
|
||||
if (!before) {
|
||||
// No copy was sent, so everything the hook put in `featureValues` is new.
|
||||
return foldFeatureValues(returned);
|
||||
}
|
||||
// A hook may return an older key where it was sent the current one; comparing in the same
|
||||
// form keeps a replaced value from reading as a deletion.
|
||||
const after = convert(featureValues ?? {});
|
||||
const patch: ProviderOptionsPatch = {};
|
||||
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
|
||||
if (isDeepStrictEqual(after[key], before[key])) continue;
|
||||
patch[key] = key in after ? after[key] : null;
|
||||
}
|
||||
return {
|
||||
...rest,
|
||||
providerOptions: applyProviderOptionsPatch({
|
||||
options: rest.providerOptions ?? undefined,
|
||||
patch,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
type ConvertedOptions = Record<string, unknown> | undefined;
|
||||
|
||||
interface ProfileOptionFields {
|
||||
provider?: string;
|
||||
options?: Record<string, unknown>;
|
||||
featureValues?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Profiles saved before options carry `featureValues`; when one comes from storage, `options`
|
||||
* wins over a stale `featureValues` key.
|
||||
*
|
||||
* A write that carries `options` is from a client that knows them: its `featureValues` fold
|
||||
* over those. A write without `options` is from the unchanged app, whose form only knows the
|
||||
* controls: its `featureValues` are a patch (`null` deletes) over the options stored for that
|
||||
* profile, so options the form never shows, such as `sandbox_mode`, survive an edit. A profile
|
||||
* moved to another provider starts from no options, because the old provider's keys mean
|
||||
* nothing to the new one.
|
||||
*/
|
||||
export function foldProfileFeatureValues<T extends ProfileOptionFields>(input: {
|
||||
profile: T;
|
||||
stored: ProfileOptionFields | undefined;
|
||||
source: "stored" | "client write";
|
||||
/** Renames old option keys for the adapter the profile's provider runs on. */
|
||||
convert: (provider: string, options: Record<string, unknown> | undefined) => ConvertedOptions;
|
||||
}): Omit<T, "featureValues"> {
|
||||
const { profile, stored, source, convert } = input;
|
||||
const { featureValues: legacyValues, ...rest } = profile;
|
||||
// A profile is stored in the current keys, so removal and merging below see one vocabulary.
|
||||
const provider = rest.provider ?? "";
|
||||
const featureValues = convert(provider, legacyValues);
|
||||
const ownOptions = convert(provider, rest.options);
|
||||
if (source === "stored") {
|
||||
if (featureValues) return { ...rest, options: { ...featureValues, ...ownOptions } };
|
||||
return ownOptions ? { ...rest, options: ownOptions } : rest;
|
||||
}
|
||||
const base =
|
||||
ownOptions ??
|
||||
(stored?.provider === rest.provider ? convert(provider, stored?.options) : undefined);
|
||||
// The form sends no `featureValues` when no control is set, which is still an edit of a
|
||||
// profile that may hold other options.
|
||||
if (!featureValues && !base) return rest;
|
||||
return {
|
||||
...rest,
|
||||
options: applyProviderOptionsPatch({ options: base, patch: featureValues ?? {} }),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* The unchanged app reads a profile's options from `featureValues`; the config API answers with
|
||||
* both fields.
|
||||
*/
|
||||
export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(profile: T): T {
|
||||
return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile;
|
||||
}
|
||||
|
||||
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
|
||||
const value = options[control.key];
|
||||
if (typeof value === "string") {
|
||||
const choice = control.choices.find((candidate) => candidate.value === value);
|
||||
if (choice) return choice.value;
|
||||
}
|
||||
return control.defaultValue ?? null;
|
||||
}
|
||||
|
||||
function describe(control: { description?: string; tooltip?: string; icon?: string }): {
|
||||
description?: string;
|
||||
tooltip?: string;
|
||||
icon?: string;
|
||||
} {
|
||||
return {
|
||||
...(control.description !== undefined ? { description: control.description } : {}),
|
||||
...(control.tooltip !== undefined ? { tooltip: control.tooltip } : {}),
|
||||
...(control.icon !== undefined ? { icon: control.icon } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function featureFromControl(control: AgentOptionControl, effective: ProviderOptions): AgentFeature {
|
||||
if (control.type === "toggle") {
|
||||
const value = effective[control.key];
|
||||
return {
|
||||
type: "toggle",
|
||||
id: control.key,
|
||||
label: control.label,
|
||||
...describe(control),
|
||||
value: typeof value === "boolean" ? value : (control.defaultValue ?? false),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "select",
|
||||
id: control.key,
|
||||
label: control.label,
|
||||
...describe(control),
|
||||
...(control.desktopTrigger ? { desktopTrigger: control.desktopTrigger } : {}),
|
||||
value: selectedChoice(control, effective),
|
||||
options: control.choices.map((choice) => ({
|
||||
id: choice.value,
|
||||
label: choice.label,
|
||||
...(choice.description ? { description: choice.description } : {}),
|
||||
...(choice.value === control.defaultValue ? { isDefault: true } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Apps that predate controls render `features`; build them from controls + effective options.
|
||||
*/
|
||||
export function featuresFromControls(input: {
|
||||
controls: readonly AgentOptionControl[] | undefined;
|
||||
options: ProviderOptions | undefined;
|
||||
}): AgentFeature[] {
|
||||
const { controls, options } = input;
|
||||
const effective = options ?? {};
|
||||
return (controls ?? []).map((control) => featureFromControl(control, effective));
|
||||
}
|
||||
|
||||
/** A recorded feature's value is not carried over: controls hold no state, only `options` do. */
|
||||
function controlFromFeature(feature: AgentFeature): AgentOptionControl {
|
||||
const descriptor = {
|
||||
key: feature.id,
|
||||
label: feature.label,
|
||||
...describe(feature),
|
||||
};
|
||||
if (feature.type === "toggle") {
|
||||
return { type: "toggle", ...descriptor };
|
||||
}
|
||||
const defaultChoice = feature.options.find((option) => option.isDefault);
|
||||
return {
|
||||
type: "select",
|
||||
...descriptor,
|
||||
...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}),
|
||||
...(defaultChoice ? { defaultValue: defaultChoice.id } : {}),
|
||||
choices: feature.options.map((option) => ({
|
||||
value: option.id,
|
||||
label: option.label,
|
||||
...(option.description ? { description: option.description } : {}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
|
||||
* Agent records written before controls stored `features`; their descriptors become controls.
|
||||
*/
|
||||
export function controlsFromFeatures(
|
||||
features: readonly AgentFeature[] | undefined,
|
||||
): AgentOptionControl[] {
|
||||
return (features ?? []).map(controlFromFeature);
|
||||
}
|
||||
@@ -58,15 +58,6 @@ interface ProviderHarnessOptions {
|
||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Long enough for a request that is free to run to have run. */
|
||||
function pause(): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
|
||||
return inputs.findLast((input) => input.type === "session.configure");
|
||||
}
|
||||
|
||||
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
let listener: ((event: ProviderEvent) => void) | null = null;
|
||||
let closeCount = 0;
|
||||
@@ -979,7 +970,7 @@ describe("PluginAgentClientRegistry", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("stores the agent options and the settings the plugin opened with, not the merged config defaults", async () => {
|
||||
test("stores only agent options while the plugin receives merged defaults", async () => {
|
||||
const logger = createTestLogger();
|
||||
const harness = createProviderHarness();
|
||||
const plugins = new PluginAgentClientRegistry(logger);
|
||||
@@ -1001,213 +992,13 @@ describe("PluginAgentClientRegistry", () => {
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
// `nested.base` comes from `config.json` and is not stored; `voice` is the plugin's own opening state.
|
||||
expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual({ ...own, voice: "direct" });
|
||||
expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual(own);
|
||||
expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({
|
||||
config: { providerOptions: { nested: { base: true, replace: "agent" } } },
|
||||
});
|
||||
await agent.session?.close();
|
||||
});
|
||||
|
||||
describe("settings", () => {
|
||||
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
|
||||
async function openStubbornVoiceSession(
|
||||
given: Record<string, boolean | string>,
|
||||
{
|
||||
refuseAfterChanging = false,
|
||||
holdLoudChange,
|
||||
}: { refuseAfterChanging?: boolean; holdLoudChange?: Promise<void> } = {},
|
||||
) {
|
||||
const state = { voice: "direct", loud: false };
|
||||
let openedSessionId = "";
|
||||
const settings = () => [
|
||||
{
|
||||
type: "select" as const,
|
||||
id: "voice",
|
||||
label: "Voice",
|
||||
value: state.voice,
|
||||
options: [
|
||||
{ label: "Direct", value: "direct" },
|
||||
{ label: "Other", value: "other" },
|
||||
],
|
||||
},
|
||||
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
|
||||
];
|
||||
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
|
||||
emit({
|
||||
type: "session.config",
|
||||
sessionId,
|
||||
config: {
|
||||
model: "plugin-model",
|
||||
mode: "build",
|
||||
models: [{ id: "plugin-model", label: "Plugin model" }],
|
||||
modes: [{ id: "build", label: "Build" }],
|
||||
thinkingOptions: [],
|
||||
settings: settings(),
|
||||
},
|
||||
});
|
||||
const harness = createProviderHarness({
|
||||
async handleInput(input, emit) {
|
||||
if (input.type === "session.open") {
|
||||
openedSessionId = input.sessionId;
|
||||
emit({
|
||||
type: "session.opened",
|
||||
requestId: input.requestId,
|
||||
sessionId: input.sessionId,
|
||||
capabilities: CAPABILITIES,
|
||||
restoration: "core",
|
||||
persistence: { version: 1, data: { token: "root" } },
|
||||
cwd: input.config.cwd,
|
||||
});
|
||||
emitConfig(emit, input.sessionId);
|
||||
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
|
||||
return true;
|
||||
}
|
||||
if (input.type === "session.configure") {
|
||||
const loud = input.changes.settings?.loud;
|
||||
if (typeof loud === "boolean") state.loud = loud;
|
||||
emitConfig(emit, input.sessionId);
|
||||
// A change that has taken effect but whose request is still pending.
|
||||
if (typeof loud === "boolean" && holdLoudChange) {
|
||||
await holdLoudChange;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "no" },
|
||||
});
|
||||
return true;
|
||||
}
|
||||
emit(
|
||||
refuseAfterChanging
|
||||
? { type: "request.failed", requestId: input.requestId, error: { message: "no" } }
|
||||
: { type: "request.completed", requestId: input.requestId },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const plugins = new PluginAgentClientRegistry(createTestLogger());
|
||||
plugins.replace([harness.registration]);
|
||||
const session = await plugins.clients()["plugin-direct"]!.createSession({
|
||||
provider: "plugin-direct",
|
||||
cwd: tmpdir(),
|
||||
providerOptions: given,
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
const changeOnItsOwn = (change: Partial<typeof state>) => {
|
||||
Object.assign(state, change);
|
||||
emitConfig(harness.emit, openedSessionId);
|
||||
};
|
||||
return { harness, session, events, changeOnItsOwn };
|
||||
}
|
||||
|
||||
test("forwards a setting the plugin changes on its own", async () => {
|
||||
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
|
||||
|
||||
changeOnItsOwn({ loud: true });
|
||||
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
|
||||
]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a rejected apply reports what the plugin moved before refusing, since nothing is stored", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({}, { refuseAfterChanging: true });
|
||||
|
||||
await expect(session.applyOptions?.({ loud: true })).rejects.toThrow("no");
|
||||
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
|
||||
]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("overlapping applies resolve one at a time: a request that fails after its change took still reports it", async () => {
|
||||
let release: () => void = () => undefined;
|
||||
const holdLoudChange = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const { harness, session, events } = await openStubbornVoiceSession({}, { holdLoudChange });
|
||||
|
||||
const first = session.applyOptions?.({ loud: true });
|
||||
const firstFailed = expect(first).rejects.toThrow("no");
|
||||
await pause();
|
||||
expect(lastConfigure(harness.inputs)).toMatchObject({
|
||||
changes: { settings: { loud: true } },
|
||||
});
|
||||
const second = session.applyOptions?.({ voice: "other" });
|
||||
// The second request does not start until the first has ended, so it cannot settle first.
|
||||
await pause();
|
||||
release();
|
||||
|
||||
await firstFailed;
|
||||
await expect(second).resolves.toEqual({ voice: "direct" });
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
|
||||
]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a setting the plugin opened differently is the session's open patch, not an event", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({ voice: "other", loud: false });
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ voice: "direct" });
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("settings the plugin opened with that nobody set are in the open patch", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("applying options configures only the settings it is given", async () => {
|
||||
const { harness, session } = await openStubbornVoiceSession({});
|
||||
|
||||
await session.applyOptions?.({ loud: true });
|
||||
|
||||
const configure = lastConfigure(harness.inputs);
|
||||
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
|
||||
expect(
|
||||
Object.keys((configure as { changes: { settings: object } }).changes.settings),
|
||||
).toEqual(["loud"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects a key the plugin does not report as a setting, and configures none of a mixed patch", async () => {
|
||||
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
|
||||
const configureBefore = lastConfigure(harness.inputs);
|
||||
|
||||
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
|
||||
/launch_key.*cannot change on a running session/,
|
||||
);
|
||||
|
||||
expect(lastConfigure(harness.inputs)).toBe(configureBefore);
|
||||
|
||||
// The value it opened with is not a change, so a whole profile can be applied.
|
||||
await session.applyOptions?.({ loud: true, launch_key: "original" });
|
||||
const configure = lastConfigure(harness.inputs) as { changes: { settings: object } };
|
||||
expect(Object.keys(configure.changes.settings)).toEqual(["loud"]);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("applying options returns the settings that ended up different, and emits nothing", async () => {
|
||||
const { session, events } = await openStubbornVoiceSession({});
|
||||
|
||||
const ended = await session.applyOptions?.({ voice: "other", loud: true });
|
||||
|
||||
expect(ended).toEqual({ voice: "direct" });
|
||||
expect(eventsOfType(events, "options_changed")).toEqual([]);
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
test.each([
|
||||
{ options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
|
||||
{ params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
|
||||
@@ -1720,13 +1511,12 @@ describe("PluginAgentClientRegistry", () => {
|
||||
});
|
||||
|
||||
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
|
||||
expect(session.controls).toEqual([
|
||||
{
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
key: "voice",
|
||||
label: "Voice",
|
||||
choices: [{ value: "direct", label: "Direct" }],
|
||||
},
|
||||
id: "voice",
|
||||
options: [{ id: "direct", label: "Direct", value: "direct" }],
|
||||
}),
|
||||
]);
|
||||
expect(session.describePersistence()).toMatchObject({
|
||||
provider: "plugin-direct",
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { PluginSessionHistory } from "./plugin-session-history.js";
|
||||
import { resolve as resolvePath } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import type { JsonValue } from "@getpaseo/protocol/agent-types";
|
||||
import { z } from "zod";
|
||||
import {
|
||||
PROVIDER_CAPABILITIES,
|
||||
@@ -32,10 +31,10 @@ import type {
|
||||
AgentCapabilityFlags,
|
||||
AgentClient,
|
||||
AgentCreateSessionOptions,
|
||||
AgentFeature,
|
||||
AgentLaunchContext,
|
||||
AgentMode,
|
||||
AgentModelDefinition,
|
||||
AgentOptionControl,
|
||||
AgentPermissionRequest,
|
||||
AgentPermissionResponse,
|
||||
AgentPersistenceHandle,
|
||||
@@ -64,7 +63,6 @@ import {
|
||||
createProviderEnv,
|
||||
type ProviderRuntimeSettings,
|
||||
} from "./provider-launch-config.js";
|
||||
import { assertChangesAreLive } from "./provider-options.js";
|
||||
import type { RegisteredProviderDefinition } from "./provider-registry.js";
|
||||
import { runProviderTurn } from "./providers/provider-runner.js";
|
||||
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
|
||||
@@ -1135,7 +1133,6 @@ class PluginAgentClient implements AgentClient {
|
||||
});
|
||||
},
|
||||
bind,
|
||||
config.settings,
|
||||
);
|
||||
bind(bridge);
|
||||
return session;
|
||||
@@ -1204,13 +1201,6 @@ class PluginAgentSession implements AgentSession {
|
||||
>();
|
||||
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
/** How the opened session differs from the settings it was given. */
|
||||
readonly openOptionsPatch: Record<string, JsonValue>;
|
||||
private knownSettings: Record<string, JsonValue> = {};
|
||||
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
|
||||
private applyingOptions = false;
|
||||
/** One apply at a time: each reconciles against the settings the one before it left. */
|
||||
private applying: Promise<unknown> = Promise.resolve();
|
||||
private currentTurnId: string | null = null;
|
||||
private closed = false;
|
||||
private replay: {
|
||||
@@ -1231,12 +1221,8 @@ class PluginAgentSession implements AgentSession {
|
||||
state: ProviderConfigState,
|
||||
) => Promise<ProviderRuntimeSession>,
|
||||
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
|
||||
private readonly givenSettings: Readonly<Record<string, JsonValue>>,
|
||||
) {
|
||||
this.subagentIdsBySession.set(bridge.id, null);
|
||||
// Every setting the session opened with that was not given, including ones nobody set.
|
||||
this.openOptionsPatch = this.settingDifferences(givenSettings);
|
||||
this.knownSettings = this.currentSettings();
|
||||
for (const event of bridge.history) this.accept(event, false);
|
||||
// Opening notices arrive before the manager subscribes to the session.
|
||||
// Commit them on registration and replay the same timestamps from history.
|
||||
@@ -1260,27 +1246,14 @@ class PluginAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
/** The plugin's `settings` are its option controls; each setting id is an option key. */
|
||||
get controls(): AgentOptionControl[] {
|
||||
return this.bridge.config.settings.map(
|
||||
(setting): AgentOptionControl =>
|
||||
setting.type === "toggle"
|
||||
? {
|
||||
type: "toggle",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
}
|
||||
: {
|
||||
type: "select",
|
||||
key: setting.id,
|
||||
label: setting.label,
|
||||
...(setting.description ? { description: setting.description } : {}),
|
||||
choices: setting.options.map((option) => ({
|
||||
value: option.value,
|
||||
label: option.label,
|
||||
})),
|
||||
},
|
||||
get features(): AgentFeature[] {
|
||||
return this.bridge.config.settings.map((setting) =>
|
||||
setting.type === "toggle"
|
||||
? { ...setting }
|
||||
: {
|
||||
...setting,
|
||||
options: setting.options.map((option) => ({ ...option, id: option.value })),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1437,44 +1410,11 @@ class PluginAgentSession implements AgentSession {
|
||||
await this.bridge.configure({ thinkingOption: thinkingOptionId });
|
||||
}
|
||||
|
||||
applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> {
|
||||
const run = this.applying.then(() => this.applyOptionsNow(options));
|
||||
this.applying = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async applyOptionsNow(options: ProviderOptions): Promise<Record<string, JsonValue>> {
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
this.assertReplayComplete();
|
||||
const before = this.currentSettings();
|
||||
// A key the plugin does not report as a setting is one it only reads when the session opens;
|
||||
// a value equal to the one it opened with is not a change.
|
||||
assertChangesAreLive({
|
||||
provider: this.provider,
|
||||
patch: options,
|
||||
current: this.givenSettings,
|
||||
live: Object.keys(before),
|
||||
await this.bridge.configure({
|
||||
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
|
||||
});
|
||||
const settings: Record<string, JsonValue> = {};
|
||||
for (const [id, value] of Object.entries(options)) {
|
||||
if (!(id in before)) continue;
|
||||
settings[id] = toJsonValue(value, `setting '${id}'`);
|
||||
}
|
||||
if (Object.keys(settings).length === 0) return {};
|
||||
this.applyingOptions = true;
|
||||
try {
|
||||
await this.bridge.configure({ settings });
|
||||
} catch (error) {
|
||||
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
|
||||
// call has ended.
|
||||
this.applyingOptions = false;
|
||||
for (const event of this.reportSettingChanges()) this.publish(event);
|
||||
throw error;
|
||||
} finally {
|
||||
this.applyingOptions = false;
|
||||
}
|
||||
const ended = this.settingDifferences({ ...before, ...settings });
|
||||
this.knownSettings = this.currentSettings();
|
||||
return ended;
|
||||
}
|
||||
|
||||
async revertConversation(input: { messageId: string }): Promise<void> {
|
||||
@@ -1656,36 +1596,10 @@ class PluginAgentSession implements AgentSession {
|
||||
};
|
||||
}
|
||||
|
||||
private currentSettings(): Record<string, JsonValue> {
|
||||
return Object.fromEntries(
|
||||
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
|
||||
);
|
||||
}
|
||||
|
||||
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
|
||||
private settingDifferences(
|
||||
known: Readonly<Record<string, JsonValue>>,
|
||||
): Record<string, JsonValue> {
|
||||
const patch: Record<string, JsonValue> = {};
|
||||
for (const [id, value] of Object.entries(this.currentSettings())) {
|
||||
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
private reportSettingChanges(): AgentStreamEvent[] {
|
||||
if (this.applyingOptions) return [];
|
||||
const patch = this.settingDifferences(this.knownSettings);
|
||||
if (Object.keys(patch).length === 0) return [];
|
||||
this.knownSettings = this.currentSettings();
|
||||
return [{ type: "options_changed", provider: this.provider, patch }];
|
||||
}
|
||||
|
||||
private translateConfig(
|
||||
event: Extract<ProviderEvent, { type: "session.config" }>,
|
||||
): AgentStreamEvent[] {
|
||||
return [
|
||||
...this.reportSettingChanges(),
|
||||
{
|
||||
type: "mode_changed",
|
||||
provider: this.provider,
|
||||
@@ -1938,8 +1852,7 @@ function mapSessionConfig(
|
||||
model: config.model,
|
||||
mode: config.modeId,
|
||||
thinkingOption: config.thinkingOptionId,
|
||||
// The plugin SDK reads composer values from `settings`; they are options here.
|
||||
settings: toJsonObject(config.providerOptions ?? {}, "provider settings"),
|
||||
settings: toJsonObject(config.featureValues ?? {}, "provider settings"),
|
||||
providerOptions: config.providerOptions,
|
||||
title: config.title ?? undefined,
|
||||
persist,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import type { z } from "zod";
|
||||
|
||||
export interface ProviderOptionIssue {
|
||||
@@ -104,26 +103,3 @@ function isPlainObject(value: unknown): value is ProviderOptions {
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rejects a live patch that changes a key the running session cannot apply (one the provider
|
||||
* reads only when it opens). A key whose value already matches the session's is not a change.
|
||||
*/
|
||||
export function assertChangesAreLive(input: {
|
||||
provider: string;
|
||||
patch: ProviderOptions;
|
||||
current: ProviderOptions | undefined;
|
||||
live: readonly string[];
|
||||
}): void {
|
||||
const issues = Object.entries(input.patch)
|
||||
.filter(
|
||||
([key, value]) =>
|
||||
!input.live.includes(key) &&
|
||||
!isDeepStrictEqual(input.current?.[key] ?? null, value ?? null),
|
||||
)
|
||||
.map(([key]) => ({
|
||||
path: [key],
|
||||
message: "cannot change on a running session; it applies when the agent is opened",
|
||||
}));
|
||||
if (issues.length > 0) throw new ProviderOptionsValidationError(input.provider, issues);
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
"applyOptions",
|
||||
"setFeature",
|
||||
"revertConversation",
|
||||
"revertFiles",
|
||||
"revertBoth",
|
||||
@@ -152,8 +152,8 @@ class FakeSession implements AgentSession {
|
||||
this.recordedCalls.push("setThinkingOption");
|
||||
}
|
||||
|
||||
async applyOptions() {
|
||||
this.recordedCalls.push("applyOptions");
|
||||
async setFeature() {
|
||||
this.recordedCalls.push("setFeature");
|
||||
}
|
||||
|
||||
async revertConversation() {
|
||||
@@ -193,7 +193,7 @@ describe("wrapSessionProvider", () => {
|
||||
await wrapped.listCommands?.();
|
||||
await wrapped.setModel?.("sonnet");
|
||||
await wrapped.setThinkingOption?.("high");
|
||||
await wrapped.applyOptions?.({ "feature-1": true });
|
||||
await wrapped.setFeature?.("feature-1", true);
|
||||
await wrapped.revertConversation?.({ messageId: "message-1" });
|
||||
await wrapped.revertFiles?.({ messageId: "message-1" });
|
||||
await wrapped.revertBoth?.({ messageId: "message-1" });
|
||||
@@ -208,7 +208,7 @@ describe("wrapSessionProvider", () => {
|
||||
"listCommands",
|
||||
"setModel",
|
||||
"setThinkingOption",
|
||||
"applyOptions",
|
||||
"setFeature",
|
||||
"revertConversation",
|
||||
"revertFiles",
|
||||
"revertBoth",
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { ToolPolicy } from "@getpaseo/protocol/agent-types";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentOptionControl,
|
||||
AgentFeature,
|
||||
AgentModelDefinition,
|
||||
AgentMode,
|
||||
AgentSessionConfig,
|
||||
@@ -392,15 +392,15 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
|
||||
return true;
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
mockState.cursorListFeaturesConfigs.push(config);
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
key: "fast",
|
||||
id: "fast",
|
||||
label: "Fast",
|
||||
defaultValue: "false",
|
||||
choices: [{ value: "false", label: "Off" }],
|
||||
value: "false",
|
||||
options: [{ id: "false", label: "Off" }],
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -791,33 +791,6 @@ test("ACP provider configuration retains opaque options", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a saved legacy Codex Fast preference wins over a configured service_tier default", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: { codex: { options: { service_tier: "default", plan_mode: false } } },
|
||||
});
|
||||
const client = registry.codex.createClient(logger);
|
||||
|
||||
// The manager hands the registry a pre-v0.10 record's `fast_mode` already converted.
|
||||
expect(client.effectiveOptions?.({ service_tier: "fast" })).toEqual({
|
||||
service_tier: "fast",
|
||||
plan_mode: false,
|
||||
});
|
||||
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "default", plan_mode: false });
|
||||
});
|
||||
|
||||
test("a configured legacy Codex Fast default supplies the service_tier default", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: { codex: { options: { fast_mode: true } } },
|
||||
});
|
||||
const client = registry.codex.createClient(logger);
|
||||
|
||||
// Removing an agent's own speed falls back to the configured Fast.
|
||||
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "fast" });
|
||||
expect(client.effectiveOptions?.({ service_tier: "default" })).toEqual({
|
||||
service_tier: "default",
|
||||
});
|
||||
});
|
||||
|
||||
test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
@@ -850,7 +823,7 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
|
||||
expect(mockState.constructorArgs.genericAcp).toEqual([]);
|
||||
});
|
||||
|
||||
test("wrapped cursor client lists ACP controls through the inner provider", async () => {
|
||||
test("wrapped cursor client lists ACP features through the inner provider", async () => {
|
||||
const registry = buildProviderRegistry(logger, {
|
||||
providerOverrides: {
|
||||
cursor: {
|
||||
@@ -864,17 +837,17 @@ test("wrapped cursor client lists ACP controls through the inner provider", asyn
|
||||
const client = registry.cursor.createClient(logger);
|
||||
|
||||
await expect(
|
||||
client.listControls?.({
|
||||
client.listFeatures?.({
|
||||
provider: "cursor",
|
||||
cwd: "/tmp/cursor",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{
|
||||
type: "select",
|
||||
key: "fast",
|
||||
id: "fast",
|
||||
label: "Fast",
|
||||
defaultValue: "false",
|
||||
choices: [{ value: "false", label: "Off" }],
|
||||
value: "false",
|
||||
options: [{ id: "false", label: "Off" }],
|
||||
},
|
||||
]);
|
||||
expect(mockState.cursorListFeaturesConfigs).toEqual([
|
||||
|
||||
@@ -48,7 +48,6 @@ import { PiRpcAgentClient } from "./providers/pi/agent.js";
|
||||
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
|
||||
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
|
||||
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
|
||||
import { convertLegacyOptions } from "./option-controls.js";
|
||||
import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js";
|
||||
import {
|
||||
AGENT_PROVIDER_DEFINITIONS,
|
||||
@@ -448,11 +447,8 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
get initialTimeline() {
|
||||
return inner.initialTimeline;
|
||||
},
|
||||
get controls() {
|
||||
return inner.controls;
|
||||
},
|
||||
get openOptionsPatch() {
|
||||
return inner.openOptionsPatch;
|
||||
get features() {
|
||||
return inner.features;
|
||||
},
|
||||
usageSession: inner.usageSession?.bind(inner),
|
||||
run: (prompt, options) => inner.run(prompt, options),
|
||||
@@ -477,7 +473,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
|
||||
listCommands: inner.listCommands?.bind(inner),
|
||||
setModel: inner.setModel?.bind(inner),
|
||||
setThinkingOption: inner.setThinkingOption?.bind(inner),
|
||||
applyOptions: inner.applyOptions?.bind(inner),
|
||||
setFeature: inner.setFeature?.bind(inner),
|
||||
revertConversation: inner.revertConversation?.bind(inner),
|
||||
revertFiles: inner.revertFiles?.bind(inner),
|
||||
revertBoth: inner.revertBoth?.bind(inner),
|
||||
@@ -491,20 +487,14 @@ function wrapClientProvider(
|
||||
profileModels: ProviderProfileModel[],
|
||||
additionalModels: ProviderProfileModel[],
|
||||
profileModelsAreAdditive: boolean,
|
||||
configuredOptions: ProviderOptions | undefined,
|
||||
providerOptions: ProviderOptions | undefined,
|
||||
): AgentClient {
|
||||
const listImportableSessions = inner.listImportableSessions?.bind(inner);
|
||||
const importSession = inner.importSession?.bind(inner);
|
||||
const listControls = inner.listControls?.bind(inner);
|
||||
const listFeatures = inner.listFeatures?.bind(inner);
|
||||
const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
|
||||
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
|
||||
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
|
||||
// Converted where it is used, so a malformed configured default fails the call that reads it
|
||||
// rather than the registry that holds every provider.
|
||||
const configuredDefaults = () =>
|
||||
convertLegacyOptions({ provider: inner.provider, options: configuredOptions });
|
||||
const withDefaults = (own: ProviderOptions | undefined) =>
|
||||
mergeProviderOptions(configuredDefaults(), own);
|
||||
|
||||
return {
|
||||
provider,
|
||||
@@ -519,7 +509,7 @@ function wrapClientProvider(
|
||||
{
|
||||
...config,
|
||||
provider: inner.provider,
|
||||
providerOptions: withDefaults(config.providerOptions),
|
||||
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
|
||||
},
|
||||
launchContext,
|
||||
{ ...options, configuredModelIds },
|
||||
@@ -536,17 +526,14 @@ function wrapClientProvider(
|
||||
{
|
||||
...overrides,
|
||||
provider: inner.provider,
|
||||
providerOptions: withDefaults(overrides?.providerOptions),
|
||||
providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions),
|
||||
},
|
||||
launchContext,
|
||||
{ ...options, configuredModelIds },
|
||||
),
|
||||
),
|
||||
fetchCatalog: async (options, context) => {
|
||||
const catalog = await inner.fetchCatalog(
|
||||
{ ...options, providerOptions: configuredDefaults() },
|
||||
context,
|
||||
);
|
||||
const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context);
|
||||
return {
|
||||
...catalog,
|
||||
models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
|
||||
@@ -561,7 +548,7 @@ function wrapClientProvider(
|
||||
config: {
|
||||
...config,
|
||||
provider: inner.provider,
|
||||
providerOptions: withDefaults(config.providerOptions),
|
||||
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
|
||||
},
|
||||
env,
|
||||
signal,
|
||||
@@ -570,18 +557,16 @@ function wrapClientProvider(
|
||||
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
|
||||
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
|
||||
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
|
||||
effectiveOptions: (own) => withDefaults(own) ?? {},
|
||||
listControls: listControls
|
||||
listFeatures: listFeatures
|
||||
? async (config) =>
|
||||
await listControls({
|
||||
await listFeatures({
|
||||
...config,
|
||||
provider: inner.provider,
|
||||
providerOptions: withDefaults(config.providerOptions),
|
||||
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
|
||||
})
|
||||
: undefined,
|
||||
listImportableSessions: listImportableSessions
|
||||
? async (options) =>
|
||||
await listImportableSessions({ ...options, providerOptions: configuredDefaults() })
|
||||
? async (options) => await listImportableSessions({ ...options, providerOptions })
|
||||
: undefined,
|
||||
importSession: importSession
|
||||
? async (input, context) => {
|
||||
@@ -590,7 +575,10 @@ function wrapClientProvider(
|
||||
config: {
|
||||
...context.config,
|
||||
provider: inner.provider,
|
||||
providerOptions: withDefaults(context.config.providerOptions),
|
||||
providerOptions: mergeProviderOptions(
|
||||
providerOptions,
|
||||
context.config.providerOptions,
|
||||
),
|
||||
},
|
||||
storedConfig: {
|
||||
...context.storedConfig,
|
||||
|
||||
@@ -1307,7 +1307,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
resolverInputs.push(input);
|
||||
return {
|
||||
modeId: input.parent?.isUnattended ? "child-unattended" : undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -1338,17 +1338,17 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent,
|
||||
unattended: false,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ modeId: "child-unattended", providerOptions: undefined });
|
||||
expect(resolved).toEqual({ modeId: "child-unattended", featureValues: undefined });
|
||||
expect(resolverInputs).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent: {
|
||||
provider: "claude",
|
||||
modeId: "parent-unattended",
|
||||
@@ -1386,7 +1386,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
resolverInputs.push(input);
|
||||
return {
|
||||
modeId: input.unattended ? "worker" : undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
};
|
||||
},
|
||||
}),
|
||||
@@ -1397,17 +1397,17 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
cwd: "/tmp/project",
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ modeId: "worker", providerOptions: undefined });
|
||||
expect(resolved).toEqual({ modeId: "worker", featureValues: undefined });
|
||||
expect(resolverInputs).toEqual([
|
||||
{
|
||||
provider: "codex",
|
||||
requestedMode: undefined,
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: modes,
|
||||
@@ -1456,7 +1456,7 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
config: {
|
||||
provider: "opencode",
|
||||
cwd: "/tmp/project",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1464,28 +1464,12 @@ describe("ProviderSnapshotManager public surface", () => {
|
||||
cwd: "/tmp/project",
|
||||
provider: "opencode",
|
||||
requestedMode: "base",
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent,
|
||||
unattended: false,
|
||||
});
|
||||
|
||||
expect(resolved).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
|
||||
|
||||
// A parent whose Auto Accept comes from the provider's config.json default has no own
|
||||
// option; it is classified on its effective options.
|
||||
const inheriting = await manager.resolveCreateConfig({
|
||||
cwd: "/tmp/project",
|
||||
provider: "opencode",
|
||||
requestedMode: "base",
|
||||
providerOptions: undefined,
|
||||
parent: {
|
||||
...parent,
|
||||
config: { provider: "opencode", cwd: "/tmp/project" },
|
||||
effectiveOptions: { auto_accept: true },
|
||||
},
|
||||
unattended: false,
|
||||
});
|
||||
expect(inheriting).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
|
||||
expect(resolved).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
|
||||
} finally {
|
||||
manager.destroy();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { normalizeAgentModelCatalog, type ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types";
|
||||
import { createHash } from "node:crypto";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { homedir } from "node:os";
|
||||
@@ -160,14 +160,14 @@ export interface ResolveProviderCreateConfigOptions {
|
||||
cwd?: string | null;
|
||||
provider: AgentProvider;
|
||||
requestedMode: string | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
parent: ManagedAgent | null;
|
||||
unattended: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedProviderCreateConfig {
|
||||
modeId: string | undefined;
|
||||
providerOptions: ProviderOptions | undefined;
|
||||
featureValues: Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
interface ResolveDefaultModelOptions {
|
||||
@@ -563,7 +563,7 @@ export class ProviderSnapshotManager {
|
||||
return definition.resolveCreateConfig({
|
||||
provider: input.provider,
|
||||
requestedMode: input.requestedMode,
|
||||
providerOptions: input.providerOptions,
|
||||
featureValues: input.featureValues,
|
||||
parent,
|
||||
unattended: input.unattended || parent?.isUnattended === true,
|
||||
availableModes: entry.modes ?? [],
|
||||
@@ -744,13 +744,8 @@ export class ProviderSnapshotManager {
|
||||
modeId: parent.currentModeId,
|
||||
isUnattended: definition.isCreateConfigUnattended({
|
||||
modeId: parent.currentModeId,
|
||||
// Provider defaults from `config.json` apply to the parent too, so classify on the
|
||||
// effective options while the stored overrides stay as they are.
|
||||
config: {
|
||||
...parent.config,
|
||||
providerOptions: parent.effectiveOptions ?? parent.config.providerOptions,
|
||||
},
|
||||
controls: parent.controls,
|
||||
config: parent.config,
|
||||
features: parent.features,
|
||||
availableModes: parent.availableModes ?? definition.modes ?? [],
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
AgentSideConnection,
|
||||
@@ -219,7 +219,7 @@ function createSessionWithConfig(
|
||||
provider?: string;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
featureValues?: Record<string, unknown>;
|
||||
} = {},
|
||||
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
|
||||
): ACPAgentSession {
|
||||
@@ -229,7 +229,7 @@ function createSessionWithConfig(
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: config.modeId ?? undefined,
|
||||
model: config.model ?? undefined,
|
||||
providerOptions: config.providerOptions,
|
||||
featureValues: config.featureValues,
|
||||
},
|
||||
{
|
||||
provider: config.provider ?? "claude-acp",
|
||||
@@ -321,20 +321,19 @@ function selectConfigOption(
|
||||
|
||||
function createCopilotSessionWithConfig(
|
||||
modeId?: string | null,
|
||||
providerOptions?: Record<string, unknown>,
|
||||
defaultCommand: string[] = ["copilot", "--acp"],
|
||||
featureValues?: Record<string, unknown>,
|
||||
): ACPAgentSession {
|
||||
return new ACPAgentSession(
|
||||
{
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/paseo-acp-test",
|
||||
modeId: modeId ?? undefined,
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
...(featureValues ? { featureValues } : {}),
|
||||
},
|
||||
{
|
||||
provider: "copilot",
|
||||
logger: createTestLogger(),
|
||||
defaultCommand,
|
||||
defaultCommand: ["copilot", "--acp"],
|
||||
defaultModes: COPILOT_MODES,
|
||||
sessionResponseTransformer: transformCopilotSessionResponse,
|
||||
configOptionsTransformer: transformCopilotConfigOptions,
|
||||
@@ -1520,7 +1519,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
test("auto-accepts ACP permission requests when the shared feature is enabled", async () => {
|
||||
const session = createSessionWithConfig({
|
||||
provider: "cursor-acp",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const events: Array<{ type: string }> = [];
|
||||
|
||||
@@ -1552,7 +1551,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
test("does not auto-accept ACP chooser requests", async () => {
|
||||
const session = createSessionWithConfig({
|
||||
provider: "kimi-acp",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
@@ -1605,8 +1604,11 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
internals.sessionId = "session-1";
|
||||
internals.connection = {};
|
||||
|
||||
await session.applyOptions({ auto_accept: true });
|
||||
await session.setFeature("auto_accept", true);
|
||||
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ type: "toggle", id: "auto_accept", value: true }),
|
||||
);
|
||||
await expect(
|
||||
session.requestPermission({
|
||||
sessionId: "session-1",
|
||||
@@ -1624,7 +1626,7 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
});
|
||||
|
||||
test("surfaces an ACP permission when auto-accept has no allow option", async () => {
|
||||
const session = createSessionWithConfig({ providerOptions: { auto_accept: true } });
|
||||
const session = createSessionWithConfig({ featureValues: { auto_accept: true } });
|
||||
const events: Array<{ type: string; request?: { id: string } }> = [];
|
||||
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
|
||||
session.subscribe((event) => events.push(event as { type: string; request?: { id: string } }));
|
||||
@@ -1784,18 +1786,35 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
const internals = asInternals<ACPSessionInternals>(session);
|
||||
internals.configOptions = [copilotAgentConfigOption("")];
|
||||
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
{
|
||||
type: "select",
|
||||
key: "agent",
|
||||
id: "agent",
|
||||
label: "Agent",
|
||||
description: "Use a Copilot custom agent profile",
|
||||
tooltip: "Select Copilot agent",
|
||||
icon: undefined,
|
||||
choices: [
|
||||
{ value: "", label: "Default", description: undefined },
|
||||
{ value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" },
|
||||
value: "",
|
||||
options: [
|
||||
{
|
||||
id: "",
|
||||
label: "Default",
|
||||
description: undefined,
|
||||
isDefault: true,
|
||||
metadata: undefined,
|
||||
},
|
||||
{
|
||||
id: "Probe Agent",
|
||||
label: "Probe Agent",
|
||||
description: "Temporary probe agent",
|
||||
isDefault: false,
|
||||
metadata: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
@@ -1818,9 +1837,15 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ key: "auto_accept" }),
|
||||
expect.objectContaining({ key: "agent" }),
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1834,188 +1859,26 @@ describe("ACPAgentSession Zed parity", () => {
|
||||
connection: { setSessionConfigOption },
|
||||
});
|
||||
|
||||
await session.applyOptions({ agent: "Probe Agent" });
|
||||
await session.setFeature("agent", "Probe Agent");
|
||||
|
||||
expect(setSessionConfigOption).toHaveBeenCalledWith({
|
||||
sessionId: "session-1",
|
||||
configId: "agent",
|
||||
value: "Probe Agent",
|
||||
});
|
||||
expect(session.controls).toEqual([
|
||||
expect.objectContaining({ key: "auto_accept" }),
|
||||
expect.objectContaining({ key: "agent" }),
|
||||
expect(session.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACPAgentSession options against an ACP agent", () => {
|
||||
const fakeAgentPath = fileURLToPath(new URL("./test-utils/fake-acp-agent.mjs", import.meta.url));
|
||||
const sessions: ACPAgentSession[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(sessions.splice(0).map((session) => session.close()));
|
||||
});
|
||||
|
||||
function thoughtOption(currentValue: string): SessionConfigOption {
|
||||
return {
|
||||
id: "thought",
|
||||
name: "Thinking",
|
||||
category: "thought_level",
|
||||
type: "select",
|
||||
currentValue,
|
||||
options: [
|
||||
{ value: "low", name: "Low" },
|
||||
{ value: "high", name: "High" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function openSession(input: {
|
||||
agent?: string;
|
||||
scenario?: Record<string, unknown>;
|
||||
providerOptions?: Record<string, unknown>;
|
||||
}): Promise<{ session: ACPAgentSession; optionEvents: AgentStreamEvent[] }> {
|
||||
const scenario = {
|
||||
options: [copilotAgentConfigOption(input.agent ?? ""), thoughtOption("low")],
|
||||
...input.scenario,
|
||||
};
|
||||
await mkdir("/tmp/paseo-acp-test", { recursive: true });
|
||||
const session = createCopilotSessionWithConfig(null, input.providerOptions, [
|
||||
process.execPath,
|
||||
fakeAgentPath,
|
||||
JSON.stringify(scenario),
|
||||
]);
|
||||
sessions.push(session);
|
||||
const optionEvents: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => {
|
||||
if (event.type === "options_changed") optionEvents.push(event);
|
||||
});
|
||||
await session.initializeNewSession();
|
||||
return { session, optionEvents };
|
||||
}
|
||||
|
||||
test("a select the agent opened differently from the options is the open patch, not an event", async () => {
|
||||
const { session, optionEvents } = await openSession({
|
||||
providerOptions: { agent: "Probe Agent" },
|
||||
scenario: { answers: { agent: "" } },
|
||||
});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ agent: "" });
|
||||
expect(optionEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("a select the agent opened with that nobody set is in the open patch", async () => {
|
||||
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ agent: "Probe Agent" });
|
||||
expect(optionEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("a notification that arrives before the open write is answered is not an event", async () => {
|
||||
const { session, optionEvents } = await openSession({
|
||||
providerOptions: { agent: "Probe Agent" },
|
||||
scenario: { onWrite: "notify-first" },
|
||||
});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({});
|
||||
expect(optionEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("applying a patch returns the selects that ended up different and emits nothing", async () => {
|
||||
const { session, optionEvents } = await openSession({ scenario: { answers: { agent: "" } } });
|
||||
|
||||
await expect(session.applyOptions({ agent: "Probe Agent" })).resolves.toEqual({ agent: "" });
|
||||
expect(optionEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("a notification that arrives before the apply write is answered is not an event", async () => {
|
||||
const { session, optionEvents } = await openSession({ scenario: { onWrite: "notify-first" } });
|
||||
|
||||
await expect(session.applyOptions({ agent: "Probe Agent" })).resolves.toEqual({});
|
||||
expect(optionEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("a rejected apply reports what the agent moved before refusing, since nothing is stored", async () => {
|
||||
const { session, optionEvents } = await openSession({
|
||||
scenario: { onWrite: "notify-then-reject" },
|
||||
});
|
||||
|
||||
await expect(session.applyOptions({ agent: "Probe Agent" })).rejects.toThrow(/write refused/);
|
||||
expect(optionEvents).toEqual([
|
||||
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("overlapping applies that both fail still report what the agent moved", async () => {
|
||||
const { session, optionEvents } = await openSession({ scenario: { onWrite: "slow-reject" } });
|
||||
|
||||
const first = session.applyOptions({ agent: "Probe Agent" });
|
||||
const second = session.applyOptions({ agent: null });
|
||||
|
||||
await expect(first).rejects.toThrow(/write refused/);
|
||||
await expect(second).rejects.toThrow(/write refused/);
|
||||
expect(optionEvents).toEqual([
|
||||
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a removal writes the select's empty choice and returns it, since the options hold what the session runs", async () => {
|
||||
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
|
||||
|
||||
await expect(session.applyOptions({ agent: null })).resolves.toEqual({ agent: "" });
|
||||
expect(optionEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("a removal of a select already on its empty choice returns that choice too", async () => {
|
||||
const { session } = await openSession({ agent: "" });
|
||||
|
||||
await expect(session.applyOptions({ agent: null })).resolves.toEqual({ agent: "" });
|
||||
});
|
||||
|
||||
test("rejects a live auto_accept the creation schema would reject, and writes nothing", async () => {
|
||||
const { session, optionEvents } = await openSession({
|
||||
scenario: { onWrite: "notify-then-reject" },
|
||||
});
|
||||
|
||||
await expect(
|
||||
session.applyOptions({ auto_accept: "yes", agent: "Probe Agent" }),
|
||||
).rejects.toThrow(/auto_accept/);
|
||||
expect(optionEvents).toEqual([]);
|
||||
});
|
||||
|
||||
test("forwards a select the agent changes on its own", async () => {
|
||||
const { session, optionEvents } = await openSession({
|
||||
scenario: { moves: { thought: { agent: "Probe Agent" } } },
|
||||
});
|
||||
|
||||
await session.setThinkingOption("high");
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(optionEvents).toEqual([
|
||||
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test("a select that appears after startup is reported with its native value", async () => {
|
||||
const { session, optionEvents } = await openSession({
|
||||
scenario: {
|
||||
options: [thoughtOption("low")],
|
||||
adds: { thought: [copilotAgentConfigOption("Probe Agent")] },
|
||||
},
|
||||
});
|
||||
expect(session.openOptionsPatch).toEqual({});
|
||||
|
||||
await session.setThinkingOption("high");
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(optionEvents).toEqual([
|
||||
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deriveModelDefinitionsFromACP", () => {
|
||||
test("attaches shared thinking options to ACP model state", () => {
|
||||
const result = deriveModelDefinitionsFromACP(
|
||||
@@ -2242,14 +2105,14 @@ describe("ACPAgentClient config features", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
providerOptions: { provider_feature: "kept" },
|
||||
featureValues: { provider_feature: "kept" },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [],
|
||||
}),
|
||||
).toEqual({
|
||||
modeId: undefined,
|
||||
providerOptions: { provider_feature: "kept", auto_accept: true },
|
||||
featureValues: { provider_feature: "kept", auto_accept: true },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2264,12 +2127,12 @@ describe("ACPAgentClient config features", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
providerOptions: { auto_accept: false },
|
||||
featureValues: { auto_accept: false },
|
||||
parent: null,
|
||||
unattended: true,
|
||||
availableModes: [],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: false } });
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: false } });
|
||||
});
|
||||
|
||||
test("maps an unattended cross-provider parent to ACP Auto Accept", () => {
|
||||
@@ -2283,7 +2146,7 @@ describe("ACPAgentClient config features", () => {
|
||||
client.resolveCreateConfig({
|
||||
provider: "generic-acp",
|
||||
requestedMode: undefined,
|
||||
providerOptions: undefined,
|
||||
featureValues: undefined,
|
||||
parent: {
|
||||
provider: "claude",
|
||||
modeId: "bypassPermissions",
|
||||
@@ -2292,7 +2155,7 @@ describe("ACPAgentClient config features", () => {
|
||||
unattended: false,
|
||||
availableModes: [{ id: "agent", label: "Agent" }],
|
||||
}),
|
||||
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
|
||||
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
|
||||
});
|
||||
|
||||
test("treats Auto Accept as an unattended ACP configuration", () => {
|
||||
@@ -2308,7 +2171,7 @@ describe("ACPAgentClient config features", () => {
|
||||
config: {
|
||||
provider: "generic-acp",
|
||||
cwd: "/tmp/acp-features",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
},
|
||||
availableModes: [],
|
||||
}),
|
||||
@@ -2323,17 +2186,22 @@ describe("ACPAgentClient config features", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "generic-acp",
|
||||
cwd: "/tmp/acp-features",
|
||||
providerOptions: { auto_accept: true },
|
||||
featureValues: { auto_accept: true },
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
label: "Auto Accept",
|
||||
value: true,
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
test("derives controls from configured ACP select options", async () => {
|
||||
test("derives features from configured ACP select options", async () => {
|
||||
class TestACPAgentClient extends ACPAgentClient {
|
||||
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
|
||||
return {
|
||||
@@ -2359,18 +2227,23 @@ describe("ACPAgentClient config features", () => {
|
||||
});
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "copilot",
|
||||
cwd: "/tmp/acp-features",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
|
||||
expect.objectContaining({
|
||||
type: "toggle",
|
||||
id: "auto_accept",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: "select",
|
||||
key: "agent",
|
||||
choices: [
|
||||
expect.objectContaining({ value: "", label: "Default" }),
|
||||
expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }),
|
||||
id: "agent",
|
||||
value: "Probe Agent",
|
||||
options: [
|
||||
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
|
||||
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
@@ -4287,7 +4160,7 @@ describe("ACPAgentClient probe cleanup", () => {
|
||||
terminateProcess: terminator.terminate,
|
||||
});
|
||||
|
||||
await client.listControls({ provider: "copilot", cwd: "/tmp/acp-features" });
|
||||
await client.listFeatures({ provider: "copilot", cwd: "/tmp/acp-features" });
|
||||
|
||||
expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" });
|
||||
expect(terminator.terminated).toContain(child);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { ACPProviderOptionsSchema } from "./acp-options.js";
|
||||
import { applyProviderOptionsPatch } from "../option-controls.js";
|
||||
import { assertChangesAreLive } from "../provider-options.js";
|
||||
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { once } from "node:events";
|
||||
@@ -70,7 +67,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateConfigUnattendedInput,
|
||||
type AgentOptionControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -865,10 +862,10 @@ export function deriveModelDefinitionsFromACP(
|
||||
}));
|
||||
}
|
||||
|
||||
export function deriveControlsFromACP(
|
||||
export function deriveFeaturesFromACP(
|
||||
configOptions: SessionConfigOption[] | null | undefined,
|
||||
featureOptions: ACPConfigFeatureOption[],
|
||||
): AgentOptionControl[] {
|
||||
): AgentFeature[] {
|
||||
return featureOptions.flatMap((featureOption) => {
|
||||
const option = findSelectConfigFeatureOption(configOptions, featureOption);
|
||||
if (!option) {
|
||||
@@ -878,42 +875,42 @@ export function deriveControlsFromACP(
|
||||
return [
|
||||
{
|
||||
type: "select",
|
||||
key: featureOption.id,
|
||||
id: featureOption.id,
|
||||
label: featureOption.label,
|
||||
description: featureOption.description,
|
||||
tooltip: featureOption.tooltip,
|
||||
icon: featureOption.icon,
|
||||
choices: deriveConfigFeatureSelectOptions(option, featureOption).map((choice) => ({
|
||||
value: choice.id,
|
||||
label: choice.label,
|
||||
description: choice.description,
|
||||
})),
|
||||
value: option.currentValue ?? null,
|
||||
options: deriveConfigFeatureSelectOptions(option, featureOption),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
|
||||
return config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
return config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
|
||||
}
|
||||
|
||||
const ACP_AUTO_ACCEPT_CONTROL: AgentOptionControl = {
|
||||
type: "toggle",
|
||||
key: ACP_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
};
|
||||
function buildACPAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
|
||||
return {
|
||||
type: "toggle",
|
||||
id: ACP_AUTO_ACCEPT_FEATURE_ID,
|
||||
label: "Auto Accept",
|
||||
description: "Automatically approves ACP permission prompts.",
|
||||
tooltip: "Auto accept permission prompts",
|
||||
icon: "shield-check",
|
||||
value: isACPAutoAcceptEnabled(config),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveACPCreateConfig(
|
||||
input: ResolveAgentCreateConfigInput,
|
||||
): ResolveAgentCreateConfigResult {
|
||||
const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true;
|
||||
const providerOptions =
|
||||
isUnattendedCreate && input.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
|
||||
? { ...input.providerOptions, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
|
||||
: input.providerOptions;
|
||||
const featureValues =
|
||||
isUnattendedCreate && input.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
|
||||
? { ...input.featureValues, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
|
||||
: input.featureValues;
|
||||
|
||||
if (
|
||||
input.requestedMode === undefined &&
|
||||
@@ -921,16 +918,22 @@ function resolveACPCreateConfig(
|
||||
input.parent !== null &&
|
||||
input.parent.provider !== input.provider
|
||||
) {
|
||||
return { modeId: undefined, providerOptions };
|
||||
return { modeId: undefined, featureValues };
|
||||
}
|
||||
|
||||
return resolveDefaultAgentCreateConfig({ ...input, providerOptions });
|
||||
return resolveDefaultAgentCreateConfig({ ...input, featureValues });
|
||||
}
|
||||
|
||||
function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean {
|
||||
return (
|
||||
isDefaultAgentCreateConfigUnattended(input) ||
|
||||
input.config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true
|
||||
input.config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true ||
|
||||
input.features?.some(
|
||||
(feature) =>
|
||||
feature.id === ACP_AUTO_ACCEPT_FEATURE_ID &&
|
||||
feature.type === "toggle" &&
|
||||
feature.value === true,
|
||||
) === true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1209,9 +1212,10 @@ export class ACPAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
|
||||
if (this.configFeatureOptions.length === 0) {
|
||||
return [ACP_AUTO_ACCEPT_CONTROL];
|
||||
return [autoAcceptFeature];
|
||||
}
|
||||
|
||||
this.assertProvider(config);
|
||||
@@ -1227,8 +1231,8 @@ export class ACPAgentClient implements AgentClient {
|
||||
probeSessionId = response.sessionId;
|
||||
const transformed = this.transformSessionResponse(response);
|
||||
return [
|
||||
ACP_AUTO_ACCEPT_CONTROL,
|
||||
...deriveControlsFromACP(transformed.configOptions, this.configFeatureOptions),
|
||||
autoAcceptFeature,
|
||||
...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions),
|
||||
];
|
||||
} finally {
|
||||
await this.closeProbe(probe, probeSessionId);
|
||||
@@ -1771,11 +1775,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
private currentTitle: string | null = null;
|
||||
private lastActivityAt: string | null = null;
|
||||
private configOptions: SessionConfigOption[] = [];
|
||||
/** How the opened session differs from the options it was given. */
|
||||
openOptionsPatch: Record<string, unknown> = {};
|
||||
/** Selects the provider moves while Paseo is opening or applying options belong to that result, not to events. */
|
||||
private optionsTransactionDepth = 0;
|
||||
private applying: Promise<unknown> = Promise.resolve();
|
||||
private cachedCommands: AgentSlashCommand[] = [];
|
||||
/** When the last command batch arrived; null while nothing has been published. */
|
||||
private commandsUpdatedAt: number | null = null;
|
||||
@@ -2100,10 +2099,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return this.currentMode;
|
||||
}
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
get features(): AgentFeature[] {
|
||||
return [
|
||||
ACP_AUTO_ACCEPT_CONTROL,
|
||||
...deriveControlsFromACP(this.configOptions, this.configFeatureOptions),
|
||||
buildACPAutoAcceptFeature(this.config),
|
||||
...deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2244,7 +2243,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
const { connection, sessionId } = this;
|
||||
const before = this.featureValues();
|
||||
const response = await this.runACPRequest(() =>
|
||||
connection.setSessionConfigOption({
|
||||
sessionId,
|
||||
@@ -2259,7 +2257,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue: modeId,
|
||||
label: "mode",
|
||||
});
|
||||
this.reportFeatureChanges(before);
|
||||
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
|
||||
this.pushEvent({
|
||||
type: "mode_changed",
|
||||
@@ -2371,7 +2368,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
const { connection, sessionId } = this;
|
||||
const before = this.featureValues();
|
||||
const response = await this.runACPRequest(() =>
|
||||
connection.setSessionConfigOption({
|
||||
sessionId,
|
||||
@@ -2386,7 +2382,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue: modelId,
|
||||
label: "model",
|
||||
});
|
||||
this.reportFeatureChanges(before);
|
||||
this.pushEvent({
|
||||
type: "model_changed",
|
||||
provider: this.provider,
|
||||
@@ -2424,7 +2419,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
throw new Error(`${this.provider} does not expose ACP thought-level selection`);
|
||||
}
|
||||
const { connection, sessionId } = this;
|
||||
const before = this.featureValues();
|
||||
const response = await this.runACPRequest(() =>
|
||||
connection.setSessionConfigOption({
|
||||
sessionId,
|
||||
@@ -2439,7 +2433,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
requestedValue: thinkingOptionId,
|
||||
label: "thought-level",
|
||||
});
|
||||
this.reportFeatureChanges(before);
|
||||
this.pushEvent({
|
||||
type: "thinking_option_changed",
|
||||
provider: this.provider,
|
||||
@@ -2447,105 +2440,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies only the options the patch sets; returns the selects that ended up different.
|
||||
* Provider notifications that arrive meanwhile are part of that result. When a write is
|
||||
* rejected, what the provider moved is reported as an event, since nothing is stored.
|
||||
*/
|
||||
/** One apply at a time: each reconciles against the state the one before it left. */
|
||||
applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||
const run = this.applying.then(() => this.applyOptionsNow(options));
|
||||
this.applying = run.catch(() => undefined);
|
||||
return run;
|
||||
}
|
||||
|
||||
private async applyOptionsNow(options: ProviderOptions): Promise<Record<string, unknown>> {
|
||||
assertChangesAreLive({
|
||||
provider: this.config.provider,
|
||||
patch: options,
|
||||
current: this.config.providerOptions,
|
||||
live: [ACP_AUTO_ACCEPT_FEATURE_ID, ...this.configFeatureOptions.map(({ id }) => id)],
|
||||
});
|
||||
ACPProviderOptionsSchema.parse(
|
||||
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
|
||||
);
|
||||
const before = this.featureValues();
|
||||
const requested: Record<string, unknown> = {};
|
||||
try {
|
||||
await this.inOptionsTransaction(async () => {
|
||||
for (const [id, value] of Object.entries(options)) {
|
||||
const featureOption = this.configFeatureOptions.find((candidate) => candidate.id === id);
|
||||
if (!featureOption) continue;
|
||||
// A removal asks for the select's empty choice; a provider without one rejects it. The
|
||||
// choice it lands on is reported against the `null` that was asked, so the options
|
||||
// hold the value the session runs with.
|
||||
requested[id] = value;
|
||||
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
|
||||
if (!option || (value ?? "") !== option.currentValue) {
|
||||
await this.setConfigFeature(id, value);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
this.reportFeatureChanges(before);
|
||||
throw error;
|
||||
}
|
||||
// Auto Accept is Paseo's own setting, so it changes only once every write has been accepted.
|
||||
if (ACP_AUTO_ACCEPT_FEATURE_ID in options) {
|
||||
this.config.providerOptions = {
|
||||
...this.config.providerOptions,
|
||||
[ACP_AUTO_ACCEPT_FEATURE_ID]: options[ACP_AUTO_ACCEPT_FEATURE_ID] === true,
|
||||
};
|
||||
}
|
||||
return this.featureDifferences({ ...before, ...requested });
|
||||
}
|
||||
|
||||
private async inOptionsTransaction<T>(run: () => Promise<T>): Promise<T> {
|
||||
this.optionsTransactionDepth += 1;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
this.optionsTransactionDepth -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** The selects as the provider reports them now. */
|
||||
private featureValues(): Record<string, string> {
|
||||
const values: Record<string, string> = {};
|
||||
for (const featureOption of this.configFeatureOptions) {
|
||||
const current = findSelectConfigFeatureOption(
|
||||
this.configOptions,
|
||||
featureOption,
|
||||
)?.currentValue;
|
||||
if (current != null) values[featureOption.id] = current;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
/** The selects whose value is not what `expected` says, including ones it does not name. */
|
||||
private featureDifferences(expected: Record<string, unknown>): Record<string, string> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(this.featureValues()).filter(([id, value]) => expected[id] !== value),
|
||||
);
|
||||
}
|
||||
|
||||
/** Reports the selects the provider moved on its own, compared with their values `before`. */
|
||||
private reportFeatureChanges(before: Record<string, string>): void {
|
||||
if (this.optionsTransactionDepth > 0) return;
|
||||
const patch = this.featureDifferences(before);
|
||||
if (Object.keys(patch).length > 0) {
|
||||
this.pushEvent({ type: "options_changed", provider: this.provider, patch });
|
||||
}
|
||||
}
|
||||
|
||||
private async setConfigFeature(featureId: string, value: unknown): Promise<void> {
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (!this.connection || !this.sessionId) {
|
||||
throw new Error("ACP session not initialized");
|
||||
}
|
||||
|
||||
if (featureId === ACP_AUTO_ACCEPT_FEATURE_ID) {
|
||||
this.config.providerOptions = {
|
||||
...this.config.providerOptions,
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
[ACP_AUTO_ACCEPT_FEATURE_ID]: value === true,
|
||||
};
|
||||
return;
|
||||
@@ -2577,13 +2479,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
value: requestedValue,
|
||||
}),
|
||||
);
|
||||
this.applyConfigOptionResponse({
|
||||
const currentValue = this.applyConfigOptionResponse({
|
||||
response,
|
||||
configId: option.id,
|
||||
category: featureOption.category,
|
||||
requestedValue,
|
||||
label: featureOption.label,
|
||||
});
|
||||
this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue };
|
||||
}
|
||||
|
||||
private applyConfigOptionResponse({
|
||||
@@ -3129,11 +3032,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
|
||||
}
|
||||
|
||||
private applyConfiguredOverrides(): Promise<void> {
|
||||
return this.inOptionsTransaction(() => this.writeConfiguredOverrides());
|
||||
}
|
||||
|
||||
private async writeConfiguredOverrides(): Promise<void> {
|
||||
private async applyConfiguredOverrides(): Promise<void> {
|
||||
const configuredModeId = this.config.modeId;
|
||||
if (configuredModeId && configuredModeId !== this.currentMode) {
|
||||
const selection = resolveACPModeSelection({
|
||||
@@ -3165,13 +3064,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
|
||||
await this.setThinkingOption(this.config.thinkingOptionId);
|
||||
}
|
||||
const configuredFeatureValues = this.config.providerOptions ?? {};
|
||||
const configuredFeatureValues = this.config.featureValues ?? {};
|
||||
for (const featureOption of this.configFeatureOptions) {
|
||||
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await this.setConfigFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
|
||||
} catch (error) {
|
||||
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
|
||||
throw error;
|
||||
@@ -3182,9 +3081,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
);
|
||||
}
|
||||
}
|
||||
// The options are the state, so every select the session opened with is reported, including
|
||||
// ones nobody set (a native default is stored like any other value).
|
||||
this.openOptionsPatch = this.featureDifferences(configuredFeatureValues);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -3401,7 +3297,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
}
|
||||
|
||||
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
|
||||
const before = this.featureValues();
|
||||
this.configOptions = this.transformConfigOptions(update.configOptions);
|
||||
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
|
||||
const nextMode = modeInfo.currentModeId;
|
||||
@@ -3412,7 +3307,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
|
||||
this.currentMode = nextMode ?? this.currentMode;
|
||||
this.currentModel = nextModel ?? this.currentModel;
|
||||
this.thinkingOptionId = nextThinkingOptionId ?? this.thinkingOptionId;
|
||||
this.reportFeatureChanges(before);
|
||||
|
||||
const events: AgentStreamEvent[] = [];
|
||||
if (nextMode !== null) {
|
||||
|
||||
@@ -2,8 +2,6 @@ import { z } from "zod";
|
||||
|
||||
export const ACPProviderOptionsSchema = z
|
||||
.object({
|
||||
// Composer control: approves permission prompts without asking.
|
||||
auto_accept: z.boolean().optional(),
|
||||
supportsMcpServers: z.boolean().optional(),
|
||||
clientCapabilities: z
|
||||
.object({
|
||||
|
||||
@@ -18,9 +18,6 @@ import {
|
||||
resolveClaudeCodeVersion,
|
||||
toClaudeSdkMcpConfig,
|
||||
} from "./agent.js";
|
||||
import { AgentManager } from "../../agent-manager.js";
|
||||
import { ensureAgentLoaded } from "../../agent-loading.js";
|
||||
import { AgentStorage } from "../../agent-storage.js";
|
||||
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
|
||||
import { claudeProjectDirSync } from "./project-dir.js";
|
||||
import { streamSession } from "../test-utils/session-stream-adapter.js";
|
||||
@@ -33,8 +30,6 @@ import type {
|
||||
} from "../../agent-sdk-types.js";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
|
||||
import { applyProviderOptionsPatch } from "../../option-controls.js";
|
||||
import { mergeProviderOptions } from "../../provider-options.js";
|
||||
|
||||
interface TestClaudeSession {
|
||||
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
|
||||
@@ -1148,39 +1143,39 @@ describe("ClaudeAgentSession features", () => {
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8[1m]",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8-20260101",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-5",
|
||||
}),
|
||||
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
|
||||
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "openrouter/anthropic/claude-opus-4-8",
|
||||
@@ -1188,7 +1183,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-5",
|
||||
@@ -1196,7 +1191,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
).resolves.toEqual([]);
|
||||
|
||||
await expect(
|
||||
client.listControls({
|
||||
client.listFeatures({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-4-6",
|
||||
@@ -1204,25 +1199,6 @@ describe("ClaudeAgentSession features", () => {
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
test("opens with Fast off on a model that has none, so unrelated options still apply", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-sonnet-5",
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ fast_mode: false });
|
||||
await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("passes initial fast mode through Claude flag settings", async () => {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
@@ -1234,7 +1210,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: { fast_mode: true },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
|
||||
await expect(
|
||||
@@ -1706,7 +1682,7 @@ describe("ClaudeAgentSession features", () => {
|
||||
ensureQuery(): Promise<unknown>;
|
||||
}
|
||||
).ensureQuery();
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
|
||||
@@ -1715,242 +1691,6 @@ describe("ClaudeAgentSession features", () => {
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("rejects a change to a launch option while the first query is starting", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
let releaseBinary!: () => void;
|
||||
const binaryLookup = new Promise<void>((resolve) => {
|
||||
releaseBinary = resolve;
|
||||
});
|
||||
let lookupStarted!: () => void;
|
||||
const lookupEntered = new Promise<void>((resolve) => {
|
||||
lookupStarted = resolve;
|
||||
});
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => {
|
||||
lookupStarted();
|
||||
await binaryLookup;
|
||||
return "/test/claude/bin";
|
||||
},
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: { disallowedTools: ["Bash"] },
|
||||
});
|
||||
|
||||
const turn = session.startTurn("hello");
|
||||
await lookupEntered;
|
||||
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
|
||||
/disallowedTools.*cannot change on a running session/,
|
||||
);
|
||||
releaseBinary();
|
||||
await turn;
|
||||
|
||||
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ disallowedTools: ["Bash"] });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("a patch before the first prompt keeps the restrictions the session opened with", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: {
|
||||
disallowedTools: ["Bash"],
|
||||
sandbox: { enabled: true, failIfUnavailable: true },
|
||||
},
|
||||
});
|
||||
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||
|
||||
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
|
||||
disallowedTools: ["Bash"],
|
||||
sandbox: { enabled: true, failIfUnavailable: true },
|
||||
});
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("an option Claude reads when it starts changes before the query exists and is rejected after", async () => {
|
||||
const { queryFactory } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: { disallowedTools: ["Bash"] },
|
||||
});
|
||||
|
||||
await session.applyOptions?.({ disallowedTools: ["Bash", "Write"] });
|
||||
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
|
||||
disallowedTools: ["Bash", "Write"],
|
||||
});
|
||||
|
||||
// The query now runs with these; replacing it would end its background work.
|
||||
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
|
||||
/disallowedTools.*cannot change on a running session/,
|
||||
);
|
||||
// The same value is not a change, and Fast still applies.
|
||||
await expect(
|
||||
session.applyOptions?.({ disallowedTools: ["Bash", "Write"], fast_mode: true }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(queryFactory).toHaveBeenCalledTimes(1);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("switching to a model without Fast turns it off, reports that, and later patches still apply", async () => {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
providerOptions: { fast_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.setModel?.("claude-fable-5");
|
||||
|
||||
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
|
||||
const reported = events.filter((event) => event.type === "options_changed");
|
||||
expect(reported).toEqual([
|
||||
{ type: "options_changed", provider: "claude", patch: { fast_mode: false } },
|
||||
]);
|
||||
// With a `config.json` default of `fast_mode: true`, the agent's effective options must
|
||||
// still read Fast as off: a removed key would let the default fill `true` back in.
|
||||
const stored = applyProviderOptionsPatch({
|
||||
options: { fast_mode: true },
|
||||
patch: reported[0].patch,
|
||||
});
|
||||
expect(mergeProviderOptions({ fast_mode: true }, stored)).toEqual({ fast_mode: false });
|
||||
await expect(session.applyOptions?.({ fast_mode: null })).resolves.toBeUndefined();
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("removing fast_mode turns Fast off", async () => {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
});
|
||||
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
|
||||
await session.applyOptions?.({ fast_mode: null });
|
||||
|
||||
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("retries a fast mode change the runtime rejected", async () => {
|
||||
const { queryFactory, queryMock } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
model: "claude-opus-4-8",
|
||||
});
|
||||
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
|
||||
queryMock.applyFlagSettings.mockClear();
|
||||
queryMock.applyFlagSettings.mockRejectedValueOnce(new Error("runtime unavailable"));
|
||||
|
||||
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
|
||||
"runtime unavailable",
|
||||
);
|
||||
await session.applyOptions?.({ fast_mode: true });
|
||||
|
||||
expect(queryMock.applyFlagSettings).toHaveBeenCalledTimes(2);
|
||||
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
|
||||
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("fast mode survives a daemon restart through the stored providerOptions", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-fast-restart-"));
|
||||
const storagePath = path.join(root, "agents");
|
||||
const makeClient = () => {
|
||||
const mock = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory: mock.queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
return { ...mock, client };
|
||||
};
|
||||
const agentId = "00000000-0000-4000-8000-0000000000f1";
|
||||
const before = makeClient();
|
||||
const storage = new AgentStorage(storagePath, logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { claude: before.client },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
try {
|
||||
await manager.createAgent(
|
||||
{ provider: "claude", cwd: root, model: "claude-opus-4-8" },
|
||||
agentId,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
await manager.setAgentOptions(agentId, { fast_mode: true });
|
||||
await manager.closeAgent(agentId);
|
||||
await manager.flush();
|
||||
await storage.flush();
|
||||
|
||||
const after = makeClient();
|
||||
const restartedStorage = new AgentStorage(storagePath, logger);
|
||||
const restarted = new AgentManager({
|
||||
clients: { claude: after.client },
|
||||
registry: restartedStorage,
|
||||
logger,
|
||||
});
|
||||
const agent = await ensureAgentLoaded(agentId, {
|
||||
agentManager: restarted,
|
||||
agentStorage: restartedStorage,
|
||||
logger,
|
||||
});
|
||||
const session = (agent as unknown as { session: { ensureQuery(): Promise<unknown> } | null })
|
||||
.session;
|
||||
await session?.ensureQuery();
|
||||
|
||||
expect(restarted.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: true });
|
||||
expect(after.queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({
|
||||
fastMode: true,
|
||||
});
|
||||
await restarted.closeAgent(agentId);
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
|
||||
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
|
||||
import { applyProviderOptionsPatch } from "../../option-controls.js";
|
||||
import { assertChangesAreLive, validateProviderOptions } from "../../provider-options.js";
|
||||
import { validateProviderOptions } from "../../provider-options.js";
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
@@ -67,7 +65,7 @@ import { foldSubagentObservations, type SubagentObservation } from "./subagents/
|
||||
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
|
||||
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
|
||||
import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js";
|
||||
import { buildClaudeControls, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
|
||||
import {
|
||||
buildBinaryDiagnosticRows,
|
||||
buildCommandResolutionDiagnosticRows,
|
||||
@@ -106,7 +104,7 @@ import {
|
||||
type AgentCapabilityFlags,
|
||||
type AgentClient,
|
||||
type AgentCreateSessionOptions,
|
||||
type AgentOptionControl,
|
||||
type AgentFeature,
|
||||
type AgentLaunchContext,
|
||||
type AgentMetadata,
|
||||
type AgentMode,
|
||||
@@ -1754,8 +1752,12 @@ export class ClaudeAgentClient implements AgentClient {
|
||||
});
|
||||
}
|
||||
|
||||
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
|
||||
return buildClaudeControls(this.assertConfig(config).model);
|
||||
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
|
||||
const claudeConfig = this.assertConfig(config);
|
||||
return buildClaudeFeatures({
|
||||
modelId: claudeConfig.model,
|
||||
fastModeEnabled: claudeConfig.featureValues?.fast_mode === true,
|
||||
});
|
||||
}
|
||||
|
||||
async listImportableSessions(
|
||||
@@ -2378,8 +2380,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private compactionMarkerOpen = false;
|
||||
private queryPumpPromise: Promise<void> | null = null;
|
||||
private queryRestartNeeded = false;
|
||||
/** True from the moment a query's launch options are copied until the query exists. */
|
||||
private queryStarting = false;
|
||||
private pendingInterruptAbort = false;
|
||||
/**
|
||||
* Whether Claude's main session is inside one of its own turns: set by system/init and by a
|
||||
@@ -2403,7 +2403,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private pendingFreshSessionId: string | null = null;
|
||||
private recentStderr = "";
|
||||
private closed = false;
|
||||
readonly openOptionsPatch: Record<string, unknown> = {};
|
||||
|
||||
constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) {
|
||||
this.config = config;
|
||||
@@ -2447,21 +2446,17 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (this.currentMode !== "plan") {
|
||||
this.planResumeMode = this.currentMode;
|
||||
}
|
||||
|
||||
// Fast cannot run on this model, so the session opens with it off. Off rather than removed
|
||||
// keeps a `config.json` default of `fast_mode: true` from filling it back in.
|
||||
if (config.providerOptions.fast_mode === true && !claudeModelSupportsFastMode(config.model)) {
|
||||
this.config.providerOptions = { ...config.providerOptions, fast_mode: false };
|
||||
this.openOptionsPatch = { fast_mode: false };
|
||||
}
|
||||
}
|
||||
|
||||
get id(): string | null {
|
||||
return this.claudeSessionId;
|
||||
}
|
||||
|
||||
get controls(): AgentOptionControl[] {
|
||||
return buildClaudeControls(this.config.model);
|
||||
get features(): AgentFeature[] {
|
||||
return buildClaudeFeatures({
|
||||
modelId: this.config.model,
|
||||
fastModeEnabled: this.config.featureValues?.fast_mode === true,
|
||||
});
|
||||
}
|
||||
|
||||
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
|
||||
@@ -2760,20 +2755,8 @@ class ClaudeAgentSession implements AgentSession {
|
||||
await activeQuery.setModel(normalizedModelId ?? undefined);
|
||||
this.config.model = normalizedModelId ?? undefined;
|
||||
this.reconcileThinkingOptionForModel(normalizedModelId);
|
||||
if (this.config.providerOptions.fast_mode) {
|
||||
const supported = claudeModelSupportsFastMode(this.config.model);
|
||||
await activeQuery.applyFlagSettings({ fastMode: supported });
|
||||
if (!supported) {
|
||||
// Fast cannot run on this model. Reporting it off rather than removed keeps a
|
||||
// `config.json` default of `fast_mode: true` from filling it back in.
|
||||
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: false };
|
||||
this.pushEvent({
|
||||
type: "options_changed",
|
||||
provider: "claude",
|
||||
patch: { fast_mode: false },
|
||||
});
|
||||
}
|
||||
this.cachedRuntimeInfo = null;
|
||||
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) {
|
||||
await this.applyFastModeFeature(false, activeQuery);
|
||||
}
|
||||
this.contextUsage.setInitialContextWindowMaxTokens(
|
||||
getClaudeContextWindowMaxTokens(this.config.model),
|
||||
@@ -2825,41 +2808,31 @@ class ClaudeAgentSession implements AgentSession {
|
||||
}
|
||||
}
|
||||
|
||||
async applyOptions(options: ProviderOptions): Promise<void> {
|
||||
// Every option but Fast is read when the query is built, and replacing a started query would
|
||||
// end its background work, so once it exists only Fast can change.
|
||||
if (this.query || this.queryStarting) {
|
||||
assertChangesAreLive({
|
||||
provider: "claude",
|
||||
patch: options,
|
||||
current: this.config.providerOptions,
|
||||
live: ["fast_mode"],
|
||||
});
|
||||
async setFeature(featureId: string, value: unknown): Promise<void> {
|
||||
if (featureId !== "fast_mode") {
|
||||
throw new Error(`Unknown Claude feature: ${featureId}`);
|
||||
}
|
||||
const providerOptions =
|
||||
validateProviderOptions(
|
||||
"claude",
|
||||
ClaudeProviderOptionsSchema,
|
||||
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
|
||||
) ?? {};
|
||||
if (providerOptions.fast_mode === true && !claudeModelSupportsFastMode(this.config.model)) {
|
||||
|
||||
const enabled = Boolean(value);
|
||||
if (enabled && !claudeModelSupportsFastMode(this.config.model)) {
|
||||
throw new Error(
|
||||
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
|
||||
);
|
||||
}
|
||||
const fastModeChanged =
|
||||
(providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true);
|
||||
if (fastModeChanged) {
|
||||
await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true });
|
||||
this.cachedRuntimeInfo = null;
|
||||
|
||||
await this.applyFastModeFeature(enabled);
|
||||
}
|
||||
|
||||
private async applyFastModeFeature(enabled: boolean, query?: Query): Promise<void> {
|
||||
this.config.featureValues = {
|
||||
...this.config.featureValues,
|
||||
fast_mode: enabled,
|
||||
};
|
||||
const activeQuery = query ?? this.query;
|
||||
if (activeQuery) {
|
||||
await activeQuery.applyFlagSettings({ fastMode: enabled });
|
||||
}
|
||||
// Held until the runtime accepts, so a rejected change is retried instead of skipped. Only
|
||||
// this call's keys are applied, over the options as they are now: another patch may have
|
||||
// landed while the runtime was answering.
|
||||
this.config.providerOptions = applyProviderOptionsPatch({
|
||||
options: this.config.providerOptions,
|
||||
patch: options,
|
||||
});
|
||||
this.cachedRuntimeInfo = null;
|
||||
}
|
||||
|
||||
getPendingPermissions(): AgentPermissionRequest[] {
|
||||
@@ -3553,17 +3526,9 @@ class ClaudeAgentSession implements AgentSession {
|
||||
this.persistence = null;
|
||||
|
||||
const input = createAsyncMessageInput<SDKUserMessage>();
|
||||
// buildOptions copies the launch options before it waits, so from here they are fixed.
|
||||
this.queryStarting = true;
|
||||
let options: ClaudeOptions;
|
||||
try {
|
||||
options = await this.buildOptions(launchMode);
|
||||
// Closure can finish while launch options are being prepared.
|
||||
if (this.closed) throw new Error("Claude session is closed");
|
||||
} catch (error) {
|
||||
this.queryStarting = false;
|
||||
throw error;
|
||||
}
|
||||
const options = await this.buildOptions(launchMode);
|
||||
// Closure can finish while launch options are being prepared.
|
||||
if (this.closed) throw new Error("Claude session is closed");
|
||||
this.logger.debug({ options: summarizeClaudeOptionsForLog(options) }, "claude query");
|
||||
this.input = input;
|
||||
this.runtimeSafety.reset();
|
||||
@@ -3581,7 +3546,6 @@ class ClaudeAgentSession implements AgentSession {
|
||||
},
|
||||
},
|
||||
);
|
||||
this.queryStarting = false;
|
||||
const fastMode = this.resolveFastModeSetting();
|
||||
if (fastMode !== null) {
|
||||
await this.query.applyFlagSettings({ fastMode });
|
||||
@@ -3701,9 +3665,10 @@ class ClaudeAgentSession implements AgentSession {
|
||||
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
|
||||
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
|
||||
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
|
||||
// `fast_mode` is a composer control the provider applies through flag settings.
|
||||
const { fast_mode: _fastMode, ...sdkProviderOptions } = this.config.providerOptions;
|
||||
const providerOptions = applyClaudeToolPolicy(sdkProviderOptions, this.config.toolPolicy);
|
||||
const providerOptions = applyClaudeToolPolicy(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
|
||||
const sdkEnv = this.harnessEnvironment;
|
||||
assertClaudeModeCanRun(permissionMode, sdkEnv);
|
||||
@@ -3822,7 +3787,7 @@ class ClaudeAgentSession implements AgentSession {
|
||||
if (!claudeModelSupportsFastMode(this.config.model)) {
|
||||
return null;
|
||||
}
|
||||
return this.config.providerOptions.fast_mode === true;
|
||||
return this.config.featureValues?.fast_mode === true;
|
||||
}
|
||||
|
||||
private normalizeMcpServers(
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { AgentOptionControl, AgentOptionToggleControl } from "../../agent-sdk-types.js";
|
||||
import type { AgentFeature, AgentFeatureToggle } from "../../agent-sdk-types.js";
|
||||
import { claudeManifestModelSupportsFastMode } from "./model-manifest.js";
|
||||
|
||||
export const CLAUDE_FAST_MODE_CONTROL: AgentOptionToggleControl = {
|
||||
export const CLAUDE_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
|
||||
type: "toggle",
|
||||
key: "fast_mode",
|
||||
id: "fast_mode",
|
||||
label: "Fast",
|
||||
description: "Lower latency Opus responses at higher token cost",
|
||||
tooltip: "Toggle fast mode",
|
||||
@@ -14,6 +14,18 @@ export function claudeModelSupportsFastMode(modelId: string | null | undefined):
|
||||
return claudeManifestModelSupportsFastMode(modelId);
|
||||
}
|
||||
|
||||
export function buildClaudeControls(modelId: string | null | undefined): AgentOptionControl[] {
|
||||
return claudeModelSupportsFastMode(modelId) ? [{ ...CLAUDE_FAST_MODE_CONTROL }] : [];
|
||||
export function buildClaudeFeatures(input: {
|
||||
modelId: string | null | undefined;
|
||||
fastModeEnabled: boolean;
|
||||
}): AgentFeature[] {
|
||||
if (!claudeModelSupportsFastMode(input.modelId)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
...CLAUDE_FAST_MODE_FEATURE,
|
||||
value: input.fastModeEnabled,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -45,8 +45,6 @@ const SandboxFilesystemSchema = z
|
||||
// Claude Agent SDK Options, maintained against @anthropic-ai/claude-agent-sdk 0.3.246.
|
||||
export const ClaudeProviderOptionsSchema = z
|
||||
.object({
|
||||
/** Composer control, not a Claude SDK option: the provider reads it and keeps it out of the SDK options. */
|
||||
fast_mode: z.boolean().optional(),
|
||||
allowedTools: z.array(z.string()).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
additionalDirectories: z.array(z.string()).optional(),
|
||||
|
||||
+154
-537
@@ -1,19 +1,7 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { AgentManager } from "../agent-manager.js";
|
||||
import { toAgentPayload } from "../agent-projections.js";
|
||||
import { AgentStorage } from "../agent-storage.js";
|
||||
import { convertLegacyOptions, featuresFromControls } from "../option-controls.js";
|
||||
import type {
|
||||
AgentClient,
|
||||
AgentSession,
|
||||
AgentSessionConfig,
|
||||
AgentStreamEvent,
|
||||
} from "../agent-sdk-types.js";
|
||||
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
|
||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
||||
import {
|
||||
createFakeCodexAppServer,
|
||||
@@ -49,32 +37,23 @@ const TEST_SPEED_MODELS = [
|
||||
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
|
||||
}));
|
||||
|
||||
function speedControl() {
|
||||
function speedFeature(value: string) {
|
||||
return {
|
||||
type: "select",
|
||||
key: "service_tier",
|
||||
id: "service_tier",
|
||||
label: "Speed",
|
||||
description: "Choose processing speed. Faster tiers increase usage.",
|
||||
tooltip: "Select speed",
|
||||
icon: "zap",
|
||||
desktopTrigger: "icon",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
value,
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "priority", label: "Fast" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const PLAN_CONTROL = {
|
||||
type: "toggle",
|
||||
key: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
};
|
||||
|
||||
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
|
||||
{
|
||||
name: "Code",
|
||||
@@ -153,7 +132,7 @@ async function createConnectedSession(
|
||||
return harness;
|
||||
}
|
||||
|
||||
describe("Codex app-server provider controls", () => {
|
||||
describe("Codex app-server provider features", () => {
|
||||
test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "gpt-6.1-sol" },
|
||||
@@ -170,19 +149,19 @@ describe("Codex app-server provider controls", () => {
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.controls).toContainEqual(
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "service_tier",
|
||||
id: "service_tier",
|
||||
type: "select",
|
||||
defaultValue: "default",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
{ value: "ultrafast", label: "Ultrafast" },
|
||||
value: "default",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "priority", label: "Fast" },
|
||||
{ id: "ultrafast", label: "Ultrafast" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.applyOptions?.({ service_tier: "ultrafast" });
|
||||
await session.setFeature?.("service_tier", "ultrafast");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "ultrafast",
|
||||
@@ -207,16 +186,16 @@ describe("Codex app-server provider controls", () => {
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.controls).toContainEqual(
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "service_tier",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "future-tier", label: "Future speed" },
|
||||
id: "service_tier",
|
||||
options: [
|
||||
{ id: "default", label: "Normal", isDefault: true },
|
||||
{ id: "future-tier", label: "Future speed" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await session.applyOptions?.({ service_tier: "future-tier" });
|
||||
await session.setFeature?.("service_tier", "future-tier");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model: "future-codex-model",
|
||||
@@ -231,7 +210,7 @@ describe("Codex app-server provider controls", () => {
|
||||
"restores %s and Normal explicitly overrides an existing thread tier",
|
||||
async (tier) => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "future-model", providerOptions: { service_tier: tier } },
|
||||
{ model: "future-model", featureValues: { service_tier: tier } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
@@ -245,8 +224,11 @@ describe("Codex app-server provider controls", () => {
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: tier }),
|
||||
);
|
||||
await session.getRuntimeInfo();
|
||||
await session.applyOptions?.({ service_tier: "default" });
|
||||
await session.setFeature?.("service_tier", "default");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "default",
|
||||
@@ -257,40 +239,17 @@ describe("Codex app-server provider controls", () => {
|
||||
},
|
||||
);
|
||||
|
||||
test.each(["priority", "ultrafast"])("restores %s from providerOptions", async (tier) => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "future-model", providerOptions: { service_tier: tier } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "future-model",
|
||||
serviceTiers: [
|
||||
{ id: "priority", name: "Fast", description: "Fast processing" },
|
||||
{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
try {
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: tier });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unadvertised tiers and non-string values", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
const { session } = await createConnectedSession();
|
||||
try {
|
||||
for (const value of ["ultrafast", "bogus"]) {
|
||||
await expect(session.applyOptions?.({ service_tier: value })).rejects.toThrow(
|
||||
for (const value of ["ultrafast", "bogus", true, null]) {
|
||||
await expect(session.setFeature?.("service_tier", value)).rejects.toThrow(
|
||||
"is not available",
|
||||
);
|
||||
}
|
||||
await expect(session.applyOptions?.({ service_tier: true })).rejects.toThrow();
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
@@ -298,7 +257,7 @@ describe("Codex app-server provider controls", () => {
|
||||
|
||||
test("switching to a model without the selected tier resets speed", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "ultra-model", providerOptions: { service_tier: "ultrafast" } },
|
||||
{ model: "ultra-model", featureValues: { service_tier: "ultrafast" } },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
@@ -311,14 +270,8 @@ describe("Codex app-server provider controls", () => {
|
||||
);
|
||||
try {
|
||||
await session.setModel("gpt-6.1-sol");
|
||||
expect(session.controls).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "service_tier",
|
||||
choices: [
|
||||
{ value: "default", label: "Normal" },
|
||||
{ value: "priority", label: "Fast" },
|
||||
],
|
||||
}),
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: "default" }),
|
||||
);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
@@ -327,47 +280,10 @@ describe("Codex app-server provider controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("a Fast chosen as `fast` is kept as the catalog's id, so a model with a different Fast id resets it", async () => {
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "model-a" },
|
||||
{
|
||||
models: [
|
||||
{
|
||||
id: "model-a",
|
||||
isDefault: true,
|
||||
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster" }],
|
||||
},
|
||||
{
|
||||
id: "model-b",
|
||||
serviceTiers: [{ id: "rapid", name: "Fast", description: "Faster" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
try {
|
||||
await expect(session.applyOptions?.({ service_tier: "fast" })).resolves.toEqual({
|
||||
service_tier: "priority",
|
||||
});
|
||||
|
||||
await session.setModel("model-b");
|
||||
|
||||
// The option the session reports and the tier the turn runs are the same speed.
|
||||
expect(events.filter((event) => event.type === "options_changed")).toEqual([
|
||||
expect.objectContaining({ patch: { service_tier: "default" } }),
|
||||
]);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("`fast` maps to the catalog's request id", async () => {
|
||||
test("maps legacy Fast commands to the catalog's request id", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
try {
|
||||
await session.applyOptions?.({ service_tier: "fast" });
|
||||
await session.setFeature?.("fast_mode", true);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "priority",
|
||||
@@ -391,13 +307,13 @@ describe("Codex app-server provider controls", () => {
|
||||
])("exposes and sends Fast for %s", async (model) => {
|
||||
const { session, appServer } = await createConnectedSession({ model });
|
||||
try {
|
||||
expect(session.controls).toContainEqual(
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
key: "service_tier",
|
||||
defaultValue: "default",
|
||||
id: "service_tier",
|
||||
value: "default",
|
||||
}),
|
||||
);
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
model,
|
||||
@@ -422,13 +338,12 @@ describe("Codex app-server provider controls", () => {
|
||||
])("does not expose or restore Fast for %s", async (model) => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model,
|
||||
providerOptions: { service_tier: "fast" },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
const fresh = await createConnectedSession({ model });
|
||||
try {
|
||||
expect(session.controls.map((control) => control.key)).toEqual(["plan_mode"]);
|
||||
await expect(fresh.session.applyOptions?.({ service_tier: "fast" })).rejects.toThrow(
|
||||
`Codex speed 'fast' is not available for model '${model}'`,
|
||||
expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]);
|
||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
|
||||
`Codex fast mode is not available for model '${model}'`,
|
||||
);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -436,16 +351,21 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
await fresh.session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("restores Fast on Astra and preserves it when switching supported models", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model: "gpt-6-astra",
|
||||
providerOptions: { service_tier: "fast" },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
try {
|
||||
expect(session.features).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "service_tier",
|
||||
value: "priority",
|
||||
}),
|
||||
);
|
||||
await session.setModel("gpt-5.6-sol");
|
||||
await session.setModel("gpt-6-astra");
|
||||
await session.startTurn("hello");
|
||||
@@ -458,33 +378,72 @@ describe("Codex app-server provider controls", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("controls return the speed selector and plan toggle when supported", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
test("features returns speed selector and plan toggle when supported", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]);
|
||||
expect(session.features).toEqual([
|
||||
speedFeature("default"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
|
||||
await session.startTurn("hello");
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "priority",
|
||||
collaborationMode: expect.objectContaining({ mode: "plan" }),
|
||||
});
|
||||
expect(session.features).toEqual([
|
||||
speedFeature("priority"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("controls return only the plan toggle when model does not support fast mode", async () => {
|
||||
test("features returns only plan toggle when model does not support fast mode", async () => {
|
||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||
|
||||
expect(session.controls).toEqual([PLAN_CONTROL]);
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("constructor ignores restored fast mode when model does not support it", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
model: "gpt-3.5-turbo",
|
||||
providerOptions: { service_tier: "fast" },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
|
||||
expect(session.controls).toEqual([PLAN_CONTROL]);
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -495,7 +454,7 @@ describe("Codex app-server provider controls", () => {
|
||||
test("selecting Fast sends the catalog service tier", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -503,12 +462,12 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("selecting Normal after Fast explicitly selects Normal", async () => {
|
||||
test("legacy Fast off explicitly selects Normal", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { service_tier: "fast" },
|
||||
featureValues: { fast_mode: true },
|
||||
});
|
||||
|
||||
await session.applyOptions?.({ service_tier: "default" });
|
||||
await session.setFeature?.("fast_mode", false);
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -516,39 +475,53 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('applyOptions({ service_tier: "fast" }) rejects models that do not support fast mode', async () => {
|
||||
test("setFeature('fast_mode', true) rejects models that do not support fast mode", async () => {
|
||||
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
|
||||
|
||||
await expect(session.applyOptions?.({ service_tier: "fast" })).rejects.toThrow(
|
||||
"Codex speed 'fast' is not available for model 'gpt-3.5-turbo'",
|
||||
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
|
||||
"Codex fast mode is not available for model 'gpt-3.5-turbo'",
|
||||
);
|
||||
});
|
||||
|
||||
test("applyOptions invalidates runtime info", async () => {
|
||||
test("setFeature invalidates runtime info", async () => {
|
||||
const { session } = await createConnectedSession();
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
|
||||
await session.applyOptions?.({ plan_mode: true });
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
|
||||
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
|
||||
extra: { collaborationMode: "Plan" },
|
||||
});
|
||||
});
|
||||
|
||||
test("constructor restores composer options from config.providerOptions", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { service_tier: "fast", plan_mode: true },
|
||||
});
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
test("setFeature throws for unknown feature ids", async () => {
|
||||
const { session } = createSessionHarness();
|
||||
|
||||
expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]);
|
||||
// A saved `fast` opens as the model's Fast tier, which the open patch says.
|
||||
expect(session.openOptionsPatch).toEqual({ service_tier: "priority" });
|
||||
expect(events.filter((event) => event.type === "options_changed")).toEqual([]);
|
||||
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
|
||||
"Unknown Codex feature: unknown_feature",
|
||||
);
|
||||
});
|
||||
|
||||
test("constructor restores feature flags from config.featureValues", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
featureValues: { fast_mode: true, plan_mode: true },
|
||||
});
|
||||
|
||||
expect(session.features).toEqual([
|
||||
speedFeature("priority"),
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -559,36 +532,10 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a patch that sets another option keeps the running sandbox and an explicit speed", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { sandbox_mode: "read-only", service_tier: "default" },
|
||||
});
|
||||
|
||||
await session.applyOptions?.({ plan_mode: true });
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
serviceTier: "default",
|
||||
sandboxPolicy: expect.objectContaining({ type: "readOnly" }),
|
||||
});
|
||||
});
|
||||
|
||||
test("rejects a live change to the approval policy or sandbox, which the thread fixed when it started", async () => {
|
||||
const { session } = await createConnectedSession({
|
||||
providerOptions: { sandbox_mode: "workspace-write" },
|
||||
});
|
||||
|
||||
await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).rejects.toThrow(
|
||||
/sandbox_mode.*cannot change on a running session/,
|
||||
);
|
||||
// The same value is not a change.
|
||||
await expect(session.applyOptions?.({ sandbox_mode: "workspace-write" })).resolves.toEqual({});
|
||||
});
|
||||
|
||||
test("startTurn includes serviceTier when fast mode is enabled", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -596,351 +543,11 @@ describe("Codex app-server provider controls", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("opening on a model without the saved tier runs Normal and the open patch says so", async () => {
|
||||
const models = [
|
||||
{ id: "plain", isDefault: true, defaultReasoningEffort: "medium", serviceTiers: [] },
|
||||
];
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "plain", providerOptions: { service_tier: "priority" } },
|
||||
{ models },
|
||||
);
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ service_tier: "default" });
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
});
|
||||
|
||||
test("a saved service_tier: fast runs as the model's Fast tier and the open patch names that tier", async () => {
|
||||
const { session, appServer } = await createConnectedSession({
|
||||
providerOptions: { service_tier: "fast" },
|
||||
});
|
||||
|
||||
expect(session.openOptionsPatch).toEqual({ service_tier: "priority" });
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
|
||||
});
|
||||
|
||||
test("a live service_tier: fast is returned as the model's Fast tier", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await expect(session.applyOptions?.({ service_tier: "fast" })).resolves.toEqual({
|
||||
service_tier: "priority",
|
||||
});
|
||||
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
|
||||
});
|
||||
|
||||
test("switching to a model without the speed tier says Normal and keeps it so on the way back", async () => {
|
||||
const models = [
|
||||
...TEST_SPEED_MODELS,
|
||||
{ id: "plain", isDefault: false, defaultReasoningEffort: "medium", serviceTiers: [] },
|
||||
];
|
||||
const { session, appServer } = await createConnectedSession(
|
||||
{ model: "gpt-5.4", providerOptions: { service_tier: "priority" } },
|
||||
{ models },
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
await session.setModel?.("plain");
|
||||
await session.setModel?.("gpt-5.4");
|
||||
|
||||
expect(events.filter((event) => event.type === "options_changed")).toEqual([
|
||||
{ type: "options_changed", provider: "codex", patch: { service_tier: "default" } },
|
||||
]);
|
||||
await session.startTurn("hello");
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
|
||||
});
|
||||
|
||||
describe("legacy Fast (fast_mode) saved or requested before service_tier", () => {
|
||||
function createLegacyFastHarness(
|
||||
rawDefaults: Record<string, unknown> = {},
|
||||
providerId = CODEX_PROVIDER,
|
||||
) {
|
||||
// The provider registry converts configured defaults before it merges them.
|
||||
const configDefaults = convertLegacyOptions({
|
||||
provider: CODEX_PROVIDER,
|
||||
options: rawDefaults,
|
||||
});
|
||||
const appServer = createFakeCodexAppServer({
|
||||
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
|
||||
"model/list": () => ({ data: TEST_SPEED_MODELS }),
|
||||
});
|
||||
const logger = createTestLogger();
|
||||
const openSession = async (
|
||||
config: AgentSessionConfig,
|
||||
resumeSessionId: string | null,
|
||||
): Promise<AgentSession> => {
|
||||
const session = new CodexAppServerAgentSession(
|
||||
// The provider registry hands sessions the `config.json` defaults merged into the agent's own options.
|
||||
{
|
||||
...config,
|
||||
provider: CODEX_PROVIDER,
|
||||
providerOptions: { ...configDefaults, ...config.providerOptions },
|
||||
},
|
||||
resumeSessionId ? { sessionId: resumeSessionId } : null,
|
||||
logger,
|
||||
async () => appServer.child,
|
||||
);
|
||||
await session.connect();
|
||||
return session;
|
||||
};
|
||||
const client: AgentClient = {
|
||||
provider: providerId,
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: false,
|
||||
supportsMcpServers: false,
|
||||
supportsReasoningStream: false,
|
||||
supportsToolInvocations: false,
|
||||
},
|
||||
isAvailable: async () => true,
|
||||
fetchCatalog: async () => ({ models: [], modes: [] }),
|
||||
// Stands in for the `config.json` provider defaults the registry wrapper merges in.
|
||||
effectiveOptions: (own) => ({ ...configDefaults, ...own }),
|
||||
createSession: (config) => openSession(config, null),
|
||||
resumeSession: (handle, config) =>
|
||||
openSession(
|
||||
{ ...config, provider: CODEX_PROVIDER } as AgentSessionConfig,
|
||||
handle.sessionId,
|
||||
),
|
||||
};
|
||||
const workdir = mkdtempSync(join(tmpdir(), "codex-legacy-fast-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { [providerId]: client },
|
||||
// A custom provider that extends Codex runs on the Codex adapter.
|
||||
providerDefinitions:
|
||||
providerId === CODEX_PROVIDER
|
||||
? {}
|
||||
: { [providerId]: { enabled: true, derivedFromProviderId: CODEX_PROVIDER } },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const turnStarts = () =>
|
||||
appServer
|
||||
.requests()
|
||||
.filter((message) => message.method === "turn/start")
|
||||
.map((message) => message.params);
|
||||
|
||||
async function create(providerOptions?: Record<string, unknown>) {
|
||||
const agent = await manager.createAgent(
|
||||
{
|
||||
provider: providerId,
|
||||
cwd: workdir,
|
||||
model: "gpt-5.4",
|
||||
...(providerOptions ? { providerOptions } : {}),
|
||||
},
|
||||
undefined,
|
||||
{ workspaceId: undefined },
|
||||
);
|
||||
return agent.id;
|
||||
}
|
||||
async function resume(providerOptions: Record<string, unknown>) {
|
||||
const agent = await manager.resumeAgentFromPersistence({
|
||||
provider: providerId,
|
||||
sessionId: "thread-1",
|
||||
metadata: { cwd: workdir, model: "gpt-5.4", providerOptions },
|
||||
});
|
||||
return agent.id;
|
||||
}
|
||||
function shownSpeed(agentId: string) {
|
||||
const payload = toAgentPayload(manager.getAgent(agentId)!);
|
||||
const control = payload.controls?.find((candidate) => candidate.key === "service_tier");
|
||||
const choice =
|
||||
control?.type === "select"
|
||||
? control.choices.find((candidate) => candidate.value === payload.options?.service_tier)
|
||||
: undefined;
|
||||
return {
|
||||
options: payload.options,
|
||||
own: manager.getAgent(agentId)?.config.providerOptions,
|
||||
// What a client reading `options` + `controls` selects, and what the legacy `features` show.
|
||||
selected:
|
||||
payload.options?.service_tier === undefined && control?.type === "select"
|
||||
? control.defaultValue
|
||||
: choice?.value,
|
||||
feature: payload.features?.find((feature) => feature.id === "service_tier")?.value,
|
||||
};
|
||||
}
|
||||
async function runTurn(agentId: string, expectedTier: string) {
|
||||
const before = turnStarts().length;
|
||||
await manager.getAgent(agentId)!.session!.startTurn("hello");
|
||||
await vi.waitFor(() => expect(turnStarts()).toHaveLength(before + 1));
|
||||
const turn = turnStarts()[before]!;
|
||||
expect(turn).toMatchObject({ serviceTier: expectedTier });
|
||||
appServer.completeTurn({ threadId: String(turn.threadId) });
|
||||
await vi.waitFor(() => expect(manager.getAgent(agentId)?.lifecycle).toBe("idle"));
|
||||
}
|
||||
return { manager, create, resume, shownSpeed, runTurn };
|
||||
}
|
||||
|
||||
const FAST = { selected: "priority", feature: "priority" };
|
||||
|
||||
test("a saved fast_mode opens as the model's Fast tier and is stored as that tier", async () => {
|
||||
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||
const agentId = await create({ fast_mode: true });
|
||||
await manager.flush();
|
||||
|
||||
expect(shownSpeed(agentId)).toMatchObject({
|
||||
options: { service_tier: "priority" },
|
||||
own: { service_tier: "priority" },
|
||||
...FAST,
|
||||
});
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("is shown as Fast after a stored record is resumed", async () => {
|
||||
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||
const agentId = await resume({ fast_mode: true });
|
||||
await manager.flush();
|
||||
|
||||
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("from config.json defaults runs Fast and is shown as Fast", async () => {
|
||||
const { create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true });
|
||||
const agentId = await create();
|
||||
|
||||
expect(shownSpeed(agentId)).toMatchObject(FAST);
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("a saved fast_mode beats a configured service_tier default", async () => {
|
||||
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness({
|
||||
service_tier: "default",
|
||||
});
|
||||
const agentId = await resume({ fast_mode: true });
|
||||
await manager.flush();
|
||||
|
||||
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("a provider extending Codex opens a saved fast_mode as Fast", async () => {
|
||||
const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness({}, "my-codex");
|
||||
const agentId = await resume({ fast_mode: true });
|
||||
await manager.flush();
|
||||
|
||||
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("a provider extending Codex creates with a requested fast_mode and applies a later one", async () => {
|
||||
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness({}, "my-codex");
|
||||
const agentId = await create({ fast_mode: false });
|
||||
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
|
||||
|
||||
await manager.setAgentOptions(agentId, { fast_mode: true });
|
||||
|
||||
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("an older client's fast_mode runs Fast at once, and a later service_tier replaces it", async () => {
|
||||
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||
const agentId = await create();
|
||||
expect(shownSpeed(agentId).selected).toBe("default");
|
||||
|
||||
await manager.setAgentOptions(agentId, { fast_mode: true });
|
||||
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST });
|
||||
await runTurn(agentId, "priority");
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: "default" });
|
||||
expect(shownSpeed(agentId)).toMatchObject({
|
||||
own: { service_tier: "default" },
|
||||
selected: "default",
|
||||
feature: "default",
|
||||
});
|
||||
await runTurn(agentId, "default");
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: "priority" });
|
||||
expect(shownSpeed(agentId)).toMatchObject({ selected: "priority" });
|
||||
await runTurn(agentId, "priority");
|
||||
});
|
||||
|
||||
test("a request carrying both speed keys follows service_tier", async () => {
|
||||
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||
const agentId = await create({ service_tier: "priority" });
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: "default", fast_mode: true });
|
||||
|
||||
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
|
||||
await runTurn(agentId, "default");
|
||||
});
|
||||
|
||||
test.each([
|
||||
[CODEX_PROVIDER, "a requested legacy fast_mode", {}, { fast_mode: true }],
|
||||
["my-codex", "a requested legacy fast_mode", {}, { fast_mode: true }],
|
||||
[CODEX_PROVIDER, "a configured legacy fast_mode", { fast_mode: true }, undefined],
|
||||
[CODEX_PROVIDER, "a configured Fast alias", { service_tier: "fast" }, undefined],
|
||||
])(
|
||||
"draft speed for %s with %s is the one the first turn runs with",
|
||||
async (providerId, _name, configured, requested) => {
|
||||
const drafting = createLegacyFastHarness(configured, providerId);
|
||||
|
||||
const draft = await drafting.manager.listDraftControls({
|
||||
provider: providerId,
|
||||
cwd: tmpdir(),
|
||||
model: "gpt-5.4",
|
||||
...(requested ? { providerOptions: requested } : {}),
|
||||
});
|
||||
const features = featuresFromControls({ controls: draft.controls, options: draft.options });
|
||||
|
||||
expect(draft.options).toEqual({ service_tier: "priority" });
|
||||
expect(draft.controls).toContainEqual(expect.objectContaining({ key: "service_tier" }));
|
||||
expect(features).toContainEqual(
|
||||
expect.objectContaining({ id: "service_tier", value: "priority" }),
|
||||
);
|
||||
// A draft session ends with its app-server; creating runs on a fresh one.
|
||||
const creating = createLegacyFastHarness(configured, providerId);
|
||||
const agentId = await creating.create(requested);
|
||||
await creating.runTurn(agentId, "priority");
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["a string", "false"],
|
||||
["a number", 1],
|
||||
])("a legacy fast_mode that is %s is rejected, not read as Fast", async (_name, value) => {
|
||||
const { manager, create } = createLegacyFastHarness();
|
||||
const agentId = await create({});
|
||||
|
||||
await expect(manager.setAgentOptions(agentId, { fast_mode: value })).rejects.toThrow(
|
||||
/fast_mode must be true, false or null/,
|
||||
);
|
||||
});
|
||||
|
||||
test("an older client turning fast_mode off selects Normal", async () => {
|
||||
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
|
||||
const agentId = await create({ service_tier: "priority" });
|
||||
|
||||
await manager.setAgentOptions(agentId, { fast_mode: false });
|
||||
|
||||
expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "default" } });
|
||||
await runTurn(agentId, "default");
|
||||
});
|
||||
|
||||
test("removing the speed returns the agent to Normal", async () => {
|
||||
const { create, manager, shownSpeed } = createLegacyFastHarness();
|
||||
const agentId = await create({ service_tier: "priority" });
|
||||
expect(shownSpeed(agentId)).toMatchObject(FAST);
|
||||
|
||||
await manager.setAgentOptions(agentId, { service_tier: null });
|
||||
|
||||
expect(shownSpeed(agentId)).toMatchObject({ own: undefined, selected: "default" });
|
||||
});
|
||||
});
|
||||
|
||||
test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => {
|
||||
const capture = createCapturedLogger();
|
||||
const prompt = "secret prompt text should not be logged";
|
||||
const { session } = await createConnectedSession(
|
||||
{ providerOptions: { service_tier: "fast" } },
|
||||
{ featureValues: { fast_mode: true } },
|
||||
{ logger: capture.logger },
|
||||
);
|
||||
|
||||
@@ -963,10 +570,20 @@ describe("Codex app-server provider controls", () => {
|
||||
test("setModel clears fast mode when switching to an unsupported model", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.applyOptions?.({ service_tier: "priority" });
|
||||
await session.setFeature?.("service_tier", "priority");
|
||||
await session.setModel("gpt-3.5-turbo");
|
||||
|
||||
expect(session.controls).toEqual([PLAN_CONTROL]);
|
||||
expect(session.features).toEqual([
|
||||
{
|
||||
type: "toggle",
|
||||
id: "plan_mode",
|
||||
label: "Plan",
|
||||
description: "Switch Codex into planning-only collaboration mode",
|
||||
tooltip: "Toggle plan mode",
|
||||
icon: "list-todo",
|
||||
value: false,
|
||||
},
|
||||
]);
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
@@ -977,7 +594,7 @@ describe("Codex app-server provider controls", () => {
|
||||
test("startTurn switches collaboration mode when plan mode is enabled", async () => {
|
||||
const { session, appServer } = await createConnectedSession();
|
||||
|
||||
await session.applyOptions?.({ plan_mode: true });
|
||||
await session.setFeature?.("plan_mode", true);
|
||||
await session.startTurn("hello");
|
||||
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user