Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andOuyang Xingyuan 82f1bea6b1 test(server): reproduce mixed-script file search and preserve existing matches
Record the reported ordering and matching expectations alongside existing Cyrillic, Korean, punctuation, and long-path searches. The reported expectations fail on main; compatibility checks expose regressions in the proposed replacement. Matching policy remains undecided; no production behavior changes.

Co-authored-by: Ouyang Xingyuan <galaxyroaming@qq.com>
2026-11-01 14:57:03 +00:00
Dominic 919e44b319 fix(server): start configured plugins concurrently (#8427)
The daemon accepts connections only after its configured plugins have
started, and PluginService.start started them one at a time, so each
plugin's build and process launch added to daemon startup. Configured
plugins now start together. Their providers and usage sources are still
published in config order: a plugin that finishes early waits for the
plugins listed before it, so when two plugins claim the same provider or
usage-source ID, the one listed first keeps it, as before. A failing
plugin still fails on its own.

Fixes #7979
2026-11-01 12:59:34 +00:00
paseo-bot[bot] 4fe44038e3 test(server): check subdirectory diffs before and after commit (#8466)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 12:35:50 +00:00
Dominic 9b0542d127 fix(server): retry a connected host that failed the capability check (#8423)
* fix(server): retry a connected host that failed the capability check

Every failure in a connected host's acceptance step was terminal: the
row went to "error" with no retry, and the status listener ignored
later reconnects. A host rejected with unsupported_capability stayed
unusable after it was updated, until the registration was saved again
or the managing daemon restarted. Capability and other non-identity
acceptance failures now go "offline" with nextRetryAt and retry with
capped backoff (1.5 s doubling to 30 s), so an updated host comes
online by itself. Self, duplicate, changed-identity and authentication
failures stay terminal.

Fixes #7971

* fix(server): keep a retried host's relay security floor and update guidance

A scheduled capability retry built a new transport factory, which
dropped the relay security floor learned from a protected handshake, so
the retry could negotiate legacy encryption and send credentials.
Retries now reuse the registration's transport factory (createHostClient
accepts one). The update guidance also stays visible while a retry
connects, the backoff resets after a successful acceptance, and
disabling or removing a host cancels its pending retry.
2026-11-01 10:59:33 +00:00
Dominic 8130147426 fix(app): stop the terminal from typing its version reply into the shell (#8419)
The browser terminal is a renderer and leaves protocol replies to the
daemon, but it still answered XTVERSION (CSI > q) and DECRQSS
(DCS $ q ... ST). Those replies went out through onData as if the user
had typed them, so a query from a program in the shell left text like
">|xterm.js(6.1.0-beta.292)" on the next input line. Both queries are
now swallowed with the other protocol queries.

Fixes #8111
2026-11-01 10:04:25 +00:00
Mohamed Boudra fa89738e3a chore(desktop): upgrade packaging tool and remove signing backport (#8450)
* chore(desktop): upgrade packaging tool and remove signing backport

* fix(nix): resolve the desktop workspace packaging tool
2026-11-01 14:46:52 +07:00
161 changed files with 3646 additions and 8524 deletions
-26
View File
@@ -135,32 +135,6 @@ jobs:
env: env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Backport electron-builder#10172 to the pinned build tool only.
# Remove this step when app-builder-lib is upgraded to a release containing it.
- name: Repair temporary keychain password in signing tool
shell: bash
run: |
node <<'KEYCHAIN_FIX'
const fs = require('node:fs');
const path = require('node:path');
const manifestPath = require.resolve('app-builder-lib/package.json');
if (JSON.parse(fs.readFileSync(manifestPath, 'utf8')).version !== '26.8.1') {
throw new Error('Reassess/remove the signing backport for the new app-builder-lib version');
}
const sourcePath = path.join(path.dirname(manifestPath), 'out/codeSign/macCodeSign.js');
let source = fs.readFileSync(sourcePath, 'utf8');
const replacements = [
['importCerts(keychainFile, certPaths, cscPasswords)', 'importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)'],
['importCerts(keychainFile, paths, keyPasswords)', 'importCerts(keychainFile, paths, keyPasswords, keychainPassword)'],
['["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]', '["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", keychainPassword, keychainFile]'],
];
for (const [before, after] of replacements) {
if (source.split(before).length !== 2) throw new Error('Unexpected signing tool source; refusing to patch');
source = source.replace(before, after);
}
fs.writeFileSync(sourcePath, source);
KEYCHAIN_FIX
- name: Set desktop package version from tag - name: Set desktop package version from tag
shell: bash shell: bash
run: | run: |
+4 -1
View File
@@ -74,7 +74,10 @@ registrations remain inspectable; a changed destination requires explicit identi
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`. Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance. operations; requesting this feature gives update guidance. The registry retries an older destination
with capped backoff, so it comes online once updated. Self, duplicate, changed-identity, and
authentication failures stay in `error` until an update changes the connection, `enabled`, or
`replaceIdentity`, or A restarts.
Disposing a routed API releases its observations and session. It does not remove the saved host or Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
+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 defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md)
for native keys and examples. 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 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 closed for Hub unattended execution until it can approve one exact injected MCP
server and tool identity without approving native tools. 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. 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. 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 provider: AgentProvider;
readonly id: string | null; readonly id: string | null;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
readonly controls?: AgentOptionControl[]; readonly features?: AgentFeature[];
run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult>;
startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>;
steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise<SteerResult>;
@@ -559,7 +500,7 @@ interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>; listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>; setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>; setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
applyOptions?(options: ProviderOptions): Promise<void>; setFeature?(featureId: string, value: unknown): Promise<void>;
tryHandleOutOfBand?(prompt: AgentPromptInput): { tryHandleOutOfBand?(prompt: AgentPromptInput): {
run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>; run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void>;
} | null; } | null;
+1 -1
View File
@@ -127,7 +127,7 @@ buildNpmPackage {
# The Nix output is not a distributable DMG, so leave it unsigned and # The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature. # disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \ CSC_IDENTITY_AUTO_DISCOVERY=false \
../../node_modules/.bin/electron-builder \ npm exec --offline -- electron-builder \
--config electron-builder.yml \ --config electron-builder.yml \
--dir \ --dir \
--mac \ --mac \
+1 -1
View File
@@ -1 +1 @@
sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU= sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ=
+1018 -1295
View File
File diff suppressed because it is too large Load Diff
@@ -62,7 +62,7 @@ async function readProviderFeaturePreferences(page: Page, provider: string): Pro
const raw = localStorage.getItem(preferencesKey); const raw = localStorage.getItem(preferencesKey);
if (!raw) return null; if (!raw) return null;
const preferences = JSON.parse(raw) as FormPreferences; 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 }, { preferencesKey: CREATE_AGENT_PREFERENCES_KEY, providerId: provider },
); );
@@ -145,7 +145,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
provider: featureProviderId, provider: featureProviderId,
model: featureModelId, model: featureModelId,
modeId: "bypassPermissions", modeId: "bypassPermissions",
options: { fast_mode: true }, featureValues: { fast_mode: true },
}, },
]); ]);
const provider = await seedModelProvider({ const provider = await seedModelProvider({
@@ -181,7 +181,7 @@ test.describe("Agent profiles repair modeless provider preferences", () => {
expect(createAgentRequest).toMatchObject({ expect(createAgentRequest).toMatchObject({
config: { config: {
provider: featureProviderId, provider: featureProviderId,
providerOptions: { fast_mode: true }, featureValues: { fast_mode: true },
}, },
}); });
} finally { } finally {
+2 -2
View File
@@ -18,7 +18,7 @@ export interface AgentCommandsDraftConfig {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
} }
export function normalizeAgentCommandsCwd(cwd: string): string { export function normalizeAgentCommandsCwd(cwd: string): string {
@@ -98,7 +98,7 @@ export function draftAgentCommandsQueryKey(input: {
"thinking", "thinking",
draftConfig.thinkingOptionId ?? null, draftConfig.thinkingOptionId ?? null,
"features", "features",
draftConfig.providerOptions ?? null, draftConfig.featureValues ?? null,
] as const; ] as const;
} }
@@ -1,149 +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 }));
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("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,168 +0,0 @@
import type {
AgentOptionControl,
AgentSessionConfig,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
/**
* 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;
}
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,
};
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 };
}),
applyProfile: (profile) =>
publish((current) => ({
...current,
provider: profile.provider,
seed: profile.options,
edits: {},
resolved: current.provider === profile.provider ? current.resolved : null,
})),
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 })),
};
}
/**
* 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.
*/
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 own = { ...state.seed, ...remembered, ...state.edits };
return Object.keys(own).length > 0 ? own : undefined;
}
export type DraftControlsRequest = Pick<
AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "providerOptions"
>;
/**
* 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: {
provider: string | null;
cwd: string;
modeId?: string | null;
modelId?: string | null;
thinkingOptionId?: string | null;
},
): 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,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", model: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { web_search: true }, featureValues: { web_search: true },
}), }),
), ),
).toEqual({ ).toEqual({
@@ -31,7 +31,7 @@ describe("materializeAgentProfile", () => {
modelId: "claude-opus-5", modelId: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { web_search: true }, featureValues: { web_search: true },
}); });
}); });
@@ -41,7 +41,7 @@ describe("materializeAgentProfile", () => {
modelId: "", modelId: "",
modeId: "", modeId: "",
thinkingOptionId: "", thinkingOptionId: "",
options: {}, featureValues: {},
}); });
}); });
@@ -61,7 +61,7 @@ describe("toAgentConfigApply", () => {
model: "claude-opus-5", model: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { web_search: true }, featureValues: { web_search: true },
}), }),
), ),
), ),
@@ -69,7 +69,7 @@ describe("toAgentConfigApply", () => {
modelId: "claude-opus-5", modelId: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", 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", () => { it("omits an empty feature map so the agent keeps its own feature values", () => {
expect( expect(
toAgentConfigApply(materializeAgentProfile(profile({ options: {} }))), toAgentConfigApply(materializeAgentProfile(profile({ featureValues: {} }))),
).not.toHaveProperty("options"); ).not.toHaveProperty("featureValues");
}); });
it("never carries a provider, which a running agent cannot change", () => { it("never carries a provider, which a running agent cannot change", () => {
@@ -12,7 +12,7 @@ export interface MaterializedAgentProfile {
modelId: string; modelId: string;
modeId: string; modeId: string;
thinkingOptionId: string; thinkingOptionId: string;
options: Record<string, unknown>; featureValues: Record<string, unknown>;
} }
function trimmed(value: string | undefined): string { function trimmed(value: string | undefined): string {
@@ -25,7 +25,7 @@ export function materializeAgentProfile(profile: AgentProfile): MaterializedAgen
modelId: trimmed(profile.model), modelId: trimmed(profile.model),
modeId: trimmed(profile.modeId), modeId: trimmed(profile.modeId),
thinkingOptionId: trimmed(profile.thinkingOptionId), 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.modelId ? { modelId: profile.modelId } : {}),
...(profile.modeId ? { modeId: profile.modeId } : {}), ...(profile.modeId ? { modeId: profile.modeId } : {}),
...(profile.thinkingOptionId ? { thinkingOptionId: profile.thinkingOptionId } : {}), ...(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 type { ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
buildControlsRequestKey, buildFeatureRequestKey,
openAgentProfileForm, openAgentProfileForm,
type AgentProfileFormModel, type AgentProfileFormModel,
} from "./profile-form-model"; } from "./profile-form-model";
@@ -173,7 +173,7 @@ describe("openAgentProfileForm", () => {
model: "claude-opus-5", model: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
options: { webSearch: true }, featureValues: { webSearch: true },
notes: "Visual work only.", notes: "Visual work only.",
}, },
}); });
@@ -229,11 +229,10 @@ describe("openAgentProfileForm", () => {
selectClaude(model); selectClaude(model);
model.setModel("claude-opus-5", { label: "Opus 5" }); model.setModel("claude-opus-5", { label: "Opus 5" });
model.setThinking("think-hard", { label: "Think hard" }); model.setThinking("think-hard", { label: "Think hard" });
model.applyControls(model.getState().controlsRequestKey ?? "", { model.applyFeatures(model.getState().featureRequestKey ?? "", [
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], { type: "toggle", id: "webSearch", label: "Web search", value: false },
options: { webSearch: false }, ]);
}); model.setFeatureValue("webSearch", true);
model.setOption("webSearch", true);
model.setProvider("codex", { label: "Codex" }); model.setProvider("codex", { label: "Codex" });
const state = model.getState(); const state = model.getState();
@@ -243,9 +242,8 @@ describe("openAgentProfileForm", () => {
expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" }); expect(state.modelDisplay).toEqual({ label: "GPT-5.2 Codex" });
expect(state.thinkingOptionId).toBe(""); expect(state.thinkingOptionId).toBe("");
expect(state.thinkingDisplay).toBeNull(); expect(state.thinkingDisplay).toBeNull();
expect(state.options).toEqual({}); expect(state.featureValues).toEqual({});
expect(state.optionEdits).toEqual({}); expect(state.features).toEqual([]);
expect(state.controls).toEqual([]);
expect(state.name).toBe("Test writer"); expect(state.name).toBe("Test writer");
}); });
@@ -336,28 +334,23 @@ describe("openAgentProfileForm", () => {
}); });
}); });
const WEB_SEARCH_CONTROLS = { describe("features", () => {
controls: [{ type: "toggle" as const, key: "webSearch", label: "Web search" }], it("asks for features per provider/model/mode/thinking combination", () => {
options: { webSearch: false },
};
describe("controls", () => {
it("asks for controls per provider/model/mode/thinking combination", () => {
const model = openWithCatalog({ mode: "create" }); const model = openWithCatalog({ mode: "create" });
selectClaude(model); selectClaude(model);
expect(model.getState().controlsRequest).toEqual({ expect(model.getState().featureRequest).toEqual({
provider: "claude", provider: "claude",
model: "claude-opus-5", model: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think", thinkingOptionId: "think",
}); });
const firstKey = model.getState().controlsRequestKey; const firstKey = model.getState().featureRequestKey;
model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" }); model.setModel("claude-haiku-4-5", { label: "Haiku 4.5" });
expect(model.getState().controlsRequestKey).not.toBe(firstKey); expect(model.getState().featureRequestKey).not.toBe(firstKey);
expect(model.getState().controlsRequest).toEqual({ expect(model.getState().featureRequest).toEqual({
provider: "claude", provider: "claude",
model: "claude-haiku-4-5", model: "claude-haiku-4-5",
modeId: "plan", modeId: "plan",
@@ -368,168 +361,77 @@ describe("openAgentProfileForm", () => {
it("moves from pending to complete only for the current request", () => { it("moves from pending to complete only for the current request", () => {
const model = openWithCatalog({ mode: "create" }); const model = openWithCatalog({ mode: "create" });
selectClaude(model); selectClaude(model);
const key = model.getState().controlsRequestKey ?? ""; const key = model.getState().featureRequestKey ?? "";
expect(model.getState().controlsResolution).toBe("pending"); expect(model.getState().featureResolution).toBe("pending");
model.applyControls("stale-key", { model.applyFeatures("stale-key", [
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], { type: "toggle", id: "webSearch", label: "Web search", value: false },
options: { webSearch: false }, ]);
}); expect(model.getState().featureResolution).toBe("pending");
expect(model.getState().controlsResolution).toBe("pending"); expect(model.getState().features).toEqual([]);
expect(model.getState().controls).toEqual([]);
model.applyControls(key, { model.applyFeatures(key, [
controls: [{ type: "toggle", key: "webSearch", label: "Web search" }], { type: "toggle", id: "webSearch", label: "Web search", value: false },
options: { webSearch: false }, ]);
}); expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().controlsResolution).toBe("complete"); expect(model.getState().disclosure.showFeaturesField).toBe(true);
expect(model.getState().disclosure.showControlsField).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" }); const model = openWithCatalog({ mode: "create" });
model.setName("Searcher");
selectClaude(model); 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().features[0]).toMatchObject({ id: "webSearch", value: true });
expect(model.getState().controlOptions).toEqual({ webSearch: true }); expect(model.getState().featureValues).toEqual({ webSearch: true });
expect(model.getState().submitValue?.options).toEqual({ webSearch: true });
}); });
it("keeps stored options while the controls are still pending", () => { 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 } },
});
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", () => {
const model = openAgentProfileForm({ const model = openAgentProfileForm({
mode: "edit", mode: "edit",
profile: { profile: {
id: "p1", id: "p1",
name: "UI work", name: "UI work",
provider: "claude", provider: "claude",
options: { webSearch: true, sandbox_mode: "read-only" }, featureValues: { webSearch: true },
}, },
}); });
model.applyProviderCatalog(ENTRIES); model.applyProviderCatalog(ENTRIES);
model.applyControls(model.getState().controlsRequestKey ?? "", WEB_SEARCH_CONTROLS);
model.setOption("webSearch", false);
expect(model.getState().submitValue?.options).toEqual({ expect(model.getState().featureResolution).toBe("pending");
webSearch: false, expect(model.getState().submitValue?.featureValues).toEqual({ webSearch: true });
sandbox_mode: "read-only",
});
}); });
it("draws the options the daemon resolved from the stored ones, then the edits", () => { 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" } },
});
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", () => {
const model = openAgentProfileForm({ const model = openAgentProfileForm({
mode: "edit", mode: "edit",
profile: { profile: {
id: "p1", id: "p1",
name: "Fast", name: "UI work",
provider: "codex", provider: "claude",
options: { service_tier: "fast", sandbox_mode: "read-only" }, featureValues: { webSearch: true, retiredFlag: true },
}, },
}); });
model.applyProviderCatalog(ENTRIES); model.applyProviderCatalog(ENTRIES);
const speed = { model.applyFeatures(model.getState().featureRequestKey ?? "", [
controls: [ { type: "toggle", id: "webSearch", label: "Web search", value: false },
{ ]);
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"); expect(model.getState().featureValues).toEqual({ webSearch: true });
// 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("resolves an unavailable feature listing without showing the field", () => { it("resolves an unavailable feature listing without showing the field", () => {
const model = openWithCatalog({ mode: "create" }); const model = openWithCatalog({ mode: "create" });
selectClaude(model); selectClaude(model);
model.applyControlsUnavailable(model.getState().controlsRequestKey ?? ""); model.applyFeaturesUnavailable(model.getState().featureRequestKey ?? "");
expect(model.getState().controlsResolution).toBe("complete"); expect(model.getState().featureResolution).toBe("complete");
expect(model.getState().disclosure.showControlsField).toBe(false); expect(model.getState().disclosure.showFeaturesField).toBe(false);
}); });
}); });
@@ -562,15 +464,15 @@ describe("openAgentProfileForm", () => {
}); });
}); });
describe("buildControlsRequestKey", () => { describe("buildFeatureRequestKey", () => {
it("is null without a provider", () => { 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", () => { it("separates the fields so a value cannot bleed across positions", () => {
expect(buildControlsRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||"); expect(buildFeatureRequestKey({ provider: "claude", model: "a|b" })).toBe("claude|a|b||");
expect(buildControlsRequestKey({ provider: "claude", modeId: "a" })).not.toBe( expect(buildFeatureRequestKey({ provider: "claude", modeId: "a" })).not.toBe(
buildControlsRequestKey({ provider: "claude", model: "a" }), buildFeatureRequestKey({ provider: "claude", model: "a" }),
); );
}); });
}); });
@@ -1,6 +1,5 @@
import type { import type {
AgentOptionControl, AgentFeature,
ProviderOptions,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentSelectOption, AgentSelectOption,
@@ -8,6 +7,7 @@ import type {
} from "@getpaseo/protocol/agent-types"; } from "@getpaseo/protocol/agent-types";
import type { AgentProfile } from "@getpaseo/protocol/messages"; import type { AgentProfile } from "@getpaseo/protocol/messages";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { applyFeatureValues, pruneFeatureValues } from "@/hooks/feature-preferences";
import { filterSelectableModels } from "@/provider-selection/model-catalog"; import { filterSelectableModels } from "@/provider-selection/model-catalog";
/** /**
@@ -57,15 +57,11 @@ export interface AgentProfileFormSnapshot {
} }
/** /**
* The inputs a controls listing needs. Controls are provider-scoped and change * 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. The * with model/mode/thinking, so every one of those is part of the request.
* 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.
*/ */
export interface AgentProfileControlsRequest { export interface AgentProfileFeatureRequest {
provider: string; provider: string;
providerOptions?: ProviderOptions;
model?: string; model?: string;
modeId?: string; modeId?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
@@ -77,7 +73,7 @@ export interface AgentProfileFormDisclosure {
showModelField: boolean; showModelField: boolean;
showModeField: boolean; showModeField: boolean;
showThinkingField: boolean; showThinkingField: boolean;
showControlsField: boolean; showFeaturesField: boolean;
} }
export interface AgentProfileFormState { export interface AgentProfileFormState {
@@ -90,18 +86,13 @@ export interface AgentProfileFormState {
modelId: string; modelId: string;
modeId: string; modeId: string;
thinkingOptionId: string; thinkingOptionId: string;
/** The options the profile was opened with, including ones no control draws. */ featureValues: Record<string, unknown>;
options: ProviderOptions;
/** Changes made in this form, over `options`. */
optionEdits: ProviderOptions;
providerOptions: AgentProfileFormOption[]; providerOptions: AgentProfileFormOption[];
modelOptions: AgentProfileFormOption[]; modelOptions: AgentProfileFormOption[];
modeOptions: AgentProfileFormOption[]; modeOptions: AgentProfileFormOption[];
thinkingOptions: AgentProfileFormOption[]; thinkingOptions: AgentProfileFormOption[];
controls: AgentOptionControl[]; features: AgentFeature[];
/** What the controls show: the options the daemon resolved from `options`, then the edits. */
controlOptions: ProviderOptions;
providerDisplay: AgentProfileFormDisplay | null; providerDisplay: AgentProfileFormDisplay | null;
modelDisplay: AgentProfileFormDisplay | null; modelDisplay: AgentProfileFormDisplay | null;
@@ -109,9 +100,9 @@ export interface AgentProfileFormState {
thinkingDisplay: AgentProfileFormDisplay | null; thinkingDisplay: AgentProfileFormDisplay | null;
catalogResolution: AgentProfileResolutionStatus; catalogResolution: AgentProfileResolutionStatus;
controlsResolution: AgentProfileResolutionStatus; featureResolution: AgentProfileResolutionStatus;
controlsRequest: AgentProfileControlsRequest | null; featureRequest: AgentProfileFeatureRequest | null;
controlsRequestKey: string | null; featureRequestKey: string | null;
disclosure: AgentProfileFormDisclosure; disclosure: AgentProfileFormDisclosure;
isSubmitting: boolean; isSubmitting: boolean;
@@ -127,12 +118,9 @@ export interface AgentProfileFormModel {
/** Late input: the host-scoped provider catalog. Never touches selections. */ /** Late input: the host-scoped provider catalog. Never touches selections. */
applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void; applyProviderCatalog: (entries: readonly ProviderSnapshotEntry[]) => void;
/** Late input: the feature list for one request. Stale keys are ignored. */ /** Late input: the feature list for one request. Stale keys are ignored. */
applyControls: ( applyFeatures: (requestKey: string, features: readonly AgentFeature[]) => void;
requestKey: string,
resolved: { controls: readonly AgentOptionControl[]; options: ProviderOptions },
) => void;
/** Resolve a request that produced no usable features (provider error). */ /** Resolve a request that produced no usable features (provider error). */
applyControlsUnavailable: (requestKey: string) => void; applyFeaturesUnavailable: (requestKey: string) => void;
setName: (value: string) => void; setName: (value: string) => void;
setAppearance: (value: { icon: string; color: string }) => void; setAppearance: (value: { icon: string; color: string }) => void;
setNotes: (value: string) => void; setNotes: (value: string) => void;
@@ -140,7 +128,7 @@ export interface AgentProfileFormModel {
setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void; setModel: (modelId: string, display: AgentProfileFormDisplay | null) => void;
setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void; setMode: (modeId: string, display: AgentProfileFormDisplay | null) => void;
setThinking: (thinkingOptionId: 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; setSubmitting: (value: boolean) => void;
setSubmitError: (value: string | null) => void; setSubmitError: (value: string | null) => void;
} }
@@ -324,29 +312,19 @@ function refineDisplay(
return current ?? { label: selectedId }; return current ?? { label: selectedId };
} }
function buildControlsRequest(state: AgentProfileFormState): AgentProfileControlsRequest | null { function buildFeatureRequest(state: AgentProfileFormState): AgentProfileFeatureRequest | null {
if (!state.provider) { if (!state.provider) {
return null; return null;
} }
const ownOptions = ownOptionsOf(state);
return { return {
provider: state.provider, provider: state.provider,
...(state.modelId ? { model: state.modelId } : {}), ...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}), ...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), ...(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. */ export function buildFeatureRequestKey(request: AgentProfileFeatureRequest | null): string | null {
function ownOptionsOf(
state: Pick<AgentProfileFormState, "options" | "optionEdits">,
): ProviderOptions {
return { ...state.options, ...state.optionEdits };
}
/** The part of a request that decides which controls exist; options only change their values. */
function buildControlsScopeKey(request: AgentProfileControlsRequest | null): string | null {
if (!request) { if (!request) {
return null; return null;
} }
@@ -358,25 +336,12 @@ function buildControlsScopeKey(request: AgentProfileControlsRequest | null): str
].join("|"); ].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 { function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | null {
const name = state.name.trim(); const name = state.name.trim();
const notes = state.notes.trim(); const notes = state.notes.trim();
if (!name || !state.provider) { if (!name || !state.provider) {
return null; return null;
} }
const options = ownOptionsOf(state);
return { return {
name, name,
...(state.icon ? { icon: state.icon } : {}), ...(state.icon ? { icon: state.icon } : {}),
@@ -385,16 +350,16 @@ function buildSubmitValue(state: AgentProfileFormState): AgentProfileValue | nul
...(state.modelId ? { model: state.modelId } : {}), ...(state.modelId ? { model: state.modelId } : {}),
...(state.modeId ? { modeId: state.modeId } : {}), ...(state.modeId ? { modeId: state.modeId } : {}),
...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}), ...(state.thinkingOptionId ? { thinkingOptionId: state.thinkingOptionId } : {}),
...(Object.keys(options).length > 0 ? { options } : {}), ...(Object.keys(state.featureValues).length > 0 ? { featureValues: state.featureValues } : {}),
...(notes ? { notes } : {}), ...(notes ? { notes } : {}),
}; };
} }
function resolveControlsStatus( function resolveFeatureStatus(
controlsRequestKey: string | null, featureRequestKey: string | null,
featuresAreCurrent: boolean, featuresAreCurrent: boolean,
): AgentProfileResolutionStatus { ): AgentProfileResolutionStatus {
if (controlsRequestKey === null) { if (featureRequestKey === null) {
return "idle"; return "idle";
} }
return featuresAreCurrent ? "complete" : "pending"; return featuresAreCurrent ? "complete" : "pending";
@@ -427,27 +392,25 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
modelId, modelId,
modeId: profile.modeId ?? "", modeId: profile.modeId ?? "",
thinkingOptionId: profile.thinkingOptionId ?? "", thinkingOptionId: profile.thinkingOptionId ?? "",
options: { ...profile.options }, featureValues: { ...profile.featureValues },
optionEdits: {},
providerOptions: [], providerOptions: [],
modelOptions: [], modelOptions: [],
modeOptions: [], modeOptions: [],
thinkingOptions: [], thinkingOptions: [],
controls: [], features: [],
controlOptions: {},
providerDisplay: seedDisplay(provider), providerDisplay: seedDisplay(provider),
modelDisplay: seedDisplay(modelId), modelDisplay: seedDisplay(modelId),
modeDisplay: seedDisplay(profile.modeId), modeDisplay: seedDisplay(profile.modeId),
thinkingDisplay: seedDisplay(profile.thinkingOptionId), thinkingDisplay: seedDisplay(profile.thinkingOptionId),
catalogResolution: "idle", catalogResolution: "idle",
controlsResolution: "idle", featureResolution: "idle",
controlsRequest: null, featureRequest: null,
controlsRequestKey: null, featureRequestKey: null,
disclosure: { disclosure: {
showModelField: false, showModelField: false,
showModeField: false, showModeField: false,
showThinkingField: false, showThinkingField: false,
showControlsField: false, showFeaturesField: false,
}, },
isSubmitting: false, isSubmitting: false,
submitError: null, submitError: null,
@@ -459,10 +422,8 @@ function buildInitialState(snapshot: AgentProfileFormSnapshot): AgentProfileForm
export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel { export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentProfileFormModel {
let entries: readonly ProviderSnapshotEntry[] = []; let entries: readonly ProviderSnapshotEntry[] = [];
let catalogResolution: AgentProfileResolutionStatus = "idle"; let catalogResolution: AgentProfileResolutionStatus = "idle";
let resolvedControlsKey: string | null = null; let resolvedFeatureKey: string | null = null;
let resolvedScopeKey: string | null = null; let resolvedFeatures: AgentFeature[] = [];
let resolvedControls: AgentOptionControl[] = [];
let resolvedOptions: ProviderOptions = {};
let listeners = new Set<() => void>(); let listeners = new Set<() => void>();
let closed = false; let closed = false;
@@ -475,23 +436,14 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modes, modes,
}); });
const thinking = resolveThinkingOptions(entries, next.provider, next.modelId); const thinking = resolveThinkingOptions(entries, next.provider, next.modelId);
const controlsRequest = buildControlsRequest(next); const featureRequest = buildFeatureRequest(next);
const controlsRequestKey = buildControlsRequestKey(controlsRequest); const featureRequestKey = buildFeatureRequestKey(featureRequest);
const controlsScopeKey = buildControlsScopeKey(controlsRequest);
const featuresAreCurrent = const featuresAreCurrent =
controlsRequestKey !== null && controlsRequestKey === resolvedControlsKey; featureRequestKey !== null && featureRequestKey === resolvedFeatureKey;
// A toggle changes only the options, so the controls stay up while the daemon answers and the const features = featuresAreCurrent
// toggle shows over the last answer. A change of provider, model, mode or thinking draws none ? applyFeatureValues(resolvedFeatures, next.featureValues)
// until its own answer arrives. : [];
const controlsApply = controlsScopeKey !== null && controlsScopeKey === resolvedScopeKey; const featureResolution = resolveFeatureStatus(featureRequestKey, featuresAreCurrent);
const controls = controlsApply ? resolvedControls : [];
let controlOptions: ProviderOptions = next.options;
if (controlsApply) {
controlOptions = featuresAreCurrent
? resolvedOptions
: { ...resolvedOptions, ...next.optionEdits };
}
const controlsResolution = resolveControlsStatus(controlsRequestKey, featuresAreCurrent);
const withOptions: AgentProfileFormState = { const withOptions: AgentProfileFormState = {
...next, ...next,
@@ -499,8 +451,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modelOptions: buildModelOptions(models), modelOptions: buildModelOptions(models),
modeOptions: buildModeOptions(modes), modeOptions: buildModeOptions(modes),
thinkingOptions: buildThinkingOptions(thinking), thinkingOptions: buildThinkingOptions(thinking),
controls, features,
controlOptions,
providerDisplay: refineDisplay( providerDisplay: refineDisplay(
next.providerDisplay, next.providerDisplay,
next.provider, next.provider,
@@ -528,9 +479,9 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
})(), })(),
), ),
catalogResolution, catalogResolution,
controlsResolution, featureResolution,
controlsRequest, featureRequest,
controlsRequestKey, featureRequestKey,
}; };
// A field appears once the catalog can populate it, and stays visible while // A field appears once the catalog can populate it, and stays visible while
@@ -541,7 +492,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)), showModeField: hasProvider && (modes.length > 0 || Boolean(withOptions.modeId)),
showThinkingField: showThinkingField:
hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)), hasProvider && (thinking.length > 0 || Boolean(withOptions.thinkingOptionId)),
showControlsField: hasProvider && controls.length > 0, showFeaturesField: hasProvider && features.length > 0,
}; };
const canSubmit = const canSubmit =
withOptions.name.trim().length > 0 && withOptions.name.trim().length > 0 &&
@@ -580,24 +531,23 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
catalogResolution = "complete"; catalogResolution = "complete";
publish((current) => current); publish((current) => current);
}, },
applyControls: (requestKey, resolved) => { applyFeatures: (requestKey, features) => {
if (requestKey !== state.controlsRequestKey) { if (requestKey !== state.featureRequestKey) {
return; return;
} }
resolvedControlsKey = requestKey; resolvedFeatureKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest); resolvedFeatures = [...features];
resolvedControls = [...resolved.controls]; publish((current) => ({
resolvedOptions = resolved.options; ...current,
publish((current) => current); featureValues: pruneFeatureValues(current.featureValues, resolvedFeatures),
}));
}, },
applyControlsUnavailable: (requestKey) => { applyFeaturesUnavailable: (requestKey) => {
if (requestKey !== state.controlsRequestKey) { if (requestKey !== state.featureRequestKey) {
return; return;
} }
resolvedControlsKey = requestKey; resolvedFeatureKey = requestKey;
resolvedScopeKey = buildControlsScopeKey(state.controlsRequest); resolvedFeatures = [];
resolvedControls = [];
resolvedOptions = {};
publish((current) => current); publish((current) => current);
}, },
setName: (value) => publish((current) => ({ ...current, name: value })), setName: (value) => publish((current) => ({ ...current, name: value })),
@@ -622,8 +572,7 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
modeDisplay: null, modeDisplay: null,
thinkingOptionId: "", thinkingOptionId: "",
thinkingDisplay: null, thinkingDisplay: null,
options: {}, featureValues: {},
optionEdits: {},
}; };
}), }),
setModel: (modelId, display) => setModel: (modelId, display) =>
@@ -649,10 +598,10 @@ export function openAgentProfileForm(snapshot: AgentProfileFormSnapshot): AgentP
publish((current) => ({ ...current, modeId, modeDisplay: display })), publish((current) => ({ ...current, modeId, modeDisplay: display })),
setThinking: (thinkingOptionId, display) => setThinking: (thinkingOptionId, display) =>
publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })), publish((current) => ({ ...current, thinkingOptionId, thinkingDisplay: display })),
setOption: (key, value) => setFeatureValue: (featureId, value) =>
publish((current) => ({ publish((current) => ({
...current, ...current,
optionEdits: { ...current.optionEdits, [key]: value }, featureValues: { ...current.featureValues, [featureId]: value },
})), })),
setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })), setSubmitting: (value) => publish((current) => ({ ...current, isSubmitting: value })),
setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })), setSubmitError: (value) => publish((current) => ({ ...current, submitError: value })),
@@ -47,7 +47,7 @@ export function buildAgentProfileTags(input: {
tags.push({ id: "thinking", label: formatThinkingOptionLabel({ id: thinkingOptionId }) }); 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) { if (featureCount > 0) {
tags.push({ id: "features", label: input.formatFeatureCount(featureCount) }); tags.push({ id: "features", label: input.formatFeatureCount(featureCount) });
} }
@@ -117,7 +117,9 @@ export function useAgentProfilePicker(
modelId: resolved.modelId, modelId: resolved.modelId,
modeId: resolved.modeId, modeId: resolved.modeId,
thinkingOptionId: resolved.thinkingOptionId, thinkingOptionId: resolved.thinkingOptionId,
...(Object.keys(resolved.options).length > 0 ? { options: resolved.options } : {}), ...(Object.keys(resolved.featureValues).length > 0
? { featureValues: resolved.featureValues }
: {}),
}), }),
).catch((error) => { ).catch((error) => {
console.warn("[useAgentProfilePicker] persist profile selection failed", error); console.warn("[useAgentProfilePicker] persist profile selection failed", error);
@@ -27,7 +27,7 @@ export function useAgentProfileFormCatalog(input: {
}, [entries, input.model]); }, [entries, input.model]);
} }
export function useAgentProfileFormControls(input: { export function useAgentProfileFormFeatures(input: {
serverId: string; serverId: string;
model: AgentProfileFormModel; model: AgentProfileFormModel;
state: AgentProfileFormState; state: AgentProfileFormState;
@@ -35,14 +35,14 @@ export function useAgentProfileFormControls(input: {
const { t } = useTranslation(); const { t } = useTranslation();
const client = useHostRuntimeClient(input.serverId); const client = useHostRuntimeClient(input.serverId);
const isConnected = useHostRuntimeIsConnected(input.serverId); const isConnected = useHostRuntimeIsConnected(input.serverId);
const request = input.state.controlsRequest; const request = input.state.featureRequest;
const requestKey = input.state.controlsRequestKey; const requestKey = input.state.featureRequestKey;
// `dataShape: "value"` and not `"list"`: a list shape keeps the previous // `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 // 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. // features of one provider/model combination to the key of another.
const controlsQuery = useFetchQuery({ const featuresQuery = useFetchQuery({
queryKey: ["agentProfileControls", input.serverId, requestKey], queryKey: ["agentProfileFeatures", input.serverId, requestKey],
dataShape: "value", dataShape: "value",
staleTimeMs: 5 * 60 * 1000, staleTimeMs: 5 * 60 * 1000,
enabled: Boolean(client && isConnected && request), enabled: Boolean(client && isConnected && request),
@@ -51,28 +51,28 @@ export function useAgentProfileFormControls(input: {
if (!client || !request) { if (!client || !request) {
throw new Error(t("workspace.terminal.hostDisconnected")); 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) { if (payload.error) {
throw new Error(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; const { model } = input;
useEffect(() => { useEffect(() => {
if (!requestKey || !data) { if (!requestKey || !data) {
return; return;
} }
model.applyControls(requestKey, data); model.applyFeatures(requestKey, data);
}, [data, model, requestKey]); }, [data, model, requestKey]);
useEffect(() => { useEffect(() => {
if (!requestKey || !isError) { if (!requestKey || !isError) {
return; return;
} }
model.applyControlsUnavailable(requestKey); model.applyFeaturesUnavailable(requestKey);
}, [isError, model, requestKey]); }, [isError, model, requestKey]);
} }
@@ -2,8 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactElement }
import { Text, View } from "react-native"; import { Text, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { StyleSheet } from "react-native-unistyles"; import { StyleSheet } from "react-native-unistyles";
import type { AgentOptionControl, ProviderOptions } from "@getpaseo/protocol/agent-types"; import type { AgentFeature } from "@getpaseo/protocol/agent-types";
import { readSelectOption, readToggleOption } from "@/agent-controls/option-controls";
import type { AgentProfile } from "@getpaseo/protocol/messages"; import type { AgentProfile } from "@getpaseo/protocol/messages";
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -26,7 +25,7 @@ import type {
} from "../internal/profile-form-model"; } from "../internal/profile-form-model";
import { import {
useAgentProfileFormCatalog, useAgentProfileFormCatalog,
useAgentProfileFormControls, useAgentProfileFormFeatures,
} from "../internal/use-profile-form-inputs"; } from "../internal/use-profile-form-inputs";
import { import {
useAgentProfileFormModel, useAgentProfileFormModel,
@@ -142,7 +141,7 @@ function OpenAgentProfileEditModal({
const model = useAgentProfileFormModel(snapshot); const model = useAgentProfileFormModel(snapshot);
const state = useAgentProfileFormState(model); const state = useAgentProfileFormState(model);
useAgentProfileFormCatalog({ serverId, model }); useAgentProfileFormCatalog({ serverId, model });
useAgentProfileFormControls({ serverId, model, state }); useAgentProfileFormFeatures({ serverId, model, state });
const sheetHeader = useMemo<SheetHeader>( const sheetHeader = useMemo<SheetHeader>(
() => ({ () => ({
@@ -329,17 +328,16 @@ function OpenAgentProfileEditModal({
/> />
) : null} ) : null}
{state.disclosure.showControlsField ? ( {state.disclosure.showFeaturesField ? (
<Field <Field
label={t("settings.host.agentProfiles.featuresLabel")} label={t("settings.host.agentProfiles.featuresLabel")}
testID="agent-profile-features-field" testID="agent-profile-features-field"
> >
<View style={styles.featureCard} testID="agent-profile-features-card"> <View style={styles.featureCard} testID="agent-profile-features-card">
{state.controls.map((control, index) => ( {state.features.map((feature, index) => (
<AgentProfileControlRow <AgentProfileFeatureRow
key={control.key} key={feature.id}
control={control} feature={feature}
options={state.controlOptions}
isFirst={index === 0} isFirst={index === 0}
disabled={state.isSubmitting} disabled={state.isSubmitting}
size={controlSize} size={controlSize}
@@ -402,16 +400,14 @@ function OpenAgentProfileEditModal({
); );
} }
function AgentProfileControlRow({ function AgentProfileFeatureRow({
control, feature,
options,
isFirst, isFirst,
disabled, disabled,
size, size,
model, model,
}: { }: {
control: AgentOptionControl; feature: AgentFeature;
options: ProviderOptions;
isFirst: boolean; isFirst: boolean;
disabled: boolean; disabled: boolean;
size: FieldControlSize; size: FieldControlSize;
@@ -422,75 +418,74 @@ function AgentProfileControlRow({
[isFirst], [isFirst],
); );
const handleToggle = useCallback( const handleToggle = useCallback(
(value: boolean) => model.setOption(control.key, value), (value: boolean) => model.setFeatureValue(feature.id, value),
[control.key, model], [feature.id, model],
); );
const handleSelect = useCallback( const handleSelect = useCallback(
(value: string) => model.setOption(control.key, value), (value: string) => model.setFeatureValue(feature.id, value),
[control.key, model], [feature.id, model],
); );
const selectOptions = useMemo<SelectFieldOption<string>[]>( const selectOptions = useMemo<SelectFieldOption<string>[]>(
() => () =>
control.type === "select" feature.type === "select"
? control.choices.map((choice) => ({ ? feature.options.map((option) => ({
id: choice.value, id: option.id,
value: choice.value, value: option.id,
label: choice.label, label: option.label,
...(choice.description ? { description: choice.description } : {}), ...(option.description ? { description: option.description } : {}),
testID: `agent-profile-feature-option-${control.key}-${choice.value}`, 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>(() => { const selectedDisplay = useMemo<SelectFieldDisplay | null>(() => {
if (control.type !== "select" || !selectedValue) { if (feature.type !== "select" || !feature.value) {
return null; return null;
} }
const choice = control.choices.find((candidate) => candidate.value === selectedValue); const option = feature.options.find((candidate) => candidate.id === feature.value);
return { label: choice?.label ?? selectedValue }; return { label: option?.label ?? feature.value };
}, [control, selectedValue]); }, [feature]);
if (control.type === "select") { if (feature.type === "select") {
return ( return (
<View style={rowStyle} testID={`agent-profile-feature-row-${control.key}`}> <View style={rowStyle} testID={`agent-profile-feature-row-${feature.id}`}>
<SelectField <SelectField
label={control.label} label={feature.label}
value={selectedValue} value={feature.value}
selectedDisplay={selectedDisplay} selectedDisplay={selectedDisplay}
options={selectOptions} options={selectOptions}
onChange={handleSelect} onChange={handleSelect}
placeholder={control.label} placeholder={feature.label}
emptyText={control.label} emptyText={feature.label}
disabled={disabled} disabled={disabled}
{...(control.description ? { hint: control.description } : {})} {...(feature.description ? { hint: feature.description } : {})}
title={control.label} title={feature.label}
size={size} size={size}
testID={`agent-profile-feature-field-${control.key}`} testID={`agent-profile-feature-field-${feature.id}`}
triggerTestID={`agent-profile-feature-trigger-${control.key}`} triggerTestID={`agent-profile-feature-trigger-${feature.id}`}
/> />
</View> </View>
); );
} }
return ( 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}> <View style={styles.featureMeta}>
<Text style={styles.featureLabel} numberOfLines={1}> <Text style={styles.featureLabel} numberOfLines={1}>
{control.label} {feature.label}
</Text> </Text>
{control.description ? ( {feature.description ? (
<Text style={styles.featureDescription} numberOfLines={2}> <Text style={styles.featureDescription} numberOfLines={2}>
{control.description} {feature.description}
</Text> </Text>
) : null} ) : null}
</View> </View>
<Switch <Switch
value={readToggleOption(control, options)} value={feature.value === true}
onValueChange={handleToggle} onValueChange={handleToggle}
disabled={disabled} disabled={disabled}
accessibilityLabel={control.label} accessibilityLabel={feature.label}
testID={`agent-profile-feature-switch-${control.key}`} testID={`agent-profile-feature-switch-${feature.id}`}
/> />
</View> </View>
); );
+1 -2
View File
@@ -1304,8 +1304,7 @@ function collectAgentSetupDiffs(left: AgentScreenAgent, right: AgentScreenAgent)
if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) { if (left.runtimeInfo?.thinkingOptionId !== right.runtimeInfo?.thinkingOptionId) {
reasons.push("agent.runtimeInfo.thinkingOptionId"); reasons.push("agent.runtimeInfo.thinkingOptionId");
} }
if (left.options !== right.options) reasons.push("agent.options"); if (left.features !== right.features) reasons.push("agent.features");
if (left.controls !== right.controls) reasons.push("agent.controls");
return reasons; return reasons;
} }
@@ -40,7 +40,16 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
cwd: "/repo", cwd: "/repo",
model: "agent-model", model: "agent-model",
thinkingOptionId: "think-hard", 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, parentAgentId: null,
labels: {}, labels: {},
...overrides, ...overrides,
@@ -105,7 +114,7 @@ describe("buildDraftAgentSetup", () => {
modeId: "mode-current", modeId: "mode-current",
model: "agent-model", model: "agent-model",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
providerOptions: { featureValues: {
"web-search": true, "web-search": true,
effort: "high", effort: "high",
}, },
@@ -65,12 +65,17 @@ export function resolveClientSlashCommand(input: {
} }
export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup { export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup {
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return { return {
provider: agent.provider, provider: agent.provider,
cwd: agent.cwd, cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null, model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
providerOptions: { ...agent.options }, featureValues,
}; };
} }
@@ -1,9 +1,5 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { import type { AgentFeature, AgentMode, AgentSelectOption } from "@getpaseo/protocol/agent-types";
AgentMode,
AgentOptionControl,
AgentSelectOption,
} from "@getpaseo/protocol/agent-types";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import type { CommandCenterContribution } from "./contributions"; import type { CommandCenterContribution } from "./contributions";
import { import {
@@ -35,7 +31,7 @@ const ICONS: AgentControlContributionSource["icons"] = {
thinking: TestIcon, thinking: TestIcon,
planMode: TestIcon, planMode: TestIcon,
mode: () => TestIcon, mode: () => TestIcon,
control: () => TestIcon, feature: () => TestIcon,
}; };
function provider(input: { function provider(input: {
@@ -69,8 +65,8 @@ function provider(input: {
}; };
} }
function toggleControl(key: string, icon?: string): AgentOptionControl { function toggleFeature(id: string, value = false, icon?: string): AgentFeature {
return { type: "toggle", key, label: key, icon }; return { type: "toggle", id, label: id, value, icon };
} }
function makeSource( function makeSource(
@@ -79,7 +75,7 @@ function makeSource(
models?: Partial<AgentControlContributionSource["models"]>; models?: Partial<AgentControlContributionSource["models"]>;
thinking?: Partial<AgentControlContributionSource["thinking"]>; thinking?: Partial<AgentControlContributionSource["thinking"]>;
modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>; modes?: Partial<NonNullable<AgentControlContributionSource["modes"]>>;
optionControls?: Partial<AgentControlContributionSource["optionControls"]>; features?: Partial<AgentControlContributionSource["features"]>;
} = {}, } = {},
): AgentControlContributionSource { ): AgentControlContributionSource {
return { return {
@@ -108,11 +104,10 @@ function makeSource(
select: () => undefined, select: () => undefined,
...overrides.modes, ...overrides.modes,
}, },
optionControls: { features: {
controls: [], list: [],
options: {}, set: () => undefined,
onSetOption: () => undefined, ...overrides.features,
...overrides.optionControls,
}, },
}; };
} }
@@ -138,7 +133,7 @@ describe("Command Center agent-control contributions", () => {
const contributions = buildAgentControlContributions({ const contributions = buildAgentControlContributions({
...makeSource({ ...makeSource({
models: { providers: [provider({ id: "claude", label: "Claude" })] }, models: { providers: [provider({ id: "claude", label: "Claude" })] },
optionControls: { controls: [toggleControl("plan_mode")], options: { plan_mode: true } }, features: { list: [toggleFeature("plan_mode", true)] },
}), }),
modes: undefined, modes: undefined,
}); });
@@ -328,10 +323,9 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default", selectedId: "default",
defaultModeId: "default", defaultModeId: "default",
}, },
optionControls: { features: {
controls: [toggleControl("plan_mode", "list-todo")], list: [toggleFeature("plan_mode", true, "list-todo")],
options: { plan_mode: true }, set: (id, value) => {
onSetOption: (id, value) => {
calls.push([id, value]); calls.push([id, value]);
}, },
}, },
@@ -347,10 +341,9 @@ describe("Command Center agent-control contributions", () => {
const calls: [string, unknown][] = []; const calls: [string, unknown][] = [];
const contributions = buildAgentControlContributions( const contributions = buildAgentControlContributions(
makeSource({ makeSource({
optionControls: { features: {
controls: [toggleControl("fast_mode", "zap"), toggleControl("auto_accept")], list: [toggleFeature("fast_mode", false, "zap"), toggleFeature("auto_accept", true)],
options: { fast_mode: false, auto_accept: true }, set: (id, value) => {
onSetOption: (id, value) => {
calls.push([id, value]); calls.push([id, value]);
}, },
}, },
@@ -383,7 +376,7 @@ describe("Command Center agent-control contributions", () => {
selectedId: "default", selectedId: "default",
defaultModeId: "default", defaultModeId: "default",
}, },
optionControls: { controls: [toggleControl("fast_mode")], options: {} }, features: { list: [toggleFeature("fast_mode")] },
}), }),
); );
const ranks = new Map<string, number>(); const ranks = new Map<string, number>();
@@ -1,10 +1,9 @@
import type { import type {
AgentFeature,
AgentMode, AgentMode,
AgentOptionControl,
AgentProvider, AgentProvider,
AgentSelectOption, AgentSelectOption,
} from "@getpaseo/protocol/agent-types"; } from "@getpaseo/protocol/agent-types";
import { readToggleOption, type ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels"; import { formatAgentModeLabel, formatThinkingOptionLabel } from "@/agent-controls/labels";
import { import {
@@ -62,7 +61,7 @@ export interface AgentControlContributionIcons {
thinking: CommandCenterIcon; thinking: CommandCenterIcon;
planMode: CommandCenterIcon; planMode: CommandCenterIcon;
mode(modeId: string): CommandCenterIcon; mode(modeId: string): CommandCenterIcon;
control(control: AgentOptionControl): CommandCenterIcon; feature(feature: AgentFeature): CommandCenterIcon;
} }
interface CommandCenterChoice { interface CommandCenterChoice {
@@ -106,7 +105,10 @@ export interface AgentControlContributionSource {
defaultModeId: string | null; defaultModeId: string | null;
select(modeId: string): void | Promise<void>; select(modeId: string): void | Promise<void>;
}; };
optionControls: ComposerOptionControls; features: {
list: readonly AgentFeature[];
set(featureId: string, value: unknown): void | Promise<void>;
};
} }
function buildContributions( function buildContributions(
@@ -240,19 +242,18 @@ function toggleChoices(input: {
} }
function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { function buildPlanModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls; const planFeature = source.features.list.find(
const planControl = controls.find( (feature) => feature.id === PLAN_MODE_FEATURE_ID && feature.type === "toggle",
(control) => control.key === PLAN_MODE_FEATURE_ID && control.type === "toggle",
); );
let choices: CommandCenterChoice[] = []; let choices: CommandCenterChoice[] = [];
if (planControl?.type === "toggle") { if (planFeature?.type === "toggle") {
choices = toggleChoices({ choices = toggleChoices({
source, source,
key: "plan", key: "plan",
icon: source.icons.control(planControl), icon: source.icons.feature(planFeature),
isOn: readToggleOption(planControl, options), isOn: planFeature.value,
turnOn: () => onSetOption(PLAN_MODE_FEATURE_ID, true), turnOn: () => source.features.set(PLAN_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(PLAN_MODE_FEATURE_ID, false), turnOff: () => source.features.set(PLAN_MODE_FEATURE_ID, false),
}); });
} else if (source.modes) { } else if (source.modes) {
const modes = source.modes; const modes = source.modes;
@@ -279,19 +280,18 @@ function buildPlanModeGroup(source: AgentControlContributionSource): CommandCent
} }
function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup { function buildFastModeGroup(source: AgentControlContributionSource): CommandCenterChoiceGroup {
const { controls, options, onSetOption } = source.optionControls; const fastFeature = source.features.list.find(
const fastControl = controls.find( (feature) => feature.id === FAST_MODE_FEATURE_ID && feature.type === "toggle",
(control) => control.key === FAST_MODE_FEATURE_ID && control.type === "toggle",
); );
const choices = const choices =
fastControl?.type === "toggle" fastFeature?.type === "toggle"
? toggleChoices({ ? toggleChoices({
source, source,
key: "fast", key: "fast",
icon: source.icons.control(fastControl), icon: source.icons.feature(fastFeature),
isOn: readToggleOption(fastControl, options), isOn: fastFeature.value,
turnOn: () => onSetOption(FAST_MODE_FEATURE_ID, true), turnOn: () => source.features.set(FAST_MODE_FEATURE_ID, true),
turnOff: () => onSetOption(FAST_MODE_FEATURE_ID, false), turnOff: () => source.features.set(FAST_MODE_FEATURE_ID, false),
}) })
: []; : [];
return { return {
@@ -1,6 +1,11 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useTranslation } from "react-i18next"; 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 type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { import {
getAgentFeatureIcon, getAgentFeatureIcon,
@@ -9,7 +14,6 @@ import {
ThinkingIcon, ThinkingIcon,
} from "@/agent-controls/icons"; } from "@/agent-controls/icons";
import { useProviderIcons } from "@/components/provider-icons"; import { useProviderIcons } from "@/components/provider-icons";
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection"; import type { ProviderSelectorProvider } from "@/provider-selection/provider-selection";
import { import {
buildAgentControlContributions, buildAgentControlContributions,
@@ -40,15 +44,15 @@ export interface AgentControlCommandCenterSource {
selectedId: string | null | undefined; selectedId: string | null | undefined;
select(modeId: string): void | Promise<void>; select(modeId: string): void | Promise<void>;
}; };
/** Absent when the surface cannot change options (the agent has no client). */ features: {
optionControls: ComposerOptionControls | null; list: readonly AgentFeature[] | undefined;
set?: (featureId: string, value: unknown) => void | Promise<void>;
};
} }
const NO_OPTION_CONTROLS: ComposerOptionControls = { function ignoreAgentFeatureChange(): void {
controls: [], return;
options: {}, }
onSetOption: () => undefined,
};
function resolveDefaultModeId( function resolveDefaultModeId(
provider: string | null | undefined, provider: string | null | undefined,
@@ -83,7 +87,7 @@ export function useAgentControlCommandCenterActions(input: {
}): void { }): void {
const { t } = useTranslation(); const { t } = useTranslation();
const { controls } = input; const { controls } = input;
const { optionControls, models, modes, thinking } = controls; const { features, models, modes, thinking } = controls;
const getProviderIcon = useProviderIcons(controls.serverId); const getProviderIcon = useProviderIcons(controls.serverId);
const actions = useMemo( const actions = useMemo(
() => () =>
@@ -100,7 +104,7 @@ export function useAgentControlCommandCenterActions(input: {
getCommandCenterIcon( getCommandCenterIcon(
getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions), getAgentModeIcon(controls.provider ?? "", modeId, controls.providerDefinitions),
), ),
control: (control) => getCommandCenterIcon(getAgentFeatureIcon(control.icon)), feature: (feature) => getCommandCenterIcon(getAgentFeatureIcon(feature.icon)),
}, },
models: { models: {
providers: models.providers, providers: models.providers,
@@ -119,7 +123,10 @@ export function useAgentControlCommandCenterActions(input: {
modes?.select, modes?.select,
resolveDefaultModeId(controls.provider, controls.providerDefinitions), resolveDefaultModeId(controls.provider, controls.providerDefinitions),
), ),
optionControls: optionControls ?? NO_OPTION_CONTROLS, features: {
list: features.set ? (features.list ?? []) : [],
set: features.set ?? ignoreAgentFeatureChange,
},
}), }),
[ [
getProviderIcon, getProviderIcon,
@@ -127,7 +134,8 @@ export function useAgentControlCommandCenterActions(input: {
controls.provider, controls.provider,
controls.providerDefinitions, controls.providerDefinitions,
controls.serverId, controls.serverId,
optionControls, features.list,
features.set,
models.providers, models.providers,
models.select, models.select,
models.selectedModelId, models.selectedModelId,
+224 -211
View File
@@ -25,13 +25,6 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useShallow } from "zustand/shallow"; import { useShallow } from "zustand/shallow";
import { Settings2 } from "lucide-react-native"; import { Settings2 } from "lucide-react-native";
import { getAgentFeatureIcon, ThinkingIcon } from "@/agent-controls/icons"; 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 { formatThinkingOptionLabel } from "@/agent-controls/labels";
import { ComboboxTrigger } from "@/components/ui/combobox-trigger"; import { ComboboxTrigger } from "@/components/ui/combobox-trigger";
import { CombinedModelSelector } from "@/components/combined-model-selector"; import { CombinedModelSelector } from "@/components/combined-model-selector";
@@ -54,8 +47,7 @@ import {
import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet"; import { AdaptiveModalSheet, type SheetHeader } from "@/components/adaptive-modal-sheet";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import type { import type {
AgentOptionControl, AgentFeature,
ProviderOptions,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentProvider, AgentProvider,
@@ -63,6 +55,8 @@ import type {
import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest"; import type { AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
import { import {
getFeatureHighlightColor, getFeatureHighlightColor,
isFeatureActive,
getFeatureTooltip,
getAgentControlHintKey, getAgentControlHintKey,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "@/composer/agent-controls/utils"; } from "@/composer/agent-controls/utils";
@@ -107,7 +101,6 @@ interface AgentControlOption {
type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`; type AgentControlSelector = "provider" | "mode" | "model" | "thinking" | `feature-${string}`;
const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = []; const EMPTY_AGENT_PROVIDER_DEFINITIONS: AgentProviderDefinition[] = [];
const EMPTY_OPTIONS: ProviderOptions = {};
interface ControlledAgentControlsProps { interface ControlledAgentControlsProps {
provider: string; provider: string;
@@ -129,7 +122,8 @@ interface ControlledAgentControlsProps {
onEditAgentProfiles?: () => void; onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
onEditAgentProfile?: (profileId: string) => void; onEditAgentProfile?: (profileId: string) => void;
optionControls?: ComposerOptionControls; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void; onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void; onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -156,7 +150,8 @@ export interface DraftAgentControlsProps {
selectedThinkingOptionId: string; selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void; onSelectThinkingOption: (thinkingOptionId: string) => void;
onApplyAgentProfile: DraftAgentProfileControls["applyProfile"]; onApplyAgentProfile: DraftAgentProfileControls["applyProfile"];
optionControls?: ComposerOptionControls; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onDropdownClose?: () => void; onDropdownClose?: () => void;
onModelSelectorOpen?: () => void; onModelSelectorOpen?: () => void;
onRetryModelProvider?: (provider: AgentProvider) => void; onRetryModelProvider?: (provider: AgentProvider) => void;
@@ -217,8 +212,7 @@ function getModeProviderDefinitions(modeControl: AgentModeControlValue | null) {
} }
function getFeatureIconColor( function getFeatureIconColor(
control: AgentOptionControl, feature: AgentFeature,
options: ProviderOptions,
palette: { palette: {
blue: { 400: string }; blue: { 400: string };
green: { 400: string }; green: { 400: string };
@@ -226,11 +220,11 @@ function getFeatureIconColor(
}, },
foregroundMuted: string, foregroundMuted: string,
): string { ): string {
if (!isControlActive(control, options)) { if (!isFeatureActive(feature)) {
return foregroundMuted; return foregroundMuted;
} }
switch (getFeatureHighlightColor(control.key)) { switch (getFeatureHighlightColor(feature.id)) {
case "blue": case "blue":
return palette.blue[400]; return palette.blue[400];
case "green": case "green":
@@ -248,20 +242,20 @@ function resolveHasAnyControl({
providerOptions, providerOptions,
canSelectModel, canSelectModel,
thinkingOptions, thinkingOptions,
controls, features,
hasMode, hasMode,
}: { }: {
providerOptions: AgentControlOption[] | undefined; providerOptions: AgentControlOption[] | undefined;
canSelectModel: boolean; canSelectModel: boolean;
thinkingOptions: AgentControlOption[] | undefined; thinkingOptions: AgentControlOption[] | undefined;
controls: readonly AgentOptionControl[] | undefined; features: AgentFeature[] | undefined;
hasMode: boolean; hasMode: boolean;
}) { }) {
return ( return (
Boolean(providerOptions?.length) || Boolean(providerOptions?.length) ||
canSelectModel || canSelectModel ||
Boolean(thinkingOptions?.length) || Boolean(thinkingOptions?.length) ||
Boolean(controls?.length) || Boolean(features?.length) ||
hasMode hasMode
); );
} }
@@ -399,8 +393,7 @@ type AgentControlsSlice = {
cwd: string | null; cwd: string | null;
runtimeModelId: string | null; runtimeModelId: string | null;
model: string | null | undefined; model: string | null | undefined;
options: ProviderOptions | undefined; features: AgentFeature[] | undefined;
controls: AgentOptionControl[] | undefined;
runtimeThinkingOptionId: string | null; runtimeThinkingOptionId: string | null;
thinkingOptionId: string | null | undefined; thinkingOptionId: string | null | undefined;
lastUsage: unknown; lastUsage: unknown;
@@ -420,8 +413,7 @@ function selectAgentControlsSlice(
cwd: currentAgent.cwd, cwd: currentAgent.cwd,
runtimeModelId: currentAgent.runtimeInfo?.model ?? null, runtimeModelId: currentAgent.runtimeInfo?.model ?? null,
model: currentAgent.model, model: currentAgent.model,
options: currentAgent.options, features: currentAgent.features,
controls: currentAgent.controls,
runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null, runtimeThinkingOptionId: currentAgent.runtimeInfo?.thinkingOptionId ?? null,
thinkingOptionId: currentAgent.thinkingOptionId, thinkingOptionId: currentAgent.thinkingOptionId,
lastUsage: currentAgent.lastUsage, lastUsage: currentAgent.lastUsage,
@@ -501,7 +493,8 @@ function ControlledAgentControls({
onEditAgentProfiles, onEditAgentProfiles,
onCreateAgentProfile, onCreateAgentProfile,
onEditAgentProfile, onEditAgentProfile,
optionControls, features,
onSetFeature,
onDropdownClose, onDropdownClose,
onModelSelectorOpen, onModelSelectorOpen,
onRetryModelProvider, onRetryModelProvider,
@@ -553,21 +546,20 @@ function ControlledAgentControls({
providerOptions, providerOptions,
canSelectModel, canSelectModel,
thinkingOptions, thinkingOptions,
controls: optionControls?.controls, features,
hasMode: modeControl !== null && modeControl !== undefined, hasMode: modeControl !== null && modeControl !== undefined,
}); });
const featureControls = useMemo( const featureControls = useMemo(
() => () =>
(optionControls?.controls ?? []).map((control) => { (features ?? []).map((feature) => {
if (control.type === "toggle") return { type: "toggle" as const }; if (feature.type === "toggle") return { type: "toggle" as const };
const selected = readSelectOption(control, optionControls?.options ?? {}); const selectedOption = feature.options.find((option) => option.id === feature.value);
const choice = control.choices.find((candidate) => candidate.value === selected);
return { return {
type: "select" as const, type: "select" as const,
label: choice?.label ?? control.label, label: selectedOption?.label ?? feature.label,
}; };
}), }),
[optionControls], [features],
); );
const controlPresence = useMemo( const controlPresence = useMemo(
() => ({ () => ({
@@ -751,7 +743,8 @@ function ControlledAgentControls({
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
thinkingOptions={formattedThinkingOptions} thinkingOptions={formattedThinkingOptions}
selectedThinkingOptionId={selectedThinkingOptionId} selectedThinkingOptionId={selectedThinkingOptionId}
optionControls={optionControls} features={features}
onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile} onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles} onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile} onCreateAgentProfile={onCreateAgentProfile}
@@ -799,7 +792,8 @@ function ControlledAgentControls({
provider={provider} provider={provider}
selectedModelId={selectedModelId} selectedModelId={selectedModelId}
selectedThinkingOptionId={selectedThinkingOptionId} selectedThinkingOptionId={selectedThinkingOptionId}
optionControls={optionControls} features={features}
onSetFeature={onSetFeature}
onApplyAgentProfile={onApplyAgentProfile} onApplyAgentProfile={onApplyAgentProfile}
onEditAgentProfiles={onEditAgentProfiles} onEditAgentProfiles={onEditAgentProfiles}
onCreateAgentProfile={onCreateAgentProfile} onCreateAgentProfile={onCreateAgentProfile}
@@ -844,7 +838,8 @@ interface DesktopAgentControlsContentProps {
selectedModelId?: string; selectedModelId?: string;
thinkingOptions?: AgentControlOption[]; thinkingOptions?: AgentControlOption[];
selectedThinkingOptionId?: string; selectedThinkingOptionId?: string;
optionControls?: ComposerOptionControls; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void; onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void; onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
@@ -905,7 +900,8 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
selectedModelId, selectedModelId,
thinkingOptions, thinkingOptions,
selectedThinkingOptionId, selectedThinkingOptionId,
optionControls, features,
onSetFeature,
onApplyAgentProfile, onApplyAgentProfile,
onEditAgentProfiles, onEditAgentProfiles,
onCreateAgentProfile, onCreateAgentProfile,
@@ -1061,7 +1057,7 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
{modeControl ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null} {modeControl ? <AgentModeControl {...modeControl} onClose={onDropdownClose} /> : null}
{presentation.aggregateFeatures && optionControls?.controls.length ? ( {presentation.aggregateFeatures && features?.length ? (
<> <>
<Pressable <Pressable
onPress={handleOpenFeatures} onPress={handleOpenFeatures}
@@ -1081,29 +1077,27 @@ function DesktopAgentControlsContent(props: DesktopAgentControlsContentProps) {
onClose={handleCloseSheet} onClose={handleCloseSheet}
testID="agent-features-sheet" testID="agent-features-sheet"
> >
{optionControls.controls.map((control) => ( {features.map((feature) => (
<SheetFeatureItem <SheetFeatureItem
key={`feature-${control.key}`} key={`feature-${feature.id}`}
control={control} feature={feature}
options={optionControls.options}
disabled={disabled} disabled={disabled}
openSelector={openSelector} openSelector={openSelector}
handleOpenChange={handleNestedOpenChange} handleOpenChange={handleNestedOpenChange}
onSetOption={optionControls.onSetOption} onSetFeature={onSetFeature}
/> />
))} ))}
</AdaptiveModalSheet> </AdaptiveModalSheet>
</> </>
) : ( ) : (
optionControls?.controls.map((control) => ( features?.map((feature) => (
<DesktopFeatureItem <DesktopFeatureItem
key={`feature-${control.key}`} key={`feature-${feature.id}`}
control={control} feature={feature}
options={optionControls.options}
disabled={disabled} disabled={disabled}
openSelector={openSelector} openSelector={openSelector}
handleOpenChange={handleOpenChange} handleOpenChange={handleOpenChange}
onSetOption={optionControls.onSetOption} onSetFeature={onSetFeature}
onActionComplete={onDropdownClose} onActionComplete={onDropdownClose}
/> />
)) ))
@@ -1116,7 +1110,8 @@ interface SheetAgentControlsContentProps {
provider: string; provider: string;
selectedModelId?: string; selectedModelId?: string;
selectedThinkingOptionId?: string; selectedThinkingOptionId?: string;
optionControls?: ComposerOptionControls; features?: AgentFeature[];
onSetFeature?: (featureId: string, value: unknown) => void;
onApplyAgentProfile?: (profileId: string) => void; onApplyAgentProfile?: (profileId: string) => void;
onEditAgentProfiles?: () => void; onEditAgentProfiles?: () => void;
onCreateAgentProfile?: (seed: AgentProfileSeed) => void; onCreateAgentProfile?: (seed: AgentProfileSeed) => void;
@@ -1159,7 +1154,8 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
provider, provider,
selectedModelId, selectedModelId,
selectedThinkingOptionId, selectedThinkingOptionId,
optionControls, features,
onSetFeature,
onApplyAgentProfile, onApplyAgentProfile,
onEditAgentProfiles, onEditAgentProfiles,
onCreateAgentProfile, onCreateAgentProfile,
@@ -1192,7 +1188,6 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
} = props; } = props;
const thinkingAnchorRef = useRef<View | null>(null); const thinkingAnchorRef = useRef<View | null>(null);
const sheetOptions = optionControls?.options ?? EMPTY_OPTIONS;
const hasThinking = comboboxThinkingOptions.length > 0; const hasThinking = comboboxThinkingOptions.length > 0;
@@ -1243,15 +1238,14 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
{modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null} {modeControl ? <AgentModeControl {...modeControl} surface="sheet" /> : null}
{(optionControls?.controls ?? []).map((control) => ( {(features ?? []).map((feature) => (
<SheetFeatureItem <SheetFeatureItem
key={`feature-${control.key}`} key={`feature-${feature.id}`}
control={control} feature={feature}
options={sheetOptions}
disabled={disabled} disabled={disabled}
openSelector={openSelector} openSelector={openSelector}
handleOpenChange={handleOpenChange} handleOpenChange={handleOpenChange}
onSetOption={optionControls?.onSetOption} onSetFeature={onSetFeature}
/> />
))} ))}
</View> </View>
@@ -1285,24 +1279,22 @@ function SheetAgentControlsContent(props: SheetAgentControlsContentProps) {
} }
function DesktopFeatureItem({ function DesktopFeatureItem({
control, feature,
options,
disabled, disabled,
openSelector, openSelector,
handleOpenChange, handleOpenChange,
onSetOption, onSetFeature,
onActionComplete, onActionComplete,
}: { }: {
control: AgentOptionControl; feature: AgentFeature;
options: ProviderOptions;
disabled: boolean; disabled: boolean;
openSelector: AgentControlSelector | null; openSelector: AgentControlSelector | null;
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
onSetOption?: (key: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
onActionComplete?: () => void; onActionComplete?: () => void;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const featureSelector: AgentControlSelector = `feature-${control.key}`; const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null); const featureAnchorRef = useRef<View>(null);
const handleFeatureOpenChange = useMemo( const handleFeatureOpenChange = useMemo(
@@ -1315,117 +1307,120 @@ function DesktopFeatureItem({
); );
const handleTogglePress = useCallback(() => { const handleTogglePress = useCallback(() => {
if (control.type === "toggle") { if (feature.type === "toggle") {
onSetOption?.(control.key, !readToggleOption(control, options)); onSetFeature?.(feature.id, !feature.value);
onActionComplete?.(); onActionComplete?.();
} }
}, [control, onActionComplete, onSetOption, options]); }, [feature, onActionComplete, onSetFeature]);
const handleSelectOption = useCallback( const handleSelectOption = useCallback(
(choiceValue: string) => { (optionId: string) => {
onSetOption?.(control.key, choiceValue); onSetFeature?.(feature.id, optionId);
}, },
[control.key, onSetOption], [feature.id, onSetFeature],
); );
const comboboxOptions = useMemo<ComboboxOption[]>( const comboboxOptions = useMemo<ComboboxOption[]>(
() => () =>
control.type === "select" feature.type === "select"
? control.choices.map((choice) => ({ id: choice.value, label: choice.label })) ? feature.options.map((option) => ({ id: option.id, label: option.label }))
: [], : [],
[control], [feature],
); );
const FeatureIcon = getAgentFeatureIcon(control.icon); if (feature.type === "toggle") {
const iconColor = getFeatureIconColor( const FeatureIcon = getAgentFeatureIcon(feature.icon);
control,
options,
theme.colors.palette,
theme.colors.foregroundMuted,
);
if (control.type === "toggle") {
return ( return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger asChild triggerRefProp="ref"> <TooltipTrigger asChild triggerRefProp="ref">
<AgentControlTrigger <AgentControlTrigger
icon={FeatureIcon} icon={FeatureIcon}
iconColor={iconColor} iconColor={getFeatureIconColor(
feature,
theme.colors.palette,
theme.colors.foregroundMuted,
)}
surface="toolbar" surface="toolbar"
label={control.label} label={feature.label}
showToolbarLabel={false} showToolbarLabel={false}
disabled={disabled} disabled={disabled}
onPress={handleTogglePress} onPress={handleTogglePress}
accessibilityLabel={getControlTooltip(control)} accessibilityLabel={getFeatureTooltip(feature)}
testID={`agent-feature-${control.key}`} testID={`agent-feature-${feature.id}`}
/> />
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" align="center" offset={8}> <TooltipContent side="top" align="center" offset={8}>
<Text style={styles.tooltipText}>{getControlTooltip(control)}</Text> <Text style={styles.tooltipText}>{getFeatureTooltip(feature)}</Text>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );
} }
const selectedValue = readSelectOption(control, options); if (feature.type === "select") {
const selectedChoice = control.choices.find((choice) => choice.value === selectedValue); const FeatureIcon = getAgentFeatureIcon(feature.icon);
const iconOnly = control.desktopTrigger === "icon"; const selectedOption = feature.options.find((o) => o.id === feature.value);
const tooltip = iconOnly const iconOnly = feature.desktopTrigger === "icon";
? `${control.label}: ${selectedChoice?.label ?? control.label}` const tooltip = iconOnly
: getControlTooltip(control); ? `${feature.label}: ${selectedOption?.label ?? feature.label}`
return ( : getFeatureTooltip(feature);
<> return (
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}> <>
<TooltipTrigger asChild triggerRefProp="ref"> <Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<AgentControlTrigger <TooltipTrigger asChild triggerRefProp="ref">
ref={featureAnchorRef} <AgentControlTrigger
icon={FeatureIcon} ref={featureAnchorRef}
iconColor={iconColor} icon={FeatureIcon}
surface="toolbar" iconColor={getFeatureIconColor(
label={control.label} feature,
value={selectedChoice?.label ?? control.label} theme.colors.palette,
showToolbarLabel={!iconOnly} theme.colors.foregroundMuted,
open={openSelector === featureSelector} )}
disabled={disabled} surface="toolbar"
onPress={handleSelectPress} label={feature.label}
accessibilityLabel={tooltip} value={selectedOption?.label ?? feature.label}
testID={`agent-feature-${control.key}`} showToolbarLabel={!iconOnly}
/> open={openSelector === featureSelector}
</TooltipTrigger> disabled={disabled}
<TooltipContent side="top" align="center" offset={8}> onPress={handleSelectPress}
<Text style={styles.tooltipText}>{tooltip}</Text> accessibilityLabel={tooltip}
</TooltipContent> testID={`agent-feature-${feature.id}`}
</Tooltip> />
<Combobox </TooltipTrigger>
options={comboboxOptions} <TooltipContent side="top" align="center" offset={8}>
value={String(selectedValue)} <Text style={styles.tooltipText}>{tooltip}</Text>
onSelect={handleSelectOption} </TooltipContent>
open={openSelector === featureSelector} </Tooltip>
onOpenChange={handleFeatureOpenChange} <Combobox
anchorRef={featureAnchorRef} options={comboboxOptions}
desktopPlacement="top-start" value={String(feature.value)}
/> onSelect={handleSelectOption}
</> open={openSelector === featureSelector}
); onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
desktopPlacement="top-start"
/>
</>
);
}
return null;
} }
function SheetFeatureItem({ function SheetFeatureItem({
control, feature,
options,
disabled, disabled,
openSelector, openSelector,
handleOpenChange, handleOpenChange,
onSetOption, onSetFeature,
}: { }: {
control: AgentOptionControl; feature: AgentFeature;
options: ProviderOptions;
disabled: boolean; disabled: boolean;
openSelector: AgentControlSelector | null; openSelector: AgentControlSelector | null;
handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void; handleOpenChange: (selector: AgentControlSelector) => (nextOpen: boolean) => void;
onSetOption?: (key: string, value: unknown) => void; onSetFeature?: (featureId: string, value: unknown) => void;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const { t } = useTranslation(); const { t } = useTranslation();
const featureSelector: AgentControlSelector = `feature-${control.key}`; const featureSelector: AgentControlSelector = `feature-${feature.id}`;
const featureAnchorRef = useRef<View>(null); const featureAnchorRef = useRef<View>(null);
const handleFeatureOpenChange = useMemo( const handleFeatureOpenChange = useMemo(
@@ -1436,71 +1431,97 @@ function SheetFeatureItem({
() => handleFeatureOpenChange(openSelector !== featureSelector), () => handleFeatureOpenChange(openSelector !== featureSelector),
[featureSelector, handleFeatureOpenChange, openSelector], [featureSelector, handleFeatureOpenChange, openSelector],
); );
const sheetHeader = useMemo<SheetHeader>(() => ({ title: control.label }), [control.label]); const sheetHeader = useMemo<SheetHeader>(() => ({ title: feature.label }), [feature.label]);
const handleSelectOption = useCallback( const handleSelectOption = useCallback(
(choiceValue: string) => { (optionId: string) => {
onSetOption?.(control.key, control.type === "toggle" ? choiceValue === "true" : choiceValue); onSetFeature?.(feature.id, feature.type === "toggle" ? optionId === "true" : optionId);
}, },
[control.key, control.type, onSetOption], [feature.id, feature.type, onSetFeature],
); );
const comboboxOptions = useMemo<ComboboxOption[]>(() => { const comboboxOptions = useMemo<ComboboxOption[]>(() => {
if (control.type === "select") { if (feature.type === "select") {
return control.choices.map((choice) => ({ id: choice.value, label: choice.label })); return feature.options.map((option) => ({ id: option.id, label: option.label }));
} }
return [ return [
{ id: "true", label: t("agentControls.features.on") }, { id: "true", label: t("agentControls.features.on") },
{ id: "false", label: t("agentControls.features.off") }, { id: "false", label: t("agentControls.features.off") },
]; ];
}, [control, t]); }, [feature, t]);
const FeatureIcon = getAgentFeatureIcon(control.icon); if (feature.type === "toggle") {
const iconColor = getFeatureIconColor( const FeatureIcon = getAgentFeatureIcon(feature.icon);
control, return (
options, <>
theme.colors.palette, <AgentControlTrigger
theme.colors.foregroundMuted, ref={featureAnchorRef}
); icon={FeatureIcon}
let selectedValue: string; iconColor={getFeatureIconColor(
let displayValue: string; feature,
if (control.type === "toggle") { theme.colors.palette,
const isOn = readToggleOption(control, options); theme.colors.foregroundMuted,
selectedValue = String(isOn); )}
displayValue = isOn ? t("agentControls.features.on") : t("agentControls.features.off"); surface="sheet"
} else { label={feature.label}
const selected = readSelectOption(control, options); value={feature.value ? t("agentControls.features.on") : t("agentControls.features.off")}
selectedValue = String(selected); open={openSelector === featureSelector}
displayValue = disabled={disabled}
control.choices.find((choice) => choice.value === selected)?.label ?? control.label; 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 (
<> if (feature.type === "select") {
<AgentControlTrigger const FeatureIcon = getAgentFeatureIcon(feature.icon);
ref={featureAnchorRef} const selectedOption = feature.options.find((o) => o.id === feature.value);
icon={FeatureIcon} return (
iconColor={iconColor} <>
surface="sheet" <AgentControlTrigger
label={control.label} ref={featureAnchorRef}
value={displayValue} icon={FeatureIcon}
open={openSelector === featureSelector} iconColor={getFeatureIconColor(
disabled={disabled} feature,
onPress={handleSelectPress} theme.colors.palette,
accessibilityLabel={getControlTooltip(control)} theme.colors.foregroundMuted,
testID={`agent-feature-${control.key}`} )}
/> surface="sheet"
<Combobox label={feature.label}
options={comboboxOptions} value={selectedOption?.label ?? feature.label}
value={selectedValue} open={openSelector === featureSelector}
onSelect={handleSelectOption} disabled={disabled}
open={openSelector === featureSelector} onPress={handleSelectPress}
onOpenChange={handleFeatureOpenChange} accessibilityLabel={getFeatureTooltip(feature)}
anchorRef={featureAnchorRef} testID={`agent-feature-${feature.id}`}
presentation="push" />
searchable={control.type === "toggle" ? false : undefined} <Combobox
header={sheetHeader} options={comboboxOptions}
/> value={String(feature.value)}
</> onSelect={handleSelectOption}
); open={openSelector === featureSelector}
onOpenChange={handleFeatureOpenChange}
anchorRef={featureAnchorRef}
presentation="push"
header={sheetHeader}
/>
</>
);
}
return null;
} }
function ThinkingComboboxOption({ 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({ export const AgentControls = memo(function AgentControls({
agentId, agentId,
serverId, serverId,
@@ -1691,8 +1700,8 @@ export const AgentControls = memo(function AgentControls({
[activeModelId, agentId, agentProvider, client, toast, updatePreferences], [activeModelId, agentId, agentProvider, client, toast, updatePreferences],
); );
const handleSetOption = useCallback( const handleSetFeature = useCallback(
(key: string, value: unknown) => { (featureId: string, value: unknown) => {
if (!client || !agentProvider) { if (!client || !agentProvider) {
return; return;
} }
@@ -1701,24 +1710,22 @@ export const AgentControls = memo(function AgentControls({
preferences: current, preferences: current,
provider: agentProvider, provider: agentProvider,
updates: { updates: {
options: { featureValues: {
[key]: value, [featureId]: value,
}, },
}, },
}), }),
).catch((error) => { ).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) => { void client.setAgentFeature(agentId, featureId, value).catch((error) => {
console.warn("[AgentControls] setAgentOptions failed", error); console.warn("[AgentControls] setAgentFeature failed", error);
toast.error(toErrorMessage(error)); toast.error(toErrorMessage(error));
}); });
}, },
[agentId, agentProvider, client, toast, updatePreferences], [agentId, agentProvider, client, toast, updatePreferences],
); );
const agentOptionControls = useAgentOptionControls(agent, handleSetOption);
const commandCenterControls = useMemo<AgentControlCommandCenterSource>( const commandCenterControls = useMemo<AgentControlCommandCenterSource>(
() => ({ () => ({
serverId, serverId,
@@ -1737,18 +1744,21 @@ export const AgentControls = memo(function AgentControls({
select: handleSelectThinkingOption, select: handleSelectThinkingOption,
}, },
modes: commandCenterModes, modes: commandCenterModes,
optionControls: client ? agentOptionControls : null, features: {
list: agent?.features,
set: handleSetFeature,
},
}), }),
[ [
activeModelId, activeModelId,
agentOptionControls, agent?.features,
client,
agentId, agentId,
agentModelSelectorProviders, agentModelSelectorProviders,
agentProvider, agentProvider,
commandCenterModes, commandCenterModes,
handleSelectCommandCenterModel, handleSelectCommandCenterModel,
handleSelectThinkingOption, handleSelectThinkingOption,
handleSetFeature,
modeProviderDefinitions, modeProviderDefinitions,
modelSelection.selectedThinkingId, modelSelection.selectedThinkingId,
modelSelection.thinkingOptions, modelSelection.thinkingOptions,
@@ -1797,7 +1807,8 @@ export const AgentControls = memo(function AgentControls({
thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined} thinkingOptions={thinkingOptions.length > 0 ? thinkingOptions : undefined}
selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined} selectedThinkingOptionId={modelSelection.selectedThinkingId ?? undefined}
onSelectThinkingOption={handleSelectThinkingOption} onSelectThinkingOption={handleSelectThinkingOption}
optionControls={agentOptionControls} features={agent.features}
onSetFeature={handleSetFeature}
isModelLoading={snapshotIsLoading || selectedProviderIsLoading} isModelLoading={snapshotIsLoading || selectedProviderIsLoading}
onModelSelectorOpen={handleModelSelectorOpen} onModelSelectorOpen={handleModelSelectorOpen}
onRetryModelProvider={handleRetryModelProvider} onRetryModelProvider={handleRetryModelProvider}
@@ -1829,7 +1840,8 @@ export function DraftAgentControls({
selectedThinkingOptionId, selectedThinkingOptionId,
onSelectThinkingOption, onSelectThinkingOption,
onApplyAgentProfile, onApplyAgentProfile,
optionControls, features,
onSetFeature,
onDropdownClose, onDropdownClose,
onModelSelectorOpen, onModelSelectorOpen,
onRetryModelProvider, onRetryModelProvider,
@@ -1915,7 +1927,8 @@ export function DraftAgentControls({
thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined} thinkingOptions={mappedThinkingOptions.length > 0 ? mappedThinkingOptions : undefined}
selectedThinkingOptionId={effectiveSelectedThinkingOption} selectedThinkingOptionId={effectiveSelectedThinkingOption}
onSelectThinkingOption={onSelectThinkingOption} onSelectThinkingOption={onSelectThinkingOption}
optionControls={optionControls} features={features}
onSetFeature={onSetFeature}
onDropdownClose={onDropdownClose} onDropdownClose={onDropdownClose}
onModelSelectorOpen={onModelSelectorOpen} onModelSelectorOpen={onModelSelectorOpen}
onRetryModelProvider={onRetryModelProvider} onRetryModelProvider={onRetryModelProvider}
@@ -1,7 +1,9 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
getFeatureHighlightColor, getFeatureHighlightColor,
getFeatureTooltip,
getAgentControlHintKey, getAgentControlHintKey,
isFeatureActive,
normalizeModelId, normalizeModelId,
resolveAgentModelSelection, resolveAgentModelSelection,
} from "./utils"; } from "./utils";
@@ -15,12 +17,51 @@ describe("getAgentControlHintKey", () => {
}); });
describe("feature metadata helpers", () => { 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", () => { it("maps feature highlight colors by feature id", () => {
expect(getFeatureHighlightColor("fast_mode")).toBe("yellow"); expect(getFeatureHighlightColor("fast_mode")).toBe("yellow");
expect(getFeatureHighlightColor("service_tier")).toBe("yellow"); expect(getFeatureHighlightColor("service_tier")).toBe("yellow");
expect(getFeatureHighlightColor("plan_mode")).toBe("blue"); expect(getFeatureHighlightColor("plan_mode")).toBe("blue");
expect(getFeatureHighlightColor("other")).toBe("default"); 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", () => { 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 { i18n } from "@/i18n/i18next";
import { formatThinkingOptionLabel } from "@/agent-controls/labels"; import { formatThinkingOptionLabel } from "@/agent-controls/labels";
import { import {
@@ -35,6 +35,16 @@ export function normalizeModelId(modelId: string | null | undefined): string | n
return normalized; 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 { export function getFeatureHighlightColor(featureId: string): FeatureHighlightColor {
switch (featureId) { switch (featureId) {
case FAST_MODE_FEATURE_ID: case FAST_MODE_FEATURE_ID:
@@ -20,11 +20,12 @@ export function resolveDraftKey(input: {
export function buildDraftAgentControls(input: { export function buildDraftAgentControls(input: {
formState: UseAgentFormStateResult; formState: UseAgentFormStateResult;
optionControls?: DraftAgentControlsProps["optionControls"]; features?: DraftAgentControlsProps["features"];
onSetFeature?: DraftAgentControlsProps["onSetFeature"];
onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"]; onApplyAgentProfile: DraftAgentControlsProps["onApplyAgentProfile"];
onDropdownClose?: DraftAgentControlsProps["onDropdownClose"]; onDropdownClose?: DraftAgentControlsProps["onDropdownClose"];
}): DraftAgentControlsProps { }): DraftAgentControlsProps {
const { formState, optionControls, onApplyAgentProfile, onDropdownClose } = input; const { formState, features, onSetFeature, onApplyAgentProfile, onDropdownClose } = input;
return { return {
providerDefinitions: formState.providerDefinitions, providerDefinitions: formState.providerDefinitions,
selectedProvider: formState.selectedProvider, selectedProvider: formState.selectedProvider,
@@ -42,7 +43,8 @@ export function buildDraftAgentControls(input: {
selectedThinkingOptionId: formState.selectedThinkingOptionId, selectedThinkingOptionId: formState.selectedThinkingOptionId,
onSelectThinkingOption: formState.setThinkingOptionFromUser, onSelectThinkingOption: formState.setThinkingOptionFromUser,
onApplyAgentProfile, onApplyAgentProfile,
optionControls, features,
onSetFeature,
onDropdownClose, onDropdownClose,
onModelSelectorOpen: formState.refetchProviderModelsIfStale, onModelSelectorOpen: formState.refetchProviderModelsIfStale,
onRetryModelProvider: formState.refreshProviderModels, onRetryModelProvider: formState.refreshProviderModels,
+25 -16
View File
@@ -8,7 +8,7 @@ import {
type CreateAgentInitialValues, type CreateAgentInitialValues,
type UseAgentFormStateResult, type UseAgentFormStateResult,
} from "@/hooks/use-agent-form-state"; } from "@/hooks/use-agent-form-state";
import { useDraftProviderOptions } from "@/hooks/use-draft-provider-options"; import { useDraftAgentFeatures } from "@/hooks/use-draft-agent-features";
import { import {
buildDraftAgentControls, buildDraftAgentControls,
hasDraftContent, hasDraftContent,
@@ -35,7 +35,7 @@ type AttachmentUpdater =
interface AgentInputDraftComposerOptions { interface AgentInputDraftComposerOptions {
initialServerId: string | null; initialServerId: string | null;
initialValues?: CreateAgentInitialValues; initialValues?: CreateAgentInitialValues;
initialProviderOptions?: Record<string, unknown>; initialFeatureValues?: Record<string, unknown>;
isVisible?: boolean; isVisible?: boolean;
lockedWorkingDir?: string; lockedWorkingDir?: string;
} }
@@ -50,7 +50,7 @@ type DraftComposerState = UseAgentFormStateResult & {
effectiveModeId: string; effectiveModeId: string;
effectiveModelId: string; effectiveModelId: string;
effectiveThinkingOptionId: string; effectiveThinkingOptionId: string;
providerOptions: Record<string, unknown> | undefined; featureValues: Record<string, unknown> | undefined;
agentControls: DraftAgentControlsProps; agentControls: DraftAgentControlsProps;
commandDraft: DraftCommandTarget; commandDraft: DraftCommandTarget;
}; };
@@ -274,25 +274,26 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
); );
const { const {
optionControls: draftOptionControls, features: draftFeatures,
options: draftOptions, featureValues: draftFeatureValues,
applyProfile: applyProfileOptions, setFeatureValue: setDraftFeatureValue,
} = useDraftProviderOptions({ applyProfileFeatureValues,
} = useDraftAgentFeatures({
serverId: formState.selectedServerId, serverId: formState.selectedServerId,
provider: formState.selectedProvider, provider: formState.selectedProvider,
cwd: workingDir, cwd: workingDir,
modeId: formState.selectedMode, modeId: formState.selectedMode,
modelId: effectiveModelId, modelId: effectiveModelId,
thinkingOptionId: effectiveThinkingOptionId, thinkingOptionId: effectiveThinkingOptionId,
initialOptions: composerOptions?.initialProviderOptions, initialFeatureValues: composerOptions?.initialFeatureValues,
}); });
const applyDraftAgentProfile = useCallback( const applyDraftAgentProfile = useCallback(
(profile: Parameters<typeof formState.applyProfileFromUser>[0]) => { (profile: Parameters<typeof formState.applyProfileFromUser>[0]) => {
formState.applyProfileFromUser(profile); formState.applyProfileFromUser(profile);
applyProfileOptions({ provider: profile.provider, options: profile.options }); applyProfileFeatureValues(profile.featureValues);
}, },
[applyProfileOptions, formState], [applyProfileFeatureValues, formState],
); );
const commandDraft = useMemo( const commandDraft = useMemo(
@@ -302,9 +303,15 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
cwd: workingDir, cwd: workingDir,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
providerOptions: draftOptions, featureValues: draftFeatureValues,
}), }),
[effectiveModelId, effectiveThinkingOptionId, draftOptions, providerSelection, workingDir], [
effectiveModelId,
effectiveThinkingOptionId,
draftFeatureValues,
providerSelection,
workingDir,
],
); );
const composerState = useMemo<DraftComposerState | null>(() => { const composerState = useMemo<DraftComposerState | null>(() => {
@@ -318,10 +325,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId, effectiveModeId,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
providerOptions: draftOptions, featureValues: draftFeatureValues,
agentControls: buildDraftAgentControls({ agentControls: buildDraftAgentControls({
formState, formState,
optionControls: draftOptionControls, features: draftFeatures,
onSetFeature: setDraftFeatureValue,
onApplyAgentProfile: applyDraftAgentProfile, onApplyAgentProfile: applyDraftAgentProfile,
}), }),
commandDraft, commandDraft,
@@ -332,10 +340,11 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
effectiveModeId, effectiveModeId,
effectiveModelId, effectiveModelId,
effectiveThinkingOptionId, effectiveThinkingOptionId,
draftOptionControls, draftFeatures,
draftOptions, draftFeatureValues,
applyDraftAgentProfile, applyDraftAgentProfile,
formState, formState,
setDraftFeatureValue,
workingDir, workingDir,
]); ]);
@@ -1,4 +1,3 @@
import type { ComposerOptionControls } from "@/agent-controls/option-controls";
import { useCallback, useEffect, useMemo, useRef } from "react"; import { useCallback, useEffect, useMemo, useRef } from "react";
import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native"; import { Keyboard, ScrollView, StyleSheet as RNStyleSheet, Text, View } from "react-native";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -66,7 +65,7 @@ interface AutoSubmitConfig {
modeId: string | null; modeId: string | null;
model: string | null; model: string | null;
thinkingOptionId: string | null; thinkingOptionId: string | null;
providerOptions: Record<string, unknown>; featureValues: Record<string, unknown>;
} }
function resolveAutoSubmitConfig( function resolveAutoSubmitConfig(
@@ -75,7 +74,7 @@ function resolveAutoSubmitConfig(
modeId?: string | null; modeId?: string | null;
model?: string | null; model?: string | null;
thinkingOptionId?: string | null; thinkingOptionId?: string | null;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
} | null, } | null,
): AutoSubmitConfig | null { ): AutoSubmitConfig | null {
if (!pending) return null; if (!pending) return null;
@@ -84,7 +83,7 @@ function resolveAutoSubmitConfig(
modeId: pending.modeId ?? null, modeId: pending.modeId ?? null,
model: pending.model ?? null, model: pending.model ?? null,
thinkingOptionId: pending.thinkingOptionId ?? null, thinkingOptionId: pending.thinkingOptionId ?? null,
providerOptions: pending.providerOptions ?? {}, featureValues: pending.featureValues ?? {},
}; };
} }
@@ -132,7 +131,7 @@ async function submitDraftCreateRequest(input: {
effectiveModeId: string; effectiveModeId: string;
effectiveModelId: string | null; effectiveModelId: string | null;
effectiveThinkingOptionId: string | null; effectiveThinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined; featureValues: Record<string, unknown> | undefined;
}; };
hostDisconnectedMessage: string; hostDisconnectedMessage: string;
selectModelMessage: string; selectModelMessage: string;
@@ -171,7 +170,7 @@ async function submitDraftCreateRequest(input: {
model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined), model: autoSubmitConfig?.model ?? (composerState.effectiveModelId || undefined),
thinkingOptionId: thinkingOptionId:
autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined), autoSubmitConfig?.thinkingOptionId ?? (composerState.effectiveThinkingOptionId || undefined),
providerOptions: autoSubmitConfig?.providerOptions ?? composerState.providerOptions, featureValues: autoSubmitConfig?.featureValues ?? composerState.featureValues,
}); });
const attachmentsArray = Array.isArray(attachments) ? attachments : undefined; const attachmentsArray = Array.isArray(attachments) ? attachments : undefined;
@@ -205,7 +204,7 @@ function buildDraftAgentSnapshot(input: {
effectiveThinkingOptionId: string | null; effectiveThinkingOptionId: string | null;
effectiveModeId: string; effectiveModeId: string;
selectedProvider: string | null; selectedProvider: string | null;
agentControls: { optionControls?: ComposerOptionControls }; agentControls: { features?: Agent["features"] };
}; };
selectModelMessage: string; selectModelMessage: string;
}): Agent { }): Agent {
@@ -242,8 +241,7 @@ function buildDraftAgentSnapshot(input: {
title: "Agent", title: "Agent",
cwd: workspaceDirectory, cwd: workspaceDirectory,
model, model,
options: composerState.agentControls.optionControls?.options, features: composerState.agentControls.features,
controls: composerState.agentControls.optionControls?.controls,
thinkingOptionId, thinkingOptionId,
parentAgentId: null, parentAgentId: null,
labels: {}, labels: {},
@@ -335,7 +333,7 @@ export function WorkspaceDraftAgentTab({
composer: { composer: {
initialServerId: serverId, initialServerId: serverId,
initialValues: draftInitialValues, initialValues: draftInitialValues,
initialProviderOptions: draftSetup?.providerOptions, initialFeatureValues: draftSetup?.featureValues,
isVisible: true, isVisible: true,
lockedWorkingDir: draftWorkingDirectory ?? undefined, lockedWorkingDir: draftWorkingDirectory ?? undefined,
}, },
@@ -353,7 +351,8 @@ export function WorkspaceDraftAgentTab({
const draftModeOptions = composerState.modeOptions; const draftModeOptions = composerState.modeOptions;
const draftSelectedMode = composerState.selectedMode; const draftSelectedMode = composerState.selectedMode;
const draftSetMode = composerState.setModeFromUser; const draftSetMode = composerState.setModeFromUser;
const draftOptionControls = composerState.agentControls.optionControls; const draftFeatures = composerState.agentControls.features;
const draftOnSetFeature = composerState.agentControls.onSetFeature;
const clearDraftInput = draftInput.clear; const clearDraftInput = draftInput.clear;
const replaceDraftText = draftInput.replaceText; const replaceDraftText = draftInput.replaceText;
@@ -522,7 +521,10 @@ export function WorkspaceDraftAgentTab({
selectedId: draftSelectedMode, selectedId: draftSelectedMode,
select: draftSetMode, select: draftSetMode,
}, },
optionControls: draftOptionControls ?? null, features: {
list: draftFeatures,
set: draftOnSetFeature,
},
}, },
}); });
const isReadyForPendingAutoSubmit = Boolean( const isReadyForPendingAutoSubmit = Boolean(
@@ -80,7 +80,7 @@ describe("create agent preferences", () => {
modelId: "gpt-5.5", modelId: "gpt-5.5",
modeId: "full-access", modeId: "full-access",
thinkingOptionId: "high", thinkingOptionId: "high",
options: { fast_mode: true }, featureValues: { fast_mode: true },
}), }),
); );
@@ -95,7 +95,7 @@ describe("create agent preferences", () => {
model: "gpt-5.5", model: "gpt-5.5",
mode: "full-access", mode: "full-access",
thinkingByModel: { "gpt-5.5": "high" }, 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", model: "gpt-5.6",
mode: undefined, mode: undefined,
thinkingByModel: undefined, thinkingByModel: undefined,
options: undefined, featureValues: undefined,
}, },
}), }),
).toEqual({ ).toEqual({
@@ -167,13 +167,13 @@ describe("create agent preferences", () => {
modelId: "one-minute-stream", modelId: "one-minute-stream",
modeId: "approval-test", modeId: "approval-test",
thinkingOptionId: "", thinkingOptionId: "",
options: {}, featureValues: {},
}), }),
).toEqual({ ).toEqual({
provider: "mock", provider: "mock",
providerPreferences: { providerPreferences: {
pi: { model: "anthropic/sonnet" }, 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", () => { it("rejects unknown persisted fields outside the explicit legacy shape", () => {
expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({}); expect(parseFormPreferences({ provider: "codex", surprise: true })).toEqual({});
}); });
@@ -1,14 +1,13 @@
import { z } from "zod"; import { z } from "zod";
import type { AgentProvider } from "@getpaseo/protocol/agent-types"; 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 { export interface ProviderPreferences {
model?: string; model?: string;
mode?: string; mode?: string;
thinkingByModel?: Record<string, string>; thinkingByModel?: Record<string, string>;
/** Last-used values of the provider's option controls, by option key. */ featureValues?: Record<string, unknown>;
options?: Record<string, unknown>;
} }
export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string }; export type LaunchTarget = { kind: "chat" } | { kind: "terminal"; profileId: string };
@@ -21,23 +20,12 @@ export interface FormPreferences {
launchTarget?: LaunchTarget; launchTarget?: LaunchTarget;
} }
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Preferences written const providerPreferencesSchema: z.ZodType<ProviderPreferences> = z.strictObject({
// before the rename stored the same record as `featureValues`. model: z.string().optional(),
function renameLegacyFeatureValues(value: unknown): unknown { mode: z.string().optional(),
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value; thinkingByModel: z.record(z.string(), z.string()).optional(),
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>; featureValues: featureValuesSchema.optional(),
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 launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [ const launchTargetSchema: z.ZodType<LaunchTarget> = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("chat") }), z.strictObject({ kind: z.literal("chat") }),
@@ -128,7 +116,7 @@ function applyProviderPreferenceUpdates(
): ProviderPreferences { ): ProviderPreferences {
const next: ProviderPreferences = { ...existing }; const next: ProviderPreferences = { ...existing };
const nextThinkingByModel = mergeDefinedRecord(existing.thinkingByModel, updates.thinkingByModel); 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) { if (updates.model !== undefined) {
next.model = updates.model; next.model = updates.model;
@@ -141,8 +129,8 @@ function applyProviderPreferenceUpdates(
if (nextThinkingByModel !== undefined) { if (nextThinkingByModel !== undefined) {
next.thinkingByModel = nextThinkingByModel; next.thinkingByModel = nextThinkingByModel;
} }
if (nextOptions !== undefined) { if (nextFeatureValues !== undefined) {
next.options = nextOptions; next.featureValues = nextFeatureValues;
} }
return next; return next;
@@ -173,7 +161,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
modelId?: string | null; modelId?: string | null;
modeId?: string | null; modeId?: string | null;
thinkingOptionId?: string | null; thinkingOptionId?: string | null;
options?: Record<string, unknown>; featureValues?: Record<string, unknown>;
}): FormPreferences { }): FormPreferences {
if (!args.provider) { if (!args.provider) {
return args.preferences; return args.preferences;
@@ -182,7 +170,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
const modelId = args.modelId?.trim() ?? ""; const modelId = args.modelId?.trim() ?? "";
const modeId = args.modeId?.trim() ?? ""; const modeId = args.modeId?.trim() ?? "";
const thinkingOptionId = args.thinkingOptionId?.trim() ?? ""; const thinkingOptionId = args.thinkingOptionId?.trim() ?? "";
const options = optionsSchema.safeParse(args.options); const featureValues = featureValuesSchema.safeParse(args.featureValues);
return mergeProviderPreferences({ return mergeProviderPreferences({
preferences: args.preferences, preferences: args.preferences,
@@ -191,7 +179,7 @@ export function mergeCreateAgentSelectionPreferences(args: {
model: modelId || undefined, model: modelId || undefined,
mode: args.modeId === undefined ? undefined : modeId || null, mode: args.modeId === undefined ? undefined : modeId || null,
...(modelId && thinkingOptionId ? { thinkingByModel: { [modelId]: thinkingOptionId } } : {}), ...(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; modelId: string;
modeId: string; modeId: string;
thinkingOptionId: string; thinkingOptionId: string;
options: Record<string, unknown>; featureValues: Record<string, unknown>;
}): FormPreferences { }): FormPreferences {
let next = args.preferences; let next = args.preferences;
if (args.previousProvider) { if (args.previousProvider) {
@@ -227,7 +215,7 @@ export function applyAgentProfilePreferences(args: {
...(args.modelId && args.thinkingOptionId ...(args.modelId && args.thinkingOptionId
? { thinkingByModel: { [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 modeId = draftConfig.modeId?.trim() ?? "";
const model = draftConfig.model?.trim() ?? ""; const model = draftConfig.model?.trim() ?? "";
const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? ""; const thinkingOptionId = draftConfig.thinkingOptionId?.trim() ?? "";
const providerOptions = draftConfig.providerOptions; const featureValues = draftConfig.featureValues;
return { return {
provider: draftConfig.provider, provider: draftConfig.provider,
cwd, cwd,
...(modeId ? { modeId } : {}), ...(modeId ? { modeId } : {}),
...(model ? { model } : {}), ...(model ? { model } : {}),
...(thinkingOptionId ? { thinkingOptionId } : {}), ...(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, modelId: model,
modeId, modeId,
thinkingOptionId, thinkingOptionId,
options: profile.options, featureValues: profile.featureValues,
}); });
}).catch((error) => { }).catch((error) => {
console.warn("[useAgentFormState] persist profile preference failed", error); console.warn("[useAgentFormState] persist profile preference failed", error);
@@ -1,6 +1,9 @@
import { useRef } from "react"; import { useRef } from "react";
import type { AgentCapabilityFlags, AgentProvider } from "@getpaseo/protocol/agent-types"; import type {
import type { Agent } from "@/stores/session-store"; AgentCapabilityFlags,
AgentFeature,
AgentProvider,
} from "@getpaseo/protocol/agent-types";
import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync"; import type { ViewedTimelineStatus } from "@/timeline/viewed-timeline-sync";
export interface AgentScreenAgent { export interface AgentScreenAgent {
@@ -19,8 +22,7 @@ export interface AgentScreenAgent {
modeId?: string | null; modeId?: string | null;
thinkingOptionId?: string | null; thinkingOptionId?: string | null;
} | null; } | null;
options?: Agent["options"]; features?: readonly AgentFeature[];
controls?: Agent["controls"];
lastError?: string | null; lastError?: string | null;
projectPlacement?: { projectPlacement?: {
projectKey?: string; 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" | "model"
| "thinkingOptionId" | "thinkingOptionId"
| "runtimeInfo" | "runtimeInfo"
| "options" | "features"
| "projectPlacement" | "projectPlacement"
>; >;
@@ -95,13 +95,18 @@ function buildForkDraftSetup(agent: ForkAgentSource): WorkspaceDraftTabSetup | u
return undefined; return undefined;
} }
const featureValues: Record<string, unknown> = {};
for (const feature of agent.features ?? []) {
featureValues[feature.id] = feature.value;
}
return { return {
provider: agent.provider, provider: agent.provider,
cwd: agent.cwd, cwd: agent.cwd,
modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null,
model: agent.model ?? agent.runtimeInfo?.model ?? null, model: agent.model ?? agent.runtimeInfo?.model ?? null,
thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null,
providerOptions: { ...agent.options }, featureValues,
}; };
} }
@@ -63,7 +63,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: { providerPreferences: {
codex: { codex: {
model: "gpt-5.4", model: "gpt-5.4",
options: { featureValues: {
fast_mode: true, fast_mode: true,
}, },
}, },
@@ -71,7 +71,7 @@ describe("mergeProviderPreferences", () => {
}, },
provider: "codex", provider: "codex",
updates: { updates: {
options: { featureValues: {
plan_mode: true, plan_mode: true,
}, },
}, },
@@ -81,7 +81,7 @@ describe("mergeProviderPreferences", () => {
providerPreferences: { providerPreferences: {
codex: { codex: {
model: "gpt-5.4", model: "gpt-5.4",
options: { featureValues: {
fast_mode: true, fast_mode: true,
plan_mode: true, plan_mode: true,
}, },
+4 -8
View File
@@ -118,8 +118,7 @@ interface ChatAgentStateShape {
model?: Agent["model"]; model?: Agent["model"];
thinkingOptionId?: Agent["thinkingOptionId"]; thinkingOptionId?: Agent["thinkingOptionId"];
runtimeInfo?: Agent["runtimeInfo"]; runtimeInfo?: Agent["runtimeInfo"];
options?: Agent["options"]; features?: Agent["features"];
controls?: Agent["controls"];
lastError?: Agent["lastError"] | null; lastError?: Agent["lastError"] | null;
} }
@@ -178,8 +177,7 @@ function selectChatAgentState(
model: agent.model, model: agent.model,
thinkingOptionId: agent.thinkingOptionId, thinkingOptionId: agent.thinkingOptionId,
runtimeInfo: agent.runtimeInfo, runtimeInfo: agent.runtimeInfo,
options: agent.options, features: agent.features,
controls: agent.controls,
lastError: agent.lastError ?? null, lastError: agent.lastError ?? null,
archivedAt: agent.archivedAt ?? null, archivedAt: agent.archivedAt ?? null,
requiresAttention: agent.requiresAttention ?? false, requiresAttention: agent.requiresAttention ?? false,
@@ -206,8 +204,7 @@ function buildChatAgentFromState(
model: state.model, model: state.model,
thinkingOptionId: state.thinkingOptionId, thinkingOptionId: state.thinkingOptionId,
runtimeInfo: state.runtimeInfo, runtimeInfo: state.runtimeInfo,
options: state.options, features: state.features,
controls: state.controls,
lastError: state.lastError ?? null, lastError: state.lastError ?? null,
projectPlacement, projectPlacement,
}; };
@@ -640,8 +637,7 @@ function AgentPanelBody({
model: agentState.model, model: agentState.model,
thinkingOptionId: agentState.thinkingOptionId, thinkingOptionId: agentState.thinkingOptionId,
runtimeInfo: agentState.runtimeInfo, runtimeInfo: agentState.runtimeInfo,
options: agentState.options, features: agentState.features,
controls: agentState.controls,
lastError: agentState.lastError ?? null, lastError: agentState.lastError ?? null,
projectPlacement, projectPlacement,
} }
@@ -312,7 +312,7 @@ export function buildDraftCommandTarget(input: {
cwd: string; cwd: string;
effectiveModelId: string; effectiveModelId: string;
effectiveThinkingOptionId: string; effectiveThinkingOptionId: string;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
}): DraftCommandTarget { }): DraftCommandTarget {
const cwd = input.cwd.trim(); const cwd = input.cwd.trim();
if (!cwd) { if (!cwd) {
@@ -332,7 +332,7 @@ export function buildDraftCommandTarget(input: {
...(input.effectiveThinkingOptionId ...(input.effectiveThinkingOptionId
? { thinkingOptionId: input.effectiveThinkingOptionId } ? { thinkingOptionId: input.effectiveThinkingOptionId }
: {}), : {}),
...(input.providerOptions ? { providerOptions: input.providerOptions } : {}), ...(input.featureValues ? { featureValues: input.featureValues } : {}),
}; };
return { status: "ready", config }; return { status: "ready", config };
} }
@@ -284,7 +284,7 @@ describe("mergeSelectedComposerPreferences", () => {
codex: { codex: {
mode: "full-access", mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" }, thinkingByModel: { "gpt-5.4-mini": "medium" },
options: { fast_mode: true }, featureValues: { fast_mode: true },
}, },
claude: { model: "claude-sonnet-4-6" }, claude: { model: "claude-sonnet-4-6" },
}, },
@@ -299,7 +299,7 @@ describe("mergeSelectedComposerPreferences", () => {
model: "gpt-5.4", model: "gpt-5.4",
mode: "full-access", mode: "full-access",
thinkingByModel: { "gpt-5.4-mini": "medium" }, thinkingByModel: { "gpt-5.4-mini": "medium" },
options: { fast_mode: true }, featureValues: { fast_mode: true },
}, },
claude: { model: "claude-sonnet-4-6" }, claude: { model: "claude-sonnet-4-6" },
}, },
@@ -271,7 +271,7 @@ interface WorkspaceDraftSubmissionConfig {
modeId: string | null; modeId: string | null;
model: string | null; model: string | null;
thinkingOptionId: string | null; thinkingOptionId: string | null;
providerOptions: Record<string, unknown> | undefined; featureValues: Record<string, unknown> | undefined;
target: WorkspaceTabTarget; target: WorkspaceTabTarget;
} }
@@ -374,7 +374,7 @@ function buildWorkspaceDraftSetupFromComposer(input: {
modeId: input.composerState.effectiveModeId || null, modeId: input.composerState.effectiveModeId || null,
model: input.composerState.effectiveModelId || null, model: input.composerState.effectiveModelId || null,
thinkingOptionId: input.composerState.effectiveThinkingOptionId || null, thinkingOptionId: input.composerState.effectiveThinkingOptionId || null,
providerOptions: input.composerState.providerOptions ?? {}, featureValues: input.composerState.featureValues ?? {},
}; };
} }
@@ -454,7 +454,7 @@ async function createWorkspaceChatAgent(input: CreateChatAgentInput): Promise<Su
modeId: composerState.effectiveModeId || undefined, modeId: composerState.effectiveModeId || undefined,
model: composerState.effectiveModelId || undefined, model: composerState.effectiveModelId || undefined,
thinkingOptionId: composerState.effectiveThinkingOptionId || undefined, thinkingOptionId: composerState.effectiveThinkingOptionId || undefined,
providerOptions: composerState.providerOptions, featureValues: composerState.featureValues,
}, },
initialPrompt: text, initialPrompt: text,
clientMessageId: `${input.draftId}:initial-message`, clientMessageId: `${input.draftId}:initial-message`,
@@ -534,7 +534,7 @@ function buildComposerConfig(input: {
return { return {
initialServerId: serverId || null, initialServerId: serverId || null,
initialValues: buildComposerInitialValues({ initialSetup }), initialValues: buildComposerInitialValues({ initialSetup }),
initialProviderOptions: initialSetup?.providerOptions, initialFeatureValues: initialSetup?.featureValues,
isVisible: true, isVisible: true,
lockedWorkingDir: workingDir, lockedWorkingDir: workingDir,
}; };
@@ -567,7 +567,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
modeId: initialSetup.modeId, modeId: initialSetup.modeId,
model: initialSetup.model, model: initialSetup.model,
thinkingOptionId: initialSetup.thinkingOptionId, thinkingOptionId: initialSetup.thinkingOptionId,
providerOptions: initialSetup.providerOptions, featureValues: initialSetup.featureValues,
target: { kind: "draft", draftId, setup: initialSetup }, target: { kind: "draft", draftId, setup: initialSetup },
}; };
} }
@@ -577,7 +577,7 @@ function resolveWorkspaceDraftSubmissionConfig(input: {
modeId: composerState.effectiveModeId || null, modeId: composerState.effectiveModeId || null,
model: composerState.effectiveModelId || null, model: composerState.effectiveModelId || null,
thinkingOptionId: composerState.effectiveThinkingOptionId || null, thinkingOptionId: composerState.effectiveThinkingOptionId || null,
providerOptions: composerState.providerOptions, featureValues: composerState.featureValues,
target: { kind: "draft", draftId }, target: { kind: "draft", draftId },
}; };
} }
@@ -635,7 +635,7 @@ function submitWorkspaceDraft(input: SubmitDraftInput): SubmitOutcome {
...(submission.modeId ? { modeId: submission.modeId } : {}), ...(submission.modeId ? { modeId: submission.modeId } : {}),
...(submission.model ? { model: submission.model } : {}), ...(submission.model ? { model: submission.model } : {}),
...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}), ...(submission.thinkingOptionId ? { thinkingOptionId: submission.thinkingOptionId } : {}),
...(submission.providerOptions ? { providerOptions: submission.providerOptions } : {}), ...(submission.featureValues ? { featureValues: submission.featureValues } : {}),
allowEmptyText: true, allowEmptyText: true,
agentCreation: input.agentCreation, agentCreation: input.agentCreation,
}); });
@@ -69,7 +69,7 @@ describe("createWorkspaceAgentInBackground", () => {
modeId: null, modeId: null,
model: null, model: null,
thinkingOptionId: null, thinkingOptionId: null,
providerOptions: {}, featureValues: {},
}, },
}); });
}); });
@@ -89,7 +89,7 @@ describe("createWorkspaceAgentInBackground", () => {
modeId: null, modeId: null,
model: null, model: null,
thinkingOptionId: null, thinkingOptionId: null,
providerOptions: {}, featureValues: {},
}, },
}); });
@@ -6,7 +6,7 @@ export function buildWorkspaceDraftAgentConfig(input: {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
}): AgentSessionConfig { }): AgentSessionConfig {
return { return {
provider: input.provider, provider: input.provider,
@@ -14,6 +14,6 @@ export function buildWorkspaceDraftAgentConfig(input: {
...(input.modeId ? { modeId: input.modeId } : {}), ...(input.modeId ? { modeId: input.modeId } : {}),
...(input.model ? { model: input.model } : {}), ...(input.model ? { model: input.model } : {}),
...(input.thinkingOptionId ? { thinkingOptionId: input.thinkingOptionId } : {}), ...(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 { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentOptionControl, AgentFeature,
ProviderOptions,
AgentProvider, AgentProvider,
AgentMode, AgentMode,
AgentCapabilityFlags, AgentCapabilityFlags,
@@ -92,8 +91,7 @@ export interface Agent {
cwd: string; cwd: string;
workspaceId?: string; workspaceId?: string;
model: string | null; model: string | null;
options?: ProviderOptions; features?: AgentFeature[];
controls?: AgentOptionControl[];
thinkingOptionId?: string | null; thinkingOptionId?: string | null;
requiresAttention?: boolean; requiresAttention?: boolean;
attentionReason?: "finished" | "error" | "permission" | null; attentionReason?: "finished" | "error" | "permission" | null;
@@ -23,7 +23,7 @@ export interface PendingWorkspaceDraftSubmission {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
allowEmptyText?: boolean; 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])( it.each([1, 2])(
"restores the agent pane after parked focus in storage version %i", "restores the agent pane after parked focus in storage version %i",
async (version) => { async (version) => {
@@ -1,25 +1,14 @@
import { z } from "zod"; import { z } from "zod";
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions"; import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01. Layouts saved before const WorkspaceDraftTabSetupStorageSchema = z.strictObject({
// the rename stored the same record as `featureValues`. provider: z.string(),
function renameLegacyFeatureValues(value: unknown): unknown { cwd: z.string(),
if (typeof value !== "object" || value === null || !("featureValues" in value)) return value; modeId: z.string().nullable(),
const { featureValues, ...rest } = value as { featureValues: unknown } & Record<string, unknown>; model: z.string().nullable(),
return "providerOptions" in rest ? rest : { ...rest, providerOptions: featureValues }; thinkingOptionId: z.string().nullable(),
} featureValues: z.record(z.string(), z.union([z.boolean(), z.string(), z.null()])),
});
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 WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [ const WorkspaceTabTargetStorageSchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("new_tab") }), z.strictObject({ kind: z.literal("new_tab") }),
z.strictObject({ z.strictObject({
@@ -4551,6 +4551,7 @@ describe("compact workspace sheet", () => {
title: agent.id, title: agent.id,
cwd: "/repo", cwd: "/repo",
model: null, model: null,
features: undefined,
thinkingOptionId: undefined, thinkingOptionId: undefined,
requiresAttention: false, requiresAttention: false,
attentionReason: null, attentionReason: null,
@@ -63,6 +63,7 @@ const AGENT_DEFAULTS: Agent = {
cwd: WORKSPACE_DIRECTORY, cwd: WORKSPACE_DIRECTORY,
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
model: null, model: null,
features: undefined,
thinkingOptionId: undefined, thinkingOptionId: undefined,
requiresAttention: false, requiresAttention: false,
attentionReason: null, attentionReason: null,
@@ -41,6 +41,7 @@ const AGENT_DEFAULTS: Agent = {
title: "Agent", title: "Agent",
cwd: "/tmp/project", cwd: "/tmp/project",
model: null, model: null,
features: undefined,
thinkingOptionId: undefined, thinkingOptionId: undefined,
requiresAttention: false, requiresAttention: false,
attentionReason: null, attentionReason: null,
@@ -60,6 +60,7 @@ const AGENT_DEFAULTS: Agent = {
cwd: "/repo/worktree", cwd: "/repo/worktree",
workspaceId: WORKSPACE_ID, workspaceId: WORKSPACE_ID,
model: null, model: null,
features: undefined,
thinkingOptionId: undefined, thinkingOptionId: undefined,
requiresAttention: false, requiresAttention: false,
attentionReason: null, attentionReason: null,
@@ -717,11 +717,13 @@ describe("terminal emulator runtime in a real browser", () => {
{ name: "DA1-zero", bytes: "\x1b[0c" }, { name: "DA1-zero", bytes: "\x1b[0c" },
{ name: "DA2", bytes: "\x1b[>c" }, { name: "DA2", bytes: "\x1b[>c" },
{ name: "DA3", bytes: "\x1b[=c" }, { name: "DA3", bytes: "\x1b[=c" },
{ name: "XTVERSION", bytes: "\x1b[>q" },
{ name: "DSR-5", bytes: "\x1b[5n" }, { name: "DSR-5", bytes: "\x1b[5n" },
{ name: "DSR-6", bytes: "\x1b[6n" }, { name: "DSR-6", bytes: "\x1b[6n" },
{ name: "DSR-?6", bytes: "\x1b[?6n" }, { name: "DSR-?6", bytes: "\x1b[?6n" },
{ name: "DECRQM", bytes: "\x1b[1$p" }, { name: "DECRQM", bytes: "\x1b[1$p" },
{ name: "DECRQM-?", bytes: "\x1b[?1$p" }, { name: "DECRQM-?", bytes: "\x1b[?1$p" },
{ name: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" }, { name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" }, { name: "OSC-11-background-color", bytes: "\x1b]11;?\x07" },
{ name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" }, { name: "OSC-12-cursor-color", bytes: "\x1b]12;?\x07" },
@@ -647,6 +647,7 @@ export class TerminalEmulatorRuntime {
terminal.parser.registerCsiHandler({ final: "c" }, () => true); terminal.parser.registerCsiHandler({ final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: ">", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true); terminal.parser.registerCsiHandler({ prefix: "=", final: "c" }, () => true);
terminal.parser.registerCsiHandler({ prefix: ">", final: "q" }, () => true);
terminal.parser.registerCsiHandler({ final: "n" }, () => true); terminal.parser.registerCsiHandler({ final: "n" }, () => true);
terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true); terminal.parser.registerCsiHandler({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true); terminal.parser.registerCsiHandler({ final: "R" }, () => true);
@@ -655,6 +656,7 @@ export class TerminalEmulatorRuntime {
{ prefix: "?", intermediates: "$", final: "p" }, { prefix: "?", intermediates: "$", final: "p" },
() => true, () => true,
); );
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
for (const code of [10, 11, 12]) { for (const code of [10, 11, 12]) {
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?"); terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
} }
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { isControlActive, readSelectOption } from "@/agent-controls/option-controls";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots";
function createSnapshot( function createSnapshot(
@@ -33,39 +32,10 @@ function createSnapshot(
persistence: input.persistence ?? null, persistence: input.persistence ?? null,
title: input.title ?? null, title: input.title ?? null,
labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"], labels: (input.labels ?? {}) as AgentSnapshotPayload["labels"],
...(input.controls ? { controls: input.controls } : {}),
...(input.options ? { options: input.options } : {}),
}; };
} }
describe("normalizeAgentSnapshot", () => { 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", () => { it("round-trips identified active turns through the canonical snapshot boundary", () => {
const snapshot = createSnapshot({ const snapshot = createSnapshot({
status: "running", status: "running",
+2 -4
View File
@@ -64,8 +64,7 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
cwd: agent.cwd, cwd: agent.cwd,
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}), ...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
model: agent.model, model: agent.model,
...(agent.options ? { options: agent.options } : {}), ...(agent.features ? { features: agent.features } : {}),
...(agent.controls ? { controls: agent.controls } : {}),
thinkingOptionId: agent.thinkingOptionId ?? null, thinkingOptionId: agent.thinkingOptionId ?? null,
createdAt: agent.createdAt.toISOString(), createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(), updatedAt: agent.updatedAt.toISOString(),
@@ -128,8 +127,7 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
cwd: snapshot.cwd, cwd: snapshot.cwd,
workspaceId: snapshot.workspaceId, workspaceId: snapshot.workspaceId,
model: snapshot.model ?? null, model: snapshot.model ?? null,
options: snapshot.options, features: snapshot.features,
controls: snapshot.controls,
thinkingOptionId: snapshot.thinkingOptionId ?? null, thinkingOptionId: snapshot.thinkingOptionId ?? null,
requiresAttention: snapshot.requiresAttention ?? false, requiresAttention: snapshot.requiresAttention ?? false,
attentionReason: snapshot.attentionReason ?? null, attentionReason: snapshot.attentionReason ?? null,
+2 -2
View File
@@ -92,7 +92,7 @@ export function normalizeWorkspaceDraftTabSetup(
thinkingOptionId: trimOptionalString( thinkingOptionId: trimOptionalString(
typeof record.thinkingOptionId === "string" ? record.thinkingOptionId : null, 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.modeId === right.modeId &&
left.model === right.model && left.model === right.model &&
left.thinkingOptionId === right.thinkingOptionId && 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; modeId: string | null;
model: string | null; model: string | null;
thinkingOptionId: string | null; thinkingOptionId: string | null;
providerOptions: Record<string, unknown>; featureValues: Record<string, unknown>;
} }
export interface WorkspaceWorkingDiffTabTarget { export interface WorkspaceWorkingDiffTabTarget {
@@ -1,217 +0,0 @@
import { controlsFromFeatures, optionsFromFeatures } from "@getpaseo/protocol/option-controls";
import type {
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 "agent.config.apply.request": {
const { options, ...config } = message.config;
if (!options) return message;
return {
...message,
config: { ...config, featureValues: { ...config.featureValues, ...options } },
};
}
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;
}
}
/** 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`. */
function profileWithOptions(profile: AgentProfile): AgentProfile {
if (profile.options || !profile.featureValues) return profile;
const { featureValues, ...rest } = profile;
return { ...rest, options: featureValues };
}
function profileWithFeatureValues(profile: AgentProfile): AgentProfile {
if (!profile.options) return profile;
const { options, ...rest } = profile;
return { ...rest, featureValues: options };
}
-391
View File
@@ -8924,394 +8924,3 @@ test("routed channels inherit an origin without drain support", async () => {
expect(route.supportsDrain).toBe(false); expect(route.supportsDrain).toBe(false);
route.close(); 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;
});
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 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" },
});
});
});
+16 -107
View File
@@ -102,7 +102,6 @@ import type {
WorkspaceSetupStatusResponseMessage, WorkspaceSetupStatusResponseMessage,
ListCommandsResponse, ListCommandsResponse,
ListProviderFeaturesResponseMessage, ListProviderFeaturesResponseMessage,
ProviderControlsListResponseMessage,
ListProviderModelsResponseMessage, ListProviderModelsResponseMessage,
ListProviderModesResponseMessage, ListProviderModesResponseMessage,
ListAvailableProvidersResponse, ListAvailableProvidersResponse,
@@ -185,10 +184,6 @@ import {
normalizeProviderSnapshotUpdateMessage, normalizeProviderSnapshotUpdateMessage,
normalizeProvidersSnapshotPayload, normalizeProvidersSnapshotPayload,
} from "./compat/normalize-provider-models.js"; } 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 { TerminalStreamRouter, type TerminalStreamEvent } from "./terminal-stream-router.js";
import type { import type {
BrowserAutomationExecuteRequest, BrowserAutomationExecuteRequest,
@@ -603,7 +598,6 @@ export interface FileUploadInput {
export type FileUploadResult = FileUploadResponse["payload"]; export type FileUploadResult = FileUploadResponse["payload"];
type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"]; type FileDownloadTokenPayload = FileDownloadTokenResponse["payload"];
type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"]; type ListProviderFeaturesPayload = ListProviderFeaturesResponseMessage["payload"];
export type ListProviderControlsPayload = ProviderControlsListResponseMessage["payload"];
type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"]; type ListProviderModelsPayload = ListProviderModelsResponseMessage["payload"];
type ListProviderModesPayload = ListProviderModesResponseMessage["payload"]; type ListProviderModesPayload = ListProviderModesResponseMessage["payload"];
type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"]; type ListAvailableProvidersPayload = ListAvailableProvidersResponse["payload"];
@@ -630,7 +624,7 @@ type WriteProjectConfigPayload = Extract<
type ListCommandsPayload = ListCommandsResponse["payload"]; type ListCommandsPayload = ListCommandsResponse["payload"];
type ListCommandsDraftConfig = Pick< type ListCommandsDraftConfig = Pick<
AgentSessionConfig, AgentSessionConfig,
"provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues" | "providerOptions" "provider" | "cwd" | "modeId" | "model" | "thinkingOptionId" | "featureValues"
>; >;
export interface WriteProjectConfigInput { export interface WriteProjectConfigInput {
repoRoot: string; repoRoot: string;
@@ -2095,13 +2089,17 @@ export class DaemonClient {
} }
private sendSessionPayload(payload: SessionInboundMessage): Promise<void> { private sendSessionPayload(payload: SessionInboundMessage): Promise<void> {
const wire = this.legacyProviderOptions()?.prepare(payload) ?? payload; return this.sendJsonMessage(
return this.sendJsonMessage("session", payload.type, { type: "session", message: wire }, () => { "session",
if (!("requestId" in payload) || typeof payload.requestId !== "string") return; payload.type,
for (const waiter of this.waiters) { { type: "session", message: payload },
if (waiter.requestId === payload.requestId) waiter.submitted = true; () => {
} 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( private sendTransportFrame(
@@ -3269,44 +3267,14 @@ export class DaemonClient {
); );
} }
if (options.background !== undefined) this.requireBackgroundWorkspaces(); if (options.background !== undefined) this.requireBackgroundWorkspaces();
const config = resolveAgentConfig(options);
const result = await this.creations.createAgent({ const result = await this.creations.createAgent({
...options, ...options,
config: this.carriesLegacyOptions(config) config: resolveAgentConfig(options),
? await this.routeLegacyCreateConfig(config)
: config,
}); });
if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed"); if (result.error || !result.agent) throw new Error(result.error ?? "Agent creation failed");
return result.agent; 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( private async createLegacyAgent(
options: CreateAgentRequestOptions, options: CreateAgentRequestOptions,
): Promise<AgentSnapshotPayload> { ): Promise<AgentSnapshotPayload> {
@@ -4097,24 +4065,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> {
if (this.legacyProviderOptions()) {
// COMPAT(providerOptionControls): added in v0.15.0, remove after 2027-05-01.
for (const [featureId, value] of Object.entries(patch)) {
await this.setAgentFeature(agentId, featureId, value);
}
return null;
}
return this.applyAgentConfig(agentId, { options: patch });
}
async setAgentThinkingOption( async setAgentThinkingOption(
agentId: string, agentId: string,
thinkingOptionId: string | null, thinkingOptionId: string | null,
@@ -5058,14 +5008,12 @@ export class DaemonClient {
if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces(); if (input.background !== undefined || input.callerAgentId) this.requireBackgroundWorkspaces();
if (input.agent?.background !== undefined) if (input.agent?.background !== undefined)
throw new Error("Configure background on the workspace, not its initial agent."); throw new Error("Configure background on the workspace, not its initial agent.");
let agentConfig = input.agent ? resolveAgentConfig(input.agent) : undefined;
if (agentConfig && this.carriesLegacyOptions(agentConfig)) {
agentConfig = await this.routeLegacyCreateConfig(agentConfig);
}
const result = await this.creations.createWorkspace({ const result = await this.creations.createWorkspace({
...input, ...input,
requestId: resolvedRequestId, requestId: resolvedRequestId,
...(input.agent && agentConfig ? { agent: { ...input.agent, config: agentConfig } } : {}), ...(input.agent
? { agent: { ...input.agent, config: resolveAgentConfig(input.agent) } }
: {}),
}); });
return { return {
...result, ...result,
@@ -5546,36 +5494,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( async listProviderFeatures(
draftConfig: ListCommandsDraftConfig, draftConfig: ListCommandsDraftConfig,
options?: { requestId?: string }, options?: { requestId?: string },
@@ -6829,14 +6747,6 @@ export class DaemonClient {
return requestId ?? crypto.randomUUID(); 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 { getLastServerInfoMessage(): ServerInfoStatusPayload | null {
return this.lastServerInfoMessage; return this.lastServerInfoMessage;
} }
@@ -7398,7 +7308,6 @@ export class DaemonClient {
private handleSessionMessage(msg: SessionOutboundMessage): void { private handleSessionMessage(msg: SessionOutboundMessage): void {
msg = this.owned.normalize(msg); msg = this.owned.normalize(msg);
msg = this.legacyProviderOptions()?.normalize(msg) ?? msg;
if ( if (
msg.type === "providers_snapshot_update" && msg.type === "providers_snapshot_update" &&
this.config.providerSnapshots !== "wire" && this.config.providerSnapshots !== "wire" &&
+2 -7
View File
@@ -1601,12 +1601,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 () => { test("agent config maps provider/model and provider-native options to the daemon", async () => {
const { client, ws } = await connectClient({ const { client, ws } = await connectClient();
providerUsageList: true,
providersSnapshotCwd: true,
ownedSubscriptions: true,
providerOptionControls: true,
});
const createdAgent = createAgent({ const createdAgent = createAgent({
model: "gpt-5.4", model: "gpt-5.4",
currentModeId: "full-access", currentModeId: "full-access",
@@ -1638,8 +1633,8 @@ test("agent config maps provider/model and provider-native options to the daemon
model: "gpt-5.4", model: "gpt-5.4",
modeId: "full-access", modeId: "full-access",
thinkingOptionId: "high", thinkingOptionId: "high",
featureValues: { webSearch: true },
providerOptions: { providerOptions: {
webSearch: true,
approval_policy: "never", approval_policy: "never",
sandbox_mode: "workspace-write", sandbox_mode: "workspace-write",
sandbox_workspace_write: { sandbox_workspace_write: {
+5 -58
View File
@@ -20,8 +20,6 @@ import type {
ListCommandsResponse, ListCommandsResponse,
ListProviderFeaturesRequestMessage, ListProviderFeaturesRequestMessage,
ListProviderFeaturesResponseMessage, ListProviderFeaturesResponseMessage,
ProviderControlsListRequestMessage,
ProviderControlsListResponseMessage,
ListProviderModelsResponseMessage, ListProviderModelsResponseMessage,
ProjectListRequestMessage, ProjectListRequestMessage,
ProjectListResponseMessage, ProjectListResponseMessage,
@@ -241,12 +239,8 @@ export interface PaseoAgentConfig {
provider: string; provider: string;
modeId?: PaseoAgentSessionConfig["modeId"]; modeId?: PaseoAgentSessionConfig["modeId"];
thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"]; thinkingOptionId?: PaseoAgentSessionConfig["thinkingOptionId"];
/** @deprecated Put these in `options`; they are folded into it on send. */
featureValues?: PaseoProviderFeatureValues; featureValues?: PaseoProviderFeatureValues;
/** /** JSON-safe provider-native settings, validated by the selected provider. */
* 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`.
*/
options?: PaseoAgentSessionConfig["providerOptions"]; options?: PaseoAgentSessionConfig["providerOptions"];
systemPrompt?: PaseoAgentSessionConfig["systemPrompt"]; systemPrompt?: PaseoAgentSessionConfig["systemPrompt"];
toolPolicy?: PaseoAgentSessionConfig["toolPolicy"]; toolPolicy?: PaseoAgentSessionConfig["toolPolicy"];
@@ -370,12 +364,7 @@ export interface PaseoAgentHandle {
readonly activeTurn: NonNullable<PaseoAgent["activeTurn"]> | null; readonly activeTurn: NonNullable<PaseoAgent["activeTurn"]> | null;
readonly lastUsage: NonNullable<PaseoAgent["lastUsage"]> | null; readonly lastUsage: NonNullable<PaseoAgent["lastUsage"]> | null;
readonly lastError: NonNullable<PaseoAgent["lastError"]> | null; readonly lastError: NonNullable<PaseoAgent["lastError"]> | null;
/** @deprecated Use `controls` and `options`. */
readonly features: NonNullable<PaseoAgent["features"]> | null; 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;
readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null; readonly runtimeInfo: NonNullable<PaseoAgent["runtimeInfo"]> | null;
readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null; readonly archivedAt: NonNullable<PaseoAgent["archivedAt"]> | null;
readonly timeline: PaseoAgentTimelineHandle; readonly timeline: PaseoAgentTimelineHandle;
@@ -388,12 +377,6 @@ export interface PaseoAgentHandle {
reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>; reload(options?: { requestId?: string }): Promise<PaseoAgentRefetchResult | null>;
send(text: string, options?: PaseoAgentSendOptions): Promise<void>; send(text: string, options?: PaseoAgentSendOptions): Promise<void>;
respondToPermission(options: PaseoAgentRespondToPermissionOptions): 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>;
/** Sends a prompt and resolves when that turn finishes or needs attention. */ /** Sends a prompt and resolves when that turn finishes or needs attention. */
run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>; run(text: string, options?: PaseoAgentRunOptions): Promise<PaseoAgentRunResult>;
/** Waits for the current turn, including one started with `prompt`. */ /** Waits for the current turn, including one started with `prompt`. */
@@ -430,21 +413,14 @@ export interface PaseoAgentActions {
export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"]; export type PaseoProviderModelsResult = ListProviderModelsResponseMessage["payload"];
export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"]; export type PaseoProviderModesResult = ListProviderModesResponseMessage["payload"];
type PaseoProviderControlsDraft = ProviderControlsListRequestMessage["draftConfig"]; type PaseoProviderFeaturesDraft = ListProviderFeaturesRequestMessage["draftConfig"];
export interface PaseoProviderControlsInput extends Omit< export interface PaseoProviderFeaturesInput extends Omit<
PaseoProviderControlsDraft, PaseoProviderFeaturesDraft,
"provider" | "model" "provider" | "model"
> { > {
/** Provider and model in `provider/model` format. */ /** Provider and model in `provider/model` format. */
provider: string; 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 PaseoProviderFeaturesResult = ListProviderFeaturesResponseMessage["payload"];
export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"]; export type PaseoProviderAvailabilityResult = ListAvailableProvidersResponse["payload"];
export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"]; export type PaseoProviderSnapshotResult = GetProvidersSnapshotResponseMessage["payload"];
@@ -483,15 +459,6 @@ export interface PaseoProviderActions {
provider: PaseoAgentProvider, provider: PaseoAgentProvider,
options?: PaseoProviderListOptions, options?: PaseoProviderListOptions,
): Promise<PaseoProviderModesResult>; ): 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( listFeatures(
draftConfig: PaseoProviderFeaturesInput, draftConfig: PaseoProviderFeaturesInput,
options?: { requestId?: string }, options?: { requestId?: string },
@@ -576,14 +543,7 @@ function toDaemonAgentCreateOptions(
serverId?: string, serverId?: string,
): CreateAgentRequestOptions { ): CreateAgentRequestOptions {
const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options; const { config: agentConfig, cwd, parent, title, prompt, ...requestOptions } = options;
const { const { provider: providerModel, options: providerOptions, ...runtimeConfig } = agentConfig;
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, model } = parseProviderModel(providerModel); const { provider, model } = parseProviderModel(providerModel);
const relationship = parent ? encodeParent(parent, serverId) : {}; const relationship = parent ? encodeParent(parent, serverId) : {};
return { return {
@@ -816,10 +776,6 @@ export function createPaseoApi(
const { provider, model } = parseProviderModel(providerModel); const { provider, model } = parseProviderModel(providerModel);
return daemonClient.listProviderFeatures({ ...draftConfig, provider, model }, options); 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), listAvailable: (options) => daemonClient.listAvailableProviders(options),
snapshot: (options) => daemonClient.getProvidersSnapshot(options), snapshot: (options) => daemonClient.getProvidersSnapshot(options),
waitForReady: (options) => waitForReady: (options) =>
@@ -1019,12 +975,6 @@ function createAgentHandleFactory(
get features() { get features() {
return current?.features ?? null; return current?.features ?? null;
}, },
get options() {
return current?.options ?? null;
},
get controls() {
return current?.controls ?? null;
},
get runtimeInfo() { get runtimeInfo() {
return current?.runtimeInfo ?? null; return current?.runtimeInfo ?? null;
}, },
@@ -1046,9 +996,6 @@ function createAgentHandleFactory(
send: async (text, options) => { send: async (text, options) => {
await daemonClient.sendAgentMessage(id, text, options); await daemonClient.sendAgentMessage(id, text, options);
}, },
setOptions: async (patch) => {
await daemonClient.setAgentOptions(id, patch);
},
respondToPermission: async ({ requestId, response }) => { respondToPermission: async ({ requestId, response }) => {
await daemonClient.respondToPermission(id, requestId, response); await daemonClient.respondToPermission(id, requestId, response);
}, },
+4 -3
View File
@@ -204,15 +204,16 @@ export function createHostTransportFactory(
} }
export function createHostClient( export function createHostClient(
connection: string, connection: string,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions, config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
budget?: HostTransportBudget, budget?: HostTransportBudget,
): DaemonClient { ): DaemonClient {
const target = parseHostTarget(connection); const target = parseHostTarget(connection);
const { webSocketAgent, ...clientConfig } = config; const { webSocketAgent, transportFactory, ...clientConfig } = config;
return new DaemonClient({ return new DaemonClient({
...clientConfig, ...clientConfig,
url: "ws://host-connector/ws", url: "ws://host-connector/ws",
...(target.password ? { password: target.password } : {}), ...(target.password ? { password: target.password } : {}),
transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }), transportFactory:
transportFactory ?? createHostTransportFactory(connection, budget, { webSocketAgent }),
}); });
} }
+1 -1
View File
@@ -41,7 +41,7 @@
"@types/node": "24.6.0", "@types/node": "24.6.0",
"@types/ws": "^8.5.14", "@types/ws": "^8.5.14",
"electron": "44.2.0", "electron": "44.2.0",
"electron-builder": "26.8.1", "electron-builder": "26.18.0",
"typescript": "5.9.3", "typescript": "5.9.3",
"unzip-crx-3": "^0.2.0", "unzip-crx-3": "^0.2.0",
"vitest": "^4.1.6", "vitest": "^4.1.6",
-3
View File
@@ -21,10 +21,7 @@ export const AgentProfileSchema = z
model: z.string().optional(), model: z.string().optional(),
modeId: z.string().optional(), modeId: z.string().optional(),
thinkingOptionId: 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(), 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. */ /** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */
notes: z.string().optional(), notes: z.string().optional(),
}) })
+1 -26
View File
@@ -1,12 +1,5 @@
import type { AgentMessage } from "./agent-message.js"; import type { AgentMessage } from "./agent-message.js";
import type { import type { AgentAttachment } from "./messages.js";
AgentAttachment,
AgentOptionChoiceSchema,
AgentOptionControlSchema,
AgentOptionSelectControlSchema,
AgentOptionToggleControlSchema,
} from "./messages.js";
import type { z } from "zod";
export type AgentProvider = string; export type AgentProvider = string;
@@ -161,20 +154,6 @@ export interface AgentFeatureSelect {
export type AgentFeature = AgentFeatureToggle | 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 { export interface AgentCapabilityFlags {
[capability: string]: boolean | undefined; [capability: string]: boolean | undefined;
supportsStreaming: boolean; supportsStreaming: boolean;
@@ -544,10 +523,6 @@ export interface AgentSessionConfig {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: 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>; featureValues?: Record<string, unknown>;
title?: string | null; title?: string | null;
providerOptions?: ProviderOptions; providerOptions?: ProviderOptions;
-72
View File
@@ -361,39 +361,6 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
AgentFeatureSelectSchema, 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({ const AgentModelDefinitionSchema = z.object({
provider: AgentProviderSchema, provider: AgentProviderSchema,
id: z.string(), id: z.string(),
@@ -523,7 +490,6 @@ const AgentSessionConfigSchema = z.object({
modeId: z.string().optional(), modeId: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
thinkingOptionId: 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(), featureValues: z.record(z.string(), z.unknown()).optional(),
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(), title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
providerOptions: ProviderOptionsSchema.optional(), providerOptions: ProviderOptionsSchema.optional(),
@@ -900,11 +866,7 @@ export const AgentSnapshotPayloadSchema = z.object({
cwd: z.string(), cwd: z.string(),
workspaceId: z.string().optional(), workspaceId: z.string().optional(),
model: z.string().nullable(), 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(), 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(), thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(), effectiveThinkingOptionId: z.string().nullable().optional(),
createdAt: z.string(), createdAt: z.string(),
@@ -2093,10 +2055,7 @@ export const AgentConfigApplySchema = z.object({
modelId: z.string().nullable().optional(), modelId: z.string().nullable().optional(),
modeId: z.string().optional(), modeId: z.string().optional(),
thinkingOptionId: z.string().nullable().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(), 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>; export type AgentConfigApply = z.infer<typeof AgentConfigApplySchema>;
@@ -3012,9 +2971,7 @@ const ListCommandsDraftConfigSchema = z.object({
modeId: z.string().optional(), modeId: z.string().optional(),
model: z.string().optional(), model: z.string().optional(),
thinkingOptionId: 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(), featureValues: z.record(z.string(), z.unknown()).optional(),
providerOptions: ProviderOptionsSchema.optional(),
}); });
export const ListProviderFeaturesRequestMessageSchema = z.object({ export const ListProviderFeaturesRequestMessageSchema = z.object({
@@ -3023,12 +2980,6 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
requestId: z.string(), 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({ export const ListCommandsRequestSchema = z.object({
type: z.literal("list_commands_request"), type: z.literal("list_commands_request"),
agentId: z.string(), agentId: z.string(),
@@ -3206,7 +3157,6 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
model: z.string().optional(), model: z.string().optional(),
modeId: z.string().optional(), modeId: z.string().optional(),
thinkingOptionId: 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(), featureValues: z.record(z.string(), z.unknown()).optional(),
providerOptions: ProviderOptionsSchema.optional(), providerOptions: ProviderOptionsSchema.optional(),
toolPolicy: ToolPolicySchema.optional(), toolPolicy: ToolPolicySchema.optional(),
@@ -3407,7 +3357,6 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsRequestMessageSchema, ListProviderModelsRequestMessageSchema,
ListProviderModesRequestMessageSchema, ListProviderModesRequestMessageSchema,
ListProviderFeaturesRequestMessageSchema, ListProviderFeaturesRequestMessageSchema,
ProviderControlsListRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema, ListAvailableProvidersRequestMessageSchema,
GetProvidersSnapshotRequestMessageSchema, GetProvidersSnapshotRequestMessageSchema,
RefreshProvidersSnapshotRequestMessageSchema, RefreshProvidersSnapshotRequestMessageSchema,
@@ -3887,7 +3836,6 @@ export const ServerInfoStatusPayloadSchema = z
selectiveAgentTimeline: z.boolean().optional(), selectiveAgentTimeline: z.boolean().optional(),
explicitEventSubscriptions: z.boolean().optional(), explicitEventSubscriptions: z.boolean().optional(),
ownedSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(),
providerOptionControls: z.boolean().optional(),
binaryChunkingV1: z.boolean().optional(), binaryChunkingV1: z.boolean().optional(),
workspaceSetupObservation: z.boolean().optional(), workspaceSetupObservation: z.boolean().optional(),
workspaceSortTimes: z.boolean().optional(), workspaceSortTimes: z.boolean().optional(),
@@ -6355,19 +6303,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({ export const ListProviderFeaturesResponseMessageSchema = z.object({
type: z.literal("list_provider_features_response"), type: z.literal("list_provider_features_response"),
payload: z.object({ payload: z.object({
@@ -7260,7 +7195,6 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsResponseMessageSchema, ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema, ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema, ListProviderFeaturesResponseMessageSchema,
ProviderControlsListResponseMessageSchema,
ListAvailableProvidersResponseSchema, ListAvailableProvidersResponseSchema,
GetProvidersSnapshotResponseMessageSchema, GetProvidersSnapshotResponseMessageSchema,
ProvidersSnapshotUpdateMessageSchema, ProvidersSnapshotUpdateMessageSchema,
@@ -7515,12 +7449,6 @@ export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema typeof ListProviderModelsRequestMessageSchema
>; >;
export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>; 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< export type ListProviderFeaturesRequestMessage = z.infer<
typeof ListProviderFeaturesRequestMessageSchema typeof ListProviderFeaturesRequestMessageSchema
>; >;
-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(), isolation: z.enum(["local", "worktree"]).optional(),
title: z.string().trim().min(1).nullable().optional(), title: z.string().trim().min(1).nullable().optional(),
providerOptions: z.record(z.string(), z.unknown()).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(), featureValues: z.record(z.string(), z.unknown()).optional(),
systemPrompt: z.string().optional(), systemPrompt: z.string().optional(),
mcpServers: z.record(z.string(), z.unknown()).optional(), mcpServers: z.record(z.string(), z.unknown()).optional(),
@@ -23,7 +23,6 @@ import { AgentStorage } from "./agent-storage.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js"; import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js"; import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import { toAgentPayload } from "./agent-projections.js"; import { toAgentPayload } from "./agent-projections.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { projectTimelineRows } from "./timeline-projection.js"; import { projectTimelineRows } from "./timeline-projection.js";
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { startAgentRun } from "./agent-prompt.js"; import { startAgentRun } from "./agent-prompt.js";
@@ -39,7 +38,7 @@ import type {
import type { import type {
AgentClient, AgentClient,
AgentCreateSessionOptions, AgentCreateSessionOptions,
AgentOptionControl, AgentFeature,
AgentLaunchContext, AgentLaunchContext,
AgentPromptInput, AgentPromptInput,
AgentProvider, AgentProvider,
@@ -58,7 +57,6 @@ import type {
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import type { PaseoToolCatalog } from "./tools/types.js"; import type { PaseoToolCatalog } from "./tools/types.js";
import type { ProviderDefinition } from "./provider-registry.js"; import type { ProviderDefinition } from "./provider-registry.js";
import { mergeProviderOptions } from "./provider-options.js";
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client"); const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client"); const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
@@ -184,8 +182,13 @@ class RecordingTimelineStore implements AgentTimelineStore {
} }
} }
function createControl(args: { key: string; label: string }): AgentOptionControl { function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature {
return { type: "toggle", key: args.key, label: args.label }; return {
type: "toggle",
id: args.id,
label: args.label,
value: args.value,
};
} }
function expectArchivedAgentRecord( function expectArchivedAgentRecord(
@@ -2329,7 +2332,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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger); const storage = new AgentStorage(storagePath, logger);
@@ -2362,21 +2365,18 @@ test("listDraftControls does not start a fallback session without a model", asyn
logger, logger,
}); });
await expect(manager.listDraftControls({ provider: "codex", cwd: workdir })).resolves.toEqual({ await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]);
controls: [],
options: {},
});
expect(client.fetchCatalogCalls).toBe(0); expect(client.fetchCatalogCalls).toBe(0);
expect(client.createSessionCalls).toBe(0); expect(client.createSessionCalls).toBe(0);
expect(client.availabilityCalls).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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger); 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 { class DraftFeatureClient extends TestAgentClient {
fetchCatalogCalls = 0; fetchCatalogCalls = 0;
createSessionCalls = 0; createSessionCalls = 0;
@@ -2398,9 +2398,9 @@ test("listDraftControls uses client feature listing without a model", async () =
return await super.createSession(config); return await super.createSession(config);
} }
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> { async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
this.featureConfigs.push(config); this.featureConfigs.push(config);
return [draftControl]; return [draftFeature];
} }
} }
const client = new DraftFeatureClient(); const client = new DraftFeatureClient();
@@ -2412,13 +2412,9 @@ test("listDraftControls uses client feature listing without a model", async () =
logger, logger,
}); });
await expect( await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
manager.listDraftControls({ draftFeature,
provider: "codex", ]);
cwd: workdir,
providerOptions: { auto_accept: true },
}),
).resolves.toEqual({ controls: [draftControl], options: { auto_accept: true } });
expect(client.fetchCatalogCalls).toBe(0); expect(client.fetchCatalogCalls).toBe(0);
expect(client.createSessionCalls).toBe(0); expect(client.createSessionCalls).toBe(0);
@@ -2427,28 +2423,15 @@ test("listDraftControls uses client feature listing without a model", async () =
{ {
provider: "codex", provider: "codex",
cwd: workdir, 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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-draft-features-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger); 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 { class DraftFeatureClient extends TestAgentClient {
fetchCatalogCalls = 0; fetchCatalogCalls = 0;
createSessionCalls = 0; createSessionCalls = 0;
@@ -2464,9 +2447,9 @@ test("listDraftControls uses explicit model config without default model fetchin
return await super.createSession(config); return await super.createSession(config);
} }
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> { async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
this.featureConfigs.push(config); this.featureConfigs.push(config);
return [draftControl]; return [draftFeature];
} }
} }
const client = new DraftFeatureClient(); const client = new DraftFeatureClient();
@@ -2478,13 +2461,13 @@ test("listDraftControls uses explicit model config without default model fetchin
logger, logger,
}); });
const draft = await manager.listDraftControls({ const features = await manager.listDraftFeatures({
provider: "codex", provider: "codex",
cwd: workdir, cwd: workdir,
model: "gpt-5.4", model: "gpt-5.4",
}); });
expect(draft).toEqual({ controls: [draftControl], options: {} }); expect(features).toEqual([draftFeature]);
expect(client.fetchCatalogCalls).toBe(0); expect(client.fetchCatalogCalls).toBe(0);
expect(client.createSessionCalls).toBe(0); expect(client.createSessionCalls).toBe(0);
expect(client.featureConfigs).toEqual([ expect(client.featureConfigs).toEqual([
@@ -4567,46 +4550,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 () => { test("importProviderSession imports the selected session without listing and publishes ready state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-")); const workdir = mkdtempSync(join(tmpdir(), "agent-manager-import-session-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
@@ -10041,13 +9984,16 @@ test("respondToPermission updates currentModeId after plan approval", async () =
expect(client.resumedModeId).toBe("acceptEdits"); 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 workdir = mkdtempSync(join(tmpdir(), "agent-manager-test-"));
const storagePath = join(workdir, "agents"); const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger); const storage = new AgentStorage(storagePath, logger);
class RefreshingPermissionSession extends TestAgentSession { 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 modeId = "auto";
private pending = [ private pending = [
{ {
@@ -10059,11 +10005,8 @@ test("respondToPermission applies provider option changes and refreshes runtime
}, },
]; ];
get controls(): AgentOptionControl[] { get features(): AgentFeature[] {
return [ return this.featureState;
createControl({ key: "web_search", label: "Fast" }),
createControl({ key: "plan_mode", label: "Plan" }),
];
} }
override async getRuntimeInfo() { override async getRuntimeInfo() {
@@ -10072,7 +10015,7 @@ test("respondToPermission applies provider option changes and refreshes runtime
sessionId: this.id, sessionId: this.id,
model: "gpt-5.4", model: "gpt-5.4",
modeId: this.modeId, modeId: this.modeId,
extra: { collaborationMode: this.planMode ? "Plan" : "Code" }, extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" },
}; };
} }
@@ -10087,12 +10030,10 @@ test("respondToPermission applies provider option changes and refreshes runtime
override async respondToPermission(): Promise<void> { override async respondToPermission(): Promise<void> {
this.modeId = "auto"; this.modeId = "auto";
this.pending = []; this.pending = [];
this.planMode = false; this.featureState = [
this.pushEvent({ createFeature({ id: "fast_mode", label: "Fast", value: true }),
type: "options_changed", createFeature({ id: "plan_mode", label: "Plan", value: false }),
provider: this.provider, ];
patch: { plan_mode: false },
});
} }
} }
@@ -10115,7 +10056,6 @@ test("respondToPermission applies provider option changes and refreshes runtime
{ {
provider: "codex", provider: "codex",
cwd: workdir, cwd: workdir,
providerOptions: { web_search: true, plan_mode: true },
}, },
undefined, undefined,
{ workspaceId: undefined }, { workspaceId: undefined },
@@ -10140,14 +10080,20 @@ test("respondToPermission applies provider option changes and refreshes runtime
const updated = manager.getAgent(snapshot.id); const updated = manager.getAgent(snapshot.id);
expect(updated?.pendingPermissions.size).toBe(0); 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({ expect(updated?.runtimeInfo).toMatchObject({
model: "gpt-5.4", model: "gpt-5.4",
extra: { collaborationMode: "Code" }, extra: { collaborationMode: "Code" },
}); });
const persisted = await storage.get(snapshot.id); 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 () => { test("respondToPermission emits refreshed state before permission_resolved", async () => {
@@ -10156,6 +10102,9 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
const storage = new AgentStorage(storagePath, logger); const storage = new AgentStorage(storagePath, logger);
class OrderedPermissionSession extends TestAgentSession { class OrderedPermissionSession extends TestAgentSession {
private featureState: AgentFeature[] = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
];
private modeId = "plan"; private modeId = "plan";
private pending = [ private pending = [
{ {
@@ -10167,8 +10116,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
}, },
]; ];
get controls(): AgentOptionControl[] { get features(): AgentFeature[] {
return [createControl({ key: "web_search", label: "Fast" })]; return this.featureState;
} }
override async getRuntimeInfo() { override async getRuntimeInfo() {
@@ -10189,11 +10138,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
} }
override async respondToPermission(): Promise<void> { override async respondToPermission(): Promise<void> {
this.pushEvent({
type: "options_changed",
provider: this.provider,
patch: { web_search: false },
});
this.pushEvent({ this.pushEvent({
type: "permission_resolved", type: "permission_resolved",
provider: this.provider, provider: this.provider,
@@ -10201,6 +10145,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
resolution: { behavior: "allow" }, resolution: { behavior: "allow" },
}); });
this.modeId = "acceptEdits"; this.modeId = "acceptEdits";
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
this.pending = []; this.pending = [];
} }
} }
@@ -10224,7 +10169,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
{ {
provider: "codex", provider: "codex",
cwd: workdir, cwd: workdir,
providerOptions: { web_search: true },
}, },
undefined, undefined,
{ workspaceId: undefined }, { workspaceId: undefined },
@@ -10236,8 +10180,9 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
return; return;
} }
if (event.type === "agent_state" && event.agent.id === snapshot.id) { if (event.type === "agent_state" && event.agent.id === snapshot.id) {
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
seen.push( seen.push(
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.web_search)}`, `state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
); );
return; return;
} }
@@ -12750,478 +12695,3 @@ test("an out-of-band command racing idle cleanup waits for the resumed runtime",
expect(h.commands).toEqual(["/maintenance"]); expect(h.commands).toEqual(["/maintenance"]);
expect(h.prompts).toEqual([]); 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);
});
});
});
+31 -224
View File
@@ -1,5 +1,4 @@
import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js"; import type { AttachedAgentTool, PluginAgentToolsRuntime } from "../plugins/agent-tools.js";
import { ProviderOptionsValidationError } from "./provider-options.js";
import type { AgentToolResult } from "@getpaseo/plugin/server"; import type { AgentToolResult } from "@getpaseo/plugin/server";
import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js"; import type { RuntimeCleanupCandidate } from "./idle-runtime-cleanup/index.js";
import { ensureAgentLoaded } from "./agent-loading.js"; import { ensureAgentLoaded } from "./agent-loading.js";
@@ -34,7 +33,7 @@ import {
type AgentCreateSessionOptions, type AgentCreateSessionOptions,
type AgentResumePurpose, type AgentResumePurpose,
type AgentResumeSessionOptions, type AgentResumeSessionOptions,
type AgentOptionControl, type AgentFeature,
type AgentLaunchContext, type AgentLaunchContext,
type AgentSlashCommand, type AgentSlashCommand,
type AgentMode, type AgentMode,
@@ -60,16 +59,7 @@ import {
type ListImportableSessionsOptions, type ListImportableSessionsOptions,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { toStoredAgentRecord } from "./agent-projections.js"; 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 { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js"; import type { AgentOwner } from "./agent-owner.js";
import { import {
@@ -220,6 +210,9 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
if (record.config.thinkingOptionId != null) { if (record.config.thinkingOptionId != null) {
config.thinkingOptionId = record.config.thinkingOptionId; config.thinkingOptionId = record.config.thinkingOptionId;
} }
if (record.config.featureValues != null) {
config.featureValues = record.config.featureValues;
}
if (record.config.providerOptions != null) { if (record.config.providerOptions != null) {
config.providerOptions = record.config.providerOptions; config.providerOptions = record.config.providerOptions;
} }
@@ -435,9 +428,7 @@ interface ManagedAgentBase {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
availableModes: AgentMode[]; availableModes: AgentMode[];
controls?: AgentOptionControl[]; features?: AgentFeature[];
/** `config.json` defaults deep-merged with `config.providerOptions`; refreshed on every state emit. */
effectiveOptions?: ProviderOptions;
currentModeId: string | null; currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>; pendingPermissions: Map<string, AgentPermissionRequest>;
bufferedPermissionResolutions: Map< bufferedPermissionResolutions: Map<
@@ -1252,17 +1243,11 @@ export class AgentManager {
} }
} }
async listDraftControls( async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
config: AgentSessionConfig,
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }> {
const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false }); const normalizedConfig = await this.normalizeConfig(config, { resolveDefaultModel: false });
const client = this.requireClient(normalizedConfig.provider); const client = this.requireClient(normalizedConfig.provider);
const options = this.effectiveOptionsFor( if (!normalizedConfig.model && !client.listFeatures) {
normalizedConfig.provider, return [];
normalizedConfig.providerOptions,
);
if (!normalizedConfig.model && !client.listControls) {
return { controls: [], options };
} }
const available = await client.isAvailable(); const available = await client.isAvailable();
if (!available) { if (!available) {
@@ -1271,18 +1256,13 @@ export class AgentManager {
); );
} }
if (client.listControls) { if (client.listFeatures) {
return { controls: await client.listControls(normalizedConfig), options }; return await client.listFeatures(normalizedConfig);
} }
const session = await client.createSession(normalizedConfig); const session = await client.createSession(normalizedConfig);
try { try {
// The options a session opens with, as create would report them, so the controls' return session.features ?? [];
// selection matches what the first turn runs with.
return {
controls: session.controls ?? [],
options: this.openedEffectiveOptions({ session, launchedOptions: options }),
};
} finally { } finally {
try { try {
await session.close(); await session.close();
@@ -1380,23 +1360,12 @@ export class AgentManager {
): Promise<ManagedAgent> { ): Promise<ManagedAgent> {
this.assertAcceptingAgentRegistrations(); this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
config = this.withConvertedOptions(config);
if (this.pluginLifecycle && !config.internal) { if (this.pluginLifecycle && !config.internal) {
const sent = exposeFeatureValuesToHook(config);
const request = await this.pluginLifecycle.before("agent.create.before", { const request = await this.pluginLifecycle.before("agent.create.before", {
config: sent, config,
env: options.env, env: options.env,
}); });
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written config = { ...request.config, internal: config.internal };
// 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 }; options = { ...options, env: request.env };
} }
await this.deleteAgentState(resolvedAgentId); await this.deleteAgentState(resolvedAgentId);
@@ -1426,11 +1395,9 @@ export class AgentManager {
); );
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const createOptions = this.buildCreateSessionOptions(options); const createOptions = this.buildCreateSessionOptions(options);
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
const session = await client.createSession(providerLaunchConfig, launchContext, createOptions); const session = await client.createSession(providerLaunchConfig, launchContext, createOptions);
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
const agent = await this.registerSession(session, storedConfig, resolvedAgentId, { const agent = await this.registerSession(session, storedConfig, resolvedAgentId, {
launchedOptions,
labels: options.labels, labels: options.labels,
initialTitle: options.initialTitle, initialTitle: options.initialTitle,
workspaceId: options.workspaceId, workspaceId: options.workspaceId,
@@ -1553,7 +1520,6 @@ export class AgentManager {
}, },
); );
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
const session = await client.resumeSession( const session = await client.resumeSession(
handle, handle,
providerLaunchConfig, providerLaunchConfig,
@@ -1562,7 +1528,6 @@ export class AgentManager {
); );
await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext); await this.requireExternalMcpSupport(session, providerLaunchConfig, launchContext);
return this.registerSession(session, storedConfig, resolvedAgentId, { return this.registerSession(session, storedConfig, resolvedAgentId, {
launchedOptions,
...options, ...options,
launchEnv, launchEnv,
persistence: handle, persistence: handle,
@@ -1618,7 +1583,6 @@ export class AgentManager {
}, },
); );
const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext); const providerLaunchConfig = this.resolveProviderLaunchConfig(launchConfig, launchContext);
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
const imported = await client.importSession( const imported = await client.importSession(
{ {
providerHandleId: input.providerHandleId, providerHandleId: input.providerHandleId,
@@ -1637,7 +1601,6 @@ export class AgentManager {
handedToRegistration = true; handedToRegistration = true;
const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, { const agent = await this.registerSession(imported.session, importedConfig, resolvedAgentId, {
launchedOptions,
labels: input.labels, labels: input.labels,
workspaceId: input.workspaceId, workspaceId: input.workspaceId,
timelineRows, timelineRows,
@@ -1743,7 +1706,6 @@ export class AgentManager {
this.assertAcceptingAgentRegistrations(); this.assertAcceptingAgentRegistrations();
this.paseoToolPolicies.set(agentId, paseoToolPolicy); this.paseoToolPolicies.set(agentId, paseoToolPolicy);
const launchedOptions = this.launchOptionsFor({ client, own: storedConfig.providerOptions });
session = handle session = handle
? await client.resumeSession(handle, providerLaunchConfig, launchContext) ? await client.resumeSession(handle, providerLaunchConfig, launchContext)
: await client.createSession(providerLaunchConfig, launchContext); : await client.createSession(providerLaunchConfig, launchContext);
@@ -1762,7 +1724,6 @@ export class AgentManager {
// Preserve existing labels and timeline during reload. // Preserve existing labels and timeline during reload.
handedToRegistration = true; handedToRegistration = true;
return this.registerSession(session, storedConfig, agentId, { return this.registerSession(session, storedConfig, agentId, {
launchedOptions,
labels: existing.labels, labels: existing.labels,
workspaceId: existing.workspaceId, workspaceId: existing.workspaceId,
owner: existing.owner, owner: existing.owner,
@@ -2200,11 +2161,7 @@ export class AgentManager {
createdAt: new Date(record.createdAt), createdAt: new Date(record.createdAt),
updatedAt, updatedAt,
availableModes: [], availableModes: [],
controls: record.controls, features: record.features,
effectiveOptions: this.effectiveOptionsFor(
record.provider,
record.config?.providerOptions ?? undefined,
),
currentModeId: record.lastModeId ?? null, currentModeId: record.lastModeId ?? null,
pendingPermissions: new Map(), pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(), bufferedPermissionResolutions: new Map(),
@@ -2297,109 +2254,20 @@ export class AgentManager {
return notice; return notice;
} }
/** async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
* 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 = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
const patch = this.convertLegacyOptions(agent.provider, requested);
let ended: ProviderOptionsPatch = {}; if (!agent.session.setFeature) {
let sent: ProviderOptionsPatch = {}; throw new Error("Agent session does not support setting features");
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);
} }
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.touchUpdatedAt(agent);
this.emitState(agent); this.emitState(agent);
} }
/** The config as the opened session left it: its own difference from the options it was given. */
private withOpenedOptions(input: {
config: AgentSessionConfig;
session: AgentSession;
}): AgentSessionConfig {
const { config } = input;
const opened = input.session.openOptionsPatch;
if (!opened || Object.keys(opened).length === 0) return config;
const own = applyProviderOptionsPatch({ options: config.providerOptions, patch: opened });
return { ...config, providerOptions: Object.keys(own).length > 0 ? own : undefined };
}
private storeOptionsPatch(input: { agent: ManagedAgent; patch: ProviderOptionsPatch }): void {
const { agent } = input;
const own = applyProviderOptionsPatch({
options: agent.config.providerOptions,
patch: input.patch,
});
agent.config.providerOptions = Object.keys(own).length > 0 ? own : undefined;
}
/**
* What the running session has: the options it opened with, moved only by what an apply sent
* it (defaults merged in, `null` where a removal left nothing) and by what the provider reports
* back. The agent's own stored options keep only the raw patch.
*/
private updateEffectiveOptions(input: {
agent: ManagedAgent;
sent: ProviderOptionsPatch;
reported: ProviderOptionsPatch;
}): void {
input.agent.effectiveOptions = applyProviderOptionsPatch({
options: input.agent.effectiveOptions,
patch: { ...input.sent, ...input.reported },
});
}
/**
* Defaults merge into what the session was given; what it reports it ended up with comes
* after, so an option it dropped does not come back from a default.
*/
private openedEffectiveOptions(input: {
session: AgentSession;
launchedOptions: ProviderOptions;
}): ProviderOptions {
return applyProviderOptionsPatch({
options: input.launchedOptions,
patch: input.session.openOptionsPatch ?? {},
});
}
/** The options a client opens with: its own defaults merged under the agent's. */
private launchOptionsFor(input: {
client: AgentClient;
own: ProviderOptions | undefined;
}): ProviderOptions {
return input.client.effectiveOptions?.(input.own) ?? input.own ?? {};
}
private effectiveOptionsFor(
provider: AgentProvider,
own: ProviderOptions | undefined,
): ProviderOptions {
const client = this.clients.get(provider);
return client ? this.launchOptionsFor({ client, own }) : (own ?? {});
}
async setTitle(agentId: string, title: string): Promise<void> { async setTitle(agentId: string, title: string): Promise<void> {
const agent = this.requireAgent(agentId); const agent = this.requireAgent(agentId);
const normalizedTitle = title.trim(); const normalizedTitle = title.trim();
@@ -3912,9 +3780,9 @@ export class AgentManager {
private async registerSession( private async registerSession(
session: AgentSession, session: AgentSession,
openedConfig: AgentSessionConfig, config: AgentSessionConfig,
agentId: string, agentId: string,
options: { options?: {
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
lastUserMessageAt?: Date | null; lastUserMessageAt?: Date | null;
@@ -3932,12 +3800,6 @@ export class AgentManager {
* attention come from what was already recorded, and installing the session is not * attention come from what was already recorded, and installing the session is not
* activity in it. * 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; restoring?: boolean;
initialTitle?: string | null; initialTitle?: string | null;
publishWhenReady?: boolean; publishWhenReady?: boolean;
@@ -3949,7 +3811,6 @@ export class AgentManager {
let registered = false; let registered = false;
try { try {
this.assertAcceptingAgentRegistrations(); this.assertAcceptingAgentRegistrations();
const config = this.withOpenedOptions({ config: openedConfig, session });
const resolvedAgentId = validateAgentId(agentId, "registerSession"); const resolvedAgentId = validateAgentId(agentId, "registerSession");
if (this.agents.has(resolvedAgentId)) { if (this.agents.has(resolvedAgentId)) {
throw new Error(`Agent with id ${resolvedAgentId} already exists`); throw new Error(`Agent with id ${resolvedAgentId} already exists`);
@@ -3971,10 +3832,6 @@ export class AgentManager {
resolvedAgentId, resolvedAgentId,
session, session,
config, config,
effectiveOptions: this.openedEffectiveOptions({
session,
launchedOptions: options.launchedOptions,
}),
now, now,
durableTimelineHasRows, durableTimelineHasRows,
options, options,
@@ -4119,7 +3976,6 @@ export class AgentManager {
resolvedAgentId: string; resolvedAgentId: string;
session: AgentSession; session: AgentSession;
config: AgentSessionConfig; config: AgentSessionConfig;
effectiveOptions: ProviderOptions;
now: Date; now: Date;
durableTimelineHasRows: boolean; durableTimelineHasRows: boolean;
options: options:
@@ -4139,15 +3995,7 @@ export class AgentManager {
} }
| undefined; | undefined;
}): ActiveManagedAgent { }): ActiveManagedAgent {
const { const { resolvedAgentId, session, config, now, durableTimelineHasRows, options } = params;
resolvedAgentId,
session,
config,
effectiveOptions,
now,
durableTimelineHasRows,
options,
} = params;
return { return {
id: resolvedAgentId, id: resolvedAgentId,
provider: config.provider, provider: config.provider,
@@ -4158,8 +4006,6 @@ export class AgentManager {
session, session,
capabilities: session.capabilities, capabilities: session.capabilities,
config, config,
// What the session opened with. Provider defaults edited later reach it when it next opens.
effectiveOptions,
runtimeInfo: undefined, runtimeInfo: undefined,
lifecycle: "initializing", lifecycle: "initializing",
createdAt: options?.createdAt ?? now, createdAt: options?.createdAt ?? now,
@@ -4465,7 +4311,7 @@ export class AgentManager {
agent.pendingPermissions.clear(); agent.pendingPermissions.clear();
} }
this.syncControls(agent); this.syncFeaturesFromSession(agent);
await this.refreshRuntimeInfo(agent, options); await this.refreshRuntimeInfo(agent, options);
} }
@@ -4811,18 +4657,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: { private dispatchStreamEventByType(params: {
agent: ActiveManagedAgent; agent: ActiveManagedAgent;
event: AgentStreamEvent; event: AgentStreamEvent;
@@ -4874,9 +4708,6 @@ export class AgentManager {
flags.shouldDispatchEvent = false; flags.shouldDispatchEvent = false;
this.emitState(agent); this.emitState(agent);
return undefined; return undefined;
case "options_changed":
this.onStreamOptionsChanged({ agent, patch: event.patch, flags });
return undefined;
case "timeline": case "timeline":
return this.onStreamTimelineEvent({ agent, event, options, flags }); return this.onStreamTimelineEvent({ agent, event, options, flags });
case "turn_completed": case "turn_completed":
@@ -5352,7 +5183,7 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent); this.enqueueBackgroundPersist(agent);
} }
this.syncControls(agent); this.syncFeaturesFromSession(agent);
this.logger.trace( this.logger.trace(
{ {
@@ -5374,9 +5205,9 @@ export class AgentManager {
}); });
} }
private syncControls(agent: ManagedAgent): void { private syncFeaturesFromSession(agent: ManagedAgent): void {
if ("session" in agent && agent.session?.controls) { if ("session" in agent && agent.session?.features) {
agent.controls = agent.session.controls; agent.features = agent.session.features;
} }
} }
@@ -5623,7 +5454,7 @@ export class AgentManager {
config: AgentSessionConfig, config: AgentSessionConfig,
options: NormalizeConfigOptions = {}, options: NormalizeConfigOptions = {},
): Promise<AgentSessionConfig> { ): Promise<AgentSessionConfig> {
const normalized: AgentSessionConfig = { ...this.withConvertedOptions(config) }; const normalized: AgentSessionConfig = { ...config };
// Always resolve cwd to absolute path for consistent history file lookup // Always resolve cwd to absolute path for consistent history file lookup
if (normalized.cwd) { if (normalized.cwd) {
@@ -5695,30 +5526,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( private async prepareSessionConfig(
config: AgentSessionConfig, config: AgentSessionConfig,
agentId: string, agentId: string,
@@ -10,7 +10,7 @@ import {
} from "./agent-projections.js"; } from "./agent-projections.js";
import type { AgentSession } from "./agent-sdk-types.js"; import type { AgentSession } from "./agent-sdk-types.js";
import type { import type {
AgentOptionControl, AgentFeature,
ImportableProviderSession, ImportableProviderSession,
AgentPermissionRequest, AgentPermissionRequest,
AgentPersistenceHandle, AgentPersistenceHandle,
@@ -128,6 +128,16 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
return { ...base, ...overrides }; return { ...base, ...overrides };
} }
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
return {
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: true,
...overrides,
};
}
describe("toStoredAgentRecord", () => { describe("toStoredAgentRecord", () => {
it("captures lifecycle metadata, config, and persistence", () => { it("captures lifecycle metadata, config, and persistence", () => {
const agent = createManagedAgent({ const agent = createManagedAgent({
@@ -397,50 +407,6 @@ 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"]);
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("carries a stored lastError onto the payload", () => { it("carries a stored lastError onto the payload", () => {
const record = toStoredAgentRecord( const record = toStoredAgentRecord(
createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }), createManagedAgent({ provider: "codex", config: { provider: "codex" }, lastError: "boom" }),
@@ -508,44 +474,13 @@ describe("toAgentPayload", () => {
}); });
}); });
it("projects controls, effective options, and legacy features from the same source", () => { it("includes features in the snapshot payload", () => {
const controls: AgentOptionControl[] = [ const features = [createFeature()];
{ type: "toggle", key: "fast_mode", label: "Fast mode" }, const agent = createManagedAgent({ features });
{
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" },
});
const payload = toAgentPayload(agent); const payload = toAgentPayload(agent);
expect(payload.controls).toEqual(controls); expect(payload.features).toEqual(features);
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" },
],
},
]);
}); });
}); });
@@ -7,7 +7,7 @@ import type {
import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js"; import type { SerializableAgentConfig, StoredAgentRecord } from "./agent-storage.js";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentOptionControl, AgentFeature,
AgentMetadata, AgentMetadata,
AgentMode, AgentMode,
AgentPermissionRequest, AgentPermissionRequest,
@@ -18,9 +18,7 @@ import type {
AgentUsage, AgentUsage,
ImportableProviderSession, ImportableProviderSession,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { ManagedAgent } from "./agent-manager.js"; import type { ManagedAgent } from "./agent-manager.js";
import { featuresFromControls } from "@getpaseo/protocol/option-controls";
import type { JsonValue } from "../json-utils.js"; import type { JsonValue } from "../json-utils.js";
import { import {
isStoredAgentProviderAvailable, isStoredAgentProviderAvailable,
@@ -99,7 +97,7 @@ export function toStoredAgentRecord(
config: config ?? null, config: config ?? null,
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined, launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
runtimeInfo, runtimeInfo,
controls: normalizeControls(agent.controls), features: normalizeFeatures(agent.features),
persistence, persistence,
lastError: agent.lastError ?? undefined, lastError: agent.lastError ?? undefined,
requiresAttention: agent.attention.requiresAttention, requiresAttention: agent.attention.requiresAttention,
@@ -145,12 +143,7 @@ export function toAgentPayload(
capabilities: cloneCapabilities(agent.capabilities), capabilities: cloneCapabilities(agent.capabilities),
currentModeId: agent.currentModeId, currentModeId: agent.currentModeId,
availableModes: cloneAvailableModes(agent.availableModes), availableModes: cloneAvailableModes(agent.availableModes),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. features: normalizeFeatures(agent.features),
features: featuresFromControls({ controls: agent.controls, options: agent.effectiveOptions }),
...(agent.effectiveOptions
? { options: sanitizeEffectiveOptions(agent.effectiveOptions) }
: {}),
controls: normalizeControls(agent.controls),
pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions), pendingPermissions: sanitizePendingPermissions(agent.pendingPermissions),
persistence: projectPersistenceHandleForWire(agent.persistence), persistence: projectPersistenceHandleForWire(agent.persistence),
title: options?.title ?? null, title: options?.title ?? null,
@@ -213,21 +206,6 @@ function buildStoredPersistenceHandle(
return toAgentPersistenceHandle(validProviders, record.persistence); return toAgentPersistenceHandle(validProviders, record.persistence);
} }
function buildStoredOptionControls(
record: StoredAgentRecord,
): Pick<AgentSnapshotPayload, "features" | "options" | "controls"> {
const controls = normalizeControls(record.controls);
const options = record.config?.providerOptions
? sanitizeEffectiveOptions(record.config.providerOptions)
: undefined;
return {
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
features: featuresFromControls({ controls, options }),
...(options ? { options } : {}),
controls,
};
}
export function buildStoredAgentPayload( export function buildStoredAgentPayload(
record: StoredAgentRecord, record: StoredAgentRecord,
validProviders: Iterable<AgentProvider>, validProviders: Iterable<AgentProvider>,
@@ -249,7 +227,6 @@ export function buildStoredAgentPayload(
const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null; const lastUserMessageAt = record.lastUserMessageAt ? new Date(record.lastUserMessageAt) : null;
const runtimeInfo = buildStoredRuntimeInfo(record); const runtimeInfo = buildStoredRuntimeInfo(record);
const optionControls = buildStoredOptionControls(record);
const providerAvailable = isStoredAgentProviderAvailable(record, validProviders); const providerAvailable = isStoredAgentProviderAvailable(record, validProviders);
const persistence = projectPersistenceHandleForWire( const persistence = projectPersistenceHandleForWire(
buildStoredPersistenceHandle(record, validProviders), buildStoredPersistenceHandle(record, validProviders),
@@ -274,7 +251,6 @@ export function buildStoredAgentPayload(
capabilities: defaultCapabilities, capabilities: defaultCapabilities,
currentModeId: record.lastModeId ?? null, currentModeId: record.lastModeId ?? null,
availableModes: [], availableModes: [],
...optionControls,
pendingPermissions: [], pendingPermissions: [],
persistence, persistence,
title: record.title ?? null, title: record.title ?? null,
@@ -399,6 +375,12 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
if (config.thinkingOptionId) { if (config.thinkingOptionId) {
serializable.thinkingOptionId = 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) { if (config.providerOptions !== undefined) {
const providerOptions = sanitizeOptionalJson(config.providerOptions); const providerOptions = sanitizeOptionalJson(config.providerOptions);
if (providerOptions && isJsonObject(providerOptions)) { if (providerOptions && isJsonObject(providerOptions)) {
@@ -474,15 +456,8 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
return modes.map((mode) => ({ ...mode })); return modes.map((mode) => ({ ...mode }));
} }
function normalizeControls( function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
controls: AgentOptionControl[] | null | undefined, return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
): 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 sanitizeOptionalJson(value: unknown): JsonValue | undefined { function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
@@ -1,7 +1,7 @@
import type { import type {
AgentOptionControl, AgentFeature,
AgentOptionSelectControl, AgentFeatureSelect,
AgentOptionToggleControl, AgentFeatureToggle,
AgentProviderNotice, AgentProviderNotice,
AgentTaskItem, AgentTaskItem,
JsonValue, JsonValue,
@@ -13,9 +13,9 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js"; import type { PaseoToolCatalog } from "./tools/types.js";
export type { export type {
AgentOptionControl, AgentFeature,
AgentOptionSelectControl, AgentFeatureSelect,
AgentOptionToggleControl, AgentFeatureToggle,
AgentProviderNotice, AgentProviderNotice,
AgentTaskItem, AgentTaskItem,
}; };
@@ -148,7 +148,7 @@ export interface AgentCreateConfigParent {
export interface ResolveAgentCreateConfigInput { export interface ResolveAgentCreateConfigInput {
provider: AgentProvider; provider: AgentProvider;
requestedMode: string | undefined; requestedMode: string | undefined;
providerOptions: ProviderOptions | undefined; featureValues: Record<string, unknown> | undefined;
parent: AgentCreateConfigParent | null; parent: AgentCreateConfigParent | null;
unattended: boolean; unattended: boolean;
availableModes: AgentMode[] | undefined; availableModes: AgentMode[] | undefined;
@@ -156,13 +156,13 @@ export interface ResolveAgentCreateConfigInput {
export interface ResolveAgentCreateConfigResult { export interface ResolveAgentCreateConfigResult {
modeId: string | undefined; modeId: string | undefined;
providerOptions: ProviderOptions | undefined; featureValues: Record<string, unknown> | undefined;
} }
export interface AgentCreateConfigUnattendedInput { export interface AgentCreateConfigUnattendedInput {
modeId: string | null; modeId: string | null;
config: AgentSessionConfig; config: AgentSessionConfig;
controls?: AgentOptionControl[]; features?: AgentFeature[];
availableModes: AgentMode[]; availableModes: AgentMode[];
} }
@@ -429,14 +429,6 @@ export type AgentStreamEvent =
provider: AgentProvider; provider: AgentProvider;
thinkingOptionId: string | null; 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"; type: "turn_failed";
provider: AgentProvider; provider: AgentProvider;
@@ -618,6 +610,7 @@ export interface AgentSessionConfig {
modeId?: string; modeId?: string;
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
title?: string | null; title?: string | null;
providerOptions?: ProviderOptions; providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy; toolPolicy?: ToolPolicy;
@@ -686,14 +679,7 @@ export interface AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly id: string | null; readonly id: string | null;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
/** Composer controls over top-level option keys. Values come from the agent's effective options. */ readonly features?: AgentFeature[];
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>;
/** New provider-owned rows to commit on registration. streamHistory must also /** New provider-owned rows to commit on registration. streamHistory must also
* replay them at their original timestamps; restored sessions omit old rows. */ * replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[]; readonly initialTimeline?: ImportedTimelineEntry[];
@@ -728,20 +714,7 @@ export interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>; listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>; setModel?(modelId: string | null): Promise<void>;
setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>; setThinkingOption?(thinkingOptionId: string | null): Promise<void | AgentProviderNotice>;
/** setFeature?(featureId: string, value: unknown): Promise<void>;
* 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>;
revertConversation?(input: { messageId: string }): Promise<void>; revertConversation?(input: { messageId: string }): Promise<void>;
revertFiles?(input: { messageId: string }): Promise<void>; revertFiles?(input: { messageId: string }): Promise<void>;
revertBoth?(input: { messageId: string }): Promise<void>; revertBoth?(input: { messageId: string }): Promise<void>;
@@ -791,17 +764,11 @@ export interface ResolveAgentDefaultModeInput {
export interface AgentClient { export interface AgentClient {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly capabilities: AgentCapabilityFlags; 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( createSession(
config: AgentSessionConfig, config: AgentSessionConfig,
launchContext?: AgentLaunchContext, launchContext?: AgentLaunchContext,
options?: AgentCreateSessionOptions, options?: AgentCreateSessionOptions,
): Promise<AgentSession>; ): Promise<AgentSession>;
/** Same contract as `createSession`: open with the options, report any difference in `openOptionsPatch`. */
resumeSession( resumeSession(
handle: AgentPersistenceHandle, handle: AgentPersistenceHandle,
overrides?: Partial<AgentSessionConfig>, overrides?: Partial<AgentSessionConfig>,
@@ -830,9 +797,7 @@ export interface AgentClient {
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult; resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean; isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>; listCommands?(config: AgentSessionConfig): Promise<AgentSlashCommand[]>;
listControls?(config: AgentSessionConfig): Promise<AgentOptionControl[]>; listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>;
/** Effective options: provider defaults from `config.json` deep-merged with the agent's own. */
effectiveOptions?(own: ProviderOptions | undefined): ProviderOptions;
listImportableSessions?( listImportableSessions?(
options?: ListImportableSessionsOptions, options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>; ): Promise<ImportableProviderSession[]>;
@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach, afterEach } from "vitest"; import { describe, expect, test, beforeEach, afterEach } from "vitest";
import os from "node:os"; import os from "node:os";
import path from "node:path"; 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 { promises as fs } from "node:fs";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
@@ -44,6 +44,9 @@ function buildManagedAgentConfig(
systemPrompt: configOverrides.systemPrompt, systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers, mcpServers: configOverrides.mcpServers,
}; };
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
config.featureValues = configOverrides.featureValues;
}
return config; return config;
} }
@@ -129,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", () => { describe("AgentStorage", () => {
let tmpDir: string; let tmpDir: string;
let storagePath: string; let storagePath: string;
@@ -200,75 +195,60 @@ describe("AgentStorage", () => {
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] }); 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( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: "agent-provider-options", id: "agent-feature-values",
config: { providerOptions: { fast_mode: true } }, config: {
featureValues: {
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("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 reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-provider-options"); const persisted = await reloaded.get("agent-no-feature-values");
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true }); expect(persisted?.config?.featureValues).toBeUndefined();
expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true }); expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
expect(buildConfigOverrides(persisted!)).toMatchObject({
cwd: "/tmp/project",
providerOptions: { fast_mode: true },
});
}); });
test("a record stored with featureValues loads them in providerOptions", async () => { test("buildConfigOverrides includes featureValues when present in stored config", async () => {
await storage.applySnapshot(createManagedAgent({ id: "agent-legacy" })); await storage.applySnapshot(
const file = findRecordFile(storagePath, "agent-legacy"); createManagedAgent({
const stored = JSON.parse(await fs.readFile(file, "utf8")); id: "agent-resume-overrides",
stored.config.providerOptions = { allowedTools: ["Read"], fast_mode: false }; config: {
stored.config.featureValues = { fast_mode: true, service_tier: "fast" }; featureValues: {
delete stored.controls; fast_mode: true,
stored.features = [ },
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true }, },
{ }),
type: "select", );
id: "service_tier",
label: "Speed", const record = await storage.get("agent-resume-overrides");
value: "fast", expect(record).not.toBeNull();
options: [ expect(buildConfigOverrides(record!)).toMatchObject({
{ id: "default", label: "Normal", isDefault: true }, cwd: "/tmp/project",
{ id: "fast", label: "Fast" }, featureValues: {
], fast_mode: true,
}, },
];
await fs.writeFile(file, JSON.stringify(stored));
const reloaded = new AgentStorage(storagePath, logger);
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 () => { test("applySnapshot preserves original createdAt timestamp", async () => {
@@ -4,20 +4,17 @@ import { z } from "zod";
import type { Logger } from "pino"; import type { Logger } from "pino";
import { writeJsonFileAtomic } from "../atomic-file.js"; 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 { toStoredAgentRecord } from "./agent-projections.js";
import type { ManagedAgent } from "./agent-manager.js"; import type { ManagedAgent } from "./agent-manager.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js"; import type { AgentSessionConfig } from "./agent-sdk-types.js";
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.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 const SERIALIZABLE_CONFIG_SCHEMA = z
.object({ .object({
modeId: z.string().nullable().optional(), modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(), model: z.string().nullable().optional(),
thinkingOptionId: 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(), featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(), providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
toolPolicy: z toolPolicy: z
@@ -45,7 +42,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z
.nullable() .nullable()
.optional(); .optional();
const RAW_STORED_AGENT_SCHEMA = z.object({ const STORED_AGENT_SCHEMA = z.object({
id: z.string(), id: z.string(),
provider: z.string(), provider: z.string(),
cwd: z.string(), cwd: z.string(),
@@ -71,8 +68,6 @@ const RAW_STORED_AGENT_SCHEMA = z.object({
extra: z.record(z.string(), z.unknown()).optional(), extra: z.record(z.string(), z.unknown()).optional(),
}) })
.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(), features: z.array(AgentFeatureSchema).optional(),
persistence: PERSISTENCE_HANDLE_SCHEMA, persistence: PERSISTENCE_HANDLE_SCHEMA,
lastError: z.string().nullable().optional(), lastError: z.string().nullable().optional(),
@@ -89,32 +84,14 @@ export type SerializableAgentConfig = Pick<
| "modeId" | "modeId"
| "model" | "model"
| "thinkingOptionId" | "thinkingOptionId"
| "featureValues"
| "providerOptions" | "providerOptions"
| "toolPolicy" | "toolPolicy"
| "systemPrompt" | "systemPrompt"
| "mcpServers" | "mcpServers"
>; >;
type RawStoredAgentRecord = z.infer<typeof RAW_STORED_AGENT_SCHEMA>; export type StoredAgentRecord = z.infer<typeof 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 function parseStoredAgentRecord(value: unknown): StoredAgentRecord { export function parseStoredAgentRecord(value: unknown): StoredAgentRecord {
return STORED_AGENT_SCHEMA.parse(value); return STORED_AGENT_SCHEMA.parse(value);
} }
@@ -94,7 +94,7 @@ export function resolveDefaultAgentCreateConfig(
availableModes: availableModeIds, availableModes: availableModeIds,
targetUnattendedMode: input.availableModes?.find(isUnattendedMode)?.id, 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(); const stub = createProviderSnapshotManagerStub();
stub.resolveCreateConfig.mockResolvedValue({ stub.resolveCreateConfig.mockResolvedValue({
modeId: "build", modeId: "build",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}); });
const dependencies: Parameters<typeof createAgentCommand>[0] = { const dependencies: Parameters<typeof createAgentCommand>[0] = {
agentManager: { agentManager: {
@@ -171,7 +171,7 @@ test("session create applies the resolved mode from the provider create config",
expect(createAgent).toHaveBeenCalledWith( expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
modeId: "build", modeId: "build",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}), }),
undefined, undefined,
expect.anything(), expect.anything(),
@@ -22,8 +22,6 @@ import { buildAgentPrompt } from "../prompt-attachments.js";
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js"; import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js"; import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.js";
import { resolveCreateAgentIntent } from "./intent.js"; import { resolveCreateAgentIntent } from "./intent.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { foldFeatureValues, type LegacyFeatureValues } from "../option-controls.js";
export interface CreateAgentCommandDependencies { export interface CreateAgentCommandDependencies {
finishSubscriptions?: FinishSubscriptions; finishSubscriptions?: FinishSubscriptions;
@@ -49,8 +47,7 @@ export interface CreateAgentFromSessionInput {
kind: "session"; kind: "session";
onAgentReady?: (agent: ManagedAgent) => Promise<void>; onAgentReady?: (agent: ManagedAgent) => Promise<void>;
agentId?: string; agentId?: string;
/** Wire configs from older clients can still carry `featureValues`; they are folded on entry. */ config: AgentSessionConfig;
config: AgentSessionConfig & LegacyFeatureValues;
workspaceId: string; workspaceId: string;
initialPrompt?: string; initialPrompt?: string;
source?: AgentPromptSource; source?: AgentPromptSource;
@@ -73,7 +70,7 @@ export interface CreateAgentFromMcpInput {
cwd?: string; cwd?: string;
workspaceId?: string; workspaceId?: string;
thinking?: string; thinking?: string;
providerOptions?: ProviderOptions; features?: Record<string, unknown>;
labels?: Record<string, string>; labels?: Record<string, string>;
mode?: string; mode?: string;
unattended?: boolean; unattended?: boolean;
@@ -221,19 +218,19 @@ async function resolveSessionCreateAgent(
input: CreateAgentFromSessionInput, input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> { ): Promise<ResolvedCreateAgent> {
const trimmedPrompt = input.initialPrompt?.trim(); const trimmedPrompt = input.initialPrompt?.trim();
const builtSessionConfig = foldFeatureValues(input.config); const builtSessionConfig = input.config;
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({ const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
cwd: builtSessionConfig.cwd, cwd: builtSessionConfig.cwd,
provider: builtSessionConfig.provider, provider: builtSessionConfig.provider,
requestedMode: builtSessionConfig.modeId, requestedMode: builtSessionConfig.modeId,
providerOptions: builtSessionConfig.providerOptions, featureValues: builtSessionConfig.featureValues,
parent: null, parent: null,
unattended: false, unattended: false,
}); });
const sessionConfig: AgentSessionConfig = { const sessionConfig: AgentSessionConfig = {
...builtSessionConfig, ...builtSessionConfig,
modeId: resolvedCreateConfig.modeId, modeId: resolvedCreateConfig.modeId,
providerOptions: resolvedCreateConfig.providerOptions, featureValues: resolvedCreateConfig.featureValues,
}; };
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments); const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0; const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
@@ -326,7 +323,7 @@ async function resolveMcpCreateAgent(
resolvedCwd: intent.cwd, resolvedCwd: intent.cwd,
trimmedPrompt, trimmedPrompt,
resolvedMode: resolvedCreateConfig.modeId, resolvedMode: resolvedCreateConfig.modeId,
resolvedProviderOptions: resolvedCreateConfig.providerOptions, resolvedFeatures: resolvedCreateConfig.featureValues,
}), }),
createOptions: { createOptions: {
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}), ...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
@@ -364,23 +361,18 @@ async function resolveMcpProviderCreateConfig(params: {
provider: string; provider: string;
resolvedCwd: string; resolvedCwd: string;
parentAgent: ManagedAgent | null; parentAgent: ManagedAgent | null;
}): Promise<{ modeId?: string; providerOptions?: ProviderOptions }> { }): Promise<{ modeId?: string; featureValues?: Record<string, unknown> }> {
const passthroughConfig = params.input.config; const passthroughConfig = params.input.config;
return params.dependencies.providerSnapshotManager.resolveCreateConfig({ return params.dependencies.providerSnapshotManager.resolveCreateConfig({
cwd: params.resolvedCwd, cwd: params.resolvedCwd,
provider: params.provider, provider: params.provider,
requestedMode: params.input.mode ?? passthroughConfig?.modeId, requestedMode: params.input.mode ?? passthroughConfig?.modeId,
providerOptions: mergeMcpProviderOptions(params.input), featureValues: params.input.features ?? passthroughConfig?.featureValues,
parent: params.parentAgent, parent: params.parentAgent,
unattended: params.input.unattended ?? false, 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: { function buildMcpSessionConfig(params: {
input: CreateAgentFromMcpInput; input: CreateAgentFromMcpInput;
resolvedProviderModel: ResolvedProviderModel; resolvedProviderModel: ResolvedProviderModel;
@@ -388,14 +380,14 @@ function buildMcpSessionConfig(params: {
resolvedCwd: string; resolvedCwd: string;
trimmedPrompt: string; trimmedPrompt: string;
resolvedMode?: string; resolvedMode?: string;
resolvedProviderOptions?: ProviderOptions; resolvedFeatures?: Record<string, unknown>;
}): AgentSessionConfig { }): AgentSessionConfig {
const passthroughConfig = params.input.config; const passthroughConfig = params.input.config;
const { provisionalTitle } = resolveCreateAgentTitles({ const { provisionalTitle } = resolveCreateAgentTitles({
configTitle: passthroughConfig?.title ?? params.input.title, configTitle: passthroughConfig?.title ?? params.input.title,
initialPrompt: params.trimmedPrompt, initialPrompt: params.trimmedPrompt,
}); });
const providerOptions = params.resolvedProviderOptions ?? mergeMcpProviderOptions(params.input); const featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues;
const config: AgentSessionConfig = { const config: AgentSessionConfig = {
...passthroughConfig, ...passthroughConfig,
provider: params.provider, provider: params.provider,
@@ -408,8 +400,8 @@ function buildMcpSessionConfig(params: {
if (provisionalTitle) { if (provisionalTitle) {
config.title = provisionalTitle; config.title = provisionalTitle;
} }
if (providerOptions) { if (featureValues) {
config.providerOptions = providerOptions; config.featureValues = featureValues;
} }
return config; return config;
} }
@@ -257,7 +257,7 @@ function buildAgentManagerSpies() {
setAgentMode: vi.fn().mockResolvedValue(undefined), setAgentMode: vi.fn().mockResolvedValue(undefined),
setAgentModel: vi.fn().mockResolvedValue(undefined), setAgentModel: vi.fn().mockResolvedValue(undefined),
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined), setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
setAgentOptions: vi.fn().mockResolvedValue(undefined), setAgentFeature: vi.fn().mockResolvedValue(undefined),
setLabels: vi.fn().mockResolvedValue(undefined), setLabels: vi.fn().mockResolvedValue(undefined),
setTitle: vi.fn().mockResolvedValue(undefined), setTitle: vi.fn().mockResolvedValue(undefined),
updateAgentMetadata: vi.fn().mockResolvedValue(undefined), updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
@@ -282,7 +282,7 @@ function buildAgentManagerSpies() {
cancelAgentRun: vi.fn(), cancelAgentRun: vi.fn(),
getPendingPermissions: vi.fn(), getPendingPermissions: vi.fn(),
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]), getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
listDraftControls: vi.fn(), listDraftFeatures: vi.fn(),
}; };
} }
@@ -472,9 +472,9 @@ function configureOpenCodeProviderStub(
const opts = input as { const opts = input as {
provider: AgentProvider; provider: AgentProvider;
requestedMode: string | undefined; requestedMode: string | undefined;
providerOptions: Record<string, unknown> | undefined; featureValues: Record<string, unknown> | undefined;
}; };
return { modeId: opts.requestedMode, providerOptions: opts.providerOptions }; return { modeId: opts.requestedMode, featureValues: opts.featureValues };
}); });
} }
@@ -1805,7 +1805,7 @@ describe("create_agent MCP tool", () => {
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
config: { title: "Feature test", providerOptions: { fast_mode: true } }, config: { title: "Feature test", featureValues: { fast_mode: true } },
} as ManagedAgent); } as ManagedAgent);
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
@@ -1834,7 +1834,7 @@ describe("create_agent MCP tool", () => {
expect.objectContaining({ expect.objectContaining({
provider: "codex", provider: "codex",
model: "gpt-5.4", model: "gpt-5.4",
providerOptions: { fast_mode: true }, featureValues: { fast_mode: true },
}), }),
undefined, undefined,
{ workspaceId: "workspace-created" }, { workspaceId: "workspace-created" },
@@ -3039,12 +3039,12 @@ describe("create_agent MCP tool", () => {
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
config: { title: "Child", providerOptions: { fast_mode: true } }, config: { title: "Child", featureValues: { fast_mode: true } },
} as ManagedAgent); } as ManagedAgent);
const providerSnapshot = createOpenCodeManager(); const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => { providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
const opts = input as { providerOptions: Record<string, unknown> | undefined }; const opts = input as { featureValues: Record<string, unknown> | undefined };
return { modeId: undefined, providerOptions: opts.providerOptions }; return { modeId: undefined, featureValues: opts.featureValues };
}); });
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
@@ -3072,7 +3072,7 @@ describe("create_agent MCP tool", () => {
expect.objectContaining({ expect.objectContaining({
provider: "codex", provider: "codex",
model: "gpt-5.4", model: "gpt-5.4",
providerOptions: { fast_mode: true }, featureValues: { fast_mode: true },
}), }),
undefined, undefined,
{ {
@@ -3273,7 +3273,7 @@ describe("create_agent MCP tool", () => {
provStub.resolveCreateConfig.mockImplementation(async (input) => { provStub.resolveCreateConfig.mockImplementation(async (input) => {
const opts = input as { requestedMode: string | undefined }; const opts = input as { requestedMode: string | undefined };
expect(opts.requestedMode).toBe("dynamic"); expect(opts.requestedMode).toBe("dynamic");
return { modeId: "dynamic", providerOptions: undefined }; return { modeId: "dynamic", featureValues: undefined };
}); });
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
agentManager, agentManager,
@@ -3307,12 +3307,12 @@ describe("create_agent MCP tool", () => {
lifecycle: "idle", lifecycle: "idle",
currentModeId: "build", currentModeId: "build",
availableModes: [], availableModes: [],
config: { title: "Child", providerOptions: { auto_accept: true } }, config: { title: "Child", featureValues: { auto_accept: true } },
} as ManagedAgent); } as ManagedAgent);
const providerSnapshot = createOpenCodeManager(); const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({ providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
modeId: "build", modeId: "build",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}); });
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
agentManager, agentManager,
@@ -3332,7 +3332,7 @@ describe("create_agent MCP tool", () => {
}); });
expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build", providerOptions: { auto_accept: true } }), expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
undefined, undefined,
{ workspaceId: "workspace-created" }, { workspaceId: "workspace-created" },
); );
@@ -3360,7 +3360,7 @@ describe("create_agent MCP tool", () => {
const providerSnapshot = createOpenCodeManager(); const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({ providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
modeId: "resolver-mode", modeId: "resolver-mode",
providerOptions: { resolver_feature: true }, featureValues: { resolver_feature: true },
}); });
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
@@ -3384,7 +3384,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith( expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
modeId: "resolver-mode", modeId: "resolver-mode",
providerOptions: { resolver_feature: true }, featureValues: { resolver_feature: true },
}), }),
undefined, undefined,
expect.any(Object), expect.any(Object),
@@ -4717,7 +4717,7 @@ describe("update_agent MCP tool", () => {
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access"); expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4"); expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high"); expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
expect(spies.agentManager.setAgentOptions).toHaveBeenCalledWith("agent-1", { fast_mode: true }); expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", { expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
title: "Updated agent", title: "Updated agent",
labels: { role: "worker" }, labels: { role: "worker" },
@@ -4742,12 +4742,12 @@ describe("update_agent MCP tool", () => {
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentOptions).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
}); });
it("does not update metadata when runtime settings fail", async () => { it("does not update metadata when runtime settings fail", async () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.setAgentOptions.mockRejectedValue(new Error("unsupported feature")); spies.agentManager.setAgentFeature.mockRejectedValue(new Error("unsupported feature"));
const server = await createAgentMcpServer({ const server = await createAgentMcpServer({
agentManager, agentManager,
agentStorage, agentStorage,
@@ -5065,7 +5065,7 @@ describe("create_schedule MCP tool", () => {
config: { config: {
title: "Parent agent", title: "Parent agent",
model: "openai/gpt-5.5", model: "openai/gpt-5.5",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}, },
} as ManagedAgent); } as ManagedAgent);
const createOrReplace = vi.fn(async (input: CreateScheduleInput) => const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
@@ -5093,7 +5093,7 @@ describe("create_schedule MCP tool", () => {
cwd: REPO_CWD, cwd: REPO_CWD,
modeId: "build", modeId: "build",
model: "openai/gpt-5.5", model: "openai/gpt-5.5",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}, },
}); });
}); });
@@ -5867,7 +5867,7 @@ describe("agent profile listing MCP tool", () => {
model: "claude-test-model", model: "claude-test-model",
modeId: "bypassPermissions", modeId: "bypassPermissions",
thinkingOptionId: "high", thinkingOptionId: "high",
options: { fast_mode: true }, featureValues: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.", notes: "Use for UI work: components, layout, design tokens. Not for backend.",
}, },
]; ];
@@ -5882,21 +5882,7 @@ describe("agent profile listing MCP tool", () => {
const response = await tool.handler({}); const response = await tool.handler({});
// The config store holds profiles with `options` only; the tool reports them as stored. expect(response.structuredContent).toEqual({ profiles });
expect(response.structuredContent).toEqual({
profiles: [
{
id: "ui-profile",
name: "UI work",
provider: "claude",
model: "claude-test-model",
modeId: "bypassPermissions",
thinkingOptionId: "high",
options: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
},
],
});
}); });
it("returns an empty array when no profiles are configured", async () => { it("returns an empty array when no profiles are configured", async () => {
@@ -5948,10 +5934,14 @@ describe("provider MCP tools", () => {
it("inspects provider features for a draft agent configuration", async () => { it("inspects provider features for a draft agent configuration", async () => {
const { agentManager, agentStorage, spies } = createTestDeps(); const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listDraftControls.mockResolvedValue({ spies.agentManager.listDraftFeatures.mockResolvedValue([
controls: [{ type: "toggle", key: "fast_mode", label: "Fast mode" }], {
options: { fast_mode: true }, type: "toggle",
}); id: "fast_mode",
label: "Fast mode",
value: false,
},
]);
const provStub = createProviderSnapshotManagerStub(); const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]); provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
const codexEntry = buildSnapshotEntry({ const codexEntry = buildSnapshotEntry({
@@ -5984,13 +5974,13 @@ describe("provider MCP tools", () => {
const response = await tool.handler(input); const response = await tool.handler(input);
expect(spies.agentManager.listDraftControls).toHaveBeenCalledWith({ expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
provider: "codex", provider: "codex",
cwd: expect.stringContaining("repo"), cwd: expect.stringContaining("repo"),
modeId: "full-access", modeId: "full-access",
model: "gpt-5.4", model: "gpt-5.4",
thinkingOptionId: "high", thinkingOptionId: "high",
providerOptions: { fast_mode: true }, featureValues: { fast_mode: true },
}); });
expect(response.structuredContent).toEqual({ expect(response.structuredContent).toEqual({
provider: "codex", provider: "codex",
@@ -6005,7 +5995,7 @@ describe("provider MCP tools", () => {
type: "toggle", type: "toggle",
id: "fast_mode", id: "fast_mode",
label: "Fast mode", label: "Fast mode",
value: true, value: false,
}, },
], ],
}); });
@@ -6221,8 +6211,14 @@ describe("agent snapshot MCP serialization", () => {
description: "Default coding mode", description: "Default coding mode",
}, },
], ],
controls: [{ type: "toggle", key: "web-search", label: "Web search" }], features: [
effectiveOptions: { "web-search": true }, {
type: "toggle",
id: "web-search",
label: "Web search",
value: true,
},
],
pendingPermissions: new Map(), pendingPermissions: new Map(),
persistence: { persistence: {
provider: "codex", provider: "codex",
@@ -1,62 +0,0 @@
import { describe, expect, test } from "vitest";
import { convertLegacyOptions, foldHookFeatureValues } from "./option-controls.js";
describe("convertLegacyOptions rejects a malformed Codex fast_mode", () => {
test.each(["false", 1, {}])("%j", (value) => {
expect(() =>
convertLegacyOptions({ provider: "codex", options: { fast_mode: value } }),
).toThrow(/fast_mode must be true, false or null/);
});
});
describe("convertLegacyOptions", () => {
test.each([
{ legacy: true, tier: "fast" },
{ legacy: false, tier: "default" },
{ legacy: null, tier: null },
])("Codex fast_mode: $legacy becomes service_tier: $tier", ({ legacy, tier }) => {
expect(
convertLegacyOptions({ provider: "codex", options: { fast_mode: legacy, plan_mode: true } }),
).toEqual({ plan_mode: true, service_tier: tier });
});
test("a service_tier in the same input wins", () => {
expect(
convertLegacyOptions({
provider: "codex",
options: { service_tier: "default", fast_mode: true },
}),
).toEqual({ service_tier: "default" });
});
test("other providers keep fast_mode", () => {
const options = { fast_mode: true };
expect(convertLegacyOptions({ provider: "claude", options })).toBe(options);
});
});
describe("foldHookFeatureValues", () => {
const convert = (options: Record<string, unknown>) =>
convertLegacyOptions({ provider: "codex", options });
test("a hook that edits featureValues.fast_mode sets service_tier", () => {
const sent = { providerOptions: {}, featureValues: {} };
const returned = { providerOptions: {}, featureValues: { fast_mode: true } };
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
service_tier: "fast",
});
});
test("a hook that replaces the speed it was sent with fast_mode selects Fast", () => {
const sent = {
providerOptions: { service_tier: "default" },
featureValues: { service_tier: "default" },
};
const returned = { ...sent, featureValues: { fast_mode: true } };
expect(foldHookFeatureValues({ returned, sent, convert }).providerOptions).toEqual({
service_tier: "fast",
});
});
});
@@ -1,204 +0,0 @@
import { isDeepStrictEqual } from "node:util";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
/**
* A patch over an agent's own options: top-level keys replace, `null` removes the key so the
* `config.json` default (or the provider's own default) applies again.
*/
export type ProviderOptionsPatch = Record<string, unknown>;
export function applyProviderOptionsPatch(input: {
options: ProviderOptions | undefined;
patch: ProviderOptionsPatch;
}): ProviderOptions {
const { options, patch } = input;
const next: ProviderOptions = { ...options };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete next[key];
} else if (value !== undefined) {
next[key] = value;
}
}
return next;
}
/** COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. */
export interface LegacyFeatureValues {
featureValues?: Record<string, unknown>;
}
/**
* COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast
* preferences have migrated. Codex used to take `fast_mode`; it takes `service_tier` now. Old
* data (stored records, config.json defaults, profiles, older clients, hooks) is converted here
* as it enters, so nothing past this point sees `fast_mode` for Codex. A `service_tier` in the
* same input wins. A patch's `null` stays `null`, which removes the speed.
*/
interface LegacyOptionsInput<O> {
provider: string | undefined;
options: O;
}
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown>>,
): Record<string, unknown>;
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
): Record<string, unknown> | undefined;
export function convertLegacyOptions(
input: LegacyOptionsInput<Record<string, unknown> | undefined>,
): Record<string, unknown> | undefined {
const { provider, options } = input;
if (provider !== "codex" || !options || !("fast_mode" in options)) return options;
const { fast_mode: legacy, ...rest } = options;
if (legacy !== undefined && legacy !== null && typeof legacy !== "boolean") {
throw new Error("Codex fast_mode must be true, false or null");
}
if ("service_tier" in rest || legacy === undefined) return rest;
if (legacy === null) return { ...rest, service_tier: null };
return { ...rest, service_tier: legacy ? "fast" : "default" };
}
/**
* The built-in adapter a provider id runs on: a custom provider is named by the one it
* `extends`, followed until a provider that extends nothing. `extendsOf` is where the caller
* reads that link (the registry's definitions, or `config.json`'s `providers` section).
*/
export function resolveAdapter(input: {
provider: string;
extendsOf: (provider: string) => string | null | undefined;
}): string {
const seen = new Set<string>();
let current = input.provider;
for (;;) {
seen.add(current);
const base = input.extendsOf(current);
if (!base || seen.has(base)) return current;
current = base;
}
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Older clients, stored records, profiles, and plugin hooks carry composer values in
* `featureValues`. This is the one place they become `providerOptions`; an explicit
* `providerOptions` key wins over a `featureValues` key of the same name.
*/
export function foldFeatureValues<
T extends {
providerOptions?: ProviderOptions | null;
featureValues?: Record<string, unknown> | null;
},
>(config: T): Omit<T, "featureValues"> {
const { featureValues, ...rest } = config;
if (!featureValues || Object.keys(featureValues).length === 0) return rest;
return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } };
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* A plugin written against the previous SDK reads and edits `config.featureValues`, so a
* hook sees a copy of the config's options there. What the hook did to that copy (set,
* replaced or deleted a key, or dropped the field) is applied as a patch over the options it
* returned; keys it left alone change nothing.
*/
export function exposeFeatureValuesToHook<
T extends { providerOptions?: ProviderOptions | null; featureValues?: Record<string, unknown> },
>(config: T): T {
if (!config.providerOptions || Object.keys(config.providerOptions).length === 0) return config;
return { ...config, featureValues: { ...config.providerOptions } };
}
export function foldHookFeatureValues<
T extends {
providerOptions?: ProviderOptions | null;
featureValues?: Record<string, unknown>;
},
>(input: {
returned: T;
sent: T;
/** Brings what the hook returned to the form of the copy it was sent. */
convert: (options: Record<string, unknown>) => Record<string, unknown>;
}): Omit<T, "featureValues"> {
const { returned, sent, convert } = input;
const { featureValues, ...rest } = returned;
const before = sent.featureValues;
if (!before) {
// No copy was sent, so everything the hook put in `featureValues` is new.
return foldFeatureValues(returned);
}
// A hook may return an older key where it was sent the current one; comparing in the same
// form keeps a replaced value from reading as a deletion.
const after = convert(featureValues ?? {});
const patch: ProviderOptionsPatch = {};
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (isDeepStrictEqual(after[key], before[key])) continue;
patch[key] = key in after ? after[key] : null;
}
return {
...rest,
providerOptions: applyProviderOptionsPatch({
options: rest.providerOptions ?? undefined,
patch,
}),
};
}
type ConvertedOptions = Record<string, unknown> | undefined;
interface ProfileOptionFields {
provider?: string;
options?: Record<string, unknown>;
featureValues?: Record<string, unknown>;
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Profiles saved before options carry `featureValues`; when one comes from storage, `options`
* wins over a stale `featureValues` key.
*
* A write that carries `options` is from a client that knows them: its `featureValues` fold
* over those. A write without `options` is from the unchanged app, whose form only knows the
* controls: its `featureValues` are a patch (`null` deletes) over the options stored for that
* profile, so options the form never shows, such as `sandbox_mode`, survive an edit. A profile
* moved to another provider starts from no options, because the old provider's keys mean
* nothing to the new one.
*/
export function foldProfileFeatureValues<T extends ProfileOptionFields>(input: {
profile: T;
stored: ProfileOptionFields | undefined;
source: "stored" | "client write";
/** Renames old option keys for the adapter the profile's provider runs on. */
convert: (provider: string, options: Record<string, unknown> | undefined) => ConvertedOptions;
}): Omit<T, "featureValues"> {
const { profile, stored, source, convert } = input;
const { featureValues: legacyValues, ...rest } = profile;
// A profile is stored in the current keys, so removal and merging below see one vocabulary.
const provider = rest.provider ?? "";
const featureValues = convert(provider, legacyValues);
const ownOptions = convert(provider, rest.options);
if (source === "stored") {
if (featureValues) return { ...rest, options: { ...featureValues, ...ownOptions } };
return ownOptions ? { ...rest, options: ownOptions } : rest;
}
const base =
ownOptions ??
(stored?.provider === rest.provider ? convert(provider, stored?.options) : undefined);
// The form sends no `featureValues` when no control is set, which is still an edit of a
// profile that may hold other options.
if (!featureValues && !base) return rest;
return {
...rest,
options: applyProviderOptionsPatch({ options: base, patch: featureValues ?? {} }),
};
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* The unchanged app reads a profile's options from `featureValues`; the config API answers with
* both fields.
*/
export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(profile: T): T {
return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile;
}
@@ -58,10 +58,6 @@ interface ProviderHarnessOptions {
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>; handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
} }
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
return inputs.findLast((input) => input.type === "session.configure");
}
function createProviderHarness(options: ProviderHarnessOptions = {}) { function createProviderHarness(options: ProviderHarnessOptions = {}) {
let listener: ((event: ProviderEvent) => void) | null = null; let listener: ((event: ProviderEvent) => void) | null = null;
let closeCount = 0; let closeCount = 0;
@@ -974,7 +970,7 @@ describe("PluginAgentClientRegistry", () => {
} }
}); });
test("stores the agent options and the settings the plugin opened with, not the merged config defaults", async () => { test("stores only agent options while the plugin receives merged defaults", async () => {
const logger = createTestLogger(); const logger = createTestLogger();
const harness = createProviderHarness(); const harness = createProviderHarness();
const plugins = new PluginAgentClientRegistry(logger); const plugins = new PluginAgentClientRegistry(logger);
@@ -996,174 +992,13 @@ describe("PluginAgentClientRegistry", () => {
undefined, undefined,
{ workspaceId: undefined }, { workspaceId: undefined },
); );
// `nested.base` comes from `config.json` and is not stored; `voice` is the plugin's own opening state. expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual(own);
expect(toStoredAgentRecord(agent).config?.providerOptions).toEqual({ ...own, voice: "direct" });
expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({ expect(harness.inputs.find((input) => input.type === "session.open")).toMatchObject({
config: { providerOptions: { nested: { base: true, replace: "agent" } } }, config: { providerOptions: { nested: { base: true, replace: "agent" } } },
}); });
await agent.session?.close(); await agent.session?.close();
}); });
describe("settings", () => {
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
async function openStubbornVoiceSession(
given: Record<string, boolean | string>,
{ refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {},
) {
const state = { voice: "direct", loud: false };
let openedSessionId = "";
const settings = () => [
{
type: "select" as const,
id: "voice",
label: "Voice",
value: state.voice,
options: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
];
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
emit({
type: "session.config",
sessionId,
config: {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: settings(),
},
});
const harness = createProviderHarness({
async handleInput(input, emit) {
if (input.type === "session.open") {
openedSessionId = input.sessionId;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: CAPABILITIES,
restoration: "core",
persistence: { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
emitConfig(emit, input.sessionId);
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.configure") {
const loud = input.changes.settings?.loud;
if (typeof loud === "boolean") state.loud = loud;
emitConfig(emit, input.sessionId);
emit(
refuseAfterChanging
? { type: "request.failed", requestId: input.requestId, error: { message: "no" } }
: { type: "request.completed", requestId: input.requestId },
);
return true;
}
return false;
},
});
const plugins = new PluginAgentClientRegistry(createTestLogger());
plugins.replace([harness.registration]);
const session = await plugins.clients()["plugin-direct"]!.createSession({
provider: "plugin-direct",
cwd: tmpdir(),
providerOptions: given,
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
const changeOnItsOwn = (change: Partial<typeof state>) => {
Object.assign(state, change);
emitConfig(harness.emit, openedSessionId);
};
return { harness, session, events, changeOnItsOwn };
}
test("forwards a setting the plugin changes on its own", async () => {
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
changeOnItsOwn({ loud: true });
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
]);
await session.close();
});
test("a rejected apply reports what the plugin moved before refusing, since nothing is stored", async () => {
const { session, events } = await openStubbornVoiceSession({}, { refuseAfterChanging: true });
await expect(session.applyOptions?.({ loud: true })).rejects.toThrow("no");
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { loud: true } },
]);
await session.close();
});
test("a setting the plugin opened differently is the session's open patch, not an event", async () => {
const { session, events } = await openStubbornVoiceSession({ voice: "other", loud: false });
expect(session.openOptionsPatch).toEqual({ voice: "direct" });
expect(eventsOfType(events, "options_changed")).toEqual([]);
await session.close();
});
test("settings the plugin opened with that nobody set are in the open patch", async () => {
const { session, events } = await openStubbornVoiceSession({});
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
expect(eventsOfType(events, "options_changed")).toEqual([]);
await session.close();
});
test("applying options configures only the settings it is given", async () => {
const { harness, session } = await openStubbornVoiceSession({});
await session.applyOptions?.({ loud: true });
const configure = lastConfigure(harness.inputs);
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
expect(
Object.keys((configure as { changes: { settings: object } }).changes.settings),
).toEqual(["loud"]);
await session.close();
});
test("rejects a key the plugin does not report as a setting, and configures none of a mixed patch", async () => {
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
const configureBefore = lastConfigure(harness.inputs);
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
/launch_key.*cannot change on a running session/,
);
expect(lastConfigure(harness.inputs)).toBe(configureBefore);
// The value it opened with is not a change, so a whole profile can be applied.
await session.applyOptions?.({ loud: true, launch_key: "original" });
const configure = lastConfigure(harness.inputs) as { changes: { settings: object } };
expect(Object.keys(configure.changes.settings)).toEqual(["loud"]);
await session.close();
});
test("applying options returns the settings that ended up different, and emits nothing", async () => {
const { session, events } = await openStubbornVoiceSession({});
const ended = await session.applyOptions?.({ voice: "other", loud: true });
expect(ended).toEqual({ voice: "direct" });
expect(eventsOfType(events, "options_changed")).toEqual([]);
await session.close();
});
});
test.each([ test.each([
{ options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, { options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
{ params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, { params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } },
@@ -1676,13 +1511,12 @@ describe("PluginAgentClientRegistry", () => {
}); });
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" }); const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
expect(session.controls).toEqual([ expect(session.features).toEqual([
{ expect.objectContaining({
type: "select", type: "select",
key: "voice", id: "voice",
label: "Voice", options: [{ id: "direct", label: "Direct", value: "direct" }],
choices: [{ value: "direct", label: "Direct" }], }),
},
]); ]);
expect(session.describePersistence()).toMatchObject({ expect(session.describePersistence()).toMatchObject({
provider: "plugin-direct", provider: "plugin-direct",
@@ -1,9 +1,8 @@
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { isDeepStrictEqual } from "node:util";
import { PluginSessionHistory } from "./plugin-session-history.js"; import { PluginSessionHistory } from "./plugin-session-history.js";
import { resolve as resolvePath } from "node:path"; import { resolve as resolvePath } from "node:path";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types"; import type { JsonValue } from "@getpaseo/protocol/agent-types";
import { z } from "zod"; import { z } from "zod";
import { import {
PROVIDER_CAPABILITIES, PROVIDER_CAPABILITIES,
@@ -32,10 +31,10 @@ import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentClient, AgentClient,
AgentCreateSessionOptions, AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext, AgentLaunchContext,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentOptionControl,
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPersistenceHandle, AgentPersistenceHandle,
@@ -64,7 +63,6 @@ import {
createProviderEnv, createProviderEnv,
type ProviderRuntimeSettings, type ProviderRuntimeSettings,
} from "./provider-launch-config.js"; } from "./provider-launch-config.js";
import { assertChangesAreLive } from "./provider-options.js";
import type { RegisteredProviderDefinition } from "./provider-registry.js"; import type { RegisteredProviderDefinition } from "./provider-registry.js";
import { runProviderTurn } from "./providers/provider-runner.js"; import { runProviderTurn } from "./providers/provider-runner.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js"; import { StaleProviderSessionError } from "./stale-provider-session-error.js";
@@ -1135,7 +1133,6 @@ class PluginAgentClient implements AgentClient {
}); });
}, },
bind, bind,
config.settings,
); );
bind(bridge); bind(bridge);
return session; return session;
@@ -1204,11 +1201,6 @@ class PluginAgentSession implements AgentSession {
>(); >();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>(); private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null; private unsubscribe: (() => void) | null = null;
/** How the opened session differs from the settings it was given. */
readonly openOptionsPatch: Record<string, JsonValue>;
private knownSettings: Record<string, JsonValue> = {};
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
private applyingOptions = false;
private currentTurnId: string | null = null; private currentTurnId: string | null = null;
private closed = false; private closed = false;
private replay: { private replay: {
@@ -1229,12 +1221,8 @@ class PluginAgentSession implements AgentSession {
state: ProviderConfigState, state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>, ) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void, private readonly onBind: (bridge: ProviderRuntimeSession) => void,
private readonly givenSettings: Readonly<Record<string, JsonValue>>,
) { ) {
this.subagentIdsBySession.set(bridge.id, null); this.subagentIdsBySession.set(bridge.id, null);
// Every setting the session opened with that was not given, including ones nobody set.
this.openOptionsPatch = this.settingDifferences(givenSettings);
this.knownSettings = this.currentSettings();
for (const event of bridge.history) this.accept(event, false); for (const event of bridge.history) this.accept(event, false);
// Opening notices arrive before the manager subscribes to the session. // Opening notices arrive before the manager subscribes to the session.
// Commit them on registration and replay the same timestamps from history. // Commit them on registration and replay the same timestamps from history.
@@ -1258,27 +1246,14 @@ class PluginAgentSession implements AgentSession {
}; };
} }
/** The plugin's `settings` are its option controls; each setting id is an option key. */ get features(): AgentFeature[] {
get controls(): AgentOptionControl[] { return this.bridge.config.settings.map((setting) =>
return this.bridge.config.settings.map( setting.type === "toggle"
(setting): AgentOptionControl => ? { ...setting }
setting.type === "toggle" : {
? { ...setting,
type: "toggle", options: setting.options.map((option) => ({ ...option, id: option.value })),
key: setting.id, },
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
})),
},
); );
} }
@@ -1435,38 +1410,11 @@ class PluginAgentSession implements AgentSession {
await this.bridge.configure({ thinkingOption: thinkingOptionId }); await this.bridge.configure({ thinkingOption: thinkingOptionId });
} }
async applyOptions(options: ProviderOptions): Promise<Record<string, JsonValue>> { async setFeature(featureId: string, value: unknown): Promise<void> {
this.assertReplayComplete(); this.assertReplayComplete();
const before = this.currentSettings(); await this.bridge.configure({
// A key the plugin does not report as a setting is one it only reads when the session opens; settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
// a value equal to the one it opened with is not a change.
assertChangesAreLive({
provider: this.provider,
patch: options,
current: this.givenSettings,
live: Object.keys(before),
}); });
const settings: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(options)) {
if (!(id in before)) continue;
settings[id] = toJsonValue(value, `setting '${id}'`);
}
if (Object.keys(settings).length === 0) return {};
this.applyingOptions = true;
try {
await this.bridge.configure({ settings });
} catch (error) {
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
// call has ended.
this.applyingOptions = false;
for (const event of this.reportSettingChanges()) this.publish(event);
throw error;
} finally {
this.applyingOptions = false;
}
const ended = this.settingDifferences({ ...before, ...settings });
this.knownSettings = this.currentSettings();
return ended;
} }
async revertConversation(input: { messageId: string }): Promise<void> { async revertConversation(input: { messageId: string }): Promise<void> {
@@ -1648,36 +1596,10 @@ class PluginAgentSession implements AgentSession {
}; };
} }
private currentSettings(): Record<string, JsonValue> {
return Object.fromEntries(
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
);
}
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
private settingDifferences(
known: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
const patch: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(this.currentSettings())) {
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
}
return patch;
}
private reportSettingChanges(): AgentStreamEvent[] {
if (this.applyingOptions) return [];
const patch = this.settingDifferences(this.knownSettings);
if (Object.keys(patch).length === 0) return [];
this.knownSettings = this.currentSettings();
return [{ type: "options_changed", provider: this.provider, patch }];
}
private translateConfig( private translateConfig(
event: Extract<ProviderEvent, { type: "session.config" }>, event: Extract<ProviderEvent, { type: "session.config" }>,
): AgentStreamEvent[] { ): AgentStreamEvent[] {
return [ return [
...this.reportSettingChanges(),
{ {
type: "mode_changed", type: "mode_changed",
provider: this.provider, provider: this.provider,
@@ -1930,8 +1852,7 @@ function mapSessionConfig(
model: config.model, model: config.model,
mode: config.modeId, mode: config.modeId,
thinkingOption: config.thinkingOptionId, thinkingOption: config.thinkingOptionId,
// The plugin SDK reads composer values from `settings`; they are options here. settings: toJsonObject(config.featureValues ?? {}, "provider settings"),
settings: toJsonObject(config.providerOptions ?? {}, "provider settings"),
providerOptions: config.providerOptions, providerOptions: config.providerOptions,
title: config.title ?? undefined, title: config.title ?? undefined,
persist, persist,
@@ -1,5 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { isDeepStrictEqual } from "node:util";
import type { z } from "zod"; import type { z } from "zod";
export interface ProviderOptionIssue { export interface ProviderOptionIssue {
@@ -104,26 +103,3 @@ function isPlainObject(value: unknown): value is ProviderOptions {
const prototype = Object.getPrototypeOf(value); const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null; return prototype === Object.prototype || prototype === null;
} }
/**
* Rejects a live patch that changes a key the running session cannot apply (one the provider
* reads only when it opens). A key whose value already matches the session's is not a change.
*/
export function assertChangesAreLive(input: {
provider: string;
patch: ProviderOptions;
current: ProviderOptions | undefined;
live: readonly string[];
}): void {
const issues = Object.entries(input.patch)
.filter(
([key, value]) =>
!input.live.includes(key) &&
!isDeepStrictEqual(input.current?.[key] ?? null, value ?? null),
)
.map(([key]) => ({
path: [key],
message: "cannot change on a running session; it applies when the agent is opened",
}));
if (issues.length > 0) throw new ProviderOptionsValidationError(input.provider, issues);
}
@@ -25,7 +25,7 @@ const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
"listCommands", "listCommands",
"setModel", "setModel",
"setThinkingOption", "setThinkingOption",
"applyOptions", "setFeature",
"revertConversation", "revertConversation",
"revertFiles", "revertFiles",
"revertBoth", "revertBoth",
@@ -152,8 +152,8 @@ class FakeSession implements AgentSession {
this.recordedCalls.push("setThinkingOption"); this.recordedCalls.push("setThinkingOption");
} }
async applyOptions() { async setFeature() {
this.recordedCalls.push("applyOptions"); this.recordedCalls.push("setFeature");
} }
async revertConversation() { async revertConversation() {
@@ -193,7 +193,7 @@ describe("wrapSessionProvider", () => {
await wrapped.listCommands?.(); await wrapped.listCommands?.();
await wrapped.setModel?.("sonnet"); await wrapped.setModel?.("sonnet");
await wrapped.setThinkingOption?.("high"); await wrapped.setThinkingOption?.("high");
await wrapped.applyOptions?.({ "feature-1": true }); await wrapped.setFeature?.("feature-1", true);
await wrapped.revertConversation?.({ messageId: "message-1" }); await wrapped.revertConversation?.({ messageId: "message-1" });
await wrapped.revertFiles?.({ messageId: "message-1" }); await wrapped.revertFiles?.({ messageId: "message-1" });
await wrapped.revertBoth?.({ messageId: "message-1" }); await wrapped.revertBoth?.({ messageId: "message-1" });
@@ -208,7 +208,7 @@ describe("wrapSessionProvider", () => {
"listCommands", "listCommands",
"setModel", "setModel",
"setThinkingOption", "setThinkingOption",
"applyOptions", "setFeature",
"revertConversation", "revertConversation",
"revertFiles", "revertFiles",
"revertBoth", "revertBoth",
@@ -4,7 +4,7 @@ import type { ToolPolicy } from "@getpaseo/protocol/agent-types";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import type { import type {
AgentClient, AgentClient,
AgentOptionControl, AgentFeature,
AgentModelDefinition, AgentModelDefinition,
AgentMode, AgentMode,
AgentSessionConfig, AgentSessionConfig,
@@ -392,15 +392,15 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
return true; return true;
} }
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> { async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
mockState.cursorListFeaturesConfigs.push(config); mockState.cursorListFeaturesConfigs.push(config);
return [ return [
{ {
type: "select", type: "select",
key: "fast", id: "fast",
label: "Fast", label: "Fast",
defaultValue: "false", value: "false",
choices: [{ value: "false", label: "Off" }], options: [{ id: "false", label: "Off" }],
}, },
]; ];
} }
@@ -791,33 +791,6 @@ test("ACP provider configuration retains opaque options", () => {
}); });
}); });
test("a saved legacy Codex Fast preference wins over a configured service_tier default", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: { codex: { options: { service_tier: "default", plan_mode: false } } },
});
const client = registry.codex.createClient(logger);
// The manager hands the registry a pre-v0.10 record's `fast_mode` already converted.
expect(client.effectiveOptions?.({ service_tier: "fast" })).toEqual({
service_tier: "fast",
plan_mode: false,
});
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "default", plan_mode: false });
});
test("a configured legacy Codex Fast default supplies the service_tier default", () => {
const registry = buildProviderRegistry(logger, {
providerOverrides: { codex: { options: { fast_mode: true } } },
});
const client = registry.codex.createClient(logger);
// Removing an agent's own speed falls back to the configured Fast.
expect(client.effectiveOptions?.({})).toEqual({ service_tier: "fast" });
expect(client.effectiveOptions?.({ service_tier: "default" })).toEqual({
service_tier: "default",
});
});
test("cursor provider extending acp uses CursorACPAgentClient", () => { test("cursor provider extending acp uses CursorACPAgentClient", () => {
const registry = buildProviderRegistry(logger, { const registry = buildProviderRegistry(logger, {
providerOverrides: { providerOverrides: {
@@ -850,7 +823,7 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
expect(mockState.constructorArgs.genericAcp).toEqual([]); expect(mockState.constructorArgs.genericAcp).toEqual([]);
}); });
test("wrapped cursor client lists ACP controls through the inner provider", async () => { test("wrapped cursor client lists ACP features through the inner provider", async () => {
const registry = buildProviderRegistry(logger, { const registry = buildProviderRegistry(logger, {
providerOverrides: { providerOverrides: {
cursor: { cursor: {
@@ -864,17 +837,17 @@ test("wrapped cursor client lists ACP controls through the inner provider", asyn
const client = registry.cursor.createClient(logger); const client = registry.cursor.createClient(logger);
await expect( await expect(
client.listControls?.({ client.listFeatures?.({
provider: "cursor", provider: "cursor",
cwd: "/tmp/cursor", cwd: "/tmp/cursor",
}), }),
).resolves.toEqual([ ).resolves.toEqual([
{ {
type: "select", type: "select",
key: "fast", id: "fast",
label: "Fast", label: "Fast",
defaultValue: "false", value: "false",
choices: [{ value: "false", label: "Off" }], options: [{ id: "false", label: "Off" }],
}, },
]); ]);
expect(mockState.cursorListFeaturesConfigs).toEqual([ expect(mockState.cursorListFeaturesConfigs).toEqual([
@@ -48,7 +48,6 @@ import { PiRpcAgentClient } from "./providers/pi/agent.js";
import { TraeACPAgentClient } from "./providers/trae-acp-agent.js"; import { TraeACPAgentClient } from "./providers/trae-acp-agent.js";
import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js"; import { MockLoadTestAgentClient } from "./providers/mock-load-test-agent.js";
import { MockSlowProviderClient } from "./providers/mock-slow-provider.js"; import { MockSlowProviderClient } from "./providers/mock-slow-provider.js";
import { convertLegacyOptions } from "./option-controls.js";
import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js"; import { ToolPolicyUnsupportedError, mergeProviderOptions } from "./provider-options.js";
import { import {
AGENT_PROVIDER_DEFINITIONS, AGENT_PROVIDER_DEFINITIONS,
@@ -448,11 +447,8 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
get initialTimeline() { get initialTimeline() {
return inner.initialTimeline; return inner.initialTimeline;
}, },
get controls() { get features() {
return inner.controls; return inner.features;
},
get openOptionsPatch() {
return inner.openOptionsPatch;
}, },
usageSession: inner.usageSession?.bind(inner), usageSession: inner.usageSession?.bind(inner),
run: (prompt, options) => inner.run(prompt, options), run: (prompt, options) => inner.run(prompt, options),
@@ -477,7 +473,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
listCommands: inner.listCommands?.bind(inner), listCommands: inner.listCommands?.bind(inner),
setModel: inner.setModel?.bind(inner), setModel: inner.setModel?.bind(inner),
setThinkingOption: inner.setThinkingOption?.bind(inner), setThinkingOption: inner.setThinkingOption?.bind(inner),
applyOptions: inner.applyOptions?.bind(inner), setFeature: inner.setFeature?.bind(inner),
revertConversation: inner.revertConversation?.bind(inner), revertConversation: inner.revertConversation?.bind(inner),
revertFiles: inner.revertFiles?.bind(inner), revertFiles: inner.revertFiles?.bind(inner),
revertBoth: inner.revertBoth?.bind(inner), revertBoth: inner.revertBoth?.bind(inner),
@@ -491,20 +487,14 @@ function wrapClientProvider(
profileModels: ProviderProfileModel[], profileModels: ProviderProfileModel[],
additionalModels: ProviderProfileModel[], additionalModels: ProviderProfileModel[],
profileModelsAreAdditive: boolean, profileModelsAreAdditive: boolean,
configuredOptions: ProviderOptions | undefined, providerOptions: ProviderOptions | undefined,
): AgentClient { ): AgentClient {
const listImportableSessions = inner.listImportableSessions?.bind(inner); const listImportableSessions = inner.listImportableSessions?.bind(inner);
const importSession = inner.importSession?.bind(inner); const importSession = inner.importSession?.bind(inner);
const listControls = inner.listControls?.bind(inner); const listFeatures = inner.listFeatures?.bind(inner);
const archiveNativeSession = inner.archiveNativeSession?.bind(inner); const archiveNativeSession = inner.archiveNativeSession?.bind(inner);
const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner); const unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id); const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
// Converted where it is used, so a malformed configured default fails the call that reads it
// rather than the registry that holds every provider.
const configuredDefaults = () =>
convertLegacyOptions({ provider: inner.provider, options: configuredOptions });
const withDefaults = (own: ProviderOptions | undefined) =>
mergeProviderOptions(configuredDefaults(), own);
return { return {
provider, provider,
@@ -519,7 +509,7 @@ function wrapClientProvider(
{ {
...config, ...config,
provider: inner.provider, provider: inner.provider,
providerOptions: withDefaults(config.providerOptions), providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
}, },
launchContext, launchContext,
{ ...options, configuredModelIds }, { ...options, configuredModelIds },
@@ -536,17 +526,14 @@ function wrapClientProvider(
{ {
...overrides, ...overrides,
provider: inner.provider, provider: inner.provider,
providerOptions: withDefaults(overrides?.providerOptions), providerOptions: mergeProviderOptions(providerOptions, overrides?.providerOptions),
}, },
launchContext, launchContext,
{ ...options, configuredModelIds }, { ...options, configuredModelIds },
), ),
), ),
fetchCatalog: async (options, context) => { fetchCatalog: async (options, context) => {
const catalog = await inner.fetchCatalog( const catalog = await inner.fetchCatalog({ ...options, providerOptions }, context);
{ ...options, providerOptions: configuredDefaults() },
context,
);
return { return {
...catalog, ...catalog,
models: mergeModels(provider, profileModels, additionalModels, catalog.models, { models: mergeModels(provider, profileModels, additionalModels, catalog.models, {
@@ -561,7 +548,7 @@ function wrapClientProvider(
config: { config: {
...config, ...config,
provider: inner.provider, provider: inner.provider,
providerOptions: withDefaults(config.providerOptions), providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
}, },
env, env,
signal, signal,
@@ -570,18 +557,16 @@ function wrapClientProvider(
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner), resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
effectiveOptions: (own) => withDefaults(own) ?? {}, listFeatures: listFeatures
listControls: listControls
? async (config) => ? async (config) =>
await listControls({ await listFeatures({
...config, ...config,
provider: inner.provider, provider: inner.provider,
providerOptions: withDefaults(config.providerOptions), providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
}) })
: undefined, : undefined,
listImportableSessions: listImportableSessions listImportableSessions: listImportableSessions
? async (options) => ? async (options) => await listImportableSessions({ ...options, providerOptions })
await listImportableSessions({ ...options, providerOptions: configuredDefaults() })
: undefined, : undefined,
importSession: importSession importSession: importSession
? async (input, context) => { ? async (input, context) => {
@@ -590,7 +575,10 @@ function wrapClientProvider(
config: { config: {
...context.config, ...context.config,
provider: inner.provider, provider: inner.provider,
providerOptions: withDefaults(context.config.providerOptions), providerOptions: mergeProviderOptions(
providerOptions,
context.config.providerOptions,
),
}, },
storedConfig: { storedConfig: {
...context.storedConfig, ...context.storedConfig,
@@ -1307,7 +1307,7 @@ describe("ProviderSnapshotManager public surface", () => {
resolverInputs.push(input); resolverInputs.push(input);
return { return {
modeId: input.parent?.isUnattended ? "child-unattended" : undefined, modeId: input.parent?.isUnattended ? "child-unattended" : undefined,
providerOptions: undefined, featureValues: undefined,
}; };
}, },
}), }),
@@ -1338,17 +1338,17 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project", cwd: "/tmp/project",
provider: "codex", provider: "codex",
requestedMode: undefined, requestedMode: undefined,
providerOptions: undefined, featureValues: undefined,
parent, parent,
unattended: false, unattended: false,
}); });
expect(resolved).toEqual({ modeId: "child-unattended", providerOptions: undefined }); expect(resolved).toEqual({ modeId: "child-unattended", featureValues: undefined });
expect(resolverInputs).toEqual([ expect(resolverInputs).toEqual([
{ {
provider: "codex", provider: "codex",
requestedMode: undefined, requestedMode: undefined,
providerOptions: undefined, featureValues: undefined,
parent: { parent: {
provider: "claude", provider: "claude",
modeId: "parent-unattended", modeId: "parent-unattended",
@@ -1386,7 +1386,7 @@ describe("ProviderSnapshotManager public surface", () => {
resolverInputs.push(input); resolverInputs.push(input);
return { return {
modeId: input.unattended ? "worker" : undefined, modeId: input.unattended ? "worker" : undefined,
providerOptions: undefined, featureValues: undefined,
}; };
}, },
}), }),
@@ -1397,17 +1397,17 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project", cwd: "/tmp/project",
provider: "codex", provider: "codex",
requestedMode: undefined, requestedMode: undefined,
providerOptions: { fast_mode: true }, featureValues: { fast_mode: true },
parent: null, parent: null,
unattended: true, unattended: true,
}); });
expect(resolved).toEqual({ modeId: "worker", providerOptions: undefined }); expect(resolved).toEqual({ modeId: "worker", featureValues: undefined });
expect(resolverInputs).toEqual([ expect(resolverInputs).toEqual([
{ {
provider: "codex", provider: "codex",
requestedMode: undefined, requestedMode: undefined,
providerOptions: { fast_mode: true }, featureValues: { fast_mode: true },
parent: null, parent: null,
unattended: true, unattended: true,
availableModes: modes, availableModes: modes,
@@ -1456,7 +1456,7 @@ describe("ProviderSnapshotManager public surface", () => {
config: { config: {
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}, },
}; };
@@ -1464,28 +1464,12 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project", cwd: "/tmp/project",
provider: "opencode", provider: "opencode",
requestedMode: "base", requestedMode: "base",
providerOptions: undefined, featureValues: undefined,
parent, parent,
unattended: false, unattended: false,
}); });
expect(resolved).toEqual({ modeId: "base", providerOptions: { auto_accept: true } }); expect(resolved).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
// A parent whose Auto Accept comes from the provider's config.json default has no own
// option; it is classified on its effective options.
const inheriting = await manager.resolveCreateConfig({
cwd: "/tmp/project",
provider: "opencode",
requestedMode: "base",
providerOptions: undefined,
parent: {
...parent,
config: { provider: "opencode", cwd: "/tmp/project" },
effectiveOptions: { auto_accept: true },
},
unattended: false,
});
expect(inheriting).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
} finally { } finally {
manager.destroy(); manager.destroy();
} }
@@ -1,4 +1,4 @@
import { normalizeAgentModelCatalog, type ProviderOptions } from "@getpaseo/protocol/agent-types"; import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { EventEmitter } from "node:events"; import { EventEmitter } from "node:events";
import { homedir } from "node:os"; import { homedir } from "node:os";
@@ -160,14 +160,14 @@ export interface ResolveProviderCreateConfigOptions {
cwd?: string | null; cwd?: string | null;
provider: AgentProvider; provider: AgentProvider;
requestedMode: string | undefined; requestedMode: string | undefined;
providerOptions: ProviderOptions | undefined; featureValues: Record<string, unknown> | undefined;
parent: ManagedAgent | null; parent: ManagedAgent | null;
unattended: boolean; unattended: boolean;
} }
export interface ResolvedProviderCreateConfig { export interface ResolvedProviderCreateConfig {
modeId: string | undefined; modeId: string | undefined;
providerOptions: ProviderOptions | undefined; featureValues: Record<string, unknown> | undefined;
} }
interface ResolveDefaultModelOptions { interface ResolveDefaultModelOptions {
@@ -563,7 +563,7 @@ export class ProviderSnapshotManager {
return definition.resolveCreateConfig({ return definition.resolveCreateConfig({
provider: input.provider, provider: input.provider,
requestedMode: input.requestedMode, requestedMode: input.requestedMode,
providerOptions: input.providerOptions, featureValues: input.featureValues,
parent, parent,
unattended: input.unattended || parent?.isUnattended === true, unattended: input.unattended || parent?.isUnattended === true,
availableModes: entry.modes ?? [], availableModes: entry.modes ?? [],
@@ -744,13 +744,8 @@ export class ProviderSnapshotManager {
modeId: parent.currentModeId, modeId: parent.currentModeId,
isUnattended: definition.isCreateConfigUnattended({ isUnattended: definition.isCreateConfigUnattended({
modeId: parent.currentModeId, modeId: parent.currentModeId,
// Provider defaults from `config.json` apply to the parent too, so classify on the config: parent.config,
// effective options while the stored overrides stay as they are. features: parent.features,
config: {
...parent.config,
providerOptions: parent.effectiveOptions ?? parent.config.providerOptions,
},
controls: parent.controls,
availableModes: parent.availableModes ?? definition.modes ?? [], availableModes: parent.availableModes ?? definition.modes ?? [],
}), }),
}; };
@@ -4,7 +4,7 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { import {
AgentSideConnection, AgentSideConnection,
@@ -198,7 +198,7 @@ function createSessionWithConfig(
provider?: string; provider?: string;
modeId?: string | null; modeId?: string | null;
model?: string | null; model?: string | null;
providerOptions?: Record<string, unknown>; featureValues?: Record<string, unknown>;
} = {}, } = {},
logger: ReturnType<typeof createTestLogger> = createTestLogger(), logger: ReturnType<typeof createTestLogger> = createTestLogger(),
): ACPAgentSession { ): ACPAgentSession {
@@ -208,7 +208,7 @@ function createSessionWithConfig(
cwd: "/tmp/paseo-acp-test", cwd: "/tmp/paseo-acp-test",
modeId: config.modeId ?? undefined, modeId: config.modeId ?? undefined,
model: config.model ?? undefined, model: config.model ?? undefined,
providerOptions: config.providerOptions, featureValues: config.featureValues,
}, },
{ {
provider: config.provider ?? "claude-acp", provider: config.provider ?? "claude-acp",
@@ -300,20 +300,19 @@ function selectConfigOption(
function createCopilotSessionWithConfig( function createCopilotSessionWithConfig(
modeId?: string | null, modeId?: string | null,
providerOptions?: Record<string, unknown>, featureValues?: Record<string, unknown>,
defaultCommand: string[] = ["copilot", "--acp"],
): ACPAgentSession { ): ACPAgentSession {
return new ACPAgentSession( return new ACPAgentSession(
{ {
provider: "copilot", provider: "copilot",
cwd: "/tmp/paseo-acp-test", cwd: "/tmp/paseo-acp-test",
modeId: modeId ?? undefined, modeId: modeId ?? undefined,
...(providerOptions ? { providerOptions } : {}), ...(featureValues ? { featureValues } : {}),
}, },
{ {
provider: "copilot", provider: "copilot",
logger: createTestLogger(), logger: createTestLogger(),
defaultCommand, defaultCommand: ["copilot", "--acp"],
defaultModes: COPILOT_MODES, defaultModes: COPILOT_MODES,
sessionResponseTransformer: transformCopilotSessionResponse, sessionResponseTransformer: transformCopilotSessionResponse,
configOptionsTransformer: transformCopilotConfigOptions, configOptionsTransformer: transformCopilotConfigOptions,
@@ -1499,7 +1498,7 @@ describe("ACPAgentSession Zed parity", () => {
test("auto-accepts ACP permission requests when the shared feature is enabled", async () => { test("auto-accepts ACP permission requests when the shared feature is enabled", async () => {
const session = createSessionWithConfig({ const session = createSessionWithConfig({
provider: "cursor-acp", provider: "cursor-acp",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}); });
const events: Array<{ type: string }> = []; const events: Array<{ type: string }> = [];
@@ -1531,7 +1530,7 @@ describe("ACPAgentSession Zed parity", () => {
test("does not auto-accept ACP chooser requests", async () => { test("does not auto-accept ACP chooser requests", async () => {
const session = createSessionWithConfig({ const session = createSessionWithConfig({
provider: "kimi-acp", provider: "kimi-acp",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
@@ -1584,8 +1583,11 @@ describe("ACPAgentSession Zed parity", () => {
internals.sessionId = "session-1"; internals.sessionId = "session-1";
internals.connection = {}; internals.connection = {};
await session.applyOptions({ auto_accept: true }); await session.setFeature("auto_accept", true);
expect(session.features).toContainEqual(
expect.objectContaining({ type: "toggle", id: "auto_accept", value: true }),
);
await expect( await expect(
session.requestPermission({ session.requestPermission({
sessionId: "session-1", sessionId: "session-1",
@@ -1603,7 +1605,7 @@ describe("ACPAgentSession Zed parity", () => {
}); });
test("surfaces an ACP permission when auto-accept has no allow option", async () => { test("surfaces an ACP permission when auto-accept has no allow option", async () => {
const session = createSessionWithConfig({ providerOptions: { auto_accept: true } }); const session = createSessionWithConfig({ featureValues: { auto_accept: true } });
const events: Array<{ type: string; request?: { id: string } }> = []; const events: Array<{ type: string; request?: { id: string } }> = [];
asInternals<ACPSessionInternals>(session).sessionId = "session-1"; asInternals<ACPSessionInternals>(session).sessionId = "session-1";
session.subscribe((event) => events.push(event as { type: string; request?: { id: string } })); session.subscribe((event) => events.push(event as { type: string; request?: { id: string } }));
@@ -1763,18 +1765,35 @@ describe("ACPAgentSession Zed parity", () => {
const internals = asInternals<ACPSessionInternals>(session); const internals = asInternals<ACPSessionInternals>(session);
internals.configOptions = [copilotAgentConfigOption("")]; internals.configOptions = [copilotAgentConfigOption("")];
expect(session.controls).toEqual([ expect(session.features).toEqual([
expect.objectContaining({ type: "toggle", key: "auto_accept" }), expect.objectContaining({
type: "toggle",
id: "auto_accept",
value: false,
}),
{ {
type: "select", type: "select",
key: "agent", id: "agent",
label: "Agent", label: "Agent",
description: "Use a Copilot custom agent profile", description: "Use a Copilot custom agent profile",
tooltip: "Select Copilot agent", tooltip: "Select Copilot agent",
icon: undefined, icon: undefined,
choices: [ value: "",
{ value: "", label: "Default", description: undefined }, options: [
{ value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" }, {
id: "",
label: "Default",
description: undefined,
isDefault: true,
metadata: undefined,
},
{
id: "Probe Agent",
label: "Probe Agent",
description: "Temporary probe agent",
isDefault: false,
metadata: undefined,
},
], ],
}, },
]); ]);
@@ -1797,9 +1816,15 @@ describe("ACPAgentSession Zed parity", () => {
configId: "agent", configId: "agent",
value: "Probe Agent", value: "Probe Agent",
}); });
expect(session.controls).toEqual([ expect(session.features).toEqual([
expect.objectContaining({ key: "auto_accept" }), expect.objectContaining({
expect.objectContaining({ key: "agent" }), id: "auto_accept",
value: false,
}),
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
]); ]);
}); });
@@ -1813,182 +1838,26 @@ describe("ACPAgentSession Zed parity", () => {
connection: { setSessionConfigOption }, connection: { setSessionConfigOption },
}); });
await session.applyOptions({ agent: "Probe Agent" }); await session.setFeature("agent", "Probe Agent");
expect(setSessionConfigOption).toHaveBeenCalledWith({ expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1", sessionId: "session-1",
configId: "agent", configId: "agent",
value: "Probe Agent", value: "Probe Agent",
}); });
expect(session.controls).toEqual([ expect(session.features).toEqual([
expect.objectContaining({ key: "auto_accept" }), expect.objectContaining({
expect.objectContaining({ key: "agent" }), id: "auto_accept",
value: false,
}),
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
]); ]);
}); });
}); });
describe("ACPAgentSession options against an ACP agent", () => {
const fakeAgentPath = fileURLToPath(new URL("./test-utils/fake-acp-agent.mjs", import.meta.url));
const sessions: ACPAgentSession[] = [];
afterEach(async () => {
await Promise.all(sessions.splice(0).map((session) => session.close()));
});
function thoughtOption(currentValue: string): SessionConfigOption {
return {
id: "thought",
name: "Thinking",
category: "thought_level",
type: "select",
currentValue,
options: [
{ value: "low", name: "Low" },
{ value: "high", name: "High" },
],
};
}
async function openSession(input: {
agent?: string;
scenario?: Record<string, unknown>;
providerOptions?: Record<string, unknown>;
}): Promise<{ session: ACPAgentSession; optionEvents: AgentStreamEvent[] }> {
const scenario = {
options: [copilotAgentConfigOption(input.agent ?? ""), thoughtOption("low")],
...input.scenario,
};
await mkdir("/tmp/paseo-acp-test", { recursive: true });
const session = createCopilotSessionWithConfig(null, input.providerOptions, [
process.execPath,
fakeAgentPath,
JSON.stringify(scenario),
]);
sessions.push(session);
const optionEvents: AgentStreamEvent[] = [];
session.subscribe((event) => {
if (event.type === "options_changed") optionEvents.push(event);
});
await session.initializeNewSession();
return { session, optionEvents };
}
test("a select the agent opened differently from the options is the open patch, not an event", async () => {
const { session, optionEvents } = await openSession({
providerOptions: { agent: "Probe Agent" },
scenario: { answers: { agent: "" } },
});
expect(session.openOptionsPatch).toEqual({ agent: "" });
expect(optionEvents).toEqual([]);
});
test("a select the agent opened with that nobody set is in the open patch", async () => {
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
expect(session.openOptionsPatch).toEqual({ agent: "Probe Agent" });
expect(optionEvents).toEqual([]);
});
test("a notification that arrives before the open write is answered is not an event", async () => {
const { session, optionEvents } = await openSession({
providerOptions: { agent: "Probe Agent" },
scenario: { onWrite: "notify-first" },
});
expect(session.openOptionsPatch).toEqual({});
expect(optionEvents).toEqual([]);
});
test("applying a patch returns the selects that ended up different and emits nothing", async () => {
const { session, optionEvents } = await openSession({ scenario: { answers: { agent: "" } } });
await expect(session.applyOptions({ agent: "Probe Agent" })).resolves.toEqual({ agent: "" });
expect(optionEvents).toEqual([]);
});
test("a notification that arrives before the apply write is answered is not an event", async () => {
const { session, optionEvents } = await openSession({ scenario: { onWrite: "notify-first" } });
await expect(session.applyOptions({ agent: "Probe Agent" })).resolves.toEqual({});
expect(optionEvents).toEqual([]);
});
test("a rejected apply reports what the agent moved before refusing, since nothing is stored", async () => {
const { session, optionEvents } = await openSession({
scenario: { onWrite: "notify-then-reject" },
});
await expect(session.applyOptions({ agent: "Probe Agent" })).rejects.toThrow(/write refused/);
expect(optionEvents).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
]);
});
test("overlapping applies that both fail still report what the agent moved", async () => {
const { session, optionEvents } = await openSession({ scenario: { onWrite: "slow-reject" } });
const first = session.applyOptions({ agent: "Probe Agent" });
const second = session.applyOptions({ agent: null });
await expect(first).rejects.toThrow(/write refused/);
await expect(second).rejects.toThrow(/write refused/);
expect(optionEvents).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
]);
});
test("a removal writes the select's empty choice and returns nothing when that took", async () => {
const { session, optionEvents } = await openSession({ agent: "Probe Agent" });
await expect(session.applyOptions({ agent: null })).resolves.toEqual({});
expect(optionEvents).toEqual([]);
});
test("rejects a live auto_accept the creation schema would reject, and writes nothing", async () => {
const { session, optionEvents } = await openSession({
scenario: { onWrite: "notify-then-reject" },
});
await expect(
session.applyOptions({ auto_accept: "yes", agent: "Probe Agent" }),
).rejects.toThrow(/auto_accept/);
expect(optionEvents).toEqual([]);
});
test("forwards a select the agent changes on its own", async () => {
const { session, optionEvents } = await openSession({
scenario: { moves: { thought: { agent: "Probe Agent" } } },
});
await session.setThinkingOption("high");
await vi.waitFor(() =>
expect(optionEvents).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
]),
);
});
test("a select that appears after startup is reported with its native value", async () => {
const { session, optionEvents } = await openSession({
scenario: {
options: [thoughtOption("low")],
adds: { thought: [copilotAgentConfigOption("Probe Agent")] },
},
});
expect(session.openOptionsPatch).toEqual({});
await session.setThinkingOption("high");
await vi.waitFor(() =>
expect(optionEvents).toEqual([
{ type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } },
]),
);
});
});
describe("deriveModelDefinitionsFromACP", () => { describe("deriveModelDefinitionsFromACP", () => {
test("attaches shared thinking options to ACP model state", () => { test("attaches shared thinking options to ACP model state", () => {
const result = deriveModelDefinitionsFromACP( const result = deriveModelDefinitionsFromACP(
@@ -2215,14 +2084,14 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "generic-acp", provider: "generic-acp",
requestedMode: undefined, requestedMode: undefined,
providerOptions: { provider_feature: "kept" }, featureValues: { provider_feature: "kept" },
parent: null, parent: null,
unattended: true, unattended: true,
availableModes: [], availableModes: [],
}), }),
).toEqual({ ).toEqual({
modeId: undefined, modeId: undefined,
providerOptions: { provider_feature: "kept", auto_accept: true }, featureValues: { provider_feature: "kept", auto_accept: true },
}); });
}); });
@@ -2237,12 +2106,12 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "generic-acp", provider: "generic-acp",
requestedMode: undefined, requestedMode: undefined,
providerOptions: { auto_accept: false }, featureValues: { auto_accept: false },
parent: null, parent: null,
unattended: true, unattended: true,
availableModes: [], availableModes: [],
}), }),
).toEqual({ modeId: undefined, providerOptions: { auto_accept: false } }); ).toEqual({ modeId: undefined, featureValues: { auto_accept: false } });
}); });
test("maps an unattended cross-provider parent to ACP Auto Accept", () => { test("maps an unattended cross-provider parent to ACP Auto Accept", () => {
@@ -2256,7 +2125,7 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "generic-acp", provider: "generic-acp",
requestedMode: undefined, requestedMode: undefined,
providerOptions: undefined, featureValues: undefined,
parent: { parent: {
provider: "claude", provider: "claude",
modeId: "bypassPermissions", modeId: "bypassPermissions",
@@ -2265,7 +2134,7 @@ describe("ACPAgentClient config features", () => {
unattended: false, unattended: false,
availableModes: [{ id: "agent", label: "Agent" }], availableModes: [{ id: "agent", label: "Agent" }],
}), }),
).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } }); ).toEqual({ modeId: undefined, featureValues: { auto_accept: true } });
}); });
test("treats Auto Accept as an unattended ACP configuration", () => { test("treats Auto Accept as an unattended ACP configuration", () => {
@@ -2281,7 +2150,7 @@ describe("ACPAgentClient config features", () => {
config: { config: {
provider: "generic-acp", provider: "generic-acp",
cwd: "/tmp/acp-features", cwd: "/tmp/acp-features",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}, },
availableModes: [], availableModes: [],
}), }),
@@ -2296,17 +2165,22 @@ describe("ACPAgentClient config features", () => {
}); });
await expect( await expect(
client.listControls({ client.listFeatures({
provider: "generic-acp", provider: "generic-acp",
cwd: "/tmp/acp-features", cwd: "/tmp/acp-features",
providerOptions: { auto_accept: true }, featureValues: { auto_accept: true },
}), }),
).resolves.toEqual([ ).resolves.toEqual([
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }), expect.objectContaining({
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
value: true,
}),
]); ]);
}); });
test("derives controls from configured ACP select options", async () => { test("derives features from configured ACP select options", async () => {
class TestACPAgentClient extends ACPAgentClient { class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> { protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return { return {
@@ -2332,18 +2206,23 @@ describe("ACPAgentClient config features", () => {
}); });
await expect( await expect(
client.listControls({ client.listFeatures({
provider: "copilot", provider: "copilot",
cwd: "/tmp/acp-features", cwd: "/tmp/acp-features",
}), }),
).resolves.toEqual([ ).resolves.toEqual([
expect.objectContaining({ type: "toggle", key: "auto_accept" }), expect.objectContaining({
type: "toggle",
id: "auto_accept",
value: false,
}),
expect.objectContaining({ expect.objectContaining({
type: "select", type: "select",
key: "agent", id: "agent",
choices: [ value: "Probe Agent",
expect.objectContaining({ value: "", label: "Default" }), options: [
expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }), expect.objectContaining({ id: "", label: "Default", isDefault: false }),
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
], ],
}), }),
]); ]);
@@ -4118,7 +3997,7 @@ describe("ACPAgentClient probe cleanup", () => {
terminateProcess: terminator.terminate, terminateProcess: terminator.terminate,
}); });
await client.listControls({ provider: "copilot", cwd: "/tmp/acp-features" }); await client.listFeatures({ provider: "copilot", cwd: "/tmp/acp-features" });
expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" }); expect(closeSession).toHaveBeenCalledWith({ sessionId: "feature-probe-session" });
expect(terminator.terminated).toContain(child); expect(terminator.terminated).toContain(child);
@@ -1,7 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { ACPProviderOptionsSchema } from "./acp-options.js"; import { ACPProviderOptionsSchema } from "./acp-options.js";
import { applyProviderOptionsPatch } from "../option-controls.js";
import { assertChangesAreLive } from "../provider-options.js";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
@@ -68,7 +65,7 @@ import {
type AgentCapabilityFlags, type AgentCapabilityFlags,
type AgentClient, type AgentClient,
type AgentCreateConfigUnattendedInput, type AgentCreateConfigUnattendedInput,
type AgentOptionControl, type AgentFeature,
type AgentLaunchContext, type AgentLaunchContext,
type AgentMetadata, type AgentMetadata,
type AgentMode, type AgentMode,
@@ -851,10 +848,10 @@ export function deriveModelDefinitionsFromACP(
})); }));
} }
export function deriveControlsFromACP( export function deriveFeaturesFromACP(
configOptions: SessionConfigOption[] | null | undefined, configOptions: SessionConfigOption[] | null | undefined,
featureOptions: ACPConfigFeatureOption[], featureOptions: ACPConfigFeatureOption[],
): AgentOptionControl[] { ): AgentFeature[] {
return featureOptions.flatMap((featureOption) => { return featureOptions.flatMap((featureOption) => {
const option = findSelectConfigFeatureOption(configOptions, featureOption); const option = findSelectConfigFeatureOption(configOptions, featureOption);
if (!option) { if (!option) {
@@ -864,42 +861,42 @@ export function deriveControlsFromACP(
return [ return [
{ {
type: "select", type: "select",
key: featureOption.id, id: featureOption.id,
label: featureOption.label, label: featureOption.label,
description: featureOption.description, description: featureOption.description,
tooltip: featureOption.tooltip, tooltip: featureOption.tooltip,
icon: featureOption.icon, icon: featureOption.icon,
choices: deriveConfigFeatureSelectOptions(option, featureOption).map((choice) => ({ value: option.currentValue ?? null,
value: choice.id, options: deriveConfigFeatureSelectOptions(option, featureOption),
label: choice.label,
description: choice.description,
})),
}, },
]; ];
}); });
} }
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean { function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
return config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true; return config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
} }
const ACP_AUTO_ACCEPT_CONTROL: AgentOptionControl = { function buildACPAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
type: "toggle", return {
key: ACP_AUTO_ACCEPT_FEATURE_ID, type: "toggle",
label: "Auto Accept", id: ACP_AUTO_ACCEPT_FEATURE_ID,
description: "Automatically approves ACP permission prompts.", label: "Auto Accept",
tooltip: "Auto accept permission prompts", description: "Automatically approves ACP permission prompts.",
icon: "shield-check", tooltip: "Auto accept permission prompts",
}; icon: "shield-check",
value: isACPAutoAcceptEnabled(config),
};
}
function resolveACPCreateConfig( function resolveACPCreateConfig(
input: ResolveAgentCreateConfigInput, input: ResolveAgentCreateConfigInput,
): ResolveAgentCreateConfigResult { ): ResolveAgentCreateConfigResult {
const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true; const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true;
const providerOptions = const featureValues =
isUnattendedCreate && input.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined isUnattendedCreate && input.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
? { ...input.providerOptions, [ACP_AUTO_ACCEPT_FEATURE_ID]: true } ? { ...input.featureValues, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
: input.providerOptions; : input.featureValues;
if ( if (
input.requestedMode === undefined && input.requestedMode === undefined &&
@@ -907,16 +904,22 @@ function resolveACPCreateConfig(
input.parent !== null && input.parent !== null &&
input.parent.provider !== input.provider input.parent.provider !== input.provider
) { ) {
return { modeId: undefined, providerOptions }; return { modeId: undefined, featureValues };
} }
return resolveDefaultAgentCreateConfig({ ...input, providerOptions }); return resolveDefaultAgentCreateConfig({ ...input, featureValues });
} }
function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean { function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean {
return ( return (
isDefaultAgentCreateConfigUnattended(input) || isDefaultAgentCreateConfigUnattended(input) ||
input.config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true input.config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true ||
input.features?.some(
(feature) =>
feature.id === ACP_AUTO_ACCEPT_FEATURE_ID &&
feature.type === "toggle" &&
feature.value === true,
) === true
); );
} }
@@ -1195,9 +1198,10 @@ export class ACPAgentClient implements AgentClient {
} }
} }
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> { async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
if (this.configFeatureOptions.length === 0) { if (this.configFeatureOptions.length === 0) {
return [ACP_AUTO_ACCEPT_CONTROL]; return [autoAcceptFeature];
} }
this.assertProvider(config); this.assertProvider(config);
@@ -1213,8 +1217,8 @@ export class ACPAgentClient implements AgentClient {
probeSessionId = response.sessionId; probeSessionId = response.sessionId;
const transformed = this.transformSessionResponse(response); const transformed = this.transformSessionResponse(response);
return [ return [
ACP_AUTO_ACCEPT_CONTROL, autoAcceptFeature,
...deriveControlsFromACP(transformed.configOptions, this.configFeatureOptions), ...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions),
]; ];
} finally { } finally {
await this.closeProbe(probe, probeSessionId); await this.closeProbe(probe, probeSessionId);
@@ -1757,11 +1761,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private currentTitle: string | null = null; private currentTitle: string | null = null;
private lastActivityAt: string | null = null; private lastActivityAt: string | null = null;
private configOptions: SessionConfigOption[] = []; private configOptions: SessionConfigOption[] = [];
/** How the opened session differs from the options it was given. */
openOptionsPatch: Record<string, unknown> = {};
/** Selects the provider moves while Paseo is opening or applying options belong to that result, not to events. */
private optionsTransactionDepth = 0;
private applying: Promise<unknown> = Promise.resolve();
private cachedCommands: AgentSlashCommand[] = []; private cachedCommands: AgentSlashCommand[] = [];
/** When the last command batch arrived; null while nothing has been published. */ /** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null; private commandsUpdatedAt: number | null = null;
@@ -2055,10 +2054,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode; return this.currentMode;
} }
get controls(): AgentOptionControl[] { get features(): AgentFeature[] {
return [ return [
ACP_AUTO_ACCEPT_CONTROL, buildACPAutoAcceptFeature(this.config),
...deriveControlsFromACP(this.configOptions, this.configFeatureOptions), ...deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions),
]; ];
} }
@@ -2199,7 +2198,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
const { connection, sessionId } = this; const { connection, sessionId } = this;
const before = this.featureValues();
const response = await this.runACPRequest(() => const response = await this.runACPRequest(() =>
connection.setSessionConfigOption({ connection.setSessionConfigOption({
sessionId, sessionId,
@@ -2214,7 +2212,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modeId, requestedValue: modeId,
label: "mode", label: "mode",
}); });
this.reportFeatureChanges(before);
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes; this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.pushEvent({ this.pushEvent({
type: "mode_changed", type: "mode_changed",
@@ -2326,7 +2323,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
const { connection, sessionId } = this; const { connection, sessionId } = this;
const before = this.featureValues();
const response = await this.runACPRequest(() => const response = await this.runACPRequest(() =>
connection.setSessionConfigOption({ connection.setSessionConfigOption({
sessionId, sessionId,
@@ -2341,7 +2337,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modelId, requestedValue: modelId,
label: "model", label: "model",
}); });
this.reportFeatureChanges(before);
this.pushEvent({ this.pushEvent({
type: "model_changed", type: "model_changed",
provider: this.provider, provider: this.provider,
@@ -2379,7 +2374,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
throw new Error(`${this.provider} does not expose ACP thought-level selection`); throw new Error(`${this.provider} does not expose ACP thought-level selection`);
} }
const { connection, sessionId } = this; const { connection, sessionId } = this;
const before = this.featureValues();
const response = await this.runACPRequest(() => const response = await this.runACPRequest(() =>
connection.setSessionConfigOption({ connection.setSessionConfigOption({
sessionId, sessionId,
@@ -2394,7 +2388,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: thinkingOptionId, requestedValue: thinkingOptionId,
label: "thought-level", label: "thought-level",
}); });
this.reportFeatureChanges(before);
this.pushEvent({ this.pushEvent({
type: "thinking_option_changed", type: "thinking_option_changed",
provider: this.provider, provider: this.provider,
@@ -2402,103 +2395,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}); });
} }
/** async setFeature(featureId: string, value: unknown): Promise<void> {
* Applies only the options the patch sets; returns the selects that ended up different.
* Provider notifications that arrive meanwhile are part of that result. When a write is
* rejected, what the provider moved is reported as an event, since nothing is stored.
*/
/** One apply at a time: each reconciles against the state the one before it left. */
applyOptions(options: ProviderOptions): Promise<Record<string, unknown>> {
const run = this.applying.then(() => this.applyOptionsNow(options));
this.applying = run.catch(() => undefined);
return run;
}
private async applyOptionsNow(options: ProviderOptions): Promise<Record<string, unknown>> {
assertChangesAreLive({
provider: this.config.provider,
patch: options,
current: this.config.providerOptions,
live: [ACP_AUTO_ACCEPT_FEATURE_ID, ...this.configFeatureOptions.map(({ id }) => id)],
});
ACPProviderOptionsSchema.parse(
applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }),
);
const before = this.featureValues();
const requested: Record<string, unknown> = {};
try {
await this.inOptionsTransaction(async () => {
for (const [id, value] of Object.entries(options)) {
const featureOption = this.configFeatureOptions.find((candidate) => candidate.id === id);
if (!featureOption) continue;
// A removal asks for the select's empty choice; a provider without one rejects it.
requested[id] = value ?? "";
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
if (!option || requested[id] !== option.currentValue) {
await this.setConfigFeature(id, value);
}
}
});
} catch (error) {
this.reportFeatureChanges(before);
throw error;
}
// Auto Accept is Paseo's own setting, so it changes only once every write has been accepted.
if (ACP_AUTO_ACCEPT_FEATURE_ID in options) {
this.config.providerOptions = {
...this.config.providerOptions,
[ACP_AUTO_ACCEPT_FEATURE_ID]: options[ACP_AUTO_ACCEPT_FEATURE_ID] === true,
};
}
return this.featureDifferences({ ...before, ...requested });
}
private async inOptionsTransaction<T>(run: () => Promise<T>): Promise<T> {
this.optionsTransactionDepth += 1;
try {
return await run();
} finally {
this.optionsTransactionDepth -= 1;
}
}
/** The selects as the provider reports them now. */
private featureValues(): Record<string, string> {
const values: Record<string, string> = {};
for (const featureOption of this.configFeatureOptions) {
const current = findSelectConfigFeatureOption(
this.configOptions,
featureOption,
)?.currentValue;
if (current != null) values[featureOption.id] = current;
}
return values;
}
/** The selects whose value is not what `expected` says, including ones it does not name. */
private featureDifferences(expected: Record<string, unknown>): Record<string, string> {
return Object.fromEntries(
Object.entries(this.featureValues()).filter(([id, value]) => expected[id] !== value),
);
}
/** Reports the selects the provider moved on its own, compared with their values `before`. */
private reportFeatureChanges(before: Record<string, string>): void {
if (this.optionsTransactionDepth > 0) return;
const patch = this.featureDifferences(before);
if (Object.keys(patch).length > 0) {
this.pushEvent({ type: "options_changed", provider: this.provider, patch });
}
}
private async setConfigFeature(featureId: string, value: unknown): Promise<void> {
if (!this.connection || !this.sessionId) { if (!this.connection || !this.sessionId) {
throw new Error("ACP session not initialized"); throw new Error("ACP session not initialized");
} }
if (featureId === ACP_AUTO_ACCEPT_FEATURE_ID) { if (featureId === ACP_AUTO_ACCEPT_FEATURE_ID) {
this.config.providerOptions = { this.config.featureValues = {
...this.config.providerOptions, ...this.config.featureValues,
[ACP_AUTO_ACCEPT_FEATURE_ID]: value === true, [ACP_AUTO_ACCEPT_FEATURE_ID]: value === true,
}; };
return; return;
@@ -2530,13 +2434,14 @@ export class ACPAgentSession implements AgentSession, ACPClient {
value: requestedValue, value: requestedValue,
}), }),
); );
this.applyConfigOptionResponse({ const currentValue = this.applyConfigOptionResponse({
response, response,
configId: option.id, configId: option.id,
category: featureOption.category, category: featureOption.category,
requestedValue, requestedValue,
label: featureOption.label, label: featureOption.label,
}); });
this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue };
} }
private applyConfigOptionResponse({ private applyConfigOptionResponse({
@@ -3081,11 +2986,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId; return this.modeIdTransformer ? this.modeIdTransformer(modeId) : modeId;
} }
private applyConfiguredOverrides(): Promise<void> { private async applyConfiguredOverrides(): Promise<void> {
return this.inOptionsTransaction(() => this.writeConfiguredOverrides());
}
private async writeConfiguredOverrides(): Promise<void> {
const configuredModeId = this.config.modeId; const configuredModeId = this.config.modeId;
if (configuredModeId && configuredModeId !== this.currentMode) { if (configuredModeId && configuredModeId !== this.currentMode) {
const selection = resolveACPModeSelection({ const selection = resolveACPModeSelection({
@@ -3117,13 +3018,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) { if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
await this.setThinkingOption(this.config.thinkingOptionId); await this.setThinkingOption(this.config.thinkingOptionId);
} }
const configuredFeatureValues = this.config.providerOptions ?? {}; const configuredFeatureValues = this.config.featureValues ?? {};
for (const featureOption of this.configFeatureOptions) { for (const featureOption of this.configFeatureOptions) {
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) { if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
continue; continue;
} }
try { try {
await this.setConfigFeature(featureOption.id, configuredFeatureValues[featureOption.id]); await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
} catch (error) { } catch (error) {
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) { if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
throw error; throw error;
@@ -3134,9 +3035,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
); );
} }
} }
// The options are the state, so every select the session opened with is reported, including
// ones nobody set (a native default is stored like any other value).
this.openOptionsPatch = this.featureDifferences(configuredFeatureValues);
} }
/** /**
@@ -3350,7 +3248,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] { private handleConfigOptionUpdate(update: ConfigOptionUpdate): AgentStreamEvent[] {
const before = this.featureValues();
this.configOptions = this.transformConfigOptions(update.configOptions); this.configOptions = this.transformConfigOptions(update.configOptions);
const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions); const modeInfo = deriveModesFromACP(this.defaultModes, null, this.configOptions);
const nextMode = modeInfo.currentModeId; const nextMode = modeInfo.currentModeId;
@@ -3361,7 +3258,6 @@ export class ACPAgentSession implements AgentSession, ACPClient {
this.currentMode = nextMode ?? this.currentMode; this.currentMode = nextMode ?? this.currentMode;
this.currentModel = nextModel ?? this.currentModel; this.currentModel = nextModel ?? this.currentModel;
this.thinkingOptionId = nextThinkingOptionId ?? this.thinkingOptionId; this.thinkingOptionId = nextThinkingOptionId ?? this.thinkingOptionId;
this.reportFeatureChanges(before);
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
if (nextMode !== null) { if (nextMode !== null) {
@@ -2,8 +2,6 @@ import { z } from "zod";
export const ACPProviderOptionsSchema = z export const ACPProviderOptionsSchema = z
.object({ .object({
// Composer control: approves permission prompts without asking.
auto_accept: z.boolean().optional(),
supportsMcpServers: z.boolean().optional(), supportsMcpServers: z.boolean().optional(),
clientCapabilities: z clientCapabilities: z
.object({ .object({
@@ -18,9 +18,6 @@ import {
resolveClaudeCodeVersion, resolveClaudeCodeVersion,
toClaudeSdkMcpConfig, toClaudeSdkMcpConfig,
} from "./agent.js"; } from "./agent.js";
import { AgentManager } from "../../agent-manager.js";
import { ensureAgentLoaded } from "../../agent-loading.js";
import { AgentStorage } from "../../agent-storage.js";
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js"; import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
import { claudeProjectDirSync } from "./project-dir.js"; import { claudeProjectDirSync } from "./project-dir.js";
import { streamSession } from "../test-utils/session-stream-adapter.js"; import { streamSession } from "../test-utils/session-stream-adapter.js";
@@ -33,8 +30,6 @@ import type {
} from "../../agent-sdk-types.js"; } from "../../agent-sdk-types.js";
import type { AgentAttachment } from "@getpaseo/protocol/messages"; import type { AgentAttachment } from "@getpaseo/protocol/messages";
import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js"; import { buildAgentPrompt, renderPromptAttachmentAsText } from "../../prompt-attachments.js";
import { applyProviderOptionsPatch } from "../../option-controls.js";
import { mergeProviderOptions } from "../../provider-options.js";
interface TestClaudeSession { interface TestClaudeSession {
translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>; translateMessageToEvents(message: SDKMessage): Promise<AgentStreamEvent[]>;
@@ -958,39 +953,39 @@ describe("ClaudeAgentSession features", () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
await expect( await expect(
client.listControls({ client.listFeatures({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-opus-4-8", model: "claude-opus-4-8",
}), }),
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]); ).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect( await expect(
client.listControls({ client.listFeatures({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-opus-4-8[1m]", model: "claude-opus-4-8[1m]",
}), }),
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]); ).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect( await expect(
client.listControls({ client.listFeatures({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-opus-4-8-20260101", model: "claude-opus-4-8-20260101",
}), }),
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]); ).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect( await expect(
client.listControls({ client.listFeatures({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-opus-5", model: "claude-opus-5",
}), }),
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]); ).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
await expect( await expect(
client.listControls({ client.listFeatures({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "openrouter/anthropic/claude-opus-4-8", model: "openrouter/anthropic/claude-opus-4-8",
@@ -998,7 +993,7 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]); ).resolves.toEqual([]);
await expect( await expect(
client.listControls({ client.listFeatures({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-sonnet-5", model: "claude-sonnet-5",
@@ -1006,7 +1001,7 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]); ).resolves.toEqual([]);
await expect( await expect(
client.listControls({ client.listFeatures({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-sonnet-4-6", model: "claude-sonnet-4-6",
@@ -1014,25 +1009,6 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]); ).resolves.toEqual([]);
}); });
test("opens with Fast off on a model that has none, so unrelated options still apply", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-5",
providerOptions: { fast_mode: true },
});
expect(session.openOptionsPatch).toEqual({ fast_mode: false });
await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined();
await session.close();
});
test("passes initial fast mode through Claude flag settings", async () => { test("passes initial fast mode through Claude flag settings", async () => {
const { queryFactory, queryMock } = createQueryMock(); const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
@@ -1044,7 +1020,7 @@ describe("ClaudeAgentSession features", () => {
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-opus-4-8", model: "claude-opus-4-8",
providerOptions: { fast_mode: true }, featureValues: { fast_mode: true },
}); });
await expect( await expect(
@@ -1516,7 +1492,7 @@ describe("ClaudeAgentSession features", () => {
ensureQuery(): Promise<unknown>; ensureQuery(): Promise<unknown>;
} }
).ensureQuery(); ).ensureQuery();
await session.applyOptions?.({ fast_mode: true }); await session.setFeature?.("fast_mode", true);
expect(queryFactory).toHaveBeenCalledTimes(1); expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true }); expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
@@ -1525,242 +1501,6 @@ describe("ClaudeAgentSession features", () => {
await session.close(); await session.close();
}); });
test("rejects a change to a launch option while the first query is starting", async () => {
const { queryFactory } = createQueryMock();
let releaseBinary!: () => void;
const binaryLookup = new Promise<void>((resolve) => {
releaseBinary = resolve;
});
let lookupStarted!: () => void;
const lookupEntered = new Promise<void>((resolve) => {
lookupStarted = resolve;
});
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => {
lookupStarted();
await binaryLookup;
return "/test/claude/bin";
},
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
providerOptions: { disallowedTools: ["Bash"] },
});
const turn = session.startTurn("hello");
await lookupEntered;
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
/disallowedTools.*cannot change on a running session/,
);
releaseBinary();
await turn;
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ disallowedTools: ["Bash"] });
await session.close();
});
test("a patch before the first prompt keeps the restrictions the session opened with", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
providerOptions: {
disallowedTools: ["Bash"],
sandbox: { enabled: true, failIfUnavailable: true },
},
});
await session.applyOptions?.({ fast_mode: true });
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
disallowedTools: ["Bash"],
sandbox: { enabled: true, failIfUnavailable: true },
});
await session.close();
});
test("an option Claude reads when it starts changes before the query exists and is rejected after", async () => {
const { queryFactory } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
providerOptions: { disallowedTools: ["Bash"] },
});
await session.applyOptions?.({ disallowedTools: ["Bash", "Write"] });
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({
disallowedTools: ["Bash", "Write"],
});
// The query now runs with these; replacing it would end its background work.
await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow(
/disallowedTools.*cannot change on a running session/,
);
// The same value is not a change, and Fast still applies.
await expect(
session.applyOptions?.({ disallowedTools: ["Bash", "Write"], fast_mode: true }),
).resolves.toBeUndefined();
expect(queryFactory).toHaveBeenCalledTimes(1);
await session.close();
});
test("switching to a model without Fast turns it off, reports that, and later patches still apply", async () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
providerOptions: { fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.setModel?.("claude-fable-5");
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
const reported = events.filter((event) => event.type === "options_changed");
expect(reported).toEqual([
{ type: "options_changed", provider: "claude", patch: { fast_mode: false } },
]);
// With a `config.json` default of `fast_mode: true`, the agent's effective options must
// still read Fast as off: a removed key would let the default fill `true` back in.
const stored = applyProviderOptionsPatch({
options: { fast_mode: true },
patch: reported[0].patch,
});
expect(mergeProviderOptions({ fast_mode: true }, stored)).toEqual({ fast_mode: false });
await expect(session.applyOptions?.({ fast_mode: null })).resolves.toBeUndefined();
await session.close();
});
test("removing fast_mode turns Fast off", async () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
});
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
await session.applyOptions?.({ fast_mode: true });
await session.applyOptions?.({ fast_mode: null });
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: false });
await session.close();
});
test("retries a fast mode change the runtime rejected", async () => {
const { queryFactory, queryMock } = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
});
await (session as unknown as { ensureQuery(): Promise<unknown> }).ensureQuery();
queryMock.applyFlagSettings.mockClear();
queryMock.applyFlagSettings.mockRejectedValueOnce(new Error("runtime unavailable"));
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
"runtime unavailable",
);
await session.applyOptions?.({ fast_mode: true });
expect(queryMock.applyFlagSettings).toHaveBeenCalledTimes(2);
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
await session.close();
});
test("fast mode survives a daemon restart through the stored providerOptions", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "claude-fast-restart-"));
const storagePath = path.join(root, "agents");
const makeClient = () => {
const mock = createQueryMock();
const client = new ClaudeAgentClient({
logger,
queryFactory: mock.queryFactory,
resolveBinary: async () => "/test/claude/bin",
});
return { ...mock, client };
};
const agentId = "00000000-0000-4000-8000-0000000000f1";
const before = makeClient();
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: { claude: before.client },
registry: storage,
logger,
});
try {
await manager.createAgent(
{ provider: "claude", cwd: root, model: "claude-opus-4-8" },
agentId,
{ workspaceId: undefined },
);
await manager.setAgentOptions(agentId, { fast_mode: true });
await manager.closeAgent(agentId);
await manager.flush();
await storage.flush();
const after = makeClient();
const restartedStorage = new AgentStorage(storagePath, logger);
const restarted = new AgentManager({
clients: { claude: after.client },
registry: restartedStorage,
logger,
});
const agent = await ensureAgentLoaded(agentId, {
agentManager: restarted,
agentStorage: restartedStorage,
logger,
});
const session = (agent as unknown as { session: { ensureQuery(): Promise<unknown> } | null })
.session;
await session?.ensureQuery();
expect(restarted.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: true });
expect(after.queryFactory.mock.calls[0]?.[0].options.settings).toMatchObject({
fastMode: true,
});
await restarted.closeAgent(agentId);
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
}); });
describe("normalizeClaudeAskUserQuestionUpdatedInput", () => { describe("normalizeClaudeAskUserQuestionUpdatedInput", () => {

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