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
91 changed files with 5193 additions and 2592 deletions
+26
View File
@@ -135,6 +135,32 @@ jobs:
env:
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
shell: bash
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`.
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
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.
operations; requesting this feature gives update guidance.
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
+1 -1
View File
@@ -127,7 +127,7 @@ buildNpmPackage {
# The Nix output is not a distributable DMG, so leave it unsigned and
# disable the hardened runtime that requires a matching signature.
CSC_IDENTITY_AUTO_DISCOVERY=false \
npm exec --offline -- electron-builder \
../../node_modules/.bin/electron-builder \
--config electron-builder.yml \
--dir \
--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: "DA2", bytes: "\x1b[>c" },
{ name: "DA3", bytes: "\x1b[=c" },
{ name: "XTVERSION", bytes: "\x1b[>q" },
{ name: "DSR-5", bytes: "\x1b[5n" },
{ 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: "DECRQSS", bytes: "\x1bP$qm\x1b\\" },
{ name: "OSC-10-foreground-color", bytes: "\x1b]10;?\x07" },
{ name: "OSC-11-background-color", bytes: "\x1b]11;?\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({ 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({ prefix: "?", final: "n" }, () => true);
terminal.parser.registerCsiHandler({ final: "R" }, () => true);
@@ -656,7 +655,6 @@ export class TerminalEmulatorRuntime {
{ prefix: "?", intermediates: "$", final: "p" },
() => true,
);
terminal.parser.registerDcsHandler({ intermediates: "$", final: "q" }, () => true);
for (const code of [10, 11, 12]) {
terminal.parser.registerOscHandler(code, (data) => data.trim() === "?");
}
+3 -4
View File
@@ -204,16 +204,15 @@ export function createHostTransportFactory(
}
export function createHostClient(
connection: string,
config: Omit<DaemonClientConfig, "url"> & HostTransportOptions,
config: Omit<DaemonClientConfig, "url" | "transportFactory"> & HostTransportOptions,
budget?: HostTransportBudget,
): DaemonClient {
const target = parseHostTarget(connection);
const { webSocketAgent, transportFactory, ...clientConfig } = config;
const { webSocketAgent, ...clientConfig } = config;
return new DaemonClient({
...clientConfig,
url: "ws://host-connector/ws",
...(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/ws": "^8.5.14",
"electron": "44.2.0",
"electron-builder": "26.18.0",
"electron-builder": "26.8.1",
"typescript": "5.9.3",
"unzip-crx-3": "^0.2.0",
"vitest": "^4.1.6",
+3
View File
@@ -21,7 +21,10 @@ export const AgentProfileSchema = z
model: z.string().optional(),
modeId: z.string().optional(),
thinkingOptionId: z.string().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read as `options`.
featureValues: z.record(z.string(), z.unknown()).optional(),
/** Provider options applied when the profile creates an agent. */
options: z.record(z.string(), z.unknown()).optional(),
/** Free text, surfaced to orchestrating agents by the `list_profiles` MCP tool. */
notes: z.string().optional(),
})
+45
View File
@@ -154,6 +154,47 @@ export interface 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 {
[capability: string]: boolean | undefined;
supportsStreaming: boolean;
@@ -523,6 +564,10 @@ export interface AgentSessionConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
/**
* @deprecated COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
* Folded into `providerOptions` at the daemon's edge; the daemon never reads it internally.
*/
featureValues?: Record<string, unknown>;
title?: string | null;
providerOptions?: ProviderOptions;
+73
View File
@@ -361,6 +361,40 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [
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({
provider: AgentProviderSchema,
id: z.string(),
@@ -490,6 +524,7 @@ const AgentSessionConfigSchema = z.object({
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
featureValues: z.record(z.string(), z.unknown()).optional(),
title: z.string().trim().min(1).max(MAX_EXPLICIT_AGENT_TITLE_CHARS).optional().nullable(),
providerOptions: ProviderOptionsSchema.optional(),
@@ -866,7 +901,11 @@ export const AgentSnapshotPayloadSchema = z.object({
cwd: z.string(),
workspaceId: z.string().optional(),
model: z.string().nullable(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Built from controls + options.
features: z.array(AgentFeatureSchema).optional(),
/** Effective provider options: `config.json` defaults deep-merged with the agent's own. */
options: ProviderOptionsSchema.optional(),
controls: z.array(AgentOptionControlSchema).optional(),
thinkingOptionId: z.string().nullable().optional(),
effectiveThinkingOptionId: z.string().nullable().optional(),
createdAt: z.string(),
@@ -2055,7 +2094,10 @@ export const AgentConfigApplySchema = z.object({
modelId: z.string().nullable().optional(),
modeId: z.string().optional(),
thinkingOptionId: z.string().nullable().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Maps to an options patch.
featureValues: z.record(z.string(), z.unknown()).optional(),
/** Patch of the agent's own options: replaces top-level keys; `null` removes the key. */
options: ProviderOptionsSchema.optional(),
});
export type AgentConfigApply = z.infer<typeof AgentConfigApplySchema>;
@@ -2971,7 +3013,9 @@ const ListCommandsDraftConfigSchema = z.object({
modeId: z.string().optional(),
model: z.string().optional(),
thinkingOptionId: z.string().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
featureValues: z.record(z.string(), z.unknown()).optional(),
providerOptions: ProviderOptionsSchema.optional(),
});
export const ListProviderFeaturesRequestMessageSchema = z.object({
@@ -2980,6 +3024,12 @@ export const ListProviderFeaturesRequestMessageSchema = z.object({
requestId: z.string(),
});
export const ProviderControlsListRequestMessageSchema = z.object({
type: z.literal("provider.controls.list.request"),
draftConfig: ListCommandsDraftConfigSchema,
requestId: z.string(),
});
export const ListCommandsRequestSchema = z.object({
type: z.literal("list_commands_request"),
agentId: z.string(),
@@ -3157,6 +3207,7 @@ export const HubExecutionAgentCreateRequestSchema = z.object({
model: z.string().optional(),
modeId: z.string().optional(),
thinkingOptionId: z.string().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
featureValues: z.record(z.string(), z.unknown()).optional(),
providerOptions: ProviderOptionsSchema.optional(),
toolPolicy: ToolPolicySchema.optional(),
@@ -3357,6 +3408,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsRequestMessageSchema,
ListProviderModesRequestMessageSchema,
ListProviderFeaturesRequestMessageSchema,
ProviderControlsListRequestMessageSchema,
ListAvailableProvidersRequestMessageSchema,
GetProvidersSnapshotRequestMessageSchema,
RefreshProvidersSnapshotRequestMessageSchema,
@@ -3836,6 +3888,7 @@ export const ServerInfoStatusPayloadSchema = z
selectiveAgentTimeline: z.boolean().optional(),
explicitEventSubscriptions: z.boolean().optional(),
ownedSubscriptions: z.boolean().optional(),
providerOptionControls: z.boolean().optional(),
binaryChunkingV1: z.boolean().optional(),
workspaceSetupObservation: z.boolean().optional(),
workspaceSortTimes: z.boolean().optional(),
@@ -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({
type: z.literal("list_provider_features_response"),
payload: z.object({
@@ -7195,6 +7261,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
ListProviderModelsResponseMessageSchema,
ListProviderModesResponseMessageSchema,
ListProviderFeaturesResponseMessageSchema,
ProviderControlsListResponseMessageSchema,
ListAvailableProvidersResponseSchema,
GetProvidersSnapshotResponseMessageSchema,
ProvidersSnapshotUpdateMessageSchema,
@@ -7449,6 +7516,12 @@ export type ListProviderModelsRequestMessage = z.infer<
typeof ListProviderModelsRequestMessageSchema
>;
export type ListProviderModesRequestMessage = z.infer<typeof ListProviderModesRequestMessageSchema>;
export type ProviderControlsListRequestMessage = z.infer<
typeof ProviderControlsListRequestMessageSchema
>;
export type ProviderControlsListResponseMessage = z.infer<
typeof ProviderControlsListResponseMessageSchema
>;
export type ListProviderFeaturesRequestMessage = z.infer<
typeof ListProviderFeaturesRequestMessageSchema
>;
+1
View File
@@ -34,6 +34,7 @@ export const ScheduleTargetSchema = z.discriminatedUnion("type", [
isolation: z.enum(["local", "worktree"]).optional(),
title: z.string().trim().min(1).nullable().optional(),
providerOptions: z.record(z.string(), z.unknown()).optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Folded into providerOptions.
featureValues: z.record(z.string(), z.unknown()).optional(),
systemPrompt: z.string().optional(),
mcpServers: z.record(z.string(), z.unknown()).optional(),
@@ -1,3 +1,4 @@
import { mergeProviderOptions } from "./provider-options.js";
import { createIdleRuntimeCleanup } from "./idle-runtime-cleanup/index.js";
import {
prepareAgentMessage,
@@ -6,7 +7,7 @@ import {
} from "./agent-messages/index.js";
import { expect, test, vi, onTestFinished } from "vitest";
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 { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@@ -23,6 +24,7 @@ import { AgentStorage } from "./agent-storage.js";
import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import { toAgentPayload } from "./agent-projections.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { projectTimelineRows } from "./timeline-projection.js";
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { startAgentRun } from "./agent-prompt.js";
@@ -38,7 +40,7 @@ import type {
import type {
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentOptionControl,
AgentLaunchContext,
AgentPromptInput,
AgentProvider,
@@ -182,13 +184,8 @@ class RecordingTimelineStore implements AgentTimelineStore {
}
}
function createFeature(args: { id: string; label: string; value: boolean }): AgentFeature {
return {
type: "toggle",
id: args.id,
label: args.label,
value: args.value,
};
function createControl(args: { key: string; label: string }): AgentOptionControl {
return { type: "toggle", key: args.key, label: args.label };
}
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 storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
@@ -2365,18 +2362,21 @@ test("listDraftFeatures does not start a fallback session without a model", asyn
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.createSessionCalls).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 storagePath = join(workdir, "agents");
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 {
fetchCatalogCalls = 0;
createSessionCalls = 0;
@@ -2398,9 +2398,9 @@ test("listDraftFeatures uses client feature listing without a model", async () =
return await super.createSession(config);
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
this.featureConfigs.push(config);
return [draftFeature];
return [draftControl];
}
}
const client = new DraftFeatureClient();
@@ -2412,9 +2412,13 @@ test("listDraftFeatures uses client feature listing without a model", async () =
logger,
});
await expect(manager.listDraftFeatures({ provider: "codex", cwd: workdir })).resolves.toEqual([
draftFeature,
]);
await expect(
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.createSessionCalls).toBe(0);
@@ -2423,15 +2427,28 @@ test("listDraftFeatures uses client feature listing without a model", async () =
{
provider: "codex",
cwd: workdir,
providerOptions: { auto_accept: true },
},
]);
// Options without a control come back too.
await expect(
manager.listDraftControls({
provider: "codex",
cwd: workdir,
providerOptions: { auto_accept: true, sandbox_mode: "workspace-write" },
}),
).resolves.toEqual({
controls: [draftControl],
options: { auto_accept: true, sandbox_mode: "workspace-write" },
});
});
test("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 storagePath = join(workdir, "agents");
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 {
fetchCatalogCalls = 0;
createSessionCalls = 0;
@@ -2447,9 +2464,9 @@ test("listDraftFeatures uses explicit model config without default model fetchin
return await super.createSession(config);
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
this.featureConfigs.push(config);
return [draftFeature];
return [draftControl];
}
}
const client = new DraftFeatureClient();
@@ -2461,13 +2478,13 @@ test("listDraftFeatures uses explicit model config without default model fetchin
logger,
});
const features = await manager.listDraftFeatures({
const draft = await manager.listDraftControls({
provider: "codex",
cwd: workdir,
model: "gpt-5.4",
});
expect(features).toEqual([draftFeature]);
expect(draft).toEqual({ controls: [draftControl], options: {} });
expect(client.fetchCatalogCalls).toBe(0);
expect(client.createSessionCalls).toBe(0);
expect(client.featureConfigs).toEqual([
@@ -9984,16 +10001,13 @@ test("respondToPermission updates currentModeId after plan approval", async () =
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 storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
class RefreshingPermissionSession extends TestAgentSession {
private featureState: AgentFeature[] = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: true }),
];
private planMode = true;
private modeId = "auto";
private pending = [
{
@@ -10005,8 +10019,11 @@ test("respondToPermission refreshes features and runtime info after provider-man
},
];
get features(): AgentFeature[] {
return this.featureState;
get controls(): AgentOptionControl[] {
return [
createControl({ key: "fast_mode", label: "Fast" }),
createControl({ key: "plan_mode", label: "Plan" }),
];
}
override async getRuntimeInfo() {
@@ -10015,7 +10032,7 @@ test("respondToPermission refreshes features and runtime info after provider-man
sessionId: this.id,
model: "gpt-5.4",
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> {
this.modeId = "auto";
this.pending = [];
this.featureState = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
];
this.planMode = false;
this.pushEvent({
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",
cwd: workdir,
providerOptions: { fast_mode: true, plan_mode: true },
},
undefined,
{ workspaceId: undefined },
@@ -10080,20 +10100,14 @@ test("respondToPermission refreshes features and runtime info after provider-man
const updated = manager.getAgent(snapshot.id);
expect(updated?.pendingPermissions.size).toBe(0);
expect(updated?.features).toEqual([
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
]);
expect(updated?.effectiveOptions).toEqual({ fast_mode: true, plan_mode: false });
expect(updated?.runtimeInfo).toMatchObject({
model: "gpt-5.4",
extra: { collaborationMode: "Code" },
});
const persisted = await storage.get(snapshot.id);
expect(persisted?.features).toEqual([
createFeature({ id: "fast_mode", label: "Fast", value: true }),
createFeature({ id: "plan_mode", label: "Plan", value: false }),
]);
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true, plan_mode: false });
});
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);
class OrderedPermissionSession extends TestAgentSession {
private featureState: AgentFeature[] = [
createFeature({ id: "fast_mode", label: "Fast", value: true }),
];
private modeId = "plan";
private pending = [
{
@@ -10116,8 +10127,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
},
];
get features(): AgentFeature[] {
return this.featureState;
get controls(): AgentOptionControl[] {
return [createControl({ key: "fast_mode", label: "Fast" })];
}
override async getRuntimeInfo() {
@@ -10138,6 +10149,11 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
}
override async respondToPermission(): Promise<void> {
this.pushEvent({
type: "options_changed",
provider: this.provider,
patch: { fast_mode: false },
});
this.pushEvent({
type: "permission_resolved",
provider: this.provider,
@@ -10145,7 +10161,6 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
resolution: { behavior: "allow" },
});
this.modeId = "acceptEdits";
this.featureState = [createFeature({ id: "fast_mode", label: "Fast", value: false })];
this.pending = [];
}
}
@@ -10169,6 +10184,7 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
{
provider: "codex",
cwd: workdir,
providerOptions: { fast_mode: true },
},
undefined,
{ workspaceId: undefined },
@@ -10180,9 +10196,8 @@ test("respondToPermission emits refreshed state before permission_resolved", asy
return;
}
if (event.type === "agent_state" && event.agent.id === snapshot.id) {
const fastMode = event.agent.features?.find((feature) => feature.id === "fast_mode");
seen.push(
`state:${event.agent.currentModeId}:${String(fastMode?.type === "toggle" ? fastMode.value : null)}`,
`state:${event.agent.currentModeId}:${String(event.agent.effectiveOptions?.fast_mode)}`,
);
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.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 AgentResumePurpose,
type AgentResumeSessionOptions,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentSlashCommand,
type AgentMode,
@@ -59,7 +59,14 @@ import {
type ListImportableSessionsOptions,
} from "./agent-sdk-types.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { toStoredAgentRecord } from "./agent-projections.js";
import {
applyProviderOptionsPatch,
exposeFeatureValuesToHook,
foldHookFeatureValues,
type ProviderOptionsPatch,
} from "./option-controls.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js";
import {
@@ -210,9 +217,6 @@ function buildStoredAgentConfig(record: StoredAgentRecord): AgentSessionConfig {
if (record.config.thinkingOptionId != null) {
config.thinkingOptionId = record.config.thinkingOptionId;
}
if (record.config.featureValues != null) {
config.featureValues = record.config.featureValues;
}
if (record.config.providerOptions != null) {
config.providerOptions = record.config.providerOptions;
}
@@ -428,7 +432,9 @@ interface ManagedAgentBase {
createdAt: Date;
updatedAt: Date;
availableModes: AgentMode[];
features?: AgentFeature[];
controls?: AgentOptionControl[];
/** `config.json` defaults deep-merged with `config.providerOptions`; refreshed on every state emit. */
effectiveOptions?: ProviderOptions;
currentModeId: string | null;
pendingPermissions: Map<string, AgentPermissionRequest>;
bufferedPermissionResolutions: Map<
@@ -775,6 +781,8 @@ export class AgentManager {
private readonly runtimeCleanupIdentities = new WeakMap<AgentSession, object>();
private readonly collectingRuntimes = new Set<string>();
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 mcpBaseUrl: 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 client = this.requireClient(normalizedConfig.provider);
if (!normalizedConfig.model && !client.listFeatures) {
return [];
const options = this.effectiveOptionsFor(normalizedConfig.provider, config.providerOptions);
if (!normalizedConfig.model && !client.listControls) {
return { controls: [], options };
}
const available = await client.isAvailable();
if (!available) {
@@ -1256,13 +1267,13 @@ export class AgentManager {
);
}
if (client.listFeatures) {
return await client.listFeatures(normalizedConfig);
if (client.listControls) {
return { controls: await client.listControls(normalizedConfig), options };
}
const session = await client.createSession(normalizedConfig);
try {
return session.features ?? [];
return { controls: session.controls ?? [], options };
} finally {
try {
await session.close();
@@ -1361,11 +1372,14 @@ export class AgentManager {
this.assertAcceptingAgentRegistrations();
const resolvedAgentId = validateAgentId(agentId ?? this.idFactory(), "createAgent");
if (this.pluginLifecycle && !config.internal) {
const sent = exposeFeatureValuesToHook(config);
const request = await this.pluginLifecycle.before("agent.create.before", {
config,
config: sent,
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 };
}
await this.deleteAgentState(resolvedAgentId);
@@ -2161,7 +2175,11 @@ export class AgentManager {
createdAt: new Date(record.createdAt),
updatedAt,
availableModes: [],
features: record.features,
controls: record.controls,
effectiveOptions: this.effectiveOptionsFor(
record.provider,
record.config?.providerOptions ?? undefined,
),
currentModeId: record.lastModeId ?? null,
pendingPermissions: new Map(),
bufferedPermissionResolutions: new Map(),
@@ -2254,18 +2272,53 @@ export class AgentManager {
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) {
throw new Error("Agent session does not support setting features");
}
if (agent.session.applyOptions) {
// 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);
agent.config.featureValues = { ...agent.config.featureValues, [featureId]: value };
this.touchUpdatedAt(agent);
this.emitState(agent);
/**
* The options a running provider is handed: the keys the agent holds, each resolved against
* the `config.json` defaults. A removed key is absent, so the provider keeps its setting;
* defaults for absent keys apply when the session is next opened.
*/
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> {
@@ -4311,7 +4364,7 @@ export class AgentManager {
agent.pendingPermissions.clear();
}
this.syncFeaturesFromSession(agent);
this.syncControls(agent);
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: {
agent: ActiveManagedAgent;
event: AgentStreamEvent;
@@ -4708,6 +4778,9 @@ export class AgentManager {
flags.shouldDispatchEvent = false;
this.emitState(agent);
return undefined;
case "options_changed":
this.onStreamOptionsChanged({ agent, patch: event.patch, flags });
return undefined;
case "timeline":
return this.onStreamTimelineEvent({ agent, event, options, flags });
case "turn_completed":
@@ -5183,7 +5256,7 @@ export class AgentManager {
this.enqueueBackgroundPersist(agent);
}
this.syncFeaturesFromSession(agent);
this.syncControls(agent);
this.logger.trace(
{
@@ -5205,10 +5278,21 @@ export class AgentManager {
});
}
private syncFeaturesFromSession(agent: ManagedAgent): void {
if ("session" in agent && agent.session?.features) {
agent.features = agent.session.features;
private syncControls(agent: ManagedAgent): void {
if ("session" in agent && agent.session?.controls) {
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 {
@@ -10,7 +10,7 @@ import {
} from "./agent-projections.js";
import type { AgentSession } from "./agent-sdk-types.js";
import type {
AgentFeature,
AgentOptionControl,
ImportableProviderSession,
AgentPermissionRequest,
AgentPersistenceHandle,
@@ -128,16 +128,6 @@ function createPermission(overrides: Partial<AgentPermissionRequest> = {}): Agen
return { ...base, ...overrides };
}
function createFeature(overrides: Partial<AgentFeature> = {}): AgentFeature {
return {
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: true,
...overrides,
};
}
describe("toStoredAgentRecord", () => {
it("captures lifecycle metadata, config, and persistence", () => {
const agent = createManagedAgent({
@@ -474,13 +464,44 @@ describe("toAgentPayload", () => {
});
});
it("includes features in the snapshot payload", () => {
const features = [createFeature()];
const agent = createManagedAgent({ features });
it("projects controls, effective options, and legacy features from the same source", () => {
const controls: AgentOptionControl[] = [
{ type: "toggle", key: "fast_mode", label: "Fast mode" },
{
type: "select",
key: "service_tier",
label: "Speed",
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "fast", label: "Fast" },
],
},
];
// `config.json` supplies fast_mode; the agent itself sets nothing for service_tier.
// Options without a control are part of the snapshot too.
const agent = createManagedAgent({
controls,
effectiveOptions: { fast_mode: true, sandbox_mode: "workspace-write" },
});
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 {
AgentCapabilityFlags,
AgentFeature,
AgentOptionControl,
AgentMetadata,
AgentMode,
AgentPermissionRequest,
@@ -18,7 +18,9 @@ import type {
AgentUsage,
ImportableProviderSession,
} from "./agent-sdk-types.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import type { ManagedAgent } from "./agent-manager.js";
import { featuresFromControls } from "./option-controls.js";
import type { JsonValue } from "../json-utils.js";
import {
isStoredAgentProviderAvailable,
@@ -97,7 +99,7 @@ export function toStoredAgentRecord(
config: config ?? null,
launchEnv: agent.launchEnv ? { ...agent.launchEnv } : undefined,
runtimeInfo,
features: normalizeFeatures(agent.features),
controls: normalizeControls(agent.controls),
persistence,
lastError: agent.lastError ?? undefined,
requiresAttention: agent.attention.requiresAttention,
@@ -143,7 +145,12 @@ export function toAgentPayload(
capabilities: cloneCapabilities(agent.capabilities),
currentModeId: agent.currentModeId,
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),
persistence: projectPersistenceHandleForWire(agent.persistence),
title: options?.title ?? null,
@@ -375,12 +382,6 @@ function buildSerializableConfig(config: AgentSessionConfig): SerializableAgentC
if (config.thinkingOptionId) {
serializable.thinkingOptionId = config.thinkingOptionId;
}
if (Object.prototype.hasOwnProperty.call(config, "featureValues")) {
const featureValues = sanitizeMetadata(config.featureValues);
if (featureValues !== undefined) {
serializable.featureValues = featureValues;
}
}
if (config.providerOptions !== undefined) {
const providerOptions = sanitizeOptionalJson(config.providerOptions);
if (providerOptions && isJsonObject(providerOptions)) {
@@ -456,8 +457,15 @@ function cloneAvailableModes(modes: AgentMode[]): AgentMode[] {
return modes.map((mode) => ({ ...mode }));
}
function normalizeFeatures(features: AgentFeature[] | null | undefined): AgentFeature[] {
return Array.isArray(features) ? features.map((feature) => ({ ...feature })) : [];
function normalizeControls(
controls: AgentOptionControl[] | null | undefined,
): AgentOptionControl[] {
return Array.isArray(controls) ? controls.map((control) => ({ ...control })) : [];
}
function sanitizeEffectiveOptions(options: ProviderOptions): ProviderOptions {
const sanitized = sanitizeOptionalJson(options);
return sanitized && isJsonObject(sanitized) ? sanitized : {};
}
function sanitizeOptionalJson(value: unknown): JsonValue | undefined {
@@ -1,7 +1,7 @@
import type {
AgentFeature,
AgentFeatureSelect,
AgentFeatureToggle,
AgentOptionControl,
AgentOptionSelectControl,
AgentOptionToggleControl,
AgentProviderNotice,
AgentTaskItem,
JsonValue,
@@ -13,9 +13,9 @@ import type { AgentAttachment } from "@getpaseo/protocol/messages";
import type { PaseoToolCatalog } from "./tools/types.js";
export type {
AgentFeature,
AgentFeatureSelect,
AgentFeatureToggle,
AgentOptionControl,
AgentOptionSelectControl,
AgentOptionToggleControl,
AgentProviderNotice,
AgentTaskItem,
};
@@ -148,7 +148,7 @@ export interface AgentCreateConfigParent {
export interface ResolveAgentCreateConfigInput {
provider: AgentProvider;
requestedMode: string | undefined;
featureValues: Record<string, unknown> | undefined;
providerOptions: ProviderOptions | undefined;
parent: AgentCreateConfigParent | null;
unattended: boolean;
availableModes: AgentMode[] | undefined;
@@ -156,13 +156,13 @@ export interface ResolveAgentCreateConfigInput {
export interface ResolveAgentCreateConfigResult {
modeId: string | undefined;
featureValues: Record<string, unknown> | undefined;
providerOptions: ProviderOptions | undefined;
}
export interface AgentCreateConfigUnattendedInput {
modeId: string | null;
config: AgentSessionConfig;
features?: AgentFeature[];
controls?: AgentOptionControl[];
availableModes: AgentMode[];
}
@@ -429,6 +429,8 @@ export type AgentStreamEvent =
provider: AgentProvider;
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";
provider: AgentProvider;
@@ -610,7 +612,6 @@ export interface AgentSessionConfig {
modeId?: string;
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
title?: string | null;
providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy;
@@ -679,7 +680,8 @@ export interface AgentSession {
readonly provider: AgentProvider;
readonly id: string | null;
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
* replay them at their original timestamps; restored sessions omit old rows. */
readonly initialTimeline?: ImportedTimelineEntry[];
@@ -714,7 +716,12 @@ export interface AgentSession {
listCommands?(): Promise<AgentSlashCommand[]>;
setModel?(modelId: string | null): Promise<void>;
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>;
revertFiles?(input: { messageId: string }): Promise<void>;
revertBoth?(input: { messageId: string }): Promise<void>;
@@ -797,7 +804,9 @@ export interface AgentClient {
resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult;
isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean;
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?(
options?: ListImportableSessionsOptions,
): Promise<ImportableProviderSession[]>;
@@ -1,7 +1,7 @@
import { describe, expect, test, beforeEach, afterEach } from "vitest";
import os from "node:os";
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 { createTestLogger } from "../../test-utils/test-logger.js";
@@ -44,9 +44,6 @@ function buildManagedAgentConfig(
systemPrompt: configOverrides.systemPrompt,
mcpServers: configOverrides.mcpServers,
};
if (Object.prototype.hasOwnProperty.call(configOverrides, "featureValues")) {
config.featureValues = configOverrides.featureValues;
}
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", () => {
let tmpDir: string;
let storagePath: string;
@@ -195,62 +200,77 @@ describe("AgentStorage", () => {
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(
createManagedAgent({
id: "agent-feature-values",
config: {
featureValues: {
fast_mode: true,
},
},
}),
);
const record = await storage.get("agent-feature-values");
expect(record?.config?.featureValues).toEqual({ fast_mode: true });
const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-feature-values");
expect(persisted?.config?.featureValues).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).featureValues).toEqual({ fast_mode: true });
});
test("applySnapshot keeps featureValues absent when they were never set", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-no-feature-values",
id: "agent-provider-options",
config: { providerOptions: { fast_mode: true } },
}),
);
const reloaded = new AgentStorage(storagePath, logger);
const persisted = await reloaded.get("agent-no-feature-values");
expect(persisted?.config?.featureValues).toBeUndefined();
expect(buildSessionConfig(persisted!).featureValues).toBeUndefined();
});
test("buildConfigOverrides includes featureValues when present in stored config", async () => {
await storage.applySnapshot(
createManagedAgent({
id: "agent-resume-overrides",
config: {
featureValues: {
fast_mode: true,
},
},
}),
);
const record = await storage.get("agent-resume-overrides");
expect(record).not.toBeNull();
expect(buildConfigOverrides(record!)).toMatchObject({
const persisted = await reloaded.get("agent-provider-options");
expect(persisted?.config?.providerOptions).toEqual({ fast_mode: true });
expect(buildSessionConfig(persisted!).providerOptions).toEqual({ fast_mode: true });
expect(buildConfigOverrides(persisted!)).toMatchObject({
cwd: "/tmp/project",
featureValues: {
fast_mode: true,
},
providerOptions: { 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 () => {
const agentId = "agent-created-at";
const firstTimestamp = new Date("2025-01-01T00:00:00.000Z");
@@ -4,17 +4,19 @@ import { z } from "zod";
import type { Logger } from "pino";
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 type { ManagedAgent } from "./agent-manager.js";
import type { AgentSessionConfig } from "./agent-sdk-types.js";
import { AgentOwnerSchema, daemonExecutionKey, type DaemonAgentOwner } from "./agent-owner.js";
import { controlsFromFeatures, foldFeatureValues } from "./option-controls.js";
const SERIALIZABLE_CONFIG_SCHEMA = z
.object({
modeId: z.string().nullable().optional(),
model: z.string().nullable().optional(),
thinkingOptionId: z.string().nullable().optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; folded on parse.
featureValues: z.record(z.string(), z.unknown()).nullable().optional(),
providerOptions: z.record(z.string(), z.unknown()).nullable().optional(),
toolPolicy: z
@@ -42,7 +44,7 @@ const PERSISTENCE_HANDLE_SCHEMA = z
.nullable()
.optional();
const STORED_AGENT_SCHEMA = z.object({
const RAW_STORED_AGENT_SCHEMA = z.object({
id: z.string(),
provider: z.string(),
cwd: z.string(),
@@ -68,6 +70,8 @@ const STORED_AGENT_SCHEMA = z.object({
extra: z.record(z.string(), z.unknown()).optional(),
})
.optional(),
controls: z.array(AgentOptionControlSchema).optional(),
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. Read-only; becomes controls.
features: z.array(AgentFeatureSchema).optional(),
persistence: PERSISTENCE_HANDLE_SCHEMA,
lastError: z.string().nullable().optional(),
@@ -84,14 +88,32 @@ export type SerializableAgentConfig = Pick<
| "modeId"
| "model"
| "thinkingOptionId"
| "featureValues"
| "providerOptions"
| "toolPolicy"
| "systemPrompt"
| "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 {
return STORED_AGENT_SCHEMA.parse(value);
}
@@ -94,7 +94,7 @@ export function resolveDefaultAgentCreateConfig(
availableModes: availableModeIds,
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();
stub.resolveCreateConfig.mockResolvedValue({
modeId: "build",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
const dependencies: Parameters<typeof createAgentCommand>[0] = {
agentManager: {
@@ -171,7 +171,7 @@ test("session create applies the resolved mode from the provider create config",
expect(createAgent).toHaveBeenCalledWith(
expect.objectContaining({
modeId: "build",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
}),
undefined,
expect.anything(),
@@ -22,6 +22,8 @@ import { buildAgentPrompt } from "../prompt-attachments.js";
import { normalizeClientMessageId, resolveClientMessageId } from "../../client-message-id.js";
import { resolveRequiredProviderModel, type ResolvedProviderModel } from "../mcp-shared.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 {
finishSubscriptions?: FinishSubscriptions;
@@ -47,7 +49,8 @@ export interface CreateAgentFromSessionInput {
kind: "session";
onAgentReady?: (agent: ManagedAgent) => Promise<void>;
agentId?: string;
config: AgentSessionConfig;
/** Wire configs from older clients can still carry `featureValues`; they are folded on entry. */
config: AgentSessionConfig & LegacyFeatureValues;
workspaceId: string;
initialPrompt?: string;
source?: AgentPromptSource;
@@ -70,7 +73,7 @@ export interface CreateAgentFromMcpInput {
cwd?: string;
workspaceId?: string;
thinking?: string;
features?: Record<string, unknown>;
providerOptions?: ProviderOptions;
labels?: Record<string, string>;
mode?: string;
unattended?: boolean;
@@ -218,19 +221,19 @@ async function resolveSessionCreateAgent(
input: CreateAgentFromSessionInput,
): Promise<ResolvedCreateAgent> {
const trimmedPrompt = input.initialPrompt?.trim();
const builtSessionConfig = input.config;
const builtSessionConfig = foldFeatureValues(input.config);
const resolvedCreateConfig = await dependencies.providerSnapshotManager.resolveCreateConfig({
cwd: builtSessionConfig.cwd,
provider: builtSessionConfig.provider,
requestedMode: builtSessionConfig.modeId,
featureValues: builtSessionConfig.featureValues,
providerOptions: builtSessionConfig.providerOptions,
parent: null,
unattended: false,
});
const sessionConfig: AgentSessionConfig = {
...builtSessionConfig,
modeId: resolvedCreateConfig.modeId,
featureValues: resolvedCreateConfig.featureValues,
providerOptions: resolvedCreateConfig.providerOptions,
};
const prompt = buildAgentPrompt(trimmedPrompt ?? "", input.images, input.attachments);
const hasPromptContent = Array.isArray(prompt) ? prompt.length > 0 : prompt.length > 0;
@@ -323,7 +326,7 @@ async function resolveMcpCreateAgent(
resolvedCwd: intent.cwd,
trimmedPrompt,
resolvedMode: resolvedCreateConfig.modeId,
resolvedFeatures: resolvedCreateConfig.featureValues,
resolvedProviderOptions: resolvedCreateConfig.providerOptions,
}),
createOptions: {
...(Object.keys(intent.labels).length > 0 ? { labels: intent.labels } : {}),
@@ -361,18 +364,23 @@ async function resolveMcpProviderCreateConfig(params: {
provider: string;
resolvedCwd: string;
parentAgent: ManagedAgent | null;
}): Promise<{ modeId?: string; featureValues?: Record<string, unknown> }> {
}): Promise<{ modeId?: string; providerOptions?: ProviderOptions }> {
const passthroughConfig = params.input.config;
return params.dependencies.providerSnapshotManager.resolveCreateConfig({
cwd: params.resolvedCwd,
provider: params.provider,
requestedMode: params.input.mode ?? passthroughConfig?.modeId,
featureValues: params.input.features ?? passthroughConfig?.featureValues,
providerOptions: mergeMcpProviderOptions(params.input),
parent: params.parentAgent,
unattended: params.input.unattended ?? false,
});
}
function mergeMcpProviderOptions(input: CreateAgentFromMcpInput): ProviderOptions | undefined {
if (!input.providerOptions) return input.config?.providerOptions;
return { ...input.config?.providerOptions, ...input.providerOptions };
}
function buildMcpSessionConfig(params: {
input: CreateAgentFromMcpInput;
resolvedProviderModel: ResolvedProviderModel;
@@ -380,14 +388,14 @@ function buildMcpSessionConfig(params: {
resolvedCwd: string;
trimmedPrompt: string;
resolvedMode?: string;
resolvedFeatures?: Record<string, unknown>;
resolvedProviderOptions?: ProviderOptions;
}): AgentSessionConfig {
const passthroughConfig = params.input.config;
const { provisionalTitle } = resolveCreateAgentTitles({
configTitle: passthroughConfig?.title ?? params.input.title,
initialPrompt: params.trimmedPrompt,
});
const featureValues = params.resolvedFeatures ?? passthroughConfig?.featureValues;
const providerOptions = params.resolvedProviderOptions ?? mergeMcpProviderOptions(params.input);
const config: AgentSessionConfig = {
...passthroughConfig,
provider: params.provider,
@@ -400,8 +408,8 @@ function buildMcpSessionConfig(params: {
if (provisionalTitle) {
config.title = provisionalTitle;
}
if (featureValues) {
config.featureValues = featureValues;
if (providerOptions) {
config.providerOptions = providerOptions;
}
return config;
}
@@ -257,7 +257,7 @@ function buildAgentManagerSpies() {
setAgentMode: vi.fn().mockResolvedValue(undefined),
setAgentModel: vi.fn().mockResolvedValue(undefined),
setAgentThinkingOption: vi.fn().mockResolvedValue(undefined),
setAgentFeature: vi.fn().mockResolvedValue(undefined),
setAgentOptions: vi.fn().mockResolvedValue(undefined),
setLabels: vi.fn().mockResolvedValue(undefined),
setTitle: vi.fn().mockResolvedValue(undefined),
updateAgentMetadata: vi.fn().mockResolvedValue(undefined),
@@ -282,7 +282,7 @@ function buildAgentManagerSpies() {
cancelAgentRun: vi.fn(),
getPendingPermissions: vi.fn(),
getRegisteredProviderIds: vi.fn().mockReturnValue(["claude"]),
listDraftFeatures: vi.fn(),
listDraftControls: vi.fn(),
};
}
@@ -472,9 +472,9 @@ function configureOpenCodeProviderStub(
const opts = input as {
provider: AgentProvider;
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",
currentModeId: null,
availableModes: [],
config: { title: "Feature test", featureValues: { fast_mode: true } },
config: { title: "Feature test", providerOptions: { fast_mode: true } },
} as ManagedAgent);
const server = await createAgentMcpServer({
@@ -1834,7 +1834,7 @@ describe("create_agent MCP tool", () => {
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
}),
undefined,
{ workspaceId: "workspace-created" },
@@ -3039,12 +3039,12 @@ describe("create_agent MCP tool", () => {
lifecycle: "idle",
currentModeId: null,
availableModes: [],
config: { title: "Child", featureValues: { fast_mode: true } },
config: { title: "Child", providerOptions: { fast_mode: true } },
} as ManagedAgent);
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockImplementation(async (input) => {
const opts = input as { featureValues: Record<string, unknown> | undefined };
return { modeId: undefined, featureValues: opts.featureValues };
const opts = input as { providerOptions: Record<string, unknown> | undefined };
return { modeId: undefined, providerOptions: opts.providerOptions };
});
const server = await createAgentMcpServer({
@@ -3072,7 +3072,7 @@ describe("create_agent MCP tool", () => {
expect.objectContaining({
provider: "codex",
model: "gpt-5.4",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
}),
undefined,
{
@@ -3273,7 +3273,7 @@ describe("create_agent MCP tool", () => {
provStub.resolveCreateConfig.mockImplementation(async (input) => {
const opts = input as { requestedMode: string | undefined };
expect(opts.requestedMode).toBe("dynamic");
return { modeId: "dynamic", featureValues: undefined };
return { modeId: "dynamic", providerOptions: undefined };
});
const server = await createAgentMcpServer({
agentManager,
@@ -3307,12 +3307,12 @@ describe("create_agent MCP tool", () => {
lifecycle: "idle",
currentModeId: "build",
availableModes: [],
config: { title: "Child", featureValues: { auto_accept: true } },
config: { title: "Child", providerOptions: { auto_accept: true } },
} as ManagedAgent);
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
modeId: "build",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
const server = await createAgentMcpServer({
agentManager,
@@ -3332,7 +3332,7 @@ describe("create_agent MCP tool", () => {
});
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({ modeId: "build", featureValues: { auto_accept: true } }),
expect.objectContaining({ modeId: "build", providerOptions: { auto_accept: true } }),
undefined,
{ workspaceId: "workspace-created" },
);
@@ -3360,7 +3360,7 @@ describe("create_agent MCP tool", () => {
const providerSnapshot = createOpenCodeManager();
providerSnapshot.stub.resolveCreateConfig.mockResolvedValue({
modeId: "resolver-mode",
featureValues: { resolver_feature: true },
providerOptions: { resolver_feature: true },
});
const server = await createAgentMcpServer({
@@ -3384,7 +3384,7 @@ describe("create_agent MCP tool", () => {
expect(spies.agentManager.createAgent).toHaveBeenCalledWith(
expect.objectContaining({
modeId: "resolver-mode",
featureValues: { resolver_feature: true },
providerOptions: { resolver_feature: true },
}),
undefined,
expect.any(Object),
@@ -4717,7 +4717,7 @@ describe("update_agent MCP tool", () => {
expect(spies.agentManager.setAgentMode).toHaveBeenCalledWith("agent-1", "full-access");
expect(spies.agentManager.setAgentModel).toHaveBeenCalledWith("agent-1", "gpt-5.4");
expect(spies.agentManager.setAgentThinkingOption).toHaveBeenCalledWith("agent-1", "high");
expect(spies.agentManager.setAgentFeature).toHaveBeenCalledWith("agent-1", "fast_mode", true);
expect(spies.agentManager.setAgentOptions).toHaveBeenCalledWith("agent-1", { fast_mode: true });
expect(spies.agentManager.updateAgentMetadata).toHaveBeenCalledWith("agent-1", {
title: "Updated agent",
labels: { role: "worker" },
@@ -4742,12 +4742,12 @@ describe("update_agent MCP tool", () => {
expect(spies.agentManager.setAgentMode).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentModel).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentThinkingOption).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentFeature).not.toHaveBeenCalled();
expect(spies.agentManager.setAgentOptions).not.toHaveBeenCalled();
});
it("does not update metadata when runtime settings fail", async () => {
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({
agentManager,
agentStorage,
@@ -5065,7 +5065,7 @@ describe("create_schedule MCP tool", () => {
config: {
title: "Parent agent",
model: "openai/gpt-5.5",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
},
} as ManagedAgent);
const createOrReplace = vi.fn(async (input: CreateScheduleInput) =>
@@ -5093,7 +5093,7 @@ describe("create_schedule MCP tool", () => {
cwd: REPO_CWD,
modeId: "build",
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",
modeId: "bypassPermissions",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
options: { fast_mode: true },
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({});
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 () => {
@@ -5934,14 +5948,10 @@ describe("provider MCP tools", () => {
it("inspects provider features for a draft agent configuration", async () => {
const { agentManager, agentStorage, spies } = createTestDeps();
spies.agentManager.listDraftFeatures.mockResolvedValue([
{
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
},
]);
spies.agentManager.listDraftControls.mockResolvedValue({
controls: [{ type: "toggle", key: "fast_mode", label: "Fast mode" }],
options: { fast_mode: true },
});
const provStub = createProviderSnapshotManagerStub();
provStub.listRegisteredProviderIds.mockReturnValue(["codex"]);
const codexEntry = buildSnapshotEntry({
@@ -5974,13 +5984,13 @@ describe("provider MCP tools", () => {
const response = await tool.handler(input);
expect(spies.agentManager.listDraftFeatures).toHaveBeenCalledWith({
expect(spies.agentManager.listDraftControls).toHaveBeenCalledWith({
provider: "codex",
cwd: expect.stringContaining("repo"),
modeId: "full-access",
model: "gpt-5.4",
thinkingOptionId: "high",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
});
expect(response.structuredContent).toEqual({
provider: "codex",
@@ -5995,7 +6005,7 @@ describe("provider MCP tools", () => {
type: "toggle",
id: "fast_mode",
label: "Fast mode",
value: false,
value: true,
},
],
});
@@ -6211,14 +6221,8 @@ describe("agent snapshot MCP serialization", () => {
description: "Default coding mode",
},
],
features: [
{
type: "toggle",
id: "web-search",
label: "Web search",
value: true,
},
],
controls: [{ type: "toggle", key: "web-search", label: "Web search" }],
effectiveOptions: { "web-search": true },
pendingPermissions: new Map(),
persistence: {
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 { createTestLogger } from "../../test-utils/test-logger.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 { buildProviderRegistry } from "./provider-registry.js";
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", () => {
test("history keeps the tool's first position when completion follows later text", async () => {
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 () => {
const harness = createProviderHarness();
const registry = new PluginAgentClientRegistry(createTestLogger());
@@ -1511,12 +1748,14 @@ describe("PluginAgentClientRegistry", () => {
});
const session = await client!.createSession({ provider: "plugin-direct", cwd: "/workspace" });
expect(session.features).toEqual([
expect.objectContaining({
expect(session.controls).toEqual([
{
type: "select",
id: "voice",
options: [{ id: "direct", label: "Direct", value: "direct" }],
}),
key: "voice",
label: "Voice",
defaultValue: "direct",
choices: [{ value: "direct", label: "Direct" }],
},
]);
expect(session.describePersistence()).toMatchObject({
provider: "plugin-direct",
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { PluginSessionHistory } from "./plugin-session-history.js";
import { resolve as resolvePath } from "node:path";
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 {
PROVIDER_CAPABILITIES,
@@ -16,6 +16,7 @@ import {
type ProviderCapability,
type ProviderConfigChanges,
type ProviderConfigState,
type ProviderSetting,
type ProviderConnection,
type ProviderError,
type ProviderEvent,
@@ -31,10 +32,10 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
AgentOptionControl,
AgentPermissionRequest,
AgentPermissionResponse,
AgentPersistenceHandle,
@@ -1094,9 +1095,8 @@ class PluginAgentClient implements AgentClient {
history: "replay" | "skip";
persist: boolean;
}): Promise<PluginAgentSession> {
const sessionId = randomUUID();
const bridge = await this.runtime.openSession({
sessionId,
sessionId: randomUUID(),
config: mapSessionConfig(input.config, input.launchContext, input.persist),
persistence: input.persistence,
history: input.history,
@@ -1133,8 +1133,10 @@ class PluginAgentClient implements AgentClient {
});
},
bind,
input.config.providerOptions ?? {},
);
bind(bridge);
session.reportOpeningSettings();
return session;
}
@@ -1211,6 +1213,8 @@ class PluginAgentSession implements AgentSession {
closed: boolean;
} | 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(
readonly provider: string,
@@ -1221,6 +1225,7 @@ class PluginAgentSession implements AgentSession {
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
private heldOptions: ProviderOptions,
) {
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
@@ -1246,14 +1251,29 @@ class PluginAgentSession implements AgentSession {
};
}
get features(): AgentFeature[] {
return this.bridge.config.settings.map((setting) =>
setting.type === "toggle"
? { ...setting }
: {
...setting,
options: setting.options.map((option) => ({ ...option, id: option.value })),
},
/** The plugin's `settings` are its option controls; each setting id is an option key. */
get controls(): AgentOptionControl[] {
return this.bridge.config.settings.map(
(setting): AgentOptionControl =>
setting.type === "toggle"
? {
type: "toggle",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
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 {
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);
}
@@ -1410,11 +1435,24 @@ class PluginAgentSession implements AgentSession {
await this.bridge.configure({ thinkingOption: thinkingOptionId });
}
async setFeature(featureId: string, value: unknown): Promise<void> {
async applyOptions(options: ProviderOptions): Promise<void> {
this.assertReplayComplete();
await this.bridge.configure({
settings: { [featureId]: toJsonValue(value, `setting '${featureId}'`) },
});
// A setting the options leave out stays as the plugin has it.
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> {
@@ -1487,6 +1525,46 @@ class PluginAgentSession implements AgentSession {
this.history.push(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[] {
@@ -1852,7 +1930,8 @@ function mapSessionConfig(
model: config.model,
mode: config.modeId,
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,
title: config.title ?? undefined,
persist,
@@ -1995,6 +2074,10 @@ function toJsonObject(value: unknown, label: string): 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 {
const encoded = JSON.stringify(value);
if (encoded === undefined) throw new Error(`${label} must be JSON-serializable`);
@@ -25,7 +25,7 @@ const OPTIONAL_AGENT_SESSION_METHOD_NAMES = [
"listCommands",
"setModel",
"setThinkingOption",
"setFeature",
"applyOptions",
"revertConversation",
"revertFiles",
"revertBoth",
@@ -152,8 +152,8 @@ class FakeSession implements AgentSession {
this.recordedCalls.push("setThinkingOption");
}
async setFeature() {
this.recordedCalls.push("setFeature");
async applyOptions() {
this.recordedCalls.push("applyOptions");
}
async revertConversation() {
@@ -193,7 +193,7 @@ describe("wrapSessionProvider", () => {
await wrapped.listCommands?.();
await wrapped.setModel?.("sonnet");
await wrapped.setThinkingOption?.("high");
await wrapped.setFeature?.("feature-1", true);
await wrapped.applyOptions?.({ "feature-1": true });
await wrapped.revertConversation?.({ messageId: "message-1" });
await wrapped.revertFiles?.({ messageId: "message-1" });
await wrapped.revertBoth?.({ messageId: "message-1" });
@@ -208,7 +208,7 @@ describe("wrapSessionProvider", () => {
"listCommands",
"setModel",
"setThinkingOption",
"setFeature",
"applyOptions",
"revertConversation",
"revertFiles",
"revertBoth",
@@ -4,7 +4,7 @@ import type { ToolPolicy } from "@getpaseo/protocol/agent-types";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type {
AgentClient,
AgentFeature,
AgentOptionControl,
AgentModelDefinition,
AgentMode,
AgentSessionConfig,
@@ -392,15 +392,15 @@ vi.mock("./providers/cursor-acp-agent.js", () => ({
return true;
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
mockState.cursorListFeaturesConfigs.push(config);
return [
{
type: "select",
id: "fast",
key: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
defaultValue: "false",
choices: [{ value: "false", label: "Off" }],
},
];
}
@@ -823,7 +823,7 @@ test("cursor provider extending acp uses CursorACPAgentClient", () => {
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, {
providerOverrides: {
cursor: {
@@ -837,17 +837,17 @@ test("wrapped cursor client lists ACP features through the inner provider", asyn
const client = registry.cursor.createClient(logger);
await expect(
client.listFeatures?.({
client.listControls?.({
provider: "cursor",
cwd: "/tmp/cursor",
}),
).resolves.toEqual([
{
type: "select",
id: "fast",
key: "fast",
label: "Fast",
value: "false",
options: [{ id: "false", label: "Off" }],
defaultValue: "false",
choices: [{ value: "false", label: "Off" }],
},
]);
expect(mockState.cursorListFeaturesConfigs).toEqual([
@@ -447,8 +447,8 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
get initialTimeline() {
return inner.initialTimeline;
},
get features() {
return inner.features;
get controls() {
return inner.controls;
},
usageSession: inner.usageSession?.bind(inner),
run: (prompt, options) => inner.run(prompt, options),
@@ -473,7 +473,7 @@ export function wrapSessionProvider(provider: AgentProvider, inner: AgentSession
listCommands: inner.listCommands?.bind(inner),
setModel: inner.setModel?.bind(inner),
setThinkingOption: inner.setThinkingOption?.bind(inner),
setFeature: inner.setFeature?.bind(inner),
applyOptions: inner.applyOptions?.bind(inner),
revertConversation: inner.revertConversation?.bind(inner),
revertFiles: inner.revertFiles?.bind(inner),
revertBoth: inner.revertBoth?.bind(inner),
@@ -491,7 +491,7 @@ function wrapClientProvider(
): AgentClient {
const listImportableSessions = inner.listImportableSessions?.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 unarchiveNativeSession = inner.unarchiveNativeSession?.bind(inner);
const configuredModelIds = [...profileModels, ...additionalModels].map((model) => model.id);
@@ -557,9 +557,10 @@ function wrapClientProvider(
resolveCreateConfig: inner.resolveCreateConfig?.bind(inner),
resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner),
isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner),
listFeatures: listFeatures
effectiveOptions: (own) => mergeProviderOptions(providerOptions, own) ?? {},
listControls: listControls
? async (config) =>
await listFeatures({
await listControls({
...config,
provider: inner.provider,
providerOptions: mergeProviderOptions(providerOptions, config.providerOptions),
@@ -1307,7 +1307,7 @@ describe("ProviderSnapshotManager public surface", () => {
resolverInputs.push(input);
return {
modeId: input.parent?.isUnattended ? "child-unattended" : undefined,
featureValues: undefined,
providerOptions: undefined,
};
},
}),
@@ -1338,17 +1338,17 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project",
provider: "codex",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent,
unattended: false,
});
expect(resolved).toEqual({ modeId: "child-unattended", featureValues: undefined });
expect(resolved).toEqual({ modeId: "child-unattended", providerOptions: undefined });
expect(resolverInputs).toEqual([
{
provider: "codex",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "claude",
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 () => {
const resolverInputs: ResolveAgentCreateConfigInput[] = [];
const modes: AgentMode[] = [{ id: "worker", label: "Worker", isUnattended: true }];
@@ -1386,7 +1448,7 @@ describe("ProviderSnapshotManager public surface", () => {
resolverInputs.push(input);
return {
modeId: input.unattended ? "worker" : undefined,
featureValues: undefined,
providerOptions: undefined,
};
},
}),
@@ -1397,17 +1459,17 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project",
provider: "codex",
requestedMode: undefined,
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
parent: null,
unattended: true,
});
expect(resolved).toEqual({ modeId: "worker", featureValues: undefined });
expect(resolved).toEqual({ modeId: "worker", providerOptions: undefined });
expect(resolverInputs).toEqual([
{
provider: "codex",
requestedMode: undefined,
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
parent: null,
unattended: true,
availableModes: modes,
@@ -1456,7 +1518,7 @@ describe("ProviderSnapshotManager public surface", () => {
config: {
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
},
};
@@ -1464,18 +1526,38 @@ describe("ProviderSnapshotManager public surface", () => {
cwd: "/tmp/project",
provider: "opencode",
requestedMode: "base",
featureValues: undefined,
providerOptions: undefined,
parent,
unattended: false,
});
expect(resolved).toEqual({ modeId: "base", featureValues: { auto_accept: true } });
expect(resolved).toEqual({ modeId: "base", providerOptions: { auto_accept: true } });
} finally {
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", () => {
test("adds a derived provider and includes it in subsequent reads", async () => {
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 { EventEmitter } from "node:events";
import { homedir } from "node:os";
@@ -160,14 +160,14 @@ export interface ResolveProviderCreateConfigOptions {
cwd?: string | null;
provider: AgentProvider;
requestedMode: string | undefined;
featureValues: Record<string, unknown> | undefined;
providerOptions: ProviderOptions | undefined;
parent: ManagedAgent | null;
unattended: boolean;
}
export interface ResolvedProviderCreateConfig {
modeId: string | undefined;
featureValues: Record<string, unknown> | undefined;
providerOptions: ProviderOptions | undefined;
}
interface ResolveDefaultModelOptions {
@@ -563,7 +563,7 @@ export class ProviderSnapshotManager {
return definition.resolveCreateConfig({
provider: input.provider,
requestedMode: input.requestedMode,
featureValues: input.featureValues,
providerOptions: input.providerOptions,
parent,
unattended: input.unattended || parent?.isUnattended === true,
availableModes: entry.modes ?? [],
@@ -744,8 +744,13 @@ export class ProviderSnapshotManager {
modeId: parent.currentModeId,
isUnattended: definition.isCreateConfigUnattended({
modeId: parent.currentModeId,
config: parent.config,
features: parent.features,
// Provider defaults from `config.json` apply to the parent too, so classify on the
// effective options while the stored overrides stay as they are.
config: {
...parent.config,
providerOptions: parent.effectiveOptions ?? parent.config.providerOptions,
},
controls: parent.controls,
availableModes: parent.availableModes ?? definition.modes ?? [],
}),
};
@@ -198,7 +198,7 @@ function createSessionWithConfig(
provider?: string;
modeId?: string | null;
model?: string | null;
featureValues?: Record<string, unknown>;
providerOptions?: Record<string, unknown>;
} = {},
logger: ReturnType<typeof createTestLogger> = createTestLogger(),
): ACPAgentSession {
@@ -208,7 +208,7 @@ function createSessionWithConfig(
cwd: "/tmp/paseo-acp-test",
modeId: config.modeId ?? undefined,
model: config.model ?? undefined,
featureValues: config.featureValues,
providerOptions: config.providerOptions,
},
{
provider: config.provider ?? "claude-acp",
@@ -300,14 +300,14 @@ function selectConfigOption(
function createCopilotSessionWithConfig(
modeId?: string | null,
featureValues?: Record<string, unknown>,
providerOptions?: Record<string, unknown>,
): ACPAgentSession {
return new ACPAgentSession(
{
provider: "copilot",
cwd: "/tmp/paseo-acp-test",
modeId: modeId ?? undefined,
...(featureValues ? { featureValues } : {}),
...(providerOptions ? { providerOptions } : {}),
},
{
provider: "copilot",
@@ -1498,7 +1498,7 @@ describe("ACPAgentSession Zed parity", () => {
test("auto-accepts ACP permission requests when the shared feature is enabled", async () => {
const session = createSessionWithConfig({
provider: "cursor-acp",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
const events: Array<{ type: string }> = [];
@@ -1530,7 +1530,7 @@ describe("ACPAgentSession Zed parity", () => {
test("does not auto-accept ACP chooser requests", async () => {
const session = createSessionWithConfig({
provider: "kimi-acp",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
const events: AgentStreamEvent[] = [];
@@ -1583,11 +1583,8 @@ describe("ACPAgentSession Zed parity", () => {
internals.sessionId = "session-1";
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(
session.requestPermission({
sessionId: "session-1",
@@ -1605,7 +1602,7 @@ describe("ACPAgentSession Zed parity", () => {
});
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 } }> = [];
asInternals<ACPSessionInternals>(session).sessionId = "session-1";
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);
internals.configOptions = [copilotAgentConfigOption("")];
expect(session.features).toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
value: false,
}),
expect(session.controls).toEqual([
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
{
type: "select",
id: "agent",
key: "agent",
label: "Agent",
description: "Use a Copilot custom agent profile",
tooltip: "Select Copilot agent",
icon: undefined,
value: "",
options: [
{
id: "",
label: "Default",
description: undefined,
isDefault: true,
metadata: undefined,
},
{
id: "Probe Agent",
label: "Probe Agent",
description: "Temporary probe agent",
isDefault: false,
metadata: undefined,
},
defaultValue: "",
choices: [
{ value: "", label: "Default", description: undefined },
{ value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" },
],
},
]);
@@ -1816,15 +1797,9 @@ describe("ACPAgentSession Zed parity", () => {
configId: "agent",
value: "Probe Agent",
});
expect(session.features).toEqual([
expect.objectContaining({
id: "auto_accept",
value: false,
}),
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
expect(session.controls).toEqual([
expect.objectContaining({ key: "auto_accept" }),
expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
]);
});
@@ -1838,22 +1813,16 @@ describe("ACPAgentSession Zed parity", () => {
connection: { setSessionConfigOption },
});
await session.setFeature("agent", "Probe Agent");
await session.applyOptions({ agent: "Probe Agent" });
expect(setSessionConfigOption).toHaveBeenCalledWith({
sessionId: "session-1",
configId: "agent",
value: "Probe Agent",
});
expect(session.features).toEqual([
expect.objectContaining({
id: "auto_accept",
value: false,
}),
expect.objectContaining({
id: "agent",
value: "Probe Agent",
}),
expect(session.controls).toEqual([
expect.objectContaining({ key: "auto_accept" }),
expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }),
]);
});
});
@@ -2084,14 +2053,14 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({
provider: "generic-acp",
requestedMode: undefined,
featureValues: { provider_feature: "kept" },
providerOptions: { provider_feature: "kept" },
parent: null,
unattended: true,
availableModes: [],
}),
).toEqual({
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({
provider: "generic-acp",
requestedMode: undefined,
featureValues: { auto_accept: false },
providerOptions: { auto_accept: false },
parent: null,
unattended: true,
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", () => {
@@ -2125,7 +2094,7 @@ describe("ACPAgentClient config features", () => {
client.resolveCreateConfig({
provider: "generic-acp",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "claude",
modeId: "bypassPermissions",
@@ -2134,7 +2103,7 @@ describe("ACPAgentClient config features", () => {
unattended: false,
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", () => {
@@ -2150,13 +2119,32 @@ describe("ACPAgentClient config features", () => {
config: {
provider: "generic-acp",
cwd: "/tmp/acp-features",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
},
availableModes: [],
}),
).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 () => {
const client = new ACPAgentClient({
provider: "generic-acp",
@@ -2165,22 +2153,17 @@ describe("ACPAgentClient config features", () => {
});
await expect(
client.listFeatures({
client.listControls({
provider: "generic-acp",
cwd: "/tmp/acp-features",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
}),
).resolves.toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
value: true,
}),
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
]);
});
test("derives features from configured ACP select options", async () => {
test("derives controls from configured ACP select options", async () => {
class TestACPAgentClient extends ACPAgentClient {
protected override async spawnProcess(): Promise<SpawnedACPProcess> {
return {
@@ -2206,23 +2189,19 @@ describe("ACPAgentClient config features", () => {
});
await expect(
client.listFeatures({
client.listControls({
provider: "copilot",
cwd: "/tmp/acp-features",
}),
).resolves.toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
value: false,
}),
expect.objectContaining({ type: "toggle", key: "auto_accept" }),
expect.objectContaining({
type: "select",
id: "agent",
value: "Probe Agent",
options: [
expect.objectContaining({ id: "", label: "Default", isDefault: false }),
expect.objectContaining({ id: "Probe Agent", label: "Probe Agent", isDefault: true }),
key: "agent",
defaultValue: "Probe Agent",
choices: [
expect.objectContaining({ value: "", label: "Default" }),
expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }),
],
}),
]);
@@ -3997,7 +3976,7 @@ describe("ACPAgentClient probe cleanup", () => {
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(terminator.terminated).toContain(child);
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { ACPProviderOptionsSchema } from "./acp-options.js";
import { type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -65,7 +66,8 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateConfigUnattendedInput,
type AgentFeature,
type AgentOptionControl,
type AgentOptionToggleControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -110,6 +112,7 @@ import {
isDefaultAgentCreateConfigUnattended,
resolveDefaultAgentCreateConfig,
} from "../create-agent-mode.js";
import { isToggleOn } from "../option-controls.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import {
checkProviderLaunchAvailable,
@@ -848,10 +851,10 @@ export function deriveModelDefinitionsFromACP(
}));
}
export function deriveFeaturesFromACP(
export function deriveControlsFromACP(
configOptions: SessionConfigOption[] | null | undefined,
featureOptions: ACPConfigFeatureOption[],
): AgentFeature[] {
): AgentOptionControl[] {
return featureOptions.flatMap((featureOption) => {
const option = findSelectConfigFeatureOption(configOptions, featureOption);
if (!option) {
@@ -861,42 +864,44 @@ export function deriveFeaturesFromACP(
return [
{
type: "select",
id: featureOption.id,
key: featureOption.id,
label: featureOption.label,
description: featureOption.description,
tooltip: featureOption.tooltip,
icon: featureOption.icon,
value: option.currentValue ?? null,
options: deriveConfigFeatureSelectOptions(option, featureOption),
// The agent reports its own current value; an unset option stands for it.
...(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 {
return config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
return config.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true;
}
function buildACPAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
return {
type: "toggle",
id: ACP_AUTO_ACCEPT_FEATURE_ID,
label: "Auto Accept",
description: "Automatically approves ACP permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
value: isACPAutoAcceptEnabled(config),
};
}
const ACP_AUTO_ACCEPT_CONTROL: AgentOptionToggleControl = {
type: "toggle",
key: ACP_AUTO_ACCEPT_FEATURE_ID,
label: "Auto Accept",
description: "Automatically approves ACP permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
};
function resolveACPCreateConfig(
input: ResolveAgentCreateConfigInput,
): ResolveAgentCreateConfigResult {
const isUnattendedCreate = input.unattended || input.parent?.isUnattended === true;
const featureValues =
isUnattendedCreate && input.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
? { ...input.featureValues, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
: input.featureValues;
const providerOptions =
isUnattendedCreate && input.providerOptions?.[ACP_AUTO_ACCEPT_FEATURE_ID] === undefined
? { ...input.providerOptions, [ACP_AUTO_ACCEPT_FEATURE_ID]: true }
: input.providerOptions;
if (
input.requestedMode === undefined &&
@@ -904,22 +909,16 @@ function resolveACPCreateConfig(
input.parent !== null &&
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 {
return (
isDefaultAgentCreateConfigUnattended(input) ||
input.config.featureValues?.[ACP_AUTO_ACCEPT_FEATURE_ID] === true ||
input.features?.some(
(feature) =>
feature.id === ACP_AUTO_ACCEPT_FEATURE_ID &&
feature.type === "toggle" &&
feature.value === true,
) === true
isToggleOn(ACP_AUTO_ACCEPT_FEATURE_ID, input.config.providerOptions, input.controls)
);
}
@@ -1198,10 +1197,9 @@ export class ACPAgentClient implements AgentClient {
}
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const autoAcceptFeature = buildACPAutoAcceptFeature(config);
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
if (this.configFeatureOptions.length === 0) {
return [autoAcceptFeature];
return [ACP_AUTO_ACCEPT_CONTROL];
}
this.assertProvider(config);
@@ -1217,8 +1215,8 @@ export class ACPAgentClient implements AgentClient {
probeSessionId = response.sessionId;
const transformed = this.transformSessionResponse(response);
return [
autoAcceptFeature,
...deriveFeaturesFromACP(transformed.configOptions, this.configFeatureOptions),
ACP_AUTO_ACCEPT_CONTROL,
...deriveControlsFromACP(transformed.configOptions, this.configFeatureOptions),
];
} finally {
await this.closeProbe(probe, probeSessionId);
@@ -1761,6 +1759,12 @@ export class ACPAgentSession implements AgentSession, ACPClient {
private currentTitle: string | null = null;
private lastActivityAt: string | null = null;
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[] = [];
/** When the last command batch arrived; null while nothing has been published. */
private commandsUpdatedAt: number | null = null;
@@ -1780,6 +1784,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
constructor(config: AgentSessionConfig, options: ACPAgentSessionOptions) {
this.provider = options.provider;
this.autoAcceptEnabled = isACPAutoAcceptEnabled(config);
this.terminateProcess = options.terminateProcess ?? terminateWithTreeKill;
this.capabilities = options.capabilities;
this.logger = options.logger.child({ module: "agent", provider: options.provider });
@@ -2025,6 +2030,11 @@ export class ACPAgentSession implements AgentSession, ACPClient {
sessionId: this.sessionId,
});
}
if (this.optionsChangedBeforeSubscribe) {
const patch = this.optionsChangedBeforeSubscribe;
this.optionsChangedBeforeSubscribe = null;
callback({ type: "options_changed", provider: this.provider, patch });
}
return () => {
this.subscribers.delete(callback);
};
@@ -2054,10 +2064,10 @@ export class ACPAgentSession implements AgentSession, ACPClient {
return this.currentMode;
}
get features(): AgentFeature[] {
get controls(): AgentOptionControl[] {
return [
buildACPAutoAcceptFeature(this.config),
...deriveFeaturesFromACP(this.configOptions, this.configFeatureOptions),
{ ...ACP_AUTO_ACCEPT_CONTROL, defaultValue: this.autoAcceptEnabled },
...deriveControlsFromACP(this.configOptions, this.configFeatureOptions),
];
}
@@ -2212,6 +2222,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modeId,
label: "mode",
});
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes;
this.pushEvent({
type: "mode_changed",
@@ -2337,6 +2348,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: modelId,
label: "model",
});
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
this.pushEvent({
type: "model_changed",
provider: this.provider,
@@ -2388,6 +2400,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue: thinkingOptionId,
label: "thought-level",
});
for (const event of this.reportSelectionChanges()) this.pushEvent(event);
this.pushEvent({
type: "thinking_option_changed",
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) {
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);
if (!featureOption) {
throw new Error(`Unknown ${this.provider} feature: ${featureId}`);
@@ -2441,7 +2490,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
requestedValue,
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({
@@ -2612,8 +2669,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
const canAutoAccept =
isACPAutoAcceptEnabled(this.config) && !isACPChooserRequest(params.options);
const canAutoAccept = this.autoAcceptEnabled && !isACPChooserRequest(params.options);
if (canAutoAccept) {
const allowOption = selectPermissionOption(params.options, { behavior: "allow" });
if (allowOption) {
@@ -2987,6 +3043,18 @@ export class ACPAgentSession implements AgentSession, ACPClient {
}
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;
if (configuredModeId && configuredModeId !== this.currentMode) {
const selection = resolveACPModeSelection({
@@ -3018,13 +3086,13 @@ export class ACPAgentSession implements AgentSession, ACPClient {
if (this.config.thinkingOptionId && this.config.thinkingOptionId !== this.thinkingOptionId) {
await this.setThinkingOption(this.config.thinkingOptionId);
}
const configuredFeatureValues = this.config.featureValues ?? {};
const configuredFeatureValues = this.config.providerOptions ?? {};
for (const featureOption of this.configFeatureOptions) {
if (!Object.prototype.hasOwnProperty.call(configuredFeatureValues, featureOption.id)) {
continue;
}
try {
await this.setFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
await this.setConfigFeature(featureOption.id, configuredFeatureValues[featureOption.id]);
} catch (error) {
if (!this.isStaleFeatureValueError(error, featureOption.id, switchedModel)) {
throw error;
@@ -3282,6 +3350,7 @@ export class ACPAgentSession implements AgentSession, ACPClient {
thinkingOptionId: this.thinkingOptionId,
});
}
events.push(...this.reportSelectionChanges());
return events;
}
@@ -3367,6 +3436,15 @@ export class ACPAgentSession implements AgentSession, ACPClient {
},
"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) {
subscriber(event);
}
@@ -18,6 +18,9 @@ import {
resolveClaudeCodeVersion,
toClaudeSdkMcpConfig,
} from "./agent.js";
import { AgentManager } from "../../agent-manager.js";
import { ensureAgentLoaded } from "../../agent-loading.js";
import { AgentStorage } from "../../agent-storage.js";
import { createImportDescriptorScheduler } from "./import-descriptor-scheduler.js";
import { claudeProjectDirSync } from "./project-dir.js";
import { streamSession } from "../test-utils/session-stream-adapter.js";
@@ -864,6 +867,34 @@ describe("ClaudeAgentSession features", () => {
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 () => {
const { queryFactory, launches } = createQueryMock();
const client = new ClaudeAgentClient({
@@ -953,39 +984,39 @@ describe("ClaudeAgentSession features", () => {
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
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(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
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(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
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(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-5",
}),
).resolves.toEqual([expect.objectContaining({ id: "fast_mode", value: false })]);
).resolves.toEqual([expect.objectContaining({ key: "fast_mode", type: "toggle" })]);
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "openrouter/anthropic/claude-opus-4-8",
@@ -993,7 +1024,7 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]);
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-5",
@@ -1001,7 +1032,7 @@ describe("ClaudeAgentSession features", () => {
).resolves.toEqual([]);
await expect(
client.listFeatures({
client.listControls({
provider: "claude",
cwd: process.cwd(),
model: "claude-sonnet-4-6",
@@ -1020,7 +1051,7 @@ describe("ClaudeAgentSession features", () => {
provider: "claude",
cwd: process.cwd(),
model: "claude-opus-4-8",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
});
await expect(
@@ -1492,7 +1523,7 @@ describe("ClaudeAgentSession features", () => {
ensureQuery(): Promise<unknown>;
}
).ensureQuery();
await session.setFeature?.("fast_mode", true);
await session.applyOptions?.({ fast_mode: true });
expect(queryFactory).toHaveBeenCalledTimes(1);
expect(queryMock.applyFlagSettings).toHaveBeenLastCalledWith({ fastMode: true });
@@ -1501,6 +1532,146 @@ describe("ClaudeAgentSession features", () => {
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", () => {
@@ -1,5 +1,6 @@
import { setImmediate } from "node:timers/promises";
import { ClaudeRuntimeSafety } from "./runtime-safety.js";
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { validateProviderOptions } from "../../provider-options.js";
import type { ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
@@ -65,7 +66,7 @@ import { foldSubagentObservations, type SubagentObservation } from "./subagents/
import { observeReplayWorkflows } from "./subagents/workflow-replay-source.js";
import { limitAgentTimelineItemContent } from "../../agent-timeline-content.js";
import { readClaudeWorkflowResultFile } from "./subagents/workflow-output.js";
import { buildClaudeFeatures, claudeModelSupportsFastMode } from "./feature-definitions.js";
import { buildClaudeControls, claudeModelSupportsFastMode } from "./feature-definitions.js";
import {
buildBinaryDiagnosticRows,
buildCommandResolutionDiagnosticRows,
@@ -104,7 +105,7 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -1752,12 +1753,8 @@ export class ClaudeAgentClient implements AgentClient {
});
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
const claudeConfig = this.assertConfig(config);
return buildClaudeFeatures({
modelId: claudeConfig.model,
fastModeEnabled: claudeConfig.featureValues?.fast_mode === true,
});
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
return buildClaudeControls(this.assertConfig(config).model);
}
async listImportableSessions(
@@ -2446,11 +2443,8 @@ class ClaudeAgentSession implements AgentSession {
return this.claudeSessionId;
}
get features(): AgentFeature[] {
return buildClaudeFeatures({
modelId: this.config.model,
fastModeEnabled: this.config.featureValues?.fast_mode === true,
});
get controls(): AgentOptionControl[] {
return buildClaudeControls(this.config.model, this.config.providerOptions.fast_mode === true);
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -2748,8 +2742,10 @@ class ClaudeAgentSession implements AgentSession {
await activeQuery.setModel(normalizedModelId ?? undefined);
this.config.model = normalizedModelId ?? undefined;
this.reconcileThinkingOptionForModel(normalizedModelId);
if (!claudeModelSupportsFastMode(this.config.model) && this.config.featureValues?.fast_mode) {
await this.applyFastModeFeature(false, activeQuery);
if (this.config.providerOptions.fast_mode) {
// 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(
getClaudeContextWindowMaxTokens(this.config.model),
@@ -2801,31 +2797,28 @@ class ClaudeAgentSession implements AgentSession {
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId !== "fast_mode") {
throw new Error(`Unknown Claude feature: ${featureId}`);
}
const enabled = Boolean(value);
if (enabled && !claudeModelSupportsFastMode(this.config.model)) {
async applyOptions(options: ProviderOptions): Promise<void> {
const providerOptions =
validateProviderOptions("claude", ClaudeProviderOptionsSchema, options) ?? {};
// Supplied keys update the running options; absent keys keep their running value.
const running = { ...this.config.providerOptions, ...providerOptions };
const hadFast = this.config.providerOptions.fast_mode === true;
const wantsFast = running.fast_mode === true;
const modelSupportsFast = claudeModelSupportsFastMode(this.config.model);
if (wantsFast && !hadFast && !modelSupportsFast) {
throw new Error(
`Claude fast mode is not available for model '${this.config.model ?? "default"}'`,
);
}
await this.applyFastModeFeature(enabled);
}
private async applyFastModeFeature(enabled: boolean, query?: Query): Promise<void> {
this.config.featureValues = {
...this.config.featureValues,
fast_mode: enabled,
};
const activeQuery = query ?? this.query;
if (activeQuery) {
await activeQuery.applyFlagSettings({ fastMode: enabled });
// A saved Fast preference stays dormant on a model without Fast (see `setModel`), so the
// runtime setting is the preference filtered by model support.
const runtimeFast = wantsFast && modelSupportsFast;
if (runtimeFast !== (hadFast && modelSupportsFast)) {
await this.query?.applyFlagSettings({ fastMode: runtimeFast });
this.cachedRuntimeInfo = null;
}
this.cachedRuntimeInfo = null;
// Held until the runtime accepts, so a rejected change is retried instead of skipped.
this.config.providerOptions = running;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -3654,10 +3647,9 @@ class ClaudeAgentSession implements AgentSession {
private async buildOptions(permissionMode: PermissionMode): Promise<ClaudeOptions> {
const { thinking, effort, ultracode } = this.resolveThinkingConfig();
const appendedSystemPrompt = this.buildAppendedSystemPrompt();
const providerOptions = applyClaudeToolPolicy(
this.config.providerOptions,
this.config.toolPolicy,
);
// `fast_mode` is a composer control the provider applies through flag settings.
const { fast_mode: _fastMode, ...sdkProviderOptions } = this.config.providerOptions;
const providerOptions = applyClaudeToolPolicy(sdkProviderOptions, this.config.toolPolicy);
const settingsOptions = await this.buildSettingsOptions(providerOptions, { ultracode });
const sdkEnv = this.harnessEnvironment;
assertClaudeModeCanRun(permissionMode, sdkEnv);
@@ -3776,7 +3768,7 @@ class ClaudeAgentSession implements AgentSession {
if (!claudeModelSupportsFastMode(this.config.model)) {
return null;
}
return this.config.featureValues?.fast_mode === true;
return this.config.providerOptions.fast_mode === true;
}
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";
export const CLAUDE_FAST_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
export const CLAUDE_FAST_MODE_CONTROL: AgentOptionToggleControl = {
type: "toggle",
id: "fast_mode",
key: "fast_mode",
label: "Fast",
description: "Lower latency Opus responses at higher token cost",
tooltip: "Toggle fast mode",
@@ -14,18 +14,11 @@ export function claudeModelSupportsFastMode(modelId: string | null | undefined):
return claudeManifestModelSupportsFastMode(modelId);
}
export function buildClaudeFeatures(input: {
modelId: string | null | undefined;
fastModeEnabled: boolean;
}): AgentFeature[] {
if (!claudeModelSupportsFastMode(input.modelId)) {
return [];
}
return [
{
...CLAUDE_FAST_MODE_FEATURE,
value: input.fastModeEnabled,
},
];
export function buildClaudeControls(
modelId: string | null | undefined,
fastEnabled = false,
): AgentOptionControl[] {
return claudeModelSupportsFastMode(modelId)
? [{ ...CLAUDE_FAST_MODE_CONTROL, defaultValue: fastEnabled }]
: [];
}
@@ -45,6 +45,8 @@ const SandboxFilesystemSchema = z
// Claude Agent SDK Options, maintained against @anthropic-ai/claude-agent-sdk 0.3.246.
export const ClaudeProviderOptionsSchema = z
.object({
/** Composer control, not a Claude SDK option: the provider reads it and keeps it out of the SDK options. */
fast_mode: z.boolean().optional(),
allowedTools: z.array(z.string()).optional(),
disallowedTools: z.array(z.string()).optional(),
additionalDirectories: z.array(z.string()).optional(),
@@ -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 { 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 {
createFakeCodexAppServer,
@@ -37,23 +44,33 @@ const TEST_SPEED_MODELS = [
serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }],
}));
function speedFeature(value: string) {
function speedControl(defaultValue = "default") {
return {
type: "select",
id: "service_tier",
key: "service_tier",
label: "Speed",
description: "Choose processing speed. Faster tiers increase usage.",
tooltip: "Select speed",
icon: "zap",
desktopTrigger: "icon",
value,
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
defaultValue,
choices: [
{ value: "default", label: "Normal" },
{ 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[] = [
{
name: "Code",
@@ -132,7 +149,7 @@ async function createConnectedSession(
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 () => {
const { session, appServer } = await createConnectedSession(
{ model: "gpt-6.1-sol" },
@@ -149,19 +166,19 @@ describe("Codex app-server provider features", () => {
},
);
try {
expect(session.features).toContainEqual(
expect(session.controls).toContainEqual(
expect.objectContaining({
id: "service_tier",
key: "service_tier",
type: "select",
value: "default",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "priority", label: "Fast" },
{ id: "ultrafast", label: "Ultrafast" },
defaultValue: "default",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
{ value: "ultrafast", label: "Ultrafast" },
],
}),
);
await session.setFeature?.("service_tier", "ultrafast");
await session.applyOptions?.({ service_tier: "ultrafast" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "ultrafast",
@@ -186,16 +203,16 @@ describe("Codex app-server provider features", () => {
},
);
try {
expect(session.features).toContainEqual(
expect(session.controls).toContainEqual(
expect.objectContaining({
id: "service_tier",
options: [
{ id: "default", label: "Normal", isDefault: true },
{ id: "future-tier", label: "Future speed" },
key: "service_tier",
choices: [
{ value: "default", label: "Normal" },
{ 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 expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model: "future-codex-model",
@@ -210,7 +227,7 @@ describe("Codex app-server provider features", () => {
"restores %s and Normal explicitly overrides an existing thread tier",
async (tier) => {
const { session, appServer } = await createConnectedSession(
{ model: "future-model", featureValues: { service_tier: tier } },
{ model: "future-model", providerOptions: { service_tier: tier } },
{
models: [
{
@@ -224,11 +241,8 @@ describe("Codex app-server provider features", () => {
},
);
try {
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: tier }),
);
await session.getRuntimeInfo();
await session.setFeature?.("service_tier", "default");
await session.applyOptions?.({ service_tier: "default" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "default",
@@ -239,17 +253,40 @@ describe("Codex app-server provider features", () => {
},
);
test("rejects unadvertised tiers and non-string values", async () => {
const { session } = await createConnectedSession();
test.each(["priority", "ultrafast"])("restores %s from providerOptions", async (tier) => {
const { session, appServer } = await createConnectedSession(
{ model: "future-model", providerOptions: { service_tier: tier } },
{
models: [
{
id: "future-model",
serviceTiers: [
{ id: "priority", name: "Fast", description: "Fast processing" },
{ id: "ultrafast", name: "Ultrafast", description: "Ultra processing" },
],
},
],
},
);
try {
for (const value of ["ultrafast", "bogus", true, null]) {
await expect(session.setFeature?.("service_tier", value)).rejects.toThrow(
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: tier });
} finally {
await session.close();
}
});
test("rejects unadvertised tiers and non-string values", async () => {
const { session, appServer } = await createConnectedSession();
try {
for (const value of ["ultrafast", "bogus"]) {
await expect(session.applyOptions?.({ service_tier: value })).rejects.toThrow(
"is not available",
);
}
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: "default" }),
);
await expect(session.applyOptions?.({ service_tier: true })).rejects.toThrow();
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" });
} finally {
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 () => {
const { session, appServer } = await createConnectedSession(
{ model: "ultra-model", featureValues: { service_tier: "ultrafast" } },
{ model: "ultra-model", providerOptions: { service_tier: "ultrafast" } },
{
models: [
{
@@ -270,8 +307,14 @@ describe("Codex app-server provider features", () => {
);
try {
await session.setModel("gpt-6.1-sol");
expect(session.features).toContainEqual(
expect.objectContaining({ id: "service_tier", value: "default" }),
expect(session.controls).toContainEqual(
expect.objectContaining({
key: "service_tier",
choices: [
{ value: "default", label: "Normal" },
{ value: "priority", label: "Fast", aliases: ["fast"] },
],
}),
);
await session.startTurn("hello");
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 () => {
const { session, appServer } = await createConnectedSession();
try {
await session.setFeature?.("fast_mode", true);
await session.applyOptions?.({ fast_mode: true });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority",
@@ -307,13 +350,13 @@ describe("Codex app-server provider features", () => {
])("exposes and sends Fast for %s", async (model) => {
const { session, appServer } = await createConnectedSession({ model });
try {
expect(session.features).toContainEqual(
expect(session.controls).toContainEqual(
expect.objectContaining({
id: "service_tier",
value: "default",
key: "service_tier",
defaultValue: "default",
}),
);
await session.setFeature?.("service_tier", "priority");
await session.applyOptions?.({ service_tier: "priority" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
model,
@@ -338,12 +381,13 @@ describe("Codex app-server provider features", () => {
])("does not expose or restore Fast for %s", async (model) => {
const { session, appServer } = await createConnectedSession({
model,
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
});
const fresh = await createConnectedSession({ model });
try {
expect(session.features.map((feature) => feature.id)).toEqual(["plan_mode"]);
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
`Codex fast mode is not available for model '${model}'`,
expect(session.controls.map((control) => control.key)).toEqual(["plan_mode"]);
await expect(fresh.session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
`Codex speed 'fast' is not available for model '${model}'`,
);
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -351,21 +395,16 @@ describe("Codex app-server provider features", () => {
});
} finally {
await session.close();
await fresh.session.close();
}
});
test("restores Fast on Astra and preserves it when switching supported models", async () => {
const { session, appServer } = await createConnectedSession({
model: "gpt-6-astra",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
});
try {
expect(session.features).toContainEqual(
expect.objectContaining({
id: "service_tier",
value: "priority",
}),
);
await session.setModel("gpt-5.6-sol");
await session.setModel("gpt-6-astra");
await session.startTurn("hello");
@@ -378,72 +417,47 @@ describe("Codex app-server provider features", () => {
}
});
test("features returns speed selector and plan toggle when supported", async () => {
const { session } = await createConnectedSession();
test("controls return the speed selector and plan toggle when supported", async () => {
const { session, appServer } = await createConnectedSession();
expect(session.features).toEqual([
speedFeature("default"),
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect(session.controls).toEqual([speedControl(), planControl()]);
await session.setFeature?.("service_tier", "priority");
await session.setFeature?.("plan_mode", true);
await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
await session.startTurn("hello");
expect(session.features).toEqual([
speedFeature("priority"),
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
serviceTier: "priority",
collaborationMode: expect.objectContaining({ mode: "plan" }),
});
});
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" });
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect(session.controls).toEqual([planControl()]);
});
test("removing the speed and plan options leaves the session as it is", async () => {
const { session, appServer } = await createConnectedSession();
await session.applyOptions?.({ service_tier: "priority", plan_mode: true });
await session.applyOptions?.({});
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 () => {
const { session, appServer } = await createConnectedSession({
model: "gpt-3.5-turbo",
featureValues: { fast_mode: true },
providerOptions: { fast_mode: true },
});
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect(session.controls).toEqual([planControl()]);
await session.startTurn("hello");
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 () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority");
await session.applyOptions?.({ service_tier: "priority" });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -464,10 +478,10 @@ describe("Codex app-server provider features", () => {
test("legacy Fast off explicitly selects Normal", async () => {
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 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" });
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(
"Codex fast mode is not available for model 'gpt-3.5-turbo'",
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(
"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();
await expect(session.getRuntimeInfo()).resolves.not.toMatchObject({
extra: { collaborationMode: "Plan" },
});
await session.setFeature?.("plan_mode", true);
await session.applyOptions?.({ plan_mode: true });
await expect(session.getRuntimeInfo()).resolves.toMatchObject({
extra: { collaborationMode: "Plan" },
});
});
test("setFeature throws for unknown feature ids", 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 () => {
test("constructor restores composer options from config.providerOptions", async () => {
const { session, appServer } = await createConnectedSession({
featureValues: { fast_mode: true, plan_mode: true },
providerOptions: { fast_mode: true, plan_mode: true },
});
expect(session.features).toEqual([
speedFeature("priority"),
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: true,
},
]);
expect(session.controls).toEqual([speedControl("priority"), planControl(true)]);
await session.startTurn("hello");
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 () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority");
await session.applyOptions?.({ service_tier: "priority" });
await session.startTurn("hello");
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 () => {
const capture = createCapturedLogger();
const prompt = "secret prompt text should not be logged";
const { session } = await createConnectedSession(
{ featureValues: { fast_mode: true } },
{ providerOptions: { fast_mode: true } },
{ 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 () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("service_tier", "priority");
await session.applyOptions?.({ service_tier: "priority" });
await session.setModel("gpt-3.5-turbo");
expect(session.features).toEqual([
{
type: "toggle",
id: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
value: false,
},
]);
expect(session.controls).toEqual([planControl()]);
await session.startTurn("hello");
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 () => {
const { session, appServer } = await createConnectedSession();
await session.setFeature?.("plan_mode", true);
await session.applyOptions?.({ plan_mode: true });
await session.startTurn("hello");
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
@@ -38,7 +38,7 @@ async function withPendingPlan(
cwd: workdir,
modeId: "auto",
model: "gpt-5.4",
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -526,7 +526,7 @@ test("idle provider exit preserves a synthetic plan approval across reconnect",
cwd: workdir,
modeId: "auto",
model: "gpt-5.4",
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
},
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 () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
providerOptions: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
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 () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
providerOptions: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
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 () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
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 () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
const events: AgentStreamEvent[] = [];
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 () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
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 () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
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 () => {
const session = createSession({
featureValues: { plan_mode: true },
providerOptions: { plan_mode: true },
});
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 () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
providerOptions: { plan_mode: true, fast_mode: true },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -7138,9 +7138,10 @@ describe("Codex app-server provider", () => {
expect(asInternals(session).serviceTier).toBe("fast");
expect(asInternals(session).planModeEnabled).toBe(false);
expect(asInternals(session).config.featureValues).toEqual({
plan_mode: false,
fast_mode: true,
expect(events).toContainEqual({
type: "options_changed",
provider: "codex",
patch: { plan_mode: false },
});
// The session returns the follow-up prompt instead of calling startTurn directly.
// 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 () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: false },
providerOptions: { plan_mode: true, fast_mode: false },
});
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
@@ -7192,9 +7193,10 @@ describe("Codex app-server provider", () => {
expect(asInternals(session).serviceTier).toBeNull();
expect(asInternals(session).planModeEnabled).toBe(false);
expect(asInternals(session).config.featureValues).toEqual({
plan_mode: false,
fast_mode: false,
expect(events).toContainEqual({
type: "options_changed",
provider: "codex",
patch: { plan_mode: false },
});
expect(result?.followUpPrompt).toEqual(
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 () => {
const session = createSession({
featureValues: { plan_mode: true, fast_mode: true },
providerOptions: { plan_mode: true, fast_mode: true },
});
asInternals(session).collaborationModes = [
{
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { validateProviderOptions } from "../provider-options.js";
import {
getAgentStreamEventTurnId,
@@ -5,7 +6,7 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentResumeSessionOptions,
type AgentMode,
@@ -75,7 +76,7 @@ import { createPathEquivalenceMatcher } from "../../../utils/path.js";
import { spawnProcess } from "../../../utils/spawn.js";
import { extractCodexTerminalSessionId, nonEmptyString } from "./tool-call-mapper-utils.js";
import {
buildCodexFeatures,
buildCodexControls,
CodexServiceTierSchema,
readCodexServiceTier,
type CodexServiceTier,
@@ -3429,7 +3430,7 @@ export class CodexAppServerAgentSession implements AgentSession {
private readonly codexHome: string;
private currentMode: string;
private hasWorkflowModeOverride: boolean;
private readonly providerOptions: CodexProviderOptions;
private providerOptions: CodexProviderOptions;
private resolvedWorkspaceWrite: NonNullable<
CodexProviderOptions["sandbox_workspace_write"]
> | null = null;
@@ -3457,7 +3458,8 @@ export class CodexAppServerAgentSession implements AgentSession {
private activeForegroundTurnId: string | null = null;
private activeClientMessageId: string | 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 planModeEnabled = false;
// 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.codexHome = deps.codexHome ?? resolveCodexHomeDir(process.env);
this.config.thinkingOptionId = normalizeCodexThinkingOptionId(this.config.thinkingOptionId);
this.serviceTier = readCodexServiceTier(this.config.featureValues);
if (this.config.featureValues?.plan_mode) {
this.planModeEnabled = true;
}
this.requestedServiceTier = readCodexServiceTier(this.providerOptions);
this.planModeEnabled = this.providerOptions.plan_mode === true;
if (this.resumeHandle?.sessionId) {
this.currentThreadId = this.resumeHandle.sessionId;
@@ -3586,10 +3586,10 @@ export class CodexAppServerAgentSession implements AgentSession {
return this.currentThreadId;
}
get features(): AgentFeature[] {
return buildCodexFeatures({
get controls(): AgentOptionControl[] {
return buildCodexControls({
serviceTiers: this.currentServiceTiers(),
serviceTier: this.serviceTier ?? "default",
runningServiceTier: this.requestedServiceTier,
planModeEnabled: this.planModeEnabled,
planModeAvailable: this.hasPlanCollaborationMode(),
});
@@ -3650,7 +3650,6 @@ export class CodexAppServerAgentSession implements AgentSession {
this.speedModels =
CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? [];
this.reconcileServiceTier();
await this.loadResolvedWorkspaceWrite();
await this.loadCollaborationModes();
await this.loadSkills();
@@ -3863,24 +3862,19 @@ export class CodexAppServerAgentSession implements AgentSession {
return selectedModel?.serviceTiers ?? [];
}
private reconcileServiceTier(): void {
const tiers = this.currentServiceTiers();
// 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 get serviceTier(): string | null {
return this.resolveServiceTier(this.requestedServiceTier);
}
private applyFeatureValue(featureId: "plan_mode", value: boolean): void {
this.config.featureValues = { ...this.config.featureValues, [featureId]: value };
this.planModeEnabled = value;
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
private resolveServiceTier(requested: string | null): string | null {
// Until the model list arrives there is nothing to check the request against.
if (this.speedModels.length === 0 || requested === "default") return requested;
const tiers = this.currentServiceTiers();
// 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 {
@@ -3945,7 +3939,13 @@ export class CodexAppServerAgentSession implements AgentSession {
const 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);
}
@@ -4645,7 +4645,6 @@ export class CodexAppServerAgentSession implements AgentSession {
async setModel(modelId: string | null): Promise<void> {
this.config.model = modelId ?? undefined;
this.reconcileServiceTier();
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
@@ -4659,35 +4658,42 @@ export class CodexAppServerAgentSession implements AgentSession {
}
}
async setFeature(featureId: string, value: unknown): Promise<void> {
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier.
if (featureId === "fast_mode") {
const fast = this.currentServiceTiers().find((tier) => tier.name.toLowerCase() === "fast");
if (value && !fast)
throw new Error(
`Codex fast mode is not available for model '${this.config.model ?? "default"}'`,
);
return this.setFeature("service_tier", value ? fast!.id : "default");
async applyOptions(options: ProviderOptions): Promise<void> {
const next = validateProviderOptions("codex", CodexProviderOptionsSchema, options) ?? {};
// An absent speed or plan option leaves the session as it is.
// Supplied keys update the running options; absent keys keep their running value, so an
// explicit tier still beats a legacy `fast_mode` on a patch that supplies neither.
const running = { ...this.providerOptions, ...next };
const speedGiven = next.service_tier !== undefined || next.fast_mode !== undefined;
const requestedTier = speedGiven ? readCodexServiceTier(running) : this.requestedServiceTier;
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") {
if (
typeof value !== "string" ||
(value !== "default" && !this.currentServiceTiers().some((tier) => tier.id === value))
) {
throw new Error(
`Codex speed '${String(value)}' is not available for model '${this.config.model ?? "default"}'`,
);
}
this.serviceTier = value;
this.reconcileServiceTier();
this.cachedRuntimeInfo = null;
return;
}
if (featureId === "plan_mode") {
this.applyFeatureValue("plan_mode", Boolean(value));
return;
}
throw new Error(`Unknown Codex feature: ${featureId}`);
this.providerOptions = running;
this.requestedServiceTier = requestedTier;
this.setPlanMode(next.plan_mode === undefined ? this.planModeEnabled : next.plan_mode === true);
this.cachedRuntimeInfo = null;
}
private isServiceTierAvailable(tier: string | null): boolean {
if (tier === null || tier === "default") return true;
const tiers = this.currentServiceTiers();
// COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated.
const legacyFast =
tier === "fast" && tiers.some((entry) => entry.name.toLowerCase() === "fast");
return legacyFast || tiers.some((entry) => entry.id === tier);
}
private setPlanMode(enabled: boolean): void {
if (this.planModeEnabled === enabled) return;
this.planModeEnabled = enabled;
this.refreshResolvedCollaborationMode();
this.cachedRuntimeInfo = null;
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -5340,7 +5346,6 @@ export class CodexAppServerAgentSession implements AgentSession {
private async applyDefaultModelAndThinking(): Promise<string> {
const { model, thinkingOptionId } = await this.resolveModelAndThinking();
this.config.model = model;
this.reconcileServiceTier();
this.config.thinkingOptionId = thinkingOptionId;
return model;
}
@@ -5407,7 +5412,13 @@ export class CodexAppServerAgentSession implements AgentSession {
private buildCodexInnerConfig(): Record<string, unknown> | null {
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) {
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";
@@ -9,51 +9,65 @@ export const CodexServiceTierSchema = z.object({
});
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 [];
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 [
{
type: "select",
id: "service_tier",
key: "service_tier",
label: "Speed",
description: "Choose processing speed. Faster tiers increase usage.",
tooltip: "Select speed",
icon: "zap",
desktopTrigger: "icon",
value,
options: [
{ id: "default", label: "Normal", isDefault: true },
...tiers.map((tier) => ({ id: tier.id, label: tier.name })),
],
defaultValue: defaultChoice?.value ?? "default",
choices,
},
];
}
export const CODEX_PLAN_MODE_FEATURE: Omit<AgentFeatureToggle, "value"> = {
export const CODEX_PLAN_MODE_CONTROL: AgentOptionToggleControl = {
type: "toggle",
id: "plan_mode",
key: "plan_mode",
label: "Plan",
description: "Switch Codex into planning-only collaboration mode",
tooltip: "Toggle plan mode",
icon: "list-todo",
};
export function buildCodexFeatures(input: {
export function buildCodexControls(input: {
serviceTiers: CodexServiceTier[];
serviceTier: string;
runningServiceTier: string | null;
planModeEnabled: boolean;
planModeAvailable?: boolean;
}): AgentFeature[] {
const features = buildCodexSpeedFeature(input.serviceTiers, input.serviceTier);
}): AgentOptionControl[] {
const controls = buildCodexSpeedControl(input.serviceTiers, input.runningServiceTier);
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 {
const tier = values?.service_tier;
export function readCodexServiceTier(options: Record<string, unknown> | undefined): string | null {
const tier = options?.service_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.
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+.
export const CodexProviderOptionsSchema = z
.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(),
sandbox_mode: z.enum(["read-only", "workspace-write", "danger-full-access"]).optional(),
sandbox_workspace_write: z
@@ -6,7 +6,7 @@ import { describe, expect, test, vi } from "vitest";
import { ACPAgentSession } 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 { GenericACPAgentClient } from "./generic-acp-agent.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({
sessionId: "session-1",
models: null,
@@ -163,43 +163,30 @@ describe("CursorACPAgentClient model discovery", () => {
});
await expect(
client.listFeatures({
client.listControls({
provider: "acp",
cwd: "/tmp/cursor",
}),
).resolves.toEqual([
{
type: "toggle",
id: "auto_accept",
key: "auto_accept",
label: "Auto Accept",
description: "Automatically approves ACP permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
value: false,
},
{
type: "select",
id: CURSOR_FAST_FEATURE_OPTION.id,
key: CURSOR_FAST_FEATURE_OPTION.id,
label: "Fast",
description: "Cursor fast mode",
tooltip: "Select Cursor fast mode",
icon: "zap",
value: "false",
options: [
{
id: "false",
label: "Off",
isDefault: true,
description: undefined,
metadata: undefined,
},
{
id: "true",
label: "Fast",
isDefault: false,
description: undefined,
metadata: undefined,
},
defaultValue: "false",
choices: [
{ value: "false", label: "Off" },
{ value: "true", label: "Fast" },
],
},
]);
@@ -228,7 +215,9 @@ describe("CursorACPAgentClient session start", () => {
session: {
currentModelId: string;
configOptions: SessionConfigOption[];
setSessionConfigOption?: () => Promise<{ configOptions: SessionConfigOption[] }>;
setSessionConfigOption?: (request: {
value: string;
}) => Promise<{ configOptions: SessionConfigOption[] }>;
} = { currentModelId: "kimi-k3", configOptions: KIMI_K3_CONFIG_OPTIONS },
logger = createTestLogger(),
): ACPAgentSession {
@@ -277,7 +266,7 @@ describe("CursorACPAgentClient session start", () => {
// still stored after the draft moved to a model that has no fast variant.
const warn = captureWarnings();
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 },
warn.logger,
);
@@ -295,7 +284,7 @@ describe("CursorACPAgentClient session start", () => {
// still listed after the model moves to one that has no fast variant.
const warn = captureWarnings();
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",
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 () => {
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",
configOptions: [fastConfigOption("false"), ...KIMI_K3_CONFIG_OPTIONS],
@@ -334,16 +323,251 @@ describe("CursorACPAgentClient session start", () => {
const session = createCursorSession({ model: "kimi-k3" });
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'",
);
});
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 () => {
// 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.
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",
configOptions: [fastConfigOption("false")],
@@ -68,7 +68,7 @@ describe("MockLoadTestAgentClient", () => {
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
featureValues: { mockPromptRejections: 1 },
providerOptions: { mockPromptRejections: 1 },
});
await expect(session.startTurn("Reject this prompt.")).rejects.toThrow(
@@ -95,7 +95,7 @@ describe("MockLoadTestAgentClient", () => {
provider: "mock",
cwd: process.cwd(),
model: "ten-second-stream",
featureValues: {
providerOptions: {
mockStreamingAssistantResponse: response,
mockStreamingAssistantIntervalMs: 20,
},
@@ -467,7 +467,7 @@ describe("MockLoadTestAgentClient", () => {
provider: "mock",
cwd: process.cwd(),
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.");
expect((await session.run("First prompt")).finalText).toBe("First response");
@@ -3,7 +3,7 @@ import type { Logger } from "pino";
import type {
AgentCapabilityFlags,
AgentClient,
AgentFeature,
AgentOptionControl,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
@@ -787,7 +787,7 @@ function getConfiguredAssistantResponses(value: unknown): string[] {
export class MockLoadTestAgentSession implements AgentSession {
readonly provider: AgentProvider = MOCK_LOAD_TEST_PROVIDER_ID;
readonly capabilities = CAPABILITIES;
readonly features: AgentFeature[] = [];
readonly controls: AgentOptionControl[] = [];
readonly id: string;
private readonly usageSessionKey = randomUUID();
@@ -812,18 +812,18 @@ export class MockLoadTestAgentSession implements AgentSession {
this.modeId = options.config.modeId ?? MOCK_LOAD_TEST_MODE_ID;
this.modelId = options.config.model ?? MOCK_LOAD_TEST_DEFAULT_MODEL_ID;
this.assistantResponse =
typeof options.config.featureValues?.mockAssistantResponse === "string"
? options.config.featureValues.mockAssistantResponse
typeof options.config.providerOptions?.mockAssistantResponse === "string"
? options.config.providerOptions.mockAssistantResponse
: null;
this.assistantResponses = getConfiguredAssistantResponses(
options.config.featureValues?.mockAssistantResponses,
options.config.providerOptions?.mockAssistantResponses,
);
this.streamingAssistantResponse =
typeof options.config.featureValues?.mockStreamingAssistantResponse === "string"
? options.config.featureValues.mockStreamingAssistantResponse
typeof options.config.providerOptions?.mockStreamingAssistantResponse === "string"
? options.config.providerOptions.mockStreamingAssistantResponse
: null;
const requestedStreamingInterval =
options.config.featureValues?.mockStreamingAssistantIntervalMs;
options.config.providerOptions?.mockStreamingAssistantIntervalMs;
this.streamingAssistantIntervalMs =
typeof requestedStreamingInterval === "number" &&
Number.isFinite(requestedStreamingInterval) &&
@@ -831,10 +831,10 @@ export class MockLoadTestAgentSession implements AgentSession {
? Math.min(requestedStreamingInterval, 1_000)
: MOCK_LOAD_TEST_INTERVAL_MS;
this.rewindError =
typeof options.config.featureValues?.mockRewindError === "string"
? options.config.featureValues.mockRewindError
typeof options.config.providerOptions?.mockRewindError === "string"
? options.config.providerOptions.mockRewindError
: null;
const requestedPromptRejections = options.config.featureValues?.mockPromptRejections;
const requestedPromptRejections = options.config.providerOptions?.mockPromptRejections;
this.remainingPromptRejections =
typeof requestedPromptRejections === "number" &&
Number.isSafeInteger(requestedPromptRejections) &&
@@ -842,7 +842,7 @@ export class MockLoadTestAgentSession implements AgentSession {
? requestedPromptRejections
: 0;
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(
"openrouter/google/gemini-3.8-flash",
);
expect(omp.requireSession().features).toEqual([
expect(omp.requireSession().controls).toEqual([
expect.objectContaining({
value: true,
defaultValue: true,
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" });
const session = omp.requireSession();
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(session.features).toEqual([
expect(session.controls).toEqual([
expect.objectContaining({
id: "fast_mode",
value: true,
key: "fast_mode",
defaultValue: true,
description: 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 () => {
const omp = new OmpHarness();
await omp.start();
@@ -649,11 +688,40 @@ describe("OMP agent client and session", () => {
} = omp.runtime().state;
omp.runtime().state = olderState;
await session.getRuntimeInfo();
expect(session.features).toEqual([]);
await expect(session.setFeature?.("fast_mode", true)).rejects.toThrow(/unavailable/i);
expect(session.controls).toEqual([]);
await expect(session.applyOptions?.({ fast_mode: true })).rejects.toThrow(/unavailable/i);
omp.runtime().state = { ...olderState, fastModeEnabled: false, fastModeActive: false };
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 () => {
@@ -661,8 +729,8 @@ describe("OMP agent client and session", () => {
await omp.start({ model: "openrouter/google/gemini-3.8-flash" });
const session = omp.requireSession();
omp.runtime().fastModeResult = { enabled: true, active: true };
await session.setFeature?.("fast_mode", true);
expect(session.features).toEqual([expect.objectContaining({ value: true })]);
await session.applyOptions?.({ fast_mode: true });
expect(session.controls).toEqual([expect.objectContaining({ defaultValue: true })]);
omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" };
omp.runtime().queueStateReports([
@@ -674,9 +742,9 @@ describe("OMP agent client and session", () => {
},
]);
await session.setModel?.("openrouter/other/model");
expect(session.features).toEqual([
expect(session.controls).toEqual([
expect.objectContaining({
value: true,
defaultValue: true,
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 () => {
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().getStateRequestCount).toBe(1);
const resumed = new OmpHarness();
await resumed.resume(
{ 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().getStateRequestCount).toBe(1);
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { mapCustomMessageToToolCall } from "../custom-message.js";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
@@ -9,7 +10,7 @@ import stripAnsi from "strip-ansi";
import {
type AgentCapabilityFlags,
type AgentClient,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -804,15 +805,16 @@ export class OmpAgentSession implements AgentSession {
return this.state.sessionId;
}
get features(): AgentFeature[] {
get controls(): AgentOptionControl[] {
if (!hasOmpFastMode(this.state)) return [];
return [
{
type: "toggle",
id: "fast_mode",
key: "fast_mode",
label: "Fast",
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
? { 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> {
if (featureId !== "fast_mode") throw new Error(`Unknown OMP feature: ${featureId}`);
if (typeof value !== "boolean") throw new Error("OMP fast mode requires a boolean");
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
const result = await this.runtimeSession.setFastMode(value);
this.state = {
...this.state,
fastModeEnabled: result.enabled,
fastModeActive: result.active,
};
this.config.featureValues = { ...this.config.featureValues, fast_mode: result.enabled };
async applyOptions(options: ProviderOptions): Promise<void> {
const requested = options.fast_mode;
if (requested !== undefined && typeof requested !== "boolean") {
throw new Error("OMP fast mode requires a boolean");
}
// An absent option leaves Fast as the runtime has it.
if (requested !== undefined && requested !== this.state.fastModeEnabled) {
if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable");
const result = await this.runtimeSession.setFastMode(requested);
this.state = {
...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> {
@@ -2228,6 +2237,23 @@ export class OmpAgentSession implements AgentSession {
private async refreshState(): Promise<void> {
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> {
@@ -2295,7 +2321,7 @@ export class OmpAgentClient implements AgentClient {
config: AgentSessionConfig,
state: OmpSessionState,
): Promise<OmpSessionState> {
const value = config.featureValues?.fast_mode;
const value = config.providerOptions?.fast_mode;
if (typeof value !== "boolean" || !hasOmpFastMode(state) || value === state.fastModeEnabled)
return state;
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 [];
}
@@ -15,6 +15,8 @@ export { OMP_MODES };
export const OmpProviderOptionsSchema = z
.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(),
rpcTimeoutMs: z.number().int().positive().optional(),
smolModel: z.string().min(1).optional(),
@@ -108,33 +108,22 @@ describe("OpenCode auto_accept feature", () => {
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 client = new OpenCodeAgentClient(createTestLogger(), undefined, {
serverManager: runtime,
createClient: runtime.createClient,
});
const enabledFeatures = await client.listFeatures({
const controls = await client.listControls({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
});
const legacyFeatures = await client.listFeatures({
provider: "opencode",
cwd: "/tmp/project",
modeId: "full-access",
providerOptions: { auto_accept: true },
});
expect(enabledFeatures).toEqual([
expect.objectContaining({
type: "toggle",
id: "auto_accept",
label: "Auto Accept",
value: true,
}),
expect(controls).toEqual([
expect.objectContaining({ type: "toggle", key: "auto_accept", label: "Auto Accept" }),
]);
expect(legacyFeatures).toEqual([expect.objectContaining({ id: "auto_accept", value: true })]);
});
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(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");
@@ -170,7 +159,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: "full-access",
featureValues: undefined,
providerOptions: undefined,
parent: null,
unattended: false,
availableModes: [
@@ -178,7 +167,7 @@ describe("OpenCode auto_accept feature", () => {
{ 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", () => {
@@ -191,7 +180,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "claude",
modeId: "bypassPermissions",
@@ -203,7 +192,7 @@ describe("OpenCode auto_accept feature", () => {
{ 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", () => {
@@ -213,7 +202,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: null,
unattended: true,
availableModes: [
@@ -221,7 +210,7 @@ describe("OpenCode auto_accept feature", () => {
{ 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", () => {
@@ -231,7 +220,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: undefined,
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "opencode",
modeId: "paseo-custom",
@@ -244,7 +233,7 @@ describe("OpenCode auto_accept feature", () => {
{ 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", () => {
@@ -254,7 +243,7 @@ describe("OpenCode auto_accept feature", () => {
client.resolveCreateConfig({
provider: "opencode",
requestedMode: "base",
featureValues: undefined,
providerOptions: undefined,
parent: {
provider: "opencode",
modeId: "orchestrator",
@@ -267,7 +256,7 @@ describe("OpenCode auto_accept feature", () => {
{ 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 () => {
@@ -283,7 +272,7 @@ describe("OpenCode auto_accept feature", () => {
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
session.subscribe((event) => receivedEvents.push(event));
@@ -301,6 +290,33 @@ describe("OpenCode auto_accept feature", () => {
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 () => {
const { openCodeClient, runtime } = mockOpenCodeClient({
events: [questionEvent(), idleEvent()],
@@ -314,7 +330,7 @@ describe("OpenCode auto_accept feature", () => {
const session = await client.createSession({
provider: "opencode",
cwd: "/tmp/project",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
session.subscribe((event) => receivedEvents.push(event));
@@ -5274,7 +5274,7 @@ describe("OpenCode provider subagent contract", () => {
const parent = await client.createSession({
provider: "opencode",
cwd: "/workspace/repo",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
parent.subscribe(() => undefined);
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { validateProviderOptions } from "../provider-options.js";
import {
createOpencodeClient,
@@ -25,7 +26,8 @@ import {
type AgentCapabilityFlags,
type AgentClient,
type AgentCreateSessionOptions,
type AgentFeature,
type AgentOptionControl,
type AgentOptionToggleControl,
type AgentLaunchContext,
type AgentMode,
type AgentModelDefinition,
@@ -58,6 +60,7 @@ import {
type ToolCallDetail,
type ToolCallTimelineItem,
} from "../agent-sdk-types.js";
import { isToggleOn } from "../option-controls.js";
import { importSessionFromPersistence } from "../provider-session-import.js";
import {
raceProviderRefreshAbort,
@@ -232,15 +235,15 @@ const DEFAULT_MODES: AgentMode[] = [
];
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(
featureValues: Record<string, unknown> | undefined,
function withOpenCodeAutoAccept(
providerOptions: ProviderOptions | undefined,
enabled: boolean,
): Record<string, unknown> {
): ProviderOptions {
return {
...featureValues,
...providerOptions,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
}
@@ -259,25 +262,25 @@ function resolveOpenCodeCreateConfig(
const requestedMode = legacyFullAccess
? OPENCODE_BUILD_MODE_ID
: (input.requestedMode ?? inheritedOpenCodeMode);
const featureValues =
const providerOptions =
legacyFullAccess ||
(isUnattendedCreate && input.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
? withOpenCodeAutoAcceptFeature(input.featureValues, true)
: input.featureValues;
(isUnattendedCreate && input.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === undefined)
? withOpenCodeAutoAccept(input.providerOptions, true)
: input.providerOptions;
if (inheritsUnattended && requestedMode === undefined) {
// Unattendedness for OpenCode is carried by auto_accept (set above), not
// 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.
return { modeId: undefined, featureValues };
return { modeId: undefined, providerOptions };
}
const resolved = resolveDefaultAgentCreateConfig({
...input,
requestedMode,
featureValues,
providerOptions,
});
return { ...resolved, featureValues };
return { ...resolved, providerOptions };
}
function isOpenCodeCreateConfigUnattended(
@@ -285,26 +288,18 @@ function isOpenCodeCreateConfigUnattended(
): boolean {
return (
isDefaultAgentCreateConfigUnattended(input) ||
input.config.featureValues?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true ||
input.features?.some(
(feature) =>
feature.id === OPENCODE_AUTO_ACCEPT_FEATURE_ID &&
(feature.value === true || feature.value === "true"),
) === true
isToggleOn(OPENCODE_AUTO_ACCEPT_FEATURE_ID, input.config.providerOptions, input.controls)
);
}
function buildOpenCodeAutoAcceptFeature(config: AgentSessionConfig): AgentFeature {
return {
type: "toggle",
id: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
label: "Auto Accept",
description: "Automatically approves OpenCode tool permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
value: isOpenCodeAutoAcceptEnabled(config),
};
}
const OPENCODE_AUTO_ACCEPT_CONTROL: AgentOptionToggleControl = {
type: "toggle",
key: OPENCODE_AUTO_ACCEPT_FEATURE_ID,
label: "Auto Accept",
description: "Automatically approves OpenCode tool permission prompts.",
tooltip: "Auto accept permission prompts",
icon: "shield-check",
};
function buildOpenCodePermissionActions(): AgentPermissionAction[] {
return [
@@ -682,10 +677,7 @@ function normalizeOpenCodeConfig(config: OpenCodeAgentConfig): OpenCodeAgentConf
return {
...normalized,
modeId: OPENCODE_BUILD_MODE_ID,
featureValues: {
...normalized.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true,
},
providerOptions: withOpenCodeAutoAccept(normalized.providerOptions, true),
};
}
@@ -1679,8 +1671,9 @@ export class OpenCodeAgentClient implements AgentClient {
}
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
return [buildOpenCodeAutoAcceptFeature(this.assertConfig(config))];
async listControls(config: AgentSessionConfig): Promise<AgentOptionControl[]> {
this.assertConfig(config);
return [OPENCODE_AUTO_ACCEPT_CONTROL];
}
async listImportableSessions(
@@ -3565,8 +3558,8 @@ class OpenCodeAgentSession implements AgentSession {
return this.turnState.status === "running" ? this.turnState.turnId : null;
}
get features(): AgentFeature[] {
return [buildOpenCodeAutoAcceptFeature(this.config)];
get controls(): AgentOptionControl[] {
return [{ ...OPENCODE_AUTO_ACCEPT_CONTROL, defaultValue: this.autoAcceptEnabled }];
}
async getRuntimeInfo(): Promise<AgentRuntimeInfo> {
@@ -4956,7 +4949,15 @@ class OpenCodeAgentSession implements AgentSession {
const normalizedModeId = normalizeOpenCodeModeId(modeId);
if (normalizedModeId === OPENCODE_LEGACY_FULL_ACCESS_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;
}
@@ -4964,17 +4965,15 @@ class OpenCodeAgentSession implements AgentSession {
this.config.modeId = normalizedModeId ?? undefined;
}
async setFeature(featureId: string, value: unknown): Promise<void> {
if (featureId !== OPENCODE_AUTO_ACCEPT_FEATURE_ID) {
throw new Error(`Unsupported OpenCode feature '${featureId}'`);
}
async applyOptions(options: ProviderOptions): Promise<void> {
// An absent option leaves auto-accept as it is.
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.config.featureValues = {
...this.config.featureValues,
[OPENCODE_AUTO_ACCEPT_FEATURE_ID]: enabled,
};
this.config.providerOptions = withOpenCodeAutoAccept(this.config.providerOptions, enabled);
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -10,6 +10,8 @@ const PermissionRuleSchema = z.union([
// OpenCode Config.permission, maintained against @opencode-ai/sdk 1.14.46.
export const OpenCodeProviderOptionsSchema = z
.object({
// Composer control: approves permission prompts without asking.
auto_accept: z.boolean().optional(),
permission: z
.union([
PermissionActionSchema,
@@ -100,11 +100,11 @@ test.each([
});
try {
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",
);
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",
);
} finally {
@@ -202,7 +202,7 @@ test.each([
const legacy = new OpenCodeAgentClient(logger);
const config = { provider: "opencode", cwd: root };
try {
expect(await client.listFeatures(config)).toEqual(await legacy.listFeatures(config));
expect(await client.listControls(config)).toEqual(await legacy.listControls(config));
} finally {
await client.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" },
});
try {
const features = await client.listFeatures({ provider: "opencode", cwd: "/tmp/project" });
expect(features.map((feature) => feature.label)).toEqual(["Auto-accept"]);
const controls = await client.listControls({ provider: "opencode", cwd: "/tmp/project" });
expect(controls.map((control) => control.label)).toEqual(["Auto-accept"]);
} finally {
await client.shutdown();
}
@@ -160,8 +160,8 @@ export class OpenCodeRuntimeClient implements AgentClient {
async listCommands(config: AgentSessionConfig) {
return (await this.client()).listCommands(config);
}
async listFeatures(config: AgentSessionConfig) {
return (await this.client()).listFeatures(config);
async listControls(config: AgentSessionConfig) {
return (await this.client()).listControls(config);
}
async listImportableSessions(options?: ListImportableSessionsOptions) {
return (await this.client()).listImportableSessions(options);
@@ -1,7 +1,7 @@
import { OpenCodeV2Session } from "./session.js";
import { V2_CAPABILITIES } from "./capabilities.js";
import {
features,
controls,
permissionRules,
applyResumeOverrides,
requiresDedicatedV2Server,
@@ -17,7 +17,7 @@ import type {
AgentCapabilityFlags,
AgentClient,
AgentCreateSessionOptions,
AgentFeature,
AgentOptionControl,
AgentLaunchContext,
AgentPersistenceHandle,
AgentSession,
@@ -222,8 +222,8 @@ export class OpenCodeV2AgentClient implements AgentClient {
throw error;
}
}
async listFeatures(config: AgentSessionConfig): Promise<AgentFeature[]> {
return features(config);
async listControls(): Promise<AgentOptionControl[]> {
return controls;
}
async listCommands(config: AgentSessionConfig) {
const connection = await this.runtime.acquire();
@@ -1,6 +1,6 @@
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", () => {
test("maps authored native permissions and exact injected MCP grants", () => {
@@ -49,17 +49,8 @@ describe("OpenCode v2 helper isolation", () => {
});
});
describe("OpenCode v2 features", () => {
test("reports the auto-accept toggle from the session value", () => {
expect(features({ provider: "opencode", cwd: "/tmp/project" })).toEqual([
{ 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 }]);
describe("OpenCode v2 controls", () => {
test("describes the auto-accept toggle over the auto_accept option", () => {
expect(controls).toEqual([{ type: "toggle", key: "auto_accept", label: "Auto-accept" }]);
});
});
@@ -2,8 +2,9 @@ import type { SessionInfo, PermissionRuleset } from "@opencode/client";
import type { V2Api } from "./api.js";
import type {
AgentFeature,
AgentLaunchContext,
AgentOptionControl,
AgentOptionToggleControl,
AgentSessionConfig,
} from "../../../agent-sdk-types.js";
@@ -11,15 +12,15 @@ import { OpenCodeProviderOptionsSchema, buildOpenCodePermissionRules } from "../
import { modelRef } from "./mapping.js";
export function features(config: AgentSessionConfig): AgentFeature[] {
return [
{
type: "toggle",
id: "auto_accept",
label: "Auto-accept",
value: config.featureValues?.["auto_accept"] === true,
},
];
const autoAcceptControl: AgentOptionToggleControl = {
type: "toggle",
key: "auto_accept",
label: "Auto-accept",
};
export const controls: AgentOptionControl[] = [autoAcceptControl];
/** 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 {
const rules =
@@ -93,7 +93,7 @@ export class SessionPermissions {
};
this.pending.set(request.id, pending);
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" });
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 { SessionTurns } from "./turns.js";
import { V2Timeline } from "./timeline.js";
@@ -31,7 +32,7 @@ import { type V2Connection } from "./runtime.js";
import { modelRef, modesFromV2 } from "./mapping.js";
import { V2_CAPABILITIES } from "./capabilities.js";
import { features } from "./configuration.js";
import { controlsFor } from "./configuration.js";
import { commands } from "./commands.js";
import { messages } from "./history.js";
import { SessionPermissions } from "./permissions.js";
@@ -118,8 +119,8 @@ export class OpenCodeV2Session implements AgentSession {
get id() {
return this.info.id;
}
get features() {
return features(this.config);
get controls() {
return controlsFor(this.config.providerOptions?.auto_accept === true);
}
private get 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.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant });
}
async setFeature(id: string, value: unknown) {
if (id !== "auto_accept" || typeof value !== "boolean")
throw new Error("Unknown OpenCode feature");
this.config.featureValues = { ...this.config.featureValues, [id]: value };
async applyOptions(options: ProviderOptions) {
// An absent key leaves auto-accept as it is.
const value = options["auto_accept"];
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)
for (const request of this.permissions.list())
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 client = createClient(pi);
await expect(
client.listFeatures(createConfig({ model: "openrouter/test/model" })),
client.listControls(createConfig({ model: "openrouter/test/model" })),
).resolves.toEqual([]);
expect(pi.recordedLaunches).toHaveLength(0);
@@ -10,7 +10,7 @@ import { z } from "zod";
import {
type AgentCapabilityFlags,
type AgentClient,
type AgentFeature,
type AgentOptionControl,
type AgentLaunchContext,
type AgentMetadata,
type AgentMode,
@@ -2828,7 +2828,7 @@ export class PiRpcAgentClient implements AgentClient {
}
}
async listFeatures(_config: AgentSessionConfig): Promise<AgentFeature[]> {
async listControls(_config: AgentSessionConfig): Promise<AgentOptionControl[]> {
return [];
}
@@ -18,6 +18,7 @@ import type { AgentMode, AgentProvider, AgentSessionConfig } from "../agent-sdk-
import type { AgentManager } from "../agent-manager.js";
import { ActiveTurnBehaviorSchema, AgentProfileSchema } from "@getpaseo/protocol/messages";
import type { DaemonConfigStore } from "../../daemon-config-store.js";
import { featuresFromControls } from "../option-controls.js";
import {
AgentFeatureSchema,
AgentPermissionRequestPayloadSchema,
@@ -939,9 +940,6 @@ function createToolCatalog(
...(callerAgent.provider === resolvedProvider && callerAgent.config.providerOptions
? { providerOptions: callerAgent.config.providerOptions }
: {}),
...(callerAgent.config.featureValues
? { featureValues: callerAgent.config.featureValues }
: {}),
...(callerAgent.config.systemPrompt ? { systemPrompt: callerAgent.config.systemPrompt } : {}),
...(callerAgent.config.mcpServers ? { mcpServers: callerAgent.config.mcpServers } : {}),
};
@@ -1638,7 +1636,7 @@ function createToolCatalog(
cwd: resolvedArgs.cwd,
workspaceId: resolvedArgs.workspaceId,
thinking: parsedArgs.settings?.thinkingOptionId,
features: parsedArgs.settings?.features,
providerOptions: parsedArgs.settings?.features,
labels: {
...parsedArgs.labels,
...(callerAgentId && !resolvedArgs.detached && options.serverId
@@ -2290,9 +2288,7 @@ function createToolCatalog(
await agentManager.setAgentThinkingOption(agentId, settings.thinkingOptionId);
}
if (settings?.features) {
for (const [featureId, value] of Object.entries(settings.features)) {
await agentManager.setAgentFeature(agentId, featureId, value);
}
await agentManager.setAgentOptions(agentId, settings.features);
}
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 " +
"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 " +
"`featureValues` into create_agent (there is no `profile` parameter). Returns an empty " +
"list if none are configured.",
"`options` into create_agent as `settings.features` (there is no `profile` parameter). " +
"Returns an empty list if none are configured.",
inputSchema: {},
outputSchema: {
profiles: z.array(AgentProfileSchema),
@@ -3124,13 +3120,13 @@ function createToolCatalog(
throw new PaseoToolInputError(entry.error ?? `Provider '${providerId}' is unavailable`);
}
const selectedModel = settings?.model ?? resolvedProviderModel.model;
const features = await agentManager.listDraftFeatures({
const { controls, options: effectiveOptions } = await agentManager.listDraftControls({
provider: providerId,
cwd: resolvedCwd,
...(settings?.modeId ? { modeId: settings.modeId } : {}),
...(selectedModel ? { model: selectedModel } : {}),
...(settings?.thinkingOptionId ? { thinkingOptionId: settings.thinkingOptionId } : {}),
...(settings?.features ? { featureValues: settings.features } : {}),
...(settings?.features ? { providerOptions: settings.features } : {}),
});
return {
content: [],
@@ -3142,7 +3138,7 @@ function createToolCatalog(
status: summary.status,
modes: summary.modes,
selectedModel: selectedModel ?? null,
features,
features: featuresFromControls(controls, effectiveOptions),
}),
};
},
@@ -119,6 +119,7 @@ const INBOUND_PERMISSION = {
list_available_providers_request: "daemon.read",
list_commands_request: "daemon.read",
list_provider_features_request: "daemon.read",
"provider.controls.list.request": "daemon.read",
list_provider_models_request: "daemon.read",
list_provider_modes_request: "daemon.read",
list_terminals_request: "workspace.read",
@@ -356,6 +357,7 @@ const OUTBOUND_PERMISSION = {
list_available_providers_response: "daemon.read",
list_commands_response: "daemon.read",
list_provider_features_response: "daemon.read",
"provider.controls.list.response": "daemon.read",
list_provider_models_response: "daemon.read",
list_provider_modes_response: "daemon.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());
});
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 () => {
const reservation = createServer();
reservation.listen(0, "127.0.0.1");
@@ -7,12 +7,10 @@ import { z } from "zod";
import {
createHostClient,
createHostChannelTransport,
createHostTransportFactory,
parseHostTarget,
HostTransportBudget,
} from "@getpaseo/client/node";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { DaemonTransportFactory } from "@getpaseo/client/internal/daemon-client-transport-types";
import {
HostSummarySchema,
HostAddSchema,
@@ -237,30 +235,23 @@ export class ConnectedHosts {
if (route.registrationId === id)
route.close(code, "Host access was disabled, replaced or removed");
}
private start(
row: Saved,
resume?: { failures: number; transports: DaemonTransportFactory },
): void {
private start(row: Saved): void {
if (!row.enabled || this.closed) return;
const transports =
resume?.transports ??
createHostTransportFactory(row.connection, this.budget, {
webSocketAgent: webSocketProxyAgent,
});
let stopped = false;
let attempts = resume?.failures ?? 0;
let retry: ReturnType<typeof setTimeout> | undefined;
const client = createHostClient(row.connection, {
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
transportFactory: transports,
});
const client = createHostClient(
row.connection,
{
clientId: `host-link-${this.self.serverId}-${row.registrationId}`,
connectTimeoutMs: 10000,
webSocketAgent: webSocketProxyAgent,
},
this.budget,
);
const live: Live = {
client,
accepted: false,
stop: () => {
stopped = true;
clearTimeout(retry);
void client.close();
},
};
@@ -294,7 +285,7 @@ export class ConnectedHosts {
if (info.features?.connectedHosts !== true || info.features?.hostMultiplexing !== true)
throw new HostError(
"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.
await this.mutate(async () => {
@@ -313,22 +304,16 @@ export class ConnectedHosts {
if (stopped || client.getLastServerInfoMessage() !== info || !client.isConnected) return;
Object.assign(row, next);
live.accepted = true;
attempts = 0;
this.changed();
});
} catch (error) {
if (stopped) return;
row.status = "error";
row.error = connectionFailure(error, client.authFailureReason !== null) ?? {
code: "host_offline",
message: "Host connection failed",
};
if (terminalFailures.has(row.error.code)) {
row.status = "error";
row.nextRetryAt = null;
} else {
stopped = true;
retry = this.scheduleAcceptanceRetry(row, attempts, transports);
}
row.nextRetryAt = null;
this.changed();
await client.close();
}
@@ -344,17 +329,18 @@ export class ConnectedHosts {
if (route.registrationId === row.registrationId) route.close();
row.status = state.status === "connecting" ? "connecting" : "offline";
row.nextRetryAt = state.status === "disconnected" ? (state.nextRetryAt ?? null) : null;
if (state.status === "disconnected")
row.error = connectionFailure(
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
code: state.failureCode,
}),
client.authFailureReason !== null,
) ?? {
code: "host_offline",
message: "Cannot reach host. Check the connection; retrying automatically.",
};
else if (row.error?.code !== "unsupported_capability") row.error = null;
row.error =
state.status === "disconnected"
? (connectionFailure(
Object.assign(new Error("Check the SSH configuration and daemon credentials"), {
code: state.failureCode,
}),
client.authFailureReason !== null,
) ?? {
code: "host_offline",
message: "Cannot reach host. Check the connection; retrying automatically.",
})
: null;
if (row.error?.code === "authentication_failed") {
row.status = "error";
row.nextRetryAt = null;
@@ -365,18 +351,6 @@ export class ConnectedHosts {
});
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: {
serverId: 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"] {
if (error instanceof HostError) return { code: error.code, message: error.message };
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 () => {
const daemon = await createTestPaseoDaemon();
const client = new DaemonClient({
@@ -3,7 +3,11 @@ import { tmpdir } from "node:os";
import path from "node:path";
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 { loadPersistedConfig } from "./persisted-config.js";
import { readSpeechSettings } from "./speech/speech-settings.js";
@@ -196,13 +200,83 @@ describe("DaemonConfigStore", () => {
model: "claude-opus-5",
modeId: "plan",
thinkingOptionId: "think-hard",
featureValues: { webSearch: true },
options: { webSearch: true },
notes: "Use for components, layout and design tokens.",
},
]);
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 () => {
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
tempDirs.push(paseoHome);
@@ -1274,6 +1348,35 @@ describe("DaemonConfigStore reload", () => {
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 () => {
const { paseoHome, store, persisted } = createReloadableStore({
overrideControlledPaths: ["daemon.relay.enabled"],
@@ -5,6 +5,11 @@ import {
type PersistedConfig,
} from "./persisted-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 {
MutableDaemonConfigSchema,
MutableDaemonConfigPatchSchema,
@@ -168,6 +173,24 @@ function omitProvidersFromOverrides(
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 {
return path
.split(".")
@@ -380,7 +403,7 @@ export class DaemonConfigStore {
this.startupPersisted = options.startupPersisted ?? loadPersistedConfig(paseoHome, this.logger);
this.lastKnownPersisted = this.startupPersisted;
this.current = MutableDaemonConfigSchema.parse({
...initial,
...foldConfigProfiles(initial, "stored"),
relay: initial.relay ?? { enabled: true },
...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.",
);
}
const { removeProviders = [], ...patch } = parsedPatch;
const { removeProviders = [], ...submittedPatch } = parsedPatch;
const patch = foldConfigProfiles(submittedPatch, "client write");
const { features: speechPatch, ...configPatch } = patch;
const removedProviders = Array.from(new Set(removeProviders));
const merged = deepMerge(this.current, configPatch);
@@ -437,7 +461,16 @@ export class DaemonConfigStore {
const configChanged = !isEqualValue(this.current, next);
// 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;
}
@@ -495,7 +528,7 @@ export class DaemonConfigStore {
// Plugin source changes require the plugin lifecycle operation or a daemon
// restart. The global switch is independently reloadable.
const desired = MutableDaemonConfigSchema.parse({
...resolved.mutable,
...foldConfigProfiles(resolved.mutable, "stored"),
plugins: this.current.plugins,
...this.readSpeech(persisted),
});
@@ -88,9 +88,13 @@ test.each(["uncommitted", "base", "commit"] as const)(
60_000,
);
test.each(["single", "batch", "rename"] as const)(
"%s diff includes hunks from a repository subdirectory before and after commit",
async (shape) => {
test.each(
(["uncommitted", "base"] as const).flatMap((mode) =>
(["single", "batch", "rename"] as const).map((shape) => ({ mode, shape })),
),
)(
"$mode $shape diff includes hunks from a repository subdirectory",
async ({ mode, shape }) => {
const ctx = await createDaemonTestContext();
const cwd = mkdtempSync(join(tmpdir(), "checkout-diff-subdirectory-"));
@@ -108,7 +112,9 @@ test.each(["single", "batch", "rename"] as const)(
}
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "initial");
git(cwd, "checkout", "-b", "feature");
if (mode === "base") {
git(cwd, "checkout", "-b", "feature");
}
const filename = shape === "rename" ? "renamed.txt" : "normal.txt";
if (shape === "rename") {
git(cwd, "mv", "sub/normal.txt", `sub/${filename}`);
@@ -117,38 +123,36 @@ test.each(["single", "batch", "rename"] as const)(
if (shape === "batch") {
writeFileSync(join(cwd, "sibling.txt"), "new sibling\n");
}
async function expectSubdirectoryDiff(mode: "uncommitted" | "base") {
const compare = { mode, baseRef: "main" };
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
if (mode === "base") {
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
}
await expectSubdirectoryDiff("uncommitted");
git(cwd, "add", ".");
git(cwd, "-c", "commit.gpgsign=false", "commit", "-m", "edit");
await expectSubdirectoryDiff("base");
const compare = { mode, baseRef: "main" };
const rootDiff = await ctx.client.getCheckoutDiff(cwd, compare);
const subdirectoryDiff = await ctx.client.getCheckoutDiff(subdirectory, compare);
const subscription = ctx.client.observeCheckoutDiff(subdirectory, compare);
const liveDiff = await subscription.ready;
await subscription.release();
expect(rootDiff.error).toBeNull();
expect(subdirectoryDiff.error).toBeNull();
expect(liveDiff.error).toBeNull();
expect(liveDiff.files).toEqual(rootDiff.files);
expect(rootDiff.files).toHaveLength(shape === "batch" ? 2 : 1);
const file = rootDiff.files.find((candidate) => candidate.path === `sub/${filename}`);
expect(file).toMatchObject({
additions: 1,
deletions: 1,
...(shape === "rename" ? { oldPath: "sub/normal.txt" } : {}),
});
expect(file?.hunks.flatMap((hunk) => hunk.lines)).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "remove", content: "before" }),
expect.objectContaining({ type: "add", content: "after" }),
]),
);
expect(subdirectoryDiff.files).toEqual(rootDiff.files);
} finally {
await ctx.cleanup();
rmSync(cwd, { recursive: true, force: true });
@@ -86,7 +86,10 @@ test("Hub MCP configuration reaches the provider alongside Paseo MCP without ent
cwd: response.payload.agent.cwd,
});
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");
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,
});
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");
});
@@ -23,7 +23,6 @@ export interface HubExecutionAgentCreateInput {
model?: string;
modeId?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
providerOptions?: ProviderOptions;
toolPolicy?: ToolPolicy;
env?: Record<string, string>;
@@ -193,7 +192,6 @@ export class DaemonExecutions implements HubExecutionAgents {
cwd: input.cwd,
mode: input.modeId,
thinking: input.thinkingOptionId,
features: input.featureValues,
env: input.env,
...(input.mcpServers || input.providerOptions || input.toolPolicy
? {
@@ -13,6 +13,7 @@ import {
} from "../agent/provider-options.js";
import type { HubExecutionAgents, OwnedAgentEvent } from "./daemon-executions.js";
import { foldFeatureValues } from "../agent/option-controls.js";
interface HubExecutionControllerOptions {
agents: HubExecutionAgents;
@@ -167,8 +168,8 @@ export class HubExecutionController {
model: message.model,
modeId: message.modeId,
thinkingOptionId: message.thinkingOptionId,
featureValues: message.featureValues,
providerOptions: message.providerOptions,
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
providerOptions: foldFeatureValues(message).providerOptions,
toolPolicy: message.toolPolicy,
env: message.env,
mcpServers: message.mcpServers,
@@ -440,7 +440,7 @@ type AcceptedCreate = Omit<
const providerCatalog = {
async resolveCreateConfig(input) {
return { modeId: input.requestedMode, featureValues: input.featureValues };
return { modeId: input.requestedMode, providerOptions: input.providerOptions };
},
} satisfies CreateAgentCommandDependencies["providerSnapshotManager"];
@@ -73,7 +73,6 @@ export function buildConfigOverrides(record: StoredAgentRecord): Partial<AgentSe
modeId: record.lastModeId ?? record.config?.modeId ?? undefined,
model: record.config?.model ?? undefined,
thinkingOptionId: record.config?.thinkingOptionId ?? undefined,
featureValues: record.config?.featureValues ?? undefined,
providerOptions: record.config?.providerOptions ?? undefined,
toolPolicy: record.config?.toolPolicy ?? undefined,
systemPrompt: record.config?.systemPrompt ?? undefined,
@@ -95,7 +94,6 @@ export function buildSessionConfig(
modeId: overrides.modeId,
model: overrides.model,
thinkingOptionId: overrides.thinkingOptionId,
featureValues: overrides.featureValues,
providerOptions: overrides.providerOptions,
toolPolicy: overrides.toolPolicy,
systemPrompt: overrides.systemPrompt,
@@ -20,7 +20,6 @@ import { DaemonConfigStore } from "../daemon-config-store.js";
import { PluginService } from "./index.js";
import { BuiltinPluginLoader } from "./builtin/index.js";
import { ManagedPluginSources } from "./managed-source.js";
import type { PluginProviderMetadata } from "./plugin-process-protocol.js";
import {
startNpmRegistry,
npmPluginPackages,
@@ -180,55 +179,6 @@ function createPluginSelectivePausedRuntime(pausedPluginId: string) {
return { runtime, started, releaseStart, starts };
}
function createGatedRuntime(providers: Record<string, readonly PluginProviderMetadata[]> = {}) {
const gates = new Map<string, PromiseWithResolvers<void>>();
const gate = (pluginId: string) => {
const existing = gates.get(pluginId);
if (existing) return existing;
const created = Promise.withResolvers<void>();
gates.set(pluginId, created);
return created;
};
const begun: string[] = [];
const running = new Set<string>();
const runtime: TestPluginRuntime = {
...emptyUsageRuntime,
catalog: () => [...running].map((id) => ({ id, clientBundle: "bundle" })),
invoke: async () => undefined,
getLogs: () => [],
clearLogs: () => undefined,
getProviderRegistrations: (pluginId) =>
running.has(pluginId) ? (providers[pluginId] ?? []) : [],
startPlugin: async (pluginId) => {
begun.push(pluginId);
await gate(pluginId).promise;
running.add(pluginId);
},
stopPluginById: async (pluginId) => running.delete(pluginId),
stopAll: async () => {
running.clear();
},
subscribe: () => () => undefined,
bindPaseoSessionHost: () => undefined,
};
return {
runtime,
begun,
finish: (pluginId: string) => gate(pluginId).resolve(),
fail: (pluginId: string, error: Error) => gate(pluginId).reject(error),
};
}
function nextNotification(service: PluginService, pluginId: string): Promise<void> {
return new Promise((resolve) => {
const unsubscribe = service.subscribe((notified) => {
if (notified !== pluginId) return;
unsubscribe();
resolve();
});
});
}
describe("PluginService", () => {
it.each([
{ shadow: false, runtime: "subprocess" },
@@ -422,127 +372,15 @@ describe("PluginService", () => {
"startup-first": { source: "directory", path: first },
"startup-second": { source: "directory", path: second },
});
const notifications: { pluginId: string; running: boolean }[] = [];
service.subscribe((pluginId) =>
notifications.push({ pluginId, running: catalogIds(service).includes(pluginId) }),
);
const snapshots: string[][] = [];
service.subscribe(() => snapshots.push(catalogIds(service)));
await service.start();
expect(notifications).toEqual([
{ pluginId: "startup-first", running: true },
{ pluginId: "startup-second", running: true },
]);
expect(catalogIds(service)).toEqual(["startup-first", "startup-second"]);
expect(snapshots).toEqual([["startup-first"], ["startup-first", "startup-second"]]);
await service.stopAllPlugins();
}, 20_000);
it("starts configured plugins concurrently and publishes them in config order", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
alpha: { source: "directory", path: "/plugins/alpha" },
beta: { source: "directory", path: "/plugins/beta" },
gamma: { source: "directory", path: "/plugins/gamma" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
let started = false;
const starting = (async () => {
await service.start();
started = true;
})();
await expect.poll(() => gated.begun).toEqual(["alpha", "beta", "gamma"]);
gated.finish("gamma");
const alphaPublished = nextNotification(service, "alpha");
gated.finish("alpha");
await alphaPublished;
expect(notified).toEqual(["alpha"]);
expect(started).toBe(false);
gated.finish("beta");
await starting;
expect(notified).toEqual(["alpha", "beta", "gamma"]);
expect(catalogIds(service)).toEqual(["alpha", "beta", "gamma"]);
await service.stopAllPlugins();
});
it("starts the other configured plugins when one fails while they are still starting", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime();
const service = createService(
home,
{
broken: { source: "directory", path: "/plugins/broken" },
healthy: { source: "directory", path: "/plugins/healthy" },
},
{ runtime: gated.runtime },
);
const notified: string[] = [];
service.subscribe((pluginId) => notified.push(pluginId));
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["broken", "healthy"]);
const brokenPublished = nextNotification(service, "broken");
gated.fail("broken", new Error("startup exploded"));
await brokenPublished;
gated.finish("healthy");
await starting;
expect(notified).toEqual(["broken", "healthy"]);
expect(catalogIds(service)).toEqual(["healthy"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "broken", status: "failed", error: "startup exploded" },
{ id: "healthy", status: "running" },
]);
await service.stopAllPlugins();
});
it("keeps a provider ID with the plugin listed first when both start at once", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
const gated = createGatedRuntime({
first: [{ id: "shared-agent", label: "First agent" }],
second: [{ id: "shared-agent", label: "Second agent" }],
});
const service = createService(
home,
{
first: { source: "directory", path: "/plugins/first" },
second: { source: "directory", path: "/plugins/second" },
},
{ runtime: gated.runtime },
);
const starting = service.start();
await expect.poll(() => gated.begun).toEqual(["first", "second"]);
gated.finish("second");
gated.finish("first");
await starting;
expect(service.getProviderRegistrations()).toMatchObject([
{ id: "shared-agent", label: "First agent" },
]);
expect(catalogIds(service)).toEqual(["first"]);
expect(await service.listPlugins()).toMatchObject([
{ id: "first", status: "running" },
{
id: "second",
status: "failed",
error: 'Plugin second cannot register provider ID "shared-agent" twice',
},
]);
await service.stopAllPlugins();
});
it("uses an explicit config key, exposes reload failure, and retries from disk", async () => {
const home = await mkdtemp(path.join(tmpdir(), "paseo-plugin-home-"));
roots.push(home);
+8 -20
View File
@@ -230,18 +230,11 @@ export class PluginService {
}
});
if (config.pluginsEnabled === true) {
let earlierPublished: Promise<unknown> = Promise.resolve();
await Promise.all(
Object.entries(config.plugins ?? {})
.filter(([, source]) => source.enabled !== false)
.map(async ([pluginId]) => {
const publishTurn = earlierPublished;
const starting = this.startConfigured(pluginId, publishTurn);
earlierPublished = Promise.all([publishTurn, starting]);
await starting;
this.notify(pluginId);
}),
);
for (const [pluginId, source] of Object.entries(config.plugins ?? {})) {
if (source.enabled === false) continue;
await this.startConfigured(pluginId);
this.notify(pluginId);
}
}
this.configStore.onFieldChange("pluginsEnabled", (value) => {
this.handleGlobalSwitch(value === true);
@@ -567,12 +560,12 @@ export class PluginService {
}).catch((error) => this.logger.error({ err: error }, "Failed to enable plugins"));
}
private async startConfigured(pluginId: string, publishTurn?: Promise<unknown>): Promise<void> {
private async startConfigured(pluginId: string): Promise<void> {
const source = this.configStore.get().plugins?.[pluginId];
if (!source || source.enabled === false || !this.canPublish(pluginId)) return;
this.errors.delete(pluginId);
try {
await this.startPlugin(pluginId, source.path, publishTurn);
await this.startPlugin(pluginId, source.path);
} catch (error) {
if (this.canPublish(pluginId)) this.recordFailure(pluginId, error);
}
@@ -601,13 +594,8 @@ export class PluginService {
);
}
private async startPlugin(
pluginId: string,
sourcePath: string,
publishTurn?: Promise<unknown>,
): Promise<void> {
private async startPlugin(pluginId: string, sourcePath: string): Promise<void> {
await this.runtime.startPlugin(pluginId, sourcePath, () => this.canPublish(pluginId));
await publishTurn;
try {
await this.publishProviderRegistrations(pluginId, sourcePath);
this.publishUsageSources(pluginId);
@@ -88,7 +88,7 @@ const NO_UNATTENDED_SCHEDULE_POLICY: Pick<ProviderSnapshotManager, "resolveCreat
async resolveCreateConfig(input) {
return {
modeId: input.unattended ? input.requestedMode : "interactive",
featureValues: input.featureValues,
providerOptions: input.providerOptions,
};
},
};
@@ -1911,7 +1911,7 @@ describe("ScheduleService", () => {
});
return {
modeId: input.unattended ? "bypassPermissions" : "interactive",
featureValues: input.featureValues,
providerOptions: input.providerOptions,
};
},
},
@@ -1980,9 +1980,9 @@ describe("ScheduleService", () => {
});
return {
modeId: input.unattended ? "build" : "interactive",
featureValues: input.unattended
? { ...input.featureValues, auto_accept: true }
: input.featureValues,
providerOptions: input.unattended
? { ...input.providerOptions, auto_accept: true }
: input.providerOptions,
};
},
},
@@ -2010,7 +2010,7 @@ describe("ScheduleService", () => {
expect(inspected.runs[0]?.error).toBeNull();
expect(createdConfigs[0]).toMatchObject({
modeId: "build",
featureValues: { auto_accept: true },
providerOptions: { auto_accept: true },
});
});
@@ -2052,7 +2052,7 @@ describe("ScheduleService", () => {
});
return {
modeId: input.requestedMode,
featureValues: { ...input.featureValues, resolved: true },
providerOptions: { ...input.providerOptions, resolved: true },
};
},
},
@@ -2099,11 +2099,13 @@ describe("ScheduleService", () => {
model: "test-model",
modeId: "stored-mode",
thinkingOptionId: "think-hard",
// Stored schedules written before options carry `featureValues`; launch folds them.
providerOptions: {
allowedTools: ["Read"],
sandbox: { enabled: true, network: { allowLocalBinding: true } },
auto_accept: true,
resolved: true,
},
featureValues: { auto_accept: true, resolved: true },
systemPrompt: "Stay concise.",
mcpServers: {
docs: {
@@ -15,6 +15,7 @@ import type { PersistedWorkspaceRecord } from "../workspace-registry.js";
import type { CreatePaseoWorktreeWorkflowResult } from "../worktree-session.js";
import { ScheduleStore } from "./store.js";
import { computeNextRunAt, validateScheduleCadence } from "./cron.js";
import { foldFeatureValues } from "../agent/option-controls.js";
import type {
CreateScheduleInput,
ScheduleExecutionResult,
@@ -998,7 +999,6 @@ export class ScheduleService {
},
mode: config.modeId,
thinking: config.thinkingOptionId,
features: config.featureValues,
unattended: true,
promptFailure: "return-error",
notifyOnFinish: false,
@@ -1098,8 +1098,8 @@ function buildScheduleAgentConfig(
model: config.model,
thinkingOptionId: config.thinkingOptionId,
title: config.title,
providerOptions: config.providerOptions,
featureValues: config.featureValues,
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
providerOptions: foldFeatureValues(config).providerOptions,
systemPrompt: config.systemPrompt,
mcpServers: config.mcpServers as AgentSessionConfig["mcpServers"],
};
+2 -2
View File
@@ -5816,7 +5816,7 @@ describe("agent config setters", () => {
const messages: SessionOutboundMessage[] = [];
const session = createSessionForTest({
messages,
agentManager: liveAgentManager({ setAgentFeature: vi.fn().mockResolvedValue(undefined) }),
agentManager: liveAgentManager({ setAgentOptions: vi.fn().mockResolvedValue(undefined) }),
});
await session.handleMessage({
@@ -5840,7 +5840,7 @@ describe("agent config setters", () => {
const session = createSessionForTest({
messages,
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,
type WorkspaceScriptsService,
} 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 { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
@@ -1036,7 +1036,7 @@ export class Session {
supportsProviderSnapshotReferences: () =>
this.supports(CLIENT_CAPS.providerSnapshotReferences),
listProviderAvailability: () => this.agentManager.listProviderAvailability(),
listDraftFeatures: (config) => this.agentManager.listDraftFeatures(config),
listDraftControls: (config) => this.agentManager.listDraftControls(config),
},
providerSnapshotManager,
logger: this.sessionLogger,
@@ -1066,8 +1066,7 @@ export class Session {
setMode: async (agentId, modeId) =>
(await setAgentModeCommand({ agentManager }, { agentId, modeId })).notice,
setModel: (agentId, modelId) => agentManager.setAgentModel(agentId, modelId),
setFeature: (agentId, featureId, value) =>
agentManager.setAgentFeature(agentId, featureId, value),
setOptions: (agentId, patch) => agentManager.setAgentOptions(agentId, patch),
setThinking: (agentId, thinkingOptionId) =>
agentManager.setAgentThinkingOption(agentId, thinkingOptionId),
},
@@ -2950,7 +2949,10 @@ export class Session {
case "get_daemon_config_request":
this.emit({
type: "get_daemon_config_response",
payload: { requestId: msg.requestId, config: this.daemonConfigStore.get() },
payload: {
requestId: msg.requestId,
config: toWireDaemonConfig(this.daemonConfigStore.get()),
},
});
return undefined;
case "daemon.get_status.request":
@@ -2974,7 +2976,7 @@ export class Session {
type: "set_daemon_config_response",
payload: {
requestId: msg.requestId,
config,
config: toWireDaemonConfig(config),
},
});
});
@@ -3181,6 +3183,8 @@ export class Session {
return this.providerCatalogSession.handleListProviderModesRequest(msg);
case "list_provider_features_request":
return this.providerCatalogSession.handleListProviderFeaturesRequest(msg);
case "provider.controls.list.request":
return this.providerCatalogSession.handleProviderControlsListRequest(msg);
case "list_available_providers_request":
return this.providerCatalogSession.handleListAvailableProvidersRequest(msg);
case "get_providers_snapshot_request":
@@ -12,7 +12,7 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
readonly loadedAgentIds: string[] = [];
readonly modeCalls: Array<{ agentId: string; modeId: string }> = [];
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 }> = [];
/** Cross-operation ordering, which the per-operation arrays above cannot show. */
readonly callLog: string[] = [];
@@ -39,9 +39,9 @@ class FakeAgentConfigOperations implements AgentConfigOperations {
if (this.failWith) throw this.failWith;
}
async setFeature(agentId: string, featureId: string, value: unknown): Promise<void> {
this.featureCalls.push({ agentId, featureId, value });
this.callLog.push(`feature:${featureId}`);
async setOptions(agentId: string, patch: Record<string, unknown>): Promise<void> {
this.optionsCalls.push({ agentId, patch });
this.callLog.push("options");
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();
await subsystem.handleSetAgentFeatureRequest({
@@ -217,9 +217,7 @@ describe("AgentConfigSession", () => {
requestId: "req-1",
});
expect(operations.featureCalls).toEqual([
{ agentId: "agent-1", featureId: "web_search", value: true },
]);
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { web_search: true } }]);
expect(emitted).toEqual([
{
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();
await subsystem.handleAgentConfigApplyRequest({
@@ -319,18 +317,16 @@ describe("AgentConfigSession", () => {
modelId: "opus-5",
modeId: "plan",
thinkingOptionId: "high",
featureValues: { webSearch: true },
options: { webSearch: true },
},
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.modeCalls).toEqual([{ agentId: "agent-1", modeId: "plan" }]);
expect(operations.thinkingCalls).toEqual([{ agentId: "agent-1", thinkingOptionId: "high" }]);
expect(operations.featureCalls).toEqual([
{ agentId: "agent-1", featureId: "webSearch", value: true },
]);
expect(operations.optionsCalls).toEqual([{ agentId: "agent-1", patch: { webSearch: true } }]);
expect(operations.loadedAgentIds).toEqual(["agent-1"]);
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 () => {
const { subsystem, operations } = makeSubsystem();
@@ -381,11 +410,11 @@ describe("AgentConfigSession", () => {
await subsystem.handleAgentConfigApplyRequest({
type: "agent.config.apply.request",
agentId: "agent-1",
config: { modeId: "plan", thinkingOptionId: "high", featureValues: { webSearch: true } },
config: { modeId: "plan", thinkingOptionId: "high", options: { webSearch: true } },
requestId: "req-1",
});
expect(operations.callLog).toEqual(["mode", "thinking", "feature:webSearch"]);
expect(operations.callLog).toEqual(["mode", "thinking", "options"]);
expect(emitted).toEqual([
{
type: "agent.config.apply.response",
@@ -3,6 +3,7 @@ import { v4 as uuidv4 } from "uuid";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import type { AgentConfigApply } from "@getpaseo/protocol/messages";
import type { AgentProviderNotice } from "../../agent/agent-sdk-types.js";
import type { ProviderOptionsPatch } from "../../agent/option-controls.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
* onto the AgentManager and loads a closed agent before mutation (mode still
* 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 {
ensureLoaded(agentId: string): Promise<void>;
setMode(agentId: string, modeId: string): Promise<AgentProviderNotice | null>;
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(
agentId: string,
thinkingOptionId: string | null,
@@ -112,8 +113,9 @@ export class AgentConfigSession {
logLabel: "set_agent_feature_request",
logFields: { agentId, featureId, value, requestId },
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 () => {
await this.operations.setFeature(agentId, featureId, value);
await this.operations.setOptions(agentId, { [featureId]: value });
return undefined;
},
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
* 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 —
* 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);
notice ??= thinkingNotice;
}
for (const [featureId, value] of Object.entries(config.featureValues ?? {})) {
await this.operations.setFeature(agentId, featureId, value);
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. `featureValues` is an options patch.
const optionsPatch = { ...config.featureValues, ...config.options };
if (Object.keys(optionsPatch).length > 0) {
await this.operations.setOptions(agentId, optionsPatch);
}
return notice;
@@ -13,7 +13,7 @@ import {
type ProviderSnapshot,
type ProviderSnapshotTransition,
} 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";
type SnapshotChangeHandler = (transition: ProviderSnapshotTransition) => void;
@@ -58,7 +58,7 @@ function makeSubsystem(options: MakeOptions = {}) {
},
supportsCompactProviderSnapshots: () => options.supportsCompactProviderSnapshots ?? false,
listProviderAvailability: async () => [],
listDraftFeatures: async () => [],
listDraftControls: async () => ({ controls: [], options: {} }),
...options.host,
};
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 () => {
const { subsystem, emitted } = makeSubsystem({
host: {
listDraftFeatures: async () => {
listDraftControls: async () => {
throw new Error("feature probe failed");
},
},
@@ -379,7 +469,7 @@ it("announces shared content without retransmitting models or hashing discovery
},
supportsProviderSnapshotReferences: () => references,
listProviderAvailability: async () => [],
listDraftFeatures: async () => [],
listDraftControls: async () => ({ controls: [], options: {} }),
},
});
const emitted: SessionOutboundMessage[] = [];
@@ -13,12 +13,14 @@ import {
} from "../../agent/provider-snapshot-manager.js";
import {
filterSelectableAgentModels,
type AgentFeature,
type AgentOptionControl,
type AgentProvider,
type AgentSessionConfig,
type ProviderSnapshotEntry,
} from "../../agent/agent-sdk-types.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";
// COMPAT(customModeIcons): the only mode icons known to clients before v0.1.84. Any
@@ -49,7 +51,9 @@ export interface ProviderCatalogSessionHost {
supportsProviderSnapshotReferences(): boolean;
publishSnapshot(project: () => SessionOutboundMessage | null): void;
listProviderAvailability(): Promise<ProviderAvailability[]>;
listDraftFeatures(config: AgentSessionConfig): Promise<AgentFeature[]>;
listDraftControls(
config: AgentSessionConfig,
): Promise<{ controls: AgentOptionControl[]; options: ProviderOptions }>;
}
export interface ProviderCatalogSessionOptions {
@@ -340,29 +344,59 @@ export class ProviderCatalogSession {
model?: string;
thinkingOptionId?: string;
featureValues?: Record<string, unknown>;
providerOptions?: ProviderOptions;
}): AgentSessionConfig {
// COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01.
const { providerOptions } = foldFeatureValues(draftConfig);
return {
provider: draftConfig.provider,
cwd: expandTilde(draftConfig.cwd),
...(draftConfig.modeId ? { modeId: draftConfig.modeId } : {}),
...(draftConfig.model ? { model: draftConfig.model } : {}),
...(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(
msg: Extract<SessionInboundMessage, { type: "list_provider_features_request" }>,
): Promise<void> {
const fetchedAt = new Date().toISOString();
try {
const sessionConfig = this.buildDraftAgentSessionConfig(msg.draftConfig);
const features = await this.host.listDraftFeatures(sessionConfig);
const { controls, options } = await this.host.listDraftControls(
this.buildDraftAgentSessionConfig(msg.draftConfig),
);
this.host.emit({
type: "list_provider_features_response",
payload: {
provider: msg.draftConfig.provider,
features,
features: featuresFromControls(controls, options),
error: null,
fetchedAt,
requestId: msg.requestId,
@@ -1,3 +1,4 @@
import type { ProviderOptions } from "@getpaseo/protocol/agent-types";
import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync, rmSync, readdirSync } from "node:fs";
import { appendFile, mkdir, readFile } from "node:fs/promises";
@@ -6,7 +7,7 @@ import path from "node:path";
import type {
AgentCapabilityFlags,
AgentClient,
AgentFeature,
AgentOptionControl,
AgentLaunchContext,
AgentMode,
AgentModelDefinition,
@@ -379,14 +380,13 @@ class FakeAgentSession implements AgentSession {
return this.providerName;
}
get features(): AgentFeature[] {
get controls(): AgentOptionControl[] {
return [
{
type: "toggle",
id: TEST_FEATURE_ID,
key: TEST_FEATURE_ID,
label: "Test feature",
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;
}
async setFeature(featureId: string, value: unknown): Promise<void> {
this.config.featureValues = {
...this.config.featureValues,
[featureId]: value,
};
async applyOptions(options: ProviderOptions): Promise<void> {
this.config.providerOptions = { ...this.config.providerOptions, ...options };
}
getPendingPermissions(): AgentPermissionRequest[] {
@@ -223,7 +223,7 @@ export function createProviderSnapshotManagerStub(): {
const listModes = vi.fn<ProviderSnapshotManager["listModes"]>(async () => []);
const resolveCreateConfig = vi.fn<ProviderSnapshotManager["resolveCreateConfig"]>(async () => ({
modeId: undefined,
featureValues: undefined,
providerOptions: undefined,
}));
const resolveDefaultModel = vi.fn<ProviderSnapshotManager["resolveDefaultModel"]>(
async () => undefined,
@@ -2007,6 +2007,7 @@ export class VoiceAssistantWebSocketServer {
usageSetting: true,
usageSources: true,
ownedSubscriptions: true,
providerOptionControls: true,
binaryChunkingV1: true,
agentRequestReceipts: true,
workspaceRequestReceipts: true,
@@ -176,6 +176,13 @@
},
"additionalProperties": {}
},
"options": {
"type": "object",
"propertyNames": {
"type": "string"
},
"additionalProperties": {}
},
"notes": {
"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/);
});
test("Nix desktop packaging resolves the workspace-local builder", () => {
const source = readFileSync(new URL("nix/desktop-package.nix", repoRoot), "utf8");
const invocation = source.match(/^\s+([^\n]*electron-builder) \\\n/m)?.[1];
assert.ok(invocation, "Nix invokes the desktop packaging tool");
const directory = mkdtempSync(join(tmpdir(), "nix-builder-resolution-"));
test("macOS signing repair keeps certificate and keychain passwords separate", () => {
const workflow = readFileSync(new URL(".github/workflows/desktop-release.yml", repoRoot), "utf8");
const match = workflow.match(/<<'KEYCHAIN_FIX'\n([\s\S]*?)\n KEYCHAIN_FIX/);
assert.ok(match, "the release workflow repairs the pinned signing tool");
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 {
const desktop = join(directory, "packages/desktop");
mkdirSync(join(desktop, "node_modules/.bin"), { recursive: true });
mkdirSync(join(desktop, "node_modules/electron-builder"));
writeFileSync(
join(desktop, "node_modules/electron-builder/package.json"),
JSON.stringify({
name: "electron-builder",
version: "1.0.0",
bin: { "electron-builder": "../.bin/electron-builder" },
}),
);
writeFileSync(
join(directory, "package.json"),
JSON.stringify({ private: true, workspaces: ["packages/desktop"] }),
);
writeFileSync(
join(desktop, "package.json"),
JSON.stringify({
name: "desktop-fixture",
private: true,
devDependencies: { "electron-builder": "1.0.0" },
}),
);
writeFileSync(
join(desktop, "node_modules/.bin/electron-builder"),
'#!/bin/sh\nprintf "workspace-builder\\n"\n',
{ mode: 0o755 },
);
const result = spawnSync("sh", ["-c", `${invocation} --version`], {
cwd: desktop,
encoding: "utf8",
});
assert.equal(result.status, 0, result.stderr);
assert.equal(result.stdout.trim(), "workspace-builder");
const packageDirectory = join(directory, "node_modules/app-builder-lib");
mkdirSync(join(packageDirectory, "out/codeSign"), { recursive: true });
const manifest = join(packageDirectory, "package.json");
const sourcePath = join(packageDirectory, "out/codeSign/macCodeSign.js");
for (const [version, source, succeeds] of [
["26.8.1", original, true],
["26.8.1", original.replace("cscPasswords);", "differentArguments);"), false],
["26.18.0", original, false],
]) {
writeFileSync(manifest, JSON.stringify({ name: "app-builder-lib", version }));
writeFileSync(sourcePath, source);
const result = spawnSync(process.execPath, ["-e", script], {
cwd: directory,
encoding: "utf8",
});
const actual = readFileSync(sourcePath, "utf8");
if (succeeds) {
assert.equal(result.status, 0, result.stderr);
assert.ok(
actual.includes("importCerts(keychainFile, certPaths, cscPasswords, keychainPassword)"),
);
assert.ok(
actual.includes("importCerts(keychainFile, paths, keyPasswords, keychainPassword)"),
);
assert.ok(actual.includes('["import", paths[i], "-P", password]'));
assert.ok(actual.includes('"-k", keychainPassword, keychainFile]'));
} else {
assert.notEqual(result.status, 0);
assert.equal(actual, source, "unexpected tool inputs must fail without a partial write");
}
}
} finally {
rmSync(directory, { recursive: true, force: true });
}