Compare commits

..
Author SHA1 Message Date
paseo-bot[bot] 965ee97e4a fix(usage): preserve migrated Go credential discovery
Follow the stored credential selection order so imported keys without an active flag remain discoverable, including when a legacy file is retained.
2026-11-04 03:37:39 +00:00
paseo-bot[bot]andIvan Pantic 02168446fb fix(usage): find Go logins in the configured data directory
Resolve relative database overrides against the OpenCode data directory so usage discovery follows the same store as the CLI.

Co-authored-by: Ivan Pantic <panta@talentkit.io>
2026-11-04 03:27:55 +00:00
Ivan Pantic 0ac8175015 fix(usage): read OpenCode v2 Go API keys from its database 2026-11-04 03:22:55 +00:00
231 changed files with 3564 additions and 11964 deletions
-2
View File
@@ -92,7 +92,6 @@ Each agent is stored as a separate JSON file, grouped by project directory.
| `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp |
| `title` | `string?` | User-visible title |
| `labels` | `Record<string, string>` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) |
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `agent.metadata.update.request`; `null` in a patch removes a key |
| `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. |
| `lastModeId` | `string?` | Last active mode ID |
| `config` | `SerializableConfig?` | Agent session configuration (see below) |
@@ -479,7 +478,6 @@ Array of workspace records. A workspace is a specific working directory within a
| `archivedAt` | `string \| null` (ISO 8601) | Soft-delete; required nullable |
| `autoArchivedChangeRequestUrl` | `string \| null` | Change request whose merged state triggered auto-archive. Restore replaces it with the current merged change request, when present, so repeated snapshots cannot archive the workspace again. |
| `labels` | `string[]?` | Normalized display names assigned from this host's shared label catalog. Missing means unlabelled. |
| `metadata` | `Record<string, JsonValue>` | Plugin-owned data (default `{}`). Paseo stores and forwards it and never reads a key. Set at creation or through `workspace.metadata.update.request`. |
| `pinnedAt` | `string \| null` (ISO 8601) | Pinned-to-top-of-sidebar timestamp; null means "not pinned" |
| `untrustedSource` | `{ kind: "change_request", forge, number, headRepository }?` | Provenance captured when a cross-repository change request creates the workspace. Missing means repository automation is allowed; explicit setup removes the field. |
+1 -2
View File
@@ -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
+3 -14
View File
@@ -474,9 +474,6 @@ path shape: a plugin can place a worktree several directories below the default
Emit from the operation owner, not a client subscription. Provider history replay must not trigger
live hooks. Notifications must not be awaited inside agent mutations: a handler can send a prompt
or answer a permission through its own daemon session. Awaiting it there deadlocks that command.
The same holds for a hook that runs inside an agent's lifecycle lane: an `agent.open.before` hook
that awaits a write to the agent being opened (`setMetadata`, `setLabels`) waits on the open, which
waits on the hook. The hooks reference tells plugin authors to start such a write without awaiting it.
## Contribute a provider
@@ -532,21 +529,13 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`.
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon.
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
(`providerOptions`) in `session.config`. A running-agent change arrives as
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
its value into an option and is sent both shapes in `session.configure`; delete it with
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
+3 -62
View File
@@ -30,65 +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.
- **Clients draw from the daemon's answer.** The app reads `controls` and `options` from the
agent snapshot for a running or closed agent. A draft and a profile form send everything they
would create with (seed, the provider's last-used control values once the provider has said it
still draws them, and the user's edits) in `provider.controls.list.request` and draw only what
comes back, so an alias such as a legacy Fast profile's `service_tier: "fast"` is resolved once,
by the daemon. A toggle shows over the last answer until its own answer arrives. The draft's
layers live in a plain model (`agent-controls/draft-options-model.ts`): applying a profile
replaces provider and options in one step, while choosing a provider by hand starts over. A
profile saves its stored options plus the form's edits, so options no control draws survive an
edit. A daemon without `server_info.features.providerOptionControls` sends `features` and
`featureValues`; `packages/client/src/compat/legacy-provider-options.ts` is the one place that
turns those into `controls` and `options` and back, so nothing in the app branches on it. A
create that carries options first lists the controls for its own provider, model, mode and
thinking level, since which options are controls differs by model, and sends those as
`featureValues`; callers do not prime anything.
- **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.
@@ -174,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.
@@ -537,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>;
@@ -559,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
View File
@@ -1 +1 @@
sha256-p97CaALgkY+x9XVvS4/ILqmbwYSX0fSbVUgQl35BjiU=
sha256-VdM4lXdgNEXPpTfvHSFvF9nm302rK/ZbVkZUp9tyOfg=
+615 -707
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -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",
@@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro
const raw = localStorage.getItem(preferencesKey);
if (!raw) return null;
const preferences = JSON.parse(raw) as FormPreferences;
return preferences.providerPreferences?.[providerId]?.options ?? null;
return preferences.providerPreferences?.[providerId]?.featureValues ?? null;
},
{ preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
);
@@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
provider: featureProviderId,
model: featureModelId,
modeId: "bypassPermissions",
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
]);
const provider = await seedModelProvider({
@@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
expect(createAgentRequest).toMatchObject({
config: {
provider: featureProviderId,
providerOptions: { fast_mode: true },
featureValues: { fast_mode: true },
},
});
} finally {
@@ -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);
@@ -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();
}
@@ -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> {
@@ -71,15 +71,6 @@ export async function focusWorkspaceRowWithKeyboard(
await expect(row).toBeFocused();
}
export async function scrollWorkspaceList(page: Page): Promise<void> {
const list = page.getByTestId("sidebar-project-workspace-list-scroll");
const before = await list.evaluate((element) => element.scrollTop);
await list.evaluate((element) =>
element.scrollBy({ top: element.scrollTop > 0 ? -40 : 40, behavior: "instant" }),
);
await expect.poll(() => list.evaluate((element) => element.scrollTop)).not.toBe(before);
}
export async function expectWorkspaceHoverCardOpen(page: Page): Promise<void> {
await expect(page.getByTestId("workspace-hover-card")).toBeVisible();
}
+2 -2
View File
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
export function normalizeAgentCommandsCwd(cwd: string): string {
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
"thinking",
draftConfig.thinkingOptionId ?? null,
"features",
draftConfig.providerOptions ?? null,
draftConfig.featureValues ?? null,
] as const;
}
@@ -1,189 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import { isControlActive } from "./option-controls";
import {
draftControlsRequest,
openDraftOptions,
ownDraftOptions,
viewDraftOptions,
} from "./draft-options-model";
const SPEED: AgentOptionControl = {
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
};
const WEB_SEARCH: AgentOptionControl = { type: "toggle", key: "web_search", label: "Web search" };
function answer(
provider: string,
requestKey: string,
options: Record<string, unknown>,
controls = [SPEED, WEB_SEARCH],
) {
return { provider, requestKey, controls, options };
}
describe("draft options model", () => {
it("draws a legacy Fast profile as the Fast tier the daemon resolved", () => {
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "fast" } });
model.applyResolved(answer("codex", "k1", { service_tier: "priority" }));
const view = viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" });
expect(view?.options).toEqual({ service_tier: "priority" });
expect(view && isControlActive(SPEED, view.options)).toBe(true);
// The daemon still gets the stored value to resolve, and so does create.
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
});
it("applying a profile from another provider keeps its options through the provider change", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", {}));
model.applyProfile({
provider: "codex",
options: { sandbox_mode: "read-only", service_tier: "fast" },
});
// The form follows the profile to its provider.
model.observeProvider("codex");
expect(ownDraftOptions(model.getState())).toEqual({
sandbox_mode: "read-only",
service_tier: "fast",
});
expect(model.getState().resolved).toBeNull();
});
it("choosing another provider by hand starts the options over", () => {
const model = openDraftOptions({ provider: "codex", seed: { sandbox_mode: "read-only" } });
model.setOption("web_search", true);
model.observeProvider("claude");
expect(ownDraftOptions(model.getState())).toBeUndefined();
expect(model.getState().resolved).toBeNull();
});
it("never pairs a manually chosen provider with the previous provider's options", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(
answer("claude", "k1", { fast_mode: true }, [
{ type: "toggle", key: "fast_mode", label: "Fast" },
]),
);
model.setOption("fast_mode", true);
const form = { provider: "claude", cwd: "/repo" };
expect(draftControlsRequest(model.getState(), form)).toMatchObject({
provider: "claude",
providerOptions: { fast_mode: true },
});
// The form shows OpenCode on its next render; the model has not observed it yet.
expect(draftControlsRequest(model.getState(), { ...form, provider: "opencode" })).toBeNull();
model.observeProvider("opencode");
const request = draftControlsRequest(model.getState(), { ...form, provider: "opencode" });
expect(request).toEqual({ provider: "opencode", cwd: "/repo" });
});
it("stops sending a toggle once the model in use draws no control for it", () => {
const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
const model = openDraftOptions({ provider: "claude", seed: { sandbox_mode: "read-only" } });
model.applyResolved(answer("claude", "opus", { fast_mode: false }, [FAST_MODE]));
model.setOption("fast_mode", true);
expect(ownDraftOptions(model.getState())).toEqual({
sandbox_mode: "read-only",
fast_mode: true,
});
// The form moves to a model without Fast; its answer lists no such control.
model.applyResolved(answer("claude", "haiku", {}, []));
expect(ownDraftOptions(model.getState())).toEqual({ sandbox_mode: "read-only" });
// Back on a model that draws the control, the toggle applies again. The daemon's answer
// for the request without it said Fast was off; the request that carries it follows.
model.applyResolved(answer("claude", "opus-again", { fast_mode: false }, [FAST_MODE]));
expect(ownDraftOptions(model.getState())).toEqual({
sandbox_mode: "read-only",
fast_mode: true,
});
});
it("always sends an option no control has drawn", () => {
const FAST_MODE: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", {}, [FAST_MODE]));
model.setOption("token_budget", 5);
expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 });
model.applyResolved(answer("claude", "k2", {}, []));
expect(ownDraftOptions(model.getState())).toEqual({ token_budget: 5 });
});
it("keeps a draft's seed when the first provider is chosen", () => {
const model = openDraftOptions({ provider: null, seed: { web_search: true } });
model.observeProvider("claude");
expect(ownDraftOptions(model.getState())).toEqual({ web_search: true });
});
it("sends a remembered value to the daemon, and draws only what comes back", () => {
const model = openDraftOptions({ provider: "codex" });
model.setRemembered({ service_tier: "fast", retired: true });
// Nothing is known about the provider's controls yet.
expect(ownDraftOptions(model.getState())).toBeUndefined();
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "fast" });
// The answer to the first request does not show the remembered value as if it applied.
expect(
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
).toEqual({ service_tier: "default" });
model.applyResolved(answer("codex", "k2", { service_tier: "priority" }));
expect(
viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k2" })?.options,
).toEqual({ service_tier: "priority" });
});
it("does not let a remembered value override the seed", () => {
const model = openDraftOptions({ provider: "codex", seed: { service_tier: "default" } });
model.applyResolved(answer("codex", "k1", { service_tier: "default" }));
model.setRemembered({ service_tier: "fast" });
expect(ownDraftOptions(model.getState())).toEqual({ service_tier: "default" });
});
it("lays a toggle over the previous answer until the new one arrives", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", { web_search: false }));
model.setOption("web_search", true);
expect(
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
).toEqual({ web_search: true });
model.applyResolved(answer("claude", "k2", { web_search: true }));
expect(
viewDraftOptions(model.getState(), { provider: "claude", requestKey: "k2" })?.options,
).toEqual({ web_search: true });
});
it("draws nothing for a provider the answer is not for", () => {
const model = openDraftOptions({ provider: "claude" });
model.applyResolved(answer("claude", "k1", {}));
expect(viewDraftOptions(model.getState(), { provider: "codex", requestKey: "k1" })).toBe(
undefined,
);
});
});
@@ -1,193 +0,0 @@
import type {
AgentOptionControl,
AgentSessionConfig,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
import { withoutLostControls } from "./lost-controls";
/**
* The options of one draft agent, as a plain model (see docs/forms.md). It owns which layers
* make up the draft's options; the daemon owns what they mean. Every layer is sent to the daemon
* (`ownDraftOptions`) and the UI draws what comes back (`viewDraftOptions`), so no alias,
* default or legacy key is resolved here.
*
* - `seed`: options the draft started from (a profile, a forked agent).
* - `remembered`: the provider's last-used control values.
* - `edits`: toggles made in this draft.
*
* Choosing a provider by hand starts the options over; applying a profile replaces the provider
* and the options in one step, so the provider change that follows does not clear them.
*/
export interface ResolvedDraftOptions {
provider: string;
/** Names the request that produced this answer; see `viewDraftOptions`. */
requestKey: string;
controls: AgentOptionControl[];
options: ProviderOptions;
}
export interface DraftOptionsState {
provider: string | null;
seed: ProviderOptions;
remembered: ProviderOptions;
edits: ProviderOptions;
resolved: ResolvedDraftOptions | null;
/** Every key an answer for this provider has drawn as a control. */
drawn: ReadonlySet<string>;
}
export interface DraftOptionsModel {
getState: () => DraftOptionsState;
subscribe: (listener: () => void) => () => void;
/** The provider the draft's form now has. A different one than the model's is a manual choice. */
observeProvider: (provider: string | null) => void;
applyProfile: (profile: { provider: string; options: ProviderOptions }) => void;
setRemembered: (remembered: ProviderOptions) => void;
setOption: (key: string, value: unknown) => void;
applyResolved: (resolved: ResolvedDraftOptions) => void;
}
export function openDraftOptions(input: {
provider: string | null;
seed?: ProviderOptions;
}): DraftOptionsModel {
let state: DraftOptionsState = {
provider: input.provider,
seed: input.seed ?? {},
remembered: {},
edits: {},
resolved: null,
drawn: new Set(),
};
const listeners = new Set<() => void>();
function publish(update: (current: DraftOptionsState) => DraftOptionsState): void {
const next = update(state);
if (next === state) return;
state = next;
for (const listener of listeners) listener();
}
return {
getState: () => state,
subscribe: (listener) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
observeProvider: (provider) =>
publish((current) => {
if (provider === current.provider) return current;
// The first provider a draft picks does not discard what it was opened with.
if (current.provider === null) return { ...current, provider };
return {
...current,
provider,
seed: {},
edits: {},
resolved: null,
drawn: new Set(),
};
}),
applyProfile: (profile) =>
publish((current) => ({
...current,
provider: profile.provider,
seed: profile.options,
edits: {},
resolved: current.provider === profile.provider ? current.resolved : null,
drawn: current.provider === profile.provider ? current.drawn : new Set(),
})),
setRemembered: (remembered) => publish((current) => ({ ...current, remembered })),
setOption: (key, value) =>
publish((current) =>
Object.is(current.edits[key], value)
? current
: { ...current, edits: { ...current.edits, [key]: value } },
),
applyResolved: (resolved) =>
publish((current) => ({
...current,
resolved,
drawn: new Set([...current.drawn, ...resolved.controls.map((control) => control.key)]),
})),
};
}
/**
* Everything the draft would create with, for the daemon to resolve. A last-used value applies
* once the provider has told us it still draws a control for that key, and never over the seed.
* An edit of a key an earlier answer drew as a control stops applying when the latest answer
* draws none for it (the model changed to one without that control) and applies again when a
* control for it returns. An edit of a key no answer has drawn is an option without a control
* and always applies.
*/
export function ownDraftOptions(state: DraftOptionsState): ProviderOptions | undefined {
const controlKeys = new Set((state.resolved?.controls ?? []).map((control) => control.key));
const remembered = Object.fromEntries(
Object.entries(state.remembered).filter(
([key]) => controlKeys.has(key) && !(key in state.seed),
),
);
const edits = withoutLostControls(state.edits, state.drawn, controlKeys);
const own = { ...state.seed, ...remembered, ...edits };
return Object.keys(own).length > 0 ? own : undefined;
}
export type DraftControlsRequest = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
>;
/** What the draft's form has chosen; the options come from the model. */
export interface DraftControlsForm {
provider: string | null;
cwd: string;
modeId?: string | null;
modelId?: string | null;
thinkingOptionId?: string | null;
}
/**
* The listing request for the draft's form, built from one snapshot of the model. The form
* reaches a new provider before the model has observed it, and the model still holds the
* previous provider's options until it does; that render has no request, so a request never
* pairs one provider with another's options.
*/
export function draftControlsRequest(
state: DraftOptionsState,
form: DraftControlsForm,
): DraftControlsRequest | null {
if (!form.provider || !form.cwd || form.provider !== state.provider) return null;
const providerOptions = ownDraftOptions(state);
return {
provider: form.provider,
cwd: form.cwd,
...(form.modeId ? { modeId: form.modeId } : {}),
...(form.modelId ? { model: form.modelId } : {}),
...(form.thinkingOptionId ? { thinkingOptionId: form.thinkingOptionId } : {}),
...(providerOptions ? { providerOptions } : {}),
};
}
/**
* What the controls draw: the daemon's answer for this provider. The last answer stays up while
* a newer request is in flight, with the toggles made since laid over it, so a switch does not
* wait for the round trip. It never carries a seed or remembered value the daemon has not
* resolved.
*/
export function viewDraftOptions(
state: DraftOptionsState,
current: { provider: string | null; requestKey: string },
): { controls: AgentOptionControl[]; options: ProviderOptions } | undefined {
const { resolved } = state;
if (!resolved || resolved.provider !== current.provider) return undefined;
return {
controls: resolved.controls,
options:
resolved.requestKey === current.requestKey
? resolved.options
: { ...resolved.options, ...state.edits },
};
}
@@ -1,16 +0,0 @@
import { Settings2 } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { getAgentFeatureIcon } from "./icons";
describe("getAgentFeatureIcon", () => {
it("draws the Lucide icon a plugin provider names", () => {
const icon = getAgentFeatureIcon("Mic");
expect(icon).not.toBe(Settings2);
expect(getAgentFeatureIcon("Mic")).toBe(icon);
});
it("falls back to the generic icon for a missing or unknown name", () => {
expect(getAgentFeatureIcon()).toBe(Settings2);
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
});
});
+1 -5
View File
@@ -13,7 +13,6 @@ import {
ShieldQuestionMark,
Zap,
} from "lucide-react-native";
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export interface AgentControlIconProps {
@@ -61,9 +60,6 @@ export function getAgentModeOptionIcon(
return icon ? MODE_ICONS[icon] : undefined;
}
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
if (!icon) return Settings2;
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
}
@@ -1,17 +0,0 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
/**
* The options that still apply after an answer from the daemon. A key an earlier answer drew as a
* control and the latest answer does not (the model changed to one without that control) is
* dropped, and applies again if a control for it returns. A key no answer has drawn is an option
* without a control and always applies.
*/
export function withoutLostControls(
options: ProviderOptions,
drawn: ReadonlySet<string>,
current: ReadonlySet<string>,
): ProviderOptions {
return Object.fromEntries(
Object.entries(options).filter(([key]) => !drawn.has(key) || current.has(key)),
);
}
@@ -1,50 +0,0 @@
import { describe, expect, it } from "vitest";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
} from "./option-controls";
const speed: AgentOptionControl = {
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
};
const fast: AgentOptionControl = { type: "toggle", key: "fast_mode", label: "Fast" };
describe("option controls", () => {
it("reads a select from options and falls back to the control's default", () => {
if (speed.type !== "select") throw new Error("select expected");
expect(readSelectOption(speed, { service_tier: "priority" })).toBe("priority");
expect(readSelectOption(speed, {})).toBe("default");
expect(readSelectOption(speed, { service_tier: "unknown-tier" })).toBe("default");
});
it("reads a toggle from options and is off when unset", () => {
if (fast.type !== "toggle") throw new Error("toggle expected");
expect(readToggleOption(fast, { fast_mode: true })).toBe(true);
expect(readToggleOption(fast, {})).toBe(false);
});
it("is active when the option differs from the control's default", () => {
expect(isControlActive(speed, { service_tier: "priority" })).toBe(true);
expect(isControlActive(speed, { service_tier: "default" })).toBe(false);
expect(isControlActive(speed, {})).toBe(false);
expect(isControlActive(fast, { fast_mode: true })).toBe(true);
expect(isControlActive(fast, {})).toBe(false);
});
it("prefers a control's tooltip over its label", () => {
expect(getControlTooltip({ label: "Plan", tooltip: "Toggle plan mode" })).toBe(
"Toggle plan mode",
);
expect(getControlTooltip({ label: "Custom" })).toBe("Custom");
});
});
@@ -1,47 +0,0 @@
import type {
AgentOptionControl,
AgentOptionSelectControl,
AgentOptionToggleControl,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
/**
* The composer's option controls. A control only describes how to draw one option key; the
* value is read from `options`, which the daemon resolved (defaults, aliases, what runs).
*/
export interface ComposerOptionControls {
controls: AgentOptionControl[];
options: ProviderOptions;
onSetOption: (key: string, value: unknown) => void;
}
export function readToggleOption(
control: AgentOptionToggleControl,
options: ProviderOptions,
): boolean {
const value = options[control.key];
return typeof value === "boolean" ? value : (control.defaultValue ?? false);
}
/** The selected choice's value, or the control's default when the option names no choice. */
export function readSelectOption(
control: AgentOptionSelectControl,
options: ProviderOptions,
): string | null {
const value = options[control.key];
if (typeof value === "string" && control.choices.some((choice) => choice.value === value)) {
return value;
}
return control.defaultValue ?? null;
}
/** Whether the option is set to something other than the control's default. */
export function isControlActive(control: AgentOptionControl, options: ProviderOptions): boolean {
if (control.type === "toggle") return readToggleOption(control, options);
const value = readSelectOption(control, options);
return value !== null && value !== control.defaultValue;
}
export function getControlTooltip(control: Pick<AgentOptionControl, "label" | "tooltip">): string {
return control.tooltip ?? control.label;
}
@@ -23,7 +23,7 @@ describe("materializeAgentProfile", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
}),
),
).toEqual({
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
});
});
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
modelId: "",
modeId: "",
thinkingOptionId: "",
options: {},
featureValues: {},
});
});
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
}),
),
),
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
modelId: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { web_search: true },
featureValues: { web_search: true },
});
});
@@ -81,8 +81,8 @@ describe("toAgentConfigApply", () => {
it("omits an empty feature map so the agent keeps its own feature values", () => {
expect(
toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))),
).not.toHaveProperty("options");
toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
).not.toHaveProperty("featureValues");
});
it("never carries a provider, which a running agent cannot change", () => {
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
modelId: string;
modeId: string;
thinkingOptionId: string;
options: Record<string, unknown>;
featureValues: Record<string, unknown>;
}
function trimmed(value: string | undefined): string {
@@ -25,7 +25,7 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen
modelId: trimmed(profile.model),
modeId: trimmed(profile.modeId),
thinkingOptionId: trimmed(profile.thinkingOptionId),
options: profile.options ?? {},
featureValues: profile.featureValues ?? {},
};
}
@@ -52,6 +52,8 @@ export function toAgentConfigApply(profile: MaterializedAgentProfile): AgentConf
...(profile.modelId ? { modelId: profile.modelId } : {}),
...(profile.modeId ? { modeId: profile.modeId } : {}),
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}),
...(Object.keys(profile.options).length > 0 ? { options: profile.options } : {}),
...(Object.keys(profile.featureValues).length > 0
? { featureValues: profile.featureValues }
: {}),
};
}
@@ -1,7 +1,7 @@
import type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest";
import {
buildControlsRequestKey,
buildFeatureRequestKey,
openAgentProfileForm,
type AgentProfileFormModel,
} from "./profile-form-model";
@@ -70,21 +70,6 @@ function optionValues(options: readonly { value: string }[]): string[] {
return options.map((option) => option.value);
}
const FAST = {
controls: [{ type: "toggle" as const, key: "fast_mode", label: "Fast" }],
options: { fast_mode: false },
};
const NO_CONTROLS = { controls: [], options: {} };
function toggledOnOpus(): AgentProfileFormModel {
const model = openWithCatalog({ mode: "create" });
model.setName("Fast");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
model.setOption("fast_mode", true);
return model;
}
describe("openAgentProfileForm", () => {
it("starts a create form empty and cannot submit", () => {
const model = openWithCatalog({ mode: "create" });
@@ -188,7 +173,7 @@ describe("openAgentProfileForm", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
options: { webSearch: true },
featureValues: { webSearch: true },
notes: "Visual work only.",
},
});
@@ -244,11 +229,10 @@ describe("openAgentProfileForm", () => {
selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" });
model.applyControls(model.getState().controlsRequestKey ?? "", {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
model.setOption("webSearch", true);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setFeatureValue("webSearch", true);
model.setProvider("codex", { label: "Codex" });
const state = model.getState();
@@ -258,9 +242,8 @@ describe("openAgentProfileForm", () => {
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
expect(state.thinkingOptionId).toBe("");
expect(state.thinkingDisplay).toBeNull();
expect(state.options).toEqual({});
expect(state.optionEdits).toEqual({});
expect(state.controls).toEqual([]);
expect(state.featureValues).toEqual({});
expect(state.features).toEqual([]);
expect(state.name).toBe("Test writer");
});
@@ -351,28 +334,23 @@ describe("openAgentProfileForm", () => {
});
});
const WEB_SEARCH_CONTROLS = {
controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }],
options: { webSearch: false },
};
describe("controls", () => {
it("asks for controls per provider/model/mode/thinking combination", () => {
describe("features", () => {
it("asks for features per provider/model/mode/thinking combination", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
expect(model.getState().controlsRequest).toEqual({
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think",
});
const firstKey = model.getState().controlsRequestKey;
const firstKey = model.getState().featureRequestKey;
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().controlsRequestKey).not.toBe(firstKey);
expect(model.getState().controlsRequest).toEqual({
expect(model.getState().featureRequestKey).not.toBe(firstKey);
expect(model.getState().featureRequest).toEqual({
provider: "claude",
model: "claude-haiku-4-5",
modeId: "plan",
@@ -383,233 +361,77 @@ describe("openAgentProfileForm", () => {
it("moves from pending to complete only for the current request", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
const key = model.getState().controlsRequestKey ?? "";
expect(model.getState().controlsResolution).toBe("pending");
const key = model.getState().featureRequestKey ?? "";
expect(model.getState().featureResolution).toBe("pending");
model.applyControls("stale-key", {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
expect(model.getState().controlsResolution).toBe("pending");
expect(model.getState().controls).toEqual([]);
model.applyFeatures("stale-key", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().features).toEqual([]);
model.applyControls(key, {
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }],
options: { webSearch: false },
});
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().disclosure.showControlsField).toBe(true);
model.applyFeatures(key, [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(true);
});
it("reflects a set option back into the rendered controls", () => {
it("reflects a set feature value back into the rendered feature", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("Searcher");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setOption("webSearch", true);
model.setFeatureValue("webSearch", true);
expect(model.getState().controls[0]).toMatchObject({ key: "webSearch" });
expect(model.getState().controlOptions).toEqual({ webSearch: true });
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
expect(model.getState().features[0]).toMatchObject({ id: "webSearch", value: true });
expect(model.getState().featureValues).toEqual({ webSearch: true });
});
it("keeps stored options while the controls are still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "UI work", provider: "claude", options: { webSearch: true } },
});
model.applyProviderCatalog(ENTRIES);
expect(model.getState().controlsResolution).toBe("pending");
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
});
it("keeps options no control draws, so editing a profile does not drop them", () => {
it("keeps stored feature values while the feature list is still pending", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "UI work",
provider: "claude",
options: { webSearch: true, sandbox_mode: "read-only" },
featureValues: { webSearch: true },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setOption("webSearch", false);
expect(model.getState().submitValue?.options).toEqual({
webSearch: false,
sandbox_mode: "read-only",
});
expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
});
it("draws the options the daemon resolved from the stored ones, then the edits", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: { id: "p1", name: "Fast", provider: "codex", options: { service_tier: "fast" } },
});
model.applyProviderCatalog(ENTRIES);
// The request carries the stored options so the daemon can resolve the legacy tier.
expect(model.getState().controlsRequest?.providerOptions).toEqual({ service_tier: "fast" });
model.applyControls(model.getState().controlsRequestKey ?? "", {
controls: [
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
},
],
options: { service_tier: "priority" },
});
expect(model.getState().controlOptions).toEqual({ service_tier: "priority" });
// Nothing was edited, so the stored value is saved as it was.
expect(model.getState().submitValue?.options).toEqual({ service_tier: "fast" });
});
it("sends an edit to the daemon with the stored options, and draws the answer", () => {
it("prunes stored values the provider no longer reports", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "Fast",
provider: "codex",
options: { service_tier: "fast", sandbox_mode: "read-only" },
},
});
model.applyProviderCatalog(ENTRIES);
const speed = {
controls: [
{
type: "select" as const,
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Standard" },
{ value: "priority", label: "Fast" },
],
},
],
options: { service_tier: "priority", sandbox_mode: "read-only" },
};
model.applyControls(model.getState().controlsRequestKey ?? "", speed);
model.setOption("service_tier", "default");
// The controls stay up while the daemon answers, with the toggle laid over the last answer.
expect(model.getState().controls).toHaveLength(1);
expect(model.getState().controlOptions).toMatchObject({ service_tier: "default" });
// The request carries everything the profile would save, so the daemon resolves it.
expect(model.getState().controlsRequest?.providerOptions).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
expect(model.getState().controlsResolution).toBe("pending");
model.applyControls(model.getState().controlsRequestKey ?? "", {
...speed,
options: { service_tier: "default", sandbox_mode: "read-only" },
});
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().controlOptions).toEqual({
service_tier: "default",
sandbox_mode: "read-only",
});
});
it("draws no controls for the previous model while another model's answer is pending", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().controls).toEqual([]);
});
it("does not save a toggle once the model that drew it is replaced by one without it", () => {
const model = toggledOnOpus();
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toBeUndefined();
expect(model.getState().controlsRequest?.providerOptions).toBeUndefined();
});
it("saves a toggle again when the model that draws it returns", () => {
const model = toggledOnOpus();
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
model.setModel("claude-opus-5", { label: "Opus 5" });
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
});
it("does not save a stored toggle profile either", () => {
const model = openAgentProfileForm({
mode: "edit",
profile: {
id: "p1",
name: "Fast",
name: "UI work",
provider: "claude",
model: "claude-opus-5",
options: { fast_mode: true },
featureValues: { webSearch: true, retiredFlag: true },
},
});
model.applyProviderCatalog(ENTRIES);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
model.applyFeatures(model.getState().featureRequestKey ?? "", [
{ type: "toggle", id: "webSearch", label: "Web search", value: false },
]);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toBeUndefined();
});
it("never drops an option no control has drawn", () => {
const model = openWithCatalog({ mode: "create" });
model.setName("Fast");
selectClaude(model);
model.applyControls(model.getState().controlsRequestKey ?? "", FAST);
model.setOption("token_budget", 8000);
model.setOption("fast_mode", true);
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControls(model.getState().controlsRequestKey ?? "", NO_CONTROLS);
expect(model.getState().submitValue?.options).toEqual({ token_budget: 8000 });
});
it("keeps a toggle when the daemon cannot list controls", () => {
const model = toggledOnOpus();
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
expect(model.getState().submitValue?.options).toEqual({ fast_mode: true });
expect(model.getState().featureValues).toEqual({ webSearch: true });
});
it("resolves an unavailable feature listing without showing the field", () => {
const model = openWithCatalog({ mode: "create" });
selectClaude(model);
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? "");
model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
expect(model.getState().controlsResolution).toBe("complete");
expect(model.getState().disclosure.showControlsField).toBe(false);
expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showFeaturesField).toBe(false);
});
});
@@ -642,15 +464,15 @@ describe("openAgentProfileForm", () => {
});
});
describe("buildControlsRequestKey", () => {
describe("buildFeatureRequestKey", () => {
it("is null without a provider", () => {
expect(buildControlsRequestKey(null)).toBeNull();
expect(buildFeatureRequestKey(null)).toBeNull();
});
it("separates the fields so a value cannot bleed across positions", () => {
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildControlsRequestKey({ provider: "claude", model: "a" }),
expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildFeatureRequestKey({ provider: "claude", model: "a" }),
);
});
});
@@ -1,14 +1,13 @@
import type {
AgentOptionControl,
ProviderOptions,
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentSelectOption,
ProviderSnapshotEntry,
} from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { withoutLostControls } from "@/agent-controls/lost-controls";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
import { filterSelectableModels } from "@/provider-selection/model-catalog";
/**
@@ -58,15 +57,11 @@ export interface AgentProfileFormSnapshot {
}
/**
* The inputs a controls listing needs. Controls are provider-scoped and change
* with model/mode/thinking, so every one of those is part of the request. The
* profile's own options ride along so the daemon can resolve them (a legacy
* `service_tier: "fast"` is drawn as the model's Fast tier); they are the same
* for the life of one provider, so they are not part of the key.
* The inputs a feature listing needs. Features are provider-scoped and change
* with model/mode/thinking, so every one of those is part of the request.
*/
export interface AgentProfileControlsRequest {
export interface AgentProfileFeatureRequest {
provider: string;
providerOptions?: ProviderOptions;
model?: string;
modeId?: string;
thinkingOptionId?: string;
@@ -78,7 +73,7 @@ export interface AgentProfileFormDisclosure {
showModelField: boolean;
showModeField: boolean;
showThinkingField: boolean;
showControlsField: boolean;
showFeaturesField: boolean;
}
export interface AgentProfileFormState {
@@ -91,22 +86,13 @@ export interface AgentProfileFormState {
modelId: string;
modeId: string;
thinkingOptionId: string;
/** The options the profile was opened with, including ones no control draws. */
options: ProviderOptions;
/** Changes made in this form, over `options`. */
optionEdits: ProviderOptions;
featureValues: Record<string, unknown>;
providerOptions: AgentProfileFormOption[];
modelOptions: AgentProfileFormOption[];
modeOptions: AgentProfileFormOption[];
thinkingOptions: AgentProfileFormOption[];
controls: AgentOptionControl[];
/** What the controls show: the options the daemon resolved from `options`, then the edits. */
controlOptions: ProviderOptions;
/** Every key an answer for this provider has drawn as a control. */
drawnControlKeys: ReadonlySet<string>;
/** The keys the latest answer draws as controls. */
answeredControlKeys: ReadonlySet<string>;
features: AgentFeature[];
providerDisplay: AgentProfileFormDisplay | null;
modelDisplay: AgentProfileFormDisplay | null;
@@ -114,9 +100,9 @@ export interface AgentProfileFormState {
thinkingDisplay: AgentProfileFormDisplay | null;
catalogResolution: AgentProfileResolutionStatus;
controlsResolution: AgentProfileResolutionStatus;
controlsRequest: AgentProfileControlsRequest | null;
controlsRequestKey: string | null;
featureResolution: AgentProfileResolutionStatus;
featureRequest: AgentProfileFeatureRequest | null;
featureRequestKey: string | null;
disclosure: AgentProfileFormDisclosure;
isSubmitting: boolean;
@@ -132,12 +118,9 @@ export interface AgentProfileFormModel {
/** Late input: the host-scoped provider catalog. Never touches selections. */
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
/** Late input: the feature list for one request. Stale keys are ignored. */
applyControls: (
requestKey: string,
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
) => void;
applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
/** Resolve a request that produced no usable features (provider error). */
applyControlsUnavailable: (requestKey: string) => void;
applyFeaturesUnavailable: (requestKey: string) => void;
setName: (value: string) => void;
setAppearance: (value: { icon: string; color: string }) => void;
setNotes: (value: string) => void;
@@ -145,7 +128,7 @@ export interface AgentProfileFormModel {
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
setThinking: (thinkingOptionId: string, display: AgentProfileFormDisplay | null) => void;
setOption: (key: string, value: unknown) => void;
setFeatureValue: (featureId: string, value: unknown) => void;
setSubmitting: (value: boolean) => void;
setSubmitError: (value: string | null) => void;
}
@@ -329,39 +312,19 @@ function refineDisplay(
return current ?? { label: selectedId };
}
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null {
function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
if (!state.provider) {
return null;
}
const ownOptions = ownOptionsOf(state);
return {
provider: state.provider,
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(ownOptions).length > 0 ? { providerOptions: ownOptions } : {}),
};
}
/**
* Everything the profile would save as its options; the daemon resolves them for the form. A
* toggle the model in use does not draw is not saved (see `withoutLostControls`).
*/
function ownOptionsOf(
state: Pick<
AgentProfileFormState,
"options" | "optionEdits" | "drawnControlKeys" | "answeredControlKeys"
>,
): ProviderOptions {
return withoutLostControls(
{ ...state.options, ...state.optionEdits },
state.drawnControlKeys,
state.answeredControlKeys,
);
}
/** The part of a request that decides which controls exist; options only change their values. */
function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null {
export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
if (!request) {
return null;
}
@@ -373,25 +336,12 @@ function buildControlsScopeKey(request: AgentProfileControlsRequest | null): str
].join("|");
}
export function buildControlsRequestKey(
request: AgentProfileControlsRequest | null,
): string | null {
if (!request) {
return null;
}
const scope = buildControlsScopeKey(request);
return request.providerOptions
? `${scope}|${JSON.stringify(Object.entries(request.providerOptions).sort(([a], [b]) => a.localeCompare(b)))}`
: scope;
}
function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
const name = state.name.trim();
const notes = state.notes.trim();
if (!name || !state.provider) {
return null;
}
const options = ownOptionsOf(state);
return {
name,
...(state.icon ? { icon: state.icon } : {}),
@@ -400,16 +350,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(options).length > 0 ? { options } : {}),
...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
...(notes ? { notes } : {}),
};
}
function resolveControlsStatus(
controlsRequestKey: string | null,
function resolveFeatureStatus(
featureRequestKey: string | null,
featuresAreCurrent: boolean,
): AgentProfileResolutionStatus {
if (controlsRequestKey === null) {
if (featureRequestKey === null) {
return "idle";
}
return featuresAreCurrent ? "complete" : "pending";
@@ -442,29 +392,25 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
modelId,
modeId: profile.modeId ?? "",
thinkingOptionId: profile.thinkingOptionId ?? "",
options: { ...profile.options },
optionEdits: {},
featureValues: { ...profile.featureValues },
providerOptions: [],
modelOptions: [],
modeOptions: [],
thinkingOptions: [],
controls: [],
controlOptions: {},
drawnControlKeys: new Set(),
answeredControlKeys: new Set(),
features: [],
providerDisplay: seedDisplay(provider),
modelDisplay: seedDisplay(modelId),
modeDisplay: seedDisplay(profile.modeId),
thinkingDisplay: seedDisplay(profile.thinkingOptionId),
catalogResolution: "idle",
controlsResolution: "idle",
controlsRequest: null,
controlsRequestKey: null,
featureResolution: "idle",
featureRequest: null,
featureRequestKey: null,
disclosure: {
showModelField: false,
showModeField: false,
showThinkingField: false,
showControlsField: false,
showFeaturesField: false,
},
isSubmitting: false,
submitError: null,
@@ -476,45 +422,28 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
let entries: readonly ProviderSnapshotEntry[] = [];
let catalogResolution: AgentProfileResolutionStatus = "idle";
let resolvedControlsKey: string | null = null;
let resolvedScopeKey: string | null = null;
let resolvedControls: AgentOptionControl[] = [];
let resolvedOptions: ProviderOptions = {};
let drawnKeys: ReadonlySet<string> = new Set();
let answeredKeys: ReadonlySet<string> = new Set();
let resolvedFeatureKey: string | null = null;
let resolvedFeatures: AgentFeature[] = [];
let listeners = new Set<() => void>();
let closed = false;
function derive(incoming: AgentProfileFormState): AgentProfileFormState {
const models = resolveModels(entries, incoming.provider);
const modes = resolveModes(entries, incoming.provider);
const next: AgentProfileFormState = {
...seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
}),
drawnControlKeys: drawnKeys,
answeredControlKeys: answeredKeys,
};
const next = seedSelections(incoming, {
entry: findEntry(entries, incoming.provider),
models,
modes,
});
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
const controlsRequest = buildControlsRequest(next);
const controlsRequestKey = buildControlsRequestKey(controlsRequest);
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
const featureRequest = buildFeatureRequest(next);
const featureRequestKey = buildFeatureRequestKey(featureRequest);
const featuresAreCurrent =
controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey;
// A toggle changes only the options, so the controls stay up while the daemon answers and the
// toggle shows over the last answer. A change of provider, model, mode or thinking draws none
// until its own answer arrives.
const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey;
const controls = controlsApply ? resolvedControls : [];
let controlOptions: ProviderOptions = next.options;
if (controlsApply) {
controlOptions = featuresAreCurrent
? resolvedOptions
: { ...resolvedOptions, ...next.optionEdits };
}
const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent);
featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
const features = featuresAreCurrent
? applyFeatureValues(resolvedFeatures, next.featureValues)
: [];
const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
const withOptions: AgentProfileFormState = {
...next,
@@ -522,8 +451,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modelOptions: buildModelOptions(models),
modeOptions: buildModeOptions(modes),
thinkingOptions: buildThinkingOptions(thinking),
controls,
controlOptions,
features,
providerDisplay: refineDisplay(
next.providerDisplay,
next.provider,
@@ -551,9 +479,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
})(),
),
catalogResolution,
controlsResolution,
controlsRequest,
controlsRequestKey,
featureResolution,
featureRequest,
featureRequestKey,
};
// A field appears once the catalog can populate it, and stays visible while
@@ -564,7 +492,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
showThinkingField:
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
showControlsField: hasProvider && controls.length > 0,
showFeaturesField: hasProvider && features.length > 0,
};
const canSubmit =
withOptions.name.trim().length > 0 &&
@@ -603,26 +531,23 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
catalogResolution = "complete";
publish((current) => current);
},
applyControls: (requestKey, resolved) => {
if (requestKey !== state.controlsRequestKey) {
applyFeatures: (requestKey, features) => {
if (requestKey !== state.featureRequestKey) {
return;
}
resolvedControlsKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
resolvedControls = [...resolved.controls];
resolvedOptions = resolved.options;
answeredKeys = new Set(resolved.controls.map((control) => control.key));
drawnKeys = new Set([...drawnKeys, ...answeredKeys]);
publish((current) => current);
resolvedFeatureKey = requestKey;
resolvedFeatures = [...features];
publish((current) => ({
...current,
featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
}));
},
applyControlsUnavailable: (requestKey) => {
if (requestKey !== state.controlsRequestKey) {
applyFeaturesUnavailable: (requestKey) => {
if (requestKey !== state.featureRequestKey) {
return;
}
resolvedControlsKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest);
resolvedControls = [];
resolvedOptions = {};
resolvedFeatureKey = requestKey;
resolvedFeatures = [];
publish((current) => current);
},
setName: (value) => publish((current) => ({ ...current, name: value })),
@@ -637,8 +562,6 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
// Everything below the provider is provider-scoped: a model id, mode id,
// thinking id or feature id from the old provider means nothing here.
// Clearing them is enough — `derive` seeds the new provider's defaults.
drawnKeys = new Set();
answeredKeys = new Set();
return {
...current,
provider: providerId,
@@ -649,8 +572,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modeDisplay: null,
thinkingOptionId: "",
thinkingDisplay: null,
options: {},
optionEdits: {},
featureValues: {},
};
}),
setModel: (modelId, display) =>
@@ -676,10 +598,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
publish((current) => ({ ...current, modeId, modeDisplay: display })),
setThinking: (thinkingOptionId, display) =>
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
setOption: (key, value) =>
setFeatureValue: (featureId, value) =>
publish((current) => ({
...current,
optionEdits: { ...current.optionEdits, [key]: value },
featureValues: { ...current.featureValues, [featureId]: value },
})),
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
@@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: {
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) });
}
const featureCount = Object.keys(input.profile.options ?? {}).length;
const featureCount = Object.keys(input.profile.featureValues ?? {}).length;
if (featureCount > 0) {
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
}
@@ -117,7 +117,9 @@ export function useAgentProfilePicker(
modelId: resolved.modelId,
modeId: resolved.modeId,
thinkingOptionId: resolved.thinkingOptionId,
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}),
...(Object.keys(resolved.featureValues).length > 0
? { featureValues: resolved.featureValues }
: {}),
}),
).catch((error) => {
console.warn("[useAgentProfilePicker] persist profile selection failed", error);
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
}, [entries, input.model]);
}
export function useAgentProfileFormControls(input: {
export function useAgentProfileFormFeatures(input: {
serverId: string;
model: AgentProfileFormModel;
state: AgentProfileFormState;
@@ -35,14 +35,14 @@ export function useAgentProfileFormControls(input: {
const { t } = useTranslation();
const client = useHostRuntimeClient(input.serverId);
const isConnected = useHostRuntimeIsConnected(input.serverId);
const request = input.state.controlsRequest;
const requestKey = input.state.controlsRequestKey;
const request = input.state.featureRequest;
const requestKey = input.state.featureRequestKey;
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous
// page's data while the next request is in flight, which would hand the
// features of one provider/model combination to the key of another.
const controlsQuery = useFetchQuery({
queryKey: ["agentProfileControls", input.serverId, requestKey],
const featuresQuery = useFetchQuery({
queryKey: ["agentProfileFeatures", input.serverId, requestKey],
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(client && isConnected && request),
@@ -51,28 +51,28 @@ export function useAgentProfileFormControls(input: {
if (!client || !request) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderControls({ ...request, cwd: HOME_SCOPE_CWD });
const payload = await client.listProviderFeatures({ ...request, cwd: HOME_SCOPE_CWD });
if (payload.error) {
throw new Error(payload.error);
}
return { controls: payload.controls ?? [], options: payload.options ?? {} };
return payload.features ?? [];
},
});
const { data, isError } = controlsQuery;
const { data, isError } = featuresQuery;
const { model } = input;
useEffect(() => {
if (!requestKey || !data) {
return;
}
model.applyControls(requestKey, data);
model.applyFeatures(requestKey, data);
}, [data, model, requestKey]);
useEffect(() => {
if (!requestKey || !isError) {
return;
}
model.applyControlsUnavailable(requestKey);
model.applyFeaturesUnavailable(requestKey);
}, [isError, model, requestKey]);
}
@@ -2,8 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement }
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles";
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button";
@@ -25,7 +24,7 @@ import type {
} from "../internal/profile-form-model";
import {
useAgentProfileFormCatalog,
useAgentProfileFormControls,
useAgentProfileFormFeatures,
} from "../internal/use-profile-form-inputs";
import {
useAgentProfileFormModel,
@@ -141,7 +140,7 @@ function OpenAgentProfileEditModal({
const model = useAgentProfileFormModel(snapshot);
const state = useAgentProfileFormState(model);
useAgentProfileFormCatalog({ serverId, model });
useAgentProfileFormControls({ serverId, model, state });
useAgentProfileFormFeatures({ serverId, model, state });
const sheetHeader = useMemo<SheetHeader>(
() => ({
@@ -328,17 +327,16 @@ function OpenAgentProfileEditModal({
/>
) : null}
{state.disclosure.showControlsField ? (
{state.disclosure.showFeaturesField ? (
<Field
label={t("settings.host.agentProfiles.featuresLabel")}
testID="agent-profile-features-field"
>
<View style={styles.featureCard} testID="agent-profile-features-card">
{state.controls.map((control, index) => (
<AgentProfileControlRow
key={control.key}
control={control}
options={state.controlOptions}
{state.features.map((feature, index) => (
<AgentProfileFeatureRow
key={feature.id}
feature={feature}
isFirst={index === 0}
disabled={state.isSubmitting}
size={controlSize}
@@ -401,16 +399,14 @@ function OpenAgentProfileEditModal({
);
}
function AgentProfileControlRow({
control,
options,
function AgentProfileFeatureRow({
feature,
isFirst,
disabled,
size,
model,
}: {
control: AgentOptionControl;
options: ProviderOptions;
feature: AgentFeature;
isFirst: boolean;
disabled: boolean;
size: ControlSize;
@@ -421,75 +417,74 @@ function AgentProfileControlRow({
[isFirst],
);
const handleToggle = useCallback(
(value: boolean) => model.setOption(control.key, value),
[control.key, model],
(value: boolean) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const handleSelect = useCallback(
(value: string) => model.setOption(control.key, value),
[control.key, model],
(value: string) => model.setFeatureValue(feature.id, value),
[feature.id, model],
);
const selectOptions = useMemo<SelectFieldOption<string>[]>(
() =>
control.type === "select"
? control.choices.map((choice) => ({
id: choice.value,
value: choice.value,
label: choice.label,
...(choice.description ? { description: choice.description } : {}),
testID: `agent-profile-feature-option-${control.key}-${choice.value}`,
feature.type === "select"
? feature.options.map((option) => ({
id: option.id,
value: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
testID: `agent-profile-feature-option-${feature.id}-${option.id}`,
}))
: [],
[control],
[feature],
);
const selectedValue = control.type === "select" ? readSelectOption(control, options) : null;
const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
if (control.type !== "select" || !selectedValue) {
if (feature.type !== "select" || !feature.value) {
return null;
}
const choice = control.choices.find((candidate) => candidate.value === selectedValue);
return { label: choice?.label ?? selectedValue };
}, [control, selectedValue]);
const option = feature.options.find((candidate) => candidate.id === feature.value);
return { label: option?.label ?? feature.value };
}, [feature]);
if (control.type === "select") {
if (feature.type === "select") {
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<SelectField
label={control.label}
value={selectedValue}
label={feature.label}
value={feature.value}
selectedDisplay={selectedDisplay}
options={selectOptions}
onChange={handleSelect}
placeholder={control.label}
emptyText={control.label}
placeholder={feature.label}
emptyText={feature.label}
disabled={disabled}
{...(control.description ? { hint: control.description } : {})}
title={control.label}
{...(feature.description ? { hint: feature.description } : {})}
title={feature.label}
size={size}
testID={`agent-profile-feature-field-${control.key}`}
triggerTestID={`agent-profile-feature-trigger-${control.key}`}
testID={`agent-profile-feature-field-${feature.id}`}
triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
/>
</View>
);
}
return (
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}>
<View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<View style={styles.featureMeta}>
<Text style={styles.featureLabel} numberOfLines={1}>
{control.label}
{feature.label}
</Text>
{control.description ? (
{feature.description ? (
<Text style={styles.featureDescription} numberOfLines={2}>
{control.description}
{feature.description}
</Text>
) : null}
</View>
<Switch
value={readToggleOption(control, options)}
value={feature.value === true}
onValueChange={handleToggle}
disabled={disabled}
accessibilityLabel={control.label}
testID={`agent-profile-feature-switch-${control.key}`}
accessibilityLabel={feature.label}
testID={`agent-profile-feature-switch-${feature.id}`}
/>
</View>
);
+1 -2
View File
@@ -1304,8 +1304,7 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent)
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId");
}
if (left.options !== right.options) reasons.push("agent.options");
if (left.controls !== right.controls) reasons.push("agent.controls");
if (left.features !== right.features) reasons.push("agent.features");
return reasons;
}
@@ -40,7 +40,16 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
cwd: "/repo",
model: "agent-model",
thinkingOptionId: "think-hard",
options: { "web-search": true, effort: "high" },
features: [
{ type: "toggle", id: "web-search", label: "Web search", value: true },
{
type: "select",
id: "effort",
label: "Effort",
value: "high",
options: [{ id: "high", label: "High" }],
},
],
parentAgentId: null,
labels: {},
...overrides,
@@ -105,7 +114,7 @@ describe("buildDraftAgentSetup", () => {
modeId: "mode-current",
model: "agent-model",
thinkingOptionId: "think-hard",
providerOptions: {
featureValues: {
"web-search": true,
effort: "high",
},
@@ -65,12 +65,17 @@ export function resolveClientSlashCommand(input: {
}
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return {
provider: agent.provider,
cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
providerOptions: { ...agent.options },
featureValues,
};
}
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest";
import type {
AgentMode,
AgentOptionControl,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import type { CommandCenterContribution } from "./contributions";
import {
@@ -35,7 +31,7 @@ const ICONS: AgentControlContributionSource["icons"] = {
thinking: TestIcon,
planMode: TestIcon,
mode: () => TestIcon,
control: () => TestIcon,
feature: () => TestIcon,
};
function provider(input: {
@@ -69,8 +65,8 @@ function provider(input: {
};
}
function toggleControl(key: string, icon?: string): AgentOptionControl {
return { type: "toggle", key, label: key, icon };
function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
return { type: "toggle", id, label: id, value, icon };
}
function makeSource(
@@ -79,7 +75,7 @@ function makeSource(
models?: Partial<AgentControlContributionSource["models"]>;
thinking?: Partial<AgentControlContributionSource["thinking"]>;
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
optionControls?: Partial<AgentControlContributionSource["optionControls"]>;
features?: Partial<AgentControlContributionSource["features"]>;
} = {},
): AgentControlContributionSource {
return {
@@ -108,11 +104,10 @@ function makeSource(
select: () => undefined,
...overrides.modes,
},
optionControls: {
controls: [],
options: {},
onSetOption: () => undefined,
...overrides.optionControls,
features: {
list: [],
set: () => undefined,
...overrides.features,
},
};
}
@@ -138,7 +133,7 @@ describe("Command Center agent-control contributions", () => {
const contributions = buildAgentControlContributions({
...makeSource({
models: { providers: [provider({ id: "claude", label: "Claude" })] },
optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } },
features: { list: [toggleFeature("plan_mode", true)] },
}),
modes: undefined,
});
@@ -328,10 +323,9 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default",
defaultModeId: "default",
},
optionControls: {
controls: [toggleControl("plan_mode", "list-todo")],
options: { plan_mode: true },
onSetOption: (id, value) => {
features: {
list: [toggleFeature("plan_mode", true, "list-todo")],
set: (id, value) => {
calls.push([id, value]);
},
},
@@ -347,10 +341,9 @@ describe("Command Center agent-control contributions", () => {
const calls: [string, unknown][] = [];
const contributions = buildAgentControlContributions(
makeSource({
optionControls: {
controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")],
options: { fast_mode: false, auto_accept: true },
onSetOption: (id, value) => {
features: {
list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
set: (id, value) => {
calls.push([id, value]);
},
},
@@ -383,7 +376,7 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default",
defaultModeId: "default",
},
optionControls: { controls: [toggleControl("fast_mode")], options: {} },
features: { list: [toggleFeature("fast_mode")] },
}),
);
const ranks = new Map<string, number>();
@@ -1,10 +1,9 @@
import type {
AgentFeature,
AgentMode,
AgentOptionControl,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import { readToggleOption, type ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import {
@@ -62,7 +61,7 @@ export interface AgentControlContributionIcons {
thinking: CommandCenterIcon;
planMode: CommandCenterIcon;
mode(modeId: string): CommandCenterIcon;
control(control: AgentOptionControl): CommandCenterIcon;
feature(feature: AgentFeature): CommandCenterIcon;
}
interface CommandCenterChoice {
@@ -106,7 +105,10 @@ export interface AgentControlContributionSource {
defaultModeId: string | null;
select(modeId: string): void | Promise<void>;
};
optionControls: ComposerOptionControls;
features: {
list: readonly AgentFeature[];
set(featureId: string, value: unknown): void | Promise<void>;
};
}
function buildContributions(
@@ -240,19 +242,18 @@ function toggleChoices(input: {
}
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls;
const planControl = controls.find(
(control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle",
const planFeature = source.features.list.find(
(feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
);
let choices: CommandCenterChoice[] = [];
if (planControl?.type === "toggle") {
if (planFeature?.type === "toggle") {
choices = toggleChoices({
source,
key: "plan",
icon: source.icons.control(planControl),
isOn: readToggleOption(planControl, options),
turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false),
icon: source.icons.feature(planFeature),
isOn: planFeature.value,
turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true),
turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false),
});
} else if (source.modes) {
const modes = source.modes;
@@ -279,19 +280,18 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent
}
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls;
const fastControl = controls.find(
(control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle",
const fastFeature = source.features.list.find(
(feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
);
const choices =
fastControl?.type === "toggle"
fastFeature?.type === "toggle"
? toggleChoices({
source,
key: "fast",
icon: source.icons.control(fastControl),
isOn: readToggleOption(fastControl, options),
turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false),
icon: source.icons.feature(fastFeature),
isOn: fastFeature.value,
turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true),
turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false),
})
: [];
return {
@@ -1,6 +1,11 @@
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { AgentMode, AgentProvider, AgentSelectOption } from "@getpaseo/protocol/agent-types";
import type {
AgentFeature,
AgentMode,
AgentProvider,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
getAgentFeatureIcon,
@@ -9,7 +14,6 @@ import {
ThinkingIcon,
} from "@/agent-controls/icons";
import { useProviderIcons } from "@/components/provider-icons";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import {
buildAgentControlContributions,
@@ -40,15 +44,15 @@ export interface AgentControlCommandCenterSource {
selectedId: string | null | undefined;
select(modeId: string): void | Promise<void>;
};
/** Absent when the surface cannot change options (the agent has no client). */
optionControls: ComposerOptionControls | null;
features: {
list: readonly AgentFeature[] | undefined;
set?: (featureId: string, value: unknown) => void | Promise<void>;
};
}
const NO_OPTION_CONTROLS: ComposerOptionControls = {
controls: [],
options: {},
onSetOption: () => undefined,
};
function ignoreAgentFeatureChange(): void {
return;
}
function resolveDefaultModeId(
provider: string | null | undefined,
@@ -83,7 +87,7 @@ export function useAgentControlCommandCenterActions(input: {
}): void {
const { t } = useTranslation();
const { controls } = input;
const { optionControls, models, modes, thinking } = controls;
const { features, models, modes, thinking } = controls;
const getProviderIcon = useProviderIcons(controls.serverId);
const actions = useMemo(
() =>
@@ -100,7 +104,7 @@ export function useAgentControlCommandCenterActions(input: {
getCommandCenterIcon(
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
),
control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)),
feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
},
models: {
providers: models.providers,
@@ -119,7 +123,10 @@ export function useAgentControlCommandCenterActions(input: {
modes?.select,
resolveDefaultModeId(controls.provider, controls.providerDefinitions),
),
optionControls: optionControls ?? NO_OPTION_CONTROLS,
features: {
list: features.set ? (features.list ?? []) : [],
set: features.set ?? ignoreAgentFeatureChange,
},
}),
[
getProviderIcon,
@@ -127,7 +134,8 @@ export function useAgentControlCommandCenterActions(input: {
controls.provider,
controls.providerDefinitions,
controls.serverId,
optionControls,
features.list,
features.set,
models.providers,
models.select,
models.selectedModelId,
+6 -46
View File
@@ -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);
+224 -211
View File
@@ -25,13 +25,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow";
import { Settings2 } from "lucide-react-native";
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons";
import {
getControlTooltip,
isControlActive,
readSelectOption,
readToggleOption,
type ComposerOptionControls,
} from "@/agent-controls/option-controls";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { CombinedModelSelector } from "@/components/combined-model-selector";
@@ -54,8 +47,7 @@ import {
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type {
AgentOptionControl,
ProviderOptions,
AgentFeature,
AgentMode,
AgentModelDefinition,
AgentProvider,
@@ -63,6 +55,8 @@ import type {
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import {
getFeatureHighlightColor,
isFeatureActive,
getFeatureTooltip,
getAgentControlHintKey,
resolveAgentModelSelection,
} from "@/composer/agent-controls/utils";
@@ -107,7 +101,6 @@ interface AgentControlOption {
type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
const EMPTY_OPTIONS: ProviderOptions = {};
interface ControlledAgentControlsProps {
provider: string;
@@ -129,7 +122,8 @@ interface ControlledAgentControlsProps {
onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
onEditAgentProfile?: (profileId: string) => void;
optionControls?: ComposerOptionControls;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -156,7 +150,8 @@ export interface DraftAgentControlsProps {
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
optionControls?: ComposerOptionControls;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void;
onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -217,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
}
function getFeatureIconColor(
control: AgentOptionControl,
options: ProviderOptions,
feature: AgentFeature,
palette: {
blue: { 400: string };
green: { 400: string };
@@ -226,11 +220,11 @@ function getFeatureIconColor(
},
foregroundMuted: string,
): string {
if (!isControlActive(control, options)) {
if (!isFeatureActive(feature)) {
return foregroundMuted;
}
switch (getFeatureHighlightColor(control.key)) {
switch (getFeatureHighlightColor(feature.id)) {
case "blue":
return palette.blue[400];
case "green":
@@ -248,20 +242,20 @@ function resolveHasAnyControl({
providerOptions,
canSelectModel,
thinkingOptions,
controls,
features,
hasMode,
}: {
providerOptions: AgentControlOption[] | undefined;
canSelectModel: boolean;
thinkingOptions: AgentControlOption[] | undefined;
controls: readonly AgentOptionControl[] | undefined;
features: AgentFeature[] | undefined;
hasMode: boolean;
}) {
return (
Boolean(providerOptions?.length) ||
canSelectModel ||
Boolean(thinkingOptions?.length) ||
Boolean(controls?.length) ||
Boolean(features?.length) ||
hasMode
);
}
@@ -399,8 +393,7 @@ type AgentControlsSlice = {
cwd: string | null;
runtimeModelId: string | null;
model: string | null | undefined;
options: ProviderOptions | undefined;
controls: AgentOptionControl[] | undefined;
features: AgentFeature[] | undefined;
runtimeThinkingOptionId: string | null;
thinkingOptionId: string | null | undefined;
lastUsage: unknown;
@@ -420,8 +413,7 @@ function selectAgentControlsSlice(
cwd: currentAgent.cwd,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model,
options: currentAgent.options,
controls: currentAgent.controls,
features: currentAgent.features,
runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null,
thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage,
@@ -501,7 +493,8 @@ function ControlledAgentControls({
onEditAgentProfiles,
onCreateAgentProfile,
onEditAgentProfile,
optionControls,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
onRetryModelProvider,
@@ -553,21 +546,20 @@ function ControlledAgentControls({
providerOptions,
canSelectModel,
thinkingOptions,
controls: optionControls?.controls,
features,
hasMode: modeControl !== null && modeControl !== undefined,
});
const featureControls = useMemo(
() =>
(optionControls?.controls ?? []).map((control) => {
if (control.type === "toggle") return { type: "toggle" as const };
const selected = readSelectOption(control, optionControls?.options ?? {});
const choice = control.choices.find((candidate) => candidate.value === selected);
(features ?? []).map((feature) => {
if (feature.type === "toggle") return { type: "toggle" as const };
const selectedOption = feature.options.find((option) => option.id === feature.value);
return {
type: "select" as const,
label: choice?.label ?? control.label,
label: selectedOption?.label ?? feature.label,
};
}),
[optionControls],
[features],
);
const controlPresence = useMemo(
() => ({
@@ -751,7 +743,8 @@ function ControlledAgentControls({
selectedModelId={selectedModelId}
thinkingOptions={formattedThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId}
optionControls={optionControls}
features={features}
onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile}
@@ -799,7 +792,8 @@ function ControlledAgentControls({
provider={provider}
selectedModelId={selectedModelId}
selectedThinkingOptionId={selectedThinkingOptionId}
optionControls={optionControls}
features={features}
onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile}
@@ -844,7 +838,8 @@ interface DesktopAgentControlsContentProps {
selectedModelId?: string;
thinkingOptions?: AgentControlOption[];
selectedThinkingOptionId?: string;
optionControls?: ComposerOptionControls;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
@@ -905,7 +900,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
selectedModelId,
thinkingOptions,
selectedThinkingOptionId,
optionControls,
features,
onSetFeature,
onApplyAgentProfile,
onEditAgentProfiles,
onCreateAgentProfile,
@@ -1061,7 +1057,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
{modeControl ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null}
{presentation.aggregateFeatures && optionControls?.controls.length ? (
{presentation.aggregateFeatures && features?.length ? (
<>
<Pressable
onPress={handleOpenFeatures}
@@ -1081,29 +1077,27 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onClose={handleCloseSheet}
testID="agent-features-sheet"
>
{optionControls.controls.map((control) => (
{features.map((feature) => (
<SheetFeatureItem
key={`feature-${control.key}`}
control={control}
options={optionControls.options}
key={`feature-${feature.id}`}
feature={feature}
disabled={disabled}
openSelector={openSelector}
handleOpenChange={handleNestedOpenChange}
onSetOption={optionControls.onSetOption}
onSetFeature={onSetFeature}
/>
))}
</AdaptiveModalSheet>
</>
) : (
optionControls?.controls.map((control) => (
features?.map((feature) => (
<DesktopFeatureItem
key={`feature-${control.key}`}
control={control}
options={optionControls.options}
key={`feature-${feature.id}`}
feature={feature}
disabled={disabled}
openSelector={openSelector}
handleOpenChange={handleOpenChange}
onSetOption={optionControls.onSetOption}
onSetFeature={onSetFeature}
onActionComplete={onDropdownClose}
/>
))
@@ -1116,7 +1110,8 @@ interface SheetAgentControlsContentProps {
provider: string;
selectedModelId?: string;
selectedThinkingOptionId?: string;
optionControls?: ComposerOptionControls;
features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
@@ -1159,7 +1154,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
provider,
selectedModelId,
selectedThinkingOptionId,
optionControls,
features,
onSetFeature,
onApplyAgentProfile,
onEditAgentProfiles,
onCreateAgentProfile,
@@ -1192,7 +1188,6 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
} = props;
const thinkingAnchorRef = useRef<View | null>(null);
const sheetOptions = optionControls?.options ?? EMPTY_OPTIONS;
const hasThinking = comboboxThinkingOptions.length > 0;
@@ -1243,15 +1238,14 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
{modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null}
{(optionControls?.controls ?? []).map((control) => (
{(features ?? []).map((feature) => (
<SheetFeatureItem
key={`feature-${control.key}`}
control={control}
options={sheetOptions}
key={`feature-${feature.id}`}
feature={feature}
disabled={disabled}
openSelector={openSelector}
handleOpenChange={handleOpenChange}
onSetOption={optionControls?.onSetOption}
onSetFeature={onSetFeature}
/>
))}
</View>
@@ -1285,24 +1279,22 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
}
function DesktopFeatureItem({
control,
options,
feature,
disabled,
openSelector,
handleOpenChange,
onSetOption,
onSetFeature,
onActionComplete,
}: {
control: AgentOptionControl;
options: ProviderOptions;
feature: AgentFeature;
disabled: boolean;
openSelector: AgentControlSelector | null;
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
onSetOption?: (key: string, value: unknown) => void;
onSetFeature?: (featureId: string, value: unknown) => void;
onActionComplete?: () => void;
}) {
const { theme } = useUnistyles();
const featureSelector: AgentControlSelector = `feature-${control.key}`;
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null);
const handleFeatureOpenChange = useMemo(
@@ -1315,117 +1307,120 @@ function DesktopFeatureItem({
);
const handleTogglePress = useCallback(() => {
if (control.type === "toggle") {
onSetOption?.(control.key, !readToggleOption(control, options));
if (feature.type === "toggle") {
onSetFeature?.(feature.id, !feature.value);
onActionComplete?.();
}
}, [control, onActionComplete, onSetOption, options]);
}, [feature, onActionComplete, onSetFeature]);
const handleSelectOption = useCallback(
(choiceValue: string) => {
onSetOption?.(control.key, choiceValue);
(optionId: string) => {
onSetFeature?.(feature.id, optionId);
},
[control.key, onSetOption],
[feature.id, onSetFeature],
);
const comboboxOptions = useMemo<ComboboxOption[]>(
() =>
control.type === "select"
? control.choices.map((choice) => ({ id: choice.value, label: choice.label }))
feature.type === "select"
? feature.options.map((option) => ({ id: option.id, label: option.label }))
: [],
[control],
[feature],
);
const FeatureIcon = getAgentFeatureIcon(control.icon);
const iconColor = getFeatureIconColor(
control,
options,
theme.colors.palette,
theme.colors.foregroundMuted,
);
if (control.type === "toggle") {
if (feature.type === "toggle") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
icon={FeatureIcon}
iconColor={iconColor}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar"
label={control.label}
label={feature.label}
showToolbarLabel={false}
disabled={disabled}
onPress={handleTogglePress}
accessibilityLabel={getControlTooltip(control)}
testID={`agent-feature-${control.key}`}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getControlTooltip(control)}</Text>
<Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent>
</Tooltip>
);
}
const selectedValue = readSelectOption(control, options);
const selectedChoice = control.choices.find((choice) => choice.value === selectedValue);
const iconOnly = control.desktopTrigger === "icon";
const tooltip = iconOnly
? `${control.label}: ${selectedChoice?.label ?? control.label}`
: getControlTooltip(control);
return (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={iconColor}
surface="toolbar"
label={control.label}
value={selectedChoice?.label ?? control.label}
showToolbarLabel={!iconOnly}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={tooltip}
testID={`agent-feature-${control.key}`}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxOptions}
value={String(selectedValue)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
desktopPlacement="top-start"
/>
</>
);
if (feature.type === "select") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
const iconOnly = feature.desktopTrigger === "icon";
const tooltip = iconOnly
? `${feature.label}: ${selectedOption?.label ?? feature.label}`
: getFeatureTooltip(feature);
return (
<>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar"
label={feature.label}
value={selectedOption?.label ?? feature.label}
showToolbarLabel={!iconOnly}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={tooltip}
testID={`agent-feature-${feature.id}`}
/>
</TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{tooltip}</Text>
</TooltipContent>
</Tooltip>
<Combobox
options={comboboxOptions}
value={String(feature.value)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
desktopPlacement="top-start"
/>
</>
);
}
return null;
}
function SheetFeatureItem({
control,
options,
feature,
disabled,
openSelector,
handleOpenChange,
onSetOption,
onSetFeature,
}: {
control: AgentOptionControl;
options: ProviderOptions;
feature: AgentFeature;
disabled: boolean;
openSelector: AgentControlSelector | null;
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
onSetOption?: (key: string, value: unknown) => void;
onSetFeature?: (featureId: string, value: unknown) => void;
}) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const featureSelector: AgentControlSelector = `feature-${control.key}`;
const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null);
const handleFeatureOpenChange = useMemo(
@@ -1436,71 +1431,97 @@ function SheetFeatureItem({
() => handleFeatureOpenChange(openSelector !== featureSelector),
[featureSelector, handleFeatureOpenChange, openSelector],
);
const sheetHeader = useMemo<SheetHeader>(() => ({ title: control.label }), [control.label]);
const sheetHeader = useMemo<SheetHeader>(() => ({ title: feature.label }), [feature.label]);
const handleSelectOption = useCallback(
(choiceValue: string) => {
onSetOption?.(control.key, control.type === "toggle" ? choiceValue === "true" : choiceValue);
(optionId: string) => {
onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId);
},
[control.key, control.type, onSetOption],
[feature.id, feature.type, onSetFeature],
);
const comboboxOptions = useMemo<ComboboxOption[]>(() => {
if (control.type === "select") {
return control.choices.map((choice) => ({ id: choice.value, label: choice.label }));
if (feature.type === "select") {
return feature.options.map((option) => ({ id: option.id, label: option.label }));
}
return [
{ id: "true", label: t("agentControls.features.on") },
{ id: "false", label: t("agentControls.features.off") },
];
}, [control, t]);
}, [feature, t]);
const FeatureIcon = getAgentFeatureIcon(control.icon);
const iconColor = getFeatureIconColor(
control,
options,
theme.colors.palette,
theme.colors.foregroundMuted,
);
let selectedValue: string;
let displayValue: string;
if (control.type === "toggle") {
const isOn = readToggleOption(control, options);
selectedValue = String(isOn);
displayValue = isOn ? t("agentControls.features.on") : t("agentControls.features.off");
} else {
const selected = readSelectOption(control, options);
selectedValue = String(selected);
displayValue =
control.choices.find((choice) => choice.value === selected)?.label ?? control.label;
if (feature.type === "toggle") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
return (
<>
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="sheet"
label={feature.label}
value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
<Combobox
options={comboboxOptions}
value={String(feature.value)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
searchable={false}
header={sheetHeader}
/>
</>
);
}
return (
<>
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={iconColor}
surface="sheet"
label={control.label}
value={displayValue}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={getControlTooltip(control)}
testID={`agent-feature-${control.key}`}
/>
<Combobox
options={comboboxOptions}
value={selectedValue}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
searchable={control.type === "toggle" ? false : undefined}
header={sheetHeader}
/>
</>
);
if (feature.type === "select") {
const FeatureIcon = getAgentFeatureIcon(feature.icon);
const selectedOption = feature.options.find((o) => o.id === feature.value);
return (
<>
<AgentControlTrigger
ref={featureAnchorRef}
icon={FeatureIcon}
iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="sheet"
label={feature.label}
value={selectedOption?.label ?? feature.label}
open={openSelector === featureSelector}
disabled={disabled}
onPress={handleSelectPress}
accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${feature.id}`}
/>
<Combobox
options={comboboxOptions}
value={String(feature.value)}
onSelect={handleSelectOption}
open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
header={sheetHeader}
/>
</>
);
}
return null;
}
function ThinkingComboboxOption({
@@ -1528,18 +1549,6 @@ function ThinkingComboboxOption({
);
}
function useAgentOptionControls(
agent: { controls?: AgentOptionControl[]; options?: ProviderOptions } | null | undefined,
onSetOption: ComposerOptionControls["onSetOption"],
): ComposerOptionControls {
const controls = agent?.controls;
const options = agent?.options;
return useMemo(
() => ({ controls: controls ?? [], options: options ?? EMPTY_OPTIONS, onSetOption }),
[controls, onSetOption, options],
);
}
export const AgentControls = memo(function AgentControls({
agentId,
serverId,
@@ -1691,8 +1700,8 @@ export const AgentControls = memo(function AgentControls({
[activeModelId, agentId, agentProvider, client, toast, updatePreferences],
);
const handleSetOption = useCallback(
(key: string, value: unknown) => {
const handleSetFeature = useCallback(
(featureId: string, value: unknown) => {
if (!client || !agentProvider) {
return;
}
@@ -1701,24 +1710,22 @@ export const AgentControls = memo(function AgentControls({
preferences: current,
provider: agentProvider,
updates: {
options: {
[key]: value,
featureValues: {
[featureId]: value,
},
},
}),
).catch((error) => {
console.warn("[AgentControls] persist option preference failed", error);
console.warn("[AgentControls] persist feature preference failed", error);
});
void client.setAgentOptions(agentId, { [key]: value }).catch((error) => {
console.warn("[AgentControls] setAgentOptions failed", error);
void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentControls] setAgentFeature failed", error);
toast.error(toErrorMessage(error));
});
},
[agentId, agentProvider, client, toast, updatePreferences],
);
const agentOptionControls = useAgentOptionControls(agent, handleSetOption);
const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
() => ({
serverId,
@@ -1737,18 +1744,21 @@ export const AgentControls = memo(function AgentControls({
select: handleSelectThinkingOption,
},
modes: commandCenterModes,
optionControls: client ? agentOptionControls : null,
features: {
list: agent?.features,
set: handleSetFeature,
},
}),
[
activeModelId,
agentOptionControls,
client,
agent?.features,
agentId,
agentModelSelectorProviders,
agentProvider,
commandCenterModes,
handleSelectCommandCenterModel,
handleSelectThinkingOption,
handleSetFeature,
modeProviderDefinitions,
modelSelection.selectedThinkingId,
modelSelection.thinkingOptions,
@@ -1797,7 +1807,8 @@ export const AgentControls = memo(function AgentControls({
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={handleSelectThinkingOption}
optionControls={agentOptionControls}
features={agent.features}
onSetFeature={handleSetFeature}
isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={handleModelSelectorOpen}
onRetryModelProvider={handleRetryModelProvider}
@@ -1829,7 +1840,8 @@ export function DraftAgentControls({
selectedThinkingOptionId,
onSelectThinkingOption,
onApplyAgentProfile,
optionControls,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen,
onRetryModelProvider,
@@ -1915,7 +1927,8 @@ export function DraftAgentControls({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption}
optionControls={optionControls}
features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider}
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest";
import {
getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHintKey,
isFeatureActive,
normalizeModelId,
resolveAgentModelSelection,
} from "./utils";
@@ -15,12 +17,51 @@ describe("getAgentControlHintKey", () => {
});
describe("feature metadata helpers", () => {
it("prefers explicit feature tooltip copy", () => {
expect(
getFeatureTooltip({
label: "Plan",
tooltip: "Toggle plan mode",
}),
).toBe("Toggle plan mode");
});
it("falls back to the feature label when no tooltip is provided", () => {
expect(
getFeatureTooltip({
label: "Custom",
}),
).toBe("Custom");
});
it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default");
});
it("treats a select feature as active when a non-default option is selected", () => {
const speed = {
type: "select" as const,
id: "service_tier",
label: "Speed",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
};
expect(isFeatureActive({ ...speed, value: "default" })).toBe(false);
expect(isFeatureActive({ ...speed, value: "fast" })).toBe(true);
expect(
isFeatureActive({
type: "toggle",
id: "fast_mode",
label: "Fast",
value: true,
}),
).toBe(true);
});
});
describe("normalizeModelId", () => {
@@ -1,4 +1,4 @@
import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import type { AgentFeature, AgentModelDefinition } from "@getpaseo/protocol/agent-types";
import { i18n } from "@/i18n/i18next";
import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import {
@@ -35,6 +35,16 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized;
}
export function getFeatureTooltip(feature: Pick<AgentFeature, "label" | "tooltip">): string {
return feature.tooltip ?? feature.label;
}
export function isFeatureActive(feature: AgentFeature): boolean {
if (feature.type === "toggle") return feature.value;
const selectedOption = feature.options.find((option) => option.id === feature.value);
return selectedOption !== undefined && !selectedOption.isDefault;
}
export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) {
case FAST_MODE_FEATURE_ID:
@@ -20,11 +20,12 @@ export function resolveDraftKey(input: {
export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult;
optionControls?: DraftAgentControlsProps["optionControls"];
features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
}): DraftAgentControlsProps {
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input;
const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
return {
providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider,
@@ -42,7 +43,8 @@ export function buildDraftAgentControls(input: {
selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser,
onApplyAgentProfile,
optionControls,
features,
onSetFeature,
onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale,
onRetryModelProvider: formState.refreshProviderModels,
+25 -16
View File
@@ -8,7 +8,7 @@ import {
type CreateAgentInitialValues,
type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state";
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options";
import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import {
buildDraftAgentControls,
hasDraftContent,
@@ -35,7 +35,7 @@ type AttachmentUpdater =
interface AgentInputDraftComposerOptions {
initialServerId: string | null;
initialValues?: CreateAgentInitialValues;
initialProviderOptions?: Record<string, unknown>;
initialFeatureValues?: Record<string, unknown>;
isVisible?: boolean;
lockedWorkingDir?: string;
}
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveModeId: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
providerOptions: Record<string, unknown> | undefined;
featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps;
commandDraft: DraftCommandTarget;
};
@@ -274,25 +274,26 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
);
const {
optionControls: draftOptionControls,
options: draftOptions,
applyProfile: applyProfileOptions,
} = useDraftProviderOptions({
features: draftFeatures,
featureValues: draftFeatureValues,
setFeatureValue: setDraftFeatureValue,
applyProfileFeatureValues,
} = useDraftAgentFeatures({
serverId: formState.selectedServerId,
provider: formState.selectedProvider,
cwd: workingDir,
modeId: formState.selectedMode,
modelId: effectiveModelId,
thinkingOptionId: effectiveThinkingOptionId,
initialOptions: composerOptions?.initialProviderOptions,
initialFeatureValues: composerOptions?.initialFeatureValues,
});
const applyDraftAgentProfile = useCallback(
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
formState.applyProfileFromUser(profile);
applyProfileOptions({ provider: profile.provider, options: profile.options });
applyProfileFeatureValues(profile.featureValues);
},
[applyProfileOptions, formState],
[applyProfileFeatureValues, formState],
);
const commandDraft = useMemo(
@@ -302,9 +303,15 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
cwd: workingDir,
effectiveModelId,
effectiveThinkingOptionId,
providerOptions: draftOptions,
featureValues: draftFeatureValues,
}),
[effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir],
[
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
providerSelection,
workingDir,
],
);
const composerState = useMemo<DraftComposerState | null>(() => {
@@ -318,10 +325,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId,
effectiveModelId,
effectiveThinkingOptionId,
providerOptions: draftOptions,
featureValues: draftFeatureValues,
agentControls: buildDraftAgentControls({
formState,
optionControls: draftOptionControls,
features: draftFeatures,
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile,
}),
commandDraft,
@@ -332,10 +340,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId,
effectiveModelId,
effectiveThinkingOptionId,
draftOptionControls,
draftOptions,
draftFeatures,
draftFeatureValues,
applyDraftAgentProfile,
formState,
setDraftFeatureValue,
workingDir,
]);
@@ -1,4 +1,3 @@
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -66,7 +65,7 @@ interface AutoSubmitConfig {
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
providerOptions: Record<string, unknown>;
featureValues: Record<string, unknown>;
}
function resolveAutoSubmitConfig(
@@ -75,7 +74,7 @@ function resolveAutoSubmitConfig(
modeId?: string | null;
model?: string | null;
thinkingOptionId?: string | null;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
} | null,
): AutoSubmitConfig | null {
if (!pending) return null;
@@ -84,7 +83,7 @@ function resolveAutoSubmitConfig(
modeId: pending.modeId ?? null,
model: pending.model ?? null,
thinkingOptionId: pending.thinkingOptionId ?? null,
providerOptions: pending.providerOptions ?? {},
featureValues: pending.featureValues ?? {},
};
}
@@ -132,7 +131,7 @@ async function submitDraftCreateRequest(input: {
effectiveModeId: string;
effectiveModelId: string | null;
effectiveThinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined;
featureValues: Record<string, unknown> | undefined;
};
hostDisconnectedMessage: string;
selectModelMessage: string;
@@ -171,7 +170,7 @@ async function submitDraftCreateRequest(input: {
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId:
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
providerOptions: autoSubmitConfig?.providerOptions ?? composerState.providerOptions,
featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
});
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
@@ -205,7 +204,7 @@ function buildDraftAgentSnapshot(input: {
effectiveThinkingOptionId: string | null;
effectiveModeId: string;
selectedProvider: string | null;
agentControls: { optionControls?: ComposerOptionControls };
agentControls: { features?: Agent["features"] };
};
selectModelMessage: string;
}): Agent {
@@ -242,8 +241,7 @@ function buildDraftAgentSnapshot(input: {
title: "Agent",
cwd: workspaceDirectory,
model,
options: composerState.agentControls.optionControls?.options,
controls: composerState.agentControls.optionControls?.controls,
features: composerState.agentControls.features,
thinkingOptionId,
parentAgentId: null,
labels: {},
@@ -335,7 +333,7 @@ export function WorkspaceDraftAgentTab({
composer: {
initialServerId: serverId,
initialValues: draftInitialValues,
initialProviderOptions: draftSetup?.providerOptions,
initialFeatureValues: draftSetup?.featureValues,
isVisible: true,
lockedWorkingDir: draftWorkingDirectory ?? undefined,
},
@@ -353,7 +351,8 @@ export function WorkspaceDraftAgentTab({
const draftModeOptions = composerState.modeOptions;
const draftSelectedMode = composerState.selectedMode;
const draftSetMode = composerState.setModeFromUser;
const draftOptionControls = composerState.agentControls.optionControls;
const draftFeatures = composerState.agentControls.features;
const draftOnSetFeature = composerState.agentControls.onSetFeature;
const clearDraftInput = draftInput.clear;
const replaceDraftText = draftInput.replaceText;
@@ -522,7 +521,10 @@ export function WorkspaceDraftAgentTab({
selectedId: draftSelectedMode,
select: draftSetMode,
},
optionControls: draftOptionControls ?? null,
features: {
list: draftFeatures,
set: draftOnSetFeature,
},
},
});
const isReadyForPendingAutoSubmit = Boolean(
@@ -80,7 +80,7 @@ describe("create agent preferences", () => {
modelId: "gpt-5.5",
modeId: "full-access",
thinkingOptionId: "high",
options: { fast_mode: true },
featureValues: { fast_mode: true },
}),
);
@@ -95,7 +95,7 @@ describe("create agent preferences", () => {
model: "gpt-5.5",
mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" },
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
},
});
@@ -119,7 +119,7 @@ describe("create agent preferences", () => {
model: "gpt-5.6",
mode: undefined,
thinkingByModel: undefined,
options: undefined,
featureValues: undefined,
},
}),
).toEqual({
@@ -167,13 +167,13 @@ describe("create agent preferences", () => {
modelId: "one-minute-stream",
modeId: "approval-test",
thinkingOptionId: "",
options: {},
featureValues: {},
}),
).toEqual({
provider: "mock",
providerPreferences: {
pi: { model: "anthropic/sonnet" },
mock: { model: "one-minute-stream", mode: "approval-test", options: {} },
mock: { model: "one-minute-stream", mode: "approval-test", featureValues: {} },
},
});
});
@@ -208,29 +208,6 @@ describe("create agent preferences", () => {
});
});
it("reads preferences saved as featureValues as options", () => {
expect(
parseFormPreferences({
provider: "codex",
providerPreferences: { codex: { featureValues: { service_tier: "priority" } } },
}),
).toEqual({
provider: "codex",
providerPreferences: { codex: { options: { service_tier: "priority" } } },
});
});
it("keeps options that are not booleans or strings, such as a profile's sandbox settings", () => {
const options = {
sandbox_workspace_write: { network_access: true, writable_roots: ["/tmp"] },
max_turns: 3,
};
expect(
parseFormPreferences({ provider: "codex", providerPreferences: { codex: { options } } }),
).toEqual({ provider: "codex", providerPreferences: { codex: { options } } });
});
it("rejects unknown persisted fields outside the explicit legacy shape", () => {
expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({});
});
@@ -1,14 +1,13 @@
import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types";
const optionsSchema = z.record(z.string(), z.json());
const featureValuesSchema = z.record(z.string(), z.union([z.boolean(), z.string(), z.null()]));
export interface ProviderPreferences {
model?: string;
mode?: string;
thinkingByModel?: Record<string, string>;
/** Last-used values of the provider's option controls, by option key. */
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
@@ -21,23 +20,12 @@ export interface FormPreferences {
launchTarget?: LaunchTarget;
}
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Preferences written
// before the rename stored the same record as `featureValues`.
function renameLegacyFeatureValues(value: unknown): unknown {
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value;
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>;
return "options" in rest ? rest : { ...rest, options: featureValues };
}
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.preprocess(
renameLegacyFeatureValues,
z.strictObject({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string(), z.string()).optional(),
options: optionsSchema.optional(),
}),
);
const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.strictObject({
model: z.string().optional(),
mode: z.string().optional(),
thinkingByModel: z.record(z.string(), z.string()).optional(),
featureValues: featureValuesSchema.optional(),
});
const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("chat") }),
@@ -128,7 +116,7 @@ function applyProviderPreferenceUpdates(
): ProviderPreferences {
const next: ProviderPreferences = { ...existing };
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel);
const nextOptions = mergeDefinedRecord(existing.options, updates.options);
const nextFeatureValues = mergeDefinedRecord(existing.featureValues, updates.featureValues);
if (updates.model !== undefined) {
next.model = updates.model;
@@ -141,8 +129,8 @@ function applyProviderPreferenceUpdates(
if (nextThinkingByModel !== undefined) {
next.thinkingByModel = nextThinkingByModel;
}
if (nextOptions !== undefined) {
next.options = nextOptions;
if (nextFeatureValues !== undefined) {
next.featureValues = nextFeatureValues;
}
return next;
@@ -173,7 +161,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
modelId?: string | null;
modeId?: string | null;
thinkingOptionId?: string | null;
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}): FormPreferences {
if (!args.provider) {
return args.preferences;
@@ -182,7 +170,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
const options = optionsSchema.safeParse(args.options);
const featureValues = featureValuesSchema.safeParse(args.featureValues);
return mergeProviderPreferences({
preferences: args.preferences,
@@ -191,7 +179,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
model: modelId || undefined,
mode: args.modeId === undefined ? undefined : modeId || null,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}),
...(options.success ? { options: options.data } : {}),
...(featureValues.success ? { featureValues: featureValues.data } : {}),
},
});
}
@@ -204,7 +192,7 @@ export function applyAgentProfilePreferences(args: {
modelId: string;
modeId: string;
thinkingOptionId: string;
options: Record<string, unknown>;
featureValues: Record<string, unknown>;
}): FormPreferences {
let next = args.preferences;
if (args.previousProvider) {
@@ -227,7 +215,7 @@ export function applyAgentProfilePreferences(args: {
...(args.modelId && args.thinkingOptionId
? { thinkingByModel: { [args.modelId]: args.thinkingOptionId } }
: {}),
options: args.options,
featureValues: args.featureValues,
},
});
}
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import { resolveFeatureValues } from "./feature-preferences";
describe("feature-preferences", () => {
const features = [
{
type: "toggle" as const,
id: "fast_mode",
label: "Fast",
value: false,
},
{
type: "toggle" as const,
id: "plan_mode",
label: "Plan",
value: false,
},
];
it("restores persisted values for available features", () => {
expect(
resolveFeatureValues({
features,
persistedFeatureValues: {
fast_mode: true,
unknown_feature: true,
},
localFeatureValues: {},
}),
).toEqual({
fast_mode: true,
});
});
it("prefers local values over persisted values", () => {
expect(
resolveFeatureValues({
features,
persistedFeatureValues: {
fast_mode: true,
plan_mode: false,
},
localFeatureValues: {
fast_mode: false,
},
}),
).toEqual({
fast_mode: false,
plan_mode: false,
});
});
});
@@ -0,0 +1,60 @@
import type { AgentFeature } from "@getpaseo/protocol/agent-types";
export function pruneFeatureValues(
featureValues: Record<string, unknown>,
features: AgentFeature[],
): Record<string, unknown> {
const allowedFeatureIds = new Set(features.map((feature) => feature.id));
let changed = false;
const next: Record<string, unknown> = {};
for (const [featureId, value] of Object.entries(featureValues)) {
if (!allowedFeatureIds.has(featureId)) {
changed = true;
continue;
}
next[featureId] = value;
}
return changed ? next : featureValues;
}
export function applyFeatureValues(
features: AgentFeature[],
featureValues: Record<string, unknown>,
): AgentFeature[] {
if (Object.keys(featureValues).length === 0) {
return features;
}
return features.map((feature) => {
if (!Object.prototype.hasOwnProperty.call(featureValues, feature.id)) {
return feature;
}
return {
...feature,
value: featureValues[feature.id],
} as AgentFeature;
});
}
export function resolveFeatureValues(args: {
features: AgentFeature[];
persistedFeatureValues: Record<string, unknown>;
localFeatureValues: Record<string, unknown>;
}): Record<string, unknown> {
const next: Record<string, unknown> = {};
for (const feature of args.features) {
if (Object.prototype.hasOwnProperty.call(args.localFeatureValues, feature.id)) {
next[feature.id] = args.localFeatureValues[feature.id];
continue;
}
if (Object.prototype.hasOwnProperty.call(args.persistedFeatureValues, feature.id)) {
next[feature.id] = args.persistedFeatureValues[feature.id];
}
}
return next;
}
@@ -128,14 +128,14 @@ function resolveDraftQueryConfig(draft?: DraftCommandTarget): DraftCommandConfig
const modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const providerOptions = draftConfig.providerOptions;
const featureValues = draftConfig.featureValues;
return {
provider: draftConfig.provider,
cwd,
...(modeId ? { modeId } : {}),
...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
...(providerOptions && Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
...(featureValues && Object.keys(featureValues).length > 0 ? { featureValues } : {}),
};
}
@@ -352,7 +352,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions): UseAgentFo
modelId: model,
modeId,
thinkingOptionId,
options: profile.options,
featureValues: profile.featureValues,
});
}).catch((error) => {
console.warn("[useAgentFormState] persist profile preference failed", error);
@@ -1,6 +1,9 @@
import { useRef } from "react";
import type { AgentCapabilityFlags, AgentProvider } from "@getpaseo/protocol/agent-types";
import type { Agent } from "@/stores/session-store";
import type {
AgentCapabilityFlags,
AgentFeature,
AgentProvider,
} from "@getpaseo/protocol/agent-types";
import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync";
export interface AgentScreenAgent {
@@ -19,8 +22,7 @@ export interface AgentScreenAgent {
modeId?: string | null;
thinkingOptionId?: string | null;
} | null;
options?: Agent["options"];
controls?: Agent["controls"];
features?: readonly AgentFeature[];
lastError?: string | null;
projectPlacement?: {
projectKey?: string;
@@ -0,0 +1,159 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import type { AgentProvider, AgentSessionConfig } from "@getpaseo/protocol/agent-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
import {
applyFeatureValues,
pruneFeatureValues,
resolveFeatureValues,
} from "./feature-preferences";
type DraftFeatureConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId"
>;
export function useDraftAgentFeatures(input: {
serverId: string | null | undefined;
provider: AgentProvider | null;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
initialFeatureValues?: Record<string, unknown>;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
input;
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
() => initialFeatureValues ?? {},
);
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || "";
const normalizedProvider = provider ?? null;
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
const persistedFeatureValues = useMemo(
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
[preferences.providerPreferences, provider],
);
const draftConfig = useMemo<DraftFeatureConfig | null>(() => {
if (!normalizedProvider || !normalizedCwd) {
return null;
}
return {
provider: normalizedProvider,
cwd: normalizedCwd,
...(modeId ? { modeId } : {}),
...(modelId ? { model: modelId } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}),
};
}, [modeId, modelId, normalizedCwd, normalizedProvider, thinkingOptionId]);
const featuresQuery = useQuery({
queryKey: [
"providerFeatures",
serverId ?? null,
normalizedProvider,
normalizedCwd || null,
modeId ?? null,
modelId ?? null,
thinkingOptionId ?? null,
],
enabled: Boolean(serverId && client && isConnected && draftConfig),
staleTime: 5 * 60 * 1000,
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderFeatures(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return payload.features ?? [];
},
});
const availableFeaturesRaw = featuresQuery.data;
const availableFeatures = useMemo(() => availableFeaturesRaw ?? [], [availableFeaturesRaw]);
const featureValues = useMemo(
() =>
resolveFeatureValues({
features: availableFeatures,
persistedFeatureValues,
localFeatureValues,
}),
[availableFeatures, localFeatureValues, persistedFeatureValues],
);
const features = useMemo(() => {
return applyFeatureValues(availableFeatures, featureValues);
}, [availableFeatures, featureValues]);
useEffect(() => {
const previousProvider = previousProviderRef.current;
previousProviderRef.current = normalizedProvider;
if (previousProvider === null) {
return;
}
if (previousProvider !== normalizedProvider) {
setLocalFeatureValues({});
}
}, [normalizedProvider]);
useEffect(() => {
if (availableFeaturesRaw === undefined) {
return;
}
const next = pruneFeatureValues(localFeatureValues, availableFeatures);
if (next !== localFeatureValues) {
setLocalFeatureValues(next);
}
}, [availableFeatures, availableFeaturesRaw, localFeatureValues]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback(
(featureId: string, value: unknown) => {
setLocalFeatureValues((current) => {
if (Object.is(current[featureId], value)) {
return current;
}
return { ...current, [featureId]: value };
});
if (!provider) {
return;
}
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider,
updates: {
featureValues: {
[featureId]: value,
},
},
}),
).catch((error) => {
console.warn("[useDraftAgentFeatures] persist feature preference failed", error);
});
},
[provider, updatePreferences],
);
const applyProfileFeatureValues = useCallback((values: Record<string, unknown>) => {
setLocalFeatureValues(values);
}, []);
return {
features,
featureValues: effectiveFeatureValues,
isLoading: featuresQuery.isLoading,
setFeatureValue,
applyProfileFeatureValues,
};
}
@@ -1,109 +0,0 @@
import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AgentProvider, ProviderOptions } from "@getpaseo/protocol/agent-types";
import {
draftControlsRequest,
openDraftOptions,
ownDraftOptions,
viewDraftOptions,
} from "@/agent-controls/draft-options-model";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import { useFetchQuery } from "@/data/query";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { mergeProviderPreferences, useFormPreferences } from "./use-form-preferences";
/**
* Adapter between a draft's options model and the screen: it sends the model's complete options
* to the daemon, hands the answer back, and persists the user's toggles as last-used values.
*/
export function useDraftProviderOptions(input: {
serverId: string | null | undefined;
provider: AgentProvider | null;
cwd: string | null | undefined;
modeId: string | null | undefined;
modelId: string | null | undefined;
thinkingOptionId: string | null | undefined;
initialOptions?: ProviderOptions;
}) {
const { t } = useTranslation();
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialOptions } = input;
const [model] = useState(() => openDraftOptions({ provider, seed: initialOptions }));
const state = useSyncExternalStore(model.subscribe, model.getState, model.getState);
const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || "";
const persisted = preferences.providerPreferences?.[provider ?? ""]?.options;
useEffect(() => model.observeProvider(provider), [model, provider]);
useEffect(() => model.setRemembered(persisted ?? {}), [model, persisted]);
const ownOptions = ownDraftOptions(state);
const draftConfig = useMemo(
() =>
draftControlsRequest(state, {
provider,
cwd: normalizedCwd,
modeId,
modelId,
thinkingOptionId,
}),
[modeId, modelId, normalizedCwd, provider, state, thinkingOptionId],
);
const queryKey = useMemo(
() => ["providerControls", serverId ?? null, draftConfig],
[draftConfig, serverId],
);
const requestKey = JSON.stringify(queryKey);
const { data } = useFetchQuery({
queryKey,
dataShape: "value",
staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(serverId && client && isConnected && draftConfig),
queryFn: async () => {
if (!client || !draftConfig) {
throw new Error(t("workspace.terminal.hostDisconnected"));
}
const payload = await client.listProviderControls(draftConfig);
if (payload.error) {
throw new Error(payload.error);
}
return { controls: payload.controls ?? [], options: payload.options ?? {} };
},
});
useEffect(() => {
if (provider && data) model.applyResolved({ provider, requestKey, ...data });
}, [data, model, provider, requestKey]);
const setOption = useCallback(
(key: string, value: unknown) => {
model.setOption(key, value);
if (!provider) return;
void updatePreferences((current) =>
mergeProviderPreferences({
preferences: current,
provider,
updates: { options: { [key]: value } },
}),
).catch((error) => {
console.warn("[useDraftProviderOptions] persist option preference failed", error);
});
},
[model, provider, updatePreferences],
);
const view = viewDraftOptions(state, { provider, requestKey });
const viewControls = view?.controls;
const viewOptions = view?.options;
const optionControls = useMemo<ComposerOptionControls | undefined>(
() =>
viewControls && viewOptions
? { controls: viewControls, options: viewOptions, onSetOption: setOption }
: undefined,
[setOption, viewControls, viewOptions],
);
return { optionControls, options: ownOptions, applyProfile: model.applyProfile };
}
+7 -2
View File
@@ -36,7 +36,7 @@ export type ForkAgentSource = Pick<
| "model"
| "thinkingOptionId"
| "runtimeInfo"
| "options"
| "features"
| "projectPlacement"
>;
@@ -95,13 +95,18 @@ function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | u
return undefined;
}
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return {
provider: agent.provider,
cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
providerOptions: { ...agent.options },
featureValues,
};
}
@@ -63,7 +63,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: {
codex: {
model: "gpt-5.4",
options: {
featureValues: {
fast_mode: true,
},
},
@@ -71,7 +71,7 @@ describe("mergeProviderPreferences", () => {
},
provider: "codex",
updates: {
options: {
featureValues: {
plan_mode: true,
},
},
@@ -81,7 +81,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: {
codex: {
model: "gpt-5.4",
options: {
featureValues: {
fast_mode: true,
plan_mode: true,
},
+4 -8
View File
@@ -116,8 +116,7 @@ interface ChatAgentStateShape {
model?: Agent["model"];
thinkingOptionId?: Agent["thinkingOptionId"];
runtimeInfo?: Agent["runtimeInfo"];
options?: Agent["options"];
controls?: Agent["controls"];
features?: Agent["features"];
lastError?: Agent["lastError"] | null;
}
@@ -176,8 +175,7 @@ function selectChatAgentState(
model: agent.model,
thinkingOptionId: agent.thinkingOptionId,
runtimeInfo: agent.runtimeInfo,
options: agent.options,
controls: agent.controls,
features: agent.features,
lastError: agent.lastError ?? null,
archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false,
@@ -204,8 +202,7 @@ function buildChatAgentFromState(
model: state.model,
thinkingOptionId: state.thinkingOptionId,
runtimeInfo: state.runtimeInfo,
options: state.options,
controls: state.controls,
features: state.features,
lastError: state.lastError ?? null,
projectPlacement,
};
@@ -638,8 +635,7 @@ function AgentPanelBody({
model: agentState.model,
thinkingOptionId: agentState.thinkingOptionId,
runtimeInfo: agentState.runtimeInfo,
options: agentState.options,
controls: agentState.controls,
features: agentState.features,
lastError: agentState.lastError ?? null,
projectPlacement,
}
-5
View File
@@ -25,11 +25,6 @@ function findLucideIcon(name: string): LucideIcon | null {
return isComponent(candidate) ? (candidate as LucideIcon) : null;
}
/** Whether `name` is a Lucide icon, so a caller can fall back before drawing nothing. */
export function isLucideIconName(name: string): boolean {
return findLucideIcon(name) !== null;
}
function isComponent(value: unknown): boolean {
return (
typeof value === "function" ||
@@ -312,7 +312,7 @@ export function buildDraftCommandTarget(input: {
cwd: string;
effectiveModelId: string;
effectiveThinkingOptionId: string;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}): DraftCommandTarget {
const cwd = input.cwd.trim();
if (!cwd) {
@@ -332,7 +332,7 @@ export function buildDraftCommandTarget(input: {
...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}),
...(input.providerOptions ? { providerOptions: input.providerOptions } : {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
return { status: "ready", config };
}
@@ -284,7 +284,7 @@ describe("mergeSelectedComposerPreferences", () => {
codex: {
mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" },
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
claude: { model: "claude-sonnet-4-6" },
},
@@ -299,7 +299,7 @@ describe("mergeSelectedComposerPreferences", () => {
model: "gpt-5.4",
mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" },
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
claude: { model: "claude-sonnet-4-6" },
},
@@ -270,7 +270,7 @@ interface WorkspaceDraftSubmissionConfig {
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined;
featureValues: Record<string, unknown> | undefined;
target: WorkspaceTabTarget;
}
@@ -373,7 +373,7 @@ function buildWorkspaceDraftSetupFromComposer(input: {
modeId: input.composerState.effectiveModeId || null,
model: input.composerState.effectiveModelId || null,
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
providerOptions: input.composerState.providerOptions ?? {},
featureValues: input.composerState.featureValues ?? {},
};
}
@@ -453,7 +453,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
modeId: composerState.effectiveModeId || undefined,
model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
providerOptions: composerState.providerOptions,
featureValues: composerState.featureValues,
},
initialPrompt: text,
clientMessageId: `${input.draftId}:initial-message`,
@@ -533,7 +533,7 @@ function buildComposerConfig(input: {
return {
initialServerId: serverId || null,
initialValues: buildComposerInitialValues({ initialSetup }),
initialProviderOptions: initialSetup?.providerOptions,
initialFeatureValues: initialSetup?.featureValues,
isVisible: true,
lockedWorkingDir: workingDir,
};
@@ -566,7 +566,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
modeId: initialSetup.modeId,
model: initialSetup.model,
thinkingOptionId: initialSetup.thinkingOptionId,
providerOptions: initialSetup.providerOptions,
featureValues: initialSetup.featureValues,
target: { kind: "draft", draftId, setup: initialSetup },
};
}
@@ -576,7 +576,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
modeId: composerState.effectiveModeId || null,
model: composerState.effectiveModelId || null,
thinkingOptionId: composerState.effectiveThinkingOptionId || null,
providerOptions: composerState.providerOptions,
featureValues: composerState.featureValues,
target: { kind: "draft", draftId },
};
}
@@ -634,7 +634,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
...(submission.providerOptions ? { providerOptions: submission.providerOptions } : {}),
...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true,
agentCreation: input.agentCreation,
});
@@ -69,7 +69,7 @@ describe("createWorkspaceAgentInBackground", () => {
modeId: null,
model: null,
thinkingOptionId: null,
providerOptions: {},
featureValues: {},
},
});
});
@@ -89,7 +89,7 @@ describe("createWorkspaceAgentInBackground", () => {
modeId: null,
model: null,
thinkingOptionId: null,
providerOptions: {},
featureValues: {},
},
});
@@ -6,7 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: {
modeId?: string;
model?: string;
thinkingOptionId?: string;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}): AgentSessionConfig {
return {
provider: input.provider,
@@ -14,6 +14,6 @@ export function buildWorkspaceDraftAgentConfig(input: {
...(input.modeId ? { modeId: input.modeId } : {}),
...(input.model ? { model: input.model } : {}),
...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}),
...(input.providerOptions ? { providerOptions: input.providerOptions } : {}),
...(input.featureValues ? { featureValues: input.featureValues } : {}),
};
}
+2 -4
View File
@@ -26,8 +26,7 @@ import type { ComposerAttachment } from "@/attachments/types";
import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type {
AgentPermissionRequest,
AgentOptionControl,
ProviderOptions,
AgentFeature,
AgentProvider,
AgentMode,
AgentCapabilityFlags,
@@ -92,8 +91,7 @@ export interface Agent {
cwd: string;
workspaceId?: string;
model: string | null;
options?: ProviderOptions;
controls?: AgentOptionControl[];
features?: AgentFeature[];
thinkingOptionId?: string | null;
requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null;
@@ -23,7 +23,7 @@ export interface PendingWorkspaceDraftSubmission {
modeId?: string;
model?: string;
thinkingOptionId?: string;
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
allowEmptyText?: boolean;
}
@@ -95,53 +95,6 @@ it("keeps the saved layout when the shell publishes its layout mode before hydra
);
});
it("keeps every saved layout when a draft tab's setup carries a nested provider option", async () => {
const values = new Map<string, string>();
const storage: StateStorage = {
getItem: (name) => values.get(name) ?? null,
setItem: (name, value) => {
values.set(name, value);
},
removeItem: (name) => {
values.delete(name);
},
};
const store = createWorkspaceLayoutStore(undefined, storage);
await store.persist.rehydrate();
store.getState().openTab({
workspaceKey: "server:kept",
target: { kind: "agent", agentId: "agent-1" },
intent: "reveal",
});
store.getState().openTab({
workspaceKey: "server:forked",
target: {
kind: "draft",
draftId: "draft-1",
setup: {
provider: "codex",
cwd: "/repo",
modeId: null,
model: null,
thinkingOptionId: null,
providerOptions: { sandbox_workspace_write: { network_access: true }, max_turns: 3 },
},
},
intent: "reveal",
});
const restored = createWorkspaceLayoutStore(undefined, storage);
await restored.persist.rehydrate();
expect(Object.keys(restored.getState().layoutByWorkspace).sort()).toEqual([
"server:forked",
"server:kept",
]);
expect(JSON.stringify(restored.getState().layoutByWorkspace["server:forked"])).toContain(
"network_access",
);
});
it.each([1, 2])(
"restores the agent pane after parked focus in storage version %i",
async (version) => {
@@ -1,25 +1,14 @@
import { z } from "zod";
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Layouts saved before
// the rename stored the same record as `featureValues`.
function renameLegacyFeatureValues(value: unknown): unknown {
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value;
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>;
return "providerOptions" in rest ? rest : { ...rest, providerOptions: featureValues };
}
const WorkspaceDraftTabSetupStorageSchema = z.preprocess(
renameLegacyFeatureValues,
z.strictObject({
provider: z.string(),
cwd: z.string(),
modeId: z.string().nullable(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
providerOptions: z.record(z.string(), z.json()),
}),
);
const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
provider: z.string(),
cwd: z.string(),
modeId: z.string().nullable(),
model: z.string().nullable(),
thinkingOptionId: z.string().nullable(),
featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
});
const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("new_tab") }),
z.strictObject({
@@ -4551,6 +4551,7 @@ describe("compact workspace sheet", () => {
title: agent.id,
cwd: "/repo",
model: null,
features: undefined,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
@@ -63,6 +63,7 @@ const AGENT_DEFAULTS: Agent = {
cwd: WORKSPACE_DIRECTORY,
workspaceId: WORKSPACE_ID,
model: null,
features: undefined,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
@@ -41,6 +41,7 @@ const AGENT_DEFAULTS: Agent = {
title: "Agent",
cwd: "/tmp/project",
model: null,
features: undefined,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
@@ -60,6 +60,7 @@ const AGENT_DEFAULTS: Agent = {
cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID,
model: null,
features: undefined,
thinkingOptionId: undefined,
requiresAttention: false,
attentionReason: null,
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { isControlActive, readSelectOption } from "@/agent-controls/option-controls";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots";
function createSnapshot(
@@ -33,39 +32,10 @@ function createSnapshot(
persistence: input.persistence ?? null,
title: input.title ?? null,
labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"],
...(input.controls ? { controls: input.controls } : {}),
...(input.options ? { options: input.options } : {}),
};
}
describe("normalizeAgentSnapshot", () => {
it("keeps a closed agent's stored options and controls, so its Fast control draws", () => {
const speed = {
type: "select" as const,
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
};
const agent = normalizeAgentSnapshot(
createSnapshot({
status: "closed",
controls: [speed],
options: { service_tier: "priority", sandbox_mode: "read-only" },
}),
"server-1",
);
expect(agent.status).toBe("closed");
expect(agent.controls).toEqual([speed]);
expect(agent.options).toEqual({ service_tier: "priority", sandbox_mode: "read-only" });
expect(readSelectOption(speed, agent.options ?? {})).toBe("priority");
expect(isControlActive(speed, agent.options ?? {})).toBe(true);
});
it("round-trips identified active turns through the canonical snapshot boundary", () => {
const snapshot = createSnapshot({
status: "running",
+2 -4
View File
@@ -64,8 +64,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
cwd: agent.cwd,
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
model: agent.model,
...(agent.options ? { options: agent.options } : {}),
...(agent.controls ? { controls: agent.controls } : {}),
...(agent.features ? { features: agent.features } : {}),
thinkingOptionId: agent.thinkingOptionId ?? null,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
@@ -129,8 +128,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
cwd: snapshot.cwd,
workspaceId: snapshot.workspaceId,
model: snapshot.model ?? null,
options: snapshot.options,
controls: snapshot.controls,
features: snapshot.features,
thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null,
+2 -2
View File
@@ -92,7 +92,7 @@ export function normalizeWorkspaceDraftTabSetup(
thinkingOptionId: trimOptionalString(
typeof record.thinkingOptionId === "string" ? record.thinkingOptionId : null,
),
providerOptions: isPlainRecord(record.providerOptions) ? { ...record.providerOptions } : {},
featureValues: isPlainRecord(record.featureValues) ? { ...record.featureValues } : {},
};
}
@@ -171,7 +171,7 @@ function workspaceDraftTabSetupsEqual(
left.modeId === right.modeId &&
left.model === right.model &&
left.thinkingOptionId === right.thinkingOptionId &&
recordsShallowEqual(left.providerOptions, right.providerOptions)
recordsShallowEqual(left.featureValues, right.featureValues)
);
}
+1 -1
View File
@@ -8,7 +8,7 @@ export interface WorkspaceDraftTabSetup {
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
providerOptions: Record<string, unknown>;
featureValues: Record<string, unknown>;
}
export interface WorkspaceWorkingDiffTabTarget {
@@ -1,252 +0,0 @@
import { controlsFromFeatures, optionsFromFeatures } from "@getpaseo/protocol/option-controls";
import type { AgentOptionControl } from "@getpaseo/protocol/agent-types";
import type {
AgentConfigApply,
AgentProfile,
AgentSnapshotPayload,
MutableDaemonConfig,
ListProviderFeaturesResponseMessage,
ProviderControlsListResponseMessage,
ServerInfoStatusPayload,
SessionInboundMessage,
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
/**
* COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01 once daemon floor >=
* v0.15.0.
*
* A daemon without `server_info.features.providerOptionControls` sends `features` (a control with
* its value attached) and takes composer values as `featureValues`. This is the one place that
* turns that into the shape everyone else uses: `controls` over `options`, written through
* `providerOptions`.
*/
export function usesLegacyProviderOptions(info: ServerInfoStatusPayload): boolean {
return info.features?.providerOptionControls !== true;
}
export class LegacyProviderOptions {
/** Gives agent snapshots `controls` and `options`, built from their `features`. */
normalize(message: SessionOutboundMessage): SessionOutboundMessage {
switch (message.type) {
case "agent_update":
if (message.payload.kind !== "upsert") return message;
return {
...message,
payload: { ...message.payload, agent: this.snapshot(message.payload.agent) },
};
case "agent_status":
return {
...message,
payload: { ...message.payload, info: this.snapshot(message.payload.info) },
};
case "fetch_agents_response":
return {
...message,
payload: {
...message.payload,
entries: message.payload.entries.map((entry) => ({
...entry,
agent: this.snapshot(entry.agent),
})),
},
};
case "fetch_agent_response":
case "agent.create.response":
case "cancel_agent_response":
return {
...message,
payload: {
...message.payload,
agent: message.payload.agent && this.snapshot(message.payload.agent),
},
} as SessionOutboundMessage;
case "workspace.create.response":
return {
...message,
payload: {
...message.payload,
...(message.payload.agent ? { agent: this.snapshot(message.payload.agent) } : {}),
},
};
case "wait_for_finish_response":
return {
...message,
payload: {
...message.payload,
final: message.payload.final && this.snapshot(message.payload.final),
},
};
case "get_daemon_config_response":
case "set_daemon_config_response":
return {
...message,
payload: { ...message.payload, config: this.daemonConfig(message.payload.config) },
};
case "fetch_agent_timeline_response":
return {
...message,
payload: {
...message.payload,
agent: message.payload.agent && this.snapshot(message.payload.agent),
},
};
case "status":
return this.status(message);
default:
return message;
}
}
private status(
message: Extract<SessionOutboundMessage, { type: "status" }>,
): SessionOutboundMessage {
const { payload } = message;
if (payload.status === "agent_created" || payload.status === "agent_resumed") {
const { agent } = payload as unknown as { agent: AgentSnapshotPayload };
return { ...message, payload: { ...payload, agent: this.snapshot(agent) } };
}
if (payload.status === "daemon_config_changed") {
const config = payload.config as MutableDaemonConfig;
return { ...message, payload: { ...payload, config: this.daemonConfig(config) } };
}
return message;
}
/**
* Splits a create's options for a legacy daemon: the ids it lists as features for that very
* config go out as `featureValues`, which is where it reads them, and the rest stay
* `providerOptions`. Which options are features depends on the model, mode and thinking level
* as well as the provider, so the ids come from a listing for this config, never from memory.
*/
routeCreateConfig<
T extends {
providerOptions?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
},
>(config: T, featureIds: ReadonlySet<string>): T {
if (!config.providerOptions) return config;
const featureValues: Record<string, unknown> = {};
const providerOptions: Record<string, unknown> = {};
for (const [key, value] of Object.entries(config.providerOptions)) {
(featureIds.has(key) ? featureValues : providerOptions)[key] = value;
}
const { providerOptions: _options, ...rest } = config;
return {
...(rest as T),
...(Object.keys(providerOptions).length > 0 ? { providerOptions } : {}),
...(Object.keys(featureValues).length > 0
? { featureValues: { ...config.featureValues, ...featureValues } }
: {}),
};
}
/** Sends options as `featureValues`, which is where a legacy daemon reads them. */
prepare(message: SessionInboundMessage): SessionInboundMessage {
switch (message.type) {
case "list_commands_request": {
if (!message.draftConfig?.providerOptions) return message;
const { providerOptions, ...draftConfig } = message.draftConfig;
return {
...message,
draftConfig: {
...draftConfig,
featureValues: { ...draftConfig.featureValues, ...providerOptions },
},
};
}
case "set_daemon_config_request":
if (!message.config.agentProfiles) return message;
return {
...message,
config: {
...message.config,
agentProfiles: message.config.agentProfiles.map(profileWithFeatureValues),
},
};
default:
return message;
}
}
/**
* An options patch as feature writes. `null` removes an option, which the old API cannot say:
* its setters take a value, and the Codex one rejects anything but a choice id. A toggle goes
* off and a choice goes to its default; an option with no control to say what its default is
* cannot be reset, and nothing is written.
*/
featureWrites(
patch: Record<string, unknown>,
controls: readonly AgentOptionControl[],
): [string, unknown][] {
return Object.entries(patch).map(([key, value]) => {
if (value !== null) return [key, value];
const control = controls.find((candidate) => candidate.key === key);
if (control?.type === "toggle") return [key, false];
if (control?.type === "select" && control.defaultValue !== undefined) {
return [key, control.defaultValue];
}
throw new Error(`Update the host to reset ${key}.`);
});
}
/** A config apply with its `options` as `featureValues`, the way a daemon without them takes it. */
featureValuesConfig(
config: AgentConfigApply,
controls: readonly AgentOptionControl[],
): AgentConfigApply {
const { options, ...rest } = config;
if (!options) return config;
return {
...rest,
featureValues: {
...rest.featureValues,
...Object.fromEntries(this.featureWrites(options, controls)),
},
};
}
/** The legacy answer to a controls listing, in the shape of the current response. */
controlsResponse(
legacy: ListProviderFeaturesResponseMessage["payload"],
): ProviderControlsListResponseMessage["payload"] {
const { features, ...rest } = legacy;
return {
...rest,
...(features
? { controls: controlsFromFeatures(features), options: optionsFromFeatures(features) }
: {}),
};
}
private daemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
if (!config.agentProfiles) return config;
return { ...config, agentProfiles: config.agentProfiles.map(profileWithOptions) };
}
private readonly snapshot = (agent: AgentSnapshotPayload): AgentSnapshotPayload => {
if (agent.controls || !agent.features) return agent;
return {
...agent,
controls: controlsFromFeatures(agent.features),
options: optionsFromFeatures(agent.features),
};
};
}
/**
* A legacy daemon stores a profile's options under `featureValues`, and keeps an `options` field
* a newer daemon left on it. Both are read, and an explicit `options` key wins, as it does on the
* daemon (`foldFeatureValues`).
*/
function profileWithOptions(profile: AgentProfile): AgentProfile {
if (!profile.featureValues) return profile;
const { featureValues, ...rest } = profile;
return { ...rest, options: { ...featureValues, ...profile.options } };
}
function profileWithFeatureValues(profile: AgentProfile): AgentProfile {
if (!profile.options) return profile;
const { options, ...rest } = profile;
return { ...rest, featureValues: options };
}
-578
View File
@@ -3729,35 +3729,6 @@ test("rejects an internal create before sending when the host predates internal
expect(mock.sent).toHaveLength(0);
});
test("metadata needs a host that advertises entityMetadata and sends nothing to one that does not", async () => {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "clsk_unit_test",
logger: createMockLogger(),
reconnect: { enabled: false },
transportFactory: () => mock.transport,
});
clients.push(client);
const connectPromise = client.connect();
mock.triggerOpen({ features: {} });
await connectPromise;
const update = "Update the host to use metadata.";
await expect(
client.createAgent({ provider: "codex", cwd: "/tmp/project", metadata: { key: "value" } }),
).rejects.toThrow(update);
await expect(
client.createWorkspace({
source: { kind: "directory", path: "/tmp/project" },
metadata: { key: "value" },
}),
).rejects.toThrow(update);
await expect(client.setAgentMetadata("agent-1", { key: null })).rejects.toThrow(update);
await expect(client.setWorkspaceMetadata("workspace-1", { key: null })).rejects.toThrow(update);
expect(mock.sent).toHaveLength(0);
});
test("sends internal workspace creation and internal listings only to hosts that support them", async () => {
const logger = createMockLogger();
const mock = createMockTransport();
@@ -8955,552 +8926,3 @@ test("routed channels inherit an origin without drain support", async () => {
expect(route.supportsDrain).toBe(false);
route.close();
});
describe("provider options across daemon versions", () => {
const agentSnapshot = {
id: "agent-1",
provider: "codex",
cwd: "/repo",
model: null,
thinkingOptionId: null,
effectiveThinkingOptionId: null,
createdAt: "2026-04-30T00:00:00.000Z",
updatedAt: "2026-04-30T00:00:00.000Z",
lastUserMessageAt: null,
status: "idle",
capabilities: {
supportsStreaming: false,
supportsSessionPersistence: false,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: null,
labels: {},
requiresAttention: false,
attentionReason: null,
};
const speedFeature = {
type: "select",
id: "service_tier",
label: "Speed",
value: "priority",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
],
};
async function connect(features: Record<string, boolean>) {
const mock = createMockTransport();
const client = new DaemonClient({
url: "ws://test",
clientId: "provider_options",
transportFactory: () => mock.transport,
reconnect: { enabled: false },
});
clients.push(client);
const connecting = client.connect();
mock.triggerOpen({ features });
await connecting;
const sentMessages = () =>
mock.sent.map(
(frame) => (JSON.parse(frame as string) as { message: Record<string, unknown> }).message,
);
return { mock, client, sentMessages };
}
test("a legacy create and a timeline read return the agent with controls over options", async () => {
const { mock, client, sentMessages } = await connect({});
const legacyAgent = { ...agentSnapshot, features: [speedFeature] };
const answerLast = (reply: (requestId: string) => Record<string, unknown>) => {
const sent = sentMessages();
const requestId = String(sent[sent.length - 1].requestId);
mock.triggerMessage(wrapSessionMessage(reply(requestId) as never));
};
const creating = client.createAgent({ provider: "codex", cwd: "/repo" });
answerLast((requestId) => ({
type: "status",
payload: { status: "agent_created", requestId, agentId: "agent-1", agent: legacyAgent },
}));
const created = await creating;
const reading = client.fetchAgentTimeline("agent-1");
answerLast((requestId) => ({
type: "fetch_agent_timeline_response",
payload: {
requestId,
agentId: "agent-1",
agent: legacyAgent,
direction: "tail",
projection: "projected",
epoch: "epoch-1",
reset: false,
staleCursor: false,
gap: false,
window: { minSeq: 0, maxSeq: 0, nextSeq: 1 },
startCursor: null,
endCursor: null,
hasOlder: false,
hasNewer: false,
entries: [],
error: null,
},
}));
const timeline = await reading;
expect(created.options).toEqual({ service_tier: "priority" });
expect(created.controls).toEqual([expect.objectContaining({ key: "service_tier" })]);
expect(timeline.agent?.options).toEqual({ service_tier: "priority" });
expect(timeline.agent?.controls).toEqual([expect.objectContaining({ key: "service_tier" })]);
});
test("a legacy daemon's features arrive as controls over options", async () => {
const { mock, client } = await connect({});
const received: Array<{ controls?: unknown; options?: unknown }> = [];
client.on("agent_update", (message) => {
if (message.payload.kind === "upsert") received.push(message.payload.agent);
});
mock.triggerMessage(
wrapSessionMessage({
type: "agent_update",
payload: { kind: "upsert", agent: { ...agentSnapshot, features: [speedFeature] } },
}),
);
expect(received).toEqual([
expect.objectContaining({
options: { service_tier: "priority" },
controls: [
expect.objectContaining({
type: "select",
key: "service_tier",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
}),
],
}),
]);
});
test("a legacy daemon takes option writes as feature writes", async () => {
const { client, sentMessages, mock } = await connect({});
const write = client.setAgentOptions("agent-1", { service_tier: "default" });
const [request] = sentMessages();
expect(request).toMatchObject({
type: "set_agent_feature_request",
agentId: "agent-1",
featureId: "service_tier",
value: "default",
});
mock.triggerMessage(
wrapSessionMessage({
type: "set_agent_feature_response",
payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null },
}),
);
await write;
});
// The old Codex setter rejects a non-string `service_tier` and the old Claude setter reads
// `null` as off, so a reset is sent as the value the old API can represent.
async function resetOnLegacyDaemon(agent: Record<string, unknown>, patch: Record<string, null>) {
const { client, sentMessages, mock } = await connect({});
const reply = (message: Record<string, unknown>) =>
mock.triggerMessage(wrapSessionMessage(message as never));
const writing = client.setAgentOptions("agent-1", patch);
const [fetch] = sentMessages();
expect(fetch).toMatchObject({ type: "fetch_agent_request", agentId: "agent-1" });
reply({
type: "fetch_agent_response",
payload: { requestId: fetch.requestId, agent, project: null, error: null },
});
return { writing, sentMessages, reply };
}
test("a legacy daemon resets a choice to the default the old API names", async () => {
const { writing, sentMessages, reply } = await resetOnLegacyDaemon(
{ ...agentSnapshot, provider: "codex", features: [speedFeature] },
{ service_tier: null },
);
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
const write = sentMessages()[1];
expect(write).toMatchObject({
type: "set_agent_feature_request",
featureId: "service_tier",
value: "default",
});
reply({
type: "set_agent_feature_response",
payload: { requestId: write.requestId, agentId: "agent-1", accepted: true, error: null },
});
await writing;
});
test("a legacy daemon resets a toggle to off", async () => {
const { writing, sentMessages, reply } = await resetOnLegacyDaemon(
{
...agentSnapshot,
features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: true }],
},
{ fast_mode: null },
);
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
const write = sentMessages()[1];
expect(write).toMatchObject({ featureId: "fast_mode", value: false });
reply({
type: "set_agent_feature_response",
payload: { requestId: write.requestId, agentId: "agent-1", accepted: true, error: null },
});
await writing;
});
test("a legacy daemon cannot reset an option no control draws, and writes nothing", async () => {
const { writing, sentMessages } = await resetOnLegacyDaemon(
{ ...agentSnapshot, features: [speedFeature] },
{ token_budget: null },
);
await expect(writing).rejects.toThrow("Update the host to reset token_budget.");
expect(sentMessages()).toHaveLength(1);
});
test("a legacy daemon applies a config reset as the default the old API names", async () => {
const { client, sentMessages, mock } = await connect({});
const reply = (message: Record<string, unknown>) =>
mock.triggerMessage(wrapSessionMessage(message as never));
const applying = client.applyAgentConfig("agent-1", {
modeId: "plan",
options: { service_tier: null, sandbox_mode: "read-only" },
});
const [fetch] = sentMessages();
expect(fetch).toMatchObject({ type: "fetch_agent_request", agentId: "agent-1" });
reply({
type: "fetch_agent_response",
payload: {
requestId: fetch.requestId,
agent: { ...agentSnapshot, provider: "codex", features: [speedFeature] },
project: null,
error: null,
},
});
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
const apply = sentMessages()[1];
expect(apply).toMatchObject({
type: "agent.config.apply.request",
config: {
modeId: "plan",
featureValues: { service_tier: "default", sandbox_mode: "read-only" },
},
});
expect(apply.config).not.toHaveProperty("options");
reply({
type: "agent.config.apply.response",
payload: { requestId: apply.requestId, agentId: "agent-1", accepted: true, error: null },
});
await applying;
});
test("a legacy daemon cannot apply a config reset it cannot name, and applies nothing", async () => {
const { client, sentMessages, mock } = await connect({});
const applying = client.applyAgentConfig("agent-1", { options: { token_budget: null } });
const [fetch] = sentMessages();
mock.triggerMessage(
wrapSessionMessage({
type: "fetch_agent_response",
payload: {
requestId: fetch.requestId,
agent: { ...agentSnapshot, features: [speedFeature] },
project: null,
error: null,
},
} as never),
);
await expect(applying).rejects.toThrow("Update the host to reset token_budget.");
expect(sentMessages()).toHaveLength(1);
});
test("a current daemon takes option writes as one options patch", async () => {
const { client, sentMessages, mock } = await connect({ providerOptionControls: true });
const write = client.setAgentOptions("agent-1", { service_tier: null, sandbox_mode: "x" });
const [request] = sentMessages();
expect(request).toMatchObject({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { options: { service_tier: null, sandbox_mode: "x" } },
});
mock.triggerMessage(
wrapSessionMessage({
type: "agent.config.apply.response",
payload: { requestId: request.requestId, agentId: "agent-1", accepted: true, error: null },
}),
);
await write;
});
test("a legacy draft listing answers with controls and options", async () => {
const { client, sentMessages, mock } = await connect({});
const listing = client.listProviderControls({
provider: "codex",
cwd: "/repo",
providerOptions: { service_tier: "default" },
});
const [request] = sentMessages();
expect(request).toMatchObject({
type: "list_provider_features_request",
draftConfig: { provider: "codex", featureValues: { service_tier: "default" } },
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_provider_features_response",
payload: {
provider: "codex",
features: [{ ...speedFeature, value: "default" }],
error: null,
fetchedAt: "2026-04-30T00:00:00.000Z",
requestId: request.requestId,
},
}),
);
await expect(listing).resolves.toMatchObject({
provider: "codex",
options: { service_tier: "default" },
controls: [expect.objectContaining({ key: "service_tier" })],
});
});
test("a legacy daemon's profiles are read and saved by their options", async () => {
const { client, sentMessages, mock } = await connect({});
const reading = client.getDaemonConfig();
const [read] = sentMessages();
mock.triggerMessage(
wrapSessionMessage({
type: "get_daemon_config_response",
payload: {
requestId: read.requestId,
config: {
mcp: { injectIntoAgents: true },
agentProfiles: [
{
id: "p1",
name: "Fast",
provider: "codex",
featureValues: { service_tier: "fast" },
},
],
},
},
}),
);
const { config } = await reading;
expect(config.agentProfiles?.[0]).toMatchObject({ options: { service_tier: "fast" } });
expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues");
void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {});
const written = sentMessages().find((message) => message.type === "set_daemon_config_request");
expect(written).toMatchObject({
config: { agentProfiles: [{ featureValues: { service_tier: "fast" } }] },
});
expect(written).not.toMatchObject({ config: { agentProfiles: [{ options: {} }] } });
});
test("a legacy daemon's profile with both featureValues and options keeps both when saved", async () => {
// `options` survives on a legacy daemon's passthrough profile after a downgrade, while its
// own app keeps editing `featureValues`.
const { client, sentMessages, mock } = await connect({});
const reading = client.getDaemonConfig();
const [read] = sentMessages();
mock.triggerMessage(
wrapSessionMessage({
type: "get_daemon_config_response",
payload: {
requestId: read.requestId,
config: {
mcp: { injectIntoAgents: true },
agentProfiles: [
{
id: "p1",
name: "Mixed",
provider: "codex",
featureValues: { service_tier: "fast", sandbox_mode: "workspace-write" },
options: { sandbox_mode: "read-only" },
},
],
},
},
}),
);
const { config } = await reading;
expect(config.agentProfiles?.[0]).toMatchObject({
options: { service_tier: "fast", sandbox_mode: "read-only" },
});
expect(config.agentProfiles?.[0]).not.toHaveProperty("featureValues");
void client.patchDaemonConfig({ agentProfiles: config.agentProfiles }).catch(() => {});
const written = sentMessages().find((message) => message.type === "set_daemon_config_request");
expect(written).toMatchObject({
config: {
agentProfiles: [{ featureValues: { service_tier: "fast", sandbox_mode: "read-only" } }],
},
});
});
test("a legacy daemon applies a profile's options as feature values", async () => {
const { client, sentMessages } = await connect({});
void client.applyAgentConfig("agent-1", { options: { service_tier: "fast" } }).catch(() => {});
const [request] = sentMessages();
expect(request).toMatchObject({
type: "agent.config.apply.request",
config: { featureValues: { service_tier: "fast" } },
});
});
test("a cold legacy create lists the provider's features before routing its options", async () => {
const { client, sentMessages, mock } = await connect({});
void client
.createAgent({
config: {
provider: "claude",
cwd: "/repo",
providerOptions: { fast_mode: true, sandbox_mode: "read-only" },
},
})
.catch(() => {});
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
const [listing] = sentMessages();
expect(listing).toMatchObject({
type: "list_provider_features_request",
draftConfig: { provider: "claude", cwd: "/repo" },
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_provider_features_response",
payload: {
provider: "claude",
features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }],
error: null,
fetchedAt: "2026-04-30T00:00:00.000Z",
requestId: listing.requestId,
},
}),
);
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
expect(sentMessages()[1].config).toMatchObject({
featureValues: { fast_mode: true },
providerOptions: { sandbox_mode: "read-only" },
});
});
test("a legacy workspace create routes its agent's options from its own model's controls", async () => {
const { client, sentMessages, mock } = await connect({ creationLifecycle: true });
mock.triggerMessage(
wrapSessionMessage({
type: "agent_update",
payload: {
kind: "upsert",
agent: { ...agentSnapshot, provider: "codex", model: "mini", features: [] },
},
}),
);
void client
.createWorkspace({
source: { kind: "directory", path: "/repo" },
agent: {
config: {
provider: "codex",
cwd: "/repo",
model: "gpt-5.5",
providerOptions: { service_tier: "fast" },
},
},
} as never)
.catch(() => {});
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
const [listing] = sentMessages();
expect(listing).toMatchObject({
type: "list_provider_features_request",
draftConfig: { provider: "codex", cwd: "/repo", model: "gpt-5.5" },
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_provider_features_response",
payload: {
provider: "codex",
features: [speedFeature],
error: null,
fetchedAt: "2026-04-30T00:00:00.000Z",
requestId: listing.requestId,
},
}),
);
const sentTypes = () => sentMessages().map((message) => message.type);
await vi.waitFor(() => expect(sentTypes()).toContain("workspace.create.request"));
const created = sentMessages()[sentTypes().indexOf("workspace.create.request")];
expect(created).toMatchObject({
agent: { config: { featureValues: { service_tier: "fast" } } },
});
});
test("a legacy create lists the controls of its own model, whatever a snapshot showed", async () => {
const { client, sentMessages, mock } = await connect({});
// A Haiku agent has no Fast control; that says nothing about Opus.
mock.triggerMessage(
wrapSessionMessage({
type: "agent_update",
payload: {
kind: "upsert",
agent: { ...agentSnapshot, provider: "claude", model: "haiku", features: [] },
},
}),
);
void client
.createAgent({
config: {
provider: "claude",
cwd: "/repo",
model: "opus",
modeId: "plan",
thinkingOptionId: "high",
providerOptions: { fast_mode: true, sandbox_mode: "read-only" },
},
})
.catch(() => {});
await vi.waitFor(() => expect(sentMessages()).toHaveLength(1));
const [listing] = sentMessages();
expect(listing).toMatchObject({
type: "list_provider_features_request",
draftConfig: {
provider: "claude",
cwd: "/repo",
model: "opus",
modeId: "plan",
thinkingOptionId: "high",
},
});
mock.triggerMessage(
wrapSessionMessage({
type: "list_provider_features_response",
payload: {
provider: "claude",
features: [{ type: "toggle", id: "fast_mode", label: "Fast", value: false }],
error: null,
fetchedAt: "2026-04-30T00:00:00.000Z",
requestId: listing.requestId,
},
}),
);
await vi.waitFor(() => expect(sentMessages()).toHaveLength(2));
expect(sentMessages()[1].config).toMatchObject({
model: "opus",
featureValues: { fast_mode: true },
providerOptions: { sandbox_mode: "read-only" },
});
});
});
+17 -185
View File
@@ -21,7 +21,6 @@ import {
} from "./connection/index.js";
import { CreationClient } from "./creation/index.js";
import type { CreationSnapshot } from "@getpaseo/protocol/messages";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { z } from "zod";
import type { SessionEventSubscription } from "@getpaseo/protocol/messages";
import type { LocalSpeechModel } from "@getpaseo/protocol/speech/rpc-schemas";
@@ -103,7 +102,6 @@ import type {
WorkspaceSetupStatusResponseMessage,
ListCommandsResponse,
ListProviderFeaturesResponseMessage,
ProviderControlsListResponseMessage,
ListProviderModelsResponseMessage,
ListProviderModesResponseMessage,
ListAvailableProvidersResponse,
@@ -145,7 +143,6 @@ import type {
AgentSkillsSaveResult,
} from "@getpaseo/protocol/messages";
import type {
AgentOptionControl,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
@@ -192,10 +189,6 @@ import {
normalizeProviderSnapshotUpdateMessage,
normalizeProvidersSnapshotPayload,
} from "./compat/normalize-provider-models.js";
import {
LegacyProviderOptions,
usesLegacyProviderOptions,
} from "./compat/legacy-provider-options.js";
import { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
import type {
BrowserAutomationExecuteRequest,
@@ -517,15 +510,11 @@ export interface CreateAgentRequestOptions extends AgentConfigOverrides {
worktreeName?: string;
requestId?: string;
labels?: Record<string, string>;
/** Plugin-owned data stored on the agent. Needs a host with `entityMetadata`. */
metadata?: CreateAgentRequestMessage["metadata"];
}
export interface CreateWorkspaceRequestOptions {
source: WorkspaceCreateRequest["source"];
title?: string;
/** Plugin-owned data stored on the workspace. Needs a host with `entityMetadata`. */
metadata?: WorkspaceCreateRequest["metadata"];
/** Workspace discovery visibility; contents retain normal durability. */
background?: boolean;
callerAgentId?: string;
@@ -615,7 +604,6 @@ export interface FileUploadInput {
export type FileUploadResult = FileUploadResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
export type ListProviderControlsPayload = ProviderControlsListResponseMessage["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
@@ -642,7 +630,7 @@ type WriteProjectConfigPayload = Extract<
type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" | "providerOptions"
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
>;
export interface WriteProjectConfigInput {
repoRoot: string;
@@ -2115,13 +2103,17 @@ export class DaemonClient {
}
private sendSessionPayload(payload: SessionInboundMessage): Promise<void> {
const wire = this.legacyProviderOptions()?.prepare(payload) ?? payload;
return this.sendJsonMessage("session", payload.type, { type: "session", message: wire }, () => {
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
for (const waiter of this.waiters) {
if (waiter.requestId === payload.requestId) waiter.submitted = true;
}
});
return this.sendJsonMessage(
"session",
payload.type,
{ type: "session", message: payload },
() => {
if (!("requestId" in payload) || typeof payload.requestId !== "string") return;
for (const waiter of this.waiters) {
if (waiter.requestId === payload.requestId) waiter.submitted = true;
}
},
);
}
private sendTransportFrame(
@@ -3313,13 +3305,6 @@ export class DaemonClient {
legacyWorkspace: (input) => this.createLegacyWorkspace(input, input.requestId),
});
// Entity metadata is gated once at the transport boundary.
private requireEntityMetadata(): void {
if (this.lastServerInfoMessage?.features?.entityMetadata !== true) {
throw new Error("Update the host to use metadata.");
}
}
// Background workspaces are gated once at the transport boundary.
private requireBackgroundWorkspaces(): void {
if (this.lastServerInfoMessage?.features?.backgroundWorkspaces !== true) {
@@ -3349,45 +3334,14 @@ export class DaemonClient {
);
}
if (options.background !== undefined) this.requireBackgroundCreation(options);
if (options.metadata !== undefined) this.requireEntityMetadata();
const config = resolveAgentConfig(options);
const result = await this.creations.createAgent({
...options,
config: this.carriesLegacyOptions(config)
? await this.routeLegacyCreateConfig(config)
: config,
config: resolveAgentConfig(options),
});
if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed");
return result.agent;
}
/**
* A legacy daemon reads control options as `featureValues`, and which options are controls
* depends on the config (Claude's differ by model), so a create that carries options first lists
* the controls for its own config. Callers never prime anything.
*/
private carriesLegacyOptions(config: AgentSessionConfig): boolean {
return (
this.legacyProviderOptions() !== null && Object.keys(config.providerOptions ?? {}).length > 0
);
}
private async routeLegacyCreateConfig(config: AgentSessionConfig): Promise<AgentSessionConfig> {
const payload = await this.listProviderFeatures({
provider: config.provider,
cwd: config.cwd || "~",
...(config.modeId ? { modeId: config.modeId } : {}),
...(config.model ? { model: config.model } : {}),
...(config.thinkingOptionId ? { thinkingOptionId: config.thinkingOptionId } : {}),
featureValues: config.providerOptions,
});
if (payload.error) throw new Error(payload.error);
return this.legacyOptions.routeCreateConfig(
config,
new Set((payload.features ?? []).map((feature) => feature.id)),
);
}
private async createLegacyAgent(
options: CreateAgentRequestOptions,
): Promise<AgentSnapshotPayload> {
@@ -3586,42 +3540,6 @@ export class DaemonClient {
return { removedWorkspaceIds: payload.removedWorkspaceIds };
}
/** Top-level keys replace; `null` removes the key. Resolves to the agent's metadata afterwards. */
async setAgentMetadata(
agentId: string,
patch: Record<string, JsonValue>,
requestId?: string,
): Promise<Record<string, JsonValue>> {
this.requireEntityMetadata();
const payload =
await this.sendNamespacedCorrelatedSessionRequest<"agent.metadata.update.response">({
requestId,
message: { type: "agent.metadata.update.request", agentId, patch },
});
if (!payload.accepted || !payload.metadata) {
throw new Error(payload.error ?? "setAgentMetadata rejected");
}
return payload.metadata;
}
/** Top-level keys replace; `null` removes the key. Resolves to the workspace's metadata afterwards. */
async setWorkspaceMetadata(
workspaceId: string,
patch: Record<string, JsonValue>,
requestId?: string,
): Promise<Record<string, JsonValue>> {
this.requireEntityMetadata();
const payload =
await this.sendNamespacedCorrelatedSessionRequest<"workspace.metadata.update.response">({
requestId,
message: { type: "workspace.metadata.update.request", workspaceId, patch },
});
if (!payload.accepted || !payload.metadata) {
throw new Error(payload.error ?? "setWorkspaceMetadata rejected");
}
return payload.metadata;
}
async setWorkspaceTitle(
workspaceId: string,
title: string | null,
@@ -4214,41 +4132,6 @@ export class DaemonClient {
}
}
/**
* Changes the agent's own provider options: top-level keys replace, `null` removes the key.
* A daemon without `providerOptionControls` takes each key as a feature write.
*/
async setAgentOptions(
agentId: string,
patch: Record<string, unknown>,
): Promise<AgentProviderNotice | null> {
const legacy = this.legacyProviderOptions();
if (legacy) {
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01.
const pending = this.legacyResetControls(agentId, patch);
const controls = pending ? await pending : [];
for (const [featureId, value] of legacy.featureWrites(patch, controls)) {
await this.setAgentFeature(agentId, featureId, value);
}
return null;
}
return this.applyAgentConfig(agentId, { options: patch });
}
/**
* COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01.
* A reset (`null`) on a daemon without `providerOptionControls` is written as the value its
* control resets to, which takes the agent's controls. Without a reset nothing is fetched, so
* the write is not delayed.
*/
private legacyResetControls(
agentId: string,
patch: Record<string, unknown>,
): Promise<AgentOptionControl[]> | null {
if (!Object.values(patch).includes(null)) return null;
return this.fetchAgent(agentId).then((result) => result?.agent.controls ?? []);
}
async setAgentThinkingOption(
agentId: string,
thinkingOptionId: string | null,
@@ -4291,18 +4174,11 @@ export class DaemonClient {
agentId: string,
config: AgentConfigApply,
): Promise<AgentProviderNotice | null> {
let wireConfig = config;
const legacy = this.legacyProviderOptions();
if (legacy && config.options) {
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01.
const pending = this.legacyResetControls(agentId, config.options);
wireConfig = legacy.featureValuesConfig(config, pending ? await pending : []);
}
const requestId = this.createRequestId();
const message = SessionInboundMessageSchema.parse({
type: "agent.config.apply.request",
agentId,
config: wireConfig,
config,
requestId,
});
const payload = await this.sendRequest({
@@ -5199,16 +5075,12 @@ export class DaemonClient {
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
if (input.agent?.background !== undefined)
throw new Error("Configure background on the workspace, not its initial agent.");
if (input.metadata !== undefined || input.agent?.metadata !== undefined)
this.requireEntityMetadata();
let agentConfig = input.agent ? resolveAgentConfig(input.agent) : undefined;
if (agentConfig && this.carriesLegacyOptions(agentConfig)) {
agentConfig = await this.routeLegacyCreateConfig(agentConfig);
}
const result = await this.creations.createWorkspace({
...input,
requestId: resolvedRequestId,
...(input.agent && agentConfig ? { agent: { ...input.agent, config: agentConfig } } : {}),
...(input.agent
? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } }
: {}),
});
return {
...result,
@@ -5689,36 +5561,6 @@ export class DaemonClient {
});
}
/**
* The controls a draft agent would show and the options it would run with: `config.json`
* defaults merged with the draft's own `providerOptions`, resolved by the daemon.
*/
async listProviderControls(
draftConfig: ListCommandsDraftConfig,
options?: { requestId?: string },
): Promise<ListProviderControlsPayload> {
const legacy = this.legacyProviderOptions();
if (legacy) {
const { providerOptions, ...rest } = draftConfig;
const payload = await this.listProviderFeatures(
{
...rest,
// A legacy daemon reads the options it exposes as features, whatever the others are.
...(providerOptions ? { featureValues: providerOptions } : {}),
},
options,
);
return legacy.controlsResponse(payload);
}
return this.sendCorrelatedSessionRequest({
requestId: options?.requestId,
message: { type: "provider.controls.list.request", draftConfig },
responseType: "provider.controls.list.response",
timeout: 90000,
});
}
/** @deprecated Use `listProviderControls`. */
async listProviderFeatures(
draftConfig: ListCommandsDraftConfig,
options?: { requestId?: string },
@@ -7009,14 +6851,6 @@ export class DaemonClient {
return requestId ?? crypto.randomUUID();
}
private readonly legacyOptions = new LegacyProviderOptions();
/** The translation for a daemon without `providerOptionControls`; null for a current one. */
private legacyProviderOptions(): LegacyProviderOptions | null {
const info = this.lastServerInfoMessage;
return info && usesLegacyProviderOptions(info) ? this.legacyOptions : null;
}
getLastServerInfoMessage(): ServerInfoStatusPayload | null {
return this.lastServerInfoMessage;
}
@@ -7599,7 +7433,6 @@ export class DaemonClient {
private handleSessionMessage(msg: SessionOutboundMessage): void {
msg = this.owned.normalize(msg);
msg = this.legacyProviderOptions()?.normalize(msg) ?? msg;
if (
msg.type === "providers_snapshot_update" &&
this.config.providerSnapshots !== "wire" &&
@@ -7888,7 +7721,6 @@ function resolveAgentConfig(options: CreateAgentRequestOptions): AgentSessionCon
worktreeName: _worktreeName,
requestId: _requestId,
labels: _labels,
metadata: _metadata,
...overrides
} = options;
+2 -7
View File
@@ -1683,12 +1683,7 @@ test("config actions delegate to existing daemon config RPCs", async () => {
});
test("agent config maps provider/model and provider-native options to the daemon", async () => {
const { client, ws } = await connectClient({
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
providerOptionControls: true,
});
const { client, ws } = await connectClient();
const createdAgent = createAgent({
model: "gpt-5.4",
currentModeId: "full-access",
@@ -1720,8 +1715,8 @@ test("agent config maps provider/model and provider-native options to the daemon
model: "gpt-5.4",
modeId: "full-access",
thinkingOptionId: "high",
featureValues: { webSearch: true },
providerOptions: {
webSearch: true,
approval_policy: "never",
sandbox_mode: "workspace-write",
sandbox_workspace_write: {
+6 -83
View File
@@ -8,7 +8,7 @@ export type { HostAdd, HostUpdate, HostSummary, HostDirectory } from "@getpaseo/
import type { OwnedSubscription } from "./connection/index.js";
export type { OwnedSubscription, SubscriptionObserver } from "./connection/index.js";
import type { DaemonClientConfig } from "./daemon-client.js";
import type { AgentPermissionResponse, JsonValue } from "@getpaseo/protocol/agent-types";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import type {
AgentSnapshotPayload,
CreationSnapshot,
@@ -20,8 +20,6 @@ import type {
ListCommandsResponse,
ListProviderFeaturesRequestMessage,
ListProviderFeaturesResponseMessage,
ProviderControlsListRequestMessage,
ProviderControlsListResponseMessage,
ListProviderModelsResponseMessage,
ProjectListRequestMessage,
ProjectListResponseMessage,
@@ -184,8 +182,6 @@ export interface PaseoWorkspaceHandle {
readonly directory: string | null;
readonly name: string | null;
readonly status: PaseoWorkspace["status"] | null;
/** Data plugins stored on the workspace. Paseo never reads a key. */
readonly metadata: NonNullable<PaseoWorkspace["metadata"]> | null;
readonly agents: {
create(options: PaseoWorkspaceAgentCreateOptions): Promise<PaseoAgentHandle>;
};
@@ -193,8 +189,6 @@ export interface PaseoWorkspaceHandle {
current(): PaseoWorkspace | null;
refresh(options?: { requestId?: string }): Promise<PaseoWorkspace | null>;
setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>;
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
archive(requestId?: string): Promise<PaseoWorkspaceArchiveResult>;
/**
* Subscribes to already-emitted daemon workspace_update events for this id.
@@ -245,12 +239,8 @@ export interface PaseoAgentConfig {
provider: string;
modeId?: PaseoAgentSessionConfig["modeId"];
thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"];
/** @deprecated Put these in `options`; they are folded into it on send. */
featureValues?: PaseoProviderFeatureValues;
/**
* JSON-safe provider settings, validated by the selected provider. They include the settings the
* provider offers a composer control for, such as `service_tier` or `fast_mode`.
*/
/** JSON-safe provider-native settings, validated by the selected provider. */
options?: PaseoAgentSessionConfig["providerOptions"];
systemPrompt?: PaseoAgentSessionConfig["systemPrompt"];
toolPolicy?: PaseoAgentSessionConfig["toolPolicy"];
@@ -282,8 +272,6 @@ export interface PaseoAgentCreateOptions {
background?: CreateAgentRequestMessage["background"];
requestId?: string;
labels?: Record<string, string>;
/** Data plugins store on the agent: JSON values under keys such as `"linear.issue"`. */
metadata?: CreateAgentRequestMessage["metadata"];
}
export type PaseoWorkspaceAgentCreateOptions = Omit<PaseoAgentCreateOptions, "cwd">;
@@ -380,14 +368,7 @@ export interface PaseoAgentHandle {
readonly activeTurn: NonNullable<PaseoAgent["activeTurn"]> | null;
readonly lastUsage: NonNullable<PaseoAgent["lastUsage"]> | null;
readonly lastError: NonNullable<PaseoAgent["lastError"]> | null;
/** @deprecated Use `controls` and `options`. */
readonly features: NonNullable<PaseoAgent["features"]> | null;
/** The provider options the agent runs with: `config.json` defaults merged with its own. */
readonly options: NonNullable<PaseoAgent["options"]> | null;
/** How the provider wants its options drawn. Controls hold no value; read `options`. */
readonly controls: NonNullable<PaseoAgent["controls"]> | null;
/** Data plugins stored on the agent. Paseo never reads a key. */
readonly metadata: NonNullable<PaseoAgent["metadata"]> | null;
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
readonly timeline: PaseoAgentTimelineHandle;
@@ -400,14 +381,6 @@ export interface PaseoAgentHandle {
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
respondToPermission(options: PaseoAgentRespondToPermissionOptions): Promise<void>;
/**
* Changes the 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. Resolves once the
* provider has applied the patch; a provider that cannot apply a key live rejects it.
*/
setOptions(patch: Record<string, unknown>): Promise<void>;
/** Top-level keys replace; `null` removes the key. Needs a host with `entityMetadata`. */
setMetadata(patch: Record<string, JsonValue>): Promise<void>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
/** Waits for the current turn, including one started with `prompt`. */
@@ -444,21 +417,14 @@ export interface PaseoAgentActions {
export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"];
export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"];
type PaseoProviderControlsDraft = ProviderControlsListRequestMessage["draftConfig"];
export interface PaseoProviderControlsInput extends Omit<
PaseoProviderControlsDraft,
type PaseoProviderFeaturesDraft = ListProviderFeaturesRequestMessage["draftConfig"];
export interface PaseoProviderFeaturesInput extends Omit<
PaseoProviderFeaturesDraft,
"provider" | "model"
> {
/** Provider and model in `provider/model` format. */
provider: string;
}
export type PaseoProviderControlsResult = ProviderControlsListResponseMessage["payload"];
/** @deprecated Use `PaseoProviderControlsInput`. */
export type PaseoProviderFeaturesInput = Omit<
ListProviderFeaturesRequestMessage["draftConfig"],
"provider" | "model"
> & { provider: string };
/** @deprecated Use `PaseoProviderControlsResult`. */
export type PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"];
export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"];
export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"];
@@ -497,15 +463,6 @@ export interface PaseoProviderActions {
provider: PaseoAgentProvider,
options?: PaseoProviderListOptions,
): Promise<PaseoProviderModesResult>;
/**
* The controls a draft agent would show and the options it would run with, resolved by the
* daemon from `config.json` defaults and the draft's own `providerOptions`.
*/
listControls(
draftConfig: PaseoProviderControlsInput,
options?: { requestId?: string },
): Promise<PaseoProviderControlsResult>;
/** @deprecated Use `listControls`. */
listFeatures(
draftConfig: PaseoProviderFeaturesInput,
options?: { requestId?: string },
@@ -590,14 +547,7 @@ function toDaemonAgentCreateOptions(
serverId?: string,
): CreateAgentRequestOptions {
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
const {
provider: providerModel,
options: ownOptions,
featureValues,
...runtimeConfig
} = agentConfig;
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. An explicit option wins.
const providerOptions = featureValues ? { ...featureValues, ...ownOptions } : ownOptions;
const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig;
const { provider, model } = parseProviderModel(providerModel);
const relationship = parent ? encodeParent(parent, serverId) : {};
return {
@@ -832,10 +782,6 @@ export function createPaseoApi(
const { provider, model } = parseProviderModel(providerModel);
return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options);
},
listControls: ({ provider: providerModel, ...draftConfig }, options) => {
const { provider, model } = parseProviderModel(providerModel);
return daemonClient.listProviderControls({ ...draftConfig, provider, model }, options);
},
listAvailable: (options) => daemonClient.listAvailableProviders(options),
snapshot: (options) => daemonClient.getProvidersSnapshot(options),
waitForReady: (options) =>
@@ -897,9 +843,6 @@ function createWorkspaceHandleFactory(
get status() {
return current?.status ?? null;
},
get metadata() {
return current?.metadata ?? null;
},
agents: {
create: async (options) => {
const snapshot = current ?? (await refresh());
@@ -919,10 +862,6 @@ function createWorkspaceHandleFactory(
current: () => current,
refresh,
setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId),
setMetadata: async (patch) => {
const metadata = await daemonClient.setWorkspaceMetadata(id, patch);
if (current) current = { ...current, metadata };
},
archive: async (requestId) => {
const result = await daemonClient.archiveWorkspace(id, requestId);
if (current) {
@@ -1024,15 +963,6 @@ function createAgentHandleFactory(
get features() {
return current?.features ?? null;
},
get options() {
return current?.options ?? null;
},
get controls() {
return current?.controls ?? null;
},
get metadata() {
return current?.metadata ?? null;
},
get runtimeInfo() {
return current?.runtimeInfo ?? null;
},
@@ -1054,13 +984,6 @@ function createAgentHandleFactory(
send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options);
},
setOptions: async (patch) => {
await daemonClient.setAgentOptions(id, patch);
},
setMetadata: async (patch) => {
const metadata = await daemonClient.setAgentMetadata(id, patch);
if (current) current = { ...current, metadata };
},
respondToPermission: async ({ requestId, response }) => {
await daemonClient.respondToPermission(id, requestId, response);
},
+1
View File
@@ -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",
-4
View File
@@ -283,10 +283,6 @@ describe("lifecycle hook SDK types", () => {
server.addHook("workspace.create.before", async ({ request }) => ({ ...request, title: "Changed" }));
server.addHook("workspace.create.before", async () => {});
server.addHook("agent.open.before", async () => {});
server.addHook("agent.open.before", async ({ request }) => {
const { metadata, ...withoutMetadata } = request;
return withoutMetadata;
});
server.addHook("agent.turn.after", async ({ agent, outcome, timeline }, { paseo, signal }) => {
const kind: "completed" | "failed" | "canceled" = outcome.kind;
// @ts-expect-error Notification payloads have no creation request.
+2 -20
View File
@@ -4,7 +4,6 @@ import type {
AgentPermissionResponse,
AgentTimelineItem,
AgentSessionConfig,
JsonValue,
} from "@getpaseo/protocol/agent-types";
import type { PaseoApi } from "@getpaseo/client";
import type { WorkspaceCreateRequest } from "@getpaseo/protocol/messages";
@@ -20,8 +19,6 @@ export interface PluginHookWorkspace {
cwd: string;
name: string | null;
archivedAt: string | null;
/** Data plugins stored on the workspace. Paseo never reads a key. */
metadata: Record<string, JsonValue>;
}
export interface PluginHookWorktree {
@@ -71,8 +68,6 @@ export interface PluginHookAgent {
provider: string;
cwd: string;
title: string | null;
/** Data plugins stored on the agent. Paseo never reads a key. */
metadata: Record<string, JsonValue>;
}
export interface PluginSessionOpenRequest {
@@ -84,7 +79,6 @@ export interface PluginSessionOpenRequest {
purpose: "interactive" | "history";
readonly title: string | null;
readonly labels: Readonly<Record<string, string>>;
readonly metadata: Readonly<Record<string, JsonValue>>;
tools: AgentToolDefinition[];
env: Record<string, string>;
mcpServers: NonNullable<AgentSessionConfig["mcpServers"]>;
@@ -122,27 +116,15 @@ export interface PluginHookEvents {
export interface PluginHookRequests {
"worktree.create.before": PluginWorktreeCreateRequest;
"agent.create.before": {
config: AgentSessionConfig;
env?: Record<string, string>;
/** Stored on the new agent. Always present in the payload; return it changed to change it. */
metadata?: Record<string, JsonValue>;
};
"agent.create.before": { config: AgentSessionConfig; env?: Record<string, string> };
"agent.open.before": PluginSessionOpenRequest;
"workspace.create.before": Omit<WorkspaceCreateRequest, "type" | "requestId">;
}
/** What a hook may return. An omitted `metadata` stays as it was. */
type PluginHookRequestResults = Omit<PluginHookRequests, "agent.open.before"> & {
"agent.open.before": Omit<PluginSessionOpenRequest, "metadata"> & {
metadata?: Readonly<Record<string, JsonValue>>;
};
};
export type PluginHooks = {
[Name in keyof PluginHookRequests]: {
request: PluginHookRequests[Name];
result: PluginHookRequestResults[Name];
result: PluginHookRequests[Name];
};
} & { [Name in keyof PluginHookEvents]: { payload: PluginHookEvents[Name] } } & {
"worktree.create.after": { payload: { worktree: PluginHookWorktree } };
+2 -74
View File
@@ -116,9 +116,7 @@ export interface ProviderSessionConfig {
model?: string;
mode?: string;
thinkingOption?: string;
/** @deprecated The same values as `providerOptions`. */
settings: Readonly<Record<string, JsonValue>>;
/** The agent's options, the provider defaults from `config.json` included. */
providerOptions?: Readonly<Record<string, unknown>>;
title?: string;
persist: boolean;
@@ -128,13 +126,6 @@ export interface ProviderConfigChanges {
model?: string | null;
mode?: string | null;
thinkingOption?: string | null;
/**
* The option keys to change on the running session, with or without a control. Apply every key
* or fail the whole request, for a key you only read when a session opens. `null` returns the
* key to the provider's own default. The committed values come back in `session.config`.
*/
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */
settings?: Readonly<Record<string, JsonValue>>;
}
@@ -320,34 +311,6 @@ export interface ProviderMode {
isUnattended?: boolean;
}
/**
* Draws one top-level option key in the composer. The control holds no value: Paseo reads it
* from the agent's options.
*/
export type ProviderOptionControl =
| {
type: "toggle";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** Shown while the key has no value. */
defaultValue?: boolean;
}
| {
type: "select";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** The choice shown while the key has no value. */
defaultValue?: string;
choices: ReadonlyArray<{ value: string; label: string; description?: string }>;
};
/** @deprecated Report `controls` and `providerOptions` instead. */
export type ProviderSetting =
| {
type: "toggle";
@@ -372,12 +335,7 @@ export interface ProviderConfigState {
models: readonly ProviderModel[];
modes: readonly ProviderMode[];
thinkingOptions: readonly ProviderThinkingOption[];
/** The option keys that get a composer control. May change at runtime, for example per model. */
controls?: readonly ProviderOptionControl[];
/** What the session runs with now, for the keys it reports. Publish the committed values. */
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Report `controls` and `providerOptions`. Each setting id is an option key. */
settings?: readonly ProviderSetting[];
settings: readonly ProviderSetting[];
}
export interface ProviderCatalog {
@@ -930,7 +888,6 @@ const configChangesSchema = z
model: z.string().nullable().optional(),
mode: z.string().nullable().optional(),
thinkingOption: z.string().nullable().optional(),
providerOptions: jsonObjectSchema.optional(),
settings: jsonObjectSchema.optional(),
})
.strict();
@@ -1067,33 +1024,6 @@ const settingSchema = z.discriminatedUnion("type", [
})
.strip(),
]);
const controlSchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("toggle"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.boolean().optional(),
})
.strip(),
z
.object({
type: z.literal("select"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.string().optional(),
choices: z.array(
z
.object({ value: z.string(), label: z.string(), description: z.string().optional() })
.strip(),
),
})
.strip(),
]);
const configStateSchema = z
.object({
model: z.string().optional(),
@@ -1102,9 +1032,7 @@ const configStateSchema = z
models: z.array(modelSchema),
modes: z.array(modeSchema),
thinkingOptions: z.array(selectOptionSchema),
controls: z.array(controlSchema).optional(),
providerOptions: jsonObjectSchema.optional(),
settings: z.array(settingSchema).optional(),
settings: z.array(settingSchema),
})
.strip();
const catalogSchema = z
-3
View File
@@ -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 -26
View File
@@ -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;
-126
View File
@@ -303,9 +303,6 @@ import type {
// unknown avoids zod-aot's recursive z.json() object-codegen regression.
const JsonWireValueSchema = z.unknown() as z.ZodType<JsonValue>;
/** Plugin-owned data on an agent or workspace. Paseo stores it and never reads a key. */
const EntityMetadataSchema = z.record(z.string(), JsonWireValueSchema);
export const AgentStatusSchema = z.enum(AGENT_LIFECYCLE_STATUSES);
const AgentModeSchema: z.ZodType<AgentMode> = z.object({
@@ -364,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(),
@@ -526,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(),
@@ -903,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(),
@@ -925,7 +884,6 @@ export const AgentSnapshotPayloadSchema = z.object({
lastError: z.string().optional(),
title: z.string().nullable(),
labels: z.record(z.string(), z.string()).default({}),
metadata: EntityMetadataSchema.optional(),
requiresAttention: z.boolean().optional(),
attentionReason: z.enum(["finished", "error", "permission"]).nullable().optional(),
attentionTimestamp: z.string().nullable().optional(),
@@ -1075,22 +1033,6 @@ export const WorkspaceTitleSetRequestSchema = z.object({
requestId: z.string(),
});
export const AgentMetadataUpdateRequestSchema = z.object({
type: z.literal("agent.metadata.update.request"),
agentId: z.string(),
// Top-level keys replace; null removes the key.
patch: EntityMetadataSchema,
requestId: z.string(),
});
export const WorkspaceMetadataUpdateRequestSchema = z.object({
type: z.literal("workspace.metadata.update.request"),
workspaceId: z.string(),
// Top-level keys replace; null removes the key.
patch: EntityMetadataSchema,
requestId: z.string(),
});
export const WorkspacePinSetRequestSchema = z.object({
type: z.literal("workspace.pin.set.request"),
workspaceId: z.string(),
@@ -1867,7 +1809,6 @@ export const CreateAgentRequestMessageSchema = z.object({
// Omitted on agent-scoped creation, the caller agent's own value applies.
background: z.boolean().optional(),
labels: z.record(z.string(), z.string()).default({}),
metadata: EntityMetadataSchema.optional(),
requestId: z.string(),
});
@@ -2135,10 +2076,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>;
@@ -2246,28 +2184,6 @@ export const WorkspaceTitleSetResponseSchema = z.object({
payload: WorkspaceTitleSetResponsePayloadSchema,
});
export const AgentMetadataUpdateResponseSchema = z.object({
type: z.literal("agent.metadata.update.response"),
payload: z.object({
requestId: z.string(),
agentId: z.string(),
accepted: z.boolean(),
metadata: EntityMetadataSchema.nullable(),
error: z.string().nullable(),
}),
});
export const WorkspaceMetadataUpdateResponseSchema = z.object({
type: z.literal("workspace.metadata.update.response"),
payload: z.object({
requestId: z.string(),
workspaceId: z.string(),
accepted: z.boolean(),
metadata: EntityMetadataSchema.nullable(),
error: z.string().nullable(),
}),
});
export const WorkspacePinSetResponsePayloadSchema = z.object({
requestId: z.string(),
workspaceId: z.string(),
@@ -2823,7 +2739,6 @@ export const WorkspaceCreateRequestSchema = z.object({
idempotencyKey: z.string().min(1).max(512).optional(),
// Optional user-set title applied to the created workspace.
title: z.string().optional(),
metadata: EntityMetadataSchema.optional(),
// Optional prompt context for workspace-level name/branch generation.
firstAgentContext: FirstAgentContextSchema.optional(),
// Retained for wire parsing; public internal creation is no longer supported.
@@ -3077,9 +2992,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({
@@ -3088,12 +3001,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(),
@@ -3271,7 +3178,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(),
@@ -3421,8 +3327,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ProjectIconSetRequestSchema,
ProjectRemoveRequestSchema,
WorkspaceTitleSetRequestSchema,
AgentMetadataUpdateRequestSchema,
WorkspaceMetadataUpdateRequestSchema,
WorkspacePinSetRequestSchema,
WorkspaceLabelListRequestSchema,
WorkspaceLabelAssignmentSetRequestSchema,
@@ -3476,7 +3380,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsRequestMessageSchema,
ListProviderModesRequestMessageSchema,
ListProviderFeaturesRequestMessageSchema,
ProviderControlsListRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema,
GetProvidersSnapshotRequestMessageSchema,
RefreshProvidersSnapshotRequestMessageSchema,
@@ -3962,8 +3865,6 @@ export const ServerInfoStatusPayloadSchema = z
selectiveAgentTimeline: z.boolean().optional(),
explicitEventSubscriptions: z.boolean().optional(),
ownedSubscriptions: z.boolean().optional(),
providerOptionControls: z.boolean().optional(),
entityMetadata: z.boolean().optional(),
binaryChunkingV1: z.boolean().optional(),
workspaceSetupObservation: z.boolean().optional(),
workspaceSortTimes: z.boolean().optional(),
@@ -4298,7 +4199,6 @@ export const WorkspaceDescriptorPayloadSchema = z
pinnedAt: z.string().nullable().optional(),
// COMPAT(workspaceLabels): added in v0.5.0, remove optional after 2027-08-14.
labels: z.array(z.string()).optional(),
metadata: EntityMetadataSchema.optional(),
// Retained for wire parsing of older daemon descriptors.
internal: z.boolean().optional(),
background: z.boolean().optional(),
@@ -6450,19 +6350,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({
@@ -7315,8 +7202,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ProjectIconSetResponseSchema,
ProjectRemoveResponseSchema,
WorkspaceTitleSetResponseSchema,
AgentMetadataUpdateResponseSchema,
WorkspaceMetadataUpdateResponseSchema,
WorkspacePinSetResponseSchema,
WorkspaceRecoveryInspectResponseSchema,
WorkspaceRecoveryRestoreResponseSchema,
@@ -7377,7 +7262,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema,
ProviderControlsListResponseMessageSchema,
ListAvailableProvidersResponseSchema,
GetProvidersSnapshotResponseMessageSchema,
ProvidersSnapshotUpdateMessageSchema,
@@ -7635,12 +7519,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
>;
@@ -7686,10 +7564,6 @@ export type ProjectIconSetRequest = z.infer<typeof ProjectIconSetRequestSchema>;
export type ProjectRemoveRequest = z.infer<typeof ProjectRemoveRequestSchema>;
export type WorkspaceTitleSetRequest = z.infer<typeof WorkspaceTitleSetRequestSchema>;
export type WorkspacePinSetRequest = z.infer<typeof WorkspacePinSetRequestSchema>;
export type AgentMetadataUpdateRequest = z.infer<typeof AgentMetadataUpdateRequestSchema>;
export type WorkspaceMetadataUpdateRequest = z.infer<typeof WorkspaceMetadataUpdateRequestSchema>;
export type AgentMetadataUpdateResponse = z.infer<typeof AgentMetadataUpdateResponseSchema>;
export type WorkspaceMetadataUpdateResponse = z.infer<typeof WorkspaceMetadataUpdateResponseSchema>;
export type WorkspaceRecoveryInspectRequest = z.infer<typeof WorkspaceRecoveryInspectRequestSchema>;
export type WorkspaceRecoveryRestoreRequest = z.infer<typeof WorkspaceRecoveryRestoreRequestSchema>;
export type SetAgentModeRequestMessage = z.infer<typeof SetAgentModeRequestMessageSchema>;
-107
View File
@@ -1,107 +0,0 @@
import type {
AgentFeature,
AgentOptionControl,
AgentOptionSelectControl,
ProviderOptions,
} from "./agent-types.js";
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
// `features` are controls with their value attached. The daemon builds them for apps that
// predate controls, and the client package rebuilds controls and options from them when it
// talks to a daemon that predates controls. Both directions live here so they stay inverse.
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 } : {}),
})),
};
}
/** 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 } : {}),
})),
};
}
/** Agent records and daemons that predate controls carry `features`; their descriptors become controls. */
export function controlsFromFeatures(
features: readonly AgentFeature[] | undefined,
): AgentOptionControl[] {
return (features ?? []).map(controlFromFeature);
}
/** The options a list of `features` stands for: each feature's value under its id. */
export function optionsFromFeatures(
features: readonly AgentFeature[] | undefined,
): ProviderOptions {
return Object.fromEntries((features ?? []).map((feature) => [feature.id, feature.value]));
}
-1
View File
@@ -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(),
@@ -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 {
@@ -132,15 +131,8 @@ export async function ensureAgentLoaded(
if (!config) {
throw new Error(`Agent ${agentId} references unavailable provider '${record.provider}'`);
}
// Known gap: `record` was read before this create, and createAgent deletes the stored
// state outside the agent's lifecycle lane. A setMetadata that saves between the read and
// the delete is overwritten by this stale metadata. That window spans everything from the
// read to the delete, including the agent.create.before hook, which a plugin can hold
// open. The fix is to read and delete the stored record inside the lane
// (createAgentInternal), which is a lifecycle change.
snapshot = await deps.agentManager.createAgent(config, agentId, {
labels: record.labels,
metadata: record.metadata,
workspaceId: record.workspaceId,
owner: record.owner,
background: record.background,
@@ -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");
@@ -5597,37 +5540,6 @@ test("setLabels merges and persists labels", async () => {
});
});
test("reopening a closed agent keeps metadata saved while the loader was reading its record", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-metadata-reopen-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new TestAgentClient() },
registry: storage,
logger,
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, title: "Metadata reopen" },
undefined,
{ workspaceId: undefined, metadata: { first: 1 } },
);
const agentId = created.id;
await manager.closeAgent(agentId);
// The loader reads the record, then a setMetadata is queued before its resume runs.
const readByLoader = await storage.get(agentId);
const saved = manager.setMetadata(agentId, { second: 2 });
const reopened = manager.resumeAgentFromPersistence(
{ provider: "codex", sessionId: "session-metadata", metadata: { cwd: workdir } },
{ cwd: workdir },
agentId,
{ metadata: readByLoader?.metadata },
);
expect(await saved).toEqual({ first: 1, second: 2 });
expect((await reopened).metadata).toEqual({ first: 1, second: 2 });
expect((await storage.get(agentId))?.metadata).toEqual({ first: 1, second: 2 });
});
test("detachAgent removes relationship lifecycle labels from a live agent and emits state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-"));
const storagePath = join(workdir, "agents");
@@ -8361,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,
@@ -9467,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),
);
@@ -10215,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 = [
{
@@ -10233,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() {
@@ -10246,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" },
};
}
@@ -10261,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 }),
];
}
}
@@ -10289,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 },
@@ -10314,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 () => {
@@ -10330,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 = [
{
@@ -10341,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() {
@@ -10363,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,
@@ -10375,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 = [];
}
}
@@ -10398,7 +10312,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
{
provider: "codex",
cwd: workdir,
providerOptions: { web_search: true },
},
undefined,
{ workspaceId: undefined },
@@ -10410,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;
}
@@ -12731,7 +12645,6 @@ test.each([
provider: "codex",
cwd: workdir,
title: null,
metadata: {},
},
turnId: "hook-turn",
outcome,
@@ -13007,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);
});
});
});
+65 -340
View File
@@ -1,12 +1,10 @@
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";
import { projectTimelineRows } from "./timeline-projection.js";
import type { PluginLifecycle } from "../plugins/lifecycle/index.js";
import { describeHookAgent, publishAgentStream } from "../plugins/lifecycle/index.js";
import { applyRecordPatch } from "../record-patch.js";
import type { PluginSessionOpenRequest } from "@getpaseo/plugin/server";
import { randomUUID } from "node:crypto";
import { basename, resolve } from "node:path";
@@ -35,7 +33,7 @@ import {
type AgentCreateSessionOptions,
type AgentResumePurpose,
type AgentResumeSessionOptions,
type AgentOptionControl,
type AgentFeature,
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
@@ -61,16 +59,7 @@ import {
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { JsonValue, 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 {
@@ -182,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;
@@ -238,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;
}
@@ -342,7 +317,6 @@ type ProviderClientMap = Partial<Record<AgentProvider, AgentClient>>;
export interface CreateAgentOptions {
labels?: Record<string, string>;
metadata?: AgentMetadata;
initialPrompt?: string;
env?: Record<string, string>;
persistSession?: boolean;
@@ -461,9 +435,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<
@@ -496,8 +468,6 @@ interface ManagedAgentBase {
* User-defined labels for categorizing agents (e.g., { surface: "workspace" }).
*/
labels: Record<string, string>;
/** Plugin-owned data. Paseo persists and forwards it and never reads a key. */
metadata: AgentMetadata;
}
type ManagedAgentWithSession = ManagedAgentBase & {
@@ -557,7 +527,6 @@ type ActiveManagedAgent =
type LiveManagedAgent = ActiveManagedAgent;
type AgentLabelPatch = Record<string, string | null>;
type AgentMetadata = Record<string, JsonValue>;
function attachManagedTurnIdentity(
agent: ActiveManagedAgent,
@@ -605,7 +574,6 @@ interface WriteLabelsResult {
interface AgentMetadataPatch {
title?: string;
labels?: AgentLabelPatch;
metadata?: Record<string, JsonValue | null>;
}
const SYSTEM_ERROR_PREFIX = "[System Error]";
@@ -664,11 +632,6 @@ function createAbortError(signal: AbortSignal | undefined, fallbackMessage: stri
return Object.assign(new Error(message), { name: "AbortError" });
}
/** The user labels and plugin metadata an agent carries; both default to empty. */
function agentEntityData(source?: { labels?: Record<string, string>; metadata?: AgentMetadata }) {
return { labels: source?.labels ?? {}, metadata: source?.metadata ?? {} };
}
function validateAgentId(agentId: string, source: string): string {
const result = AgentIdSchema.safeParse(agentId);
if (!result.success) {
@@ -677,6 +640,21 @@ function validateAgentId(agentId: string, source: string): string {
return result.data;
}
function applyLabelPatch(
labels: Record<string, string>,
patch: AgentLabelPatch,
): Record<string, string> {
const nextLabels = { ...labels };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete nextLabels[key];
} else {
nextLabels[key] = value;
}
}
return nextLabels;
}
function buildExplicitTimelineSeedForRegister(
now: Date,
options:
@@ -805,7 +783,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>();
@@ -1278,17 +1255,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) {
@@ -1297,18 +1268,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();
@@ -1406,25 +1372,13 @@ 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,
metadata: options.metadata ?? {},
});
// 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,
};
options = { ...options, env: request.env, metadata: request.metadata ?? options.metadata };
config = { ...request.config, internal: config.internal };
options = { ...options, env: request.env };
}
await this.deleteAgentState(resolvedAgentId);
const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig(
@@ -1449,18 +1403,14 @@ export class AgentManager {
workspaceId: options.workspaceId ?? null,
title: options.initialTitle ?? storedConfig.title ?? null,
labels: options.labels ?? {},
metadata: options.metadata ?? {},
},
);
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,
metadata: options.metadata,
initialTitle: options.initialTitle,
workspaceId: options.workspaceId,
owner: options.owner,
@@ -1495,7 +1445,6 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
@@ -1529,7 +1478,6 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
workspaceId?: string;
owner?: AgentOwner;
background?: boolean;
@@ -1553,8 +1501,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(
@@ -1581,25 +1531,19 @@ export class AgentManager {
purpose,
workspaceId: options?.workspaceId ?? null,
title: record?.title ?? mergedConfig.title ?? null,
...agentEntityData(record ?? options),
labels: record?.labels ?? options?.labels ?? {},
},
);
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,
// The caller read its metadata before this turn in the lane; a setMetadata queued ahead of
// it has saved a newer record, the one the open hooks saw.
...(record ? { metadata: record.metadata } : {}),
launchEnv,
persistence: handle,
restoring: true,
@@ -1654,7 +1598,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,
@@ -1673,7 +1616,6 @@ export class AgentManager {
handedToRegistration = true;
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
launchedOptions,
labels: input.labels,
workspaceId: input.workspaceId,
timelineRows,
@@ -1756,7 +1698,6 @@ export class AgentManager {
workspaceId: existing.workspaceId,
title: await this.resolveInitialPersistedTitle(agentId, existing.config, null),
labels: existing.labels,
metadata: existing.metadata,
},
);
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
@@ -1780,7 +1721,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);
@@ -1799,9 +1739,7 @@ export class AgentManager {
// Preserve existing labels and timeline during reload.
handedToRegistration = true;
return this.registerSession(session, storedConfig, agentId, {
launchedOptions,
labels: existing.labels,
metadata: existing.metadata,
workspaceId: existing.workspaceId,
owner: existing.owner,
background: existing.background,
@@ -2127,7 +2065,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;
@@ -2240,11 +2178,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(),
@@ -2265,13 +2199,12 @@ export class AgentManager {
internal: record.internal,
background: record.background,
labels: record.labels,
metadata: record.metadata,
},
});
}
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;
@@ -2287,7 +2220,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;
@@ -2309,7 +2242,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
@@ -2339,107 +2272,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 ?? {};
}
/** What an agent of this provider runs with, given its own options: the defaults under them. */
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();
@@ -2465,30 +2311,10 @@ export class AgentManager {
});
}
/** Applies a plugin-metadata patch (null removes a key) and returns the stored result. */
async setMetadata(
agentId: string,
patch: Record<string, JsonValue | null>,
): Promise<AgentMetadata> {
return this.runLifecycleMutation(agentId, async () => {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
liveAgent.metadata = applyRecordPatch<JsonValue>(liveAgent.metadata, patch);
this.touchUpdatedAt(liveAgent);
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
return liveAgent.metadata;
}
const record = await this.writeStoredMetadata(agentId, { metadata: patch });
if (!record.internal) this.dispatchStoredAgentState(record);
return record.metadata;
});
}
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
liveAgent.labels = applyRecordPatch(liveAgent.labels, patch);
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
this.touchUpdatedAt(liveAgent);
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
@@ -2513,8 +2339,7 @@ export class AgentManager {
const nextRecord = {
...record,
...(patch.title ? { title: patch.title } : {}),
...(patch.labels ? { labels: applyRecordPatch(record.labels, patch.labels) } : {}),
...(patch.metadata ? { metadata: applyRecordPatch(record.metadata, patch.metadata) } : {}),
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
updatedAt: this.nextStoredUpdatedAt(record),
};
await registry.upsert(nextRecord);
@@ -2693,7 +2518,7 @@ export class AgentManager {
await registry.upsert({
...record,
...(updates?.workspaceId ? { workspaceId: updates.workspaceId } : {}),
...(updates?.labels ? { labels: applyRecordPatch(record.labels, updates.labels) } : {}),
...(updates?.labels ? { labels: applyLabelPatch(record.labels, updates.labels) } : {}),
archivedAt: null,
updatedAt: new Date().toISOString(),
});
@@ -2706,8 +2531,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 &&
@@ -2830,7 +2654,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;
@@ -2959,34 +2783,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,
@@ -3009,8 +2805,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,
@@ -3697,12 +3491,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(
@@ -4010,14 +3799,13 @@ export class AgentManager {
private async registerSession(
session: AgentSession,
openedConfig: AgentSessionConfig,
config: AgentSessionConfig,
agentId: string,
options: {
options?: {
createdAt?: Date;
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
timeline?: AgentTimelineItem[];
timelineRows?: AgentTimelineRow[];
timelineNextSeq?: number;
@@ -4031,12 +3819,6 @@ 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;
@@ -4049,7 +3831,6 @@ export class AgentManager {
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`);
@@ -4071,10 +3852,6 @@ export class AgentManager {
resolvedAgentId,
session,
config,
effectiveOptions: this.openedEffectiveOptions({
session,
launchedOptions: options.launchedOptions,
}),
now,
durableTimelineHasRows,
background: options?.background === true,
@@ -4220,7 +3997,6 @@ export class AgentManager {
resolvedAgentId: string;
session: AgentSession;
config: AgentSessionConfig;
effectiveOptions: ProviderOptions;
now: Date;
durableTimelineHasRows: boolean;
background: boolean;
@@ -4230,7 +4006,6 @@ export class AgentManager {
updatedAt?: Date;
lastUserMessageAt?: Date | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
historyPrimed?: boolean;
lastUsage?: AgentUsage;
lastError?: string;
@@ -4242,15 +4017,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,
@@ -4261,8 +4028,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,
@@ -4290,7 +4055,7 @@ export class AgentManager {
attention: resolveInitialAttention(options?.attention),
internal: config.internal ?? false,
background: params.background,
...agentEntityData(options),
labels: options?.labels ?? {},
} as ActiveManagedAgent;
}
@@ -4569,7 +4334,7 @@ export class AgentManager {
agent.pendingPermissions.clear();
}
this.syncControls(agent);
this.syncFeaturesFromSession(agent);
await this.refreshRuntimeInfo(agent, options);
}
@@ -4915,18 +4680,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;
@@ -4978,9 +4731,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":
@@ -5458,7 +5208,7 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent);
}
this.syncControls(agent);
this.syncFeaturesFromSession(agent);
this.logger.trace(
{
@@ -5480,9 +5230,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;
}
}
@@ -5728,7 +5478,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) {
@@ -5800,30 +5550,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,
@@ -5875,7 +5601,6 @@ export class AgentManager {
workspaceId?: string | null;
title?: string | null;
labels?: Record<string, string>;
metadata?: AgentMetadata;
},
): Promise<AgentLaunchContext> {
// A plugin changing during this open must still make the resulting session stale.
@@ -5893,7 +5618,7 @@ export class AgentManager {
purpose: opening?.purpose ?? "interactive",
env: { ...env },
title: opening?.title ?? null,
...agentEntityData(opening),
labels: opening?.labels ?? {},
tools: [],
mcpServers: {},
};
@@ -10,16 +10,13 @@ import {
} from "./agent-projections.js";
import type { AgentSession } from "./agent-sdk-types.js";
import type {
AgentOptionControl,
AgentFeature,
ImportableProviderSession,
AgentPermissionRequest,
AgentPersistenceHandle,
AgentSessionConfig,
} from "./agent-sdk-types.js";
/** A provider with no defaults of its own: the effective options are the agent's own. */
const ownOptionsOnly = (_provider: string, own: Record<string, unknown> | undefined) => own ?? {};
type ManagedAgentOverrides = Omit<Partial<ManagedAgent>, "config" | "pendingPermissions"> & {
config?: Partial<AgentSessionConfig>;
pendingPermissions?: Map<string, AgentPermissionRequest>;
@@ -131,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({
@@ -384,7 +391,7 @@ describe("toAgentPayload", () => {
}),
);
const payload = buildStoredAgentPayload(record, ["codex"], ownOptionsOnly);
const payload = buildStoredAgentPayload(record, ["codex"]);
expect(record.persistence?.metadata).toEqual({
conversationId: "conversation-stored",
@@ -400,81 +407,15 @@ describe("toAgentPayload", () => {
});
});
it("projects a closed agent's stored controls and options", () => {
const controls: AgentOptionControl[] = [
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
},
];
const record = toStoredAgentRecord(
createManagedAgent({
provider: "codex",
lifecycle: "closed",
controls,
config: {
provider: "codex",
providerOptions: { service_tier: "priority", sandbox_mode: "read-only" },
},
}),
);
const payload = buildStoredAgentPayload(record, ["codex"], ownOptionsOnly);
expect(payload.status).toBe("closed");
expect(payload.controls).toEqual(controls);
expect(payload.options).toEqual({ service_tier: "priority", sandbox_mode: "read-only" });
expect(payload.features).toEqual([
{
type: "select",
id: "service_tier",
label: "Speed",
value: "priority",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
],
},
]);
});
it("projects a closed agent's options with the provider defaults under its own, like a live one", () => {
const record = toStoredAgentRecord(
createManagedAgent({
provider: "claude",
lifecycle: "closed",
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
config: { provider: "claude", providerOptions: {} },
}),
);
const withConfigDefaults = (_provider: string, own: Record<string, unknown> | undefined) => ({
fast_mode: true,
...own,
});
const payload = buildStoredAgentPayload(record, ["claude"], withConfigDefaults);
expect(payload.options).toEqual({ fast_mode: true });
expect(payload.features).toEqual([
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
]);
});
it("carries a stored lastError onto the payload", () => {
const record = toStoredAgentRecord(
createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }),
);
expect(buildStoredAgentPayload(record, ["codex"], ownOptionsOnly).lastError).toBe("boom");
expect(
buildStoredAgentPayload({ ...record, lastError: null }, ["codex"], ownOptionsOnly),
).not.toHaveProperty("lastError");
expect(buildStoredAgentPayload(record, ["codex"]).lastError).toBe("boom");
expect(buildStoredAgentPayload({ ...record, lastError: null }, ["codex"])).not.toHaveProperty(
"lastError",
);
});
it("omits lastUsage when not available", () => {
@@ -533,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 "@getpaseo/protocol/option-controls";
import type { JsonValue } from "../json-utils.js";
import {
isStoredAgentProviderAvailable,
@@ -94,13 +92,12 @@ export function toStoredAgentRecord(
lastUserMessageAt: agent.lastUserMessageAt ? agent.lastUserMessageAt.toISOString() : null,
title: options?.title ?? null,
labels: agent.labels,
metadata: agent.metadata,
lastStatus: agent.lifecycle,
lastModeId: agent.currentModeId ?? config?.modeId ?? null,
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,
@@ -147,17 +144,11 @@ 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,
metadata: agent.metadata,
archivedAt: options?.archivedAt ?? null,
...visibilityFlags(agent),
};
@@ -227,32 +218,9 @@ function visibilityFlags(agent: {
};
}
/** The options an agent runs with, given the options it owns; see `toAgentPayload`. */
export type EffectiveOptionsResolver = (
provider: AgentProvider,
own: ProviderOptions | undefined,
) => ProviderOptions;
function buildStoredOptionControls(
record: StoredAgentRecord,
effectiveOptionsFor: EffectiveOptionsResolver,
): Pick<AgentSnapshotPayload, "features" | "options" | "controls"> {
const controls = normalizeControls(record.controls);
const options = sanitizeEffectiveOptions(
effectiveOptionsFor(record.provider, record.config?.providerOptions ?? undefined),
);
return {
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
features: featuresFromControls({ controls, options }),
options,
controls,
};
}
export function buildStoredAgentPayload(
record: StoredAgentRecord,
validProviders: Iterable<AgentProvider>,
effectiveOptionsFor: EffectiveOptionsResolver,
): AgentSnapshotPayload {
const defaultCapabilities = {
supportsStreaming: false,
@@ -271,7 +239,6 @@ export function buildStoredAgentPayload(
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
const runtimeInfo = buildStoredRuntimeInfo(record);
const optionControls = buildStoredOptionControls(record, effectiveOptionsFor);
const providerAvailable = isStoredAgentProviderAvailable(record, validProviders);
const persistence = projectPersistenceHandleForWire(
buildStoredPersistenceHandle(record, validProviders),
@@ -296,7 +263,6 @@ export function buildStoredAgentPayload(
capabilities: defaultCapabilities,
currentModeId: record.lastModeId ?? null,
availableModes: [],
...optionControls,
pendingPermissions: [],
persistence,
title: record.title ?? null,
@@ -306,7 +272,6 @@ export function buildStoredAgentPayload(
archivedAt: record.archivedAt ?? null,
...visibilityFlags(record),
labels: normalizeLabels(record.labels),
metadata: record.metadata,
...(typeof record.lastError === "string" ? { lastError: record.lastError } : {}),
...(providerAvailable ? {} : { providerUnavailable: true }),
};
@@ -422,6 +387,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)) {
@@ -497,15 +468,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: Record<string, unknown>): 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);
});
});
+21 -161
View File
@@ -2,23 +2,19 @@ import { promises as fs, type Dirent } from "node:fs";
import path from "node:path";
import { z } from "zod";
import type { Logger } from "pino";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
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 } from "@getpaseo/protocol/option-controls";
import { 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
@@ -46,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(),
@@ -57,7 +53,6 @@ const RAW_STORED_AGENT_SCHEMA = z.object({
lastUserMessageAt: z.string().nullable().optional(),
title: z.string().nullable().optional(),
labels: z.record(z.string(), z.string()).default({}),
metadata: z.record(z.string(), z.custom<JsonValue>()).default({}),
lastStatus: AgentStatusSchema.default("closed"),
resumeOnStartup: z.boolean().optional(),
lastModeId: z.string().nullable().optional(),
@@ -73,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(),
@@ -92,32 +85,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);
}
@@ -130,14 +105,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) {
@@ -149,119 +119,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;
@@ -281,7 +143,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> {
@@ -371,14 +234,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);
@@ -431,9 +286,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) {
@@ -443,7 +298,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();
@@ -451,25 +306,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;
}
@@ -506,11 +364,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> {
@@ -547,7 +408,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(),
@@ -26,8 +26,6 @@ import {
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 {
@@ -59,8 +57,7 @@ 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;
@@ -70,7 +67,6 @@ export interface CreateAgentFromSessionInput {
images?: Array<{ data: string; mimeType: string }>;
attachments?: AgentAttachment[];
labels: Record<string, string>;
metadata?: CreateAgentOptions["metadata"];
env?: Record<string, string>;
provisionalTitle: string | null;
firstAgentContext: FirstAgentContext;
@@ -85,7 +81,7 @@ export interface CreateAgentFromMcpInput {
cwd?: string;
workspaceId?: string;
thinking?: string;
providerOptions?: ProviderOptions;
features?: Record<string, unknown>;
labels?: Record<string, string>;
mode?: string;
unattended?: boolean;
@@ -235,19 +231,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;
@@ -265,7 +261,6 @@ async function resolveSessionCreateAgent(
config: sessionConfig,
createOptions: {
labels: input.labels,
metadata: input.metadata,
initialPrompt: trimmedPrompt,
env: input.env,
initialTitle: input.provisionalTitle,
@@ -346,7 +341,7 @@ 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 } : {}),
@@ -385,23 +380,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;
@@ -409,14 +399,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,
@@ -429,8 +419,8 @@ function buildMcpSessionConfig(params: {
if (provisionalTitle) {
config.title = provisionalTitle;
}
if (providerOptions) {
config.providerOptions = providerOptions;
if (featureValues) {
config.featureValues = featureValues;
}
return config;
}
@@ -1,4 +1,3 @@
import { resolveWorkspaceRootAgent } from "../workspace-directory.js";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
@@ -51,12 +50,12 @@ beforeEach(async () => {
manager: { listAgents: () => [] },
workspaces: {
get: async (id) => workspaces.find((entry) => entry.workspaceId === id) ?? null,
list: async () => workspaces,
},
projectLive: () => {
throw new Error("No live agents in this suite");
},
projectStored: (record) =>
buildStoredAgentPayload(record, ["codex"], (_provider, own) => own ?? {}),
projectStored: (record) => buildStoredAgentPayload(record, ["codex"]),
isProviderVisible: () => true,
isStoredProviderAvailable: () => true,
});
@@ -113,28 +112,3 @@ test("prompt recency ignores background agents", async () => {
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");
});

Some files were not shown because too many files have changed in this diff Show More