mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-03 07:03:18 +00:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user