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-02 00:29:50 +01:00
parent c3a7547ce5
commit 389db81784
19 changed files with 519 additions and 183 deletions
+2 -1
View File
@@ -1,7 +1,8 @@
# Direct provider example
This plugin implements `ProviderRegistration` directly. It demonstrates connection negotiation,
catalog and session listing, session creation, prompt and command disposition, settings,
catalog and session listing, session creation, prompt and command disposition, option controls
with icons (`controls` and `providerOptions`),
persistence and replay, archive actions, and an ordinary child session.
The provider emits versioned `provider-result` timeline items for its root and child sessions.
@@ -40,7 +40,7 @@ describe("direct provider example", () => {
await connection.close();
});
it("demonstrates settings, commands, persistence, and provider-rendered timeline items", async () => {
it("demonstrates option controls, commands, persistence, and provider-rendered timeline items", async () => {
const connection = await createDirectExampleProvider().connect({
versions: [1],
capabilities: [
@@ -65,8 +65,8 @@ describe("direct provider example", () => {
mcpServers: {},
model: "example-1",
mode: "build",
settings: { concise: true, voice: "calm" },
providerOptions: { privateValue: 1 },
settings: {},
providerOptions: { concise: true, voice: "calm", privateValue: 1 },
persist: true,
},
history: "skip",
@@ -75,10 +75,11 @@ describe("direct provider example", () => {
expect.objectContaining({
type: "session.config",
config: expect.objectContaining({
settings: expect.arrayContaining([
expect.objectContaining({ id: "concise", value: true }),
expect.objectContaining({ id: "voice", value: "calm" }),
]),
controls: [
expect.objectContaining({ key: "concise", icon: "Zap" }),
expect.objectContaining({ key: "voice", icon: "Mic", defaultValue: "calm" }),
],
providerOptions: { concise: true, voice: "calm" },
}),
}),
);
@@ -88,8 +89,20 @@ describe("direct provider example", () => {
type: "session.configure",
requestId: "configure-complete",
sessionId: "session-complete",
changes: { settings: { voice: "direct" } },
changes: { providerOptions: { voice: "direct", concise: null } },
});
expect(events.findLast((event) => event.type === "session.config")).toMatchObject({
config: { providerOptions: { voice: "direct" } },
});
await connection.send({
type: "session.configure",
requestId: "configure-refused",
sessionId: "session-complete",
changes: { providerOptions: { voice: "loud" } },
});
expect(events).toContainEqual(
expect.objectContaining({ type: "request.failed", requestId: "configure-refused" }),
);
await connection.send({
type: "session.prompt",
sessionId: "session-complete",
@@ -1,7 +1,9 @@
import {
negotiateProviderCapabilities,
requireProviderCapabilities,
type ProviderConfigChanges,
type ProviderConfigState,
type ProviderOptionControl,
type ProviderConnection,
type ProviderEvent,
type ProviderInput,
@@ -25,8 +27,29 @@ const CAPABILITIES = [
"timeline.plugin",
] as const;
const VOICES = [
{ value: "calm", label: "Calm" },
{ value: "direct", label: "Direct" },
];
// Which option keys get a composer control. The control holds no value: Paseo reads it from
// `providerOptions`, in a draft as well as on a running session.
const CONTROLS: ProviderOptionControl[] = [
{ type: "toggle", key: "concise", label: "Concise responses", icon: "Zap" },
{
type: "select",
key: "voice",
label: "Voice",
icon: "Mic",
defaultValue: "calm",
choices: VOICES,
},
];
interface ExampleSession {
config: ProviderSessionConfig;
/** What the session runs with. A key with no value runs its default. */
options: { concise?: boolean; voice?: string };
persistence: ProviderPersistence;
turn: number;
activeTurnId: string | null;
@@ -175,8 +198,13 @@ function openSession(
version: 1,
data: { nativeSessionId: `example:${input.sessionId}` },
};
const { concise, voice } = input.config.providerOptions ?? {};
const session: ExampleSession = {
config: input.config,
options: {
...(typeof concise === "boolean" ? { concise } : {}),
...(VOICES.some((candidate) => candidate.value === voice) ? { voice: voice as string } : {}),
},
persistence,
turn: 0,
activeTurnId: null,
@@ -352,14 +380,41 @@ function configureSession(
changes.thinkingOption === null
? undefined
: (changes.thinkingOption ?? session.config.thinkingOption),
settings: changes.settings
? { ...session.config.settings, ...changes.settings }
: session.config.settings,
};
try {
session.options = applyOptions(session.options, changes.providerOptions ?? {});
} catch (error) {
state.emit({
type: "request.failed",
requestId: input.requestId,
error: { message: error instanceof Error ? error.message : String(error) },
});
return;
}
state.emit({ type: "session.config", sessionId: input.sessionId, config: configState(session) });
state.emit({ type: "request.completed", requestId: input.requestId });
}
/** Apply the whole patch or throw. `null` returns a key to its default. */
function applyOptions(
options: ExampleSession["options"],
patch: NonNullable<ProviderConfigChanges["providerOptions"]>,
): ExampleSession["options"] {
const next = { ...options };
for (const [key, value] of Object.entries(patch)) {
if (key === "concise" && (value === null || typeof value === "boolean")) {
if (value === null) delete next.concise;
else next.concise = value;
} else if (key === "voice" && (value === null || VOICES.some((v) => v.value === value))) {
if (value === null) delete next.voice;
else next.voice = value as string;
} else {
throw new Error(`Cannot set ${key} to ${JSON.stringify(value)}`);
}
}
return next;
}
function configState(session: ExampleSession): ProviderConfigState {
return {
model: session.config.model ?? "example-1",
@@ -368,25 +423,9 @@ function configState(session: ExampleSession): ProviderConfigState {
models: [{ id: "example-1", label: "Example 1" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: [
{
type: "toggle",
id: "concise",
label: "Concise responses",
value: session.config.settings.concise === true,
},
{
type: "select",
id: "voice",
label: "Voice",
value:
typeof session.config.settings.voice === "string" ? session.config.settings.voice : null,
options: [
{ label: "Calm", value: "calm" },
{ label: "Direct", value: "direct" },
],
},
],
controls: CONTROLS,
// What the session ended up with, so Paseo stores what runs.
providerOptions: { ...session.options },
};
}