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);