From 427c2d074304ba403d5b32d47e203e61105415c5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 17:01:54 +0100 Subject: [PATCH] fix(server): ACP reports a select that appears after startup with its native value A model switch can introduce a select (Cursor Fast) that the session did not have at open. Its value is now stored like any other native selection instead of being skipped because the previous snapshot did not name it. --- .../server/agent/providers/acp-agent.test.ts | 18 ++++++++++++++++++ .../src/server/agent/providers/acp-agent.ts | 12 +++--------- .../providers/test-utils/fake-acp-agent.mjs | 7 +++++-- 3 files changed, 26 insertions(+), 11 deletions(-) diff --git a/packages/server/src/server/agent/providers/acp-agent.test.ts b/packages/server/src/server/agent/providers/acp-agent.test.ts index c8a24085f9..ae7ede3579 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1956,6 +1956,24 @@ describe("ACPAgentSession options against an ACP agent", () => { ]), ); }); + + test("a select that appears after startup is reported with its native value", async () => { + const { session, optionEvents } = await openSession({ + scenario: { + options: [thoughtOption("low")], + adds: { thought: [copilotAgentConfigOption("Probe Agent")] }, + }, + }); + expect(session.openOptionsPatch).toEqual({}); + + await session.setThinkingOption("high"); + + await vi.waitFor(() => + expect(optionEvents).toEqual([ + { type: "options_changed", provider: "copilot", patch: { agent: "Probe Agent" } }, + ]), + ); + }); }); describe("deriveModelDefinitionsFromACP", () => { diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index f48ae85361..1055a56a2c 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -2467,12 +2467,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { return values; } - /** The selects whose value is not what `expected` says; selects it does not name are skipped. */ + /** The selects whose value is not what `expected` says, including ones it does not name. */ private featureDifferences(expected: Record): Record { return Object.fromEntries( - Object.entries(this.featureValues()).filter( - ([id, value]) => id in expected && expected[id] !== value, - ), + Object.entries(this.featureValues()).filter(([id, value]) => expected[id] !== value), ); } @@ -3130,11 +3128,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { } // The options are the state, so every select the session opened with is reported, including // ones nobody set (a native default is stored like any other value). - this.openOptionsPatch = Object.fromEntries( - Object.entries(this.featureValues()).filter( - ([id, value]) => configuredFeatureValues[id] !== value, - ), - ); + this.openOptionsPatch = this.featureDifferences(configuredFeatureValues); } /** diff --git a/packages/server/src/server/agent/providers/test-utils/fake-acp-agent.mjs b/packages/server/src/server/agent/providers/test-utils/fake-acp-agent.mjs index 1ea77896f2..e284a326da 100644 --- a/packages/server/src/server/agent/providers/test-utils/fake-acp-agent.mjs +++ b/packages/server/src/server/agent/providers/test-utils/fake-acp-agent.mjs @@ -5,6 +5,7 @@ // "notify-then-reject" does the same and then fails the write // moves: { [configId]: { [id]: value } } writes that also move another select, announced // by a config_option_update after the write is answered +// adds: { [configId]: option[] } writes that make more selects appear, announced the same way import { Readable, Writable } from "node:stream"; import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk"; @@ -39,8 +40,10 @@ export const connection = new AgentSideConnection( return { configOptions: options }; } const moves = scenario.moves?.[configId]; - if (moves) { - for (const [id, moved] of Object.entries(moves)) set(id, moved); + const adds = scenario.adds?.[configId]; + if (moves || adds) { + for (const [id, moved] of Object.entries(moves ?? {})) set(id, moved); + options = [...options, ...(adds ?? [])]; setTimeout(() => void announce(), 20); } return { configOptions: options };