feat(plugin): provider plugins declare option controls and receive option changes

A provider plugin reports `controls` and the `providerOptions` it runs in
`session.config`, and receives running-session changes as
`session.configure` `changes.providerOptions` (null returns a key to its
default), under the same apply-or-throw contract as built-in providers.
A control may declare a `defaultValue`; a value a plugin reports at that
default for a key nobody set stays unset instead of being pinned to the agent.

A plugin that still reports `settings` keeps working: the daemon reads each
setting as a control plus an option and sends changes in both shapes
(COMPAT(plugin-provider-settings)).

The app resolves a control's icon as a Lucide name when it is not one of the
built-in kebab-case names, so a plugin's icon shows live and in drafts.
Public docs and the provider-direct example use only `providerOptions` and
controls; the migration doc gains the rows.
This commit is contained in:
Mohamed Boudra
2026-11-04 05:35:10 +01:00
parent a7c976679d
commit 5cbfef5f18
19 changed files with 519 additions and 183 deletions
@@ -0,0 +1,60 @@
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { ProviderConfigState } from "@getpaseo/plugin/server/provider";
import type { AgentOptionControl } from "./agent-sdk-types.js";
/** How a provider session's composer options look to the daemon, whichever shape it reports. */
export interface PluginOptionState {
controls: AgentOptionControl[];
/** The values the session runs with, for the keys it reports. */
options: Record<string, JsonValue>;
/** The provider reports `settings`, so it also reads `changes.settings`. */
legacySettings: boolean;
}
/**
* A provider that reports `controls` and `providerOptions` is read as is.
* COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A provider still
* reporting `settings` has each setting id read as an option key and its value as the option.
*/
export function readPluginOptionState(config: ProviderConfigState): PluginOptionState {
if (config.controls || config.providerOptions) {
return {
controls: (config.controls ?? []).map(toControl),
options: { ...config.providerOptions },
legacySettings: false,
};
}
const settings = config.settings ?? [];
return {
controls: settings.map(
(setting): AgentOptionControl =>
setting.type === "toggle"
? {
type: "toggle",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
})),
},
),
options: Object.fromEntries(settings.map((setting) => [setting.id, setting.value])),
legacySettings: true,
};
}
function toControl(
control: NonNullable<ProviderConfigState["controls"]>[number],
): AgentOptionControl {
return control.type === "toggle"
? { ...control }
: { ...control, choices: control.choices.map((choice) => ({ ...choice })) };
}
@@ -58,6 +58,10 @@ interface ProviderHarnessOptions {
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
}
function controlKey(control: { key: string }): string {
return control.key;
}
function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined {
return inputs.findLast((input) => input.type === "session.configure");
}
@@ -1004,7 +1008,9 @@ describe("PluginAgentClientRegistry", () => {
await agent.session?.close();
});
describe("settings", () => {
// The same contract holds for a plugin that reports `controls` and `providerOptions` and for one
// still reporting `settings`.
describe.each(["controls", "settings"] as const)("options of a plugin reporting %s", (shape) => {
// A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given.
async function openStubbornVoiceSession(
given: Record<string, boolean | string>,
@@ -1012,19 +1018,46 @@ describe("PluginAgentClientRegistry", () => {
) {
const state = { voice: "direct", loud: false };
let openedSessionId = "";
const settings = () => [
{
type: "select" as const,
id: "voice",
label: "Voice",
value: state.voice,
options: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
];
const reported = () =>
shape === "controls"
? {
controls: [
{
type: "select" as const,
key: "voice",
label: "Voice",
icon: "Mic",
defaultValue: "direct",
choices: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{
type: "toggle" as const,
key: "loud",
label: "Loud",
icon: "Volume2",
defaultValue: false,
},
],
providerOptions: { voice: state.voice, loud: state.loud },
}
: {
settings: [
{
type: "select" as const,
id: "voice",
label: "Voice",
value: state.voice,
options: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
},
{ type: "toggle" as const, id: "loud", label: "Loud", value: state.loud },
],
};
const emitConfig = (emit: (event: ProviderEvent) => void, sessionId: string) =>
emit({
type: "session.config",
@@ -1035,7 +1068,7 @@ describe("PluginAgentClientRegistry", () => {
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: settings(),
...reported(),
},
});
const harness = createProviderHarness({
@@ -1056,7 +1089,7 @@ describe("PluginAgentClientRegistry", () => {
return true;
}
if (input.type === "session.configure") {
const loud = input.changes.settings?.loud;
const loud = input.changes.providerOptions?.loud;
if (typeof loud === "boolean") state.loud = loud;
emitConfig(emit, input.sessionId);
emit(
@@ -1115,10 +1148,12 @@ describe("PluginAgentClientRegistry", () => {
await session.close();
});
test("settings the plugin opened with that nobody set are in the open patch", async () => {
test("options the plugin opened with that nobody set are in the open patch, unless they are the control's default", async () => {
const { session, events } = await openStubbornVoiceSession({});
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
expect(session.openOptionsPatch).toEqual(
shape === "controls" ? {} : { voice: "direct", loud: false },
);
expect(eventsOfType(events, "options_changed")).toEqual([]);
await session.close();
});
@@ -1129,10 +1164,42 @@ describe("PluginAgentClientRegistry", () => {
await session.applyOptions?.({ loud: true });
const configure = lastConfigure(harness.inputs);
expect(configure).toMatchObject({ changes: { settings: { loud: true } } });
expect(
Object.keys((configure as { changes: { settings: object } }).changes.settings),
).toEqual(["loud"]);
// Only a plugin that reports `settings` is also sent them.
expect((configure as { changes: object }).changes).toEqual(
shape === "settings"
? { providerOptions: { loud: true }, settings: { loud: true } }
: { providerOptions: { loud: true } },
);
await session.close();
});
test("a removal reaches the plugin as null", async () => {
const { harness, session } = await openStubbornVoiceSession({});
const ended = await session.applyOptions?.({ loud: null });
expect(lastConfigure(harness.inputs)).toMatchObject({
changes: { providerOptions: { loud: null } },
});
// It went back to its default; only a plugin without a declared default pins that value.
expect(ended).toEqual(shape === "controls" ? {} : { loud: false });
await session.close();
});
test("draws a control from what the plugin reports, with its icon and default", async () => {
const { session } = await openStubbornVoiceSession({});
expect((session.controls ?? []).map(controlKey)).toEqual(["voice", "loud"]);
if (shape === "controls") {
expect(session.controls?.[0]).toMatchObject({
icon: "Mic",
defaultValue: "direct",
choices: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
});
}
await session.close();
});
@@ -1148,8 +1215,10 @@ describe("PluginAgentClientRegistry", () => {
// 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"]);
const configure = lastConfigure(harness.inputs) as {
changes: { providerOptions: object };
};
expect(Object.keys(configure.changes.providerOptions)).toEqual(["loud"]);
await session.close();
});
@@ -65,6 +65,7 @@ import {
type ProviderRuntimeSettings,
} from "./provider-launch-config.js";
import { assertChangesAreLive } from "./provider-options.js";
import { readPluginOptionState } from "./plugin-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";
@@ -606,7 +607,7 @@ class ProviderRuntimeSession {
private readonly activeTurnIds = new Set<string>();
private terminal = false;
private closePromise: Promise<void> | null = null;
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [], settings: [] };
config: ProviderConfigState = { models: [], modes: [], thinkingOptions: [] };
commands: Array<{ name: string; description: string; argumentHint?: string }> = [];
persistence: ProviderPersistence | null = null;
@@ -1119,6 +1120,7 @@ class PluginAgentClient implements AgentClient {
forget,
async (persistence, state) => {
forget();
const { options } = readPluginOptionState(state);
return await this.runtime.openSession({
sessionId: randomUUID(),
config: {
@@ -1126,9 +1128,8 @@ class PluginAgentClient implements AgentClient {
model: state.model,
mode: state.mode,
thinkingOption: state.thinkingOption,
settings: Object.fromEntries(
state.settings.map((setting) => [setting.id, setting.value]),
),
settings: { ...config.settings, ...options },
providerOptions: { ...config.providerOptions, ...options },
},
persistence,
history: "replay",
@@ -1204,9 +1205,9 @@ class PluginAgentSession implements AgentSession {
>();
private readonly childSnapshots = new Map<string, Map<string, TimelineTextSnapshot>>();
private unsubscribe: (() => void) | null = null;
/** How the opened session differs from the settings it was given. */
/** How the opened session differs from the options it was given. */
readonly openOptionsPatch: Record<string, JsonValue>;
private knownSettings: Record<string, JsonValue> = {};
private knownOptions: Record<string, JsonValue> = {};
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
private applyingOptions = false;
private currentTurnId: string | null = null;
@@ -1229,12 +1230,12 @@ class PluginAgentSession implements AgentSession {
state: ProviderConfigState,
) => Promise<ProviderRuntimeSession>,
private readonly onBind: (bridge: ProviderRuntimeSession) => void,
private readonly givenSettings: Readonly<Record<string, JsonValue>>,
private readonly givenOptions: Readonly<Record<string, JsonValue>>,
) {
this.subagentIdsBySession.set(bridge.id, null);
// Every setting the session opened with that was not given, including ones nobody set.
this.openOptionsPatch = this.settingDifferences(givenSettings);
this.knownSettings = this.currentSettings();
this.openOptionsPatch = this.optionDifferences(givenOptions);
this.knownOptions = this.currentOptions();
for (const event of bridge.history) this.accept(event, false);
// Opening notices arrive before the manager subscribes to the session.
// Commit them on registration and replay the same timestamps from history.
@@ -1258,28 +1259,8 @@ class PluginAgentSession implements AgentSession {
};
}
/** 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 } : {}),
}
: {
type: "select",
key: setting.id,
label: setting.label,
...(setting.description ? { description: setting.description } : {}),
choices: setting.options.map((option) => ({
value: option.value,
label: option.label,
})),
},
);
return readPluginOptionState(this.bridge.config).controls;
}
async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<AgentRunResult> {
@@ -1437,35 +1418,41 @@ 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 before = this.currentOptions();
const { controls, legacySettings } = readPluginOptionState(this.bridge.config);
const live = controls.map((control) => control.key);
// A key the plugin has no control for 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),
current: this.givenOptions,
live,
});
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 (!live.includes(id)) continue;
settings[id] = toJsonValue(value, `option '${id}'`);
}
if (Object.keys(settings).length === 0) return {};
this.applyingOptions = true;
try {
await this.bridge.configure({ settings });
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01.
await this.bridge.configure({
providerOptions: settings,
...(legacySettings ? { settings } : {}),
});
} catch (error) {
// Nothing is stored for a refused patch, so what the plugin moved is reported once the
// call has ended.
this.applyingOptions = false;
for (const event of this.reportSettingChanges()) this.publish(event);
for (const event of this.reportOptionChanges()) this.publish(event);
throw error;
} finally {
this.applyingOptions = false;
}
const ended = this.settingDifferences({ ...before, ...settings });
this.knownSettings = this.currentSettings();
const ended = this.optionDifferences({ ...before, ...settings });
this.knownOptions = this.currentOptions();
return ended;
}
@@ -1648,28 +1635,32 @@ class PluginAgentSession implements AgentSession {
};
}
private currentSettings(): Record<string, JsonValue> {
return Object.fromEntries(
this.bridge.config.settings.map((setting) => [setting.id, setting.value]),
);
private currentOptions(): Record<string, JsonValue> {
return readPluginOptionState(this.bridge.config).options;
}
/** Settings whose value differs from `known`: the plugin's own change or its opening state. */
private settingDifferences(
known: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
/**
* Options whose value differs from `known`: the plugin's own change or its opening state. A key
* with no value in `known` that the plugin reports at its control's declared default stays
* unset, so the agent follows the default instead of pinning it.
*/
private optionDifferences(known: Readonly<Record<string, JsonValue>>): Record<string, JsonValue> {
const { controls, options } = readPluginOptionState(this.bridge.config);
const patch: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(this.currentSettings())) {
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
for (const [id, value] of Object.entries(options)) {
if (isDeepStrictEqual(known[id], value)) continue;
const control = controls.find((candidate) => candidate.key === id);
if (known[id] == null && control?.defaultValue === value) continue;
patch[id] = value;
}
return patch;
}
private reportSettingChanges(): AgentStreamEvent[] {
private reportOptionChanges(): AgentStreamEvent[] {
if (this.applyingOptions) return [];
const patch = this.settingDifferences(this.knownSettings);
const patch = this.optionDifferences(this.knownOptions);
if (Object.keys(patch).length === 0) return [];
this.knownSettings = this.currentSettings();
this.knownOptions = this.currentOptions();
return [{ type: "options_changed", provider: this.provider, patch }];
}
@@ -1677,7 +1668,7 @@ class PluginAgentSession implements AgentSession {
event: Extract<ProviderEvent, { type: "session.config" }>,
): AgentStreamEvent[] {
return [
...this.reportSettingChanges(),
...this.reportOptionChanges(),
{
type: "mode_changed",
provider: this.provider,