From 0ce2010773c21fef1b053bb10581db9cdd3b5912 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 09:42:31 +0100 Subject: [PATCH] fix(server): keep plugin startup settings, opening reconciliation and reset defaults across rewind A plugin that cannot be configured takes its settings in the open request as before; only a configurable plugin opens without overrides so its reported values can serve as reset defaults. Option changes a plugin makes while its session opens are held and delivered to the manager when it subscribes, so the stored option follows the committed value. The reset defaults belong to the logical session and survive the session being reopened after a rewind. --- .../src/server/agent/plugin-provider.test.ts | 96 ++++++++++++++++++- .../src/server/agent/plugin-provider.ts | 38 ++++++-- 2 files changed, 122 insertions(+), 12 deletions(-) diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 0b2b432e37..07208aaedc 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -898,17 +898,22 @@ function voiceConfig(voice: string) { * A plugin whose `voice` setting defaults to "direct". With `acceptsVoice: false` it completes a * configure request but keeps its voice. */ -function startVoicePlugin(input: { acceptsVoice: boolean }) { +function startVoicePlugin(input: { acceptsVoice: boolean; configurable?: boolean }) { + const capabilities = [ + ...CAPABILITIES.filter((name) => input.configurable !== false || name !== "session.configure"), + "session.revert.conversation", + ]; const harness = createProviderHarness({ + capabilities, async handleInput(request, emit) { if (request.type === "session.open") { emit({ type: "session.opened", requestId: request.requestId, sessionId: request.sessionId, - capabilities: CAPABILITIES, + capabilities, restoration: "core", - persistence: { version: 1, data: { token: "root" } }, + persistence: request.persistence ?? { version: 1, data: { token: "root" } }, cwd: request.config.cwd, }); emit({ @@ -919,6 +924,31 @@ function startVoicePlugin(input: { acceptsVoice: boolean }) { emit({ type: "session.ready", requestId: request.requestId, sessionId: request.sessionId }); return true; } + if (request.type === "session.prompt") { + if (request.prompt.input.type !== "message") throw new Error("Expected a message"); + const text = request.prompt.input.content + .flatMap((part) => (part.type === "text" ? [part.text] : [])) + .join(""); + const turnId = `turn-${text}`; + emit({ + type: "session.prompt_result", + sessionId: request.sessionId, + clientMessageId: request.prompt.clientMessageId, + result: { type: "turn", turnId }, + }); + emit({ type: "session.turn", sessionId: request.sessionId, turnId, state: "started" }); + emit({ + type: "timeline.item", + sessionId: request.sessionId, + item: { type: "user_message", id: `user-${text}`, text, revertToken: text }, + }); + emit({ type: "session.turn", sessionId: request.sessionId, turnId, state: "completed" }); + return true; + } + if (request.type === "session.revert") { + emit({ type: "request.completed", requestId: request.requestId }); + return true; + } if (request.type !== "session.configure") return false; const requested = request.changes.settings?.voice; emit({ @@ -1732,6 +1762,66 @@ describe("PluginAgentClientRegistry", () => { } }); + test("a setting passed to a plugin that cannot be configured goes in the open request", async () => { + const { harness, manager, registry } = startVoicePlugin({ + acceptsVoice: true, + configurable: false, + }); + try { + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } }, + undefined, + { workspaceId: undefined }, + ); + + const opened = harness.inputs.find((input) => input.type === "session.open"); + expect(opened?.type === "session.open" && opened.config.settings).toEqual({ voice: "soft" }); + expect(configuredSettings(harness)).toEqual([]); + expect(toAgentPayload(manager.getAgent(agent.id)!).options).toEqual({ voice: "soft" }); + } finally { + await registry.shutdown(); + } + }); + + test("a selection the plugin settles on while the agent is created replaces the requested option", async () => { + const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: false }); + try { + const agent = await manager.createAgent( + { provider: harness.registration.id, cwd: tmpdir(), providerOptions: { voice: "soft" } }, + undefined, + { workspaceId: undefined }, + ); + + const payload = toAgentPayload(manager.getAgent(agent.id)!); + expect(payload.options).toEqual({ voice: "direct" }); + expect(voiceFeature(payload)).toMatchObject({ value: "direct" }); + } finally { + await registry.shutdown(); + } + }); + + test("removing an option after a conversation rewind returns the setting to the plugin's default", async () => { + const { harness, registry } = startVoicePlugin({ acceptsVoice: true }); + try { + const client = registry.clients()[harness.registration.id]!; + const session = await client.createSession({ + provider: harness.registration.id, + cwd: tmpdir(), + providerOptions: { voice: "soft" }, + }); + await session.run("A"); + await session.run("B"); + await session.revertConversation!({ messageId: "user-B" }); + + await session.applyOptions!({}); + + expect(configuredSettings(harness)).toEqual([{ voice: "soft" }, { voice: "direct" }]); + await session.close(); + } finally { + await registry.shutdown(); + } + }); + test("a selection the plugin keeps on its previous value replaces the requested option", async () => { const { harness, manager, registry } = startVoicePlugin({ acceptsVoice: false }); try { diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 746898b6a4..d594e215e3 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -195,6 +195,11 @@ class ProviderRuntime { return this.connection?.capabilities ?? []; } + /** The capabilities of the connection, connecting first if it is not open yet. */ + async connectedCapabilities(): Promise { + return (await this.getConnection()).capabilities; + } + get isClosed(): boolean { return this.closed; } @@ -1097,13 +1102,13 @@ class PluginAgentClient implements AgentClient { }): Promise { const sessionId = randomUUID(); // The plugin reports its own setting values once open, and those are what a later option - // removal returns to. So the session opens without setting overrides and applies them after. + // removal returns to. So a plugin that can be configured opens without setting overrides and + // has them applied after. One that cannot takes them in the open request. + const configurable = (await this.runtime.connectedCapabilities()).includes("session.configure"); + const openConfig = mapSessionConfig(input.config, input.launchContext, input.persist); const bridge = await this.runtime.openSession({ sessionId, - config: { - ...mapSessionConfig(input.config, input.launchContext, input.persist), - settings: {}, - }, + config: configurable ? { ...openConfig, settings: {} } : openConfig, persistence: input.persistence, history: input.history, }); @@ -1143,7 +1148,7 @@ class PluginAgentClient implements AgentClient { ); bind(bridge); try { - await session.applyOptions(input.config.providerOptions ?? {}); + if (configurable) await session.applyOptions(input.config.providerOptions ?? {}); } catch (error) { await session.close(); throw error; @@ -1224,8 +1229,13 @@ class PluginAgentSession implements AgentSession { closed: boolean; } | null = null; private replaying: Promise | null = null; - /** Each setting's value before Paseo first changed it, for an option that is later removed. */ + /** + * Each setting's value before Paseo first changed it, for an option that is later removed. + * It belongs to the logical session, so it survives the session being reopened after a rewind. + */ private startingSettings = new Map(); + /** Option changes the plugin made before anything listened, delivered to the first subscriber. */ + private optionsChangedBeforeSubscribe: Record | null = null; constructor( readonly provider: string, @@ -1345,6 +1355,11 @@ class PluginAgentSession implements AgentSession { subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.listeners.add(callback); + if (this.optionsChangedBeforeSubscribe) { + const patch = this.optionsChangedBeforeSubscribe; + this.optionsChangedBeforeSubscribe = null; + callback({ type: "options_changed", provider: this.provider, patch }); + } return () => this.listeners.delete(callback); } @@ -1541,7 +1556,13 @@ class PluginAgentSession implements AgentSession { // A setting that is not what the agent holds is an option change, so the stored option // follows what the plugin reports. const patch = this.changedSettings(event.config.settings); - if (patch) this.emit({ type: "options_changed", provider: this.provider, patch }); + if (!patch) return; + // The plugin can settle on other values while the session opens, before the manager listens. + if (this.listeners.size === 0) { + this.optionsChangedBeforeSubscribe = { ...this.optionsChangedBeforeSubscribe, ...patch }; + return; + } + this.emit({ type: "options_changed", provider: this.provider, patch }); } /** @@ -1826,7 +1847,6 @@ class PluginAgentSession implements AgentSession { const bridge = await this.reopen(replay.persistence, replay.config); this.bridge = bridge; this.subagentIdsBySession.set(bridge.id, null); - this.startingSettings.clear(); for (const event of bridge.history) this.accept(event, false); this.unsubscribe = bridge.onEvent((event) => this.accept(event, true)); this.onBind(bridge);