From 377b06be0dd2f0dd5deda297f991a13438686899 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 01:10:56 +0100 Subject: [PATCH] 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. --- .../src/server/agent/plugin-provider.test.ts | 19 ++++++++++++++++ .../src/server/agent/plugin-provider.ts | 22 +++++++++++++------ 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index ecfcd440b6..e93283c390 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -1171,6 +1171,8 @@ describe("PluginAgentClientRegistry", () => { } 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 @@ -1233,6 +1235,23 @@ describe("PluginAgentClientRegistry", () => { }, ); + 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 }); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index e1560d2fa7..60350ec9aa 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1462,7 +1462,7 @@ class PluginAgentSession implements AgentSession { } finally { this.applyingOptions = false; } - const ended = this.optionDifferences({ ...before, ...settings }); + const ended = this.optionChanges({ ...before, ...settings }); for (const [id, value] of Object.entries(settings)) { if (value === null) delete this.committedOptions[id]; } @@ -1672,14 +1672,22 @@ class PluginAgentSession implements AgentSession { this.knownOptions = current; } + /** + * What changed against the options `known` to be set: values that differ, and `null` for a key + * the plugin no longer reports, which went back to its default. + */ + private optionChanges(known: Readonly>): Record { + const patch = this.optionDifferences(known); + const current = this.currentOptions(); + for (const [id, value] of Object.entries(known)) { + if (value !== null && !(id in current)) patch[id] = null; + } + return patch; + } + private reportOptionChanges(): AgentStreamEvent[] { if (this.applyingOptions) return []; - const patch = this.optionDifferences(this.knownOptions); - // A key the plugin reported before and no longer reports went back to its default. - const current = this.currentOptions(); - for (const id of Object.keys(this.knownOptions)) { - if (!(id in current)) patch[id] = null; - } + const patch = this.optionChanges(this.knownOptions); if (Object.keys(patch).length === 0) return []; this.commitOptions(); return [{ type: "options_changed", provider: this.provider, patch }];