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-04 04:25:09 +01:00
parent 0f979c21e7
commit c68f8cd9df
15 changed files with 70 additions and 66 deletions
@@ -2331,12 +2331,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;
}
@@ -147,7 +147,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 {};
@@ -2458,7 +2458,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 {
@@ -1819,7 +1819,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();
@@ -2832,7 +2832,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(
@@ -2848,7 +2848,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[] {
@@ -4770,7 +4770,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);
@@ -380,7 +380,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;
@@ -3160,7 +3160,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,