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.
This commit is contained in:
Mohamed Boudra
2026-11-02 00:34:19 +01:00
parent 970c281daf
commit 00ec4bc80c
5 changed files with 91 additions and 40 deletions
+3 -3
View File
@@ -129,9 +129,9 @@ export interface ProviderConfigChanges {
mode?: string | null;
thinkingOption?: string | null;
/**
* The option keys to change on the running session. A key it cannot apply live is not in
* `controls`, and Paseo does not send it. `null` returns the key to the provider's own default.
* Apply the change or fail the request; the committed values come back in `session.config`.
* 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`. */
@@ -1016,7 +1016,10 @@ describe("PluginAgentClientRegistry", () => {
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 reported = () =>
shape === "controls"
@@ -1041,7 +1044,11 @@ describe("PluginAgentClientRegistry", () => {
defaultValue: false,
},
],
providerOptions: { voice: state.voice, loud: state.loud },
providerOptions: {
voice: state.voice,
loud: state.loud,
...(state.tokenBudget === undefined ? {} : { token_budget: state.tokenBudget }),
},
}
: {
settings: [
@@ -1089,8 +1096,18 @@ describe("PluginAgentClientRegistry", () => {
return true;
}
if (input.type === "session.configure") {
const loud = input.changes.providerOptions?.loud;
const { loud, token_budget: tokenBudget } = input.changes.providerOptions ?? {};
// 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;
emitConfig(emit, input.sessionId);
emit(
refuseAfterChanging
@@ -1201,24 +1218,47 @@ describe("PluginAgentClientRegistry", () => {
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);
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 expect(session.applyOptions?.({ loud: true, launch_key: "changed" })).rejects.toThrow(
/launch_key.*cannot change on a running session/,
);
await session.applyOptions?.({ token_budget: 2000 });
expect(lastConfigure(harness.inputs)).toMatchObject({
changes: { providerOptions: { token_budget: 2000 } },
});
expect(lastConfigure(harness.inputs)).toBe(configureBefore);
// 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();
},
);
// 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.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({});
@@ -1420,19 +1420,26 @@ class PluginAgentSession implements AgentSession {
this.assertReplayComplete();
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.givenOptions,
live,
});
const settings: Record<string, JsonValue> = {};
for (const [id, value] of Object.entries(options)) {
if (!live.includes(id)) continue;
settings[id] = toJsonValue(value, `option '${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;
+9 -5
View File
@@ -275,9 +275,11 @@ at the default and values nobody set. The same controls appear in the New worksp
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.
A user change arrives as `session.configure` with `changes.providerOptions` holding only the keys
that changed. Apply the change and publish the committed `session.config` before
`request.completed`, or fail the request with `request.failed` and leave every key as it was. A
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.
@@ -286,8 +288,10 @@ If the provider changes an option on its own, for example a model switch turns F
a new `session.config` with the new values. Paseo stores them and does not set the old ones
back.
Keys without a control are still delivered in `session.open` and stored. A provider-specific
token budget, for example, belongs in `providerOptions` and is not a dedicated prompt field.
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
+2 -2
View File
@@ -244,11 +244,11 @@ emit({
`icon` 是 Lucide 图标名称。`defaultValue` 是控件在键没有值时显示的值。会话打开或应用修改时,你报告的每个值(包括处于默认值的值和无人设置过的值)Paseo 都会保存。相同的控件会出现在“新建工作区”界面、草稿标签页和运行中的 Agent 上。只为你能在运行中的会话里修改的键提供控件。Provider 可以在运行时改变控件,包括在模型变化之后。
用户的修改会以 `session.configure` 的形式到达,`changes.providerOptions` 只包含发生变化的键。应用它,并在 `request.completed` 之前发布已确定的 `session.config`;或者用 `request.failed` 使请求失败,并让每个键保持原样。值为 `null` 表示把该键恢复为 Provider 的默认值。请发布会话最终实际使用的值,而不是请求的副本:Paseo 会保存你发布的值,使保存的选项与实际运行的一致。
修改会以 `session.configure` 的形式到达,`changes.providerOptions` 只包含发生变化的键,无论这些键是否有控件。应用每一个键,并在 `request.completed` 之前发布已确定的 `session.config`。如果无法应用其中某个键(例如只在会话打开时读取的键),用 `request.failed` 使请求失败,并让每个键保持原样。值为 `null` 表示把该键恢复为 Provider 的默认值。请发布会话最终实际使用的值,而不是请求的副本:Paseo 会保存你发布的值,使保存的选项与实际运行的一致。
如果 Provider 自行改变了某个选项(例如切换模型后关闭了 Fast),请发布带有新值的 `session.config`。Paseo 会保存它们,不会把旧值设置回去。
没有控件的键仍会在 `session.open` 中交付并被保存。例如 Provider 专属的 token 预算应放在 `providerOptions` 中,它不是一个专门的提示词字段。
并非每个选项都需要控件。例如 Provider 专属的 token 预算应放在 `providerOptions` 中,它不是一个专门的提示词字段。它会在 `session.open` 中交付,调用方可以在运行中的 Agent 上修改它,由你的 `session.configure` 处理程序决定能否实时应用。请在 `providerOptions` 中报告它,使 Paseo 保存实际运行的值。
## 添加持久化与重放 {#add-persistence-and-replay}