Compare commits

..
Author SHA1 Message Date
Mohamed Boudra d0a5d4d970 fix(server): an apply never reads a requested option as one the plugin dropped
A plugin may run an option it never reports. Only a key it reported before can be
reported as removed, and a rewind reopens with the accepted request plus what the
plugin reported back.
2026-11-02 01:43:01 +01:00
Mohamed Boudra 377b06be0d fix(server): an apply reports the options the plugin reset along the way
Applying and unsolicited reports now share one conversion from the plugin's
report to a patch, so a key the plugin stops reporting is a removal on both.
2026-11-02 01:10:56 +01:00
Mohamed Boudra 25a3efe0ac fix(server): a plugin that stops reporting an option has reset it
The adapter only visited keys present in a new report, so a key the plugin
dropped to return to its default kept its stored value and its rewind value.
A previously reported key that disappears is now reported as removed.
2026-11-02 01:01:24 +01:00
Mohamed Boudra 5b4636b856 fix(server): a rewound plugin session reopens with its options as they stand
The reopen merged the plugin's reported options over the launch record, so a
key the plugin reset by leaving it out came back at its launch value. The
session now keeps the committed options, removals included. The provider-direct
example also refuses a combined request before changing anything.
2026-11-02 00:52:17 +01:00
Mohamed Boudra 00ec4bc80c fix(server): a plugin decides which options it applies live
A plugin reporting `controls` and `providerOptions` receives every key a live
change touches, with or without a control, and applies them or rejects the
whole request. The daemon's live-key guard now runs only for a plugin still
reporting `settings`, which cannot refuse a key.
2026-11-02 00:34:19 +01:00
Mohamed Boudra 970c281daf fix(server): a plugin provider's reported options are stored like any provider's
A control's `defaultValue` only says what the control shows for a key with no
value. Opening and applying store every option the session reports, defaults
and values nobody set included, as built-in providers do; the special case that
left a value at the default unset is removed.
2026-11-02 00:31:08 +01:00
Mohamed Boudra 389db81784 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.
2026-11-02 00:29:50 +01:00
19 changed files with 824 additions and 204 deletions
+11 -3
View File
@@ -511,13 +511,21 @@ need no change. See [catalogue ownership](providers.md#provider-snapshot-refresh
`send()` resolves after acceptance. Publish operation completion, prompt disposition, turn state,
configuration, permissions, persistence, and complete timeline snapshots through `onEvent()`.
Route messages, structured commands, steering, and command side effects through `session.prompt`.
Provider settings are toggle/select data that Paseo renders in the composer. Keep private options in
the opaque `ProviderSessionConfig.providerOptions` object on `session.open`.
It contains the provider defaults and per-agent overrides merged by the daemon.
A provider's configuration is the opaque `ProviderSessionConfig.providerOptions` object on
`session.open`. It contains the provider defaults and per-agent overrides merged by the daemon.
Validate and apply it inside the provider; core does not know your option shape.
See [provider options](custom-providers.md#provider-options) for configuration and
merge semantics.
The provider draws some of those keys in the composer by reporting `controls` and the values it runs
(`providerOptions`) in `session.config`. A running-agent change arrives as
`session.configure` with `changes.providerOptions` and follows the same apply-or-throw contract as
built-in providers ([options flow](providers.md#options-flow)). `PluginAgentSession` in
`packages/server/src/server/agent/plugin-provider.ts` is that adapter. A provider still reporting
`settings` is read through `plugin-provider-options.ts`, which turns each setting into a control and
its value into an option and is sent both shapes in `session.configure`; delete it with
`COMPAT(plugin-provider-settings)`. The ACP shim still reports `settings`.
Providers add an optional veto to daemon baseline cleanup through the per-session
[runtime safety query](../public-docs/plugins/providers.md#permit-idle-runtime-cleanup).
The daemon calls the same `AgentSession.canStopRuntime()` interface for built-ins and plugins;
@@ -0,0 +1,16 @@
import { Settings2 } from "lucide-react-native";
import { describe, expect, it } from "vitest";
import { getAgentFeatureIcon } from "./icons";
describe("getAgentFeatureIcon", () => {
it("draws the Lucide icon a plugin provider names", () => {
const icon = getAgentFeatureIcon("Mic");
expect(icon).not.toBe(Settings2);
expect(getAgentFeatureIcon("Mic")).toBe(icon);
});
it("falls back to the generic icon for a missing or unknown name", () => {
expect(getAgentFeatureIcon()).toBe(Settings2);
expect(getAgentFeatureIcon("not-an-icon")).toBe(Settings2);
});
});
+5 -1
View File
@@ -13,6 +13,7 @@ import {
ShieldQuestionMark,
Zap,
} from "lucide-react-native";
import { isLucideIconName, pluginIconComponent } from "@/plugins/icons";
import { getModeVisuals, type AgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
export interface AgentControlIconProps {
@@ -60,6 +61,9 @@ export function getAgentModeOptionIcon(
return icon ? MODE_ICONS[icon] : undefined;
}
/** A built-in control names its icon in kebab case; a plugin provider names a Lucide icon. */
export function getAgentFeatureIcon(icon?: string): AgentControlIcon {
return (icon ? FEATURE_ICONS[icon] : undefined) ?? Settings2;
if (!icon) return Settings2;
if (FEATURE_ICONS[icon]) return FEATURE_ICONS[icon];
return isLucideIconName(icon) ? pluginIconComponent(icon) : Settings2;
}
+5
View File
@@ -25,6 +25,11 @@ function findLucideIcon(name: string): LucideIcon | null {
return isComponent(candidate) ? (candidate as LucideIcon) : null;
}
/** Whether `name` is a Lucide icon, so a caller can fall back before drawing nothing. */
export function isLucideIconName(name: string): boolean {
return findLucideIcon(name) !== null;
}
function isComponent(value: unknown): boolean {
return (
typeof value === "function" ||
+74 -2
View File
@@ -116,7 +116,9 @@ export interface ProviderSessionConfig {
model?: string;
mode?: string;
thinkingOption?: string;
/** @deprecated The same values as `providerOptions`. */
settings: Readonly<Record<string, JsonValue>>;
/** The agent's options, the provider defaults from `config.json` included. */
providerOptions?: Readonly<Record<string, unknown>>;
title?: string;
persist: boolean;
@@ -126,6 +128,13 @@ export interface ProviderConfigChanges {
model?: string | null;
mode?: string | null;
thinkingOption?: string | null;
/**
* The option keys to change on the running session, with or without a control. Apply every key
* or fail the whole request, for a key you only read when a session opens. `null` returns the
* key to the provider's own default. The committed values come back in `session.config`.
*/
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Sent next to `providerOptions` to a provider that reports `settings`. */
settings?: Readonly<Record<string, JsonValue>>;
}
@@ -311,6 +320,34 @@ export interface ProviderMode {
isUnattended?: boolean;
}
/**
* Draws one top-level option key in the composer. The control holds no value: Paseo reads it
* from the agent's options.
*/
export type ProviderOptionControl =
| {
type: "toggle";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** Shown while the key has no value. */
defaultValue?: boolean;
}
| {
type: "select";
key: string;
label: string;
description?: string;
/** A Lucide icon name. */
icon?: string;
/** The choice shown while the key has no value. */
defaultValue?: string;
choices: ReadonlyArray<{ value: string; label: string; description?: string }>;
};
/** @deprecated Report `controls` and `providerOptions` instead. */
export type ProviderSetting =
| {
type: "toggle";
@@ -335,7 +372,12 @@ export interface ProviderConfigState {
models: readonly ProviderModel[];
modes: readonly ProviderMode[];
thinkingOptions: readonly ProviderThinkingOption[];
settings: readonly ProviderSetting[];
/** The option keys that get a composer control. May change at runtime, for example per model. */
controls?: readonly ProviderOptionControl[];
/** What the session runs with now, for the keys it reports. Publish the committed values. */
providerOptions?: Readonly<Record<string, JsonValue>>;
/** @deprecated Report `controls` and `providerOptions`. Each setting id is an option key. */
settings?: readonly ProviderSetting[];
}
export interface ProviderCatalog {
@@ -888,6 +930,7 @@ const configChangesSchema = z
model: z.string().nullable().optional(),
mode: z.string().nullable().optional(),
thinkingOption: z.string().nullable().optional(),
providerOptions: jsonObjectSchema.optional(),
settings: jsonObjectSchema.optional(),
})
.strict();
@@ -1024,6 +1067,33 @@ const settingSchema = z.discriminatedUnion("type", [
})
.strip(),
]);
const controlSchema = z.discriminatedUnion("type", [
z
.object({
type: z.literal("toggle"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.boolean().optional(),
})
.strip(),
z
.object({
type: z.literal("select"),
key: idSchema,
label: z.string(),
description: z.string().optional(),
icon: z.string().optional(),
defaultValue: z.string().optional(),
choices: z.array(
z
.object({ value: z.string(), label: z.string(), description: z.string().optional() })
.strip(),
),
})
.strip(),
]);
const configStateSchema = z
.object({
model: z.string().optional(),
@@ -1032,7 +1102,9 @@ const configStateSchema = z
models: z.array(modelSchema),
modes: z.array(modeSchema),
thinkingOptions: z.array(selectOptionSchema),
settings: z.array(settingSchema),
controls: z.array(controlSchema).optional(),
providerOptions: jsonObjectSchema.optional(),
settings: z.array(settingSchema).optional(),
})
.strip();
const catalogSchema = z
@@ -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");
}
@@ -501,6 +505,144 @@ test("combined plugin rewind replays the retained native branch", async () => {
});
});
test("a rewind reopens with the options as they stand, a reset included", async () => {
const options: Record<string, boolean> = {};
const report = (emit: (event: ProviderEvent) => void, sessionId: string) =>
emit({
type: "session.config",
sessionId,
config: {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
controls: [{ type: "toggle", key: "concise", label: "Concise" }],
providerOptions: { ...options },
},
});
const harness = createRewindProviderHarness({
handleInput: async (input, emit) => {
if (input.type === "session.open" && input.history !== "replay") {
options.concise = true;
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation"],
restoration: "core",
persistence: { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
report(emit, input.sessionId);
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.configure") {
// A plugin that represents the default by leaving the key out.
delete options.concise;
report(emit, input.sessionId);
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: tmpdir(),
providerOptions: { concise: true },
});
await session.run("A");
await session.applyOptions?.({ concise: null });
await session.revertConversation!({ messageId: "user-A" });
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
expect(reopened?.config.providerOptions).toEqual({});
await session.close();
await registry.shutdown();
});
// A plugin may report no options at all while it runs the ones it was given.
async function withUnreportedOptionSession(
run: (
session: Awaited<ReturnType<AgentClient["createSession"]>>,
harness: ReturnType<typeof createRewindProviderHarness>,
) => Promise<void>,
) {
const harness = createRewindProviderHarness({
handleInput: async (input, emit) => {
if (input.type === "session.open" && input.history !== "replay") {
emit({
type: "session.opened",
requestId: input.requestId,
sessionId: input.sessionId,
capabilities: [...CAPABILITIES, "session.revert.conversation"],
restoration: "core",
persistence: { version: 1, data: { token: "root" } },
cwd: input.config.cwd,
});
emit({
type: "session.config",
sessionId: input.sessionId,
config: {
model: "plugin-model",
mode: "build",
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
return true;
}
if (input.type === "session.configure") {
emit({ type: "request.completed", requestId: input.requestId });
return true;
}
return false;
},
});
const registry = new PluginAgentClientRegistry(createTestLogger());
registry.replace([harness.registration]);
const session = await registry.clients()[harness.registration.id]!.createSession({
provider: harness.registration.id,
cwd: tmpdir(),
providerOptions: { token_budget: 1000 },
});
try {
await run(session, harness);
} finally {
await session.close();
await registry.shutdown();
}
}
test("applying an option the plugin never reports leaves the stored option alone", async () => {
await withUnreportedOptionSession(async (session) => {
// The same value as a whole-profile patch carries it, then a new one.
expect(await session.applyOptions?.({ token_budget: 1000 })).toEqual({});
expect(await session.applyOptions?.({ token_budget: 2000 })).toEqual({});
});
});
test("a rewind reopens with the value the plugin accepted for an option it never reports", async () => {
await withUnreportedOptionSession(async (session, harness) => {
await session.run("A");
await session.applyOptions?.({ token_budget: 2000 });
await session.revertConversation!({ messageId: "user-A" });
const reopened = harness.inputs.findLast((input) => input.type === "session.open");
expect(reopened?.config.providerOptions).toEqual({ token_budget: 2000 });
});
});
test("plugin rewind resumes the latest persistence rather than the original handle", async () => {
await withPluginRewindSession(
{
@@ -1004,27 +1146,63 @@ 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>,
{ refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {},
) {
const state = { voice: "direct", loud: false };
const state: { voice: string; loud: boolean; tokenBudget?: number } = {
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,
...(state.tokenBudget === undefined ? {} : { token_budget: state.tokenBudget }),
},
}
: {
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 +1213,7 @@ describe("PluginAgentClientRegistry", () => {
models: [{ id: "plugin-model", label: "Plugin model" }],
modes: [{ id: "build", label: "Build" }],
thinkingOptions: [],
settings: settings(),
...reported(),
},
});
const harness = createProviderHarness({
@@ -1056,8 +1234,22 @@ describe("PluginAgentClientRegistry", () => {
return true;
}
if (input.type === "session.configure") {
const loud = input.changes.settings?.loud;
const patch =
shape === "settings" ? input.changes.settings : input.changes.providerOptions;
const { loud, token_budget: tokenBudget } = patch ?? {};
// A plugin that takes `token_budget` live refuses the whole request on a bad value.
if (tokenBudget !== undefined && typeof tokenBudget !== "number") {
emit({
type: "request.failed",
requestId: input.requestId,
error: { message: "bad budget" },
});
return true;
}
if (typeof loud === "boolean") state.loud = loud;
if (typeof tokenBudget === "number") state.tokenBudget = tokenBudget;
// Going loud returns an unrequested token budget to its default, by leaving it out.
if (loud === true && tokenBudget === undefined) state.tokenBudget = undefined;
emitConfig(emit, input.sessionId);
emit(
refuseAfterChanging
@@ -1096,6 +1288,47 @@ describe("PluginAgentClientRegistry", () => {
await session.close();
});
test.runIf(shape === "controls")(
"reports an option the plugin stops reporting as removed, alone or with another change",
async () => {
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
changeOnItsOwn({ tokenBudget: 500 });
changeOnItsOwn({ tokenBudget: undefined });
changeOnItsOwn({ tokenBudget: 700 });
changeOnItsOwn({ tokenBudget: undefined, loud: true });
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: null } },
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 700 } },
{
type: "options_changed",
provider: "plugin-direct",
patch: { token_budget: null, loud: true },
},
]);
await session.close();
},
);
test.runIf(shape === "controls")(
"an apply returns the option the plugin reset along the way as removed",
async () => {
const { session, events, changeOnItsOwn } = await openStubbornVoiceSession({});
changeOnItsOwn({ tokenBudget: 500 });
const ended = await session.applyOptions?.({ loud: true });
expect(ended).toEqual({ token_budget: null });
// The apply reports it; the plugin's later identical report is not a second change.
expect(eventsOfType(events, "options_changed")).toEqual([
{ type: "options_changed", provider: "plugin-direct", patch: { token_budget: 500 } },
]);
await session.close();
},
);
test("a rejected apply reports what the plugin moved before refusing, since nothing is stored", async () => {
const { session, events } = await openStubbornVoiceSession({}, { refuseAfterChanging: true });
@@ -1115,7 +1348,7 @@ 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, defaults included", async () => {
const { session, events } = await openStubbornVoiceSession({});
expect(session.openOptionsPatch).toEqual({ voice: "direct", loud: false });
@@ -1129,30 +1362,94 @@ 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"]);
await session.close();
});
test("rejects a key the plugin does not report as a setting, and configures none of a mixed patch", async () => {
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
const configureBefore = lastConfigure(harness.inputs);
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
/launch_key.*cannot change on a running session/,
// 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 } },
);
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();
});
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, and what it ended up with is reported like any provider's.
expect(ended).toEqual({ loud: false });
await session.close();
});
test("draws a control from what the plugin reports", async () => {
const { session } = await openStubbornVoiceSession({});
expect((session.controls ?? []).map(controlKey)).toEqual(["voice", "loud"]);
await session.close();
});
test.runIf(shape === "controls")(
"keeps a control's icon, default and choices as the plugin declared them",
async () => {
const { session } = await openStubbornVoiceSession({});
expect(session.controls?.[0]).toMatchObject({
icon: "Mic",
defaultValue: "direct",
choices: [
{ label: "Direct", value: "direct" },
{ label: "Other", value: "other" },
],
});
await session.close();
},
);
test.runIf(shape === "controls")(
"sends a live option that has no control, and the plugin's answer decides",
async () => {
const { harness, session, events } = await openStubbornVoiceSession({});
await session.applyOptions?.({ token_budget: 2000 });
expect(lastConfigure(harness.inputs)).toMatchObject({
changes: { providerOptions: { token_budget: 2000 } },
});
// The plugin refuses the request as a whole, so the other key it was given is not applied.
await expect(session.applyOptions?.({ loud: true, token_budget: "lots" })).rejects.toThrow(
"bad budget",
);
expect(eventsOfType(events, "options_changed")).toEqual([]);
expect(await session.applyOptions?.({ token_budget: 3000 })).toEqual({});
await session.close();
},
);
test.runIf(shape === "settings")(
"rejects a key the plugin does not report as a setting, and configures none of a mixed patch",
async () => {
const { harness, session } = await openStubbornVoiceSession({ launch_key: "original" });
const configureBefore = lastConfigure(harness.inputs);
await expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
/launch_key.*cannot change on a running session/,
);
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: { providerOptions: object };
};
expect(Object.keys(configure.changes.providerOptions)).toEqual(["loud"]);
await session.close();
},
);
test("applying options returns the settings that ended up different, and emits nothing", async () => {
const { session, events } = await openStubbornVoiceSession({});
@@ -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;
@@ -1117,7 +1118,7 @@ class PluginAgentClient implements AgentClient {
this.provider,
bridge,
forget,
async (persistence, state) => {
async (persistence, state, options) => {
forget();
return await this.runtime.openSession({
sessionId: randomUUID(),
@@ -1126,9 +1127,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: options,
providerOptions: options,
},
persistence,
history: "replay",
@@ -1204,9 +1204,12 @@ 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> = {};
// The options a reopened session starts with: what it launched with, then every value the
// plugin reported or was given, minus every key that was reset.
private readonly committedOptions: Record<string, JsonValue>;
/** While `applyOptions` waits on the plugin, its `session.config` answers that call. */
private applyingOptions = false;
private currentTurnId: string | null = null;
@@ -1227,14 +1230,16 @@ class PluginAgentSession implements AgentSession {
private readonly reopen: (
persistence: ProviderPersistence,
state: ProviderConfigState,
options: Readonly<Record<string, JsonValue>>,
) => 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.committedOptions = { ...givenOptions };
this.openOptionsPatch = this.optionDifferences(givenOptions);
this.commitOptions();
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 +1263,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 +1422,50 @@ 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;
// 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 before = this.currentOptions();
const { controls, legacySettings } = readPluginOptionState(this.bridge.config);
const settings: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(options)) {
if (!(id in before)) continue;
settings[id] = toJsonValue(value, `setting '${id}'`);
// COMPAT(plugin-provider-settings): added in v0.15.0, remove after 2027-05-01. A plugin
// reporting `settings` cannot refuse a key, so only its settings are sent and any other
// changed key is rejected here; a value equal to the one it opened with is not a change.
if (legacySettings) {
const live = controls.map((control) => control.key);
assertChangesAreLive({
provider: this.provider,
patch: options,
current: this.givenOptions,
live,
});
for (const [id, value] of Object.entries(options)) {
if (live.includes(id)) settings[id] = toJsonValue(value, `option '${id}'`);
}
} else {
// The plugin applies every key it is given or rejects the whole request.
for (const [id, value] of Object.entries(options)) {
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();
// A requested key is a request, not a report: only a key the plugin reported before can be
// one it has now dropped.
const ended = this.optionChanges({ ...before, ...settings }, before);
this.commitOptions(settings);
return ended;
}
@@ -1648,28 +1648,58 @@ 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. */
private optionDifferences(known: Readonly<Record<string, JsonValue>>): Record<string, JsonValue> {
const patch: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(this.currentSettings())) {
for (const [id, value] of Object.entries(this.currentOptions())) {
if (!isDeepStrictEqual(known[id], value)) patch[id] = value;
}
return patch;
}
private reportSettingChanges(): AgentStreamEvent[] {
/**
* Settle the options a reopened session starts with: what is committed, then the request the
* plugin accepted, then what it reported back. A key it reported before and no longer reports
* was reset.
*/
private commitOptions(accepted: Readonly<Record<string, JsonValue>> = {}): void {
for (const [id, value] of Object.entries(accepted)) {
if (value === null) delete this.committedOptions[id];
else this.committedOptions[id] = value;
}
const current = this.currentOptions();
for (const id of Object.keys(this.knownOptions)) {
if (!(id in current)) delete this.committedOptions[id];
}
Object.assign(this.committedOptions, current);
this.knownOptions = current;
}
/**
* What changed against the options `known` to be set: values that differ, and `null` for a key
* in `reported` that the plugin no longer reports, which went back to its default.
*/
private optionChanges(
known: Readonly<Record<string, JsonValue>>,
reported: Readonly<Record<string, JsonValue>>,
): Record<string, JsonValue> {
const patch = this.optionDifferences(known);
const current = this.currentOptions();
for (const id of Object.keys(reported)) {
if (known[id] !== null && !(id in current)) patch[id] = null;
}
return patch;
}
private reportOptionChanges(): AgentStreamEvent[] {
if (this.applyingOptions) return [];
const patch = this.settingDifferences(this.knownSettings);
const patch = this.optionChanges(this.knownOptions, this.knownOptions);
if (Object.keys(patch).length === 0) return [];
this.knownSettings = this.currentSettings();
this.commitOptions();
return [{ type: "options_changed", provider: this.provider, patch }];
}
@@ -1677,7 +1707,7 @@ class PluginAgentSession implements AgentSession {
event: Extract<ProviderEvent, { type: "session.config" }>,
): AgentStreamEvent[] {
return [
...this.reportSettingChanges(),
...this.reportOptionChanges(),
{
type: "mode_changed",
provider: this.provider,
@@ -1828,7 +1858,7 @@ class PluginAgentSession implements AgentSession {
await this.bridge.close();
replay.closed = true;
}
const bridge = await this.reopen(replay.persistence, replay.config);
const bridge = await this.reopen(replay.persistence, replay.config, this.committedOptions);
this.bridge = bridge;
this.subagentIdsBySession.set(bridge.id, null);
for (const event of bridge.history) this.accept(event, false);
+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,7 +89,29 @@ 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: { model: "other-model", providerOptions: { voice: "loud" } },
});
expect(events).toContainEqual(
expect.objectContaining({ type: "request.failed", requestId: "configure-refused" }),
);
// A refused request changes nothing, the model it named included.
await connection.send({
type: "session.configure",
requestId: "configure-after-refused",
sessionId: "session-complete",
changes: {},
});
expect(events.findLast((event) => event.type === "session.config")).not.toMatchObject({
config: { model: "other-model" },
});
await connection.send({
type: "session.prompt",
@@ -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,
@@ -344,6 +372,19 @@ function configureSession(
): void {
const session = requireSession(state, input.sessionId);
const changes = input.changes;
// Work out everything first, so a refused request changes nothing.
let options: ExampleSession["options"];
try {
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;
}
session.options = options;
session.config = {
...session.config,
model: changes.model === null ? undefined : (changes.model ?? session.config.model),
@@ -352,14 +393,31 @@ function configureSession(
changes.thinkingOption === null
? undefined
: (changes.thinkingOption ?? session.config.thinkingOption),
settings: changes.settings
? { ...session.config.settings, ...changes.settings }
: session.config.settings,
};
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 +426,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 },
};
}
+8 -8
View File
@@ -206,14 +206,14 @@ Receives `{ request: { config, env? } }`. Config changes are saved and remain af
**`agent.create.before.config`** uses `AgentSessionConfig`:
| Fields | Constraint |
| --------------------------------------------- | -------------------------------------------------------------------------- |
| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options |
| `modeId`, `thinkingOptionId`, `featureValues` | Provider-specific selections |
| `title`, `systemPrompt` | Agent configuration |
| `providerOptions` | Opaque provider-specific options |
| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals |
| `cwd` | Cannot change |
| Fields | Constraint |
| ---------------------------- | -------------------------------------------------------------------------- |
| `provider`, `model` | Separate fields; changing provider may require changing model/mode/options |
| `modeId`, `thinkingOptionId` | Provider-specific selections |
| `title`, `systemPrompt` | Agent configuration |
| `providerOptions` | Provider options, including speed and auto-accept selections |
| `mcpServers`, `toolPolicy` | MCP configuration and exact-tool preapprovals |
| `cwd` | Cannot change |
### Change saved configuration
+8 -8
View File
@@ -203,14 +203,14 @@ server.addHook("worktree.create.after", async ({ worktree }) => {
**`agent.create.before.config`** 使用 `AgentSessionConfig`:
| 字段 | 约束 |
| --------------------------------------------- | -------------------------------------------------------- |
| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 |
| `modeId`, `thinkingOptionId`, `featureValues` | 特定于 Provider 的选择 |
| `title`, `systemPrompt` | Agent 配置 |
| `providerOptions` | 不透明的、特定于 Provider 的选项 |
| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 |
| `cwd` | 不能修改 |
| 字段 | 约束 |
| ---------------------------- | -------------------------------------------------------- |
| `provider`, `model` | 独立的字段;修改 Provider 可能需要同时修改模型/模式/选项 |
| `modeId`, `thinkingOptionId` | 特定于 Provider 的选择 |
| `title`, `systemPrompt` | Agent 配置 |
| `providerOptions` | Provider 选项,包括速度和自动批准的选择 |
| `mcpServers`, `toolPolicy` | MCP 配置和精确的工具预批准 |
| `cwd` | 不能修改 |
### 修改已保存的配置 {#change-saved-configuration}
+20
View File
@@ -184,6 +184,26 @@ For paced timeline text, replace `useRevealedText(text, phase)` and the surround
`<StreamedText text={text} phase={phase} style={style} />` from `/client/ui`.
Use `SettingsSection` for plugin settings headings; `SettingsGroup` is deprecated.
### Provider options in v0.15
A provider plugin has one configuration record, `providerOptions`, and describes which keys get a
composer control. A provider that still reports `settings` keeps working through v0.15: Paseo reads each
setting `id` as an option key and each `value` as that option, and sends changes in both
`changes.providerOptions` and `changes.settings`. Remove `settings` before the shim is deleted after
2027-05-01.
| Previous API | Replacement |
| ---------------------------------------------- | -------------------------------------------------------------------------- |
| `session.config` `settings: ProviderSetting[]` | `controls: ProviderOptionControl[]` plus `providerOptions` |
| Setting `id` | Control `key` |
| Setting `value` | `providerOptions[key]`; the control holds no value |
| Select setting `options: { label, value }[]` | Select control `choices: { value, label, description? }[]` |
| Setting with no icon or default | Control `icon` (a Lucide name) and `defaultValue` |
| `session.configure` `changes.settings` | `changes.providerOptions`; `null` returns a key to its default |
| `session.open` `config.settings` | `config.providerOptions` |
| `ProviderSetting` type | `ProviderOptionControl` |
| `agent.create.before` `config.featureValues` | `config.providerOptions`; a returned `featureValues` edit is still applied |
## 5. Recognize half-migration errors
| Compiler or load error | Meaning and fix |
+16
View File
@@ -152,6 +152,22 @@ pill.remove();
对于逐步显示的时间线文本,把 `useRevealedText(text, phase)` 及其外层的 `Text` 换成 `/client/ui` 中的 `<StreamedText text={text} phase={phase} style={style} />`。插件设置的标题使用 `SettingsSection`;`SettingsGroup` 已弃用。
### v0.15 中的 Provider 选项 {#provider-options-in-v015}
Provider 插件只有一个配置记录 `providerOptions`,并用控件描述哪些键显示在 Composer 中。仍然报告 `settings` 的 Provider 在 v0.15 中继续可用:Paseo 把每个 setting 的 `id` 当作选项键,把 `value` 当作该选项的值,并同时在 `changes.providerOptions` 和 `changes.settings` 中发送修改。请在 2027-05-01 之后兼容层被删除之前移除 `settings`。
| 之前的 API | 替代方式 |
| ------------------------------------------------- | --------------------------------------------------------------- |
| `session.config` 的 `settings: ProviderSetting[]` | `controls: ProviderOptionControl[]` 加 `providerOptions` |
| Setting 的 `id` | 控件的 `key` |
| Setting 的 `value` | `providerOptions[key]`;控件本身不保存值 |
| 选择类 setting 的 `options: { label, value }[]` | 选择类控件的 `choices: { value, label, description? }[]` |
| 没有图标或默认值的 setting | 控件的 `icon`(Lucide 名称)和 `defaultValue` |
| `session.configure` 的 `changes.settings` | `changes.providerOptions`;`null` 表示恢复该键的默认值 |
| `session.open` 的 `config.settings` | `config.providerOptions` |
| `ProviderSetting` 类型 | `ProviderOptionControl` |
| `agent.create.before` 的 `config.featureValues` | `config.providerOptions`;返回的 `featureValues` 修改仍会被应用 |
## 5. 识别只迁移了一半的错误 {#5-recognize-half-migration-errors}
| 编译器或加载错误 | 含义与修复方法 |
+45 -25
View File
@@ -22,7 +22,7 @@ Choose one implementation path:
The examples are:
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, settings, prompts, persistence, child sessions, and a provider-owned timeline renderer;
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct): sessions, option controls, prompts, persistence, child sessions, and a provider-owned timeline renderer;
- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer): an ACP command template with a Zod-validated vendor edit transformer. Replace `example-acp --stdio` with an installed ACP agent before loading it;
- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking): a renderer-only plugin that does not implement a provider.
@@ -138,7 +138,7 @@ later through `session.config`.
## Open a session
`session.open` contains the complete launch configuration: working directory, environment, system
prompt, MCP servers, tool policy, model, mode, settings, opaque provider options, and persistence
prompt, MCP servers, tool policy, model, mode, `providerOptions`, and persistence
preference.
For persisted host-scoped plugin settings, use the handle returned by `server.registerSettings()`.
@@ -177,7 +177,8 @@ emit({
models,
modes,
thinkingOptions,
settings: [],
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
@@ -229,10 +230,16 @@ terminal `completed`, `failed`, or `canceled` event.
At this point the provider is usable: users can select a model, open a session, send a message, and
see the response.
## Add session-specific composer controls
## Add composer controls for provider options
Add `session.configure` only when the native agent has settings users should change after opening a
session. Publish them in `session.config` as toggle or select controls:
`providerOptions` is the provider's own configuration: one record of JSON values that Paseo
stores per agent and hands to `session.open`. It includes the defaults a user set in `config.json`
for your provider. Validate every key you read.
Add `controls` only when users should see and change some of those options in the composer. A
control draws one top-level key of `providerOptions` as a toggle or a select. It holds no value:
Paseo reads the value from `providerOptions`. Publish the controls and the values you run with in
`session.config`:
```ts
emit({
@@ -243,35 +250,48 @@ emit({
models: [{ id: "agent-large", label: "Agent Large" }],
modes: [],
thinkingOptions: [],
settings: [
{
type: "toggle",
id: "fast",
label: "Fast mode",
value: nativeSession.fast,
},
controls: [
{ type: "toggle", key: "fast", label: "Fast mode", icon: "Zap", defaultValue: false },
{
type: "select",
id: "approval",
key: "approval",
label: "Approvals",
value: nativeSession.approval,
options: [
{ label: "Ask", value: "ask" },
{ label: "Auto accept", value: "auto" },
icon: "Shield",
defaultValue: "ask",
choices: [
{ value: "ask", label: "Ask" },
{ value: "auto", label: "Auto accept" },
],
},
],
providerOptions: { fast: nativeSession.fast, approval: nativeSession.approval },
},
});
```
Paseo renders these controls in the composer. A user change arrives as `session.configure`; apply it
and publish the committed `session.config` before `request.completed`. The provider may change the
available controls at runtime, including after a model change.
`icon` is a Lucide icon name. `defaultValue` is what a control shows for a key that has no value.
Paseo stores every value you report when a session opens or a change is applied, including values
at the default and values nobody set. The same controls appear in the New workspace screen, in draft
tabs, and on running agents. Offer a control only for a key you can change on a running session.
The provider may change its controls at runtime, including after a model change.
`settings` are user-facing controls. `providerOptions` is opaque JSON supplied at session creation
for provider-specific configuration that Paseo does not render. A provider-specific token budget
belongs in one of those two places; it is not a dedicated prompt field.
A change arrives as `session.configure` with `changes.providerOptions` holding only the keys
that changed, whether or not they have a control. Apply every key and publish the committed
`session.config` before `request.completed`. Fail the request with `request.failed` and leave
every key as it was when you cannot apply one, for example a key you read only when a session
opens. A
value of `null` returns that key to the provider's default. Publish what the session ended up
with, not a copy of the request: Paseo stores your published values so the stored options match
what runs.
If the provider changes an option on its own, for example a model switch turns Fast off, publish
a new `session.config` with the new values. Paseo stores them and does not set the old ones
back.
Not every option needs a control. A provider-specific token budget, for example, belongs in
`providerOptions` and is not a dedicated prompt field. It is delivered in `session.open`, a caller
can change it on a running agent, and your `session.configure` handler decides whether it can
apply it live. Report it in `providerOptions` so Paseo stores what runs.
## Add persistence and replay
@@ -467,7 +487,7 @@ export const vendorEdits: AcpTransformer = {
Test the provider against the real agent, not only mocked frames:
1. install the plugin as a directory source;
2. confirm its SVG, models, modes, and settings appear;
2. confirm its SVG, models, modes, and option controls appear;
3. create a session and complete a real prompt;
4. exercise permissions, steering, cancellation, persistence, and replay when supported;
5. render every custom timeline item on desktop and mobile-width layouts;
+24 -20
View File
@@ -19,7 +19,7 @@ Provider 插件把一个编程 Agent 接入 Paseo,而无需把它加入 Paseo
示例有:
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct):会话、设置、提示词、持久化、子会话,以及一个由 Provider 拥有的时间线渲染器;
- [`provider-direct`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-direct):会话、选项控件、提示词、持久化、子会话,以及一个由 Provider 拥有的时间线渲染器;
- [`provider-acp-transformer`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/provider-acp-transformer):一个 ACP 命令模板,带有一个用 Zod 校验的厂商编辑转换器。加载之前,请把 `example-acp --stdio` 替换为一个已安装的 ACP Agent;
- [`inline-thinking`](https://github.com/getpaseo/paseo/tree/main/plugin-examples/inline-thinking):一个只包含渲染器、不实现 Provider 的插件。
@@ -125,7 +125,7 @@ if (input.type === "catalog") {
## 打开会话 {#open-a-session}
`session.open` 包含完整的启动配置:工作目录、环境变量、系统提示词、MCP 服务器、工具策略、模型、模式、设置、不透明的 Provider 选项以及持久化偏好。
`session.open` 包含完整的启动配置:工作目录、环境变量、系统提示词、MCP 服务器、工具策略、模型、模式、`providerOptions` 以及持久化偏好。
对于持久化的、作用于主机的插件设置,请使用 `server.registerSettings()` 返回的句柄。在目录和会话回调中读取它;当 Provider 在内存中保留配置时,订阅它。`read()` 和 `subscribe()` 的契约见[设置](./reference.md#persisted-values)。
@@ -160,7 +160,8 @@ emit({
models,
modes,
thinkingOptions,
settings: [],
controls: [],
providerOptions: {},
},
});
emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId });
@@ -207,9 +208,11 @@ emit({ type: "session.turn", sessionId, turnId, state: "completed" });
到这一步,Provider 已经可用:用户可以选择模型、打开会话、发送消息并看到回复。
## 添加会话专属的 Composer 控件 {#add-session-specific-composer-controls}
## 为 Provider 选项添加 Composer 控件 {#add-composer-controls-for-provider-options}
只有当原生 Agent 有用户在打开会话后需要修改的设置时,才添加 `session.configure`。在 `session.config` 中以开关或选择控件的形式发布它们:
`providerOptions` 是 Provider 自己的配置:一个由 JSON 值组成的记录,Paseo 按 Agent 保存它,并在 `session.open` 中交给你。它包含用户在 `config.json` 中为你的 Provider 设置的默认值。请校验你读取的每个键。
只有当用户需要在 Composer 中查看并修改其中一部分选项时,才添加 `controls`。一个控件把 `providerOptions` 的一个顶层键绘制为开关或选择器。控件本身不保存值:Paseo 从 `providerOptions` 读取值。在 `session.config` 中发布控件,以及你正在使用的值:
```ts
emit({
@@ -220,31 +223,32 @@ emit({
models: [{ id: "agent-large", label: "Agent Large" }],
modes: [],
thinkingOptions: [],
settings: [
{
type: "toggle",
id: "fast",
label: "Fast mode",
value: nativeSession.fast,
},
controls: [
{ type: "toggle", key: "fast", label: "Fast mode", icon: "Zap", defaultValue: false },
{
type: "select",
id: "approval",
key: "approval",
label: "Approvals",
value: nativeSession.approval,
options: [
{ label: "Ask", value: "ask" },
{ label: "Auto accept", value: "auto" },
icon: "Shield",
defaultValue: "ask",
choices: [
{ value: "ask", label: "Ask" },
{ value: "auto", label: "Auto accept" },
],
},
],
providerOptions: { fast: nativeSession.fast, approval: nativeSession.approval },
},
});
```
Paseo 会在 Composer 中渲染这些控件。用户的修改会以 `session.configure` 的形式到达;应用它,并在 `request.completed` 之前发布已确定的 `session.config`。Provider 可以在运行时改变可用的控件,包括在模型变化之后。
`icon` 是 Lucide 图标名称。`defaultValue` 是控件在键没有值时显示的值。会话打开或应用修改时,你报告的每个值(包括处于默认值的值和无人设置过的值)Paseo 都会保存。相同的控件会出现在“新建工作区”界面、草稿标签页和运行中的 Agent 上。只为你能在运行中的会话里修改的键提供控件。Provider 可以在运行时改变控件,包括在模型变化之后。
`settings` 是面向用户的控件。`providerOptions` 是在创建会话时提供的不透明 JSON,用于 Paseo 不会渲染的 Provider 专属配置。Provider 专属的 token 预算应放在这两处之一;它不是一个专门的提示词字段。
修改会以 `session.configure` 的形式到达,`changes.providerOptions` 只包含发生变化的键,无论这些键是否有控件。应用每一个键,并在 `request.completed` 之前发布已确定的 `session.config`。如果无法应用其中某个键(例如只在会话打开时读取的键),用 `request.failed` 使请求失败,并让每个键保持原样。值为 `null` 表示把该键恢复为 Provider 的默认值。请发布会话最终实际使用的值,而不是请求的副本:Paseo 会保存你发布的值,使保存的选项与实际运行的一致。
如果 Provider 自行改变了某个选项(例如切换模型后关闭了 Fast),请发布带有新值的 `session.config`。Paseo 会保存它们,不会把旧值设置回去。
并非每个选项都需要控件。例如 Provider 专属的 token 预算应放在 `providerOptions` 中,它不是一个专门的提示词字段。它会在 `session.open` 中交付,调用方可以在运行中的 Agent 上修改它,由你的 `session.configure` 处理程序决定能否实时应用。请在 `providerOptions` 中报告它,使 Paseo 保存实际运行的值。
## 添加持久化与重放 {#add-persistence-and-replay}
@@ -405,7 +409,7 @@ export const vendorEdits: AcpTransformer = {
用真实的 Agent 测试 Provider,而不只是模拟的数据帧:
1. 以目录来源安装插件;
2. 确认它的 SVG、模型、模式和设置都会显示;
2. 确认它的 SVG、模型、模式和选项控件都会显示;
3. 创建会话并完成一段真实的提示词;
4. 在支持的情况下,试用权限、引导、取消、持久化和重放;
5. 在桌面和移动端宽度的布局中渲染每一种自定义时间线条目;
+7 -5
View File
@@ -409,7 +409,7 @@ process, credential, and other machine-local work under `server/`. A plugin with
### Providers
Follow [Build a provider plugin](/docs/plugins/providers) for direct and ACP implementations,
session lifecycle, composer settings, timeline renderers, testing, and distribution.
session lifecycle, option controls, timeline renderers, testing, and distribution.
Call `server.registerProvider()` with a `ProviderRegistration` from
`@getpaseo/plugin/server/provider`. Its connection accepts inputs with `send()` and emits complete state
@@ -420,10 +420,12 @@ Use the single `session.prompt` input for messages, structured commands, steerin
effects. Repeat `clientMessageId` on the live user timeline item and publish exactly one matching
`session.prompt_result`. Publish provider-created children as sessions with `parentSessionId`.
Provider settings are toggle/select descriptors that Paseo renders in the composer. Keep
provider-private options under `ProviderSessionConfig.providerOptions` on `session.open`.
This record includes configured defaults and per-agent overrides; validate and apply it in your
provider. See [Provider options](/docs/sdk/provider-options) for configuration and merge semantics.
Provider options are one record, `ProviderSessionConfig.providerOptions` on `session.open`. It
includes configured defaults and per-agent overrides; validate and apply it in your provider. Report
`controls` in `session.config` for the top-level keys that get a toggle or select in the composer,
with the values the session runs under `providerOptions`. A running-session change arrives as
`session.configure` with `changes.providerOptions`. See [Provider options](/docs/sdk/provider-options)
for configuration and merge semantics.
Host tools arrive as MCP servers in the complete
session config.
+2 -2
View File
@@ -353,13 +353,13 @@ Paseo 为服务端代码提供 `@getpaseo/plugin`、`@getpaseo/plugin/server`、
### Provider {#providers}
直接实现和 ACP 实现、会话生命周期、Composer 设置、时间线渲染器、测试和分发,见[构建 Provider 插件](/docs/plugins/providers)。
直接实现和 ACP 实现、会话生命周期、选项控件、时间线渲染器、测试和分发,见[构建 Provider 插件](/docs/plugins/providers)。
使用来自 `@getpaseo/plugin/server/provider` 的 `ProviderRegistration` 调用 `server.registerProvider()`。它的连接通过 `send()` 接收输入,并通过 `onEvent()` 发出完整的状态快照。`send()` 只报告是否接受;提示词的处理结果、轮次、配置、持久化、权限和失败都是事件。
消息、结构化命令、引导和命令副作用都使用同一个 `session.prompt` 输入。在实时的用户时间线条目上重复 `clientMessageId`,并恰好发布一个与之匹配的 `session.prompt_result`。把 Provider 创建的子会话发布为带有 `parentSessionId` 的会话。
Provider 设置是 Paseo 在 Composer 中渲染的开关/选择描述符。把 Provider 私有的选项保留在 `session.open` 的 `ProviderSessionConfig.providerOptions` 中。这个记录包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。配置和合并语义见 [Provider 选项](/docs/sdk/provider-options)。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。
Provider 选项是一个记录,即 `session.open` 中的 `ProviderSessionConfig.providerOptions`。它包含配置的默认值和针对单个 Agent 的覆盖值;请在你的 Provider 中校验并应用它。在 `session.config` 中为需要在 Composer 里显示开关或选择器的顶层键报告 `controls`,并在 `providerOptions` 下报告会话正在使用的值。运行中会话的修改会以 `session.configure` 的形式到达,内容在 `changes.providerOptions` 中。配置和合并语义见 [Provider 选项](/docs/sdk/provider-options)。宿主工具以 MCP 服务器的形式出现在完整的会话配置中。
Paseo 刷新 Agent 的方式是关闭它当前的 Provider 会话,再用当前的配置和持久化值打开它。Provider 在 `session.open` 期间重新读取外部状态。