mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
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:
@@ -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 },
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user