diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 5bdb08da04..e00720f9d5 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -58,6 +58,11 @@ interface ProviderHarnessOptions { handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise; } +/** Long enough for a request that is free to run to have run. */ +function pause(): Promise { + return new Promise((resolve) => setTimeout(resolve, 20)); +} + function lastConfigure(inputs: ProviderInput[]): ProviderInput | undefined { return inputs.findLast((input) => input.type === "session.configure"); } @@ -1008,7 +1013,10 @@ describe("PluginAgentClientRegistry", () => { // A plugin that keeps `voice` on "direct" whatever it is asked, and takes `loud` as given. async function openStubbornVoiceSession( given: Record, - { refuseAfterChanging = false }: { refuseAfterChanging?: boolean } = {}, + { + refuseAfterChanging = false, + holdLoudChange, + }: { refuseAfterChanging?: boolean; holdLoudChange?: Promise } = {}, ) { const state = { voice: "direct", loud: false }; let openedSessionId = ""; @@ -1059,6 +1067,16 @@ describe("PluginAgentClientRegistry", () => { const loud = input.changes.settings?.loud; if (typeof loud === "boolean") state.loud = loud; emitConfig(emit, input.sessionId); + // A change that has taken effect but whose request is still pending. + if (typeof loud === "boolean" && holdLoudChange) { + await holdLoudChange; + emit({ + type: "request.failed", + requestId: input.requestId, + error: { message: "no" }, + }); + return true; + } emit( refuseAfterChanging ? { type: "request.failed", requestId: input.requestId, error: { message: "no" } } @@ -1107,6 +1125,32 @@ describe("PluginAgentClientRegistry", () => { await session.close(); }); + test("overlapping applies resolve one at a time: a request that fails after its change took still reports it", async () => { + let release: () => void = () => undefined; + const holdLoudChange = new Promise((resolve) => { + release = resolve; + }); + const { harness, session, events } = await openStubbornVoiceSession({}, { holdLoudChange }); + + const first = session.applyOptions?.({ loud: true }); + const firstFailed = expect(first).rejects.toThrow("no"); + await pause(); + expect(lastConfigure(harness.inputs)).toMatchObject({ + changes: { settings: { loud: true } }, + }); + const second = session.applyOptions?.({ voice: "other" }); + // The second request does not start until the first has ended, so it cannot settle first. + await pause(); + release(); + + await firstFailed; + await expect(second).resolves.toEqual({ voice: "direct" }); + expect(eventsOfType(events, "options_changed")).toEqual([ + { type: "options_changed", provider: "plugin-direct", patch: { loud: true } }, + ]); + await session.close(); + }); + test("a setting the plugin opened differently is the session's open patch, not an event", async () => { const { session, events } = await openStubbornVoiceSession({ voice: "other", loud: false }); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index 0c22b26b9a..860f0eb656 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1209,6 +1209,8 @@ class PluginAgentSession implements AgentSession { private knownSettings: Record = {}; /** While `applyOptions` waits on the plugin, its `session.config` answers that call. */ private applyingOptions = false; + /** One apply at a time: each reconciles against the settings the one before it left. */ + private applying: Promise = Promise.resolve(); private currentTurnId: string | null = null; private closed = false; private replay: { @@ -1435,7 +1437,13 @@ class PluginAgentSession implements AgentSession { await this.bridge.configure({ thinkingOption: thinkingOptionId }); } - async applyOptions(options: ProviderOptions): Promise> { + applyOptions(options: ProviderOptions): Promise> { + const run = this.applying.then(() => this.applyOptionsNow(options)); + this.applying = run.catch(() => undefined); + return run; + } + + private async applyOptionsNow(options: ProviderOptions): Promise> { this.assertReplayComplete(); const before = this.currentSettings(); // A key the plugin does not report as a setting is one it only reads when the session opens; diff --git a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts index 8bceae77c9..c96ce93676 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.full-access.test.ts @@ -152,6 +152,48 @@ describe("OpenCode auto_accept feature", () => { await session.close(); }); + test("reports the Auto Accept that legacy full-access turned on, when it opens or resumes", async () => { + const { openCodeClient, runtime } = mockOpenCodeClient(); + runtime.enqueueClient(openCodeClient); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + + const created = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + modeId: "full-access", + }); + expect(created.openOptionsPatch).toEqual({ auto_accept: true }); + await created.close(); + + const resumed = await client.resumeSession( + { provider: "opencode", sessionId: "session-1" }, + { cwd: "/tmp/project", modeId: "full-access" }, + ); + expect(resumed.openOptionsPatch).toEqual({ auto_accept: true }); + await resumed.close(); + }); + + test("reports nothing when Auto Accept was already on", async () => { + const { runtime } = mockOpenCodeClient(); + const client = new OpenCodeAgentClient(createTestLogger(), undefined, { + serverManager: runtime, + createClient: runtime.createClient, + }); + + const session = await client.createSession({ + provider: "opencode", + cwd: "/tmp/project", + modeId: "full-access", + providerOptions: { auto_accept: true }, + }); + + expect(session.openOptionsPatch).toEqual({}); + await session.close(); + }); + test("resolves legacy full-access for provider-driven child creation", () => { const client = new OpenCodeAgentClient(createTestLogger()); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index 82232cc815..bacf6487be 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -237,6 +237,16 @@ function isOpenCodeAutoAcceptEnabled(config: AgentSessionConfig): boolean { return config.providerOptions?.[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true; } +/** What opening changed in the options the caller gave: legacy `full-access` turns Auto Accept on. */ +function openCodeOpenOptionsPatch( + given: AgentSessionConfig, + opened: OpenCodeAgentConfig, +): Record { + return isOpenCodeAutoAcceptEnabled(opened) && !isOpenCodeAutoAcceptEnabled(given) + ? { [OPENCODE_AUTO_ACCEPT_FEATURE_ID]: true } + : {}; +} + function withOpenCodeAutoAccept( providerOptions: ProviderOptions | undefined, enabled: boolean, @@ -1494,6 +1504,7 @@ export class OpenCodeAgentClient implements AgentClient { false, unbindBridge, connectServer, + openCodeOpenOptionsPatch(config, openCodeConfig), ); } catch (error) { await connection.release(); @@ -1549,6 +1560,7 @@ export class OpenCodeAgentClient implements AgentClient { registeredAcquisition !== null, unbindBridge, connectServer, + openCodeOpenOptionsPatch(config, openCodeConfig), ); } catch (error) { await connection.release(); @@ -3363,6 +3375,7 @@ interface OpenCodeServerConnection { class OpenCodeAgentSession implements AgentSession { readonly provider = "opencode" as const; readonly capabilities = OPENCODE_CAPABILITIES; + readonly openOptionsPatch: Record; private readonly config: OpenCodeAgentConfig; private server: OpenCodeServerConnection; @@ -3450,8 +3463,10 @@ class OpenCodeAgentSession implements AgentSession { private readonly externallyDriven = false, releaseBridge?: () => void, connectServer?: () => Promise, + openOptionsPatch: Record = {}, ) { this.config = config; + this.openOptionsPatch = openOptionsPatch; this.server = { client, events,