Compare commits

..
Author SHA1 Message Date
Mohamed Boudra dbe5d7b3e1 fix(server): resolve supplied options against configured defaults before applying them live
setAgentOptions hands a running provider the keys the agent holds, each resolved
against the config.json defaults, so a nested patch (for example a sandbox
object) keeps the configured restrictions. Removed keys stay absent.
2026-11-01 13:21:42 +01:00
Mohamed Boudra d5953d2ea7 fix(server): Claude and Codex merge supplied options into their running options
setAgentOptions hands the session only the agent's own options, so Claude's
applyOptions no longer replaces the restrictions merged in at open (disallowed
tools, sandbox), and Codex derives its speed from the running record so an
explicit service_tier still beats a legacy fast_mode on an unrelated patch.
2026-11-01 13:11:41 +01:00
Mohamed Boudra e4269f5059 fix(server): Codex keeps unsupplied running options, snapshots show the live setting after a removal
- Codex applyOptions merges the supplied options into the running ones, so an
  unrelated patch or a removal no longer drops the sandbox and approval settings
  that were merged in when the session opened.
- A control the running session reports for a key the agent no longer holds
  shows its live value in the effective options, instead of the config.json
  default, so the snapshot and unattended-parent checks agree with the provider.
2026-11-01 13:02:32 +01:00
Mohamed Boudra 70a01a5c38 fix(server): keep removed options out of live apply, keep Auto Accept inheritance, honor dropped featureValues
- setAgentOptions hands the running provider the agent's own options instead of
  the config.json defaults merged in, so a removed key is absent and the provider
  keeps its setting. Defaults apply when the session is next opened.
- ACP and OpenCode classify a parent as unattended from the live Auto Accept
  control when the stored option was removed.
- A create hook that omits or unsets featureValues removes every key it was sent.
2026-11-01 12:43:27 +01:00
Mohamed Boudra c51767af9b refactor(server): removing an option leaves a running provider on its current setting
Removing an option from a running agent drops the key from its stored and snapshot
options, but providers no longer revert to a starting value. The provider default
applies from the next open, and each control's default value shows the live setting.
This deletes the reset-to-baseline machinery in the plugin, ACP, OMP, Claude, Codex
and OpenCode sessions and keeps the options_changed reconciliation.

Also fixes two review findings:
- ACP startup overrides are reported once after all startup writes, so an
  intermediate selection change no longer overwrites a requested option.
- A create hook that removes providerOptions or featureValues keys keeps them
  removed instead of having the unchanged featureValues mirror restore them.
2026-11-01 12:31:37 +01:00
Mohamed Boudra 91fc543256 fix(server): reset ACP and plugin settings only when a held option is removed, deliver ACP startup acknowledgements
An options patch that never mentioned a setting reset it to its starting value whenever the provider had changed it on its own. The reset now applies only when an option the session held becomes absent, for ACP selections and plugin settings alike.

An ACP provider can answer a startup write with a different value. That option change was dropped because nothing was subscribed yet; the first subscriber now receives it, so the stored option matches what the provider kept.
2026-11-01 10:43:05 +01:00
Mohamed Boudra ba4348ee73 fix(server): reset OMP Fast to its starting value only when a held option is removed
Every options patch without fast_mode reset Fast to the value it had when the session started, so an unrelated patch undid a model family's own Fast setting. The reset now applies only when a fast_mode option that was held becomes absent.
2026-11-01 10:31:28 +01:00
Mohamed Boudra 822388e2f3 fix(server): let a held OMP Fast option follow the runtime when it changes by itself
OMP tracks Fast per model family, so switching models can turn it off. The session now reports that as an option change when a fast_mode option is held, instead of leaving the stored option true while the runtime reports false.
2026-11-01 10:21:55 +01:00
Mohamed Boudra b27b0b9d5c fix(server): keep a legacy feature's recorded value when migrating it to a control
An agent stored with features and no controls showed its toggles as off and its selects as unset in a snapshot built while its runtime was unloaded, because only the default choice survived the migration. The recorded value is now the migrated control's unset value; an explicit effective option still wins.
2026-11-01 10:12:16 +01:00
Mohamed Boudra a144b5c7af fix(server): reopen the created native plugin session, keep ACP reset acknowledgements from storing removed options
When a plugin session declines configuration and is reopened with its settings, the reopen continues the native session the first open created instead of creating a second one.

An ACP provider response to a write that only resets an absent option to its default no longer emits an option change, so a removed option stays absent.
2026-11-01 10:01:36 +01:00
Mohamed Boudra 67e4f17dee fix(server): honor the opened session's capabilities, reconcile startup-only plugin settings, keep ACP resets absent
A plugin session that declines session.configure although the connection offers it is reopened with its settings in the open request, so the requested setting is not lost. Opening settings the plugin settles on are compared with the agent's options for every plugin, including those that cannot be configured.

An ACP provider notification that arrives before the response to a reset write no longer stores the default as an explicit override: while options are being applied, the requested options are the ones held.
2026-11-01 09:52:06 +01:00
Mohamed Boudra 0ce2010773 fix(server): keep plugin startup settings, opening reconciliation and reset defaults across rewind
A plugin that cannot be configured takes its settings in the open request as before; only a configurable plugin opens without overrides so its reported values can serve as reset defaults.

Option changes a plugin makes while its session opens are held and delivered to the manager when it subscribes, so the stored option follows the committed value. The reset defaults belong to the logical session and survive the session being reopened after a rewind.
2026-11-01 09:42:31 +01:00
Mohamed Boudra d8d9f197be fix(server): reset plugin settings supplied at open and follow the plugin's committed selection
A plugin only reports the value a setting has after it opens, so overrides passed in the open request left no default for a later option removal to return to. The session now opens without setting overrides and applies them afterwards, which records the default.

A plugin that completes a configure request but keeps its previous value reported nothing new, so the requested option was stored anyway. Config reports are now compared with the options the agent holds, not with the previous report.
2026-11-01 09:25:34 +01:00
Mohamed Boudra 7856e7e986 fix(server): restore plugin settings when an option is removed, keep a dormant Claude Fast preference editable
- A plugin setting returns to the value it had before Paseo first changed it when its option is
  removed. Plugin-reported changes are reported only for settings the agent holds an option for,
  so removing an option does not bring it back.
- Claude accepts an unrelated options patch on a model without Fast while the saved Fast preference
  stays dormant. Turning Fast on for such a model is still refused, and the runtime setting is the
  preference filtered by model support.
2026-11-01 09:12:37 +01:00
Mohamed Boudra ca7a9c14de fix(server): follow ACP selections changed by writes, keep removed ACP options absent, classify parents on effective options
- ACP reports a select the provider changed in a mode, model or thinking write response as
  options_changed, the same as it does for config_option_update.
- ACP applyOptions leaves the session holding exactly the options it was given, so a removed
  option stays absent and a later provider selection is not taken as a change to a stored one.
- Unattended-parent classification reads the parent's effective options, so auto-accept supplied by
  provider configuration counts.
2026-11-01 09:03:26 +01:00
Mohamed Boudra 571ae26050 fix(server): commit option updates only after the provider accepts them, and follow ACP selections
- setAgentOptions keeps the requested options as a candidate outside the agent's record. Provider
  reports (options_changed) made while applying are folded into the candidate, and the record is
  published and persisted once, after acceptance. A rejection leaves the record, snapshots and
  stored copy as they were.
- ACP reports a select the provider changed on its own (config_option_update, or a write that
  kept another value) as options_changed when the agent holds an explicit option for it, so the
  stored option follows the provider.
2026-11-01 08:53:13 +01:00
Mohamed Boudra fd4077e24d fix(server): serialize option patches, keep failed updates from changing state, report stored options
- setAgentOptions runs in the agent's lifecycle lane, so overlapping patches to different keys
  no longer overwrite each other. Options the provider reports while applying land on top of the
  requested ones; a rejected update restores the previous record.
- ACP applies auto_accept after the select writes, so a rejected bundle leaves permission
  handling as it was.
- A plugin provider's own setting changes are reported as options_changed, so the stored option
  follows what the plugin reports.
- Stored-only agent updates carry the effective options, so metadata-only updates keep reporting
  saved values and config defaults.
