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.
This commit is contained in:
Mohamed Boudra
2026-11-01 16:42:17 +01:00
parent 993eef1668
commit a8d539ca7c
15 changed files with 70 additions and 66 deletions
+14 -29
View File
@@ -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<typeof AgentOptionChoiceSchema>;
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<typeof AgentOptionToggleControlSchema>;
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<typeof AgentOptionSelectControlSchema>;
/**
* A provider's description of a composer control over one top-level option key.
* The control carries no value: it is read from the agent's effective options.
*/
export type AgentOptionControl = AgentOptionToggleControl | AgentOptionSelectControl;
export type AgentOptionControl = z.infer<typeof AgentOptionControlSchema>;
export interface AgentCapabilityFlags {
[capability: string]: boolean | undefined;
@@ -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;
}
@@ -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) }
: {}),
@@ -12,11 +12,12 @@ import type {
*/
export type ProviderOptionsPatch = Record<string, unknown>;
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<T extends ProfileOptionFields>(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));
}
@@ -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();
});
@@ -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<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
givenSettings: Readonly<Record<string, JsonValue>>,
private readonly givenSettings: Readonly<Record<string, JsonValue>>,
) {
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<Record<string, JsonValue>> {
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<string, JsonValue> = {};
for (const [id, value] of Object.entries(options)) {
if (!(id in before)) continue;
settings[id] = toJsonValue(value, `setting '${id}'`);
}
if (Object.keys(settings).length === 0) return {};
@@ -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<string, unknown> = {};
try {
@@ -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();
@@ -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[] {
@@ -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) {
@@ -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;
@@ -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);
@@ -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;
@@ -3138,7 +3138,7 @@ function createToolCatalog(
status: summary.status,
modes: summary.modes,
selectedModel: selectedModel ?? null,
features: featuresFromControls(controls, effectiveOptions),
features: featuresFromControls({ controls, options: effectiveOptions }),
}),
};
},
@@ -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,