From a8d539ca7c73003e365950bfeab15cab90e94d27 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 16:42:17 +0100 Subject: [PATCH] refactor(server): option helpers take named inputs, control types come from their schemas, and a plugin accepts launch options it opened with A whole profile is applied by the unchanged app, so a launch-only plugin option whose value has not changed must not fail the apply. applyProviderOptionsPatch and featuresFromControls take one object, and the protocol control types are inferred from the wire schemas. --- packages/protocol/src/agent-types.ts | 43 ++++++------------- .../server/src/server/agent/agent-manager.ts | 4 +- .../src/server/agent/agent-projections.ts | 2 +- .../src/server/agent/option-controls.ts | 30 ++++++++----- .../src/server/agent/plugin-provider.test.ts | 5 +++ .../src/server/agent/plugin-provider.ts | 24 +++++------ .../src/server/agent/providers/acp-agent.ts | 4 +- .../agent/providers/claude/agent.test.ts | 5 ++- .../server/agent/providers/claude/agent.ts | 7 ++- .../agent/providers/codex-app-server-agent.ts | 2 +- .../src/server/agent/providers/omp/agent.ts | 2 +- .../server/agent/providers/opencode-agent.ts | 2 +- .../agent/providers/opencode/v2/session.ts | 2 +- .../src/server/agent/tools/paseo-tools.ts | 2 +- .../provider/provider-catalog-session.ts | 2 +- 15 files changed, 70 insertions(+), 66 deletions(-) diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 6ece6958f9..efbd119c31 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -1,5 +1,12 @@ import type { AgentMessage } from "./agent-message.js"; -import type { AgentAttachment } from "./messages.js"; +import type { + AgentAttachment, + AgentOptionChoiceSchema, + AgentOptionControlSchema, + AgentOptionSelectControlSchema, + AgentOptionToggleControlSchema, +} from "./messages.js"; +import type { z } from "zod"; export type AgentProvider = string; @@ -154,41 +161,19 @@ export interface AgentFeatureSelect { export type AgentFeature = AgentFeatureToggle | AgentFeatureSelect; -export interface AgentOptionChoice { - value: string; - label: string; - description?: string; -} +export type AgentOptionChoice = z.infer; -interface AgentOptionControlBase { - /** Top-level provider option key this control reads and writes. */ - key: string; - label: string; - description?: string; - tooltip?: string; - icon?: string; -} +/** `key` is the top-level provider option key the control reads and writes. */ +export type AgentOptionToggleControl = z.infer; -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[]; -} +/** `defaultValue` is the choice an unset option stands for. */ +export type AgentOptionSelectControl = z.infer; /** * 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 type AgentOptionControl = z.infer; export interface AgentCapabilityFlags { [capability: string]: boolean | undefined; diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 6995fc5a06..7a76b552b0 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2313,12 +2313,12 @@ export class AgentManager { private withOpenedOptions(config: AgentSessionConfig, session: AgentSession): AgentSessionConfig { const opened = session.openOptionsPatch; if (!opened || Object.keys(opened).length === 0) return config; - const own = applyProviderOptionsPatch(config.providerOptions, opened); + const own = applyProviderOptionsPatch({ options: config.providerOptions, patch: opened }); return { ...config, providerOptions: Object.keys(own).length > 0 ? own : undefined }; } private storeOptionsPatch(agent: ManagedAgent, patch: ProviderOptionsPatch): void { - const own = applyProviderOptionsPatch(agent.config.providerOptions, patch); + const own = applyProviderOptionsPatch({ options: agent.config.providerOptions, patch }); agent.config.providerOptions = Object.keys(own).length > 0 ? own : undefined; } diff --git a/packages/server/src/server/agent/agent-projections.ts b/packages/server/src/server/agent/agent-projections.ts index caeffe25e7..c42b2e6867 100644 --- a/packages/server/src/server/agent/agent-projections.ts +++ b/packages/server/src/server/agent/agent-projections.ts @@ -146,7 +146,7 @@ export function toAgentPayload( currentModeId: agent.currentModeId, availableModes: cloneAvailableModes(agent.availableModes), // COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. - features: featuresFromControls(agent.controls, agent.effectiveOptions), + features: featuresFromControls({ controls: agent.controls, options: agent.effectiveOptions }), ...(agent.effectiveOptions ? { options: sanitizeEffectiveOptions(agent.effectiveOptions) } : {}), diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index fa1d6b4562..d1fcda04e3 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -12,11 +12,12 @@ import type { */ export type ProviderOptionsPatch = Record; -export function applyProviderOptionsPatch( - own: ProviderOptions | undefined, - patch: ProviderOptionsPatch, -): ProviderOptions { - const next: ProviderOptions = { ...own }; +export function applyProviderOptionsPatch(input: { + options: ProviderOptions | undefined; + patch: ProviderOptionsPatch; +}): ProviderOptions { + const { options, patch } = input; + const next: ProviderOptions = { ...options }; for (const [key, value] of Object.entries(patch)) { if (value === null) { delete next[key]; @@ -83,7 +84,10 @@ export function foldHookFeatureValues< } return { ...rest, - providerOptions: applyProviderOptionsPatch(rest.providerOptions ?? undefined, patch), + providerOptions: applyProviderOptionsPatch({ + options: rest.providerOptions ?? undefined, + patch, + }), }; } @@ -119,7 +123,10 @@ export function foldProfileFeatureValues(input: { // The form sends no `featureValues` when no control is set, which is still an edit of a // profile that may hold other options. if (!featureValues && !base) return rest; - return { ...rest, options: applyProviderOptionsPatch(base, featureValues ?? {}) }; + return { + ...rest, + options: applyProviderOptionsPatch({ options: base, patch: featureValues ?? {} }), + }; } /** @@ -183,10 +190,11 @@ function featureFromControl(control: AgentOptionControl, effective: ProviderOpti * 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[] { +export function featuresFromControls(input: { + controls: readonly AgentOptionControl[] | undefined; + options: ProviderOptions | undefined; +}): AgentFeature[] { + const { controls, options } = input; const effective = options ?? {}; return (controls ?? []).map((control) => featureFromControl(control, effective)); } diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index fd15cadafb..5bdb08da04 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1145,6 +1145,11 @@ describe("PluginAgentClientRegistry", () => { ); expect(lastConfigure(harness.inputs)).toBe(configureBefore); + + // The value it opened with is not a change, so a whole profile can be applied. + await session.applyOptions?.({ loud: true, launch_key: "original" }); + const configure = lastConfigure(harness.inputs) as { changes: { settings: object } }; + expect(Object.keys(configure.changes.settings)).toEqual(["loud"]); await session.close(); }); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index d21ce06fbb..0c22b26b9a 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -64,7 +64,7 @@ import { createProviderEnv, type ProviderRuntimeSettings, } from "./provider-launch-config.js"; -import { ProviderOptionsValidationError } from "./provider-options.js"; +import { assertChangesAreLive } from "./provider-options.js"; import type { RegisteredProviderDefinition } from "./provider-registry.js"; import { runProviderTurn } from "./providers/provider-runner.js"; import { StaleProviderSessionError } from "./stale-provider-session-error.js"; @@ -1229,7 +1229,7 @@ class PluginAgentSession implements AgentSession { state: ProviderConfigState, ) => Promise, private readonly onBind: (bridge: ProviderRuntimeSession) => void, - givenSettings: Readonly>, + private readonly givenSettings: Readonly>, ) { this.subagentIdsBySession.set(bridge.id, null); // Every setting the session opened with that was not given, including ones nobody set. @@ -1438,19 +1438,17 @@ class PluginAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise> { this.assertReplayComplete(); const before = this.currentSettings(); - // A key the plugin does not report as a setting is one it only reads when the session opens. - const unsupported = Object.keys(options).filter((key) => !(key in before)); - if (unsupported.length > 0) { - throw new ProviderOptionsValidationError( - this.provider, - unsupported.map((key) => ({ - path: [key], - message: "cannot change on a running session; it applies when the agent is opened", - })), - ); - } + // A key the plugin does not report as a setting is one it only reads when the session opens; + // a value equal to the one it opened with is not a change. + assertChangesAreLive({ + provider: this.provider, + patch: options, + current: this.givenSettings, + live: Object.keys(before), + }); const settings: Record = {}; for (const [id, value] of Object.entries(options)) { + if (!(id in before)) continue; settings[id] = toJsonValue(value, `setting '${id}'`); } if (Object.keys(settings).length === 0) return {}; diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 5383def8c1..f48ae85361 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -2413,7 +2413,9 @@ export class ACPAgentSession implements AgentSession, ACPClient { current: this.config.providerOptions, live: [ACP_AUTO_ACCEPT_FEATURE_ID, ...this.configFeatureOptions.map(({ id }) => id)], }); - ACPProviderOptionsSchema.parse(applyProviderOptionsPatch(this.config.providerOptions, options)); + ACPProviderOptionsSchema.parse( + applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }), + ); const before = this.featureValues(); const requested: Record = {}; try { diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 7bdd13fd49..20fcf87003 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1629,7 +1629,10 @@ describe("ClaudeAgentSession features", () => { ]); // With a `config.json` default of `fast_mode: true`, the agent's effective options must // still read Fast as off: a removed key would let the default fill `true` back in. - const stored = applyProviderOptionsPatch({ fast_mode: true }, reported[0].patch); + const stored = applyProviderOptionsPatch({ + options: { fast_mode: true }, + patch: reported[0].patch, + }); expect(mergeProviderOptions({ fast_mode: true }, stored)).toEqual({ fast_mode: false }); await expect(session.applyOptions?.({ fast_mode: null })).resolves.toBeUndefined(); await session.close(); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index aadd6545b9..76d534aebe 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2825,7 +2825,7 @@ class ClaudeAgentSession implements AgentSession { validateProviderOptions( "claude", ClaudeProviderOptionsSchema, - applyProviderOptionsPatch(this.config.providerOptions, options), + applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }), ) ?? {}; if (providerOptions.fast_mode === true && !claudeModelSupportsFastMode(this.config.model)) { throw new Error( @@ -2841,7 +2841,10 @@ class ClaudeAgentSession implements AgentSession { // Held until the runtime accepts, so a rejected change is retried instead of skipped. Only // this call's keys are applied, over the options as they are now: another patch may have // landed while the runtime was answering. - this.config.providerOptions = applyProviderOptionsPatch(this.config.providerOptions, options); + this.config.providerOptions = applyProviderOptionsPatch({ + options: this.config.providerOptions, + patch: options, + }); } getPendingPermissions(): AgentPermissionRequest[] { diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 412fbcbc7d..e6f205c508 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -4714,7 +4714,7 @@ export class CodexAppServerAgentSession implements AgentSession { validateProviderOptions( "codex", CodexProviderOptionsSchema, - applyProviderOptionsPatch(running, options), + applyProviderOptionsPatch({ options: running, patch: options }), ) ?? {}; let requestedTier = this.requestedServiceTier; if ("service_tier" in options || "fast_mode" in options) { diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 8c32b01d65..ab0246cee4 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -834,7 +834,7 @@ export class OmpAgentSession implements AgentSession { const next = validateProviderOptions( "omp", OmpProviderOptionsSchema, - applyProviderOptionsPatch(this.config.providerOptions, options), + applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }), ); if (options.fast_mode === undefined) return {}; const requested = next?.fast_mode ?? false; diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index cdfe6dba66..82232cc815 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -4974,7 +4974,7 @@ class OpenCodeAgentSession implements AgentSession { const next = validateProviderOptions( "opencode", OpenCodeProviderOptionsSchema, - applyProviderOptionsPatch(this.config.providerOptions, options), + applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }), ); if (OPENCODE_AUTO_ACCEPT_FEATURE_ID in options) { this.setAutoAccept(next?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true); diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 27f5d7fb52..b442bcf523 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -372,7 +372,7 @@ export class OpenCodeV2Session implements AgentSession { const next = validateProviderOptions( "opencode", OpenCodeProviderOptionsSchema, - applyProviderOptionsPatch(this.config.providerOptions, options), + applyProviderOptionsPatch({ options: this.config.providerOptions, patch: options }), ); if (!("auto_accept" in options)) return; const value = next?.auto_accept === true; diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 3309a2415e..8a0409b188 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -3138,7 +3138,7 @@ function createToolCatalog( status: summary.status, modes: summary.modes, selectedModel: selectedModel ?? null, - features: featuresFromControls(controls, effectiveOptions), + features: featuresFromControls({ controls, options: effectiveOptions }), }), }; }, diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index 7a4f3200fa..041d7182fc 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -396,7 +396,7 @@ export class ProviderCatalogSession { type: "list_provider_features_response", payload: { provider: msg.draftConfig.provider, - features: featuresFromControls(controls, options), + features: featuresFromControls({ controls, options }), error: null, fetchedAt, requestId: msg.requestId,