2026-11-01 08:44:02 +01:00
Mohamed Boudra f487316a9d chore(website): regenerate the config schema for profile options 2026-11-01 08:17:11 +01:00
Mohamed Boudra 1ee149c85b fix(server): show Fast for a stored Codex fast_mode: true without rewriting the record
Reverts the storage migration from a987b4a43. The Codex speed control now takes its default from
the session's current options with the same rule the runtime uses (an explicit service_tier wins;
fast_mode: true applies only while service_tier is absent). This holds on create, resume, live
apply and for config.json defaults, and stored options stay as set.
2026-11-01 07:53:29 +01:00
Mohamed Boudra 72d488f17a fix(server): read a pre-v0.10 Codex fast_mode record as service_tier "fast" and store it that way
A record holding `fast_mode: true` without `service_tier` ran Fast but the speed control showed
Normal, and picking Normal then wrote "default". The Codex session now normalizes the old boolean
at construction and reports the new shape through its existing options_changed event.
2026-11-01 07:53:29 +01:00
Mohamed Boudra 8e83d9346a fix(server): keep snapshot options as configured, restore OMP baseline, cover options through public interfaces
- Codex: drop displayOptions; a select choice may carry aliases so a saved Fast preference selects the model's Fast tier from options + controls alone.
- OMP: removing fast_mode restores the runtime's starting setting.
- ACP/Cursor removal tests run through real session initialization.
- agent.config.apply of a non-control option reaches the provider and shows in the next snapshot (daemon e2e).
- Daemon config store persists legacy-featureValues profiles as options on the next profile save.
2026-11-01 07:53:29 +01:00
Mohamed Boudra 6c22bc72df fix(server): address Phase 1 audit findings on option controls
- Removing auto_accept (OpenCode, ACP) turns live approval off; ACP selects return to their session default.
- agent.create.before hook featureValues edits override options the create already set.
- Claude commits fast_mode only after the runtime accepts it.
- Codex snapshots show a saved Fast preference as the tier the model resolves it to (AgentSession.displayOptions).
- Profiles are folded into options in the daemon config store; the config API still answers with featureValues.
- Snapshot options and provider.controls.list carry all effective options.
- Test: Claude fast_mode survives a restart through stored providerOptions.
2026-11-01 07:53:29 +01:00
Mohamed Boudra bc00edf904 feat(server): fold featureValues into providerOptions and describe composer controls
Providers emit option controls over top-level providerOptions keys; the daemon stores one
options record per agent, merges config.json defaults as effective values, and applies patches
live via AgentSession.applyOptions. Legacy featureValues (wire, stored records, profiles,
schedules, hooks, MCP) fold into providerOptions; snapshots still carry features built from
controls. Snapshot options expose only keys that have a control.
2026-11-01 07:53:29 +01:00
Mohamed Boudra afce426a43 feat(protocol): option controls, options patch, and provider.controls.list
Add AgentOptionControl descriptors, snapshot options/controls, an options patch on
agent.config.apply, provider.controls.list RPC, and server_info.features.providerOptionControls.
featureValues stays accepted on the wire (COMPAT).
2026-11-01 07:53:28 +01:00
89 changed files with 5145 additions and 2422 deletions
+26
View File
@@ -135,6 +135,32 @@ 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: |
+1 -4
View File
@@ -74,10 +74,7 @@ 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. The registry retries an older destination operations; requesting this feature gives update guidance.
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
+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 \
npm exec --offline -- electron-builder \ ../../node_modules/.bin/electron-builder \
--config electron-builder.yml \ --config electron-builder.yml \
--dir \ --dir \
--mac \ --mac \
+1 -1
View File
@@ -1 +1 @@
sha256-KN/AVZV/o0TleLLpmggSrdJqjFDJ/NJYVHiC54xb4jQ= sha256-CeT2PtYIAFwvBC9LIku8VkTl0pvKxYzSCn0QW2ManfU=
+1295 -1018
View File
File diff suppressed because it is too large Load Diff
@@ -717,13 +717,11 @@ 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,7 +647,6 @@ 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);
@@ -656,7 +655,6 @@ 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() === "?");
} }
+3 -4
View File
@@ -204,16 +204,15 @@ export function createHostTransportFactory(
} }
export function createHostClient( export function createHostClient(
connection: string, connection: string,
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions, config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
budget?: HostTransportBudget, budget?: HostTransportBudget,
): DaemonClient { ): DaemonClient {
const target = parseHostTarget(connection); const target = parseHostTarget(connection);
const { webSocketAgent, transportFactory, ...clientConfig } = config; const { webSocketAgent, ...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: transportFactory: createHostTransportFactory(connection, budget, { webSocketAgent }),
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.18.0", "electron-builder": "26.8.1",
"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,7 +21,10 @@ 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(),
}) })
+45
View File
@@ -154,6 +154,47 @@ export interface AgentFeatureSelect {
export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect; export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect;
export interface AgentOptionChoice {
value: string;
label: string;
description?: string;
/**
* Other stored option values that select this choice. A saved value the provider has since
* renamed still reads back as this choice.
*/
aliases?: string[];
}
interface AgentOptionControlBase {
/** Top-level provider option key this control reads and writes. */
key: string;
label: string;
description?: string;
tooltip?: string;
icon?: string;
}
export interface AgentOptionToggleControl extends AgentOptionControlBase {
type: "toggle";
/** What an unset option stands for; omitted means off. */
defaultValue?: boolean;
}
export interface AgentOptionSelectControl extends AgentOptionControlBase {
type: "select";
/** Desktop toolbar presentation; omitted means labeled. Mobile selectors stay labeled. */
desktopTrigger?: "icon" | "label";
/** The choice an unset option stands for. */
defaultValue?: string;
choices: AgentOptionChoice[];
}
/**
* 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 = AgentOptionToggleControl | AgentOptionSelectControl;
export interface AgentCapabilityFlags { export interface AgentCapabilityFlags {
[capability: string]: boolean | undefined; [capability: string]: boolean | undefined;
supportsStreaming: boolean; supportsStreaming: boolean;
@@ -523,6 +564,10 @@ 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;
+73
View File
@@ -361,6 +361,40 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
AgentFeatureSelectSchema, AgentFeatureSelectSchema,
]); ]);
export const AgentOptionChoiceSchema = z.object({
value: z.string(),
label: z.string(),
description: z.string().optional(),
aliases: z.array(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(),
@@ -490,6 +524,7 @@ 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(),
@@ -866,7 +901,11 @@ 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(),
@@ -2055,7 +2094,10 @@ 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>;
@@ -2971,7 +3013,9 @@ 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({
@@ -2980,6 +3024,12 @@ 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(),
@@ -3157,6 +3207,7 @@ 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(),
@@ -3357,6 +3408,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsRequestMessageSchema, ListProviderModelsRequestMessageSchema,
ListProviderModesRequestMessageSchema, ListProviderModesRequestMessageSchema,
ListProviderFeaturesRequestMessageSchema, ListProviderFeaturesRequestMessageSchema,
ProviderControlsListRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema, ListAvailableProvidersRequestMessageSchema,
GetProvidersSnapshotRequestMessageSchema, GetProvidersSnapshotRequestMessageSchema,
RefreshProvidersSnapshotRequestMessageSchema, RefreshProvidersSnapshotRequestMessageSchema,
@@ -3836,6 +3888,7 @@ 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(),
@@ -6303,6 +6356,19 @@ 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({
@@ -7195,6 +7261,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsResponseMessageSchema, ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema, ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema, ListProviderFeaturesResponseMessageSchema,
ProviderControlsListResponseMessageSchema,
ListAvailableProvidersResponseSchema, ListAvailableProvidersResponseSchema,
GetProvidersSnapshotResponseMessageSchema, GetProvidersSnapshotResponseMessageSchema,
ProvidersSnapshotUpdateMessageSchema, ProvidersSnapshotUpdateMessageSchema,
@@ -7449,6 +7516,12 @@ 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
>; >;
+1
View File
@@ -34,6 +34,7 @@ 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(),
@@ -1,3 +1,4 @@
import { mergeProviderOptions } from "./provider-options.js";
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js"; import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
import { import {
prepareAgentMessage, prepareAgentMessage,
@@ -6,7 +7,7 @@ import {
} from "./agent-messages/index.js"; } from "./agent-messages/index.js";
import { expect, test, vi, onTestFinished } from "vitest"; import { expect, test, vi, onTestFinished } from "vitest";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -23,6 +24,7 @@ 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";
@@ -38,7 +40,7 @@ import type {
import type { import type {
AgentClient, AgentClient,
AgentCreateSessionOptions, AgentCreateSessionOptions,
AgentFeature, AgentOptionControl,
AgentLaunchContext, AgentLaunchContext,
AgentPromptInput, AgentPromptInput,
AgentProvider, AgentProvider,
@@ -182,13 +184,8 @@ class RecordingTimelineStore implements AgentTimelineStore {
} }
} }
function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature { function createControl(args: { key: string; label: string }): AgentOptionControl {
return { return { type: "toggle", key: args.key, label: args.label };
type: "toggle",
id: args.id,
label: args.label,
value: args.value,
};
} }
function expectArchivedAgentRecord( function expectArchivedAgentRecord(
@@ -2332,7 +2329,7 @@ test("listDraftCommands uses explicit model config without default model fetchin
]); ]);
}); });
test("listDraftFeatures does not start a fallback session without a model", async () => { test("listDraftControls 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);
@@ -2365,18 +2362,21 @@ test("listDraftFeatures does not start a fallback session without a model", asyn
logger, logger,
}); });
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([]); await expect(manager.listDraftControls({ 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("listDraftFeatures uses client feature listing without a model", async () => { test("listDraftControls 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 draftFeature = createFeature({ id: "auto_accept", label: "Auto Accept", value: false }); const draftControl = createControl({ key: "auto_accept", label: "Auto Accept" });
class DraftFeatureClient extends TestAgentClient { class DraftFeatureClient extends TestAgentClient {
fetchCatalogCalls = 0; fetchCatalogCalls = 0;
createSessionCalls = 0; createSessionCalls = 0;
@@ -2398,9 +2398,9 @@ test("listDraftFeatures uses client feature listing without a model", async () =
return await super.createSession(config); return await super.createSession(config);
} }
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> { async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
this.featureConfigs.push(config); this.featureConfigs.push(config);
return [draftFeature]; return [draftControl];
} }
} }
const client = new DraftFeatureClient(); const client = new DraftFeatureClient();
@@ -2412,9 +2412,13 @@ test("listDraftFeatures uses client feature listing without a model", async () =
logger, logger,
}); });
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([ await expect(
draftFeature, manager.listDraftControls({
]); 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);
@@ -2423,15 +2427,28 @@ test("listDraftFeatures 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("listDraftFeatures uses explicit model config without default model fetching", async () => { test("listDraftControls 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 draftFeature = createFeature({ id: "fast_mode", label: "Fast mode", value: false }); const draftControl = createControl({ key: "fast_mode", label: "Fast mode" });
class DraftFeatureClient extends TestAgentClient { class DraftFeatureClient extends TestAgentClient {
fetchCatalogCalls = 0; fetchCatalogCalls = 0;
createSessionCalls = 0; createSessionCalls = 0;
@@ -2447,9 +2464,9 @@ test("listDraftFeatures uses explicit model config without default model fetchin
return await super.createSession(config); return await super.createSession(config);
} }
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> { async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
this.featureConfigs.push(config); this.featureConfigs.push(config);
return [draftFeature]; return [draftControl];
} }
} }
const client = new DraftFeatureClient(); const client = new DraftFeatureClient();
@@ -2461,13 +2478,13 @@ test("listDraftFeatures uses explicit model config without default model fetchin
logger, logger,
}); });
const features = await manager.listDraftFeatures({ const draft = await manager.listDraftControls({
provider: "codex", provider: "codex",
cwd: workdir, cwd: workdir,
model: "gpt-5.4", model: "gpt-5.4",
}); });
expect(features).toEqual([draftFeature]); expect(draft).toEqual({ controls: [draftControl], options: {} });
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([
@@ -9984,16 +10001,13 @@ test("respondToPermission updates currentModeId after plan approval", async () =
expect(client.resumedModeId).toBe("acceptEdits"); expect(client.resumedModeId).toBe("acceptEdits");
}); });
test("respondToPermission refreshes features and runtime info after provider-managed plan approval", async () => { test("respondToPermission applies provider option changes and refreshes 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 featureState: AgentFeature[] = [ private planMode = true;
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 = [
{ {
@@ -10005,8 +10019,11 @@ test("respondToPermission refreshes features and runtime info after provider-man
}, },
]; ];
get features(): AgentFeature[] { get controls(): AgentOptionControl[] {
return this.featureState; return [
createControl({ key: "fast_mode", label: "Fast" }),
createControl({ key: "plan_mode", label: "Plan" }),
];
} }
override async getRuntimeInfo() { override async getRuntimeInfo() {
@@ -10015,7 +10032,7 @@ test("respondToPermission refreshes features and runtime info after provider-man
sessionId: this.id, sessionId: this.id,
model: "gpt-5.4", model: "gpt-5.4",
modeId: this.modeId, modeId: this.modeId,
extra: { collaborationMode: this.features[1]?.value ? "Plan" : "Code" }, extra: { collaborationMode: this.planMode ? "Plan" : "Code" },
}; };
} }
@@ -10030,10 +10047,12 @@ test("respondToPermission refreshes features and runtime info after provider-man
override async respondToPermission(): Promise<void> { override async respondToPermission(): Promise<void> {
this.modeId = "auto"; this.modeId = "auto";
this.pending = []; this.pending = [];
this.featureState = [ this.planMode = false;
createFeature({ id: "fast_mode", label: "Fast", value: true }), this.pushEvent({
createFeature({ id: "plan_mode", label: "Plan", value: false }), type: "options_changed",
]; provider: this.provider,
patch: { plan_mode: false },
});
} }
} }
@@ -10056,6 +10075,7 @@ test("respondToPermission refreshes features and runtime info after provider-man
{ {
provider: "codex", provider: "codex",
cwd: workdir, cwd: workdir,
providerOptions: { fast_mode: true, plan_mode: true },
}, },
undefined, undefined,
{ workspaceId: undefined }, { workspaceId: undefined },
@@ -10080,20 +10100,14 @@ test("respondToPermission refreshes features and runtime info after provider-man
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?.features).toEqual([ expect(updated?.effectiveOptions).toEqual({ fast_mode: true, plan_mode: false });
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?.features).toEqual([ expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true, plan_mode: false });
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 () => {
@@ -10102,9 +10116,6 @@ 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 = [
{ {
@@ -10116,8 +10127,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
}, },
]; ];
get features(): AgentFeature[] { get controls(): AgentOptionControl[] {
return this.featureState; return [createControl({ key: "fast_mode", label: "Fast" })];
} }
override async getRuntimeInfo() { override async getRuntimeInfo() {
@@ -10138,6 +10149,11 @@ 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: { fast_mode: false },
});
this.pushEvent({ this.pushEvent({
type: "permission_resolved", type: "permission_resolved",
provider: this.provider, provider: this.provider,
@@ -10145,7 +10161,6 @@ 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 = [];
} }
} }
@@ -10169,6 +10184,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
{ {
provider: "codex", provider: "codex",
cwd: workdir, cwd: workdir,
providerOptions: { fast_mode: true },
}, },
undefined, undefined,
{ workspaceId: undefined }, { workspaceId: undefined },
@@ -10180,9 +10196,8 @@ 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(fastMode?.type === "toggle" ? fastMode.value : null)}`, `state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.fast_mode)}`,
); );
return; return;
} }
@@ -12695,3 +12710,605 @@ 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;
async applyOptions(options: ProviderOptions): Promise<void> {
if (this.rejectWith) throw this.rejectWith;
this.applied.push(options);
}
}
class OptionsClient extends TestAgentClient {
session: OptionsSession | null = null;
// 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);
return this.session;
}
}
async function createOptionsAgent(providerOptions?: ProviderOptions) {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new OptionsClient();
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({ fast_mode: true });
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ fast_mode: true });
expect(agent?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true });
expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", fast_mode: true });
});
test("setAgentOptions on a running agent replaces top-level keys and applies the effective options", async () => {
const { manager, client, storage, agentId } = await createOptionsAgent({ fast_mode: true });
await manager.setAgentOptions(agentId, { service_tier: "fast" });
expect(client.session?.applied).toEqual([{ service_tier: "fast", fast_mode: true }]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toEqual({ fast_mode: true, service_tier: "fast" });
expect(agent?.effectiveOptions).toEqual({ service_tier: "fast", fast_mode: true });
await manager.flush();
expect((await storage.get(agentId))?.config?.providerOptions).toEqual({
fast_mode: true,
service_tier: "fast",
});
});
test("setAgentOptions removes a key with null so the config.json default applies again", async () => {
const { manager, client, agentId } = await createOptionsAgent({ service_tier: "fast" });
await manager.setAgentOptions(agentId, { service_tier: null });
// The provider is handed the agent's own options, so the removed key is absent and the
// running provider keeps its setting; the default applies when the session is next opened.
expect(client.session?.applied).toEqual([{}]);
const agent = manager.getAgent(agentId);
expect(agent?.config.providerOptions).toBeUndefined();
expect(agent?.effectiveOptions).toEqual({ service_tier: "default" });
// An unrelated patch afterwards still leaves the removed key to the running provider.
await manager.setAgentOptions(agentId, { plan_mode: true });
expect(client.session?.applied).toEqual([{}, { plan_mode: true }]);
});
test("the snapshot reports the configured options and resolves aliases through controls", async () => {
class AliasSession extends OptionsSession {
get controls(): AgentOptionControl[] {
return [
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
],
},
];
}
}
class AliasClient extends OptionsClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new AliasSession(config);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const manager = new AgentManager({
clients: { codex: new AliasClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { service_tier: "fast" } },
undefined,
{ workspaceId: undefined },
);
const payload = toAgentPayload(manager.getAgent(created.id)!);
expect(payload.options).toEqual({ service_tier: "fast" });
expect(payload.features).toEqual([
expect.objectContaining({ id: "service_tier", value: "priority" }),
]);
});
test("after a removal the snapshot shows the live setting, not the configured default", async () => {
class LiveSession extends OptionsSession {
private tier = "priority";
override async applyOptions(options: ProviderOptions): Promise<void> {
await super.applyOptions(options);
if (typeof options.service_tier === "string") this.tier = options.service_tier;
}
get controls(): AgentOptionControl[] {
return [
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: this.tier,
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast" },
],
},
];
}
}
class LiveClient extends OptionsClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new LiveSession(config);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const manager = new AgentManager({
clients: { codex: new LiveClient() },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { service_tier: "priority" } },
undefined,
{ workspaceId: undefined },
);
await manager.setAgentOptions(created.id, { service_tier: null });
const agent = manager.getAgent(created.id)!;
expect(agent.config.providerOptions).toBeUndefined();
expect(toAgentPayload(agent).options).toEqual({ service_tier: "priority" });
expect(toAgentPayload(agent).features).toEqual([
expect.objectContaining({ id: "service_tier", value: "priority" }),
]);
});
test("a supplied option is resolved against the configured defaults before it reaches the provider", async () => {
class DefaultsClient extends OptionsClient {
override effectiveOptions(own: ProviderOptions | undefined): ProviderOptions {
return (
mergeProviderOptions({ sandbox: { enabled: true, failIfUnavailable: true } }, own) ?? {}
);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-"));
const client = new DefaultsClient();
const manager = new AgentManager({
clients: { codex: client },
registry: new AgentStorage(join(workdir, "agents"), logger),
logger,
});
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
await manager.setAgentOptions(created.id, { sandbox: { network: { allowed: ["a.example"] } } });
expect(client.session?.applied).toEqual([
{
sandbox: { enabled: true, failIfUnavailable: true, network: { allowed: ["a.example"] } },
},
]);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
sandbox: { network: { allowed: ["a.example"] } },
});
});
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("a provider that rejects an option leaves the stored options unchanged", async () => {
const { manager, client, agentId } = await createOptionsAgent({ fast_mode: false });
client.session!.rejectWith = new Error("fast mode unavailable");
await expect(manager.setAgentOptions(agentId, { fast_mode: true })).rejects.toThrow(
"fast mode unavailable",
);
expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({ fast_mode: 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: { fast_mode: true } },
}),
},
});
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ fast_mode: 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, fast_mode: true },
},
}
: request,
},
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { fast_mode: false, plan_mode: true } },
undefined,
{ workspaceId: undefined },
);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
fast_mode: 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: { fast_mode: false, plan_mode: true } },
undefined,
{ workspaceId: undefined },
);
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
fast_mode: false,
plan_mode: false,
});
});
async function optionsAfterCreateHook(
edit: (config: AgentSessionConfig) => AgentSessionConfig,
): Promise<ProviderOptions | undefined> {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new OptionsClient() },
registry: storage,
logger,
pluginLifecycle: {
emit: () => {},
before: async (name, request) =>
name === "agent.create.before" ? { ...request, config: edit(request.config) } : request,
},
});
const agent = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
providerOptions: { fast_mode: true, plan_mode: true },
},
undefined,
{ workspaceId: undefined },
);
return manager.getAgent(agent.id)?.config.providerOptions;
}
function withoutFastMode(values: Record<string, unknown> | null | undefined) {
const { fast_mode: _removed, ...rest } = values ?? {};
return rest;
}
test("agent.create.before hook clearing providerOptions leaves the agent without options", async () => {
const options = await optionsAfterCreateHook((config) => ({ ...config, providerOptions: {} }));
expect(options).toEqual({});
});
test("agent.create.before hook deleting one providerOptions key keeps the others", async () => {
const options = await optionsAfterCreateHook((config) => ({
...config,
providerOptions: withoutFastMode(config.providerOptions),
}));
expect(options).toEqual({ plan_mode: true });
});
test("agent.create.before hook clearing featureValues leaves the agent without options", async () => {
const options = await optionsAfterCreateHook((config) => ({ ...config, featureValues: {} }));
expect(options).toEqual({});
});
test("agent.create.before hook omitting featureValues leaves the agent without options", async () => {
const options = await optionsAfterCreateHook((config) => {
const { featureValues: _omitted, ...rest } = config;
return rest;
});
expect(options).toEqual({});
});
test("agent.create.before hook returning undefined featureValues leaves the agent without options", async () => {
const options = await optionsAfterCreateHook((config) => ({
...config,
featureValues: undefined,
}));
expect(options).toEqual({});
});
test("agent.create.before hook deleting one featureValues key keeps the others", async () => {
const options = await optionsAfterCreateHook((config) => ({
...config,
featureValues: withoutFastMode(config.featureValues),
}));
expect(options).toEqual({ plan_mode: true });
});
test("overlapping patches to different keys both reach the provider and the stored record", async () => {
let release: () => void = () => {};
const firstApplyHeld = new Promise<void>((resolve) => {
release = resolve;
});
class HeldSession extends OptionsSession {
override async applyOptions(options: ProviderOptions): Promise<void> {
const first = this.applied.length === 0;
this.applied.push(options);
if (first) await firstApplyHeld;
}
}
class HeldClient extends OptionsClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
const session = new HeldSession(config);
this.session = session;
return session;
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-overlap-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const client = new HeldClient();
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: undefined,
});
const speed = manager.setAgentOptions(created.id, { service_tier: "fast" });
const plan = manager.setAgentOptions(created.id, { plan_mode: true });
release();
await Promise.all([speed, plan]);
expect(client.session?.applied.at(-1)).toEqual({ service_tier: "fast", plan_mode: true });
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({
service_tier: "fast",
plan_mode: true,
});
await manager.flush();
expect((await storage.get(created.id))?.config?.providerOptions).toEqual({
service_tier: "fast",
plan_mode: true,
});
});
test("a rejected update that saw provider events leaves the record, snapshot and stored copy as they were", async () => {
class NoisySession extends OptionsSession {
override async applyOptions(): Promise<void> {
// The provider reports a change and a usage update, then refuses the request.
this.pushEvent({
type: "options_changed",
provider: this.provider,
patch: { voice: "soft" },
});
this.pushEvent({
type: "usage_updated",
provider: this.provider,
usage: { inputTokens: 1 },
});
throw new Error("refused");
}
}
class NoisyClient extends OptionsClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
this.session = new NoisySession(config);
return this.session;
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-rejected-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new NoisyClient() },
registry: storage,
logger,
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { voice: "direct" } },
undefined,
{ workspaceId: undefined },
);
const published: Array<ProviderOptions | undefined> = [];
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === created.id) {
published.push(event.agent.effectiveOptions);
}
},
{ replayState: false },
);
await expect(manager.setAgentOptions(created.id, { voice: "soft" })).rejects.toThrow("refused");
await manager.flush();
expect(manager.getAgent(created.id)?.config.providerOptions).toEqual({ voice: "direct" });
expect(toAgentPayload(manager.getAgent(created.id)!).options).toMatchObject({
voice: "direct",
});
expect(published.every((options) => options?.voice === "direct")).toBe(true);
expect((await storage.get(created.id))?.config?.providerOptions).toEqual({ voice: "direct" });
});
test("a metadata-only update of a stored agent reports its saved options and config defaults", async () => {
class PlanSession extends OptionsSession {
get controls(): AgentOptionControl[] {
return [createControl({ key: "plan_mode", label: "Plan" })];
}
}
class PlanClient extends OptionsClient {
override async createSession(config: AgentSessionConfig): Promise<AgentSession> {
return new PlanSession(config);
}
}
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-stored-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({
clients: { codex: new PlanClient() },
registry: storage,
logger,
});
const created = await manager.createAgent(
{ provider: "codex", cwd: workdir, providerOptions: { plan_mode: true } },
undefined,
{ workspaceId: "workspace-1" },
);
await manager.closeAgent(created.id);
const updates: ManagedAgent[] = [];
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === created.id)
updates.push(event.agent);
},
{ replayState: false },
);
await manager.markAgentUnread(created.id);
expect(updates).toHaveLength(1);
const payload = toAgentPayload(updates[0]!);
expect(payload.options).toEqual({ service_tier: "default", plan_mode: true });
expect(payload.features?.find((feature) => feature.id === "plan_mode")).toMatchObject({
value: true,
});
});
test("a metadata-only update of an agent stored with legacy features reports their values", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-legacy-features-"));
const agentsDir = join(workdir, "agents");
const first = new AgentManager({
clients: { codex: new OptionsClient() },
registry: new AgentStorage(agentsDir, logger),
logger,
});
const created = await first.createAgent({ provider: "codex", cwd: workdir }, undefined, {
workspaceId: "workspace-1",
});
await first.closeAgent(created.id);
// Rewrite the record as an older daemon stored it: features with values, no controls.
const dir = readdirSync(agentsDir, { withFileTypes: true }).find(
(entry) =>
entry.isDirectory() && existsSync(join(agentsDir, entry.name, `${created.id}.json`)),
)!;
const file = join(agentsDir, dir.name, `${created.id}.json`);
const stored = JSON.parse(readFileSync(file, "utf8"));
delete stored.controls;
stored.features = [
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
{
type: "select",
id: "voice",
label: "Voice",
value: "soft",
options: [
{ id: "direct", label: "Direct" },
{ id: "soft", label: "Soft" },
],
},
];
writeFileSync(file, JSON.stringify(stored));
const manager = new AgentManager({
clients: { codex: new OptionsClient() },
registry: new AgentStorage(agentsDir, logger),
logger,
});
const updates: ManagedAgent[] = [];
manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === created.id)
updates.push(event.agent);
},
{ replayState: false },
);
await manager.markAgentUnread(created.id);
const payload = toAgentPayload(updates.at(-1)!);
expect(payload.features?.map((feature) => [feature.id, feature.value])).toEqual([
["fast_mode", true],
["voice", "soft"],
]);
});
});
+113 -29
View File
@@ -33,7 +33,7 @@ import {
type AgentCreateSessionOptions, type AgentCreateSessionOptions,
type AgentResumePurpose, type AgentResumePurpose,
type AgentResumeSessionOptions, type AgentResumeSessionOptions,
type AgentFeature, type AgentOptionControl,
type AgentLaunchContext, type AgentLaunchContext,
type AgentSlashCommand, type AgentSlashCommand,
type AgentMode, type AgentMode,
@@ -59,7 +59,14 @@ 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,
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 {
@@ -210,9 +217,6 @@ 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;
} }
@@ -428,7 +432,9 @@ interface ManagedAgentBase {
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
availableModes: AgentMode[]; availableModes: AgentMode[];
features?: AgentFeature[]; controls?: AgentOptionControl[];
/** `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<
@@ -775,6 +781,8 @@ export class AgentManager {
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>(); private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
private readonly collectingRuntimes = new Set<string>(); private readonly collectingRuntimes = new Set<string>();
private readonly lifecycleMutationTails = new Map<string, Promise<void>>(); private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
/** Options a `setAgentOptions` call is applying, until the provider accepts or rejects them. */
private readonly pendingOptionCandidates = new Map<string, ProviderOptions>();
private readonly agentStreamCoalescer: AgentStreamCoalescer; private readonly agentStreamCoalescer: AgentStreamCoalescer;
private mcpBaseUrl: string | null; private mcpBaseUrl: string | null;
private readonly mcpAuthToken: string | null; private readonly mcpAuthToken: string | null;
@@ -1243,11 +1251,14 @@ export class AgentManager {
} }
} }
async listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> { async listDraftControls(
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);
if (!normalizedConfig.model && !client.listFeatures) { const options = this.effectiveOptionsFor(normalizedConfig.provider, config.providerOptions);
return []; if (!normalizedConfig.model && !client.listControls) {
return { controls: [], options };
} }
const available = await client.isAvailable(); const available = await client.isAvailable();
if (!available) { if (!available) {
@@ -1256,13 +1267,13 @@ export class AgentManager {
); );
} }
if (client.listFeatures) { if (client.listControls) {
return await client.listFeatures(normalizedConfig); return { controls: await client.listControls(normalizedConfig), options };
} }
const session = await client.createSession(normalizedConfig); const session = await client.createSession(normalizedConfig);
try { try {
return session.features ?? []; return { controls: session.controls ?? [], options };
} finally { } finally {
try { try {
await session.close(); await session.close();
@@ -1361,11 +1372,14 @@ export class AgentManager {
this.assertAcceptingAgentRegistrations(); this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent"); const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
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, config: sent,
env: options.env, env: options.env,
}); });
config = { ...request.config, internal: config.internal }; // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A plugin written
// against the previous SDK edits `config.featureValues`.
config = { ...foldHookFeatureValues(request.config, sent), internal: config.internal };
options = { ...options, env: request.env }; options = { ...options, env: request.env };
} }
await this.deleteAgentState(resolvedAgentId); await this.deleteAgentState(resolvedAgentId);
@@ -2161,7 +2175,11 @@ export class AgentManager {
createdAt: new Date(record.createdAt), createdAt: new Date(record.createdAt),
updatedAt, updatedAt,
availableModes: [], availableModes: [],
features: record.features, controls: record.controls,
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(),
@@ -2254,18 +2272,53 @@ export class AgentManager {
return notice; return notice;
} }
async setAgentFeature(agentId: string, featureId: string, value: unknown): Promise<void> { /**
const agent = this.requireAgent(agentId); * Patches the agent's own options and hands the provider the result. The provider applies what
* it can live and reads the rest, with the `config.json` defaults, at next open; a provider that rejects a
* value leaves the stored options unchanged.
*/
async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void> {
// One patch at a time per agent: each derives its options from the committed record, so
// overlapping patches to different keys would otherwise overwrite one another.
await this.runLifecycleMutation(agentId, async () => {
const agent = this.requireAgent(agentId);
let candidate = applyProviderOptionsPatch(agent.config.providerOptions, patch);
if (!agent.session.setFeature) { if (agent.session.applyOptions) {
throw new Error("Agent session does not support setting features"); // The candidate stays out of the agent's record until the provider accepts it. Values
} // the provider reports meanwhile (`options_changed`) are folded into the candidate, so
// a rejection leaves the record, its snapshots and its stored copy as they were.
this.pendingOptionCandidates.set(agentId, candidate);
try {
await agent.session.applyOptions(this.liveOptionsFor(agent.provider, candidate));
} finally {
// Events the provider queued before answering must reach the candidate, not the record.
await this.drainSessionEvents(agentId);
candidate = this.pendingOptionCandidates.get(agentId) ?? candidate;
this.pendingOptionCandidates.delete(agentId);
}
}
agent.config.providerOptions = Object.keys(candidate).length > 0 ? candidate : undefined;
this.touchUpdatedAt(agent);
this.emitState(agent);
});
}
await agent.session.setFeature(featureId, value); /**
await this.drainSessionEvents(agentId); * The options a running provider is handed: the keys the agent holds, each resolved against
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value }; * the `config.json` defaults. A removed key is absent, so the provider keeps its setting;
this.touchUpdatedAt(agent); * defaults for absent keys apply when the session is next opened.
this.emitState(agent); */
private liveOptionsFor(provider: AgentProvider, own: ProviderOptions): ProviderOptions {
const effective = this.effectiveOptionsFor(provider, own);
return Object.fromEntries(Object.keys(own).map((key) => [key, effective[key]]));
}
private effectiveOptionsFor(
provider: AgentProvider,
own: ProviderOptions | undefined,
): ProviderOptions {
return this.clients.get(provider)?.effectiveOptions?.(own) ?? own ?? {};
} }
async setTitle(agentId: string, title: string): Promise<void> { async setTitle(agentId: string, title: string): Promise<void> {
@@ -4311,7 +4364,7 @@ export class AgentManager {
agent.pendingPermissions.clear(); agent.pendingPermissions.clear();
} }
this.syncFeaturesFromSession(agent); this.syncControls(agent);
await this.refreshRuntimeInfo(agent, options); await this.refreshRuntimeInfo(agent, options);
} }
@@ -4657,6 +4710,23 @@ export class AgentManager {
); );
} }
private onStreamOptionsChanged(params: {
agent: ActiveManagedAgent;
patch: ProviderOptionsPatch;
flags: StreamEventFlags;
}): void {
const { agent, patch, flags } = params;
flags.shouldDispatchEvent = false;
const candidate = this.pendingOptionCandidates.get(agent.id);
if (candidate) {
this.pendingOptionCandidates.set(agent.id, applyProviderOptionsPatch(candidate, patch));
return;
}
const ownOptions = applyProviderOptionsPatch(agent.config.providerOptions, patch);
agent.config.providerOptions = Object.keys(ownOptions).length > 0 ? ownOptions : undefined;
this.emitState(agent);
}
private dispatchStreamEventByType(params: { private dispatchStreamEventByType(params: {
agent: ActiveManagedAgent; agent: ActiveManagedAgent;
event: AgentStreamEvent; event: AgentStreamEvent;
@@ -4708,6 +4778,9 @@ 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":
@@ -5183,7 +5256,7 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent); this.enqueueBackgroundPersist(agent);
} }
this.syncFeaturesFromSession(agent); this.syncControls(agent);
this.logger.trace( this.logger.trace(
{ {
@@ -5205,10 +5278,21 @@ export class AgentManager {
}); });
} }
private syncFeaturesFromSession(agent: ManagedAgent): void { private syncControls(agent: ManagedAgent): void {
if ("session" in agent && agent.session?.features) { if ("session" in agent && agent.session?.controls) {
agent.features = agent.session.features; agent.controls = agent.session.controls;
} }
const own = agent.config.providerOptions;
const effective = { ...this.effectiveOptionsFor(agent.provider, own) };
// After a removal the running provider keeps its setting, so a control it reports for a key
// the agent no longer holds shows that live value rather than the `config.json` default.
for (const control of agent.controls ?? []) {
const live = control.defaultValue;
if (own?.[control.key] === undefined && control.key in effective && live !== undefined) {
effective[control.key] = live;
}
}
agent.effectiveOptions = effective;
} }
private checkAndSetAttention(agent: ManagedAgent): void { private checkAndSetAttention(agent: ManagedAgent): void {
@@ -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 {
AgentFeature, AgentOptionControl,
ImportableProviderSession, ImportableProviderSession,
AgentPermissionRequest, AgentPermissionRequest,
AgentPersistenceHandle, AgentPersistenceHandle,
@@ -128,16 +128,6 @@ 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({
@@ -474,13 +464,44 @@ describe("toAgentPayload", () => {
}); });
}); });
it("includes features in the snapshot payload", () => { it("projects controls, effective options, and legacy features from the same source", () => {
const features = [createFeature()]; const controls: AgentOptionControl[] = [
const agent = createManagedAgent({ features }); { type: "toggle", key: "fast_mode", label: "Fast mode" },
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "fast", label: "Fast" },
],
},
];
// `config.json` supplies fast_mode; the agent itself sets nothing for service_tier.
// Options without a control are part of the snapshot too.
const agent = createManagedAgent({
controls,
effectiveOptions: { fast_mode: true, sandbox_mode: "workspace-write" },
});
const payload = toAgentPayload(agent); const payload = toAgentPayload(agent);
expect(payload.features).toEqual(features); expect(payload.controls).toEqual(controls);
expect(payload.options).toEqual({ fast_mode: true, sandbox_mode: "workspace-write" });
expect(payload.features).toEqual([
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
{
type: "select",
id: "service_tier",
label: "Speed",
value: "default",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
},
]);
}); });
}); });
@@ -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,
AgentFeature, AgentOptionControl,
AgentMetadata, AgentMetadata,
AgentMode, AgentMode,
AgentPermissionRequest, AgentPermissionRequest,
@@ -18,7 +18,9 @@ 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 "./option-controls.js";
import type { JsonValue } from "../json-utils.js"; import type { JsonValue } from "../json-utils.js";
import { import {
isStoredAgentProviderAvailable, isStoredAgentProviderAvailable,
@@ -97,7 +99,7 @@ export function toStoredAgentRecord(
config: config ?? null, config: config ?? null,
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined, launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
runtimeInfo, runtimeInfo,
features: normalizeFeatures(agent.features), controls: normalizeControls(agent.controls),
persistence, persistence,
lastError: agent.lastError ?? undefined, lastError: agent.lastError ?? undefined,
requiresAttention: agent.attention.requiresAttention, requiresAttention: agent.attention.requiresAttention,
@@ -143,7 +145,12 @@ 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),
features: normalizeFeatures(agent.features), // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
features: featuresFromControls(agent.controls, 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,
@@ -375,12 +382,6 @@ 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)) {
@@ -456,8 +457,15 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
return modes.map((mode) => ({ ...mode })); return modes.map((mode) => ({ ...mode }));
} }
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] { function normalizeControls(
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : []; controls: AgentOptionControl[] | null | undefined,
): AgentOptionControl[] {
return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : [];
}
function sanitizeEffectiveOptions(options: ProviderOptions): ProviderOptions {
const sanitized = sanitizeOptionalJson(options);
return sanitized && isJsonObject(sanitized) ? sanitized : {};
} }
function sanitizeOptionalJson(value: unknown): JsonValue | undefined { function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
@@ -1,7 +1,7 @@
import type { import type {
AgentFeature, AgentOptionControl,
AgentFeatureSelect, AgentOptionSelectControl,
AgentFeatureToggle, AgentOptionToggleControl,
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 {
AgentFeature, AgentOptionControl,
AgentFeatureSelect, AgentOptionSelectControl,
AgentFeatureToggle, AgentOptionToggleControl,
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;
featureValues: Record<string, unknown> | undefined; providerOptions: ProviderOptions | 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;
featureValues: Record<string, unknown> | undefined; providerOptions: ProviderOptions | undefined;
} }
export interface AgentCreateConfigUnattendedInput { export interface AgentCreateConfigUnattendedInput {
modeId: string | null; modeId: string | null;
config: AgentSessionConfig; config: AgentSessionConfig;
features?: AgentFeature[]; controls?: AgentOptionControl[];
availableModes: AgentMode[]; availableModes: AgentMode[];
} }
@@ -429,6 +429,8 @@ export type AgentStreamEvent =
provider: AgentProvider; provider: AgentProvider;
thinkingOptionId: string | null; thinkingOptionId: string | null;
} }
/** The provider changed options on its own; `null` removes the agent's own value. */
| { type: "options_changed"; provider: AgentProvider; patch: Record<string, unknown> }
| { | {
type: "turn_failed"; type: "turn_failed";
provider: AgentProvider; provider: AgentProvider;
@@ -610,7 +612,6 @@ 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;
@@ -679,7 +680,8 @@ export interface AgentSession {
readonly provider: AgentProvider; readonly provider: AgentProvider;
readonly id: string | null; readonly id: string | null;
readonly capabilities: AgentCapabilityFlags; readonly capabilities: AgentCapabilityFlags;
readonly features?: AgentFeature[]; /** Composer controls over top-level option keys. Values come from the agent's effective options. */
readonly controls?: AgentOptionControl[];
/** 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[];
@@ -714,7 +716,12 @@ 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>; /**
* The agent's effective options changed (defaults merged with the agent's own, after the
* patch). Apply what can change live and read the rest at next open. Throw to reject a value;
* the daemon stores the patch only when this resolves.
*/
applyOptions?(options: ProviderOptions): Promise<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>;
@@ -797,7 +804,9 @@ 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[]>;
listFeatures?(config: AgentSessionConfig): Promise<AgentFeature[]>; listControls?(config: AgentSessionConfig): Promise<AgentOptionControl[]>;
/** 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 { mkdtempSync, readdirSync, rmSync } from "node:fs"; import { existsSync, 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,9 +44,6 @@ 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;
} }
@@ -132,6 +129,14 @@ 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;
@@ -195,62 +200,77 @@ describe("AgentStorage", () => {
expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] }); expect(persisted.config?.providerOptions).toEqual({ allowedTools: ["Read"] });
}); });
test("applySnapshot stores and reloads featureValues when present", async () => { test("applySnapshot stores and reloads composer values as providerOptions", async () => {
await storage.applySnapshot( await storage.applySnapshot(
createManagedAgent({ createManagedAgent({
id: "agent-feature-values", id: "agent-provider-options",
config: { config: { providerOptions: { fast_mode: true } },
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-no-feature-values"); const persisted = await reloaded.get("agent-provider-options");
expect(persisted?.config?.featureValues).toBeUndefined(); expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined(); expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true });
}); expect(buildConfigOverrides(persisted!)).toMatchObject({
test("buildConfigOverrides includes featureValues when present in stored config", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-resume-overrides",
config: {
featureValues: {
fast_mode: true,
},
},
}),
);
const record = await storage.get("agent-resume-overrides");
expect(record).not.toBeNull();
expect(buildConfigOverrides(record!)).toMatchObject({
cwd: "/tmp/project", cwd: "/tmp/project",
featureValues: { providerOptions: { fast_mode: true },
fast_mode: true,
},
}); });
}); });
test("a record stored with featureValues loads them in providerOptions", async () => {
await storage.applySnapshot(createManagedAgent({ id: "agent-legacy" }));
const file = findRecordFile(storagePath, "agent-legacy");
const stored = JSON.parse(await fs.readFile(file, "utf8"));
stored.config.providerOptions = { allowedTools: ["Read"], fast_mode: false };
stored.config.featureValues = { fast_mode: true, service_tier: "fast" };
delete stored.controls;
stored.features = [
{ type: "toggle", id: "fast_mode", label: "Fast mode", value: true },
{
type: "select",
id: "service_tier",
label: "Speed",
value: "fast",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "fast", label: "Fast" },
],
},
];
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", defaultValue: true },
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "fast",
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 () => {
const agentId = "agent-created-at"; const agentId = "agent-created-at";
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z"); const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
@@ -4,17 +4,19 @@ 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, AgentStatusSchema } from "../messages.js"; import { AgentFeatureSchema, AgentOptionControlSchema, 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, 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
@@ -42,7 +44,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z
.nullable() .nullable()
.optional(); .optional();
const STORED_AGENT_SCHEMA = z.object({ const RAW_STORED_AGENT_SCHEMA = z.object({
id: z.string(), id: z.string(),
provider: z.string(), provider: z.string(),
cwd: z.string(), cwd: z.string(),
@@ -68,6 +70,8 @@ const 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(),
@@ -84,14 +88,32 @@ export type SerializableAgentConfig = Pick<
| "modeId" | "modeId"
| "model" | "model"
| "thinkingOptionId" | "thinkingOptionId"
| "featureValues"
| "providerOptions" | "providerOptions"
| "toolPolicy" | "toolPolicy"
| "systemPrompt" | "systemPrompt"
| "mcpServers" | "mcpServers"
>; >;
export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>; type RawStoredAgentRecord = z.infer<typeof RAW_STORED_AGENT_SCHEMA>;
type RawStoredAgentConfig = NonNullable<RawStoredAgentRecord["config"]>;
/** Records carry `providerOptions` and `controls` only; legacy fields are folded when read. */
export type StoredAgentRecord = Omit<RawStoredAgentRecord, "config" | "features"> & {
config?: Omit<RawStoredAgentConfig, "featureValues"> | null;
};
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
function foldLegacyRecordFields(record: RawStoredAgentRecord): StoredAgentRecord {
const { features, config, controls, ...rest } = record;
return {
...rest,
...(config ? { config: foldFeatureValues(config) } : { config }),
...((controls ?? features) ? { controls: controls ?? controlsFromFeatures(features) } : {}),
};
}
const STORED_AGENT_SCHEMA = RAW_STORED_AGENT_SCHEMA.transform(foldLegacyRecordFields);
export 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,
}), }),
featureValues: input.featureValues, providerOptions: input.providerOptions,
}; };
} }
@@ -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",
featureValues: { auto_accept: true }, providerOptions: { 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",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}), }),
undefined, undefined,
expect.anything(), expect.anything(),
@@ -22,6 +22,8 @@ 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;
@@ -47,7 +49,8 @@ export interface CreateAgentFromSessionInput {
kind: "session"; kind: "session";
onAgentReady?: (agent: ManagedAgent) => Promise<void>; onAgentReady?: (agent: ManagedAgent) => Promise<void>;
agentId?: string; agentId?: string;
config: AgentSessionConfig; /** Wire configs from older clients can still carry `featureValues`; they are folded on entry. */
config: AgentSessionConfig & LegacyFeatureValues;
workspaceId: string; workspaceId: string;
initialPrompt?: string; initialPrompt?: string;
source?: AgentPromptSource; source?: AgentPromptSource;
@@ -70,7 +73,7 @@ export interface CreateAgentFromMcpInput {
cwd?: string; cwd?: string;
workspaceId?: string; workspaceId?: string;
thinking?: string; thinking?: string;
features?: Record<string, unknown>; providerOptions?: ProviderOptions;
labels?: Record<string, string>; labels?: Record<string, string>;
mode?: string; mode?: string;
unattended?: boolean; unattended?: boolean;
@@ -218,19 +221,19 @@ async function resolveSessionCreateAgent(
input: CreateAgentFromSessionInput, input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> { ): Promise<ResolvedCreateAgent> {
const trimmedPrompt = input.initialPrompt?.trim(); const trimmedPrompt = input.initialPrompt?.trim();
const builtSessionConfig = input.config; const builtSessionConfig = foldFeatureValues(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,
featureValues: builtSessionConfig.featureValues, providerOptions: builtSessionConfig.providerOptions,
parent: null, parent: null,
unattended: false, unattended: false,
}); });
const sessionConfig: AgentSessionConfig = { const sessionConfig: AgentSessionConfig = {
...builtSessionConfig, ...builtSessionConfig,
modeId: resolvedCreateConfig.modeId, modeId: resolvedCreateConfig.modeId,
featureValues: resolvedCreateConfig.featureValues, providerOptions: resolvedCreateConfig.providerOptions,
}; };
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;
@@ -323,7 +326,7 @@ async function resolveMcpCreateAgent(
resolvedCwd: intent.cwd, resolvedCwd: intent.cwd,
trimmedPrompt, trimmedPrompt,
resolvedMode: resolvedCreateConfig.modeId, resolvedMode: resolvedCreateConfig.modeId,
resolvedFeatures: resolvedCreateConfig.featureValues, resolvedProviderOptions: resolvedCreateConfig.providerOptions,
}), }),
createOptions: { createOptions: {
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}), ...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
@@ -361,18 +364,23 @@ async function resolveMcpProviderCreateConfig(params: {
provider: string; provider: string;
resolvedCwd: string; resolvedCwd: string;
parentAgent: ManagedAgent | null; parentAgent: ManagedAgent | null;
}): Promise<{ modeId?: string; featureValues?: Record<string, unknown> }> { }): Promise<{ modeId?: string; providerOptions?: ProviderOptions }> {
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,
featureValues: params.input.features ?? passthroughConfig?.featureValues, providerOptions: mergeMcpProviderOptions(params.input),
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;
@@ -380,14 +388,14 @@ function buildMcpSessionConfig(params: {
resolvedCwd: string; resolvedCwd: string;
trimmedPrompt: string; trimmedPrompt: string;
resolvedMode?: string; resolvedMode?: string;
resolvedFeatures?: Record<string, unknown>; resolvedProviderOptions?: ProviderOptions;
}): 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 featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues; const providerOptions = params.resolvedProviderOptions ?? mergeMcpProviderOptions(params.input);
const config: AgentSessionConfig = { const config: AgentSessionConfig = {
...passthroughConfig, ...passthroughConfig,
provider: params.provider, provider: params.provider,
@@ -400,8 +408,8 @@ function buildMcpSessionConfig(params: {
if (provisionalTitle) { if (provisionalTitle) {
config.title = provisionalTitle; config.title = provisionalTitle;
} }
if (featureValues) { if (providerOptions) {
config.featureValues = featureValues; config.providerOptions = providerOptions;
} }
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),
setAgentFeature: vi.fn().mockResolvedValue(undefined), setAgentOptions: 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"]),
listDraftFeatures: vi.fn(), listDraftControls: 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;
featureValues: Record<string, unknown> | undefined; providerOptions: Record<string, unknown> | undefined;
}; };
return { modeId: opts.requestedMode, featureValues: opts.featureValues }; return { modeId: opts.requestedMode, providerOptions: opts.providerOptions };
}); });
} }
@@ -1805,7 +1805,7 @@ describe("create_agent MCP tool", () => {
lifecycle: "idle", lifecycle: "idle",
currentModeId: null, currentModeId: null,
availableModes: [], availableModes: [],
config: { title: "Feature test", featureValues: { fast_mode: true } }, config: { title: "Feature test", providerOptions: { 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",
featureValues: { fast_mode: true }, providerOptions: { 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", featureValues: { fast_mode: true } }, config: { title: "Child", providerOptions: { 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 { featureValues: Record<string, unknown> | undefined }; const opts = input as { providerOptions: Record<string, unknown> | undefined };
return { modeId: undefined, featureValues: opts.featureValues }; return { modeId: undefined, providerOptions: opts.providerOptions };
}); });
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",
featureValues: { fast_mode: true }, providerOptions: { 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", featureValues: undefined }; return { modeId: "dynamic", providerOptions: 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", featureValues: { auto_accept: true } }, config: { title: "Child", providerOptions: { 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",
featureValues: { auto_accept: true }, providerOptions: { 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", featureValues: { auto_accept: true } }), expect.objectContaining({ modeId: "build", providerOptions: { 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",
featureValues: { resolver_feature: true }, providerOptions: { 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",
featureValues: { resolver_feature: true }, providerOptions: { 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.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true); expect(spies.agentManager.setAgentOptions).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.setAgentFeature).not.toHaveBeenCalled(); expect(spies.agentManager.setAgentOptions).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.setAgentFeature.mockRejectedValue(new Error("unsupported feature")); spies.agentManager.setAgentOptions.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",
featureValues: { auto_accept: true }, providerOptions: { 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",
featureValues: { auto_accept: true }, providerOptions: { 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",
featureValues: { fast_mode: true }, options: { 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,7 +5882,21 @@ describe("agent profile listing MCP tool", () => {
const response = await tool.handler({}); const response = await tool.handler({});
expect(response.structuredContent).toEqual({ profiles }); // The config store holds profiles with `options` only; the tool reports them as stored.
expect(response.structuredContent).toEqual({
profiles: [
{
id: "ui-profile",
name: "UI work",
provider: "claude",
model: "claude-test-model",
modeId: "bypassPermissions",
thinkingOptionId: "high",
options: { fast_mode: true },
notes: "Use for UI work: components, layout, design tokens. Not for backend.",
},
],
});
}); });
it("returns an empty array when no profiles are configured", async () => { it("returns an empty array when no profiles are configured", async () => {
@@ -5934,14 +5948,10 @@ 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.listDraftFeatures.mockResolvedValue([ spies.agentManager.listDraftControls.mockResolvedValue({
{ controls: [{ type: "toggle", key: "fast_mode", label: "Fast mode" }],
type: "toggle", options: { fast_mode: true },
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({
@@ -5974,13 +5984,13 @@ describe("provider MCP tools", () => {
const response = await tool.handler(input); const response = await tool.handler(input);
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({ expect(spies.agentManager.listDraftControls).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",
featureValues: { fast_mode: true }, providerOptions: { fast_mode: true },
}); });
expect(response.structuredContent).toEqual({ expect(response.structuredContent).toEqual({
provider: "codex", provider: "codex",
@@ -5995,7 +6005,7 @@ describe("provider MCP tools", () => {
type: "toggle", type: "toggle",
id: "fast_mode", id: "fast_mode",
label: "Fast mode", label: "Fast mode",
value: false, value: true,
}, },
], ],
}); });
@@ -6211,14 +6221,8 @@ describe("agent snapshot MCP serialization", () => {
description: "Default coding mode", description: "Default coding mode",
}, },
], ],
features: [ controls: [{ type: "toggle", key: "web-search", label: "Web search" }],
{ 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",
@@ -0,0 +1,241 @@
import { isDeepStrictEqual } from "node:util";
import type {
AgentFeature,
AgentOptionControl,
AgentOptionSelectControl,
ProviderOptions,
} from "@getpaseo/protocol/agent-types";
/**
* A patch over an agent's own options: top-level keys replace, `null` removes the key. Removal
* does not revert a running provider: it keeps its current setting, and the `config.json`
* default (or the provider's own default) applies from the next open.
*/
export type ProviderOptionsPatch = Record<string, unknown>;
export function applyProviderOptionsPatch(
own: ProviderOptions | undefined,
patch: ProviderOptionsPatch,
): ProviderOptions {
const next: ProviderOptions = { ...own };
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(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;
},
>(input: T): Omit<T, "featureValues"> {
const { featureValues, ...rest } = input;
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 the config's options there. The options the hook returns are the result: a key
* it removed from `providerOptions` stays removed. Keys it added, changed, or deleted in
* `featureValues` (compared with what it was sent, and an absent field counts as empty) are
* applied on top.
*/
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> },
>(returned: T, sent: T): Omit<T, "featureValues"> {
const { featureValues, ...rest } = returned;
const mirrored = sent.providerOptions !== undefined && sent.featureValues !== undefined;
// Without a mirror the hook's `featureValues` are all new options, as for any other client.
const options: ProviderOptions = mirrored
? { ...rest.providerOptions }
: { ...featureValues, ...rest.providerOptions };
if (mirrored) {
// A hook that dropped the whole field removed every key it was sent.
const after = featureValues ?? {};
const before = sent.featureValues ?? {};
for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) {
if (isDeepStrictEqual(after[key], before[key])) continue;
if (key in after) options[key] = after[key];
else delete options[key];
}
}
return { ...rest, providerOptions: options };
}
interface ProfileOptionFields {
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 the profile comes from storage,
* `options` wins over a stale `featureValues` key. When it comes from a client write, the
* client that sent `featureValues` is the unchanged app, which only knows that field, so its
* `featureValues` win over the `options` it received from the previous read.
*/
export function foldProfileFeatureValues<T extends ProfileOptionFields>(
profile: T,
source: "stored" | "client write" = "stored",
): Omit<T, "featureValues"> {
const { featureValues, ...rest } = profile;
if (!featureValues) return rest;
return {
...rest,
options:
source === "stored"
? { ...featureValues, ...rest.options }
: { ...rest.options, ...featureValues },
};
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* The unchanged app reads a profile's options from `featureValues`; the config API answers with
* both fields.
*/
export function withLegacyProfileFeatureValues<T extends ProfileOptionFields>(profile: T): T {
return profile.options ? { ...profile, featureValues: { ...profile.options } } : profile;
}
function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) {
const value = options[control.key];
if (typeof value === "string") {
const choice = control.choices.find(
(candidate) => candidate.value === value || candidate.aliases?.includes(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 } : {}),
};
}
/**
* Whether a toggle is on: the effective option, else the control's own value (what the running
* provider shows after the option was removed).
*/
export function isToggleOn(
key: string,
options: ProviderOptions | undefined,
controls: readonly AgentOptionControl[] | undefined,
): boolean {
const option = options?.[key];
if (typeof option === "boolean") return option;
const control = controls?.find((candidate) => candidate.key === key);
return control?.type === "toggle" && control.defaultValue === true;
}
function featureFromControl(control: AgentOptionControl, effective: ProviderOptions): AgentFeature {
if (control.type === "toggle") {
const value = effective[control.key];
return {
type: "toggle",
id: control.key,
label: control.label,
...describe(control),
value: typeof value === "boolean" ? value : (control.defaultValue ?? false),
};
}
return {
type: "select",
id: control.key,
label: control.label,
...describe(control),
...(control.desktopTrigger ? { desktopTrigger: control.desktopTrigger } : {}),
value: selectedChoice(control, effective),
options: control.choices.map((choice) => ({
id: choice.value,
label: choice.label,
...(choice.description ? { description: choice.description } : {}),
...(choice.value === control.defaultValue ? { isDefault: true } : {}),
})),
};
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Apps that predate controls render `features`; build them from controls + effective options.
*/
export function featuresFromControls(
controls: readonly AgentOptionControl[] | undefined,
options: ProviderOptions | undefined,
): AgentFeature[] {
const effective = options ?? {};
return (controls ?? []).map((control) => featureFromControl(control, effective));
}
/**
* A recorded feature's value is the control's unset value: it is what the agent showed before
* any option was stored, and an explicit effective option still wins over it.
*/
function controlFromFeature(feature: AgentFeature): AgentOptionControl {
const descriptor = {
key: feature.id,
label: feature.label,
...describe(feature),
};
if (feature.type === "toggle") {
return { type: "toggle", ...descriptor, defaultValue: feature.value };
}
const defaultChoice = feature.options.find((option) => option.isDefault);
const unsetValue = feature.value ?? defaultChoice?.id;
return {
type: "select",
...descriptor,
...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}),
...(unsetValue != null ? { defaultValue: unsetValue } : {}),
choices: feature.options.map((option) => ({
value: option.id,
label: option.label,
...(option.description ? { description: option.description } : {}),
})),
};
}
/**
* COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Agent records written before controls stored `features`; their descriptors become controls.
*/
export function controlsFromFeatures(
features: readonly AgentFeature[] | undefined,
): AgentOptionControl[] {
return (features ?? []).map(controlFromFeature);
}
@@ -17,7 +17,7 @@ import { setImmediate as nextTurn } from "node:timers/promises";
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { createTestLogger } from "../../test-utils/test-logger.js"; import { createTestLogger } from "../../test-utils/test-logger.js";
import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js"; import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js";
import { toStoredAgentRecord } from "./agent-projections.js"; import { toAgentPayload, toStoredAgentRecord } from "./agent-projections.js";
import { AgentManager } from "./agent-manager.js"; import { AgentManager } from "./agent-manager.js";
import { buildProviderRegistry } from "./provider-registry.js"; import { buildProviderRegistry } from "./provider-registry.js";
import { ProviderOverrideSchema } from "@getpaseo/protocol/provider-config"; import { ProviderOverrideSchema } from "@getpaseo/protocol/provider-config";
@@ -876,6 +876,89 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
} }
} }
const VOICES = [
{ label: "Direct", value: "direct" },
{ label: "Soft", value: "soft" },
];
function voiceConfig(voice: string) {
return {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: [
{ type: "select" as const, id: "voice", label: "Voice", value: voice, options: VOICES },
],
};
}
/**
* A plugin whose `voice` setting defaults to "direct". With `acceptsVoice: false` it completes a
* configure request but keeps its voice.
*/
function startVoicePlugin(input: { acceptsVoice: boolean; configurable?: boolean }) {
const capabilities = CAPABILITIES.filter(
(name) => input.configurable !== false || name !== "session.configure",
);
const harness = createProviderHarness({
capabilities,
async handleInput(request, emit) {
if (request.type === "session.open") {
emit({
type: "session.opened",
requestId: request.requestId,
sessionId: request.sessionId,
capabilities,
restoration: "core",
persistence: { version: 1, data: { token: "root" } },
cwd: request.config.cwd,
});
emit({
type: "session.config",
sessionId: request.sessionId,
config: voiceConfig(
input.acceptsVoice ? String(request.config.settings?.voice ?? "direct") : "direct",
),
});
emit({ type: "session.ready", requestId: request.requestId, sessionId: request.sessionId });
return true;
}
if (request.type !== "session.configure") return false;
const requested = request.changes.settings?.voice;
emit({
type: "session.config",
sessionId: request.sessionId,
config: voiceConfig(
input.acceptsVoice && typeof requested === "string" ? requested : "direct",
),
});
emit({ type: "request.completed", requestId: request.requestId });
return true;
},
});
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({
logger,
clients: registry.clients(),
providerDefinitions: registry.definitions(),
});
return { harness, manager, registry };
}
function configuredSettings(harness: ReturnType<typeof createProviderHarness>) {
return harness.inputs.flatMap((input) =>
input.type === "session.configure" ? [input.changes.settings] : [],
);
}
function voiceFeature(payload: ReturnType<typeof toAgentPayload>) {
return payload.features?.find((feature) => feature.id === "voice");
}
describe("PluginAgentClientRegistry", () => { describe("PluginAgentClientRegistry", () => {
test("history keeps the tool's first position when completion follows later text", async () => { test("history keeps the tool's first position when completion follows later text", async () => {
const harness = createProviderHarness(); const harness = createProviderHarness();
@@ -1489,6 +1572,160 @@ describe("PluginAgentClientRegistry", () => {
} }
}); });
test("a setting the plugin changes on its own replaces an option the user had selected", async () => {
const harness = createProviderHarness();
const logger = createTestLogger();
const registry = new PluginAgentClientRegistry(logger);
registry.replace([harness.registration]);
const manager = new AgentManager({
logger,
clients: registry.clients(),
providerDefinitions: registry.definitions(),
});
try {
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "direct" } },
undefined,
{ workspaceId: undefined },
);
const opened = harness.inputs.find((input) => input.type === "session.open");
if (opened?.type !== "session.open") throw new Error("session was not opened");
// The plugin reports a new voice after, say, a mode change.
harness.emit({
type: "session.config",
sessionId: opened.sessionId,
config: {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: [
{
type: "select",
id: "voice",
label: "Voice",
value: "soft",
options: [
{ label: "Direct", value: "direct" },
{ label: "Soft", value: "soft" },
],
},
],
},
});
await expect
.poll(() => manager.getAgent(agent.id)?.config.providerOptions)
.toEqual({ voice: "soft" });
const payload = toAgentPayload(manager.getAgent(agent.id)!);
expect(payload.options).toEqual({ voice: "soft" });
expect(payload.features?.find((feature) => feature.id === "voice")).toMatchObject({
value: "soft",
});
} finally {
await registry.shutdown();
}
});
test("a setting passed to a plugin that cannot be configured goes in the open request", async () => {
const { harness, manager, registry } = startVoicePlugin({
acceptsVoice: true,
configurable: false,
});
try {
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
undefined,
{ workspaceId: undefined },
);
const opened = harness.inputs.find((input) => input.type === "session.open");
expect(opened?.type === "session.open" && opened.config.settings).toEqual({ voice: "soft" });
expect(configuredSettings(harness)).toEqual([]);
expect(toAgentPayload(manager.getAgent(agent.id)!).options).toEqual({ voice: "soft" });
} finally {
await registry.shutdown();
}
});
test("a setting a plugin without configuration normalizes while opening replaces the requested option", async () => {
const { harness, manager, registry } = startVoicePlugin({
acceptsVoice: false,
configurable: false,
});
try {
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
undefined,
{ workspaceId: undefined },
);
const payload = toAgentPayload(manager.getAgent(agent.id)!);
expect(payload.options).toEqual({ voice: "direct" });
expect(voiceFeature(payload)).toMatchObject({ value: "direct" });
} finally {
await registry.shutdown();
}
});
test("a selection the plugin settles on while the agent is created replaces the requested option", async () => {
const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: false });
try {
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
undefined,
{ workspaceId: undefined },
);
const payload = toAgentPayload(manager.getAgent(agent.id)!);
expect(payload.options).toEqual({ voice: "direct" });
expect(voiceFeature(payload)).toMatchObject({ value: "direct" });
} finally {
await registry.shutdown();
}
});
test("removing an option leaves the plugin's setting as it is", async () => {
const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: true });
try {
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } },
undefined,
{ workspaceId: undefined },
);
await manager.setAgentOptions(agent.id, { voice: null });
const payload = toAgentPayload(manager.getAgent(agent.id)!);
expect(payload.options).toEqual({});
expect(configuredSettings(harness)).toEqual([]);
expect(voiceFeature(payload)).toMatchObject({ value: "soft" });
} finally {
await registry.shutdown();
}
});
test("a selection the plugin keeps on its previous value replaces the requested option", async () => {
const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: false });
try {
const agent = await manager.createAgent(
{ provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "direct" } },
undefined,
{ workspaceId: undefined },
);
await manager.setAgentOptions(agent.id, { voice: "soft" });
const payload = toAgentPayload(manager.getAgent(agent.id)!);
expect(payload.options).toEqual({ voice: "direct" });
expect(voiceFeature(payload)).toMatchObject({ value: "direct" });
} finally {
await registry.shutdown();
}
});
test("adapts callback providers into the existing AgentClient and AgentSession path", async () => { test("adapts callback providers into the existing AgentClient and AgentSession path", async () => {
const harness = createProviderHarness(); const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger()); const registry = new PluginAgentClientRegistry(createTestLogger());
@@ -1511,12 +1748,14 @@ describe("PluginAgentClientRegistry", () => {
}); });
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" }); const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
expect(session.features).toEqual([ expect(session.controls).toEqual([
expect.objectContaining({ {
type: "select", type: "select",
id: "voice", key: "voice",
options: [{ id: "direct", label: "Direct", value: "direct" }], label: "Voice",
}), defaultValue: "direct",
choices: [{ value: "direct", label: "Direct" }],
},
]); ]);
expect(session.describePersistence()).toMatchObject({ expect(session.describePersistence()).toMatchObject({
provider: "plugin-direct", provider: "plugin-direct",
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
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 } from "@getpaseo/protocol/agent-types"; import type { JsonValue, ProviderOptions } from "@getpaseo/protocol/agent-types";
import { z } from "zod"; import { z } from "zod";
import { import {
PROVIDER_CAPABILITIES, PROVIDER_CAPABILITIES,
@@ -16,6 +16,7 @@ import {
type ProviderCapability, type ProviderCapability,
type ProviderConfigChanges, type ProviderConfigChanges,
type ProviderConfigState, type ProviderConfigState,
type ProviderSetting,
type ProviderConnection, type ProviderConnection,
type ProviderError, type ProviderError,
type ProviderEvent, type ProviderEvent,
@@ -31,10 +32,10 @@ import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentClient, AgentClient,
AgentCreateSessionOptions, AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext, AgentLaunchContext,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
AgentOptionControl,
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentPersistenceHandle, AgentPersistenceHandle,
@@ -1094,9 +1095,8 @@ class PluginAgentClient implements AgentClient {
history: "replay" | "skip"; history: "replay" | "skip";
persist: boolean; persist: boolean;
}): Promise<PluginAgentSession> { }): Promise<PluginAgentSession> {
const sessionId = randomUUID();
const bridge = await this.runtime.openSession({ const bridge = await this.runtime.openSession({
sessionId, sessionId: randomUUID(),
config: mapSessionConfig(input.config, input.launchContext, input.persist), config: mapSessionConfig(input.config, input.launchContext, input.persist),
persistence: input.persistence, persistence: input.persistence,
history: input.history, history: input.history,
@@ -1133,8 +1133,10 @@ class PluginAgentClient implements AgentClient {
}); });
}, },
bind, bind,
input.config.providerOptions ?? {},
); );
bind(bridge); bind(bridge);
session.reportOpeningSettings();
return session; return session;
} }
@@ -1211,6 +1213,8 @@ class PluginAgentSession implements AgentSession {
closed: boolean; closed: boolean;
} | null = null; } | null = null;
private replaying: Promise<void> | null = null; private replaying: Promise<void> | null = null;
/** Option changes the plugin made before anything listened, delivered to the first subscriber. */
private optionsChangedBeforeSubscribe: Record<string, JsonValue> | null = null;
constructor( constructor(
readonly provider: string, readonly provider: string,
@@ -1221,6 +1225,7 @@ 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 heldOptions: ProviderOptions,
) { ) {
this.subagentIdsBySession.set(bridge.id, null); this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false); for (const event of bridge.history) this.accept(event, false);
@@ -1246,14 +1251,29 @@ class PluginAgentSession implements AgentSession {
}; };
} }
get features(): AgentFeature[] { /** The plugin's `settings` are its option controls; each setting id is an option key. */
return this.bridge.config.settings.map((setting) => get controls(): AgentOptionControl[] {
setting.type === "toggle" return this.bridge.config.settings.map(
? { ...setting } (setting): AgentOptionControl =>
: { setting.type === "toggle"
...setting, ? {
options: setting.options.map((option) => ({ ...option, id: option.value })), type: "toggle",
}, key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
defaultValue: setting.value,
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
...(setting.value !== null ? { defaultValue: setting.value } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
})),
},
); );
} }
@@ -1314,6 +1334,11 @@ class PluginAgentSession implements AgentSession {
subscribe(callback: (event: AgentStreamEvent) => void): () => void { subscribe(callback: (event: AgentStreamEvent) => void): () => void {
this.listeners.add(callback); this.listeners.add(callback);
if (this.optionsChangedBeforeSubscribe) {
const patch = this.optionsChangedBeforeSubscribe;
this.optionsChangedBeforeSubscribe = null;
callback({ type: "options_changed", provider: this.provider, patch });
}
return () => this.listeners.delete(callback); return () => this.listeners.delete(callback);
} }
@@ -1410,11 +1435,24 @@ class PluginAgentSession implements AgentSession {
await this.bridge.configure({ thinkingOption: thinkingOptionId }); await this.bridge.configure({ thinkingOption: thinkingOptionId });
} }
async setFeature(featureId: string, value: unknown): Promise<void> { async applyOptions(options: ProviderOptions): Promise<void> {
this.assertReplayComplete(); this.assertReplayComplete();
await this.bridge.configure({ // A setting the options leave out stays as the plugin has it.
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) }, const settings: Record<string, JsonValue> = {};
}); for (const setting of this.bridge.config.settings) {
const value = options[setting.id];
if (value === undefined || value === setting.value) continue;
settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`);
}
// Held before the request, because the plugin's config report arrives while it is pending.
const previous = this.heldOptions;
this.heldOptions = options;
try {
if (Object.keys(settings).length > 0) await this.bridge.configure({ settings });
} catch (error) {
this.heldOptions = previous;
throw error;
}
} }
async revertConversation(input: { messageId: string }): Promise<void> { async revertConversation(input: { messageId: string }): Promise<void> {
@@ -1487,6 +1525,46 @@ class PluginAgentSession implements AgentSession {
this.history.push(next); this.history.push(next);
if (live) this.emit(next); if (live) this.emit(next);
} }
if (!live || event.type !== "session.config") return;
// A setting that is not what the agent holds is an option change, so the stored option
// follows what the plugin reports.
const patch = this.changedSettings(event.config.settings);
if (!patch) return;
// The plugin can settle on other values while the session opens, before the manager listens.
if (this.listeners.size === 0) {
this.optionsChangedBeforeSubscribe = { ...this.optionsChangedBeforeSubscribe, ...patch };
return;
}
this.emit({ type: "options_changed", provider: this.provider, patch });
}
/**
* The settings whose reported value differs from the option the agent holds. A plugin that
* changed a setting on its own, or kept its previous value when asked for another, reports the
* value that is in effect.
*/
/**
* Holds back, for the manager, the opening settings the plugin settled on that differ from the
* options the agent was given. Reports that arrive after open are handled as they come.
*/
reportOpeningSettings(): void {
const patch = this.changedSettings(this.bridge.config.settings);
if (patch) {
this.optionsChangedBeforeSubscribe = { ...this.optionsChangedBeforeSubscribe, ...patch };
}
}
private changedSettings(settings: readonly ProviderSetting[]): Record<string, JsonValue> | null {
const changed: Record<string, JsonValue> = {};
for (const [id, value] of settingValues(settings)) {
// Only a setting the agent holds an option for is reported; the others show through the
// control's default.
if (!Object.hasOwn(this.heldOptions, id)) continue;
if (this.heldOptions[id] !== value) changed[id] = value;
}
if (Object.keys(changed).length === 0) return null;
this.heldOptions = { ...this.heldOptions, ...changed };
return changed;
} }
private translate(event: ProviderEvent): AgentStreamEvent[] { private translate(event: ProviderEvent): AgentStreamEvent[] {
@@ -1852,7 +1930,8 @@ function mapSessionConfig(
model: config.model, model: config.model,
mode: config.modeId, mode: config.modeId,
thinkingOption: config.thinkingOptionId, thinkingOption: config.thinkingOptionId,
settings: toJsonObject(config.featureValues ?? {}, "provider settings"), // The plugin SDK reads composer values from `settings`; they are options here.
settings: toJsonObject(config.providerOptions ?? {}, "provider settings"),
providerOptions: config.providerOptions, providerOptions: config.providerOptions,
title: config.title ?? undefined, title: config.title ?? undefined,
persist, persist,
@@ -1995,6 +2074,10 @@ function toJsonObject(value: unknown, label: string): Record<string, JsonValue>
return encoded as Record<string, JsonValue>; return encoded as Record<string, JsonValue>;
} }
function settingValues(settings: readonly ProviderSetting[]): Map<string, JsonValue> {
return new Map(settings.map((setting) => [setting.id, setting.value]));
}
function toJsonValue(value: unknown, label: string): JsonValue { function toJsonValue(value: unknown, label: string): JsonValue {
const encoded = JSON.stringify(value); const encoded = JSON.stringify(value);
if (encoded === undefined) throw new Error(`${label} must be JSON-serializable`); if (encoded === undefined) throw new Error(`${label} must be JSON-serializable`);
@@ -25,7 +25,7 @@ const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
"listCommands", "listCommands",
"setModel", "setModel",
"setThinkingOption", "setThinkingOption",
"setFeature", "applyOptions",
"revertConversation", "revertConversation",
"revertFiles", "revertFiles",
"revertBoth", "revertBoth",
@@ -152,8 +152,8 @@ class FakeSession implements AgentSession {
this.recordedCalls.push("setThinkingOption"); this.recordedCalls.push("setThinkingOption");
} }
async setFeature() { async applyOptions() {
this.recordedCalls.push("setFeature"); this.recordedCalls.push("applyOptions");
} }
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.setFeature?.("feature-1", true); await wrapped.applyOptions?.({ "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",
"setFeature", "applyOptions",
"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,
AgentFeature, AgentOptionControl,
AgentModelDefinition, AgentModelDefinition,
AgentMode, AgentMode,
AgentSessionConfig, AgentSessionConfig,
@@ -392,15 +392,15 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
return true; return true;
} }
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> { async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
mockState.cursorListFeaturesConfigs.push(config); mockState.cursorListFeaturesConfigs.push(config);
return [ return [
{ {
type: "select", type: "select",
id: "fast", key: "fast",
label: "Fast", label: "Fast",
value: "false", defaultValue: "false",
options: [{ id: "false", label: "Off" }], choices: [{ value: "false", label: "Off" }],
}, },
]; ];
} }
@@ -823,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 features through the inner provider", async () => { test("wrapped cursor client lists ACP controls through the inner provider", async () => {
const registry = buildProviderRegistry(logger, { const registry = buildProviderRegistry(logger, {
providerOverrides: { providerOverrides: {
cursor: { cursor: {
@@ -837,17 +837,17 @@ test("wrapped cursor client lists ACP features through the inner provider", asyn
const client = registry.cursor.createClient(logger); const client = registry.cursor.createClient(logger);
await expect( await expect(
client.listFeatures?.({ client.listControls?.({
provider: "cursor", provider: "cursor",
cwd: "/tmp/cursor", cwd: "/tmp/cursor",
}), }),
).resolves.toEqual([ ).resolves.toEqual([
{ {
type: "select", type: "select",
id: "fast", key: "fast",
label: "Fast", label: "Fast",
value: "false", defaultValue: "false",
options: [{ id: "false", label: "Off" }], choices: [{ value: "false", label: "Off" }],
}, },
]); ]);
expect(mockState.cursorListFeaturesConfigs).toEqual([ expect(mockState.cursorListFeaturesConfigs).toEqual([
@@ -447,8 +447,8 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
get initialTimeline() { get initialTimeline() {
return inner.initialTimeline; return inner.initialTimeline;
}, },
get features() { get controls() {
return inner.features; return inner.controls;
}, },
usageSession: inner.usageSession?.bind(inner), usageSession: inner.usageSession?.bind(inner),
run: (prompt, options) => inner.run(prompt, options), run: (prompt, options) => inner.run(prompt, options),
@@ -473,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),
setFeature: inner.setFeature?.bind(inner), applyOptions: inner.applyOptions?.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,7 +491,7 @@ function wrapClientProvider(
): 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 listFeatures = inner.listFeatures?.bind(inner); const listControls = inner.listControls?.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);
@@ -557,9 +557,10 @@ 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),
listFeatures: listFeatures effectiveOptions: (own) => mergeProviderOptions(providerOptions, own) ?? {},
listControls: listControls
? async (config) => ? async (config) =>
await listFeatures({ await listControls({
...config, ...config,
provider: inner.provider, provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions), providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
@@ -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,
featureValues: undefined, providerOptions: undefined,
}; };
}, },
}), }),
@@ -1338,17 +1338,17 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project", cwd: "/tmp/project",
provider: "codex", provider: "codex",
requestedMode: undefined, requestedMode: undefined,
featureValues: undefined, providerOptions: undefined,
parent, parent,
unattended: false, unattended: false,
}); });
expect(resolved).toEqual({ modeId: "child-unattended", featureValues: undefined }); expect(resolved).toEqual({ modeId: "child-unattended", providerOptions: undefined });
expect(resolverInputs).toEqual([ expect(resolverInputs).toEqual([
{ {
provider: "codex", provider: "codex",
requestedMode: undefined, requestedMode: undefined,
featureValues: undefined, providerOptions: undefined,
parent: { parent: {
provider: "claude", provider: "claude",
modeId: "parent-unattended", modeId: "parent-unattended",
@@ -1363,6 +1363,68 @@ describe("ProviderSnapshotManager public surface", () => {
} }
}); });
test("resolveCreateConfig classifies a parent on its effective options", async () => {
const resolverInputs: ResolveAgentCreateConfigInput[] = [];
const manager = new ProviderSnapshotManager({
logger: createTestLogger(),
providerOverrides: {
copilot: { enabled: false },
opencode: { enabled: false },
pi: { enabled: false },
},
extraClients: {
codex: createExtraClient("codex", {
async isAvailable() {
return true;
},
async fetchCatalog() {
return { models: [] as AgentModelDefinition[], modes: [] };
},
resolveCreateConfig(input) {
resolverInputs.push(input);
return { modeId: undefined, providerOptions: undefined };
},
}),
claude: createExtraClient("claude", {
async isAvailable() {
return true;
},
async fetchCatalog() {
return { models: [] as AgentModelDefinition[], modes: [] };
},
isCreateConfigUnattended(input) {
return input.config.providerOptions?.auto_accept === true;
},
}),
},
});
try {
// The auto-accept comes from provider configuration, not from the agent's own options.
const parent: ManagedAgent = {
...CLOSED_PARENT,
id: "parent-agent",
provider: "claude",
currentModeId: "default",
config: { provider: "claude", cwd: "/tmp/project" },
effectiveOptions: { auto_accept: true },
};
await manager.resolveCreateConfig({
cwd: "/tmp/project",
provider: "codex",
requestedMode: undefined,
providerOptions: undefined,
parent,
unattended: false,
});
expect(resolverInputs[0]).toMatchObject({ parent: { isUnattended: true }, unattended: true });
expect(parent.config.providerOptions).toBeUndefined();
} finally {
manager.destroy();
}
});
test("resolveCreateConfig passes explicit unattended intent to provider policy", async () => { test("resolveCreateConfig passes explicit unattended intent to provider policy", async () => {
const resolverInputs: ResolveAgentCreateConfigInput[] = []; const resolverInputs: ResolveAgentCreateConfigInput[] = [];
const modes: AgentMode[] = [{ id: "worker", label: "Worker", isUnattended: true }]; const modes: AgentMode[] = [{ id: "worker", label: "Worker", isUnattended: true }];
@@ -1386,7 +1448,7 @@ describe("ProviderSnapshotManager public surface", () => {
resolverInputs.push(input); resolverInputs.push(input);
return { return {
modeId: input.unattended ? "worker" : undefined, modeId: input.unattended ? "worker" : undefined,
featureValues: undefined, providerOptions: undefined,
}; };
}, },
}), }),
@@ -1397,17 +1459,17 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project", cwd: "/tmp/project",
provider: "codex", provider: "codex",
requestedMode: undefined, requestedMode: undefined,
featureValues: { fast_mode: true }, providerOptions: { fast_mode: true },
parent: null, parent: null,
unattended: true, unattended: true,
}); });
expect(resolved).toEqual({ modeId: "worker", featureValues: undefined }); expect(resolved).toEqual({ modeId: "worker", providerOptions: undefined });
expect(resolverInputs).toEqual([ expect(resolverInputs).toEqual([
{ {
provider: "codex", provider: "codex",
requestedMode: undefined, requestedMode: undefined,
featureValues: { fast_mode: true }, providerOptions: { fast_mode: true },
parent: null, parent: null,
unattended: true, unattended: true,
availableModes: modes, availableModes: modes,
@@ -1456,7 +1518,7 @@ describe("ProviderSnapshotManager public surface", () => {
config: { config: {
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}, },
}; };
@@ -1464,18 +1526,38 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project", cwd: "/tmp/project",
provider: "opencode", provider: "opencode",
requestedMode: "base", requestedMode: "base",
featureValues: undefined, providerOptions: undefined,
parent, parent,
unattended: false, unattended: false,
}); });
expect(resolved).toEqual({ modeId: "base", featureValues: { auto_accept: true } }); expect(resolved).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
} finally { } finally {
manager.destroy(); manager.destroy();
} }
}); });
}); });
test("an OpenCode parent whose auto accept option was removed but is still on is unattended", async () => {
const openCode = new OpenCodeAgentClient(createTestLogger());
const parent: ManagedAgent = {
...CLOSED_PARENT,
id: "parent-agent",
provider: "opencode",
config: { provider: "opencode", cwd: "/tmp/project" },
controls: [{ type: "toggle", key: "auto_accept", label: "Auto Accept", defaultValue: true }],
};
expect(
openCode.isCreateConfigUnattended({
modeId: parent.currentModeId,
config: parent.config,
controls: parent.controls,
availableModes: [],
}),
).toBe(true);
});
describe("ProviderSnapshotManager applyMutableProviderConfig", () => { describe("ProviderSnapshotManager applyMutableProviderConfig", () => {
test("adds a derived provider and includes it in subsequent reads", async () => { test("adds a derived provider and includes it in subsequent reads", async () => {
const manager = new ProviderSnapshotManager({ const manager = new ProviderSnapshotManager({
@@ -1,4 +1,4 @@
import { normalizeAgentModelCatalog } from "@getpaseo/protocol/agent-types"; import { normalizeAgentModelCatalog, type ProviderOptions } 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;
featureValues: Record<string, unknown> | undefined; providerOptions: ProviderOptions | undefined;
parent: ManagedAgent | null; parent: ManagedAgent | null;
unattended: boolean; unattended: boolean;
} }
export interface ResolvedProviderCreateConfig { export interface ResolvedProviderCreateConfig {
modeId: string | undefined; modeId: string | undefined;
featureValues: Record<string, unknown> | undefined; providerOptions: ProviderOptions | 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,
featureValues: input.featureValues, providerOptions: input.providerOptions,
parent, parent,
unattended: input.unattended || parent?.isUnattended === true, unattended: input.unattended || parent?.isUnattended === true,
availableModes: entry.modes ?? [], availableModes: entry.modes ?? [],
@@ -744,8 +744,13 @@ export class ProviderSnapshotManager {
modeId: parent.currentModeId, modeId: parent.currentModeId,
isUnattended: definition.isCreateConfigUnattended({ isUnattended: definition.isCreateConfigUnattended({
modeId: parent.currentModeId, modeId: parent.currentModeId,
config: parent.config, // Provider defaults from `config.json` apply to the parent too, so classify on the
features: parent.features, // effective options while the stored overrides stay as they are.
config: {
...parent.config,
providerOptions: parent.effectiveOptions ?? parent.config.providerOptions,
},
controls: parent.controls,
availableModes: parent.availableModes ?? definition.modes ?? [], availableModes: parent.availableModes ?? definition.modes ?? [],
}), }),
}; };
@@ -198,7 +198,7 @@ function createSessionWithConfig(
provider?: string; provider?: string;
modeId?: string | null; modeId?: string | null;
model?: string | null; model?: string | null;
featureValues?: Record<string, unknown>; providerOptions?: 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,
featureValues: config.featureValues, providerOptions: config.providerOptions,
}, },
{ {
provider: config.provider ?? "claude-acp", provider: config.provider ?? "claude-acp",
@@ -300,14 +300,14 @@ function selectConfigOption(
function createCopilotSessionWithConfig( function createCopilotSessionWithConfig(
modeId?: string | null, modeId?: string | null,
featureValues?: Record<string, unknown>, providerOptions?: Record<string, unknown>,
): 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,
...(featureValues ? { featureValues } : {}), ...(providerOptions ? { providerOptions } : {}),
}, },
{ {
provider: "copilot", provider: "copilot",
@@ -1498,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",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}); });
const events: Array<{ type: string }> = []; const events: Array<{ type: string }> = [];
@@ -1530,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",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
@@ -1583,11 +1583,8 @@ describe("ACPAgentSession Zed parity", () => {
internals.sessionId = "session-1"; internals.sessionId = "session-1";
internals.connection = {}; internals.connection = {};
await session.setFeature("auto_accept", true); await session.applyOptions({ 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",
@@ -1605,7 +1602,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({ featureValues: { auto_accept: true } }); const session = createSessionWithConfig({ providerOptions: { 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 } }));
@@ -1765,35 +1762,19 @@ describe("ACPAgentSession Zed parity", () => {
const internals = asInternals<ACPSessionInternals>(session); const internals = asInternals<ACPSessionInternals>(session);
internals.configOptions = [copilotAgentConfigOption("")]; internals.configOptions = [copilotAgentConfigOption("")];
expect(session.features).toEqual([ expect(session.controls).toEqual([
expect.objectContaining({ expect.objectContaining({ type: "toggle", key: "auto_accept" }),
type: "toggle",
id: "auto_accept",
value: false,
}),
{ {
type: "select", type: "select",
id: "agent", key: "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,
value: "", defaultValue: "",
options: [ choices: [
{ { value: "", label: "Default", description: undefined },
id: "", { value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" },
label: "Default",
description: undefined,
isDefault: true,
metadata: undefined,
},
{
id: "Probe Agent",
label: "Probe Agent",
description: "Temporary probe agent",
isDefault: false,
metadata: undefined,
},
], ],
}, },
]); ]);
@@ -1816,15 +1797,9 @@ describe("ACPAgentSession Zed parity", () => {
configId: "agent", configId: "agent",
value: "Probe Agent", value: "Probe Agent",
}); });
expect(session.features).toEqual([ expect(session.controls).toEqual([
expect.objectContaining({ expect.objectContaining({ key: "auto_accept" }),
id: "auto_accept", expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
value: false,
}),
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
]); ]);
}); });
@@ -1838,22 +1813,16 @@ describe("ACPAgentSession Zed parity", () => {
connection: { setSessionConfigOption }, connection: { setSessionConfigOption },
}); });
await session.setFeature("agent", "Probe Agent"); await session.applyOptions({ 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.features).toEqual([ expect(session.controls).toEqual([
expect.objectContaining({ expect.objectContaining({ key: "auto_accept" }),
id: "auto_accept", expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
value: false,
}),
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
]); ]);
}); });
}); });
@@ -2084,14 +2053,14 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "generic-acp", provider: "generic-acp",
requestedMode: undefined, requestedMode: undefined,
featureValues: { provider_feature: "kept" }, providerOptions: { provider_feature: "kept" },
parent: null, parent: null,
unattended: true, unattended: true,
availableModes: [], availableModes: [],
}), }),
).toEqual({ ).toEqual({
modeId: undefined, modeId: undefined,
featureValues: { provider_feature: "kept", auto_accept: true }, providerOptions: { provider_feature: "kept", auto_accept: true },
}); });
}); });
@@ -2106,12 +2075,12 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "generic-acp", provider: "generic-acp",
requestedMode: undefined, requestedMode: undefined,
featureValues: { auto_accept: false }, providerOptions: { auto_accept: false },
parent: null, parent: null,
unattended: true, unattended: true,
availableModes: [], availableModes: [],
}), }),
).toEqual({ modeId: undefined, featureValues: { auto_accept: false } }); ).toEqual({ modeId: undefined, providerOptions: { 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", () => {
@@ -2125,7 +2094,7 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "generic-acp", provider: "generic-acp",
requestedMode: undefined, requestedMode: undefined,
featureValues: undefined, providerOptions: undefined,
parent: { parent: {
provider: "claude", provider: "claude",
modeId: "bypassPermissions", modeId: "bypassPermissions",
@@ -2134,7 +2103,7 @@ describe("ACPAgentClient config features", () => {
unattended: false, unattended: false,
availableModes: [{ id: "agent", label: "Agent" }], availableModes: [{ id: "agent", label: "Agent" }],
}), }),
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } }); ).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
}); });
test("treats Auto Accept as an unattended ACP configuration", () => { test("treats Auto Accept as an unattended ACP configuration", () => {
@@ -2150,13 +2119,32 @@ describe("ACPAgentClient config features", () => {
config: { config: {
provider: "generic-acp", provider: "generic-acp",
cwd: "/tmp/acp-features", cwd: "/tmp/acp-features",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}, },
availableModes: [], availableModes: [],
}), }),
).toBe(true); ).toBe(true);
}); });
test("an ACP session whose Auto Accept option was removed but is still on stays unattended", () => {
const client = new ACPAgentClient({
provider: "generic-acp",
logger: createTestLogger(),
defaultCommand: ["generic-acp", "acp"],
});
expect(
client.isCreateConfigUnattended({
modeId: null,
config: { provider: "generic-acp", cwd: "/tmp/acp-features", providerOptions: {} },
controls: [
{ type: "toggle", key: "auto_accept", label: "Auto Accept", defaultValue: true },
],
availableModes: [],
}),
).toBe(true);
});
test("exposes Auto Accept for every ACP provider without starting a probe", async () => { test("exposes Auto Accept for every ACP provider without starting a probe", async () => {
const client = new ACPAgentClient({ const client = new ACPAgentClient({
provider: "generic-acp", provider: "generic-acp",
@@ -2165,22 +2153,17 @@ describe("ACPAgentClient config features", () => {
}); });
await expect( await expect(
client.listFeatures({ client.listControls({
provider: "generic-acp", provider: "generic-acp",
cwd: "/tmp/acp-features", cwd: "/tmp/acp-features",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}), }),
).resolves.toEqual([ ).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
value: true,
}),
]); ]);
}); });
test("derives features from configured ACP select options", async () => { test("derives controls 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 {
@@ -2206,23 +2189,19 @@ describe("ACPAgentClient config features", () => {
}); });
await expect( await expect(
client.listFeatures({ client.listControls({
provider: "copilot", provider: "copilot",
cwd: "/tmp/acp-features", cwd: "/tmp/acp-features",
}), }),
).resolves.toEqual([ ).resolves.toEqual([
expect.objectContaining({ expect.objectContaining({ type: "toggle", key: "auto_accept" }),
type: "toggle",
id: "auto_accept",
value: false,
}),
expect.objectContaining({ expect.objectContaining({
type: "select", type: "select",
id: "agent", key: "agent",
value: "Probe Agent", defaultValue: "Probe Agent",
options: [ choices: [
expect.objectContaining({ id: "", label: "Default", isDefault: false }), expect.objectContaining({ value: "", label: "Default" }),
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }), expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }),
], ],
}), }),
]); ]);
@@ -3997,7 +3976,7 @@ describe("ACPAgentClient probe cleanup", () => {
terminateProcess: terminator.terminate, terminateProcess: terminator.terminate,
}); });
await client.listFeatures({ provider: "copilot", cwd: "/tmp/acp-features" }); await client.listControls({ 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,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { ACPProviderOptionsSchema } from "./acp-options.js"; import { ACPProviderOptionsSchema } from "./acp-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";
@@ -65,7 +66,8 @@ import {
type AgentCapabilityFlags, type AgentCapabilityFlags,
type AgentClient, type AgentClient,
type AgentCreateConfigUnattendedInput, type AgentCreateConfigUnattendedInput,
type AgentFeature, type AgentOptionControl,
type AgentOptionToggleControl,
type AgentLaunchContext, type AgentLaunchContext,
type AgentMetadata, type AgentMetadata,
type AgentMode, type AgentMode,
@@ -110,6 +112,7 @@ import {
isDefaultAgentCreateConfigUnattended, isDefaultAgentCreateConfigUnattended,
resolveDefaultAgentCreateConfig, resolveDefaultAgentCreateConfig,
} from "../create-agent-mode.js"; } from "../create-agent-mode.js";
import { isToggleOn } from "../option-controls.js";
import { importSessionFromPersistence } from "../provider-session-import.js"; import { importSessionFromPersistence } from "../provider-session-import.js";
import { import {
checkProviderLaunchAvailable, checkProviderLaunchAvailable,
@@ -848,10 +851,10 @@ export function deriveModelDefinitionsFromACP(
})); }));
} }
export function deriveFeaturesFromACP( export function deriveControlsFromACP(
configOptions: SessionConfigOption[] | null | undefined, configOptions: SessionConfigOption[] | null | undefined,
featureOptions: ACPConfigFeatureOption[], featureOptions: ACPConfigFeatureOption[],
): AgentFeature[] { ): AgentOptionControl[] {
return featureOptions.flatMap((featureOption) => { return featureOptions.flatMap((featureOption) => {
const option = findSelectConfigFeatureOption(configOptions, featureOption); const option = findSelectConfigFeatureOption(configOptions, featureOption);
if (!option) { if (!option) {
@@ -861,42 +864,44 @@ export function deriveFeaturesFromACP(
return [ return [
{ {
type: "select", type: "select",
id: featureOption.id, key: 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,
value: option.currentValue ?? null, // The agent reports its own current value; an unset option stands for it.
options: deriveConfigFeatureSelectOptions(option, featureOption), ...(option.currentValue != null ? { defaultValue: option.currentValue } : {}),
choices: deriveConfigFeatureSelectOptions(option, featureOption).map((choice) => ({
value: choice.id,
label: choice.label,
description: choice.description,
})),
}, },
]; ];
}); });
} }
function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean { function isACPAutoAcceptEnabled(config: AgentSessionConfig): boolean {
return config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true; return config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
} }
function buildACPAutoAcceptFeature(config: AgentSessionConfig): AgentFeature { const ACP_AUTO_ACCEPT_CONTROL: AgentOptionToggleControl = {
return { type: "toggle",
type: "toggle", key: ACP_AUTO_ACCEPT_FEATURE_ID,
id: ACP_AUTO_ACCEPT_FEATURE_ID, label: "Auto Accept",
label: "Auto Accept", description: "Automatically approves ACP permission prompts.",
description: "Automatically approves ACP permission prompts.", tooltip: "Auto accept permission prompts",
tooltip: "Auto accept permission prompts", icon: "shield-check",
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 featureValues = const providerOptions =
isUnattendedCreate && input.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined isUnattendedCreate && input.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
? { ...input.featureValues, [ACP_AUTO_ACCEPT_FEATURE_ID]: true } ? { ...input.providerOptions, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
: input.featureValues; : input.providerOptions;
if ( if (
input.requestedMode === undefined && input.requestedMode === undefined &&
@@ -904,22 +909,16 @@ function resolveACPCreateConfig(
input.parent !== null && input.parent !== null &&
input.parent.provider !== input.provider input.parent.provider !== input.provider
) { ) {
return { modeId: undefined, featureValues }; return { modeId: undefined, providerOptions };
} }
return resolveDefaultAgentCreateConfig({ ...input, featureValues }); return resolveDefaultAgentCreateConfig({ ...input, providerOptions });
} }
function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean { function isACPCreateConfigUnattended(input: AgentCreateConfigUnattendedInput): boolean {
return ( return (
isDefaultAgentCreateConfigUnattended(input) || isDefaultAgentCreateConfigUnattended(input) ||
input.config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true || isToggleOn(ACP_AUTO_ACCEPT_FEATURE_ID, input.config.providerOptions, input.controls)
input.features?.some(
(feature) =>
feature.id === ACP_AUTO_ACCEPT_FEATURE_ID &&
feature.type === "toggle" &&
feature.value === true,
) === true
); );
} }
@@ -1198,10 +1197,9 @@ export class ACPAgentClient implements AgentClient {
} }
} }
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> { async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
if (this.configFeatureOptions.length === 0) { if (this.configFeatureOptions.length === 0) {
return [autoAcceptFeature]; return [ACP_AUTO_ACCEPT_CONTROL];
} }
this.assertProvider(config); this.assertProvider(config);
@@ -1217,8 +1215,8 @@ export class ACPAgentClient implements AgentClient {
probeSessionId = response.sessionId; probeSessionId = response.sessionId;
const transformed = this.transformSessionResponse(response); const transformed = this.transformSessionResponse(response);
return [ return [
autoAcceptFeature, ACP_AUTO_ACCEPT_CONTROL,
...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions), ...deriveControlsFromACP(transformed.configOptions, this.configFeatureOptions),
]; ];
} finally { } finally {
await this.closeProbe(probe, probeSessionId); await this.closeProbe(probe, probeSessionId);
@@ -1761,6 +1759,12 @@ 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[] = [];
/** Whether permission prompts are approved. Removing the option leaves it as it is. */
private autoAcceptEnabled: boolean;
/** True while startup overrides are written, whose intermediate values are not provider changes. */
private applyingStartupOverrides = false;
/** Option changes made before anything listened, delivered to the first subscriber. */
private optionsChangedBeforeSubscribe: Record<string, unknown> | null = null;
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;
@@ -1780,6 +1784,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
constructor(config: AgentSessionConfig, options: ACPAgentSessionOptions) { constructor(config: AgentSessionConfig, options: ACPAgentSessionOptions) {
this.provider = options.provider; this.provider = options.provider;
this.autoAcceptEnabled = isACPAutoAcceptEnabled(config);
this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill; this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill;
this.capabilities = options.capabilities; this.capabilities = options.capabilities;
this.logger = options.logger.child({ module: "agent", provider: options.provider }); this.logger = options.logger.child({ module: "agent", provider: options.provider });
@@ -2025,6 +2030,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
sessionId: this.sessionId, sessionId: this.sessionId,
}); });
} }
if (this.optionsChangedBeforeSubscribe) {
const patch = this.optionsChangedBeforeSubscribe;
this.optionsChangedBeforeSubscribe = null;
callback({ type: "options_changed", provider: this.provider, patch });
}
return () => { return () => {
this.subscribers.delete(callback); this.subscribers.delete(callback);
}; };
@@ -2054,10 +2064,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode; return this.currentMode;
} }
get features(): AgentFeature[] { get controls(): AgentOptionControl[] {
return [ return [
buildACPAutoAcceptFeature(this.config), { ...ACP_AUTO_ACCEPT_CONTROL, defaultValue: this.autoAcceptEnabled },
...deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions), ...deriveControlsFromACP(this.configOptions, this.configFeatureOptions),
]; ];
} }
@@ -2212,6 +2222,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modeId, requestedValue: modeId,
label: "mode", label: "mode",
}); });
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
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",
@@ -2337,6 +2348,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modelId, requestedValue: modelId,
label: "model", label: "model",
}); });
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
this.pushEvent({ this.pushEvent({
type: "model_changed", type: "model_changed",
provider: this.provider, provider: this.provider,
@@ -2388,6 +2400,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: thinkingOptionId, requestedValue: thinkingOptionId,
label: "thought-level", label: "thought-level",
}); });
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
this.pushEvent({ this.pushEvent({
type: "thinking_option_changed", type: "thinking_option_changed",
provider: this.provider, provider: this.provider,
@@ -2395,19 +2408,55 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}); });
} }
async setFeature(featureId: string, value: unknown): Promise<void> { /**
* When the provider reports a different value for a select the agent holds an explicit option
* for, the stored option follows the report. Selections with no stored option are shown through
* the control's default instead.
*/
private reportSelectionChanges(): AgentStreamEvent[] {
if (this.applyingStartupOverrides) return [];
const patch: Record<string, string> = {};
const held = this.config.providerOptions;
for (const featureOption of this.configFeatureOptions) {
const stored = held?.[featureOption.id];
if (typeof stored !== "string") continue;
const current = findSelectConfigFeatureOption(
this.configOptions,
featureOption,
)?.currentValue;
if (current != null && current !== stored) patch[featureOption.id] = current;
}
if (Object.keys(patch).length === 0) return [];
this.config.providerOptions = { ...this.config.providerOptions, ...patch };
return [{ type: "options_changed", provider: this.provider, patch }];
}
async applyOptions(options: ProviderOptions): Promise<void> {
// What the provider kept for each selection written here, which can differ from the request.
// A key the options leave out is not written: the running provider keeps its selection.
const acknowledged: Record<string, unknown> = {};
for (const featureOption of this.configFeatureOptions) {
if (!Object.prototype.hasOwnProperty.call(options, featureOption.id)) continue;
const value = options[featureOption.id];
const option = findSelectConfigFeatureOption(this.configOptions, featureOption);
if (option && value === option.currentValue) continue;
await this.setConfigFeature(featureOption.id, value);
acknowledged[featureOption.id] = this.config.providerOptions?.[featureOption.id];
}
// Last, so a rejected selection leaves permission handling as it was.
if (Object.prototype.hasOwnProperty.call(options, ACP_AUTO_ACCEPT_FEATURE_ID)) {
this.autoAcceptEnabled = options[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
}
// The session holds the options it was given, so a key the agent no longer has stays absent
// and a later provider report is not mistaken for a change to a stored override.
this.config.providerOptions = { ...options, ...acknowledged };
}
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) {
this.config.featureValues = {
...this.config.featureValues,
[ACP_AUTO_ACCEPT_FEATURE_ID]: value === true,
};
return;
}
const featureOption = this.configFeatureOptions.find((option) => option.id === featureId); const featureOption = this.configFeatureOptions.find((option) => option.id === featureId);
if (!featureOption) { if (!featureOption) {
throw new Error(`Unknown ${this.provider} feature: ${featureId}`); throw new Error(`Unknown ${this.provider} feature: ${featureId}`);
@@ -2441,7 +2490,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue, requestedValue,
label: featureOption.label, label: featureOption.label,
}); });
this.config.featureValues = { ...this.config.featureValues, [featureId]: currentValue }; this.config.providerOptions = { ...this.config.providerOptions, [featureId]: currentValue };
if (currentValue !== requestedValue) {
// The provider kept a different selection than the one asked for.
this.pushEvent({
type: "options_changed",
provider: this.provider,
patch: { [featureId]: currentValue },
});
}
} }
private applyConfigOptionResponse({ private applyConfigOptionResponse({
@@ -2612,8 +2669,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> { async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const canAutoAccept = const canAutoAccept = this.autoAcceptEnabled && !isACPChooserRequest(params.options);
isACPAutoAcceptEnabled(this.config) && !isACPChooserRequest(params.options);
if (canAutoAccept) { if (canAutoAccept) {
const allowOption = selectPermissionOption(params.options, { behavior: "allow" }); const allowOption = selectPermissionOption(params.options, { behavior: "allow" });
if (allowOption) { if (allowOption) {
@@ -2987,6 +3043,18 @@ export class ACPAgentSession implements AgentSession, ACPClient {
} }
private async applyConfiguredOverrides(): Promise<void> { private async applyConfiguredOverrides(): Promise<void> {
// Mode, model and thinking writes move other selections through values that are not final
// yet; the requested options are reconciled once everything has been written.
this.applyingStartupOverrides = true;
try {
await this.writeConfiguredOverrides();
} finally {
this.applyingStartupOverrides = false;
}
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
}
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({
@@ -3018,13 +3086,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.featureValues ?? {}; const configuredFeatureValues = this.config.providerOptions ?? {};
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.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]); await this.setConfigFeature(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;
@@ -3282,6 +3350,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
thinkingOptionId: this.thinkingOptionId, thinkingOptionId: this.thinkingOptionId,
}); });
} }
events.push(...this.reportSelectionChanges());
return events; return events;
} }
@@ -3367,6 +3436,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}, },
"provider.acp.event_emit", "provider.acp.event_emit",
); );
// The provider can settle on other options while the session starts, before the manager
// listens; the first subscriber receives them.
if (event.type === "options_changed" && this.subscribers.size === 0) {
this.optionsChangedBeforeSubscribe = {
...this.optionsChangedBeforeSubscribe,
...event.patch,
};
return;
}
for (const subscriber of this.subscribers) { for (const subscriber of this.subscribers) {
subscriber(event); subscriber(event);
} }
@@ -1,48 +0,0 @@
import pino from "pino";
import { describe, expect, test } from "vitest";
import { ClaudeAgentClient } from "./agent.js";
describe("Claude runtime info before a turn", () => {
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
test("reports the explicitly configured model before the first turn", async () => {
const session = await client.createSession({
provider: "claude",
cwd: process.cwd(),
model: "claude-haiku-5-5",
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
} finally {
await session.close();
}
});
test("reports the persisted configured model after rebuilding an idle session", async () => {
const session = await client.resumeSession({
provider: "claude",
sessionId: "runtime-model-before-turn",
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
});
try {
expect(await session.getRuntimeInfo()).toMatchObject({
sessionId: "runtime-model-before-turn",
model: "claude-haiku-5-5",
});
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
} finally {
await session.close();
}
});
test("leaves the model unknown when Claude will choose its default", async () => {
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
try {
expect((await session.getRuntimeInfo()).model).toBeNull();
} finally {
await session.close();
}
});
});
@@ -18,6 +18,9 @@ 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";
@@ -864,6 +867,34 @@ describe("ClaudeAgentSession features", () => {
await session.close(); await session.close();
}); });
test("applying options before the first query keeps the session's restrictions", 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 },
},
});
// The manager hands the session only the agent's own options.
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("passes extra Claude Code CLI arguments to the SDK", async () => { test("passes extra Claude Code CLI arguments to the SDK", async () => {
const { queryFactory, launches } = createQueryMock(); const { queryFactory, launches } = createQueryMock();
const client = new ClaudeAgentClient({ const client = new ClaudeAgentClient({
@@ -953,39 +984,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.listFeatures({ client.listControls({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-opus-4-8", model: "claude-opus-4-8",
}), }),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]); ).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
await expect( await expect(
client.listFeatures({ client.listControls({
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({ id: "fast_mode", value: false })]); ).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
await expect( await expect(
client.listFeatures({ client.listControls({
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({ id: "fast_mode", value: false })]); ).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
await expect( await expect(
client.listFeatures({ client.listControls({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-opus-5", model: "claude-opus-5",
}), }),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]); ).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
await expect( await expect(
client.listFeatures({ client.listControls({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "openrouter/anthropic/claude-opus-4-8", model: "openrouter/anthropic/claude-opus-4-8",
@@ -993,7 +1024,7 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]); ).resolves.toEqual([]);
await expect( await expect(
client.listFeatures({ client.listControls({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-sonnet-5", model: "claude-sonnet-5",
@@ -1001,7 +1032,7 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]); ).resolves.toEqual([]);
await expect( await expect(
client.listFeatures({ client.listControls({
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-sonnet-4-6", model: "claude-sonnet-4-6",
@@ -1020,7 +1051,7 @@ describe("ClaudeAgentSession features", () => {
provider: "claude", provider: "claude",
cwd: process.cwd(), cwd: process.cwd(),
model: "claude-opus-4-8", model: "claude-opus-4-8",
featureValues: { fast_mode: true }, providerOptions: { fast_mode: true },
}); });
await expect( await expect(
@@ -1492,7 +1523,7 @@ describe("ClaudeAgentSession features", () => {
ensureQuery(): Promise<unknown>; ensureQuery(): Promise<unknown>;
} }
).ensureQuery(); ).ensureQuery();
await session.setFeature?.("fast_mode", true); await session.applyOptions?.({ fast_mode: true });
expect(queryFactory).toHaveBeenCalledTimes(1); expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true }); expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
@@ -1501,6 +1532,146 @@ describe("ClaudeAgentSession features", () => {
await session.close(); 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("an unrelated options patch is accepted after switching away from a Fast model", 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.setModel("claude-opus-4-8");
await session.applyOptions?.({ fast_mode: true });
await session.setModel("claude-sonnet-5");
queryMock.applyFlagSettings.mockClear();
// The manager sends the full record, which still holds the dormant Fast preference.
await expect(
session.applyOptions?.({ fast_mode: true, allowedTools: ["Read"] }),
).resolves.toBeUndefined();
expect(queryMock.applyFlagSettings).not.toHaveBeenCalled();
// Removing the option keeps the preference as it is; nothing is sent to the runtime.
await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined();
expect(queryMock.applyFlagSettings).not.toHaveBeenCalled();
await session.close();
});
test("removing the fast_mode option leaves Fast on and the control shows it", 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 });
queryMock.applyFlagSettings.mockClear();
await session.applyOptions?.({});
expect(queryMock.applyFlagSettings).not.toHaveBeenCalled();
expect(session.controls).toEqual([
expect.objectContaining({ key: "fast_mode", defaultValue: 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", () => {
@@ -1,5 +1,6 @@
import { setImmediate } from "node:timers/promises"; import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js"; import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { validateProviderOptions } from "../../provider-options.js"; import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process"; import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -65,7 +66,7 @@ import { foldSubagentObservations, type SubagentObservation } from "./subagents/
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js"; import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js"; import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js"; import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js";
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js"; import { buildClaudeControls, claudeModelSupportsFastMode } from "./feature-definitions.js";
import { import {
buildBinaryDiagnosticRows, buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows, buildCommandResolutionDiagnosticRows,
@@ -104,7 +105,7 @@ import {
type AgentCapabilityFlags, type AgentCapabilityFlags,
type AgentClient, type AgentClient,
type AgentCreateSessionOptions, type AgentCreateSessionOptions,
type AgentFeature, type AgentOptionControl,
type AgentLaunchContext, type AgentLaunchContext,
type AgentMetadata, type AgentMetadata,
type AgentMode, type AgentMode,
@@ -1752,12 +1753,8 @@ export class ClaudeAgentClient implements AgentClient {
}); });
} }
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> { async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
const claudeConfig = this.assertConfig(config); return buildClaudeControls(this.assertConfig(config).model);
return buildClaudeFeatures({
modelId: claudeConfig.model,
fastModeEnabled: claudeConfig.featureValues?.fast_mode === true,
});
} }
async listImportableSessions( async listImportableSessions(
@@ -2446,11 +2443,8 @@ class ClaudeAgentSession implements AgentSession {
return this.claudeSessionId; return this.claudeSessionId;
} }
get features(): AgentFeature[] { get controls(): AgentOptionControl[] {
return buildClaudeFeatures({ return buildClaudeControls(this.config.model, this.config.providerOptions.fast_mode === true);
modelId: this.config.model,
fastModeEnabled: this.config.featureValues?.fast_mode === true,
});
} }
async getRuntimeInfo(): Promise<AgentRuntimeInfo> { async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -2460,7 +2454,7 @@ class ClaudeAgentSession implements AgentSession {
const info: AgentRuntimeInfo = { const info: AgentRuntimeInfo = {
provider: "claude", provider: "claude",
sessionId: this.claudeSessionId, sessionId: this.claudeSessionId,
model: this.lastOptionsModel ?? this.config.model ?? null, model: this.lastOptionsModel,
modeId: this.currentMode ?? null, modeId: this.currentMode ?? null,
...(this.lastRuntimeModel ...(this.lastRuntimeModel
? { ? {
@@ -2748,8 +2742,10 @@ class ClaudeAgentSession implements AgentSession {
await activeQuery.setModel(normalizedModelId ?? undefined); await activeQuery.setModel(normalizedModelId ?? undefined);
this.config.model = normalizedModelId ?? undefined; this.config.model = normalizedModelId ?? undefined;
this.reconcileThinkingOptionForModel(normalizedModelId); this.reconcileThinkingOptionForModel(normalizedModelId);
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) { if (this.config.providerOptions.fast_mode) {
await this.applyFastModeFeature(false, activeQuery); // The option is the user's intent; the runtime follows it only on models that support it.
await activeQuery.applyFlagSettings({ fastMode: this.resolveFastModeSetting() === true });
this.cachedRuntimeInfo = null;
} }
this.contextUsage.setInitialContextWindowMaxTokens( this.contextUsage.setInitialContextWindowMaxTokens(
getClaudeContextWindowMaxTokens(this.config.model), getClaudeContextWindowMaxTokens(this.config.model),
@@ -2801,31 +2797,28 @@ class ClaudeAgentSession implements AgentSession {
} }
} }
async setFeature(featureId: string, value: unknown): Promise<void> { async applyOptions(options: ProviderOptions): Promise<void> {
if (featureId !== "fast_mode") { const providerOptions =
throw new Error(`Unknown Claude feature: ${featureId}`); validateProviderOptions("claude", ClaudeProviderOptionsSchema, options) ?? {};
} // Supplied keys update the running options; absent keys keep their running value.
const running = { ...this.config.providerOptions, ...providerOptions };
const enabled = Boolean(value); const hadFast = this.config.providerOptions.fast_mode === true;
if (enabled && !claudeModelSupportsFastMode(this.config.model)) { const wantsFast = running.fast_mode === true;
const modelSupportsFast = claudeModelSupportsFastMode(this.config.model);
if (wantsFast && !hadFast && !modelSupportsFast) {
throw new Error( throw new Error(
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`, `Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
); );
} }
// A saved Fast preference stays dormant on a model without Fast (see `setModel`), so the
await this.applyFastModeFeature(enabled); // runtime setting is the preference filtered by model support.
} const runtimeFast = wantsFast && modelSupportsFast;
if (runtimeFast !== (hadFast && modelSupportsFast)) {
private async applyFastModeFeature(enabled: boolean, query?: Query): Promise<void> { await this.query?.applyFlagSettings({ fastMode: runtimeFast });
this.config.featureValues = { this.cachedRuntimeInfo = null;
...this.config.featureValues,
fast_mode: enabled,
};
const activeQuery = query ?? this.query;
if (activeQuery) {
await activeQuery.applyFlagSettings({ fastMode: enabled });
} }
this.cachedRuntimeInfo = null; // Held until the runtime accepts, so a rejected change is retried instead of skipped.
this.config.providerOptions = running;
} }
getPendingPermissions(): AgentPermissionRequest[] { getPendingPermissions(): AgentPermissionRequest[] {
@@ -3654,10 +3647,9 @@ class ClaudeAgentSession implements AgentSession {
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> { private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
const { thinking, effort, ultracode } = this.resolveThinkingConfig(); const { thinking, effort, ultracode } = this.resolveThinkingConfig();
const appendedSystemPrompt = this.buildAppendedSystemPrompt(); const appendedSystemPrompt = this.buildAppendedSystemPrompt();
const providerOptions = applyClaudeToolPolicy( // `fast_mode` is a composer control the provider applies through flag settings.
this.config.providerOptions, const { fast_mode: _fastMode, ...sdkProviderOptions } = this.config.providerOptions;
this.config.toolPolicy, const providerOptions = applyClaudeToolPolicy(sdkProviderOptions, this.config.toolPolicy);
);
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode }); const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
const sdkEnv = this.harnessEnvironment; const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv); assertClaudeModeCanRun(permissionMode, sdkEnv);
@@ -3776,7 +3768,7 @@ class ClaudeAgentSession implements AgentSession {
if (!claudeModelSupportsFastMode(this.config.model)) { if (!claudeModelSupportsFastMode(this.config.model)) {
return null; return null;
} }
return this.config.featureValues?.fast_mode === true; return this.config.providerOptions.fast_mode === true;
} }
private normalizeMcpServers( private normalizeMcpServers(
@@ -1,9 +1,9 @@
import type { AgentFeature, AgentFeatureToggle } from "../../agent-sdk-types.js"; import type { AgentOptionControl, AgentOptionToggleControl } from "../../agent-sdk-types.js";
import { claudeManifestModelSupportsFastMode } from "./model-manifest.js"; import { claudeManifestModelSupportsFastMode } from "./model-manifest.js";
export const CLAUDE_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = { export const CLAUDE_FAST_MODE_CONTROL: AgentOptionToggleControl = {
type: "toggle", type: "toggle",
id: "fast_mode", key: "fast_mode",
label: "Fast", label: "Fast",
description: "Lower latency Opus responses at higher token cost", description: "Lower latency Opus responses at higher token cost",
tooltip: "Toggle fast mode", tooltip: "Toggle fast mode",
@@ -14,18 +14,11 @@ export function claudeModelSupportsFastMode(modelId: string | null | undefined):
return claudeManifestModelSupportsFastMode(modelId); return claudeManifestModelSupportsFastMode(modelId);
} }
export function buildClaudeFeatures(input: { export function buildClaudeControls(
modelId: string | null | undefined; modelId: string | null | undefined,
fastModeEnabled: boolean; fastEnabled = false,
}): AgentFeature[] { ): AgentOptionControl[] {
if (!claudeModelSupportsFastMode(input.modelId)) { return claudeModelSupportsFastMode(modelId)
return []; ? [{ ...CLAUDE_FAST_MODE_CONTROL, defaultValue: fastEnabled }]
} : [];
return [
{
...CLAUDE_FAST_MODE_FEATURE,
value: input.fastModeEnabled,
},
];
} }
@@ -45,6 +45,8 @@ const SandboxFilesystemSchema = z
// Claude Agent SDK Options, maintained against @anthropic-ai/claude-agent-sdk 0.3.246. // Claude Agent SDK Options, maintained against @anthropic-ai/claude-agent-sdk 0.3.246.
export const ClaudeProviderOptionsSchema = z export const ClaudeProviderOptionsSchema = z
.object({ .object({
/** Composer control, not a Claude SDK option: the provider reads it and keeps it out of the SDK options. */
fast_mode: z.boolean().optional(),
allowedTools: z.array(z.string()).optional(), allowedTools: z.array(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(), disallowedTools: z.array(z.string()).optional(),
additionalDirectories: z.array(z.string()).optional(), additionalDirectories: z.array(z.string()).optional(),
@@ -1,7 +1,14 @@
import { featuresFromControls } from "../option-controls.js";
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import pino from "pino"; import pino from "pino";
import { describe, expect, test } from "vitest"; import { describe, expect, test, vi } from "vitest";
import type { AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; import { AgentManager } from "../agent-manager.js";
import { toAgentPayload } from "../agent-projections.js";
import { AgentStorage } from "../agent-storage.js";
import type { AgentClient, AgentSession, AgentSessionConfig } from "../agent-sdk-types.js";
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js"; import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
import { import {
createFakeCodexAppServer, createFakeCodexAppServer,
@@ -37,23 +44,33 @@ const TEST_SPEED_MODELS = [
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }], serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
})); }));
function speedFeature(value: string) { function speedControl(defaultValue = "default") {
return { return {
type: "select", type: "select",
id: "service_tier", key: "service_tier",
label: "Speed", label: "Speed",
description: "Choose processing speed. Faster tiers increase usage.", description: "Choose processing speed. Faster tiers increase usage.",
tooltip: "Select speed", tooltip: "Select speed",
icon: "zap", icon: "zap",
desktopTrigger: "icon", desktopTrigger: "icon",
value, defaultValue,
options: [ choices: [
{ id: "default", label: "Normal", isDefault: true }, { value: "default", label: "Normal" },
{ id: "priority", label: "Fast" }, { value: "priority", label: "Fast", aliases: ["fast"] },
], ],
}; };
} }
const planControl = (defaultValue = false) => ({
type: "toggle",
defaultValue,
key: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
});
const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [ const TEST_COLLABORATION_MODES: CollaborationModeRecord[] = [
{ {
name: "Code", name: "Code",
@@ -132,7 +149,7 @@ async function createConnectedSession(
return harness; return harness;
} }
describe("Codex app-server provider features", () => { describe("Codex app-server provider controls", () => {
test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => { test("GPT-6.1 Sol offers catalog speed tiers and sends Ultrafast", async () => {
const { session, appServer } = await createConnectedSession( const { session, appServer } = await createConnectedSession(
{ model: "gpt-6.1-sol" }, { model: "gpt-6.1-sol" },
@@ -149,19 +166,19 @@ describe("Codex app-server provider features", () => {
}, },
); );
try { try {
expect(session.features).toContainEqual( expect(session.controls).toContainEqual(
expect.objectContaining({ expect.objectContaining({
id: "service_tier", key: "service_tier",
type: "select", type: "select",
value: "default", defaultValue: "default",
options: [ choices: [
{ id: "default", label: "Normal", isDefault: true }, { value: "default", label: "Normal" },
{ id: "priority", label: "Fast" }, { value: "priority", label: "Fast", aliases: ["fast"] },
{ id: "ultrafast", label: "Ultrafast" }, { value: "ultrafast", label: "Ultrafast" },
], ],
}), }),
); );
await session.setFeature?.("service_tier", "ultrafast"); await session.applyOptions?.({ service_tier: "ultrafast" });
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "ultrafast", serviceTier: "ultrafast",
@@ -186,16 +203,16 @@ describe("Codex app-server provider features", () => {
}, },
); );
try { try {
expect(session.features).toContainEqual( expect(session.controls).toContainEqual(
expect.objectContaining({ expect.objectContaining({
id: "service_tier", key: "service_tier",
options: [ choices: [
{ id: "default", label: "Normal", isDefault: true }, { value: "default", label: "Normal" },
{ id: "future-tier", label: "Future speed" }, { value: "future-tier", label: "Future speed" },
], ],
}), }),
); );
await session.setFeature?.("service_tier", "future-tier"); await session.applyOptions?.({ service_tier: "future-tier" });
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "future-codex-model", model: "future-codex-model",
@@ -210,7 +227,7 @@ describe("Codex app-server provider features", () => {
"restores %s and Normal explicitly overrides an existing thread tier", "restores %s and Normal explicitly overrides an existing thread tier",
async (tier) => { async (tier) => {
const { session, appServer } = await createConnectedSession( const { session, appServer } = await createConnectedSession(
{ model: "future-model", featureValues: { service_tier: tier } }, { model: "future-model", providerOptions: { service_tier: tier } },
{ {
models: [ models: [
{ {
@@ -224,11 +241,8 @@ describe("Codex app-server provider features", () => {
}, },
); );
try { try {
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: tier }),
);
await session.getRuntimeInfo(); await session.getRuntimeInfo();
await session.setFeature?.("service_tier", "default"); await session.applyOptions?.({ service_tier: "default" });
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default", serviceTier: "default",
@@ -239,17 +253,40 @@ describe("Codex app-server provider features", () => {
}, },
); );
test("rejects unadvertised tiers and non-string values", async () => { test.each(["priority", "ultrafast"])("restores %s from providerOptions", async (tier) => {
const { session } = await createConnectedSession(); const { session, appServer } = await createConnectedSession(
{ model: "future-model", providerOptions: { service_tier: tier } },
{
models: [
{
id: "future-model",
serviceTiers: [
{ id: "priority", name: "Fast", description: "Fast processing" },
{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" },
],
},
],
},
);
try { try {
for (const value of ["ultrafast", "bogus", true, null]) { await session.startTurn("hello");
await expect(session.setFeature?.("service_tier", value)).rejects.toThrow( await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: tier });
} finally {
await session.close();
}
});
test("rejects unadvertised tiers and non-string values", async () => {
const { session, appServer } = await createConnectedSession();
try {
for (const value of ["ultrafast", "bogus"]) {
await expect(session.applyOptions?.({ service_tier: value })).rejects.toThrow(
"is not available", "is not available",
); );
} }
expect(session.features).toContainEqual( await expect(session.applyOptions?.({ service_tier: true })).rejects.toThrow();
expect.objectContaining({ id: "service_tier", value: "default" }), await session.startTurn("hello");
); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
} finally { } finally {
await session.close(); await session.close();
} }
@@ -257,7 +294,7 @@ describe("Codex app-server provider features", () => {
test("switching to a model without the selected tier resets speed", async () => { test("switching to a model without the selected tier resets speed", async () => {
const { session, appServer } = await createConnectedSession( const { session, appServer } = await createConnectedSession(
{ model: "ultra-model", featureValues: { service_tier: "ultrafast" } }, { model: "ultra-model", providerOptions: { service_tier: "ultrafast" } },
{ {
models: [ models: [
{ {
@@ -270,8 +307,14 @@ describe("Codex app-server provider features", () => {
); );
try { try {
await session.setModel("gpt-6.1-sol"); await session.setModel("gpt-6.1-sol");
expect(session.features).toContainEqual( expect(session.controls).toContainEqual(
expect.objectContaining({ id: "service_tier", value: "default" }), expect.objectContaining({
key: "service_tier",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
],
}),
); );
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" }); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
@@ -283,7 +326,7 @@ describe("Codex app-server provider features", () => {
test("maps legacy Fast commands to the catalog's request id", async () => { test("maps legacy Fast commands to the catalog's request id", async () => {
const { session, appServer } = await createConnectedSession(); const { session, appServer } = await createConnectedSession();
try { try {
await session.setFeature?.("fast_mode", true); await session.applyOptions?.({ fast_mode: true });
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority", serviceTier: "priority",
@@ -307,13 +350,13 @@ describe("Codex app-server provider features", () => {
])("exposes and sends Fast for %s", async (model) => { ])("exposes and sends Fast for %s", async (model) => {
const { session, appServer } = await createConnectedSession({ model }); const { session, appServer } = await createConnectedSession({ model });
try { try {
expect(session.features).toContainEqual( expect(session.controls).toContainEqual(
expect.objectContaining({ expect.objectContaining({
id: "service_tier", key: "service_tier",
value: "default", defaultValue: "default",
}), }),
); );
await session.setFeature?.("service_tier", "priority"); await session.applyOptions?.({ service_tier: "priority" });
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model, model,
@@ -338,12 +381,13 @@ describe("Codex app-server provider features", () => {
])("does not expose or restore Fast for %s", async (model) => { ])("does not expose or restore Fast for %s", async (model) => {
const { session, appServer } = await createConnectedSession({ const { session, appServer } = await createConnectedSession({
model, model,
featureValues: { fast_mode: true }, providerOptions: { fast_mode: true },
}); });
const fresh = await createConnectedSession({ model });
try { try {
expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]); expect(session.controls.map((control) => control.key)).toEqual(["plan_mode"]);
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow( await expect(fresh.session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
`Codex fast mode is not available for model '${model}'`, `Codex speed 'fast' is not available for model '${model}'`,
); );
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -351,21 +395,16 @@ describe("Codex app-server provider features", () => {
}); });
} finally { } finally {
await session.close(); await session.close();
await fresh.session.close();
} }
}); });
test("restores Fast on Astra and preserves it when switching supported models", async () => { test("restores Fast on Astra and preserves it when switching supported models", async () => {
const { session, appServer } = await createConnectedSession({ const { session, appServer } = await createConnectedSession({
model: "gpt-6-astra", model: "gpt-6-astra",
featureValues: { fast_mode: true }, providerOptions: { fast_mode: true },
}); });
try { try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "service_tier",
value: "priority",
}),
);
await session.setModel("gpt-5.6-sol"); await session.setModel("gpt-5.6-sol");
await session.setModel("gpt-6-astra"); await session.setModel("gpt-6-astra");
await session.startTurn("hello"); await session.startTurn("hello");
@@ -378,72 +417,47 @@ describe("Codex app-server provider features", () => {
} }
}); });
test("features returns speed selector and plan toggle when supported", async () => { test("controls return the speed selector and plan toggle when supported", async () => {
const { session } = await createConnectedSession(); const { session, appServer } = await createConnectedSession();
expect(session.features).toEqual([ expect(session.controls).toEqual([speedControl(), planControl()]);
speedFeature("default"),
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
await session.setFeature?.("service_tier", "priority"); await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
await session.setFeature?.("plan_mode", true); await session.startTurn("hello");
expect(session.features).toEqual([ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
speedFeature("priority"), serviceTier: "priority",
{ collaborationMode: expect.objectContaining({ mode: "plan" }),
type: "toggle", });
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
}); });
test("features returns only plan toggle when model does not support fast mode", async () => { test("controls return only the plan toggle when model does not support fast mode", async () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" }); const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
expect(session.features).toEqual([ expect(session.controls).toEqual([planControl()]);
{ });
type: "toggle",
id: "plan_mode", test("removing the speed and plan options leaves the session as it is", async () => {
label: "Plan", const { session, appServer } = await createConnectedSession();
description: "Switch Codex into planning-only collaboration mode", await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
tooltip: "Toggle plan mode",
icon: "list-todo", await session.applyOptions?.({});
value: false,
}, expect(session.controls).toEqual([speedControl("priority"), planControl(true)]);
]); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority",
collaborationMode: expect.objectContaining({ mode: "plan" }),
});
}); });
test("constructor ignores restored fast mode when model does not support it", async () => { test("constructor ignores restored fast mode when model does not support it", async () => {
const { session, appServer } = await createConnectedSession({ const { session, appServer } = await createConnectedSession({
model: "gpt-3.5-turbo", model: "gpt-3.5-turbo",
featureValues: { fast_mode: true }, providerOptions: { fast_mode: true },
}); });
expect(session.features).toEqual([ expect(session.controls).toEqual([planControl()]);
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -454,7 +468,7 @@ describe("Codex app-server provider features", () => {
test("selecting Fast sends the catalog service tier", async () => { test("selecting Fast sends the catalog service tier", async () => {
const { session, appServer } = await createConnectedSession(); const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority"); await session.applyOptions?.({ service_tier: "priority" });
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -464,10 +478,10 @@ describe("Codex app-server provider features", () => {
test("legacy Fast off explicitly selects Normal", async () => { test("legacy Fast off explicitly selects Normal", async () => {
const { session, appServer } = await createConnectedSession({ const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true }, providerOptions: { fast_mode: true },
}); });
await session.setFeature?.("fast_mode", false); await session.applyOptions?.({ fast_mode: false });
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -475,53 +489,34 @@ describe("Codex app-server provider features", () => {
}); });
}); });
test("setFeature('fast_mode', true) rejects models that do not support fast mode", async () => { test("applyOptions({ fast_mode: true }) rejects models that do not support fast mode", async () => {
const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" }); const { session } = await createConnectedSession({ model: "gpt-3.5-turbo" });
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow( await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
"Codex fast mode is not available for model 'gpt-3.5-turbo'", "Codex speed 'fast' is not available for model 'gpt-3.5-turbo'",
); );
}); });
test("setFeature invalidates runtime info", async () => { test("applyOptions invalidates runtime info", async () => {
const { session } = await createConnectedSession(); const { session } = await createConnectedSession();
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({ await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
extra: { collaborationMode: "Plan" }, extra: { collaborationMode: "Plan" },
}); });
await session.setFeature?.("plan_mode", true); await session.applyOptions?.({ plan_mode: true });
await expect(session.getRuntimeInfo()).resolves.toMatchObject({ await expect(session.getRuntimeInfo()).resolves.toMatchObject({
extra: { collaborationMode: "Plan" }, extra: { collaborationMode: "Plan" },
}); });
}); });
test("setFeature throws for unknown feature ids", async () => { test("constructor restores composer options from config.providerOptions", async () => {
const { session } = createSessionHarness();
await expect(session.setFeature?.("unknown_feature", true)).rejects.toThrow(
"Unknown Codex feature: unknown_feature",
);
});
test("constructor restores feature flags from config.featureValues", async () => {
const { session, appServer } = await createConnectedSession({ const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true, plan_mode: true }, providerOptions: { fast_mode: true, plan_mode: true },
}); });
expect(session.features).toEqual([ expect(session.controls).toEqual([speedControl("priority"), planControl(true)]);
speedFeature("priority"),
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -532,10 +527,54 @@ describe("Codex app-server provider features", () => {
}); });
}); });
test("applyOptions accepts an option that has no control", async () => {
const { session } = await createConnectedSession();
await expect(session.applyOptions?.({ sandbox_mode: "read-only" })).resolves.toBeUndefined();
});
test("applying unrelated options keeps the running sandbox", async () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { sandbox_mode: "read-only" },
});
await session.applyOptions?.({ plan_mode: true });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
sandboxPolicy: { type: "readOnly" },
});
});
test("removing the sandbox option keeps the running sandbox", async () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { sandbox_mode: "read-only" },
});
await session.applyOptions?.({});
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
sandboxPolicy: { type: "readOnly" },
});
});
test("an explicit tier still beats a legacy fast_mode on a patch that supplies neither", async () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { service_tier: "default", fast_mode: true },
});
// The manager hands the session only the agent's own options.
await session.applyOptions?.({ fast_mode: true, plan_mode: true });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
});
test("startTurn includes serviceTier when fast mode is enabled", async () => { test("startTurn includes serviceTier when fast mode is enabled", async () => {
const { session, appServer } = await createConnectedSession(); const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority"); await session.applyOptions?.({ service_tier: "priority" });
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -543,11 +582,202 @@ describe("Codex app-server provider features", () => {
}); });
}); });
test("a saved Fast preference selects the model's Fast tier without rewriting the stored option", async () => {
const { session, appServer } = await createConnectedSession({
providerOptions: { service_tier: "fast" },
});
// The model's Fast tier is "priority". The choice names "fast" as an alias, so a client reading
// `options` + `controls` selects it, and the legacy features projection agrees.
const speed = session.controls.find((control) => control.key === "service_tier");
expect(speed).toMatchObject({
choices: expect.arrayContaining([
expect.objectContaining({ value: "priority", aliases: ["fast"] }),
]),
});
expect(
featuresFromControls(session.controls, { service_tier: "fast" }).find(
(feature) => feature.id === "service_tier",
),
).toMatchObject({ value: "priority" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" });
});
describe("a pre-v0.10 fast_mode: true option", () => {
function createLegacyFastHarness(configDefaults: Record<string, unknown> = {}) {
const appServer = createFakeCodexAppServer({
"collaborationMode/list": () => ({ data: TEST_COLLABORATION_MODES }),
"model/list": () => ({ data: TEST_SPEED_MODELS }),
});
const logger = createTestLogger();
const openSession = async (
config: AgentSessionConfig,
resumeSessionId: string | null,
): Promise<AgentSession> => {
const session = new CodexAppServerAgentSession(
// The provider registry hands sessions the `config.json` defaults merged into the agent's own options.
{
...config,
provider: CODEX_PROVIDER,
providerOptions: { ...configDefaults, ...config.providerOptions },
},
resumeSessionId ? { sessionId: resumeSessionId } : null,
logger,
async () => appServer.child,
);
await session.connect();
return session;
};
const client: AgentClient = {
provider: CODEX_PROVIDER,
capabilities: {
supportsStreaming: true,
supportsSessionPersistence: true,
supportsDynamicModes: false,
supportsMcpServers: false,
supportsReasoningStream: false,
supportsToolInvocations: false,
},
isAvailable: async () => true,
fetchCatalog: async () => ({ models: [], modes: [] }),
// Stands in for the `config.json` provider defaults the registry wrapper merges in.
effectiveOptions: (own) => ({ ...configDefaults, ...own }),
createSession: (config) => openSession(config, null),
resumeSession: (handle, config) =>
openSession(
{ ...config, provider: CODEX_PROVIDER } as AgentSessionConfig,
handle.sessionId,
),
};
const workdir = mkdtempSync(join(tmpdir(), "codex-legacy-fast-"));
const storage = new AgentStorage(join(workdir, "agents"), logger);
const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger });
const turnStarts = () =>
appServer
.requests()
.filter((message) => message.method === "turn/start")
.map((message) => message.params);
async function create(providerOptions?: Record<string, unknown>) {
const agent = await manager.createAgent(
{
provider: CODEX_PROVIDER,
cwd: workdir,
model: "gpt-5.4",
...(providerOptions ? { providerOptions } : {}),
},
undefined,
{ workspaceId: undefined },
);
return agent.id;
}
async function resume(providerOptions: Record<string, unknown>) {
const agent = await manager.resumeAgentFromPersistence({
provider: CODEX_PROVIDER,
sessionId: "thread-1",
metadata: { cwd: workdir, model: "gpt-5.4", providerOptions },
});
return agent.id;
}
function shownSpeed(agentId: string) {
const payload = toAgentPayload(manager.getAgent(agentId)!);
const control = payload.controls?.find((candidate) => candidate.key === "service_tier");
const choice =
control?.type === "select"
? control.choices.find((candidate) => candidate.value === payload.options?.service_tier)
: undefined;
return {
options: payload.options,
own: manager.getAgent(agentId)?.config.providerOptions,
// What a client reading `options` + `controls` selects, and what the legacy `features` show.
selected:
payload.options?.service_tier === undefined && control?.type === "select"
? control.defaultValue
: choice?.value,
feature: payload.features?.find((feature) => feature.id === "service_tier")?.value,
};
}
async function runTurn(agentId: string, expectedTier: string) {
const before = turnStarts().length;
await manager.getAgent(agentId)!.session!.startTurn("hello");
await vi.waitFor(() => expect(turnStarts()).toHaveLength(before + 1));
const turn = turnStarts()[before]!;
expect(turn).toMatchObject({ serviceTier: expectedTier });
appServer.completeTurn({ threadId: String(turn.threadId) });
await vi.waitFor(() => expect(manager.getAgent(agentId)?.lifecycle).toBe("idle"));
}
return { manager, create, resume, shownSpeed, runTurn };
}
test("is stored as set, shows Fast, and sends the Fast tier", async () => {
const { create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create({ fast_mode: true });
expect(shownSpeed(agentId)).toEqual({
options: { fast_mode: true },
own: { fast_mode: true },
selected: "priority",
feature: "priority",
});
await runTurn(agentId, "priority");
});
test("shows Fast after a stored record is resumed", async () => {
const { resume, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await resume({ fast_mode: true });
expect(shownSpeed(agentId)).toEqual({
options: { fast_mode: true },
own: { fast_mode: true },
selected: "priority",
feature: "priority",
});
await runTurn(agentId, "priority");
});
test("from config.json defaults shows Fast and leaves the agent's own options empty", async () => {
const { create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true });
const agentId = await create();
expect(shownSpeed(agentId)).toEqual({
options: { fast_mode: true },
own: undefined,
selected: "priority",
feature: "priority",
});
await runTurn(agentId, "priority");
});
test("set live shows Fast at once, and an explicit service_tier then wins", async () => {
const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness();
const agentId = await create();
expect(shownSpeed(agentId).selected).toBe("default");
await manager.setAgentOptions(agentId, { fast_mode: true });
expect(shownSpeed(agentId)).toMatchObject({ selected: "priority", feature: "priority" });
await runTurn(agentId, "priority");
await manager.setAgentOptions(agentId, { service_tier: "default" });
expect(shownSpeed(agentId)).toMatchObject({
own: { fast_mode: true, service_tier: "default" },
selected: "default",
feature: "default",
});
await runTurn(agentId, "default");
await manager.setAgentOptions(agentId, { service_tier: "priority" });
expect(shownSpeed(agentId)).toMatchObject({ selected: "priority" });
await runTurn(agentId, "priority");
});
});
test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => { test("startTurn logs a sanitized turn/start summary for fast mode observability", async () => {
const capture = createCapturedLogger(); const capture = createCapturedLogger();
const prompt = "secret prompt text should not be logged"; const prompt = "secret prompt text should not be logged";
const { session } = await createConnectedSession( const { session } = await createConnectedSession(
{ featureValues: { fast_mode: true } }, { providerOptions: { fast_mode: true } },
{ logger: capture.logger }, { logger: capture.logger },
); );
@@ -570,20 +800,10 @@ describe("Codex app-server provider features", () => {
test("setModel clears fast mode when switching to an unsupported model", async () => { test("setModel clears fast mode when switching to an unsupported model", async () => {
const { session, appServer } = await createConnectedSession(); const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority"); await session.applyOptions?.({ service_tier: "priority" });
await session.setModel("gpt-3.5-turbo"); await session.setModel("gpt-3.5-turbo");
expect(session.features).toEqual([ expect(session.controls).toEqual([planControl()]);
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -594,7 +814,7 @@ describe("Codex app-server provider features", () => {
test("startTurn switches collaboration mode when plan mode is enabled", async () => { test("startTurn switches collaboration mode when plan mode is enabled", async () => {
const { session, appServer } = await createConnectedSession(); const { session, appServer } = await createConnectedSession();
await session.setFeature?.("plan_mode", true); await session.applyOptions?.({ plan_mode: true });
await session.startTurn("hello"); await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -38,7 +38,7 @@ async function withPendingPlan(
cwd: workdir, cwd: workdir,
modeId: "auto", modeId: "auto",
model: "gpt-5.4", model: "gpt-5.4",
featureValues: { plan_mode: true }, providerOptions: { plan_mode: true },
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event)); session.subscribe((event) => events.push(event));
@@ -526,7 +526,7 @@ test("idle provider exit preserves a synthetic plan approval across reconnect",
cwd: workdir, cwd: workdir,
modeId: "auto", modeId: "auto",
model: "gpt-5.4", model: "gpt-5.4",
featureValues: { plan_mode: true }, providerOptions: { plan_mode: true },
}, },
undefined, undefined,
{ workspaceId: undefined }, { workspaceId: undefined },
@@ -6188,7 +6188,7 @@ describe("Codex app-server provider", () => {
test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => { test("emits a synthetic plan approval permission after a successful Codex plan turn", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, providerOptions: { plan_mode: true, fast_mode: true },
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event)); session.subscribe((event) => events.push(event));
@@ -6258,7 +6258,7 @@ describe("Codex app-server provider", () => {
test("does not complete Codex plan timeline cards while plan approval is pending", async () => { test("does not complete Codex plan timeline cards while plan approval is pending", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, providerOptions: { plan_mode: true, fast_mode: true },
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event)); session.subscribe((event) => events.push(event));
@@ -6304,7 +6304,7 @@ describe("Codex app-server provider", () => {
test("replaces a pending synthetic plan approval when a later plan completes", async () => { test("replaces a pending synthetic plan approval when a later plan completes", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true }, providerOptions: { plan_mode: true },
}); });
await asInternals(session).handleNotification("turn/started", { await asInternals(session).handleNotification("turn/started", {
@@ -6337,7 +6337,7 @@ describe("Codex app-server provider", () => {
test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => { test("dismisses a pending synthetic plan approval after a new prompt is accepted", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true }, providerOptions: { plan_mode: true },
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event)); session.subscribe((event) => events.push(event));
@@ -6379,7 +6379,7 @@ describe("Codex app-server provider", () => {
test("makes the old plan non-actionable while a new prompt is being prepared", async () => { test("makes the old plan non-actionable while a new prompt is being prepared", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true }, providerOptions: { plan_mode: true },
}); });
await asInternals(session).handleNotification("turn/started", { await asInternals(session).handleNotification("turn/started", {
@@ -6433,7 +6433,7 @@ describe("Codex app-server provider", () => {
test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => { test("does not dismiss a new plan approval emitted while a prompt is being accepted", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true }, providerOptions: { plan_mode: true },
}); });
await asInternals(session).handleNotification("turn/started", { await asInternals(session).handleNotification("turn/started", {
@@ -6483,7 +6483,7 @@ describe("Codex app-server provider", () => {
test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => { test("keeps a synthetic plan dismissed when a new prompt is rejected", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true }, providerOptions: { plan_mode: true },
}); });
await asInternals(session).handleNotification("turn/started", { await asInternals(session).handleNotification("turn/started", {
@@ -7107,7 +7107,7 @@ describe("Codex app-server provider", () => {
test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => { test("approving a synthetic Codex plan permission disables plan mode, preserves fast mode, and returns follow-up prompt", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, providerOptions: { plan_mode: true, fast_mode: true },
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event)); session.subscribe((event) => events.push(event));
@@ -7138,9 +7138,10 @@ describe("Codex app-server provider", () => {
expect(asInternals(session).serviceTier).toBe("fast"); expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(false); expect(asInternals(session).planModeEnabled).toBe(false);
expect(asInternals(session).config.featureValues).toEqual({ expect(events).toContainEqual({
plan_mode: false, type: "options_changed",
fast_mode: true, provider: "codex",
patch: { plan_mode: false },
}); });
// The session returns the follow-up prompt instead of calling startTurn directly. // The session returns the follow-up prompt instead of calling startTurn directly.
// The caller (session/agent-manager) is responsible for sending it through streamAgent. // The caller (session/agent-manager) is responsible for sending it through streamAgent.
@@ -7161,7 +7162,7 @@ describe("Codex app-server provider", () => {
test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => { test("approving a synthetic Codex plan permission keeps fast mode disabled when it started disabled", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: false }, providerOptions: { plan_mode: true, fast_mode: false },
}); });
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event)); session.subscribe((event) => events.push(event));
@@ -7192,9 +7193,10 @@ describe("Codex app-server provider", () => {
expect(asInternals(session).serviceTier).toBeNull(); expect(asInternals(session).serviceTier).toBeNull();
expect(asInternals(session).planModeEnabled).toBe(false); expect(asInternals(session).planModeEnabled).toBe(false);
expect(asInternals(session).config.featureValues).toEqual({ expect(events).toContainEqual({
plan_mode: false, type: "options_changed",
fast_mode: false, provider: "codex",
patch: { plan_mode: false },
}); });
expect(result?.followUpPrompt).toEqual( expect(result?.followUpPrompt).toEqual(
expect.stringContaining("The user approved the plan. Implement it now."), expect.stringContaining("The user approved the plan. Implement it now."),
@@ -7203,7 +7205,7 @@ describe("Codex app-server provider", () => {
test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => { test("follow-up implementation turn keeps fast service tier and switches back to code collaboration mode", async () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, providerOptions: { plan_mode: true, fast_mode: true },
}); });
asInternals(session).collaborationModes = [ asInternals(session).collaborationModes = [
{ {
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { validateProviderOptions } from "../provider-options.js"; import { validateProviderOptions } from "../provider-options.js";
import { import {
getAgentStreamEventTurnId, getAgentStreamEventTurnId,
@@ -5,7 +6,7 @@ import {
type AgentCapabilityFlags, type AgentCapabilityFlags,
type AgentClient, type AgentClient,
type AgentCreateSessionOptions, type AgentCreateSessionOptions,
type AgentFeature, type AgentOptionControl,
type AgentLaunchContext, type AgentLaunchContext,
type AgentResumeSessionOptions, type AgentResumeSessionOptions,
type AgentMode, type AgentMode,
@@ -75,7 +76,7 @@ import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js"; import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js"; import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import { import {
buildCodexFeatures, buildCodexControls,
CodexServiceTierSchema, CodexServiceTierSchema,
readCodexServiceTier, readCodexServiceTier,
type CodexServiceTier, type CodexServiceTier,
@@ -3429,7 +3430,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly codexHome: string; private readonly codexHome: string;
private currentMode: string; private currentMode: string;
private hasWorkflowModeOverride: boolean; private hasWorkflowModeOverride: boolean;
private readonly providerOptions: CodexProviderOptions; private providerOptions: CodexProviderOptions;
private resolvedWorkspaceWrite: NonNullable< private resolvedWorkspaceWrite: NonNullable<
CodexProviderOptions["sandbox_workspace_write"] CodexProviderOptions["sandbox_workspace_write"]
> | null = null; > | null = null;
@@ -3457,7 +3458,8 @@ export class CodexAppServerAgentSession implements AgentSession {
private activeForegroundTurnId: string | null = null; private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | null = null; private activeClientMessageId: string | null = null;
private cachedRuntimeInfo: AgentRuntimeInfo | null = null; private cachedRuntimeInfo: AgentRuntimeInfo | null = null;
private serviceTier: string | null = null; /** The tier the options ask for; `serviceTier` is what the current model can honor. */
private requestedServiceTier: string | null = null;
private speedModels: CodexModel[] = []; private speedModels: CodexModel[] = [];
private planModeEnabled = false; private planModeEnabled = false;
// History read by rewind or an archived resume, held until streamHistory consumes it. // History read by rewind or an archived resume, held until streamHistory consumes it.
@@ -3568,10 +3570,8 @@ export class CodexAppServerAgentSession implements AgentSession {
this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions); this.asyncQuestions = new CodexAsyncQuestions(resumeHandle?.metadata?.asyncQuestions);
this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env); this.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId); this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
this.serviceTier = readCodexServiceTier(this.config.featureValues); this.requestedServiceTier = readCodexServiceTier(this.providerOptions);
if (this.config.featureValues?.plan_mode) { this.planModeEnabled = this.providerOptions.plan_mode === true;
this.planModeEnabled = true;
}
if (this.resumeHandle?.sessionId) { if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId; this.currentThreadId = this.resumeHandle.sessionId;
@@ -3586,10 +3586,10 @@ export class CodexAppServerAgentSession implements AgentSession {
return this.currentThreadId; return this.currentThreadId;
} }
get features(): AgentFeature[] { get controls(): AgentOptionControl[] {
return buildCodexFeatures({ return buildCodexControls({
serviceTiers: this.currentServiceTiers(), serviceTiers: this.currentServiceTiers(),
serviceTier: this.serviceTier ?? "default", runningServiceTier: this.requestedServiceTier,
planModeEnabled: this.planModeEnabled, planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(), planModeAvailable: this.hasPlanCollaborationMode(),
}); });
@@ -3650,7 +3650,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.speedModels = this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? []; CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
this.reconcileServiceTier();
await this.loadResolvedWorkspaceWrite(); await this.loadResolvedWorkspaceWrite();
await this.loadCollaborationModes(); await this.loadCollaborationModes();
await this.loadSkills(); await this.loadSkills();
@@ -3863,24 +3862,19 @@ export class CodexAppServerAgentSession implements AgentSession {
return selectedModel?.serviceTiers ?? []; return selectedModel?.serviceTiers ?? [];
} }
private reconcileServiceTier(): void { private get serviceTier(): string | null {
const tiers = this.currentServiceTiers(); return this.resolveServiceTier(this.requestedServiceTier);
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
if (this.serviceTier === "fast" && !tiers.some((tier) => tier.id === "fast")) {
this.serviceTier = tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
}
if (this.serviceTier !== "default" && !tiers.some((tier) => tier.id === this.serviceTier)) {
this.serviceTier = null;
}
const { fast_mode: _legacyFast, ...values } = this.config.featureValues ?? {};
this.config.featureValues = { ...values, service_tier: this.serviceTier ?? "default" };
} }
private applyFeatureValue(featureId: "plan_mode", value: boolean): void { private resolveServiceTier(requested: string | null): string | null {
this.config.featureValues = { ...this.config.featureValues, [featureId]: value }; // Until the model list arrives there is nothing to check the request against.
this.planModeEnabled = value; if (this.speedModels.length === 0 || requested === "default") return requested;
this.refreshResolvedCollaborationMode(); const tiers = this.currentServiceTiers();
this.cachedRuntimeInfo = null; // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
if (requested === "fast" && !tiers.some((tier) => tier.id === "fast")) {
return tiers.find((tier) => tier.name.toLowerCase() === "fast")?.id ?? null;
}
return tiers.some((tier) => tier.id === requested) ? requested : null;
} }
private rememberPlanResult(item: ToolCallTimelineItem): void { private rememberPlanResult(item: ToolCallTimelineItem): void {
@@ -3945,7 +3939,13 @@ export class CodexAppServerAgentSession implements AgentSession {
const planText = const planText =
typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : ""; typeof params.planText === "string" ? normalizePlanMarkdown(params.planText) : "";
this.applyFeatureValue("plan_mode", false); this.setPlanMode(false);
// The user left plan mode; their options follow so controls and reopened sessions agree.
this.emitEvent({
type: "options_changed",
provider: CODEX_PROVIDER,
patch: { plan_mode: false },
});
return buildCodexPlanImplementationPrompt(planText); return buildCodexPlanImplementationPrompt(planText);
} }
@@ -4645,7 +4645,6 @@ export class CodexAppServerAgentSession implements AgentSession {
async setModel(modelId: string | null): Promise<void> { async setModel(modelId: string | null): Promise<void> {
this.config.model = modelId ?? undefined; this.config.model = modelId ?? undefined;
this.reconcileServiceTier();
this.refreshResolvedCollaborationMode(); this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null; this.cachedRuntimeInfo = null;
} }
@@ -4659,35 +4658,42 @@ export class CodexAppServerAgentSession implements AgentSession {
} }
} }
async setFeature(featureId: string, value: unknown): Promise<void> { async applyOptions(options: ProviderOptions): Promise<void> {
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier. const next = validateProviderOptions("codex", CodexProviderOptionsSchema, options) ?? {};
if (featureId === "fast_mode") { // An absent speed or plan option leaves the session as it is.
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast"); // Supplied keys update the running options; absent keys keep their running value, so an
if (value && !fast) // explicit tier still beats a legacy `fast_mode` on a patch that supplies neither.
throw new Error( const running = { ...this.providerOptions, ...next };
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`, const speedGiven = next.service_tier !== undefined || next.fast_mode !== undefined;
); const requestedTier = speedGiven ? readCodexServiceTier(running) : this.requestedServiceTier;
return this.setFeature("service_tier", value ? fast!.id : "default"); if (
requestedTier !== this.requestedServiceTier &&
!this.isServiceTierAvailable(requestedTier)
) {
throw new Error(
`Codex speed '${String(requestedTier)}' is not available for model '${this.config.model ?? "default"}'`,
);
} }
if (featureId === "service_tier") { this.providerOptions = running;
if ( this.requestedServiceTier = requestedTier;
typeof value !== "string" || this.setPlanMode(next.plan_mode === undefined ? this.planModeEnabled : next.plan_mode === true);
(value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value)) this.cachedRuntimeInfo = null;
) { }
throw new Error(
`Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`, private isServiceTierAvailable(tier: string | null): boolean {
); if (tier === null || tier === "default") return true;
} const tiers = this.currentServiceTiers();
this.serviceTier = value; // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
this.reconcileServiceTier(); const legacyFast =
this.cachedRuntimeInfo = null; tier === "fast" && tiers.some((entry) => entry.name.toLowerCase() === "fast");
return; return legacyFast || tiers.some((entry) => entry.id === tier);
} }
if (featureId === "plan_mode") {
this.applyFeatureValue("plan_mode", Boolean(value)); private setPlanMode(enabled: boolean): void {
return; if (this.planModeEnabled === enabled) return;
} this.planModeEnabled = enabled;
throw new Error(`Unknown Codex feature: ${featureId}`); this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
} }
getPendingPermissions(): AgentPermissionRequest[] { getPendingPermissions(): AgentPermissionRequest[] {
@@ -5340,7 +5346,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private async applyDefaultModelAndThinking(): Promise<string> { private async applyDefaultModelAndThinking(): Promise<string> {
const { model, thinkingOptionId } = await this.resolveModelAndThinking(); const { model, thinkingOptionId } = await this.resolveModelAndThinking();
this.config.model = model; this.config.model = model;
this.reconcileServiceTier();
this.config.thinkingOptionId = thinkingOptionId; this.config.thinkingOptionId = thinkingOptionId;
return model; return model;
} }
@@ -5407,7 +5412,13 @@ export class CodexAppServerAgentSession implements AgentSession {
private buildCodexInnerConfig(): Record<string, unknown> | null { private buildCodexInnerConfig(): Record<string, unknown> | null {
const innerConfig: Record<string, unknown> = {}; const innerConfig: Record<string, unknown> = {};
Object.assign(innerConfig, this.providerOptions); const {
service_tier: _tier,
plan_mode: _plan,
fast_mode: _fast,
...codexConfig
} = this.providerOptions;
Object.assign(innerConfig, codexConfig);
if (this.deps.customCodexConfig) { if (this.deps.customCodexConfig) {
Object.assign(innerConfig, this.deps.customCodexConfig); Object.assign(innerConfig, this.deps.customCodexConfig);
} }
@@ -1,4 +1,4 @@
import type { AgentFeature, AgentFeatureToggle } from "../agent-sdk-types.js"; import type { AgentOptionControl, AgentOptionToggleControl } from "../agent-sdk-types.js";
import { z } from "zod"; import { z } from "zod";
@@ -9,51 +9,65 @@ export const CodexServiceTierSchema = z.object({
}); });
export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>; export type CodexServiceTier = z.infer<typeof CodexServiceTierSchema>;
function buildCodexSpeedFeature(tiers: CodexServiceTier[], value: string): AgentFeature[] { // The control shows the speed the session is running as its default: a client selects the
// option's value when `service_tier` is set and the running speed when it is not.
function buildCodexSpeedControl(
tiers: CodexServiceTier[],
runningTier: string | null,
): AgentOptionControl[] {
if (tiers.length === 0) return []; if (tiers.length === 0) return [];
const choices = [
{ value: "default", label: "Normal" },
...tiers.map((tier) => ({
value: tier.id,
label: tier.name,
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
...(tier.id !== "fast" && tier.name.toLowerCase() === "fast" ? { aliases: ["fast"] } : {}),
})),
];
const defaultChoice = choices.find(
(choice) => choice.value === runningTier || choice.aliases?.includes(runningTier ?? ""),
);
return [ return [
{ {
type: "select", type: "select",
id: "service_tier", key: "service_tier",
label: "Speed", label: "Speed",
description: "Choose processing speed. Faster tiers increase usage.", description: "Choose processing speed. Faster tiers increase usage.",
tooltip: "Select speed", tooltip: "Select speed",
icon: "zap", icon: "zap",
desktopTrigger: "icon", desktopTrigger: "icon",
value, defaultValue: defaultChoice?.value ?? "default",
options: [ choices,
{ id: "default", label: "Normal", isDefault: true },
...tiers.map((tier) => ({ id: tier.id, label: tier.name })),
],
}, },
]; ];
} }
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = { export const CODEX_PLAN_MODE_CONTROL: AgentOptionToggleControl = {
type: "toggle", type: "toggle",
id: "plan_mode", key: "plan_mode",
label: "Plan", label: "Plan",
description: "Switch Codex into planning-only collaboration mode", description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode", tooltip: "Toggle plan mode",
icon: "list-todo", icon: "list-todo",
}; };
export function buildCodexFeatures(input: { export function buildCodexControls(input: {
serviceTiers: CodexServiceTier[]; serviceTiers: CodexServiceTier[];
serviceTier: string; runningServiceTier: string | null;
planModeEnabled: boolean; planModeEnabled: boolean;
planModeAvailable?: boolean; planModeAvailable?: boolean;
}): AgentFeature[] { }): AgentOptionControl[] {
const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier); const controls = buildCodexSpeedControl(input.serviceTiers, input.runningServiceTier);
if (input.planModeAvailable !== false) { if (input.planModeAvailable !== false) {
features.push({ ...CODEX_PLAN_MODE_FEATURE, value: input.planModeEnabled }); controls.push({ ...CODEX_PLAN_MODE_CONTROL, defaultValue: input.planModeEnabled });
} }
return features; return controls;
} }
export function readCodexServiceTier(values: Record<string, unknown> | undefined): string | null { export function readCodexServiceTier(options: Record<string, unknown> | undefined): string | null {
const tier = values?.service_tier; const tier = options?.service_tier;
if (typeof tier === "string") return tier; if (typeof tier === "string") return tier;
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
return values?.fast_mode ? "fast" : null; return options?.fast_mode ? "fast" : null;
} }
@@ -37,6 +37,11 @@ const NetworkPolicySchema = z
// Codex config reference, maintained against Codex CLI 0.143+. // Codex config reference, maintained against Codex CLI 0.143+.
export const CodexProviderOptionsSchema = z export const CodexProviderOptionsSchema = z
.object({ .object({
// Composer controls, not Codex config: the provider reads them and keeps them out of `config`.
service_tier: z.string().optional(),
plan_mode: z.boolean().optional(),
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
fast_mode: z.boolean().optional(),
approval_policy: ApprovalPolicySchema.optional(), approval_policy: ApprovalPolicySchema.optional(),
sandbox_mode: z.enum(["read-only", "workspace-write", "danger-full-access"]).optional(), sandbox_mode: z.enum(["read-only", "workspace-write", "danger-full-access"]).optional(),
sandbox_workspace_write: z sandbox_workspace_write: z
@@ -6,7 +6,7 @@ import { describe, expect, test, vi } from "vitest";
import { ACPAgentSession } from "./acp-agent.js"; import { ACPAgentSession } from "./acp-agent.js";
import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js"; import type { SpawnedACPProcess, SessionStateResponse } from "./acp-agent.js";
import type { AgentSessionConfig } from "../agent-sdk-types.js"; import type { AgentSessionConfig, AgentStreamEvent } from "../agent-sdk-types.js";
import { CURSOR_FAST_FEATURE_OPTION, CursorACPAgentClient } from "./cursor-acp-agent.js"; import { CURSOR_FAST_FEATURE_OPTION, CursorACPAgentClient } from "./cursor-acp-agent.js";
import { GenericACPAgentClient } from "./generic-acp-agent.js"; import { GenericACPAgentClient } from "./generic-acp-agent.js";
import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createTestLogger } from "../../../test-utils/test-logger.js";
@@ -155,7 +155,7 @@ describe("CursorACPAgentClient model discovery", () => {
}); });
}); });
test("exposes Cursor fast mode through provider features", async () => { test("exposes Cursor fast mode through provider controls", async () => {
const client = new TestCursorACPAgentClient({ const client = new TestCursorACPAgentClient({
sessionId: "session-1", sessionId: "session-1",
models: null, models: null,
@@ -163,43 +163,30 @@ describe("CursorACPAgentClient model discovery", () => {
}); });
await expect( await expect(
client.listFeatures({ client.listControls({
provider: "acp", provider: "acp",
cwd: "/tmp/cursor", cwd: "/tmp/cursor",
}), }),
).resolves.toEqual([ ).resolves.toEqual([
{ {
type: "toggle", type: "toggle",
id: "auto_accept", key: "auto_accept",
label: "Auto Accept", label: "Auto Accept",
description: "Automatically approves ACP permission prompts.", description: "Automatically approves ACP permission prompts.",
tooltip: "Auto accept permission prompts", tooltip: "Auto accept permission prompts",
icon: "shield-check", icon: "shield-check",
value: false,
}, },
{ {
type: "select", type: "select",
id: CURSOR_FAST_FEATURE_OPTION.id, key: CURSOR_FAST_FEATURE_OPTION.id,
label: "Fast", label: "Fast",
description: "Cursor fast mode", description: "Cursor fast mode",
tooltip: "Select Cursor fast mode", tooltip: "Select Cursor fast mode",
icon: "zap", icon: "zap",
value: "false", defaultValue: "false",
options: [ choices: [
{ { value: "false", label: "Off" },
id: "false", { value: "true", label: "Fast" },
label: "Off",
isDefault: true,
description: undefined,
metadata: undefined,
},
{
id: "true",
label: "Fast",
isDefault: false,
description: undefined,
metadata: undefined,
},
], ],
}, },
]); ]);
@@ -228,7 +215,9 @@ describe("CursorACPAgentClient session start", () => {
session: { session: {
currentModelId: string; currentModelId: string;
configOptions: SessionConfigOption[]; configOptions: SessionConfigOption[];
setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>; setSessionConfigOption?: (request: {
value: string;
}) => Promise<{ configOptions: SessionConfigOption[] }>;
} = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS }, } = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
logger = createTestLogger(), logger = createTestLogger(),
): ACPAgentSession { ): ACPAgentSession {
@@ -277,7 +266,7 @@ describe("CursorACPAgentClient session start", () => {
// still stored after the draft moved to a model that has no fast variant. // still stored after the draft moved to a model that has no fast variant.
const warn = captureWarnings(); const warn = captureWarnings();
const session = createCursorSession( const session = createCursorSession(
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } }, { model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{ currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS }, { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
warn.logger, warn.logger,
); );
@@ -295,7 +284,7 @@ describe("CursorACPAgentClient session start", () => {
// still listed after the model moves to one that has no fast variant. // still listed after the model moves to one that has no fast variant.
const warn = captureWarnings(); const warn = captureWarnings();
const session = createCursorSession( const session = createCursorSession(
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } }, { model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{ {
currentModelId: "composer-2.5", currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS], configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
@@ -319,7 +308,7 @@ describe("CursorACPAgentClient session start", () => {
test("still fails session start when a stored feature write fails for another reason", async () => { test("still fails session start when a stored feature write fails for another reason", async () => {
const session = createCursorSession( const session = createCursorSession(
{ model: "kimi-k3", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } }, { model: "kimi-k3", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{ {
currentModelId: "composer-2.5", currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS], configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
@@ -334,16 +323,251 @@ describe("CursorACPAgentClient session start", () => {
const session = createCursorSession({ model: "kimi-k3" }); const session = createCursorSession({ model: "kimi-k3" });
await session.initializeNewSession(); await session.initializeNewSession();
await expect(session.setFeature(CURSOR_FAST_FEATURE_OPTION.id, "true")).rejects.toThrow( await expect(session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" })).rejects.toThrow(
"acp does not expose ACP feature 'fast'", "acp does not expose ACP feature 'fast'",
); );
}); });
test("removing auto_accept keeps answering permission requests and the control shows it", async () => {
const session = createCursorSession({ model: "composer-2.5" });
await session.initializeNewSession();
const request = {
sessionId: "session-1",
toolCall: { toolCallId: "tool-1", title: "Run command", kind: "execute", status: "pending" },
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
} as const;
await session.applyOptions({ auto_accept: true });
await session.applyOptions({});
await expect(session.requestPermission({ ...request })).resolves.toEqual({
outcome: { outcome: "selected", optionId: "allow-always" },
});
expect(session.controls).toContainEqual(
expect.objectContaining({ key: "auto_accept", defaultValue: true }),
);
});
test("a rejected options update leaves permission handling as it was", async () => {
const session = createCursorSession(
{ model: "composer-2.5" },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false")],
setSessionConfigOption: vi.fn(async () => ({ configOptions: [fastConfigOption("false")] })),
},
);
await session.initializeNewSession();
await expect(
session.applyOptions({
auto_accept: true,
[CURSOR_FAST_FEATURE_OPTION.id]: "no-such-choice",
}),
).rejects.toThrow();
void session.requestPermission({
sessionId: "session-1",
toolCall: { toolCallId: "tool-1", title: "Run command", kind: "execute", status: "pending" },
options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }],
});
await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1));
});
test("a selection the provider changes on its own replaces the stored option", async () => {
const session = createCursorSession(
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("true")],
},
);
await session.initializeNewSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.sessionUpdate({
sessionId: "session-1",
update: { sessionUpdate: "config_option_update", configOptions: [fastConfigOption("false")] },
});
expect(events).toContainEqual({
type: "options_changed",
provider: "acp",
patch: { [CURSOR_FAST_FEATURE_OPTION.id]: "false" },
});
});
test("a selection a model or thinking write changes replaces the stored option", async () => {
const reasoning = (currentValue: string): SessionConfigOption => ({
...KIMI_K3_CONFIG_OPTIONS[0]!,
currentValue,
});
const session = createCursorSession(
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{
currentModelId: "composer-2.5",
configOptions: [reasoning("max"), fastConfigOption("true")],
// Changing the reasoning level also turns Fast off, with no separate notification.
setSessionConfigOption: async (request) => ({
configOptions: [
reasoning(request.value === "low" ? "low" : "max"),
fastConfigOption(request.value === "low" ? "false" : "true"),
],
}),
},
);
await session.initializeNewSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.setThinkingOption("low");
expect(events).toContainEqual({
type: "options_changed",
provider: "acp",
patch: { [CURSOR_FAST_FEATURE_OPTION.id]: "false" },
});
});
test("an option removed from the agent stays absent when the provider later selects it again", async () => {
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")],
}));
const session = createCursorSession(
{ model: "composer-2.5" },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false")],
setSessionConfigOption,
},
);
await session.initializeNewSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" });
await session.applyOptions({});
await session.sessionUpdate({
sessionId: "session-1",
update: { sessionUpdate: "config_option_update", configOptions: [fastConfigOption("true")] },
});
expect(events.filter((event) => event.type === "options_changed")).toEqual([]);
});
test("startup writes keep the requested Fast option while a thinking change settles", async () => {
const state = { reasoning: "max", fast: "false" };
const setSessionConfigOption = vi.fn(async (request: { configId: string; value: string }) => {
if (request.configId === "reasoning") state.reasoning = request.value;
if (request.configId === "fast") state.fast = request.value;
return {
configOptions: [
{ ...KIMI_K3_CONFIG_OPTIONS[0], currentValue: state.reasoning } as SessionConfigOption,
fastConfigOption(state.fast as "true" | "false"),
],
};
});
const session = createCursorSession(
{
model: "composer-2.5",
thinkingOptionId: "low",
providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" },
},
{
currentModelId: "composer-2.5",
configOptions: [KIMI_K3_CONFIG_OPTIONS[0]!, fastConfigOption("false")],
setSessionConfigOption,
},
);
await session.initializeNewSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual([
"low",
"true",
]);
expect(events.filter((event) => event.type === "options_changed")).toEqual([]);
expect(session.controls).toContainEqual(
expect.objectContaining({ key: "fast", defaultValue: "true" }),
);
});
test("an options patch that never held fast leaves a selection the provider made alone", async () => {
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")],
}));
const session = createCursorSession(
{ model: "composer-2.5" },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false")],
setSessionConfigOption,
},
);
await session.initializeNewSession();
await session.sessionUpdate({
sessionId: "session-1",
update: { sessionUpdate: "config_option_update", configOptions: [fastConfigOption("true")] },
});
await session.applyOptions({ rpcTimeoutMs: 120000 });
expect(setSessionConfigOption).not.toHaveBeenCalled();
});
test("an acknowledgement given while the session starts reaches the first subscriber", async () => {
const session = createCursorSession(
{ model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false")],
setSessionConfigOption: vi.fn(async () => ({
configOptions: [fastConfigOption("false")],
})),
},
);
await session.initializeNewSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
expect(events.filter((event) => event.type === "options_changed")).toEqual([
{ type: "options_changed", provider: "acp", patch: { fast: "false" } },
]);
});
test("removing the Fast option leaves the provider on its current selection", async () => {
const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({
configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")],
}));
const session = createCursorSession(
{ model: "composer-2.5" },
{
currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false")],
setSessionConfigOption,
},
);
await session.initializeNewSession();
await session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" });
// `{ fast: null }` with no config.json default reaches the provider as no key at all.
await session.applyOptions({});
expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual(["true"]);
expect(session.controls).toContainEqual(
expect.objectContaining({ key: "fast", defaultValue: "true" }),
);
});
test("still fails session start when the provider rejects a write it should accept", async () => { test("still fails session start when the provider rejects a write it should accept", async () => {
// No model switch, so the session's options came straight from session/new and the // No model switch, so the session's options came straight from session/new and the
// provider disagreeing about a feature it just advertised is a real failure. // provider disagreeing about a feature it just advertised is a real failure.
const session = createCursorSession( const session = createCursorSession(
{ model: "composer-2.5", featureValues: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } }, { model: "composer-2.5", providerOptions: { [CURSOR_FAST_FEATURE_OPTION.id]: "true" } },
{ {
currentModelId: "composer-2.5", currentModelId: "composer-2.5",
configOptions: [fastConfigOption("false")], configOptions: [fastConfigOption("false")],
@@ -68,7 +68,7 @@ describe("MockLoadTestAgentClient", () => {
provider: "mock", provider: "mock",
cwd: process.cwd(), cwd: process.cwd(),
model: "ten-second-stream", model: "ten-second-stream",
featureValues: { mockPromptRejections: 1 }, providerOptions: { mockPromptRejections: 1 },
}); });
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow( await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
@@ -95,7 +95,7 @@ describe("MockLoadTestAgentClient", () => {
provider: "mock", provider: "mock",
cwd: process.cwd(), cwd: process.cwd(),
model: "ten-second-stream", model: "ten-second-stream",
featureValues: { providerOptions: {
mockStreamingAssistantResponse: response, mockStreamingAssistantResponse: response,
mockStreamingAssistantIntervalMs: 20, mockStreamingAssistantIntervalMs: 20,
}, },
@@ -467,7 +467,7 @@ describe("MockLoadTestAgentClient", () => {
provider: "mock", provider: "mock",
cwd: process.cwd(), cwd: process.cwd(),
model: "e2e-fast-stream", model: "e2e-fast-stream",
featureValues: { mockAssistantResponses: ["First response", "![Second](second.png)"] }, providerOptions: { mockAssistantResponses: ["First response", "![Second](second.png)"] },
}); });
await session.run("Emit a synthetic turn failure."); await session.run("Emit a synthetic turn failure.");
expect((await session.run("First prompt")).finalText).toBe("First response"); expect((await session.run("First prompt")).finalText).toBe("First response");
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentClient, AgentClient,
AgentFeature, AgentOptionControl,
AgentLaunchContext, AgentLaunchContext,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
@@ -787,7 +787,7 @@ function getConfiguredAssistantResponses(value: unknown): string[] {
export class MockLoadTestAgentSession implements AgentSession { export class MockLoadTestAgentSession implements AgentSession {
readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID; readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID;
readonly capabilities = CAPABILITIES; readonly capabilities = CAPABILITIES;
readonly features: AgentFeature[] = []; readonly controls: AgentOptionControl[] = [];
readonly id: string; readonly id: string;
private readonly usageSessionKey = randomUUID(); private readonly usageSessionKey = randomUUID();
@@ -812,18 +812,18 @@ export class MockLoadTestAgentSession implements AgentSession {
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID; this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID; this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
this.assistantResponse = this.assistantResponse =
typeof options.config.featureValues?.mockAssistantResponse === "string" typeof options.config.providerOptions?.mockAssistantResponse === "string"
? options.config.featureValues.mockAssistantResponse ? options.config.providerOptions.mockAssistantResponse
: null; : null;
this.assistantResponses = getConfiguredAssistantResponses( this.assistantResponses = getConfiguredAssistantResponses(
options.config.featureValues?.mockAssistantResponses, options.config.providerOptions?.mockAssistantResponses,
); );
this.streamingAssistantResponse = this.streamingAssistantResponse =
typeof options.config.featureValues?.mockStreamingAssistantResponse === "string" typeof options.config.providerOptions?.mockStreamingAssistantResponse === "string"
? options.config.featureValues.mockStreamingAssistantResponse ? options.config.providerOptions.mockStreamingAssistantResponse
: null; : null;
const requestedStreamingInterval = const requestedStreamingInterval =
options.config.featureValues?.mockStreamingAssistantIntervalMs; options.config.providerOptions?.mockStreamingAssistantIntervalMs;
this.streamingAssistantIntervalMs = this.streamingAssistantIntervalMs =
typeof requestedStreamingInterval === "number" && typeof requestedStreamingInterval === "number" &&
Number.isFinite(requestedStreamingInterval) && Number.isFinite(requestedStreamingInterval) &&
@@ -831,10 +831,10 @@ export class MockLoadTestAgentSession implements AgentSession {
? Math.min(requestedStreamingInterval, 1_000) ? Math.min(requestedStreamingInterval, 1_000)
: MOCK_LOAD_TEST_INTERVAL_MS; : MOCK_LOAD_TEST_INTERVAL_MS;
this.rewindError = this.rewindError =
typeof options.config.featureValues?.mockRewindError === "string" typeof options.config.providerOptions?.mockRewindError === "string"
? options.config.featureValues.mockRewindError ? options.config.providerOptions.mockRewindError
: null; : null;
const requestedPromptRejections = options.config.featureValues?.mockPromptRejections; const requestedPromptRejections = options.config.providerOptions?.mockPromptRejections;
this.remainingPromptRejections = this.remainingPromptRejections =
typeof requestedPromptRejections === "number" && typeof requestedPromptRejections === "number" &&
Number.isSafeInteger(requestedPromptRejections) && Number.isSafeInteger(requestedPromptRejections) &&
@@ -842,7 +842,7 @@ export class MockLoadTestAgentSession implements AgentSession {
? requestedPromptRejections ? requestedPromptRejections
: 0; : 0;
this.remainingSteerFailures = getPositiveFeatureInteger( this.remainingSteerFailures = getPositiveFeatureInteger(
options.config.featureValues?.mockSteerAmbiguousFailures, options.config.providerOptions?.mockSteerAmbiguousFailures,
); );
} }
@@ -613,9 +613,9 @@ describe("OMP agent client and session", () => {
expect(omp.requireSession().describePersistence()?.metadata?.model).toBe( expect(omp.requireSession().describePersistence()?.metadata?.model).toBe(
"openrouter/google/gemini-3.8-flash", "openrouter/google/gemini-3.8-flash",
); );
expect(omp.requireSession().features).toEqual([ expect(omp.requireSession().controls).toEqual([
expect.objectContaining({ expect.objectContaining({
value: true, defaultValue: true,
description: expect.stringMatching(/does not apply/i), description: expect.stringMatching(/does not apply/i),
}), }),
]); ]);
@@ -626,18 +626,57 @@ describe("OMP agent client and session", () => {
await omp.start({ model: "openrouter/google/gemini-3.8-flash" }); await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const session = omp.requireSession(); const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: false }; omp.runtime().fastModeResult = { enabled: true, active: false };
await session.setFeature?.("fast_mode", true); await session.applyOptions?.({ fast_mode: true });
expect(omp.runtime().setFastModeRequests).toEqual([true]); expect(omp.runtime().setFastModeRequests).toEqual([true]);
expect(session.features).toEqual([ expect(session.controls).toEqual([
expect.objectContaining({ expect.objectContaining({
id: "fast_mode", key: "fast_mode",
value: true, defaultValue: true,
description: expect.stringMatching(/does not apply to this model/i), description: expect.stringMatching(/does not apply to this model/i),
tooltip: expect.stringMatching(/does not apply to this model/i), tooltip: expect.stringMatching(/does not apply to this model/i),
}), }),
]); ]);
}); });
test("removing the fast_mode option leaves the runtime on its current setting", async () => {
const omp = new OmpHarness();
await omp.start();
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.applyOptions?.({ fast_mode: true });
expect(omp.runtime().setFastModeRequests).toEqual([true]);
// `{ fast_mode: null }` with no config.json default reaches the provider as no key at all.
await session.applyOptions?.({});
expect(omp.runtime().setFastModeRequests).toEqual([true]);
expect(session.controls).toEqual([expect.objectContaining({ defaultValue: true })]);
});
test("an options patch that never held fast_mode leaves the runtime's Fast setting alone", async () => {
const omp = new OmpHarness();
await omp.start({ model: "openai/gpt-5" });
const session = omp.requireSession();
expect(omp.runtime().state.fastModeEnabled).toBe(false);
// Another model family has Fast on natively.
omp.runtime().setModelResult = { provider: "anthropic", id: "claude-x" };
omp.runtime().queueStateReports([
{
...omp.runtime().state,
model: omp.runtime().setModelResult,
fastModeEnabled: true,
fastModeActive: true,
},
]);
await session.setModel?.("anthropic/claude-x");
await session.applyOptions?.({ rpcTimeoutMs: 120000 });
expect(omp.runtime().setFastModeRequests).toEqual([]);
});
test("shows Fast only when OMP reports its state fields", async () => { test("shows Fast only when OMP reports its state fields", async () => {
const omp = new OmpHarness(); const omp = new OmpHarness();
await omp.start(); await omp.start();
@@ -649,11 +688,40 @@ describe("OMP agent client and session", () => {
} = omp.runtime().state; } = omp.runtime().state;
omp.runtime().state = olderState; omp.runtime().state = olderState;
await session.getRuntimeInfo(); await session.getRuntimeInfo();
expect(session.features).toEqual([]); expect(session.controls).toEqual([]);
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i); await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(/unavailable/i);
omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false }; omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
await session.getRuntimeInfo(); await session.getRuntimeInfo();
expect(session.features).toHaveLength(1); expect(session.controls).toHaveLength(1);
});
test("a Fast change OMP makes by itself replaces the held fast_mode option", async () => {
const omp = new OmpHarness();
await omp.start({ model: "openai/gpt-5" });
const session = omp.requireSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.applyOptions?.({ fast_mode: true });
// Another model family has its own Fast tier, which is unset.
omp.runtime().setModelResult = { provider: "anthropic", id: "claude-x" };
omp.runtime().queueStateReports([
{
...omp.runtime().state,
model: omp.runtime().setModelResult,
fastModeEnabled: false,
fastModeActive: false,
},
]);
await session.setModel?.("anthropic/claude-x");
expect(events.filter((event) => event.type === "options_changed")).toEqual([
{ type: "options_changed", provider: "omp", patch: { fast_mode: false } },
]);
// The options the manager now holds leave the runtime alone.
await session.applyOptions?.({ fast_mode: false });
expect(omp.runtime().setFastModeRequests).toEqual([true]);
}); });
test("switching model refreshes whether Fast applies", async () => { test("switching model refreshes whether Fast applies", async () => {
@@ -661,8 +729,8 @@ describe("OMP agent client and session", () => {
await omp.start({ model: "openrouter/google/gemini-3.8-flash" }); await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const session = omp.requireSession(); const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: true }; omp.runtime().fastModeResult = { enabled: true, active: true };
await session.setFeature?.("fast_mode", true); await session.applyOptions?.({ fast_mode: true });
expect(session.features).toEqual([expect.objectContaining({ value: true })]); expect(session.controls).toEqual([expect.objectContaining({ defaultValue: true })]);
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" }; omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
omp.runtime().queueStateReports([ omp.runtime().queueStateReports([
@@ -674,9 +742,9 @@ describe("OMP agent client and session", () => {
}, },
]); ]);
await session.setModel?.("openrouter/other/model"); await session.setModel?.("openrouter/other/model");
expect(session.features).toEqual([ expect(session.controls).toEqual([
expect.objectContaining({ expect.objectContaining({
value: true, defaultValue: true,
description: expect.stringMatching(/does not apply/i), description: expect.stringMatching(/does not apply/i),
}), }),
]); ]);
@@ -684,14 +752,14 @@ describe("OMP agent client and session", () => {
test("restores Fast from the initial OMP state on create and resume", async () => { test("restores Fast from the initial OMP state on create and resume", async () => {
const created = new OmpHarness(); const created = new OmpHarness();
await created.start({ featureValues: { fast_mode: true } }); await created.start({ providerOptions: { fast_mode: true } });
expect(created.runtime().setFastModeRequests).toEqual([true]); expect(created.runtime().setFastModeRequests).toEqual([true]);
expect(created.runtime().getStateRequestCount).toBe(1); expect(created.runtime().getStateRequestCount).toBe(1);
const resumed = new OmpHarness(); const resumed = new OmpHarness();
await resumed.resume( await resumed.resume(
{ user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } }, { user: { id: "user-1", text: "hello" }, assistant: { id: "assistant-1", text: "hi" } },
{ featureValues: { fast_mode: true } }, { providerOptions: { fast_mode: true } },
); );
expect(resumed.runtime().setFastModeRequests).toEqual([true]); expect(resumed.runtime().setFastModeRequests).toEqual([true]);
expect(resumed.runtime().getStateRequestCount).toBe(1); expect(resumed.runtime().getStateRequestCount).toBe(1);
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { mapCustomMessageToToolCall } from "../custom-message.js"; import { mapCustomMessageToToolCall } from "../custom-message.js";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
@@ -9,7 +10,7 @@ import stripAnsi from "strip-ansi";
import { import {
type AgentCapabilityFlags, type AgentCapabilityFlags,
type AgentClient, type AgentClient,
type AgentFeature, type AgentOptionControl,
type AgentLaunchContext, type AgentLaunchContext,
type AgentMetadata, type AgentMetadata,
type AgentMode, type AgentMode,
@@ -804,15 +805,16 @@ export class OmpAgentSession implements AgentSession {
return this.state.sessionId; return this.state.sessionId;
} }
get features(): AgentFeature[] { get controls(): AgentOptionControl[] {
if (!hasOmpFastMode(this.state)) return []; if (!hasOmpFastMode(this.state)) return [];
return [ return [
{ {
type: "toggle", type: "toggle",
id: "fast_mode", key: "fast_mode",
label: "Fast", label: "Fast",
icon: "zap", icon: "zap",
value: this.state.fastModeEnabled === true, // OMP owns fast mode; an unset option stands for what the runtime reports.
defaultValue: this.state.fastModeEnabled === true,
...(this.state.fastModeEnabled && !this.state.fastModeActive ...(this.state.fastModeEnabled && !this.state.fastModeActive
? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE } ? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE }
: {}), : {}),
@@ -820,17 +822,24 @@ export class OmpAgentSession implements AgentSession {
]; ];
} }
async setFeature(featureId: string, value: unknown): Promise<void> { async applyOptions(options: ProviderOptions): Promise<void> {
if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`); const requested = options.fast_mode;
if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean"); if (requested !== undefined && typeof requested !== "boolean") {
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable"); throw new Error("OMP fast mode requires a boolean");
const result = await this.runtimeSession.setFastMode(value); }
this.state = { // An absent option leaves Fast as the runtime has it.
...this.state, if (requested !== undefined && requested !== this.state.fastModeEnabled) {
fastModeEnabled: result.enabled, if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
fastModeActive: result.active, const result = await this.runtimeSession.setFastMode(requested);
}; this.state = {
this.config.featureValues = { ...this.config.featureValues, fast_mode: result.enabled }; ...this.state,
fastModeEnabled: result.enabled,
fastModeActive: result.active,
};
}
const { fast_mode: _previous, ...others } = this.config.providerOptions ?? {};
this.config.providerOptions =
requested === undefined ? others : { ...others, fast_mode: requested };
} }
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> { async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
@@ -2228,6 +2237,23 @@ export class OmpAgentSession implements AgentSession {
private async refreshState(): Promise<void> { private async refreshState(): Promise<void> {
this.state = await this.runtimeSession.getState(); this.state = await this.runtimeSession.getState();
this.followRuntimeFastMode();
}
/**
* OMP tracks Fast per model family, so switching models can change it. A held `fast_mode`
* option follows what the runtime reports; with no option held, the control's default shows it.
*/
private followRuntimeFastMode(): void {
const held = this.config.providerOptions?.fast_mode;
const reported = this.state.fastModeEnabled;
if (typeof held !== "boolean" || typeof reported !== "boolean" || held === reported) return;
this.config.providerOptions = { ...this.config.providerOptions, fast_mode: reported };
this.emit({
type: "options_changed",
provider: this.provider,
patch: { fast_mode: reported },
});
} }
private async refreshAfterTurn(finalUsage: Promise<void>): Promise<void> { private async refreshAfterTurn(finalUsage: Promise<void>): Promise<void> {
@@ -2295,7 +2321,7 @@ export class OmpAgentClient implements AgentClient {
config: AgentSessionConfig, config: AgentSessionConfig,
state: OmpSessionState, state: OmpSessionState,
): Promise<OmpSessionState> { ): Promise<OmpSessionState> {
const value = config.featureValues?.fast_mode; const value = config.providerOptions?.fast_mode;
if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled) if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled)
return state; return state;
const result = await runtimeSession.setFastMode(value); const result = await runtimeSession.setFastMode(value);
@@ -2530,7 +2556,7 @@ export class OmpAgentClient implements AgentClient {
} }
} }
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> { async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
return []; return [];
} }
@@ -15,6 +15,8 @@ export { OMP_MODES };
export const OmpProviderOptionsSchema = z export const OmpProviderOptionsSchema = z
.object({ .object({
// Composer control, not a runtime option: the session reads it and asks OMP to match.
fast_mode: z.boolean().optional(),
sessionDir: z.string().min(1).optional(), sessionDir: z.string().min(1).optional(),
rpcTimeoutMs: z.number().int().positive().optional(), rpcTimeoutMs: z.number().int().positive().optional(),
smolModel: z.string().min(1).optional(), smolModel: z.string().min(1).optional(),
@@ -108,33 +108,22 @@ describe("OpenCode auto_accept feature", () => {
expect(modes).toEqual([]); expect(modes).toEqual([]);
}); });
test("lists auto accept as a provider feature", async () => { test("lists auto accept as a provider control", async () => {
const { runtime } = mockOpenCodeClient(); const { runtime } = mockOpenCodeClient();
const client = new OpenCodeAgentClient(createTestLogger(), undefined, { const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime, serverManager: runtime,
createClient: runtime.createClient, createClient: runtime.createClient,
}); });
const enabledFeatures = await client.listFeatures({ const controls = await client.listControls({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
});
const legacyFeatures = await client.listFeatures({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
}); });
expect(enabledFeatures).toEqual([ expect(controls).toEqual([
expect.objectContaining({ expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
value: true,
}),
]); ]);
expect(legacyFeatures).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
}); });
test("keeps legacy full-access as an alias for build plus auto accept", async () => { test("keeps legacy full-access as an alias for build plus auto accept", async () => {
@@ -151,7 +140,7 @@ describe("OpenCode auto_accept feature", () => {
}); });
expect(await session.getCurrentMode()).toBe("build"); expect(await session.getCurrentMode()).toBe("build");
expect(session.features).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]); expect(session.controls).toEqual([expect.objectContaining({ key: "auto_accept" })]);
await session.run("Implement the change"); await session.run("Implement the change");
@@ -170,7 +159,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "opencode", provider: "opencode",
requestedMode: "full-access", requestedMode: "full-access",
featureValues: undefined, providerOptions: undefined,
parent: null, parent: null,
unattended: false, unattended: false,
availableModes: [ availableModes: [
@@ -178,7 +167,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "plan", label: "Plan" }, { id: "plan", label: "Plan" },
], ],
}), }),
).toEqual({ modeId: "build", featureValues: { auto_accept: true } }); ).toEqual({ modeId: "build", providerOptions: { auto_accept: true } });
}); });
test("inherits unattended callers as auto accept with OpenCode's default agent", () => { test("inherits unattended callers as auto accept with OpenCode's default agent", () => {
@@ -191,7 +180,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "opencode", provider: "opencode",
requestedMode: undefined, requestedMode: undefined,
featureValues: undefined, providerOptions: undefined,
parent: { parent: {
provider: "claude", provider: "claude",
modeId: "bypassPermissions", modeId: "bypassPermissions",
@@ -203,7 +192,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "plan", label: "Plan" }, { id: "plan", label: "Plan" },
], ],
}), }),
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } }); ).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
}); });
test("defaults unattended creation without a parent to auto accept with OpenCode's default agent", () => { test("defaults unattended creation without a parent to auto accept with OpenCode's default agent", () => {
@@ -213,7 +202,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "opencode", provider: "opencode",
requestedMode: undefined, requestedMode: undefined,
featureValues: undefined, providerOptions: undefined,
parent: null, parent: null,
unattended: true, unattended: true,
availableModes: [ availableModes: [
@@ -221,7 +210,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "plan", label: "Plan" }, { id: "plan", label: "Plan" },
], ],
}), }),
).toEqual({ modeId: undefined, featureValues: { auto_accept: true } }); ).toEqual({ modeId: undefined, providerOptions: { auto_accept: true } });
}); });
test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => { test("preserves the selected OpenCode agent when inheriting auto accept from an OpenCode parent", () => {
@@ -231,7 +220,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "opencode", provider: "opencode",
requestedMode: undefined, requestedMode: undefined,
featureValues: undefined, providerOptions: undefined,
parent: { parent: {
provider: "opencode", provider: "opencode",
modeId: "paseo-custom", modeId: "paseo-custom",
@@ -244,7 +233,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "paseo-custom", label: "Paseo Custom" }, { id: "paseo-custom", label: "Paseo Custom" },
], ],
}), }),
).toEqual({ modeId: "paseo-custom", featureValues: { auto_accept: true } }); ).toEqual({ modeId: "paseo-custom", providerOptions: { auto_accept: true } });
}); });
test("inherits auto accept from an OpenCode parent when the child chooses a mode", () => { test("inherits auto accept from an OpenCode parent when the child chooses a mode", () => {
@@ -254,7 +243,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({ client.resolveCreateConfig({
provider: "opencode", provider: "opencode",
requestedMode: "base", requestedMode: "base",
featureValues: undefined, providerOptions: undefined,
parent: { parent: {
provider: "opencode", provider: "opencode",
modeId: "orchestrator", modeId: "orchestrator",
@@ -267,7 +256,7 @@ describe("OpenCode auto_accept feature", () => {
{ id: "orchestrator", label: "Orchestrator" }, { id: "orchestrator", label: "Orchestrator" },
], ],
}), }),
).toEqual({ modeId: "base", featureValues: { auto_accept: true } }); ).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
}); });
test("auto-approves tool permissions when auto accept is enabled", async () => { test("auto-approves tool permissions when auto accept is enabled", async () => {
@@ -283,7 +272,7 @@ describe("OpenCode auto_accept feature", () => {
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}); });
session.subscribe((event) => receivedEvents.push(event)); session.subscribe((event) => receivedEvents.push(event));
@@ -301,6 +290,33 @@ describe("OpenCode auto_accept feature", () => {
await session.close(); await session.close();
}); });
test("removing auto_accept keeps auto-approving and the control shows it", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [toolPermissionEvent(), idleEvent()],
});
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
providerOptions: { auto_accept: true },
});
// The daemon passes effective options; with the key removed and no config default it is absent.
await session.applyOptions?.({});
await session.run("Run verification");
expect(openCodeClient.calls.permissionReply).toHaveLength(1);
expect(session.controls).toEqual([
expect.objectContaining({ key: "auto_accept", defaultValue: true }),
]);
await session.close();
});
test("keeps questions separate from auto accept tool approval", async () => { test("keeps questions separate from auto accept tool approval", async () => {
const { openCodeClient, runtime } = mockOpenCodeClient({ const { openCodeClient, runtime } = mockOpenCodeClient({
events: [questionEvent(), idleEvent()], events: [questionEvent(), idleEvent()],
@@ -314,7 +330,7 @@ describe("OpenCode auto_accept feature", () => {
const session = await client.createSession({ const session = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/tmp/project", cwd: "/tmp/project",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}); });
session.subscribe((event) => receivedEvents.push(event)); session.subscribe((event) => receivedEvents.push(event));
@@ -5274,7 +5274,7 @@ describe("OpenCode provider subagent contract", () => {
const parent = await client.createSession({ const parent = await client.createSession({
provider: "opencode", provider: "opencode",
cwd: "/workspace/repo", cwd: "/workspace/repo",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}); });
parent.subscribe(() => undefined); parent.subscribe(() => undefined);
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { validateProviderOptions } from "../provider-options.js"; import { validateProviderOptions } from "../provider-options.js";
import { import {
createOpencodeClient, createOpencodeClient,
@@ -25,7 +26,8 @@ import {
type AgentCapabilityFlags, type AgentCapabilityFlags,
type AgentClient, type AgentClient,
type AgentCreateSessionOptions, type AgentCreateSessionOptions,
type AgentFeature, type AgentOptionControl,
type AgentOptionToggleControl,
type AgentLaunchContext, type AgentLaunchContext,
type AgentMode, type AgentMode,
type AgentModelDefinition, type AgentModelDefinition,
@@ -58,6 +60,7 @@ import {
type ToolCallDetail, type ToolCallDetail,
type ToolCallTimelineItem, type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { isToggleOn } from "../option-controls.js";
import { importSessionFromPersistence } from "../provider-session-import.js"; import { importSessionFromPersistence } from "../provider-session-import.js";
import { import {
raceProviderRefreshAbort, raceProviderRefreshAbort,
@@ -232,15 +235,15 @@ const DEFAULT_MODES: AgentMode[] = [
]; ];
function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean { function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean {
return config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true; return config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true;
} }
function withOpenCodeAutoAcceptFeature( function withOpenCodeAutoAccept(
featureValues: Record<string, unknown> | undefined, providerOptions: ProviderOptions | undefined,
enabled: boolean, enabled: boolean,
): Record<string, unknown> { ): ProviderOptions {
return { return {
...featureValues, ...providerOptions,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled, [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
}; };
} }
@@ -259,25 +262,25 @@ function resolveOpenCodeCreateConfig(
const requestedMode = legacyFullAccess const requestedMode = legacyFullAccess
? OPENCODE_BUILD_MODE_ID ? OPENCODE_BUILD_MODE_ID
: (input.requestedMode ?? inheritedOpenCodeMode); : (input.requestedMode ?? inheritedOpenCodeMode);
const featureValues = const providerOptions =
legacyFullAccess || legacyFullAccess ||
(isUnattendedCreate && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined) (isUnattendedCreate && input.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
? withOpenCodeAutoAcceptFeature(input.featureValues, true) ? withOpenCodeAutoAccept(input.providerOptions, true)
: input.featureValues; : input.providerOptions;
if (inheritsUnattended && requestedMode === undefined) { if (inheritsUnattended && requestedMode === undefined) {
// Unattendedness for OpenCode is carried by auto_accept (set above), not // Unattendedness for OpenCode is carried by auto_accept (set above), not
// by any particular agent. Leave the mode unset so OpenCode uses its own // by any particular agent. Leave the mode unset so OpenCode uses its own
// default agent — `build` may not exist in the user's OpenCode config. // default agent — `build` may not exist in the user's OpenCode config.
return { modeId: undefined, featureValues }; return { modeId: undefined, providerOptions };
} }
const resolved = resolveDefaultAgentCreateConfig({ const resolved = resolveDefaultAgentCreateConfig({
...input, ...input,
requestedMode, requestedMode,
featureValues, providerOptions,
}); });
return { ...resolved, featureValues }; return { ...resolved, providerOptions };
} }
function isOpenCodeCreateConfigUnattended( function isOpenCodeCreateConfigUnattended(
@@ -285,26 +288,18 @@ function isOpenCodeCreateConfigUnattended(
): boolean { ): boolean {
return ( return (
isDefaultAgentCreateConfigUnattended(input) || isDefaultAgentCreateConfigUnattended(input) ||
input.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true || isToggleOn(OPENCODE_AUTO_ACCEPT_FEATURE_ID, input.config.providerOptions, input.controls)
input.features?.some(
(feature) =>
feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID &&
(feature.value === true || feature.value === "true"),
) === true
); );
} }
function buildOpenCodeAutoAcceptFeature(config: AgentSessionConfig): AgentFeature { const OPENCODE_AUTO_ACCEPT_CONTROL: AgentOptionToggleControl = {
return { type: "toggle",
type: "toggle", key: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
id: OPENCODE_AUTO_ACCEPT_FEATURE_ID, label: "Auto Accept",
label: "Auto Accept", description: "Automatically approves OpenCode tool permission prompts.",
description: "Automatically approves OpenCode tool permission prompts.", tooltip: "Auto accept permission prompts",
tooltip: "Auto accept permission prompts", icon: "shield-check",
icon: "shield-check", };
value: isOpenCodeAutoAcceptEnabled(config),
};
}
function buildOpenCodePermissionActions(): AgentPermissionAction[] { function buildOpenCodePermissionActions(): AgentPermissionAction[] {
return [ return [
@@ -682,10 +677,7 @@ function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConf
return { return {
...normalized, ...normalized,
modeId: OPENCODE_BUILD_MODE_ID, modeId: OPENCODE_BUILD_MODE_ID,
featureValues: { providerOptions: withOpenCodeAutoAccept(normalized.providerOptions, true),
...normalized.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
},
}; };
} }
@@ -1679,8 +1671,9 @@ export class OpenCodeAgentClient implements AgentClient {
} }
} }
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> { async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))]; this.assertConfig(config);
return [OPENCODE_AUTO_ACCEPT_CONTROL];
} }
async listImportableSessions( async listImportableSessions(
@@ -3565,8 +3558,8 @@ class OpenCodeAgentSession implements AgentSession {
return this.turnState.status === "running" ? this.turnState.turnId : null; return this.turnState.status === "running" ? this.turnState.turnId : null;
} }
get features(): AgentFeature[] { get controls(): AgentOptionControl[] {
return [buildOpenCodeAutoAcceptFeature(this.config)]; return [{ ...OPENCODE_AUTO_ACCEPT_CONTROL, defaultValue: this.autoAcceptEnabled }];
} }
async getRuntimeInfo(): Promise<AgentRuntimeInfo> { async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -4956,7 +4949,15 @@ class OpenCodeAgentSession implements AgentSession {
const normalizedModeId = normalizeOpenCodeModeId(modeId); const normalizedModeId = normalizeOpenCodeModeId(modeId);
if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) { if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_MODE_ID) {
this.currentMode = OPENCODE_BUILD_MODE_ID; this.currentMode = OPENCODE_BUILD_MODE_ID;
await this.setFeature(OPENCODE_AUTO_ACCEPT_FEATURE_ID, true); this.setAutoAccept(true);
this.notifySubscribers(
{
type: "options_changed",
provider: "opencode",
patch: { [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true },
},
null,
);
return; return;
} }
@@ -4964,17 +4965,15 @@ class OpenCodeAgentSession implements AgentSession {
this.config.modeId = normalizedModeId ?? undefined; this.config.modeId = normalizedModeId ?? undefined;
} }
async setFeature(featureId: string, value: unknown): Promise<void> { async applyOptions(options: ProviderOptions): Promise<void> {
if (featureId !== OPENCODE_AUTO_ACCEPT_FEATURE_ID) { // An absent option leaves auto-accept as it is.
throw new Error(`Unsupported OpenCode feature '${featureId}'`); const requested = options[OPENCODE_AUTO_ACCEPT_FEATURE_ID];
} if (requested !== undefined) this.setAutoAccept(requested === true);
}
const enabled = value === true; private setAutoAccept(enabled: boolean): void {
this.autoAcceptEnabled = enabled; this.autoAcceptEnabled = enabled;
this.config.featureValues = { this.config.providerOptions = withOpenCodeAutoAccept(this.config.providerOptions, enabled);
...this.config.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
} }
getPendingPermissions(): AgentPermissionRequest[] { getPendingPermissions(): AgentPermissionRequest[] {
@@ -10,6 +10,8 @@ const PermissionRuleSchema = z.union([
// OpenCode Config.permission, maintained against @opencode-ai/sdk 1.14.46. // OpenCode Config.permission, maintained against @opencode-ai/sdk 1.14.46.
export const OpenCodeProviderOptionsSchema = z export const OpenCodeProviderOptionsSchema = z
.object({ .object({
// Composer control: approves permission prompts without asking.
auto_accept: z.boolean().optional(),
permission: z permission: z
.union([ .union([
PermissionActionSchema, PermissionActionSchema,
@@ -100,11 +100,11 @@ test.each([
}); });
try { try {
await writeFile(script, initialSource); await writeFile(script, initialSource);
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe( expect((await client.listControls({ provider: "opencode", cwd: root }))[0]?.label).toBe(
"Auto Accept", "Auto Accept",
); );
await writeFile(script, 'console.log("opencode v2.0.10")'); await writeFile(script, 'console.log("opencode v2.0.10")');
expect((await client.listFeatures({ provider: "opencode", cwd: root }))[0]?.label).toBe( expect((await client.listControls({ provider: "opencode", cwd: root }))[0]?.label).toBe(
"Auto-accept", "Auto-accept",
); );
} finally { } finally {
@@ -202,7 +202,7 @@ test.each([
const legacy = new OpenCodeAgentClient(logger); const legacy = new OpenCodeAgentClient(logger);
const config = { provider: "opencode", cwd: root }; const config = { provider: "opencode", cwd: root };
try { try {
expect(await client.listFeatures(config)).toEqual(await legacy.listFeatures(config)); expect(await client.listControls(config)).toEqual(await legacy.listControls(config));
} finally { } finally {
await client.shutdown(); await client.shutdown();
await legacy.shutdown(); await legacy.shutdown();
@@ -221,8 +221,8 @@ test("selects v2 without probing the binary when an external server is configure
env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" }, env: { PASEO_OPENCODE_SERVER_URL: "http://127.0.0.1:4096" },
}); });
try { try {
const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" }); const controls = await client.listControls({ provider: "opencode", cwd: "/tmp/project" });
expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]); expect(controls.map((control) => control.label)).toEqual(["Auto-accept"]);
} finally { } finally {
await client.shutdown(); await client.shutdown();
} }
@@ -160,8 +160,8 @@ export class OpenCodeRuntimeClient implements AgentClient {
async listCommands(config: AgentSessionConfig) { async listCommands(config: AgentSessionConfig) {
return (await this.client()).listCommands(config); return (await this.client()).listCommands(config);
} }
async listFeatures(config: AgentSessionConfig) { async listControls(config: AgentSessionConfig) {
return (await this.client()).listFeatures(config); return (await this.client()).listControls(config);
} }
async listImportableSessions(options?: ListImportableSessionsOptions) { async listImportableSessions(options?: ListImportableSessionsOptions) {
return (await this.client()).listImportableSessions(options); return (await this.client()).listImportableSessions(options);
@@ -1,7 +1,7 @@
import { OpenCodeV2Session } from "./session.js"; import { OpenCodeV2Session } from "./session.js";
import { V2_CAPABILITIES } from "./capabilities.js"; import { V2_CAPABILITIES } from "./capabilities.js";
import { import {
features, controls,
permissionRules, permissionRules,
applyResumeOverrides, applyResumeOverrides,
requiresDedicatedV2Server, requiresDedicatedV2Server,
@@ -17,7 +17,7 @@ import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentClient, AgentClient,
AgentCreateSessionOptions, AgentCreateSessionOptions,
AgentFeature, AgentOptionControl,
AgentLaunchContext, AgentLaunchContext,
AgentPersistenceHandle, AgentPersistenceHandle,
AgentSession, AgentSession,
@@ -222,8 +222,8 @@ export class OpenCodeV2AgentClient implements AgentClient {
throw error; throw error;
} }
} }
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> { async listControls(): Promise<AgentOptionControl[]> {
return features(config); return controls;
} }
async listCommands(config: AgentSessionConfig) { async listCommands(config: AgentSessionConfig) {
const connection = await this.runtime.acquire(); const connection = await this.runtime.acquire();
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import { features, permissionRules, requiresDedicatedV2Server } from "./configuration.js"; import { controls, permissionRules, requiresDedicatedV2Server } from "./configuration.js";
describe("OpenCode v2 permission rules", () => { describe("OpenCode v2 permission rules", () => {
test("maps authored native permissions and exact injected MCP grants", () => { test("maps authored native permissions and exact injected MCP grants", () => {
@@ -49,17 +49,8 @@ describe("OpenCode v2 helper isolation", () => {
}); });
}); });
describe("OpenCode v2 features", () => { describe("OpenCode v2 controls", () => {
test("reports the auto-accept toggle from the session value", () => { test("describes the auto-accept toggle over the auto_accept option", () => {
expect(features({ provider: "opencode", cwd: "/tmp/project" })).toEqual([ expect(controls).toEqual([{ type: "toggle", key: "auto_accept", label: "Auto-accept" }]);
{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: false },
]);
expect(
features({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
}),
).toEqual([{ type: "toggle", id: "auto_accept", label: "Auto-accept", value: true }]);
}); });
}); });
@@ -2,8 +2,9 @@ import type { SessionInfo, PermissionRuleset } from "@opencode/client";
import type { V2Api } from "./api.js"; import type { V2Api } from "./api.js";
import type { import type {
AgentFeature,
AgentLaunchContext, AgentLaunchContext,
AgentOptionControl,
AgentOptionToggleControl,
AgentSessionConfig, AgentSessionConfig,
} from "../../../agent-sdk-types.js"; } from "../../../agent-sdk-types.js";
@@ -11,15 +12,15 @@ import { OpenCodeProviderOptionsSchema, buildOpenCodePermissionRules } from "../
import { modelRef } from "./mapping.js"; import { modelRef } from "./mapping.js";
export function features(config: AgentSessionConfig): AgentFeature[] { const autoAcceptControl: AgentOptionToggleControl = {
return [ type: "toggle",
{ key: "auto_accept",
type: "toggle", label: "Auto-accept",
id: "auto_accept", };
label: "Auto-accept", export const controls: AgentOptionControl[] = [autoAcceptControl];
value: config.featureValues?.["auto_accept"] === true, /** The controls of a running session, showing whether auto-accept is on. */
}, export function controlsFor(autoAccept: boolean): AgentOptionControl[] {
]; return [{ ...autoAcceptControl, defaultValue: autoAccept }];
} }
export function permissionRules(config: AgentSessionConfig): PermissionRuleset { export function permissionRules(config: AgentSessionConfig): PermissionRuleset {
const rules = const rules =
@@ -93,7 +93,7 @@ export class SessionPermissions {
}; };
this.pending.set(request.id, pending); this.pending.set(request.id, pending);
this.permissionOwners.set(request.id, sessionID); this.permissionOwners.set(request.id, sessionID);
if (!this.config.toolPolicy && this.config.featureValues?.["auto_accept"] === true) if (!this.config.toolPolicy && this.config.providerOptions?.["auto_accept"] === true)
await this.respondToPermission(request.id, { behavior: "allow" }); await this.respondToPermission(request.id, { behavior: "allow" });
else this.emit({ type: "permission_requested", provider: "opencode", request: pending }); else this.emit({ type: "permission_requested", provider: "opencode", request: pending });
} }
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { SessionChildren } from "./children.js"; import { SessionChildren } from "./children.js";
import { SessionTurns } from "./turns.js"; import { SessionTurns } from "./turns.js";
import { V2Timeline } from "./timeline.js"; import { V2Timeline } from "./timeline.js";
@@ -31,7 +32,7 @@ import { type V2Connection } from "./runtime.js";
import { modelRef, modesFromV2 } from "./mapping.js"; import { modelRef, modesFromV2 } from "./mapping.js";
import { V2_CAPABILITIES } from "./capabilities.js"; import { V2_CAPABILITIES } from "./capabilities.js";
import { features } from "./configuration.js"; import { controlsFor } from "./configuration.js";
import { commands } from "./commands.js"; import { commands } from "./commands.js";
import { messages } from "./history.js"; import { messages } from "./history.js";
import { SessionPermissions } from "./permissions.js"; import { SessionPermissions } from "./permissions.js";
@@ -118,8 +119,8 @@ export class OpenCodeV2Session implements AgentSession {
get id() { get id() {
return this.info.id; return this.info.id;
} }
get features() { get controls() {
return features(this.config); return controlsFor(this.config.providerOptions?.auto_accept === true);
} }
private get client() { private get client() {
return this.connection.client; return this.connection.client;
@@ -358,10 +359,12 @@ export class OpenCodeV2Session implements AgentSession {
this.info = await this.client.session.get({ sessionID: this.id }); this.info = await this.client.session.get({ sessionID: this.id });
this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant }); this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
} }
async setFeature(id: string, value: unknown) { async applyOptions(options: ProviderOptions) {
if (id !== "auto_accept" || typeof value !== "boolean") // An absent key leaves auto-accept as it is.
throw new Error("Unknown OpenCode feature"); const value = options["auto_accept"];
this.config.featureValues = { ...this.config.featureValues, [id]: value }; if (value === undefined) return;
if (typeof value !== "boolean") throw new Error("OpenCode auto_accept requires a boolean");
this.config.providerOptions = { ...this.config.providerOptions, auto_accept: value };
if (value && !this.config.toolPolicy) if (value && !this.config.toolPolicy)
for (const request of this.permissions.list()) for (const request of this.permissions.list())
if (request.kind !== "question") if (request.kind !== "question")
@@ -2961,12 +2961,12 @@ describe("PiRpcAgentClient", () => {
}, },
); );
test("lists no draft features without starting a Pi session", async () => { test("lists no draft controls without starting a Pi session", async () => {
const pi = new FakePi(); const pi = new FakePi();
const client = createClient(pi); const client = createClient(pi);
await expect( await expect(
client.listFeatures(createConfig({ model: "openrouter/test/model" })), client.listControls(createConfig({ model: "openrouter/test/model" })),
).resolves.toEqual([]); ).resolves.toEqual([]);
expect(pi.recordedLaunches).toHaveLength(0); expect(pi.recordedLaunches).toHaveLength(0);
@@ -10,7 +10,7 @@ import { z } from "zod";
import { import {
type AgentCapabilityFlags, type AgentCapabilityFlags,
type AgentClient, type AgentClient,
type AgentFeature, type AgentOptionControl,
type AgentLaunchContext, type AgentLaunchContext,
type AgentMetadata, type AgentMetadata,
type AgentMode, type AgentMode,
@@ -2828,7 +2828,7 @@ export class PiRpcAgentClient implements AgentClient {
} }
} }
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> { async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
return []; return [];
} }
@@ -18,6 +18,7 @@ import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-
import type { AgentManager } from "../agent-manager.js"; import type { AgentManager } from "../agent-manager.js";
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages"; import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../../daemon-config-store.js"; import type { DaemonConfigStore } from "../../daemon-config-store.js";
import { featuresFromControls } from "../option-controls.js";
import { import {
AgentFeatureSchema, AgentFeatureSchema,
AgentPermissionRequestPayloadSchema, AgentPermissionRequestPayloadSchema,
@@ -939,9 +940,6 @@ function createToolCatalog(
...(callerAgent.provider === resolvedProvider && callerAgent.config.providerOptions ...(callerAgent.provider === resolvedProvider && callerAgent.config.providerOptions
? { providerOptions: callerAgent.config.providerOptions } ? { providerOptions: callerAgent.config.providerOptions }
: {}), : {}),
...(callerAgent.config.featureValues
? { featureValues: callerAgent.config.featureValues }
: {}),
...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}), ...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}),
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}), ...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
}; };
@@ -1638,7 +1636,7 @@ function createToolCatalog(
cwd: resolvedArgs.cwd, cwd: resolvedArgs.cwd,
workspaceId: resolvedArgs.workspaceId, workspaceId: resolvedArgs.workspaceId,
thinking: parsedArgs.settings?.thinkingOptionId, thinking: parsedArgs.settings?.thinkingOptionId,
features: parsedArgs.settings?.features, providerOptions: parsedArgs.settings?.features,
labels: { labels: {
...parsedArgs.labels, ...parsedArgs.labels,
...(callerAgentId && !resolvedArgs.detached && options.serverId ...(callerAgentId && !resolvedArgs.detached && options.serverId
@@ -2290,9 +2288,7 @@ function createToolCatalog(
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId); await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
} }
if (settings?.features) { if (settings?.features) {
for (const [featureId, value] of Object.entries(settings.features)) { await agentManager.setAgentOptions(agentId, settings.features);
await agentManager.setAgentFeature(agentId, featureId, value);
}
} }
await updateAgentCommand({ agentManager }, { agentId, name, labels }); await updateAgentCommand({ agentManager }, { agentId, name, labels });
@@ -3070,8 +3066,8 @@ function createToolCatalog(
"List agent profiles: named provider/model/mode bundles a human configured for specific " + "List agent profiles: named provider/model/mode bundles a human configured for specific " +
"kinds of work. Read each profile's `notes` to pick the one that fits the task you're " + "kinds of work. Read each profile's `notes` to pick the one that fits the task you're " +
"delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " + "delegating, then copy its `provider`, `model`, `modeId`, `thinkingOptionId`, and " +
"`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " + "`options` into create_agent as `settings.features` (there is no `profile` parameter). " +
"list if none are configured.", "Returns an empty list if none are configured.",
inputSchema: {}, inputSchema: {},
outputSchema: { outputSchema: {
profiles: z.array(AgentProfileSchema), profiles: z.array(AgentProfileSchema),
@@ -3124,13 +3120,13 @@ function createToolCatalog(
throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`); throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`);
} }
const selectedModel = settings?.model ?? resolvedProviderModel.model; const selectedModel = settings?.model ?? resolvedProviderModel.model;
const features = await agentManager.listDraftFeatures({ const { controls, options: effectiveOptions } = await agentManager.listDraftControls({
provider: providerId, provider: providerId,
cwd: resolvedCwd, cwd: resolvedCwd,
...(settings?.modeId ? { modeId: settings.modeId } : {}), ...(settings?.modeId ? { modeId: settings.modeId } : {}),
...(selectedModel ? { model: selectedModel } : {}), ...(selectedModel ? { model: selectedModel } : {}),
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}), ...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
...(settings?.features ? { featureValues: settings.features } : {}), ...(settings?.features ? { providerOptions: settings.features } : {}),
}); });
return { return {
content: [], content: [],
@@ -3142,7 +3138,7 @@ function createToolCatalog(
status: summary.status, status: summary.status,
modes: summary.modes, modes: summary.modes,
selectedModel: selectedModel ?? null, selectedModel: selectedModel ?? null,
features, features: featuresFromControls(controls, effectiveOptions),
}), }),
}; };
}, },
@@ -119,6 +119,7 @@ const INBOUND_PERMISSION = {
list_available_providers_request: "daemon.read", list_available_providers_request: "daemon.read",
list_commands_request: "daemon.read", list_commands_request: "daemon.read",
list_provider_features_request: "daemon.read", list_provider_features_request: "daemon.read",
"provider.controls.list.request": "daemon.read",
list_provider_models_request: "daemon.read", list_provider_models_request: "daemon.read",
list_provider_modes_request: "daemon.read", list_provider_modes_request: "daemon.read",
list_terminals_request: "workspace.read", list_terminals_request: "workspace.read",
@@ -356,6 +357,7 @@ const OUTBOUND_PERMISSION = {
list_available_providers_response: "daemon.read", list_available_providers_response: "daemon.read",
list_commands_response: "daemon.read", list_commands_response: "daemon.read",
list_provider_features_response: "daemon.read", list_provider_features_response: "daemon.read",
"provider.controls.list.response": "daemon.read",
list_provider_models_response: "daemon.read", list_provider_models_response: "daemon.read",
list_provider_modes_response: "daemon.read", list_provider_modes_response: "daemon.read",
list_terminals_response: "workspace.read", list_terminals_response: "workspace.read",
@@ -784,211 +784,6 @@ test("old capabilities fail before execution and authentication errors recover t
expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId()); expect((await online(paseo)).hosts[0]!.serverId).toBe(b.daemon.getServerId());
}); });
async function updatableDestination() {
let features: { connectedHosts: boolean; hostMultiplexing: boolean } | undefined;
let holding = false;
const held: (() => void)[] = [];
let connections = 0;
const http = createServer();
const ws = new WebSocketServer({ server: http });
ws.on("connection", (socket) => {
connections++;
socket.on("message", (data) => {
const frame = JSON.parse(data.toString());
if (frame.type === "ping") socket.send(JSON.stringify({ type: "pong" }));
if (frame.type !== "hello") return;
const reply = () =>
socket.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_updated",
hostname: "updated",
version: "0.12.0",
...(features ? { features } : {}),
},
},
}),
);
if (holding) held.push(reply);
else reply();
});
});
http.listen(0, "127.0.0.1");
await once(http, "listening");
cleanup.push(async () => {
for (const socket of ws.clients) socket.terminate();
await new Promise<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((resolve) => http.close(() => resolve()));
});
const address = http.address();
if (!address || typeof address === "string") throw new Error("Missing destination port");
return {
port: address.port,
connections: () => connections,
hold: () => {
holding = true;
},
update: () => {
features = { connectedHosts: true, hostMultiplexing: true };
holding = false;
for (const reply of held.splice(0)) reply();
},
};
}
async function capabilityRetry(paseo: ReturnType<typeof createPaseoApi>): Promise<number> {
let retryAt = "";
await expect
.poll(
async () => {
const row = (await paseo.hosts.list()).hosts[0];
retryAt = row?.nextRetryAt ?? "";
return row;
},
{ timeout: 5000 },
)
.toMatchObject({
status: "offline",
nextRetryAt: expect.any(String),
error: { code: "unsupported_capability" },
});
return Date.parse(retryAt);
}
test("a destination updated past unsupported_capability keeps its guidance while retrying and comes online, while duplicate_host stays terminal", async () => {
const destination = await updatableDestination();
const a = await daemon();
const { paseo } = await api(a);
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
await capabilityRetry(paseo);
destination.hold();
await expect
.poll(async () => (await paseo.hosts.list()).hosts[0], { timeout: 5000 })
.toMatchObject({ status: "connecting", error: { code: "unsupported_capability" } });
await expect(paseo.hosts.client("srv_updated")).rejects.toMatchObject({
code: "unsupported_capability",
});
destination.update();
expect((await online(paseo)).hosts[0]).toMatchObject({
registrationId: host.registrationId,
serverId: "srv_updated",
nextRetryAt: null,
error: null,
});
await paseo.hosts.add({ connection: `ws://127.0.0.1:${destination.port}/ws` });
await expect
.poll(async () => (await paseo.hosts.list()).hosts[1], { timeout: 5000 })
.toMatchObject({ status: "error", nextRetryAt: null, error: { code: "duplicate_host" } });
});
test("disabling or removing a host cancels its pending capability retry", async () => {
const destination = await updatableDestination();
const a = await daemon();
const { paseo } = await api(a);
const host = await paseo.hosts.add({ connection: `127.0.0.1:${destination.port}` });
let retryAt = await capabilityRetry(paseo);
let connections = destination.connections();
await paseo.hosts.update(host.registrationId, { enabled: false });
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
expect(destination.connections()).toBe(connections);
await paseo.hosts.update(host.registrationId, { enabled: true });
retryAt = await capabilityRetry(paseo);
connections = destination.connections();
await paseo.hosts.remove(host.registrationId);
await new Promise((resolve) => setTimeout(resolve, retryAt - Date.now() + 500));
expect(destination.connections()).toBe(connections);
});
import {
createDaemonChannel,
exportPublicKey,
generateKeyPair,
openDaemonSecureChannel,
type Transport,
} from "@getpaseo/relay/e2ee";
test("a capability retry over a relay keeps the protected endpoint security it negotiated", async () => {
const keys = generateKeyPair();
const legacyReceived: unknown[] = [];
let connections = 0;
let retryClosed = false;
const http = createServer();
const ws = new WebSocketServer({ server: http });
ws.on("connection", (socket) => {
connections++;
const wire: Transport = {
send: (data) => socket.send(data),
close: (code, reason) => socket.close(code, reason),
onmessage: null,
onclose: null,
onerror: null,
};
socket.on("message", (data, isBinary) => {
const bytes = Buffer.from(data as Buffer);
wire.onmessage?.({
data: isBinary
? bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)
: bytes.toString(),
isBinary,
});
});
socket.on("close", (code, reason) => wire.onclose?.(code, reason.toString()));
if (connections > 1) {
socket.on("close", () => {
retryClosed = true;
});
void createDaemonChannel(wire, keys, {
onmessage: (data) => legacyReceived.push(data),
}).catch(() => {});
return;
}
const channel = openDaemonSecureChannel(wire, {
daemonKeyPair: keys,
events: {
onmessage: (data) => {
if (typeof data !== "string" || JSON.parse(data).type !== "hello") return;
void channel.then((secure) =>
secure.send(
JSON.stringify({
type: "session",
message: {
type: "status",
payload: {
status: "server_info",
serverId: "srv_relayed",
hostname: "relayed",
version: "0.12.0",
},
},
}),
),
);
},
},
});
void channel.catch(() => {});
});
http.listen(0, "127.0.0.1");
await once(http, "listening");
cleanup.push(async () => {
for (const socket of ws.clients) socket.terminate();
await new Promise<void>((resolve) => ws.close(() => resolve()));
await new Promise<void>((resolve) => http.close(() => resolve()));
});
const address = http.address();
if (!address || typeof address === "string") throw new Error("Missing relay port");
const a = await daemon();
const { paseo } = await api(a);
await paseo.hosts.add({
connection: `relay://127.0.0.1:${address.port}/srv_relayed?key=${encodeURIComponent(exportPublicKey(keys.publicKey))}`,
});
await capabilityRetry(paseo);
await expect.poll(() => retryClosed, { timeout: 15000 }).toBe(true);
expect(legacyReceived).toEqual([]);
});
test("an unreachable saved target becomes online after its daemon starts without being added again", async () => { test("an unreachable saved target becomes online after its daemon starts without being added again", async () => {
const reservation = createServer(); const reservation = createServer();
reservation.listen(0, "127.0.0.1"); reservation.listen(0, "127.0.0.1");
@@ -7,12 +7,10 @@ import { z } from "zod";
import { import {
createHostClient, createHostClient,
createHostChannelTransport, createHostChannelTransport,
createHostTransportFactory,
parseHostTarget, parseHostTarget,
HostTransportBudget, HostTransportBudget,
} from "@getpaseo/client/node"; } from "@getpaseo/client/node";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
import { import {
HostSummarySchema, HostSummarySchema,
HostAddSchema, HostAddSchema,
@@ -237,30 +235,23 @@ export class ConnectedHosts {
if (route.registrationId === id) if (route.registrationId === id)
route.close(code, "Host access was disabled, replaced or removed"); route.close(code, "Host access was disabled, replaced or removed");
} }
private start( private start(row: Saved): void {
row: Saved,
resume?: { failures: number; transports: DaemonTransportFactory },
): void {
if (!row.enabled || this.closed) return; if (!row.enabled || this.closed) return;
const transports =
resume?.transports ??
createHostTransportFactory(row.connection, this.budget, {
webSocketAgent: webSocketProxyAgent,
});
let stopped = false; let stopped = false;
let attempts = resume?.failures ?? 0; const client = createHostClient(
let retry: ReturnType<typeof setTimeout> | undefined; row.connection,
const client = createHostClient(row.connection, { {
clientId: `host-link-${this.self.serverId}-${row.registrationId}`, clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000, connectTimeoutMs: 10000,
transportFactory: transports, webSocketAgent: webSocketProxyAgent,
}); },
this.budget,
);
const live: Live = { const live: Live = {
client, client,
accepted: false, accepted: false,
stop: () => { stop: () => {
stopped = true; stopped = true;
clearTimeout(retry);
void client.close(); void client.close();
}, },
}; };
@@ -294,7 +285,7 @@ export class ConnectedHosts {
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true) if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
throw new HostError( throw new HostError(
"unsupported_capability", "unsupported_capability",
"Update the destination daemon to use connected hosts; retrying automatically.", "Update the destination daemon to use connected hosts.",
); );
// Pin and persist under the same writer as management to prevent discovery races. // Pin and persist under the same writer as management to prevent discovery races.
await this.mutate(async () => { await this.mutate(async () => {
@@ -313,22 +304,16 @@ export class ConnectedHosts {
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return; if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
Object.assign(row, next); Object.assign(row, next);
live.accepted = true; live.accepted = true;
attempts = 0;
this.changed(); this.changed();
}); });
} catch (error) { } catch (error) {
if (stopped) return; if (stopped) return;
row.status = "error";
row.error = connectionFailure(error, client.authFailureReason !== null) ?? { row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
code: "host_offline", code: "host_offline",
message: "Host connection failed", message: "Host connection failed",
}; };
if (terminalFailures.has(row.error.code)) { row.nextRetryAt = null;
row.status = "error";
row.nextRetryAt = null;
} else {
stopped = true;
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
}
this.changed(); this.changed();
await client.close(); await client.close();
} }
@@ -344,17 +329,18 @@ export class ConnectedHosts {
if (route.registrationId === row.registrationId) route.close(); if (route.registrationId === row.registrationId) route.close();
row.status = state.status === "connecting" ? "connecting" : "offline"; row.status = state.status === "connecting" ? "connecting" : "offline";
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null; row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
if (state.status === "disconnected") row.error =
row.error = connectionFailure( state.status === "disconnected"
Object.assign(new Error("Check the SSH configuration and daemon credentials"), { ? (connectionFailure(
code: state.failureCode, Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
}), code: state.failureCode,
client.authFailureReason !== null, }),
) ?? { client.authFailureReason !== null,
code: "host_offline", ) ?? {
message: "Cannot reach host. Check the connection; retrying automatically.", code: "host_offline",
}; message: "Cannot reach host. Check the connection; retrying automatically.",
else if (row.error?.code !== "unsupported_capability") row.error = null; })
: null;
if (row.error?.code === "authentication_failed") { if (row.error?.code === "authentication_failed") {
row.status = "error"; row.status = "error";
row.nextRetryAt = null; row.nextRetryAt = null;
@@ -365,18 +351,6 @@ export class ConnectedHosts {
}); });
void client.connect().catch(() => {}); void client.connect().catch(() => {});
} }
private scheduleAcceptanceRetry(
row: Saved,
failures: number,
transports: DaemonTransportFactory,
): ReturnType<typeof setTimeout> {
const delay = acceptanceRetryDelayMs(failures);
row.status = "offline";
row.nextRetryAt = new Date(Date.now() + delay).toISOString();
const retry = setTimeout(() => this.start(row, { failures: failures + 1, transports }), delay);
retry.unref();
return retry;
}
async openRoute(input: { async openRoute(input: {
serverId: string; serverId: string;
scopeId: string; scopeId: string;
@@ -557,20 +531,6 @@ export class ConnectedHosts {
} }
} }
const ACCEPTANCE_RETRY_BASE_MS = 1500;
const ACCEPTANCE_RETRY_MAX_MS = 30_000;
function acceptanceRetryDelayMs(failures: number): number {
return Math.min(ACCEPTANCE_RETRY_BASE_MS * 2 ** failures, ACCEPTANCE_RETRY_MAX_MS);
}
const terminalFailures = new Set([
"server_id_mismatch",
"self_host",
"duplicate_host",
"authentication_failed",
]);
function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] { function connectionFailure(error: unknown, authenticationFailed: boolean): HostSummary["error"] {
if (error instanceof HostError) return { code: error.code, message: error.message }; if (error instanceof HostError) return { code: error.code, message: error.message };
if (error instanceof Error && "code" in error && error.code === "authentication_failed") if (error instanceof Error && "code" in error && error.code === "authentication_failed")
@@ -180,6 +180,56 @@ test("DaemonClient uploads file bytes to daemon temp storage", async () => {
} }
}); });
test("agent.config.apply sets an option that has no control and the next snapshot reports it", async () => {
const clients = createTestAgentClients();
const codex = clients.codex as AgentClient;
const applied: unknown[] = [];
// Records what reaches the provider session; behavior is the fake client's.
const recordingCodex: AgentClient = Object.create(codex, {
createSession: {
value: async (...args: Parameters<AgentClient["createSession"]>) => {
const session = await codex.createSession(...args);
const apply = session.applyOptions?.bind(session);
session.applyOptions = async (options) => {
applied.push(options);
await apply?.(options);
};
return session;
},
},
});
const daemon = await createTestPaseoDaemon({
agentClients: { ...clients, codex: recordingCodex },
});
const client = new DaemonClient({
url: `ws://127.0.0.1:${daemon.port}/ws`,
appVersion: "0.1.82",
});
try {
await client.connect();
await client.fetchAgents({ subscribe: {} });
const agent = await client.createAgent({
provider: "codex",
cwd: tmpCwd(),
title: "Options agent",
modeId: "full-access",
model: "gpt-5.4-mini",
});
expect(agent.options?.sandbox_mode).toBeUndefined();
await client.applyAgentConfig(agent.id, { options: { sandbox_mode: "read-only" } });
expect(applied).toEqual([expect.objectContaining({ sandbox_mode: "read-only" })]);
const next = await client.fetchAgent(agent.id);
expect(next?.agent.options).toMatchObject({ sandbox_mode: "read-only" });
expect(next?.agent.controls?.map((control) => control.key)).not.toContain("sandbox_mode");
} finally {
await client.close();
await daemon.close();
}
});
test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => { test("createAgent with background initialPrompt returns a running snapshot before turn completion", async () => {
const daemon = await createTestPaseoDaemon(); const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({ const client = new DaemonClient({
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, describe, expect, test } from "vitest"; import { afterEach, describe, expect, test } from "vitest";
import { DaemonConfigStore, applyMutableProviderConfigToOverrides } from "./daemon-config-store.js"; import {
DaemonConfigStore,
applyMutableProviderConfigToOverrides,
toWireDaemonConfig,
} from "./daemon-config-store.js";
import { loadConfig } from "./config.js"; import { loadConfig } from "./config.js";
import { loadPersistedConfig } from "./persisted-config.js"; import { loadPersistedConfig } from "./persisted-config.js";
import { readSpeechSettings } from "./speech/speech-settings.js"; import { readSpeechSettings } from "./speech/speech-settings.js";
@@ -196,13 +200,83 @@ describe("DaemonConfigStore", () => {
model: "claude-opus-5", model: "claude-opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
featureValues: { webSearch: true }, options: { webSearch: true },
notes: "Use for components, layout and design tokens.", notes: "Use for components, layout and design tokens.",
}, },
]); ]);
expect(store.get().agentProfiles).toHaveLength(1); expect(store.get().agentProfiles).toHaveLength(1);
}); });
test("a profile written with featureValues is stored as options, and the config API answers with both", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
});
// The unchanged app sends back the `options` it read plus the `featureValues` it edited.
await store.patch({
agentProfiles: [
{
id: "p",
name: "P",
provider: "claude",
options: { fast_mode: false },
featureValues: { fast_mode: true },
},
],
});
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
]);
expect(store.get().agentProfiles).toEqual([
{ id: "p", name: "P", provider: "claude", options: { fast_mode: true } },
]);
expect(toWireDaemonConfig(store.get()).agentProfiles).toEqual([
{
id: "p",
name: "P",
provider: "claude",
options: { fast_mode: true },
featureValues: { fast_mode: true },
},
]);
});
test("a profile stored with featureValues loads as options", () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
const store = new DaemonConfigStore(paseoHome, {
relay: { enabled: false },
mcp: { injectIntoAgents: false },
browserTools: { enabled: false },
providers: {},
metadataGeneration: { providers: [] },
autoArchiveAfterMerge: false,
enableTerminalAgentHooks: false,
appendSystemPrompt: "",
agentProfiles: [
{
id: "legacy",
name: "Legacy",
provider: "codex",
featureValues: { service_tier: "fast" },
},
],
});
expect(store.get().agentProfiles).toEqual([
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
]);
});
test("patch replaces the whole agent profile list rather than merging entries", async () => { test("patch replaces the whole agent profile list rather than merging entries", async () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-")); const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome); tempDirs.push(paseoHome);
@@ -1274,6 +1348,35 @@ describe("DaemonConfigStore reload", () => {
expect(store.get().agentProfiles).toBeUndefined(); expect(store.get().agentProfiles).toBeUndefined();
}); });
test("reload folds a profile file written with featureValues into options, and saving it persists options", async () => {
const { paseoHome, store, persisted } = createReloadableStore();
writeConfig(paseoHome, {
...persisted,
daemon: {
...persisted.daemon,
agentProfiles: [
{
id: "legacy",
name: "Legacy",
provider: "codex",
featureValues: { service_tier: "fast" },
},
],
},
});
await store.reload();
expect(store.get().agentProfiles).toEqual([
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
]);
// The app reads the profile and writes the list back on its next edit.
await store.patch({ agentProfiles: store.get().agentProfiles });
expect(loadPersistedConfig(paseoHome).daemon?.agentProfiles).toEqual([
{ id: "legacy", name: "Legacy", provider: "codex", options: { service_tier: "fast" } },
]);
});
test("reports a launch-controlled edit without changing live state", async () => { test("reports a launch-controlled edit without changing live state", async () => {
const { paseoHome, store, persisted } = createReloadableStore({ const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"], overrideControlledPaths: ["daemon.relay.enabled"],
@@ -5,6 +5,11 @@ import {
type PersistedConfig, type PersistedConfig,
} from "./persisted-config.js"; } from "./persisted-config.js";
import { ProviderOverrideSchema } from "./agent/provider-launch-config.js"; import { ProviderOverrideSchema } from "./agent/provider-launch-config.js";
import {
foldProfileFeatureValues,
withLegacyProfileFeatureValues,
} from "./agent/option-controls.js";
import type { AgentProfile } from "@getpaseo/protocol/agent-profile";
import { import {
MutableDaemonConfigSchema, MutableDaemonConfigSchema,
MutableDaemonConfigPatchSchema, MutableDaemonConfigPatchSchema,
@@ -168,6 +173,24 @@ function omitProvidersFromOverrides(
return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined; return Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined;
} }
/** Profiles are held and persisted with `options` only. */
function foldConfigProfiles<T extends { agentProfiles?: AgentProfile[] }>(
config: T,
source: "stored" | "client write",
): T {
if (!config.agentProfiles) return config;
return {
...config,
agentProfiles: config.agentProfiles.map((profile) => foldProfileFeatureValues(profile, source)),
};
}
/** The config API boundary: the unchanged app reads profile options from `featureValues`. */
export function toWireDaemonConfig(config: MutableDaemonConfig): MutableDaemonConfig {
if (!config.agentProfiles) return config;
return { ...config, agentProfiles: config.agentProfiles.map(withLegacyProfileFeatureValues) };
}
function getValueAtPath(config: MutableDaemonConfig, path: string): unknown { function getValueAtPath(config: MutableDaemonConfig, path: string): unknown {
return path return path
.split(".") .split(".")
@@ -380,7 +403,7 @@ export class DaemonConfigStore {
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger); this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
this.lastKnownPersisted = this.startupPersisted; this.lastKnownPersisted = this.startupPersisted;
this.current = MutableDaemonConfigSchema.parse({ this.current = MutableDaemonConfigSchema.parse({
...initial, ...foldConfigProfiles(initial, "stored"),
relay: initial.relay ?? { enabled: true }, relay: initial.relay ?? { enabled: true },
...this.readSpeech(this.startupPersisted), ...this.readSpeech(this.startupPersisted),
}); });
@@ -418,7 +441,8 @@ export class DaemonConfigStore {
"Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.", "Relay is controlled by a daemon launch override. Remove PASEO_RELAY_ENABLED or the relay CLI flag before changing it here.",
); );
} }
const { removeProviders = [], ...patch } = parsedPatch; const { removeProviders = [], ...submittedPatch } = parsedPatch;
const patch = foldConfigProfiles(submittedPatch, "client write");
const { features: speechPatch, ...configPatch } = patch; const { features: speechPatch, ...configPatch } = patch;
const removedProviders = Array.from(new Set(removeProviders)); const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch); const merged = deepMerge(this.current, configPatch);
@@ -437,7 +461,16 @@ export class DaemonConfigStore {
const configChanged = !isEqualValue(this.current, next); const configChanged = !isEqualValue(this.current, next);
// A replaced API key leaves the speech view unchanged, so a speech patch always persists. // A replaced API key leaves the speech view unchanged, so a speech patch always persists.
if (!configChanged && removedProviders.length === 0 && speechPatch === undefined) { // Profiles held folded in memory may still be on disk with `featureValues`; saving them writes `options`.
const profilesStaleOnDisk =
patch.agentProfiles !== undefined &&
!isEqualValue(patch.agentProfiles, this.lastKnownPersisted.daemon?.agentProfiles);
if (
!configChanged &&
removedProviders.length === 0 &&
speechPatch === undefined &&
!profilesStaleOnDisk
) {
return this.current; return this.current;
} }
@@ -495,7 +528,7 @@ export class DaemonConfigStore {
// Plugin source changes require the plugin lifecycle operation or a daemon // Plugin source changes require the plugin lifecycle operation or a daemon
// restart. The global switch is independently reloadable. // restart. The global switch is independently reloadable.
const desired = MutableDaemonConfigSchema.parse({ const desired = MutableDaemonConfigSchema.parse({
...resolved.mutable, ...foldConfigProfiles(resolved.mutable, "stored"),
plugins: this.current.plugins, plugins: this.current.plugins,
...this.readSpeech(persisted), ...this.readSpeech(persisted),
}); });
@@ -86,7 +86,10 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
cwd: response.payload.agent.cwd, cwd: response.payload.agent.cwd,
}); });
expect(JSON.stringify(response.payload.agent)).not.toContain(bearer); expect(JSON.stringify(response.payload.agent)).not.toContain(bearer);
expect(JSON.stringify(response.payload.agent)).not.toContain("private-build"); // Provider options are not secret and appear in the snapshot's `options`.
expect(response.payload.agent.options).toMatchObject({
sandbox_workspace_write: { writable_roots: ["/var/cache/private-build"] },
});
expect(JSON.stringify(response.payload.agent)).not.toContain("finish_execution"); expect(JSON.stringify(response.payload.agent)).not.toContain("finish_execution");
const update = hub.hubMessages().find((message) => message.type === "hub.execution.agent.update"); const update = hub.hubMessages().find((message) => message.type === "hub.execution.agent.update");
@@ -104,7 +107,6 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
cwd: update.payload.agent.cwd, cwd: update.payload.agent.cwd,
}); });
expect(JSON.stringify(update.payload.agent)).not.toContain(bearer); expect(JSON.stringify(update.payload.agent)).not.toContain(bearer);
expect(JSON.stringify(update.payload.agent)).not.toContain("private-build");
expect(JSON.stringify(update.payload.agent)).not.toContain("finish_execution"); expect(JSON.stringify(update.payload.agent)).not.toContain("finish_execution");
}); });
@@ -23,7 +23,6 @@ export interface HubExecutionAgentCreateInput {
model?: string; model?: string;
modeId?: string; modeId?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
providerOptions?: ProviderOptions; providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy; toolPolicy?: ToolPolicy;
env?: Record<string, string>; env?: Record<string, string>;
@@ -193,7 +192,6 @@ export class DaemonExecutions implements HubExecutionAgents {
cwd: input.cwd, cwd: input.cwd,
mode: input.modeId, mode: input.modeId,
thinking: input.thinkingOptionId, thinking: input.thinkingOptionId,
features: input.featureValues,
env: input.env, env: input.env,
...(input.mcpServers || input.providerOptions || input.toolPolicy ...(input.mcpServers || input.providerOptions || input.toolPolicy
? { ? {
@@ -13,6 +13,7 @@ import {
} from "../agent/provider-options.js"; } from "../agent/provider-options.js";
import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js"; import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js";
import { foldFeatureValues } from "../agent/option-controls.js";
interface HubExecutionControllerOptions { interface HubExecutionControllerOptions {
agents: HubExecutionAgents; agents: HubExecutionAgents;
@@ -167,8 +168,8 @@ export class HubExecutionController {
model: message.model, model: message.model,
modeId: message.modeId, modeId: message.modeId,
thinkingOptionId: message.thinkingOptionId, thinkingOptionId: message.thinkingOptionId,
featureValues: message.featureValues, // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
providerOptions: message.providerOptions, providerOptions: foldFeatureValues(message).providerOptions,
toolPolicy: message.toolPolicy, toolPolicy: message.toolPolicy,
env: message.env, env: message.env,
mcpServers: message.mcpServers, mcpServers: message.mcpServers,
@@ -440,7 +440,7 @@ type AcceptedCreate = Omit<
const providerCatalog = { const providerCatalog = {
async resolveCreateConfig(input) { async resolveCreateConfig(input) {
return { modeId: input.requestedMode, featureValues: input.featureValues }; return { modeId: input.requestedMode, providerOptions: input.providerOptions };
}, },
} satisfies CreateAgentCommandDependencies["providerSnapshotManager"]; } satisfies CreateAgentCommandDependencies["providerSnapshotManager"];
@@ -73,7 +73,6 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
modeId: record.lastModeId ?? record.config?.modeId ?? undefined, modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined, model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined, thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
featureValues: record.config?.featureValues ?? undefined,
providerOptions: record.config?.providerOptions ?? undefined, providerOptions: record.config?.providerOptions ?? undefined,
toolPolicy: record.config?.toolPolicy ?? undefined, toolPolicy: record.config?.toolPolicy ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined, systemPrompt: record.config?.systemPrompt ?? undefined,
@@ -95,7 +94,6 @@ export function buildSessionConfig(
modeId: overrides.modeId, modeId: overrides.modeId,
model: overrides.model, model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId, thinkingOptionId: overrides.thinkingOptionId,
featureValues: overrides.featureValues,
providerOptions: overrides.providerOptions, providerOptions: overrides.providerOptions,
toolPolicy: overrides.toolPolicy, toolPolicy: overrides.toolPolicy,
systemPrompt: overrides.systemPrompt, systemPrompt: overrides.systemPrompt,
@@ -88,7 +88,7 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreat
async resolveCreateConfig(input) { async resolveCreateConfig(input) {
return { return {
modeId: input.unattended ? input.requestedMode : "interactive", modeId: input.unattended ? input.requestedMode : "interactive",
featureValues: input.featureValues, providerOptions: input.providerOptions,
}; };
}, },
}; };
@@ -1911,7 +1911,7 @@ describe("ScheduleService", () => {
}); });
return { return {
modeId: input.unattended ? "bypassPermissions" : "interactive", modeId: input.unattended ? "bypassPermissions" : "interactive",
featureValues: input.featureValues, providerOptions: input.providerOptions,
}; };
}, },
}, },
@@ -1980,9 +1980,9 @@ describe("ScheduleService", () => {
}); });
return { return {
modeId: input.unattended ? "build" : "interactive", modeId: input.unattended ? "build" : "interactive",
featureValues: input.unattended providerOptions: input.unattended
? { ...input.featureValues, auto_accept: true } ? { ...input.providerOptions, auto_accept: true }
: input.featureValues, : input.providerOptions,
}; };
}, },
}, },
@@ -2010,7 +2010,7 @@ describe("ScheduleService", () => {
expect(inspected.runs[0]?.error).toBeNull(); expect(inspected.runs[0]?.error).toBeNull();
expect(createdConfigs[0]).toMatchObject({ expect(createdConfigs[0]).toMatchObject({
modeId: "build", modeId: "build",
featureValues: { auto_accept: true }, providerOptions: { auto_accept: true },
}); });
}); });
@@ -2052,7 +2052,7 @@ describe("ScheduleService", () => {
}); });
return { return {
modeId: input.requestedMode, modeId: input.requestedMode,
featureValues: { ...input.featureValues, resolved: true }, providerOptions: { ...input.providerOptions, resolved: true },
}; };
}, },
}, },
@@ -2099,11 +2099,13 @@ describe("ScheduleService", () => {
model: "test-model", model: "test-model",
modeId: "stored-mode", modeId: "stored-mode",
thinkingOptionId: "think-hard", thinkingOptionId: "think-hard",
// Stored schedules written before options carry `featureValues`; launch folds them.
providerOptions: { providerOptions: {
allowedTools: ["Read"], allowedTools: ["Read"],
sandbox: { enabled: true, network: { allowLocalBinding: true } }, sandbox: { enabled: true, network: { allowLocalBinding: true } },
auto_accept: true,
resolved: true,
}, },
featureValues: { auto_accept: true, resolved: true },
systemPrompt: "Stay concise.", systemPrompt: "Stay concise.",
mcpServers: { mcpServers: {
docs: { docs: {
@@ -15,6 +15,7 @@ import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
import { ScheduleStore } from "./store.js"; import { ScheduleStore } from "./store.js";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js"; import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
import { foldFeatureValues } from "../agent/option-controls.js";
import type { import type {
CreateScheduleInput, CreateScheduleInput,
ScheduleExecutionResult, ScheduleExecutionResult,
@@ -998,7 +999,6 @@ export class ScheduleService {
}, },
mode: config.modeId, mode: config.modeId,
thinking: config.thinkingOptionId, thinking: config.thinkingOptionId,
features: config.featureValues,
unattended: true, unattended: true,
promptFailure: "return-error", promptFailure: "return-error",
notifyOnFinish: false, notifyOnFinish: false,
@@ -1098,8 +1098,8 @@ function buildScheduleAgentConfig(
model: config.model, model: config.model,
thinkingOptionId: config.thinkingOptionId, thinkingOptionId: config.thinkingOptionId,
title: config.title, title: config.title,
providerOptions: config.providerOptions, // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
featureValues: config.featureValues, providerOptions: foldFeatureValues(config).providerOptions,
systemPrompt: config.systemPrompt, systemPrompt: config.systemPrompt,
mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"], mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"],
}; };
+2 -2
View File
@@ -5816,7 +5816,7 @@ describe("agent config setters", () => {
const messages: SessionOutboundMessage[] = []; const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({ const session = createSessionForTest({
messages, messages,
agentManager: liveAgentManager({ setAgentFeature: vi.fn().mockResolvedValue(undefined) }), agentManager: liveAgentManager({ setAgentOptions: vi.fn().mockResolvedValue(undefined) }),
}); });
await session.handleMessage({ await session.handleMessage({
@@ -5840,7 +5840,7 @@ describe("agent config setters", () => {
const session = createSessionForTest({ const session = createSessionForTest({
messages, messages,
agentManager: liveAgentManager({ agentManager: liveAgentManager({
setAgentFeature: vi.fn().mockRejectedValue(new Error("feature boom")), setAgentOptions: vi.fn().mockRejectedValue(new Error("feature boom")),
}), }),
}); });
+10 -6
View File
@@ -80,7 +80,7 @@ import {
createWorkspaceScriptsService, createWorkspaceScriptsService,
type WorkspaceScriptsService, type WorkspaceScriptsService,
} from "./session/workspace-scripts/workspace-scripts-service.js"; } from "./session/workspace-scripts/workspace-scripts-service.js";
import type { DaemonConfigStore } from "./daemon-config-store.js"; import { toWireDaemonConfig, type DaemonConfigStore } from "./daemon-config-store.js";
import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js"; import { releaseWorkspaceServicePortPlan } from "./workspace-service-port-registry.js";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket"; import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
@@ -1036,7 +1036,7 @@ export class Session {
supportsProviderSnapshotReferences: () => supportsProviderSnapshotReferences: () =>
this.supports(CLIENT_CAPS.providerSnapshotReferences), this.supports(CLIENT_CAPS.providerSnapshotReferences),
listProviderAvailability: () => this.agentManager.listProviderAvailability(), listProviderAvailability: () => this.agentManager.listProviderAvailability(),
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config), listDraftControls: (config) => this.agentManager.listDraftControls(config),
}, },
providerSnapshotManager, providerSnapshotManager,
logger: this.sessionLogger, logger: this.sessionLogger,
@@ -1066,8 +1066,7 @@ export class Session {
setMode: async (agentId, modeId) => setMode: async (agentId, modeId) =>
(await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice, (await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice,
setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId), setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId),
setFeature: (agentId, featureId, value) => setOptions: (agentId, patch) => agentManager.setAgentOptions(agentId, patch),
agentManager.setAgentFeature(agentId, featureId, value),
setThinking: (agentId, thinkingOptionId) => setThinking: (agentId, thinkingOptionId) =>
agentManager.setAgentThinkingOption(agentId, thinkingOptionId), agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
}, },
@@ -2950,7 +2949,10 @@ export class Session {
case "get_daemon_config_request": case "get_daemon_config_request":
this.emit({ this.emit({
type: "get_daemon_config_response", type: "get_daemon_config_response",
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() }, payload: {
requestId: msg.requestId,
config: toWireDaemonConfig(this.daemonConfigStore.get()),
},
}); });
return undefined; return undefined;
case "daemon.get_status.request": case "daemon.get_status.request":
@@ -2974,7 +2976,7 @@ export class Session {
type: "set_daemon_config_response", type: "set_daemon_config_response",
payload: { payload: {
requestId: msg.requestId, requestId: msg.requestId,
config, config: toWireDaemonConfig(config),
}, },
}); });
}); });
@@ -3181,6 +3183,8 @@ export class Session {
return this.providerCatalogSession.handleListProviderModesRequest(msg); return this.providerCatalogSession.handleListProviderModesRequest(msg);
case "list_provider_features_request": case "list_provider_features_request":
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg); return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
case "provider.controls.list.request":
return this.providerCatalogSession.handleProviderControlsListRequest(msg);
case "list_available_providers_request": case "list_available_providers_request":
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg); return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
case "get_providers_snapshot_request": case "get_providers_snapshot_request":
@@ -12,7 +12,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
readonly loadedAgentIds: string[] = []; readonly loadedAgentIds: string[] = [];
readonly modeCalls: Array<{ agentId: string; modeId: string }> = []; readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = []; readonly modelCalls: Array<{ agentId: string; modelId: string | null }> = [];
readonly featureCalls: Array<{ agentId: string; featureId: string; value: unknown }> = []; readonly optionsCalls: Array<{ agentId: string; patch: Record<string, unknown> }> = [];
readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = []; readonly thinkingCalls: Array<{ agentId: string; thinkingOptionId: string | null }> = [];
/** Cross-operation ordering, which the per-operation arrays above cannot show. */ /** Cross-operation ordering, which the per-operation arrays above cannot show. */
readonly callLog: string[] = []; readonly callLog: string[] = [];
@@ -39,9 +39,9 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
if (this.failWith) throw this.failWith; if (this.failWith) throw this.failWith;
} }
async setFeature(agentId: string, featureId: string, value: unknown): Promise<void> { async setOptions(agentId: string, patch: Record<string, unknown>): Promise<void> {
this.featureCalls.push({ agentId, featureId, value }); this.optionsCalls.push({ agentId, patch });
this.callLog.push(`feature:${featureId}`); this.callLog.push("options");
if (this.failWith) throw this.failWith; if (this.failWith) throw this.failWith;
} }
@@ -206,7 +206,7 @@ describe("AgentConfigSession", () => {
}); });
}); });
test("set feature: forwards the feature value and emits an accepted response with no notice", async () => { test("set feature: maps the feature value to a one-key options patch and emits an accepted response with no notice", async () => {
const { subsystem, emitted, operations } = makeSubsystem(); const { subsystem, emitted, operations } = makeSubsystem();
await subsystem.handleSetAgentFeatureRequest({ await subsystem.handleSetAgentFeatureRequest({
@@ -217,9 +217,7 @@ describe("AgentConfigSession", () => {
requestId: "req-1", requestId: "req-1",
}); });
expect(operations.featureCalls).toEqual([ expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { web_search: true } }]);
{ agentId: "agent-1", featureId: "web_search", value: true },
]);
expect(emitted).toEqual([ expect(emitted).toEqual([
{ {
type: "set_agent_feature_response", type: "set_agent_feature_response",
@@ -309,7 +307,7 @@ describe("AgentConfigSession", () => {
}); });
}); });
test("apply: sets model before thinking and features last, in one accepted response", async () => { test("apply: sets model before thinking and options last, in one accepted response", async () => {
const { subsystem, emitted, operations } = makeSubsystem(); const { subsystem, emitted, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({ await subsystem.handleAgentConfigApplyRequest({
@@ -319,18 +317,16 @@ describe("AgentConfigSession", () => {
modelId: "opus-5", modelId: "opus-5",
modeId: "plan", modeId: "plan",
thinkingOptionId: "high", thinkingOptionId: "high",
featureValues: { webSearch: true }, options: { webSearch: true },
}, },
requestId: "req-1", requestId: "req-1",
}); });
expect(operations.callLog).toEqual(["model", "mode", "thinking", "feature:webSearch"]); expect(operations.callLog).toEqual(["model", "mode", "thinking", "options"]);
expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]); expect(operations.modelCalls).toEqual([{ agentId: "agent-1", modelId: "opus-5" }]);
expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]); expect(operations.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]); expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
expect(operations.featureCalls).toEqual([ expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { webSearch: true } }]);
{ agentId: "agent-1", featureId: "webSearch", value: true },
]);
expect(operations.loadedAgentIds).toEqual(["agent-1"]); expect(operations.loadedAgentIds).toEqual(["agent-1"]);
expect(emitted).toEqual([ expect(emitted).toEqual([
{ {
@@ -346,6 +342,39 @@ describe("AgentConfigSession", () => {
]); ]);
}); });
test("apply: passes an options patch through, null included, in one call", async () => {
const { subsystem, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { options: { fast_mode: true, service_tier: null } },
requestId: "req-1",
});
expect(operations.optionsCalls).toEqual([
{ agentId: "agent-1", patch: { fast_mode: true, service_tier: null } },
]);
});
test("apply: folds legacy featureValues into the options patch, options winning", async () => {
const { subsystem, operations } = makeSubsystem();
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: {
featureValues: { fast_mode: true, service_tier: "fast" },
options: { fast_mode: false },
},
requestId: "req-1",
});
expect(operations.optionsCalls).toEqual([
{ agentId: "agent-1", patch: { fast_mode: false, service_tier: "fast" } },
]);
});
test("apply: leaves omitted fields alone", async () => { test("apply: leaves omitted fields alone", async () => {
const { subsystem, operations } = makeSubsystem(); const { subsystem, operations } = makeSubsystem();
@@ -381,11 +410,11 @@ describe("AgentConfigSession", () => {
await subsystem.handleAgentConfigApplyRequest({ await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request", type: "agent.config.apply.request",
agentId: "agent-1", agentId: "agent-1",
config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } }, config: { modeId: "plan", thinkingOptionId: "high", options: { webSearch: true } },
requestId: "req-1", requestId: "req-1",
}); });
expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]); expect(operations.callLog).toEqual(["mode", "thinking", "options"]);
expect(emitted).toEqual([ expect(emitted).toEqual([
{ {
type: "agent.config.apply.response", type: "agent.config.apply.response",
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from "uuid";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils"; import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import type { AgentConfigApply } from "@getpaseo/protocol/messages"; import type { AgentConfigApply } from "@getpaseo/protocol/messages";
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js"; import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
import type { ProviderOptionsPatch } from "../../agent/option-controls.js";
import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js"; import type { SessionInboundMessage, SessionOutboundMessage } from "../../messages.js";
/** /**
@@ -22,13 +23,13 @@ export interface AgentConfigSessionHost {
* The per-agent config mutations this subsystem drives. The shell adapts these * The per-agent config mutations this subsystem drives. The shell adapts these
* onto the AgentManager and loads a closed agent before mutation (mode still * onto the AgentManager and loads a closed agent before mutation (mode still
* routes through setAgentModeCommand); tests wire an in-memory fake. Mode and * routes through setAgentModeCommand); tests wire an in-memory fake. Mode and
* thinking yield a provider notice; model and feature do not. * thinking yield a provider notice; model and options do not.
*/ */
export interface AgentConfigOperations { export interface AgentConfigOperations {
ensureLoaded(agentId: string): Promise<void>; ensureLoaded(agentId: string): Promise<void>;
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>; setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
setModel(agentId: string, modelId: string | null): Promise<void>; setModel(agentId: string, modelId: string | null): Promise<void>;
setFeature(agentId: string, featureId: string, value: unknown): Promise<void>; setOptions(agentId: string, patch: ProviderOptionsPatch): Promise<void>;
setThinking( setThinking(
agentId: string, agentId: string,
thinkingOptionId: string | null, thinkingOptionId: string | null,
@@ -112,8 +113,9 @@ export class AgentConfigSession {
logLabel: "set_agent_feature_request", logLabel: "set_agent_feature_request",
logFields: { agentId, featureId, value, requestId }, logFields: { agentId, featureId, value, requestId },
failureText: "Failed to set agent feature", failureText: "Failed to set agent feature",
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. A feature is a one-key options patch.
run: async () => { run: async () => {
await this.operations.setFeature(agentId, featureId, value); await this.operations.setOptions(agentId, { [featureId]: value });
return undefined; return undefined;
}, },
emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }), emitResponse: (payload) => this.host.emit({ type: "set_agent_feature_response", payload }),
@@ -152,7 +154,7 @@ export class AgentConfigSession {
/** /**
* Model first, because a thinking option only means anything against the model * Model first, because a thinking option only means anything against the model
* that offers it; features last so they settle on top of the rest. An omitted * that offers it; options last so they settle on top of the rest. An omitted
* field is left alone. The first notice produced is the one the client sees — * field is left alone. The first notice produced is the one the client sees —
* later steps still run, so a notice never truncates the bundle. * later steps still run, so a notice never truncates the bundle.
* *
@@ -189,8 +191,10 @@ export class AgentConfigSession {
const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId); const thinkingNotice = await this.operations.setThinking(agentId, config.thinkingOptionId);
notice ??= thinkingNotice; notice ??= thinkingNotice;
} }
for (const [featureId, value] of Object.entries(config.featureValues ?? {})) { // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. `featureValues` is an options patch.
await this.operations.setFeature(agentId, featureId, value); const optionsPatch = { ...config.featureValues, ...config.options };
if (Object.keys(optionsPatch).length > 0) {
await this.operations.setOptions(agentId, optionsPatch);
} }
return notice; return notice;
@@ -13,7 +13,7 @@ import {
type ProviderSnapshot, type ProviderSnapshot,
type ProviderSnapshotTransition, type ProviderSnapshotTransition,
} from "../../agent/provider-snapshot-manager.js"; } from "../../agent/provider-snapshot-manager.js";
import type { ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js"; import type { AgentSessionConfig, ProviderSnapshotEntry } from "../../agent/agent-sdk-types.js";
import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec"; import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void; type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void;
@@ -58,7 +58,7 @@ function makeSubsystem(options: MakeOptions = {}) {
}, },
supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false, supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false,
listProviderAvailability: async () => [], listProviderAvailability: async () => [],
listDraftFeatures: async () => [], listDraftControls: async () => ({ controls: [], options: {} }),
...options.host, ...options.host,
}; };
const providerSnapshotManager = createStub<ProviderSnapshotManager>({ const providerSnapshotManager = createStub<ProviderSnapshotManager>({
@@ -296,10 +296,100 @@ describe("ProviderCatalogSession", () => {
}); });
}); });
it("lists controls and effective options for a draft config", async () => {
const drafts: AgentSessionConfig[] = [];
const { subsystem, emitted } = makeSubsystem({
host: {
listDraftControls: async (config) => {
drafts.push(config);
return {
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
options: { fast_mode: true },
};
},
},
});
await subsystem.handleProviderControlsListRequest({
type: "provider.controls.list.request",
requestId: "c1",
draftConfig: {
provider: "claude",
cwd: "/tmp/project",
model: "claude-opus-4-8",
providerOptions: { fast_mode: true },
},
});
expect(drafts).toEqual([
{
provider: "claude",
cwd: "/tmp/project",
model: "claude-opus-4-8",
providerOptions: { fast_mode: true },
},
]);
expect(findByType(emitted, "provider.controls.list.response")?.payload).toEqual({
provider: "claude",
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
options: { fast_mode: true },
error: null,
fetchedAt: expect.any(String),
requestId: "c1",
});
});
it("surfaces a controls failure inline, not as an rpc_error", async () => {
const { subsystem, emitted } = makeSubsystem({
host: {
listDraftControls: async () => {
throw new Error("controls probe failed");
},
},
});
await subsystem.handleProviderControlsListRequest({
type: "provider.controls.list.request",
requestId: "c2",
draftConfig: { provider: "codex", cwd: "/tmp/project" },
});
expect(findByType(emitted, "rpc_error")).toBeUndefined();
expect(findByType(emitted, "provider.controls.list.response")?.payload.error).toBe(
"controls probe failed",
);
});
it("answers list_provider_features_request from controls, folding legacy featureValues", async () => {
const drafts: AgentSessionConfig[] = [];
const { subsystem, emitted } = makeSubsystem({
host: {
listDraftControls: async (config) => {
drafts.push(config);
return {
controls: [{ type: "toggle", key: "fast_mode", label: "Fast" }],
options: { fast_mode: true },
};
},
},
});
await subsystem.handleListProviderFeaturesRequest({
type: "list_provider_features_request",
requestId: "f0",
draftConfig: { provider: "claude", cwd: "/tmp/project", featureValues: { fast_mode: true } },
});
expect(drafts[0]?.providerOptions).toEqual({ fast_mode: true });
expect(findByType(emitted, "list_provider_features_response")?.payload.features).toEqual([
{ type: "toggle", id: "fast_mode", label: "Fast", value: true },
]);
});
it("surfaces a feature-list failure inline, not as an rpc_error", async () => { it("surfaces a feature-list failure inline, not as an rpc_error", async () => {
const { subsystem, emitted } = makeSubsystem({ const { subsystem, emitted } = makeSubsystem({
host: { host: {
listDraftFeatures: async () => { listDraftControls: async () => {
throw new Error("feature probe failed"); throw new Error("feature probe failed");
}, },
}, },
@@ -379,7 +469,7 @@ it("announces shared content without retransmitting models or hashing discovery
}, },
supportsProviderSnapshotReferences: () => references, supportsProviderSnapshotReferences: () => references,
listProviderAvailability: async () => [], listProviderAvailability: async () => [],
listDraftFeatures: async () => [], listDraftControls: async () => ({ controls: [], options: {} }),
}, },
}); });
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
@@ -13,12 +13,14 @@ import {
} from "../../agent/provider-snapshot-manager.js"; } from "../../agent/provider-snapshot-manager.js";
import { import {
filterSelectableAgentModels, filterSelectableAgentModels,
type AgentFeature, type AgentOptionControl,
type AgentProvider, type AgentProvider,
type AgentSessionConfig, type AgentSessionConfig,
type ProviderSnapshotEntry, type ProviderSnapshotEntry,
} from "../../agent/agent-sdk-types.js"; } from "../../agent/agent-sdk-types.js";
import type { ProviderAvailability } from "../../agent/agent-manager.js"; import type { ProviderAvailability } from "../../agent/agent-manager.js";
import { featuresFromControls, foldFeatureValues } from "../../agent/option-controls.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { expandTilde } from "../../../utils/path.js"; import { expandTilde } from "../../../utils/path.js";
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any // COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
@@ -49,7 +51,9 @@ export interface ProviderCatalogSessionHost {
supportsProviderSnapshotReferences(): boolean; supportsProviderSnapshotReferences(): boolean;
publishSnapshot(project: () => SessionOutboundMessage | null): void; publishSnapshot(project: () => SessionOutboundMessage | null): void;
listProviderAvailability(): Promise<ProviderAvailability[]>; listProviderAvailability(): Promise<ProviderAvailability[]>;
listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>; listDraftControls(
config: AgentSessionConfig,
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }>;
} }
export interface ProviderCatalogSessionOptions { export interface ProviderCatalogSessionOptions {
@@ -340,29 +344,59 @@ export class ProviderCatalogSession {
model?: string; model?: string;
thinkingOptionId?: string; thinkingOptionId?: string;
featureValues?: Record<string, unknown>; featureValues?: Record<string, unknown>;
providerOptions?: ProviderOptions;
}): AgentSessionConfig { }): AgentSessionConfig {
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
const { providerOptions } = foldFeatureValues(draftConfig);
return { return {
provider: draftConfig.provider, provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd), cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}), ...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}), ...(draftConfig.model ? { model: draftConfig.model } : {}),
...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}), ...(draftConfig.thinkingOptionId ? { thinkingOptionId: draftConfig.thinkingOptionId } : {}),
...(draftConfig.featureValues ? { featureValues: draftConfig.featureValues } : {}), ...(providerOptions ? { providerOptions } : {}),
}; };
} }
async handleProviderControlsListRequest(
msg: Extract<SessionInboundMessage, { type: "provider.controls.list.request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
const provider = msg.draftConfig.provider;
try {
const { controls, options } = await this.host.listDraftControls(
this.buildDraftAgentSessionConfig(msg.draftConfig),
);
this.host.emit({
type: "provider.controls.list.response",
payload: { provider, controls, options, error: null, fetchedAt, requestId: msg.requestId },
});
} catch (error) {
this.logger.error(
{ err: error, provider, draftConfig: msg.draftConfig },
`Failed to list controls for ${provider}`,
);
this.host.emit({
type: "provider.controls.list.response",
payload: { provider, error: getErrorMessage(error), fetchedAt, requestId: msg.requestId },
});
}
}
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Answered from controls.
async handleListProviderFeaturesRequest( async handleListProviderFeaturesRequest(
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>, msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
): Promise<void> { ): Promise<void> {
const fetchedAt = new Date().toISOString(); const fetchedAt = new Date().toISOString();
try { try {
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig); const { controls, options } = await this.host.listDraftControls(
const features = await this.host.listDraftFeatures(sessionConfig); this.buildDraftAgentSessionConfig(msg.draftConfig),
);
this.host.emit({ this.host.emit({
type: "list_provider_features_response", type: "list_provider_features_response",
payload: { payload: {
provider: msg.draftConfig.provider, provider: msg.draftConfig.provider,
features, features: featuresFromControls(controls, options),
error: null, error: null,
fetchedAt, fetchedAt,
requestId: msg.requestId, requestId: msg.requestId,
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs"; import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
import { appendFile, mkdir, readFile } from "node:fs/promises"; import { appendFile, mkdir, readFile } from "node:fs/promises";
@@ -6,7 +7,7 @@ import path from "node:path";
import type { import type {
AgentCapabilityFlags, AgentCapabilityFlags,
AgentClient, AgentClient,
AgentFeature, AgentOptionControl,
AgentLaunchContext, AgentLaunchContext,
AgentMode, AgentMode,
AgentModelDefinition, AgentModelDefinition,
@@ -379,14 +380,13 @@ class FakeAgentSession implements AgentSession {
return this.providerName; return this.providerName;
} }
get features(): AgentFeature[] { get controls(): AgentOptionControl[] {
return [ return [
{ {
type: "toggle", type: "toggle",
id: TEST_FEATURE_ID, key: TEST_FEATURE_ID,
label: "Test feature", label: "Test feature",
description: "Deterministic provider feature used by MCP integration tests.", description: "Deterministic provider feature used by MCP integration tests.",
value: this.config.featureValues?.[TEST_FEATURE_ID] === true,
}, },
]; ];
} }
@@ -873,11 +873,8 @@ class FakeAgentSession implements AgentSession {
this.config.modeId = modeId; this.config.modeId = modeId;
} }
async setFeature(featureId: string, value: unknown): Promise<void> { async applyOptions(options: ProviderOptions): Promise<void> {
this.config.featureValues = { this.config.providerOptions = { ...this.config.providerOptions, ...options };
...this.config.featureValues,
[featureId]: value,
};
} }
getPendingPermissions(): AgentPermissionRequest[] { getPendingPermissions(): AgentPermissionRequest[] {
@@ -223,7 +223,7 @@ export function createProviderSnapshotManagerStub(): {
const listModes = vi.fn<ProviderSnapshotManager["listModes"]>(async () => []); const listModes = vi.fn<ProviderSnapshotManager["listModes"]>(async () => []);
const resolveCreateConfig = vi.fn<ProviderSnapshotManager["resolveCreateConfig"]>(async () => ({ const resolveCreateConfig = vi.fn<ProviderSnapshotManager["resolveCreateConfig"]>(async () => ({
modeId: undefined, modeId: undefined,
featureValues: undefined, providerOptions: undefined,
})); }));
const resolveDefaultModel = vi.fn<ProviderSnapshotManager["resolveDefaultModel"]>( const resolveDefaultModel = vi.fn<ProviderSnapshotManager["resolveDefaultModel"]>(
async () => undefined, async () => undefined,
@@ -2007,6 +2007,7 @@ export class VoiceAssistantWebSocketServer {
usageSetting: true, usageSetting: true,
usageSources: true, usageSources: true,
ownedSubscriptions: true, ownedSubscriptions: true,
providerOptionControls: true,
binaryChunkingV1: true, binaryChunkingV1: true,
agentRequestReceipts: true, agentRequestReceipts: true,
workspaceRequestReceipts: true, workspaceRequestReceipts: true,
@@ -176,6 +176,13 @@
}, },
"additionalProperties": {} "additionalProperties": {}
}, },
"options": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"notes": { "notes": {
"type": "string" "type": "string"
} }
+44 -39
View File
@@ -680,46 +680,51 @@ test("macOS jobs use latest runners while preserving release architectures", ()
assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/); assert.match(release, /runner: macos-26-intel\n\s+electron_arch: x64/);
}); });
test("Nix desktop packaging resolves the workspace-local builder", () => { test("macOS signing repair keeps certificate and keychain passwords separate", () => {
const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8"); const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1]; const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
assert.ok(invocation, "Nix invokes the desktop packaging tool"); assert.ok(match, "the release workflow repairs the pinned signing tool");
const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-")); const script = match[1].replace(/^ /gm, "");
const original = [
"return await importCerts(keychainFile, certPaths, cscPasswords);",
"async function importCerts(keychainFile, paths, keyPasswords) {",
'["import", paths[i], "-P", password]',
'["set-key-partition-list", "-S", "apple-tool:,apple:", "-s", "-k", password, keychainFile]',
"}",
].join("\n");
const directory = mkdtempSync(join(tmpdir(), "signing-repair-"));
try { try {
const desktop = join(directory, "packages/desktop"); const packageDirectory = join(directory, "node_modules/app-builder-lib");
mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true }); mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
mkdirSync(join(desktop, "node_modules/electron-builder")); const manifest = join(packageDirectory, "package.json");
writeFileSync( const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
join(desktop, "node_modules/electron-builder/package.json"), for (const [version, source, succeeds] of [
JSON.stringify({ ["26.8.1", original, true],
name: "electron-builder", ["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
version: "1.0.0", ["26.18.0", original, false],
bin: { "electron-builder": "../.bin/electron-builder" }, ]) {
}), writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
); writeFileSync(sourcePath, source);
writeFileSync( const result = spawnSync(process.execPath, ["-e", script], {
join(directory, "package.json"), cwd: directory,
JSON.stringify({ private: true, workspaces: ["packages/desktop"] }), encoding: "utf8",
); });
writeFileSync( const actual = readFileSync(sourcePath, "utf8");
join(desktop, "package.json"), if (succeeds) {
JSON.stringify({ assert.equal(result.status, 0, result.stderr);
name: "desktop-fixture", assert.ok(
private: true, actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
devDependencies: { "electron-builder": "1.0.0" }, );
}), assert.ok(
); actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
writeFileSync( );
join(desktop, "node_modules/.bin/electron-builder"), assert.ok(actual.includes('["import", paths[i], "-P", password]'));
'#!/bin/sh\nprintf "workspace-builder\\n"\n', assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
{ mode: 0o755 }, } else {
); assert.notEqual(result.status, 0);
const result = spawnSync("sh", ["-c", `${invocation} --version`], { assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
cwd: desktop, }
encoding: "utf8", }
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), "workspace-builder");
} finally { } finally {
rmSync(directory, { recursive: true, force: true }); rmSync(directory, { recursive: true, force: true });
} }