diff --git a/docs/providers.md b/docs/providers.md index 60fb75a785..ae57827b51 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -30,6 +30,25 @@ at session startup. See [provider configuration](custom-providers.md#provider-op for defaults and merge rules, and the [SDK guide](../public-docs/sdk/provider-options.md) for native keys and examples. +### Options flow + +`options` on the agent snapshot is the only option state. Controls describe how to draw an +option and read `options`; they hold no value of their own. Any option can be read and set with +or without a control. + +- **Create and resume.** The provider opens with the options it is given and reports nothing + about them while opening. If the session ended up different, it says so once afterwards, as an + `options_changed` event. Events emitted before the daemon subscribes are dropped, so emit that + report from `subscribe`. +- **Live apply.** `applyOptions` receives only the keys the patch set. The daemon stores the patch + after the call resolves, onto the agent's options as they are at that moment, so the last write + wins. Throwing rejects the whole patch. A result that differs from the request is reported + afterwards, not from inside the call. +- **Provider-initiated changes.** A setting the provider changes on its own is an + `options_changed`. The daemon stores and forwards it and does not push the old value back. +- **Removal.** Removing an option drops the key and sends the provider nothing. The session + keeps its current setting until the next open. + Exact MCP preapproval is a separate daemon-owned contract. A new provider must fail closed for Hub unattended execution until it can approve one exact injected MCP server and tool identity without approving native tools. @@ -115,7 +134,7 @@ Submitted user-message wire items carry the same Paseo ID in `messageId` and `cl Provider adapters must terminalize every transient timeline row before emitting the turn's terminal event. Codex may omit the completed `contextCompaction` item when a turn ends during compaction, so its adapter closes any pending root compaction before forwarding `turn_completed`, `turn_failed`, or `turn_canceled`. A terminal turn must never leave the client showing an operation as still loading. -Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listFeatures` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session feature listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listFeatures` implementation may return features from an incomplete, model-less draft and owns which features are valid in that state. +Draft metadata lookups should avoid creating provider sessions when the upstream provider has top-level APIs for that metadata. Prefer `AgentClient.fetchCatalog`, `listCommands`, or `listControls` over creating a scratch `AgentSession`; scratch sessions can show up as empty native sessions in provider import/history UIs. `fetchCatalog` is the single discovery API for models and modes — provider implementations may use one process, separate upstream calls, or static data internally, but callers outside the provider do not get separate runtime model/mode probes. Draft command listing and scratch-session control listing require an explicit draft model. Do not resolve a default model through catalog discovery. A client-level `listControls` implementation may return controls from an incomplete, model-less draft and owns which features are valid in that state. Provider session import has its own contract. The picker calls `listImportableSessions` and receives rows only: provider handle, cwd, title, prompt previews, and last activity. Import calls `importSession({ providerHandleId, cwd })` for the selected row and must not call listing again. The provider returns the resumed session, storage config, persistence handle, and hydrated timeline for that one native session; `AgentManager.importProviderSession` seeds the daemon timeline and publishes the Paseo agent only after it is ready. @@ -478,7 +497,7 @@ interface AgentSession { readonly provider: AgentProvider; readonly id: string | null; readonly capabilities: AgentCapabilityFlags; - readonly features?: AgentFeature[]; + readonly controls?: AgentOptionControl[]; run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise; startTurn(prompt: AgentPromptInput, options?: AgentRunOptions): Promise<{ turnId: string }>; steerActiveTurn?(prompt: AgentPromptInput, options: SteerActiveTurnOptions): Promise; @@ -500,7 +519,7 @@ interface AgentSession { listCommands?(): Promise; setModel?(modelId: string | null): Promise; setThinkingOption?(thinkingOptionId: string | null): Promise; - setFeature?(featureId: string, value: unknown): Promise; + applyOptions?(options: ProviderOptions): Promise; tryHandleOutOfBand?(prompt: AgentPromptInput): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise; } | null; diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 641c2bf20b..6ece6958f9 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -158,11 +158,6 @@ export interface AgentOptionChoice { value: string; label: string; description?: string; - /** - * Other stored option values that select this choice. A saved value the provider has since - * renamed still reads back as this choice. - */ - aliases?: string[]; } interface AgentOptionControlBase { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index fe082d5a68..30519993f2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -365,7 +365,6 @@ export const AgentOptionChoiceSchema = z.object({ value: z.string(), label: z.string(), description: z.string().optional(), - aliases: z.array(z.string()).optional(), }); export const AgentOptionToggleControlSchema = z.object({ diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index c6442ca1d3..3e79764aff 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -12714,9 +12714,11 @@ describe("agent options", () => { class OptionsSession extends TestAgentSession { readonly applied: ProviderOptions[] = []; rejectWith: Error | null = null; + applyGate: Promise | null = null; async applyOptions(options: ProviderOptions): Promise { if (this.rejectWith) throw this.rejectWith; + await this.applyGate; this.applied.push(options); } } @@ -12757,12 +12759,12 @@ describe("agent options", () => { expect(toAgentPayload(agent!).options).toEqual({ service_tier: "default", fast_mode: true }); }); - test("setAgentOptions on a running agent replaces top-level keys and applies the effective options", async () => { + test("setAgentOptions hands the provider only the keys the patch sets", async () => { const { manager, client, storage, agentId } = await createOptionsAgent({ fast_mode: true }); await manager.setAgentOptions(agentId, { service_tier: "fast" }); - expect(client.session?.applied).toEqual([{ service_tier: "fast", fast_mode: true }]); + expect(client.session?.applied).toEqual([{ service_tier: "fast" }]); const agent = manager.getAgent(agentId); expect(agent?.config.providerOptions).toEqual({ fast_mode: true, service_tier: "fast" }); expect(agent?.effectiveOptions).toEqual({ service_tier: "fast", fast_mode: true }); @@ -12773,66 +12775,23 @@ describe("agent options", () => { }); }); - test("setAgentOptions removes a key with null so the config.json default applies again", async () => { + test("setAgentOptions removes a key with null and sends the provider nothing", async () => { const { manager, client, agentId } = await createOptionsAgent({ service_tier: "fast" }); await manager.setAgentOptions(agentId, { service_tier: null }); - expect(client.session?.applied).toEqual([{ service_tier: "default" }]); + expect(client.session?.applied).toEqual([]); const agent = manager.getAgent(agentId); expect(agent?.config.providerOptions).toBeUndefined(); expect(agent?.effectiveOptions).toEqual({ service_tier: "default" }); }); - test("the snapshot reports the configured options and resolves aliases through controls", async () => { - class AliasSession extends OptionsSession { - get controls(): AgentOptionControl[] { - return [ - { - type: "select", - key: "service_tier", - label: "Speed", - defaultValue: "default", - choices: [ - { value: "default", label: "Normal" }, - { value: "priority", label: "Fast", aliases: ["fast"] }, - ], - }, - ]; - } - } - class AliasClient extends OptionsClient { - override async createSession(config: AgentSessionConfig): Promise { - return new AliasSession(config); - } - } - const workdir = mkdtempSync(join(tmpdir(), "agent-manager-options-")); - const manager = new AgentManager({ - clients: { codex: new AliasClient() }, - registry: new AgentStorage(join(workdir, "agents"), logger), - logger, - }); - const created = await manager.createAgent( - { provider: "codex", cwd: workdir, providerOptions: { service_tier: "fast" } }, - undefined, - { workspaceId: undefined }, - ); - - const payload = toAgentPayload(manager.getAgent(created.id)!); - expect(payload.options).toEqual({ service_tier: "fast" }); - expect(payload.features).toEqual([ - expect.objectContaining({ id: "service_tier", value: "priority" }), - ]); - }); - test("an option without a control is settable through the patch and readable in the snapshot", async () => { const { manager, client, storage, agentId } = await createOptionsAgent(); await manager.setAgentOptions(agentId, { sandbox_mode: "read-only" }); - expect(client.session?.applied).toEqual([ - { service_tier: "default", sandbox_mode: "read-only" }, - ]); + expect(client.session?.applied).toEqual([{ sandbox_mode: "read-only" }]); const agent = manager.getAgent(agentId); expect(agent?.config.providerOptions).toEqual({ sandbox_mode: "read-only" }); expect(toAgentPayload(agent!).options).toEqual({ @@ -12845,6 +12804,41 @@ describe("agent options", () => { }); }); + test("concurrent patches both land: each is applied to the options as they are when its provider call resolves", async () => { + const { manager, client, agentId } = await createOptionsAgent(); + let release!: () => void; + client.session!.applyGate = new Promise((resolve) => { + release = resolve; + }); + + const slow = manager.setAgentOptions(agentId, { sandbox_mode: "read-only" }); + client.session!.applyGate = null; + await manager.setAgentOptions(agentId, { fast_mode: true }); + release(); + await slow; + + expect(manager.getAgent(agentId)?.config.providerOptions).toEqual({ + sandbox_mode: "read-only", + fast_mode: true, + }); + }); + + test("a stored agent that is not loaded still reports its effective options", async () => { + const { manager, agentId } = await createOptionsAgent({ fast_mode: true }); + await manager.closeAgent(agentId); + const states: ManagedAgent[] = []; + manager.subscribe( + (event) => { + if (event.type === "agent_state" && event.agent.id === agentId) states.push(event.agent); + }, + { replayState: false }, + ); + + await manager.markAgentUnread(agentId); + + expect(states.at(-1)?.effectiveOptions).toEqual({ service_tier: "default", fast_mode: true }); + }); + test("a provider that rejects an option leaves the stored options unchanged", async () => { const { manager, client, agentId } = await createOptionsAgent({ fast_mode: false }); client.session!.rejectWith = new Error("fast mode unavailable"); @@ -12951,4 +12945,52 @@ describe("agent options", () => { plan_mode: false, }); }); + + describe.each([ + { + name: "deletes a key", + edit: (values: Record | undefined) => { + const { fast_mode: _removed, ...rest } = values ?? {}; + return { featureValues: rest }; + }, + expected: { plan_mode: true }, + }, + { + name: "empties featureValues", + edit: () => ({ featureValues: {} }), + expected: {}, + }, + { + name: "drops featureValues", + edit: () => ({ featureValues: undefined }), + expected: {}, + }, + ])("agent.create.before hook that $name", ({ edit, expected }) => { + test("removes those options from the new agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-hook-")); + const manager = new AgentManager({ + clients: { codex: new OptionsClient() }, + registry: new AgentStorage(join(workdir, "agents"), logger), + logger, + pluginLifecycle: { + emit: () => {}, + before: async (name, request) => + name === "agent.create.before" + ? { + ...request, + config: { ...request.config, ...edit(request.config.featureValues) }, + } + : request, + }, + }); + + const created = await manager.createAgent( + { provider: "codex", cwd: workdir, providerOptions: { fast_mode: true, plan_mode: true } }, + undefined, + { workspaceId: undefined }, + ); + + expect(manager.getAgent(created.id)?.config.providerOptions).toEqual(expected); + }); + }); }); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 48e8be0b65..c4b654f6ac 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -2174,6 +2174,10 @@ export class AgentManager { updatedAt, availableModes: [], controls: record.controls, + effectiveOptions: this.effectiveOptionsFor( + record.provider, + record.config?.providerOptions ?? undefined, + ), currentModeId: record.lastModeId ?? null, pendingPermissions: new Map(), bufferedPermissionResolutions: new Map(), @@ -2267,23 +2271,35 @@ export class AgentManager { } /** - * Patches the agent's own options and hands the provider the resulting effective options. The - * provider applies what it can live and reads the rest at next open; a provider that rejects a - * value leaves the stored options unchanged. + * Applies an options patch to a running agent (see `AgentSession.applyOptions`). The provider + * gets only the keys the patch sets, each resolved against the `config.json` defaults; a + * removal sends it nothing and it keeps its current setting until the session is next opened. + * The patch is stored once the provider accepts it, over the options as they are at that + * moment, so concurrent writes to one agent resolve by arrival order. */ async setAgentOptions(agentId: string, patch: ProviderOptionsPatch): Promise { const agent = this.requireAgent(agentId); - const ownOptions = applyProviderOptionsPatch(agent.config.providerOptions, patch); - - if (agent.session.applyOptions) { - await agent.session.applyOptions(this.effectiveOptionsFor(agent.provider, ownOptions)); - await this.drainSessionEvents(agentId); + const set = Object.fromEntries( + Object.entries(patch).filter(([, value]) => value !== null && value !== undefined), + ); + if (agent.session.applyOptions && Object.keys(set).length > 0) { + const resolved = this.effectiveOptionsFor(agent.provider, set); + await agent.session.applyOptions( + Object.fromEntries(Object.keys(set).map((key) => [key, resolved[key]])), + ); } - agent.config.providerOptions = Object.keys(ownOptions).length > 0 ? ownOptions : undefined; + this.storeOptionsPatch(agent, patch); + // Differences the provider reports after applying arrive now and are stored as reported. + await this.drainSessionEvents(agentId); this.touchUpdatedAt(agent); this.emitState(agent); } + private storeOptionsPatch(agent: ManagedAgent, patch: ProviderOptionsPatch): void { + const own = applyProviderOptionsPatch(agent.config.providerOptions, patch); + agent.config.providerOptions = Object.keys(own).length > 0 ? own : undefined; + } + private effectiveOptionsFor( provider: AgentProvider, own: ProviderOptions | undefined, @@ -4686,8 +4702,7 @@ export class AgentManager { flags: StreamEventFlags; }): void { const { agent, patch, flags } = params; - const ownOptions = applyProviderOptionsPatch(agent.config.providerOptions, patch); - agent.config.providerOptions = Object.keys(ownOptions).length > 0 ? ownOptions : undefined; + this.storeOptionsPatch(agent, patch); flags.shouldDispatchEvent = false; this.emitState(agent); } diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 6067ed997c..234016e63b 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -429,7 +429,13 @@ export type AgentStreamEvent = provider: AgentProvider; thinkingOptionId: string | null; } - /** The provider changed options on its own; `null` removes the agent's own value. */ + /** + * The provider changed options on its own: after `createSession`/`resumeSession` or + * `applyOptions` it ended up different from what it was given, or later it changed a setting + * itself. The daemon stores the patch and forwards it; it does not undo it. `null` removes the + * agent's own value. Emit it once, after the operation resolves and the daemon is subscribed + * (sessions typically emit the open report from `subscribe`), never while the operation runs. + */ | { type: "options_changed"; provider: AgentProvider; patch: Record } | { type: "turn_failed"; @@ -717,9 +723,12 @@ export interface AgentSession { setModel?(modelId: string | null): Promise; setThinkingOption?(thinkingOptionId: string | null): Promise; /** - * The agent's effective options changed (defaults merged with the agent's own, after the - * patch). Apply what can change live and read the rest at next open. Throw to reject a value; - * the daemon stores the patch only when this resolves. + * Apply the options a patch just set. `options` holds only those keys, resolved against the + * `config.json` defaults, never the agent's whole record. Leave every other setting alone. + * Removing an option sends nothing: the session keeps its current setting until the next open. + * Throw to reject a value; the daemon stores the patch only when this resolves, so the call is + * all-or-nothing. Emit nothing from here. If the result differs from what was asked for, + * report it afterwards as `options_changed`. */ applyOptions?(options: ProviderOptions): Promise; revertConversation?(input: { messageId: string }): Promise; @@ -771,11 +780,17 @@ export interface ResolveAgentDefaultModeInput { export interface AgentClient { readonly provider: AgentProvider; readonly capabilities: AgentCapabilityFlags; + /** + * Create the agent with `config.providerOptions` (defaults already merged in). Report nothing + * about options while creating. If the agent ended up different from the options, say so once, + * afterwards, as `options_changed`. + */ createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, options?: AgentCreateSessionOptions, ): Promise; + /** Same contract as `createSession`: open with the options, report any difference once, afterwards. */ resumeSession( handle: AgentPersistenceHandle, overrides?: Partial, diff --git a/packages/server/src/server/agent/agent-storage.test.ts b/packages/server/src/server/agent/agent-storage.test.ts index 9c8296d0f6..b2d152559f 100644 --- a/packages/server/src/server/agent/agent-storage.test.ts +++ b/packages/server/src/server/agent/agent-storage.test.ts @@ -252,12 +252,12 @@ describe("AgentStorage", () => { expect(persisted?.config).not.toHaveProperty("featureValues"); expect(persisted).not.toHaveProperty("features"); expect(persisted?.controls).toEqual([ - { type: "toggle", key: "fast_mode", label: "Fast mode" }, + { type: "toggle", key: "fast_mode", label: "Fast mode", defaultValue: true }, { type: "select", key: "service_tier", label: "Speed", - defaultValue: "default", + defaultValue: "fast", choices: [ { value: "default", label: "Normal" }, { value: "fast", label: "Fast" }, diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index c69065c1f5..00dd9d909c 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -54,8 +54,9 @@ export function foldFeatureValues< /** * COMPAT(featureValues): added in v0.15.0, remove after 2027-05-01. * A plugin written against the previous SDK reads and edits `config.featureValues`, so a - * hook sees the config's options there. Keys the hook changed come back as options and - * override what the config already held; keys it left alone do not. + * hook sees a copy of the config's options there. What the hook did to that copy (set, + * replaced or deleted a key, or dropped the field) is applied as a patch over the options it + * returned; keys it left alone change nothing. */ export function exposeFeatureValuesToHook< T extends { providerOptions?: ProviderOptions | null; featureValues?: Record }, @@ -68,13 +69,21 @@ export function foldHookFeatureValues< T extends { providerOptions?: ProviderOptions | null; featureValues?: Record }, >(returned: T, sent: T): Omit { const { featureValues, ...rest } = returned; - if (!featureValues) return rest; - const before = sent.featureValues ?? {}; - const edits: Record = {}; - for (const [key, value] of Object.entries(featureValues)) { - if (!isDeepStrictEqual(value, before[key])) edits[key] = value; + const before = sent.featureValues; + if (!before) { + // No copy was sent, so everything the hook put in `featureValues` is new. + return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions } }; } - return { ...rest, providerOptions: { ...featureValues, ...rest.providerOptions, ...edits } }; + const after = featureValues ?? {}; + const patch: ProviderOptionsPatch = {}; + for (const key of new Set([...Object.keys(before), ...Object.keys(after)])) { + if (isDeepStrictEqual(after[key], before[key])) continue; + patch[key] = key in after ? after[key] : null; + } + return { + ...rest, + providerOptions: applyProviderOptionsPatch(rest.providerOptions ?? undefined, patch), + }; } interface ProfileOptionFields { @@ -116,9 +125,7 @@ export function withLegacyProfileFeatureValues(pr function selectedChoice(control: AgentOptionSelectControl, options: ProviderOptions) { const value = options[control.key]; if (typeof value === "string") { - const choice = control.choices.find( - (candidate) => candidate.value === value || candidate.aliases?.includes(value), - ); + const choice = control.choices.find((candidate) => candidate.value === value); if (choice) return choice.value; } return control.defaultValue ?? null; @@ -175,6 +182,10 @@ export function featuresFromControls( return (controls ?? []).map((control) => featureFromControl(control, effective)); } +/** + * A recorded feature's value is the control's unset value: it is what the agent showed before + * any option was stored, and an explicit effective option still wins over it. + */ function controlFromFeature(feature: AgentFeature): AgentOptionControl { const descriptor = { key: feature.id, @@ -182,14 +193,15 @@ function controlFromFeature(feature: AgentFeature): AgentOptionControl { ...describe(feature), }; if (feature.type === "toggle") { - return { type: "toggle", ...descriptor }; + return { type: "toggle", ...descriptor, defaultValue: feature.value }; } const defaultChoice = feature.options.find((option) => option.isDefault); + const unsetValue = feature.value ?? defaultChoice?.id; return { type: "select", ...descriptor, ...(feature.desktopTrigger ? { desktopTrigger: feature.desktopTrigger } : {}), - ...(defaultChoice ? { defaultValue: defaultChoice.id } : {}), + ...(unsetValue != null ? { defaultValue: unsetValue } : {}), choices: feature.options.map((option) => ({ value: option.id, label: option.label, diff --git a/packages/server/src/server/agent/plugin-provider.test.ts b/packages/server/src/server/agent/plugin-provider.test.ts index 1d474b513a..71f8a87b7e 100644 --- a/packages/server/src/server/agent/plugin-provider.test.ts +++ b/packages/server/src/server/agent/plugin-provider.test.ts @@ -999,6 +999,78 @@ describe("PluginAgentClientRegistry", () => { await agent.session?.close(); }); + test("reports a setting the plugin opened differently once, and applies only the options it is given", async () => { + const settings = (voice: string) => [ + { + type: "select" as const, + id: "voice", + label: "Voice", + value: voice, + options: [ + { label: "Direct", value: "direct" }, + { label: "Other", value: "other" }, + ], + }, + { type: "toggle" as const, id: "loud", label: "Loud", value: false }, + ]; + const state = { voice: "direct" }; + const harness = createProviderHarness({ + async handleInput(input, emit) { + if (input.type === "session.open") { + emit({ + type: "session.opened", + requestId: input.requestId, + sessionId: input.sessionId, + capabilities: CAPABILITIES, + restoration: "core", + persistence: { version: 1, data: { token: "root" } }, + cwd: input.config.cwd, + }); + emit({ + type: "session.config", + sessionId: input.sessionId, + config: { + model: "plugin-model", + mode: "build", + models: [{ id: "plugin-model", label: "Plugin model" }], + modes: [{ id: "build", label: "Build" }], + thinkingOptions: [], + settings: settings(state.voice), + }, + }); + emit({ type: "session.ready", requestId: input.requestId, sessionId: input.sessionId }); + return true; + } + return false; + }, + }); + const logger = createTestLogger(); + const plugins = new PluginAgentClientRegistry(logger); + plugins.replace([harness.registration]); + const client = plugins.clients()["plugin-direct"]!; + const session = await client.createSession({ + provider: "plugin-direct", + cwd: tmpdir(), + providerOptions: { voice: "other", loud: false }, + }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + session.subscribe((event) => events.push(event)); + + // The plugin opened on "direct" although "other" was asked for: said once, on subscribe. + expect(eventsOfType(events, "options_changed")).toEqual([ + { type: "options_changed", provider: "plugin-direct", patch: { voice: "direct" } }, + ]); + + await session.applyOptions?.({ loud: true }); + const configure = harness.inputs.findLast((input) => input.type === "session.configure"); + expect(configure).toMatchObject({ changes: { settings: { loud: true } } }); + expect(Object.keys((configure as { changes: { settings: object } }).changes.settings)).toEqual([ + "loud", + ]); + await session.close(); + }); + test.each([ { options: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, { params: { nested: { base: true, replace: "config" }, list: [1, 2], scalar: "config" } }, @@ -1516,7 +1588,6 @@ describe("PluginAgentClientRegistry", () => { type: "select", key: "voice", label: "Voice", - defaultValue: "direct", choices: [{ value: "direct", label: "Direct" }], }, ]); diff --git a/packages/server/src/server/agent/plugin-provider.ts b/packages/server/src/server/agent/plugin-provider.ts index a4db0f2ed6..b4069ebda6 100644 --- a/packages/server/src/server/agent/plugin-provider.ts +++ b/packages/server/src/server/agent/plugin-provider.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; import { PluginSessionHistory } from "./plugin-session-history.js"; import { resolve as resolvePath } from "node:path"; import type { Logger } from "pino"; @@ -1133,6 +1134,7 @@ class PluginAgentClient implements AgentClient { }); }, bind, + config.settings, ); bind(bridge); return session; @@ -1201,6 +1203,9 @@ class PluginAgentSession implements AgentSession { >(); private readonly childSnapshots = new Map>(); private unsubscribe: (() => void) | null = null; + /** How the opened session differs from the settings it was given; reported at first subscribe. */ + private openOptionReport: Record = {}; + private knownSettings: Record = {}; private currentTurnId: string | null = null; private closed = false; private replay: { @@ -1221,8 +1226,13 @@ class PluginAgentSession implements AgentSession { state: ProviderConfigState, ) => Promise, private readonly onBind: (bridge: ProviderRuntimeSession) => void, + givenSettings: Readonly>, ) { this.subagentIdsBySession.set(bridge.id, null); + this.openOptionReport = Object.fromEntries( + Object.entries(this.settingDifferences(givenSettings)).filter(([id]) => id in givenSettings), + ); + this.knownSettings = this.currentSettings(); for (const event of bridge.history) this.accept(event, false); // Opening notices arrive before the manager subscribes to the session. // Commit them on registration and replay the same timestamps from history. @@ -1256,14 +1266,12 @@ class PluginAgentSession implements AgentSession { key: setting.id, label: setting.label, ...(setting.description ? { description: setting.description } : {}), - defaultValue: setting.value, } : { type: "select", key: setting.id, label: setting.label, ...(setting.description ? { description: setting.description } : {}), - ...(setting.value !== null ? { defaultValue: setting.value } : {}), choices: setting.options.map((option) => ({ value: option.value, label: option.label, @@ -1329,6 +1337,10 @@ class PluginAgentSession implements AgentSession { subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.listeners.add(callback); + if (Object.keys(this.openOptionReport).length > 0) { + callback({ type: "options_changed", provider: this.provider, patch: this.openOptionReport }); + this.openOptionReport = {}; + } return () => this.listeners.delete(callback); } @@ -1430,13 +1442,12 @@ class PluginAgentSession implements AgentSession { const settings: Record = {}; for (const setting of this.bridge.config.settings) { const value = options[setting.id]; - if (value !== undefined && value !== setting.value) { - settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`); - } - } - if (Object.keys(settings).length > 0) { - await this.bridge.configure({ settings }); + if (value === undefined) continue; + settings[setting.id] = toJsonValue(value, `setting '${setting.id}'`); } + if (Object.keys(settings).length === 0) return; + Object.assign(this.knownSettings, settings); + await this.bridge.configure({ settings }); } async revertConversation(input: { messageId: string }): Promise { @@ -1618,10 +1629,35 @@ class PluginAgentSession implements AgentSession { }; } + private currentSettings(): Record { + return Object.fromEntries( + this.bridge.config.settings.map((setting) => [setting.id, setting.value]), + ); + } + + /** Settings whose value differs from `known`: the plugin's own change or its opening state. */ + private settingDifferences( + known: Readonly>, + ): Record { + const patch: Record = {}; + for (const [id, value] of Object.entries(this.currentSettings())) { + if (!isDeepStrictEqual(known[id], value)) patch[id] = value; + } + return patch; + } + + private reportSettingChanges(): AgentStreamEvent[] { + const patch = this.settingDifferences(this.knownSettings); + if (Object.keys(patch).length === 0) return []; + this.knownSettings = this.currentSettings(); + return [{ type: "options_changed", provider: this.provider, patch }]; + } + private translateConfig( event: Extract, ): AgentStreamEvent[] { return [ + ...this.reportSettingChanges(), { type: "mode_changed", provider: this.provider, diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index cd30b81909..46d1633da2 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -1470,6 +1470,22 @@ describe("ProviderSnapshotManager public surface", () => { }); expect(resolved).toEqual({ modeId: "base", providerOptions: { auto_accept: true } }); + + // A parent whose Auto Accept comes from the provider's config.json default has no own + // option; it is classified on its effective options. + const inheriting = await manager.resolveCreateConfig({ + cwd: "/tmp/project", + provider: "opencode", + requestedMode: "base", + providerOptions: undefined, + parent: { + ...parent, + config: { provider: "opencode", cwd: "/tmp/project" }, + effectiveOptions: { auto_accept: true }, + }, + unattended: false, + }); + expect(inheriting).toEqual({ modeId: "base", providerOptions: { auto_accept: true } }); } finally { manager.destroy(); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 7590806407..7e728713be 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -744,7 +744,12 @@ export class ProviderSnapshotManager { modeId: parent.currentModeId, isUnattended: definition.isCreateConfigUnattended({ modeId: parent.currentModeId, - config: parent.config, + // Provider defaults from `config.json` apply to the parent too, so classify on the + // effective options while the stored overrides stay as they are. + config: { + ...parent.config, + providerOptions: parent.effectiveOptions ?? parent.config.providerOptions, + }, controls: parent.controls, availableModes: parent.availableModes ?? definition.modes ?? [], }), 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 f4804a8ada..1711645445 100644 --- a/packages/server/src/server/agent/providers/acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/acp-agent.test.ts @@ -1771,7 +1771,6 @@ describe("ACPAgentSession Zed parity", () => { description: "Use a Copilot custom agent profile", tooltip: "Select Copilot agent", icon: undefined, - defaultValue: "", choices: [ { value: "", label: "Default", description: undefined }, { value: "Probe Agent", label: "Probe Agent", description: "Temporary probe agent" }, @@ -1799,7 +1798,57 @@ describe("ACPAgentSession Zed parity", () => { }); expect(session.controls).toEqual([ expect.objectContaining({ key: "auto_accept" }), - expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }), + expect.objectContaining({ key: "agent" }), + ]); + }); + + test("reports once, at subscribe, a select the provider opened differently from the options", async () => { + const setSessionConfigOption = vi.fn(async () => ({ + configOptions: [copilotAgentConfigOption("")], + })); + const session = createCopilotSessionWithConfig(null, { agent: "Probe Agent" }); + const { internals } = prepareConfiguredOverrideSession(session, { + configOptions: [copilotAgentConfigOption("")], + connection: { setSessionConfigOption }, + }); + const emitted: AgentStreamEvent[] = []; + session.subscribe((event) => emitted.push(event)); + await internals.applyConfiguredOverrides(); + expect(emitted.filter((event) => event.type === "options_changed")).toEqual([]); + + const first: AgentStreamEvent[] = []; + session.subscribe((event) => first.push(event)); + const second: AgentStreamEvent[] = []; + session.subscribe((event) => second.push(event)); + + expect(first.filter((event) => event.type === "options_changed")).toEqual([ + { type: "options_changed", provider: "copilot", patch: { agent: "" } }, + ]); + expect(second.filter((event) => event.type === "options_changed")).toEqual([]); + }); + + test("forwards a select the provider changes on its own, and applying a patch emits nothing", async () => { + const setSessionConfigOption = vi.fn(async () => ({ + configOptions: [copilotAgentConfigOption("Probe Agent")], + })); + const session = createCopilotSessionWithConfig(); + const { internals } = prepareConfiguredOverrideSession(session, { + configOptions: [copilotAgentConfigOption("")], + connection: { setSessionConfigOption }, + }); + await internals.applyConfiguredOverrides(); + const emitted: AgentStreamEvent[] = []; + session.subscribe((event) => emitted.push(event)); + + await session.applyOptions({ agent: "Probe Agent" }); + expect(emitted.filter((event) => event.type === "options_changed")).toEqual([]); + + asInternals(session).translateSessionUpdate({ + sessionUpdate: "config_option_update", + configOptions: [copilotAgentConfigOption("")], + }); + expect(emitted.filter((event) => event.type === "options_changed")).toEqual([ + { type: "options_changed", provider: "copilot", patch: { agent: "" } }, ]); }); @@ -1822,7 +1871,7 @@ describe("ACPAgentSession Zed parity", () => { }); expect(session.controls).toEqual([ expect.objectContaining({ key: "auto_accept" }), - expect.objectContaining({ key: "agent", defaultValue: "Probe Agent" }), + expect.objectContaining({ key: "agent" }), ]); }); }); @@ -2179,7 +2228,6 @@ describe("ACPAgentClient config features", () => { expect.objectContaining({ type: "select", key: "agent", - defaultValue: "Probe Agent", choices: [ expect.objectContaining({ value: "", label: "Default" }), expect.objectContaining({ value: "Probe Agent", label: "Probe Agent" }), diff --git a/packages/server/src/server/agent/providers/acp-agent.ts b/packages/server/src/server/agent/providers/acp-agent.ts index 8e3cba69e2..0e7de998cb 100644 --- a/packages/server/src/server/agent/providers/acp-agent.ts +++ b/packages/server/src/server/agent/providers/acp-agent.ts @@ -867,8 +867,6 @@ export function deriveControlsFromACP( description: featureOption.description, tooltip: featureOption.tooltip, icon: featureOption.icon, - // The agent reports its own current value; an unset option stands for it. - ...(option.currentValue != null ? { defaultValue: option.currentValue } : {}), choices: deriveConfigFeatureSelectOptions(option, featureOption).map((choice) => ({ value: choice.id, label: choice.label, @@ -1757,7 +1755,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { private currentTitle: string | null = null; private lastActivityAt: string | null = null; private configOptions: SessionConfigOption[] = []; - private readonly configFeatureDefaults = new Map(); + /** The value of each select as Paseo last knew it; a different value is the provider's change. */ + private readonly knownFeatureValues = new Map(); + /** How the opened session differs from the options it was given; reported at first subscribe. */ + private openOptionReport: Record = {}; private cachedCommands: AgentSlashCommand[] = []; /** When the last command batch arrived; null while nothing has been published. */ private commandsUpdatedAt: number | null = null; @@ -2022,6 +2023,10 @@ export class ACPAgentSession implements AgentSession, ACPClient { sessionId: this.sessionId, }); } + if (Object.keys(this.openOptionReport).length > 0) { + callback({ type: "options_changed", provider: this.provider, patch: this.openOptionReport }); + this.openOptionReport = {}; + } return () => { this.subscribers.delete(callback); }; @@ -2209,6 +2214,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { requestedValue: modeId, label: "mode", }); + this.reportOptionChanges(); this.availableModes = deriveModesFromACP(this.defaultModes, null, this.configOptions).modes; this.pushEvent({ type: "mode_changed", @@ -2334,6 +2340,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { requestedValue: modelId, label: "model", }); + this.reportOptionChanges(); this.pushEvent({ type: "model_changed", provider: this.provider, @@ -2385,6 +2392,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { requestedValue: thinkingOptionId, label: "thought-level", }); + this.reportOptionChanges(); this.pushEvent({ type: "thinking_option_changed", provider: this.provider, @@ -2392,39 +2400,50 @@ export class ACPAgentSession implements AgentSession, ACPClient { }); } - /** - * The value a select reported before Paseo wrote to it. An option removed from the agent's - * options returns the select to this value. - */ - private recordConfigFeatureDefaults(): void { + /** Applies only the options the patch sets. Differences are reported after this resolves. */ + async applyOptions(options: ProviderOptions): Promise { + for (const [id, value] of Object.entries(options)) { + const featureOption = this.configFeatureOptions.find((candidate) => candidate.id === id); + if (!featureOption) continue; + const option = findSelectConfigFeatureOption(this.configOptions, featureOption); + if (!option || value !== option.currentValue) { + await this.setConfigFeature(id, value); + } + this.knownFeatureValues.set(id, String(value)); + this.config.providerOptions = { ...this.config.providerOptions, [id]: value }; + } + // Auto Accept is Paseo's own setting, so it changes only once every write has been accepted. + if (ACP_AUTO_ACCEPT_FEATURE_ID in options) { + this.config.providerOptions = { + ...this.config.providerOptions, + [ACP_AUTO_ACCEPT_FEATURE_ID]: options[ACP_AUTO_ACCEPT_FEATURE_ID] === true, + }; + } + setImmediate(() => this.reportOptionChanges()); + } + + /** Reports selects the provider changed on its own since Paseo last knew their values. */ + private reportOptionChanges(): void { + const patch: Record = {}; for (const featureOption of this.configFeatureOptions) { - if (this.configFeatureDefaults.has(featureOption.id)) continue; const current = findSelectConfigFeatureOption( this.configOptions, featureOption, )?.currentValue; - if (current != null) this.configFeatureDefaults.set(featureOption.id, current); - } - } - - async applyOptions(options: ProviderOptions): Promise { - await this.setConfigFeature( - ACP_AUTO_ACCEPT_FEATURE_ID, - options[ACP_AUTO_ACCEPT_FEATURE_ID] === true, - ); - for (const featureOption of this.configFeatureOptions) { - const explicit = Object.prototype.hasOwnProperty.call(options, featureOption.id); - const value = explicit - ? options[featureOption.id] - : this.configFeatureDefaults.get(featureOption.id); - if (value === undefined) continue; - const option = findSelectConfigFeatureOption(this.configOptions, featureOption); - if (option && value === option.currentValue) continue; - if (!explicit && (!option || !findSelectConfigChoice({ option, value: String(value) }))) { - // A default recorded under a previous model may not exist on the current one. - continue; + const known = this.knownFeatureValues.get(featureOption.id); + if (current == null || known === current) continue; + this.knownFeatureValues.set(featureOption.id, current); + if (known === undefined) continue; + patch[featureOption.id] = current; + if (this.config.providerOptions && featureOption.id in this.config.providerOptions) { + this.config.providerOptions = { + ...this.config.providerOptions, + [featureOption.id]: current, + }; } - await this.setConfigFeature(featureOption.id, value); + } + if (Object.keys(patch).length > 0) { + this.pushEvent({ type: "options_changed", provider: this.provider, patch }); } } @@ -2467,14 +2486,13 @@ export class ACPAgentSession implements AgentSession, ACPClient { value: requestedValue, }), ); - const currentValue = this.applyConfigOptionResponse({ + this.applyConfigOptionResponse({ response, configId: option.id, category: featureOption.category, requestedValue, label: featureOption.label, }); - this.config.providerOptions = { ...this.config.providerOptions, [featureId]: currentValue }; } private applyConfigOptionResponse({ @@ -2997,7 +3015,6 @@ export class ACPAgentSession implements AgentSession, ACPClient { : response; this.configOptions = this.transformConfigOptions(transformed.configOptions ?? []); - this.recordConfigFeatureDefaults(); const modeInfo = deriveModesFromACP(this.defaultModes, transformed.modes, this.configOptions); this.availableModes = modeInfo.modes; @@ -3069,6 +3086,26 @@ export class ACPAgentSession implements AgentSession, ACPClient { ); } } + this.recordOpenedFeatureValues(configuredFeatureValues); + } + + /** Remembers what each select ended at, and what differs from the options the session was given. */ + private recordOpenedFeatureValues(given: Record): void { + for (const featureOption of this.configFeatureOptions) { + const current = findSelectConfigFeatureOption( + this.configOptions, + featureOption, + )?.currentValue; + if (current == null) continue; + this.knownFeatureValues.set(featureOption.id, current); + if (featureOption.id in given && given[featureOption.id] !== current) { + this.openOptionReport[featureOption.id] = current; + this.config.providerOptions = { + ...this.config.providerOptions, + [featureOption.id]: current, + }; + } + } } /** @@ -3292,6 +3329,7 @@ export class ACPAgentSession implements AgentSession, ACPClient { this.currentMode = nextMode ?? this.currentMode; this.currentModel = nextModel ?? this.currentModel; this.thinkingOptionId = nextThinkingOptionId ?? this.thinkingOptionId; + this.reportOptionChanges(); const events: AgentStreamEvent[] = []; if (nextMode !== null) { diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 99a8056349..e0d649e6ff 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1505,6 +1505,29 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("a patch that does not set fast_mode leaves Fast on", async () => { + const { queryFactory, queryMock } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-opus-4-8", + }); + await (session as unknown as { ensureQuery(): Promise }).ensureQuery(); + await session.applyOptions?.({ fast_mode: true }); + queryMock.applyFlagSettings.mockClear(); + + // Removing fast_mode, or setting another option, sends only the keys set: Fast stays on. + await session.applyOptions?.({ allowedTools: ["Read"] }); + + expect(queryMock.applyFlagSettings).not.toHaveBeenCalled(); + await session.close(); + }); + test("retries a fast mode change the runtime rejected", async () => { const { queryFactory, queryMock } = createQueryMock(); const client = new ClaudeAgentClient({ diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 9c8f70bd1c..0666fb8dca 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2799,7 +2799,10 @@ class ClaudeAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise { const providerOptions = - validateProviderOptions("claude", ClaudeProviderOptionsSchema, options) ?? {}; + validateProviderOptions("claude", ClaudeProviderOptionsSchema, { + ...this.config.providerOptions, + ...options, + }) ?? {}; if (providerOptions.fast_mode === true && !claudeModelSupportsFastMode(this.config.model)) { throw new Error( `Claude fast mode is not available for model '${this.config.model ?? "default"}'`, diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts index df0c689bac..f8fb4fb92d 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.features.test.ts @@ -1,4 +1,3 @@ -import { featuresFromControls } from "../option-controls.js"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -8,7 +7,12 @@ import { describe, expect, test, vi } from "vitest"; import { AgentManager } from "../agent-manager.js"; import { toAgentPayload } from "../agent-projections.js"; import { AgentStorage } from "../agent-storage.js"; -import type { AgentClient, AgentSession, AgentSessionConfig } from "../agent-sdk-types.js"; +import type { + AgentClient, + AgentSession, + AgentSessionConfig, + AgentStreamEvent, +} from "../agent-sdk-types.js"; import { CodexAppServerAgentSession } from "./codex-app-server-agent.js"; import { createFakeCodexAppServer, @@ -44,7 +48,7 @@ const TEST_SPEED_MODELS = [ serviceTiers: [{ id: "priority", name: "Fast", description: "Faster processing" }], })); -function speedControl(defaultValue = "default") { +function speedControl() { return { type: "select", key: "service_tier", @@ -53,10 +57,10 @@ function speedControl(defaultValue = "default") { tooltip: "Select speed", icon: "zap", desktopTrigger: "icon", - defaultValue, + defaultValue: "default", choices: [ { value: "default", label: "Normal" }, - { value: "priority", label: "Fast", aliases: ["fast"] }, + { value: "priority", label: "Fast" }, ], }; } @@ -172,7 +176,7 @@ describe("Codex app-server provider controls", () => { defaultValue: "default", choices: [ { value: "default", label: "Normal" }, - { value: "priority", label: "Fast", aliases: ["fast"] }, + { value: "priority", label: "Fast" }, { value: "ultrafast", label: "Ultrafast" }, ], }), @@ -311,7 +315,7 @@ describe("Codex app-server provider controls", () => { key: "service_tier", choices: [ { value: "default", label: "Normal" }, - { value: "priority", label: "Fast", aliases: ["fast"] }, + { value: "priority", label: "Fast" }, ], }), ); @@ -500,8 +504,19 @@ describe("Codex app-server provider controls", () => { const { session, appServer } = await createConnectedSession({ providerOptions: { fast_mode: true, plan_mode: true }, }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); + session.subscribe((event) => events.push(event)); - expect(session.controls).toEqual([speedControl("priority"), PLAN_CONTROL]); + expect(session.controls).toEqual([speedControl(), PLAN_CONTROL]); + // A saved pre-v0.10 Fast preference opens as the model's Fast tier and says so once. + expect(events.filter((event) => event.type === "options_changed")).toEqual([ + { + type: "options_changed", + provider: "codex", + patch: { service_tier: "priority", fast_mode: null }, + }, + ]); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ @@ -512,6 +527,20 @@ describe("Codex app-server provider controls", () => { }); }); + test("a patch that sets another option keeps the running sandbox and an explicit speed", async () => { + const { session, appServer } = await createConnectedSession({ + providerOptions: { sandbox_mode: "read-only", service_tier: "default", fast_mode: true }, + }); + + await session.applyOptions?.({ plan_mode: true }); + await session.startTurn("hello"); + + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ + serviceTier: "default", + sandboxPolicy: expect.objectContaining({ type: "readOnly" }), + }); + }); + test("applyOptions accepts an option that has no control", async () => { const { session } = await createConnectedSession(); @@ -529,24 +558,20 @@ describe("Codex app-server provider controls", () => { }); }); - test("a saved Fast preference selects the model's Fast tier without rewriting the stored option", async () => { + test("a saved service_tier: fast runs as the model's Fast tier and is reported as that tier", async () => { const { session, appServer } = await createConnectedSession({ providerOptions: { service_tier: "fast" }, }); + const events: AgentStreamEvent[] = []; + session.subscribe((event) => events.push(event)); - // The model's Fast tier is "priority". The choice names "fast" as an alias, so a client reading - // `options` + `controls` selects it, and the legacy features projection agrees. - const speed = session.controls.find((control) => control.key === "service_tier"); - expect(speed).toMatchObject({ - choices: expect.arrayContaining([ - expect.objectContaining({ value: "priority", aliases: ["fast"] }), - ]), - }); - expect( - featuresFromControls(session.controls, { service_tier: "fast" }).find( - (feature) => feature.id === "service_tier", - ), - ).toMatchObject({ value: "priority" }); + expect(events.filter((event) => event.type === "options_changed")).toEqual([ + { + type: "options_changed", + provider: "codex", + patch: { service_tier: "priority", fast_mode: null }, + }, + ]); await session.startTurn("hello"); await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "priority" }); @@ -658,57 +683,54 @@ describe("Codex app-server provider controls", () => { return { manager, create, resume, shownSpeed, runTurn }; } - test("is stored as set, shows Fast, and sends the Fast tier", async () => { - const { create, shownSpeed, runTurn } = createLegacyFastHarness(); + const FAST = { selected: "priority", feature: "priority" }; + + test("opens as the model's Fast tier and is stored as that tier", async () => { + const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness(); const agentId = await create({ fast_mode: true }); + await manager.flush(); - expect(shownSpeed(agentId)).toEqual({ - options: { fast_mode: true }, - own: { fast_mode: true }, - selected: "priority", - feature: "priority", + expect(shownSpeed(agentId)).toMatchObject({ + options: { service_tier: "priority" }, + own: { service_tier: "priority" }, + ...FAST, }); await runTurn(agentId, "priority"); }); - test("shows Fast after a stored record is resumed", async () => { - const { resume, shownSpeed, runTurn } = createLegacyFastHarness(); + test("is shown as Fast after a stored record is resumed", async () => { + const { manager, resume, shownSpeed, runTurn } = createLegacyFastHarness(); const agentId = await resume({ fast_mode: true }); + await manager.flush(); - expect(shownSpeed(agentId)).toEqual({ - options: { fast_mode: true }, - own: { fast_mode: true }, - selected: "priority", - feature: "priority", - }); + expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST }); await runTurn(agentId, "priority"); }); - test("from config.json defaults shows Fast and leaves the agent's own options empty", async () => { - const { create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true }); + test("from config.json defaults runs Fast and shows it through the agent's own tier", async () => { + const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness({ fast_mode: true }); const agentId = await create(); + await manager.flush(); - expect(shownSpeed(agentId)).toEqual({ - options: { fast_mode: true }, - own: undefined, - selected: "priority", - feature: "priority", - }); + expect(shownSpeed(agentId)).toMatchObject({ own: { service_tier: "priority" }, ...FAST }); await runTurn(agentId, "priority"); }); - test("set live shows Fast at once, and an explicit service_tier then wins", async () => { + test("set live runs Fast at once, and a later service_tier replaces it", async () => { const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness(); const agentId = await create(); expect(shownSpeed(agentId).selected).toBe("default"); await manager.setAgentOptions(agentId, { fast_mode: true }); - expect(shownSpeed(agentId)).toMatchObject({ selected: "priority", feature: "priority" }); + // The provider names the tier it resolved on the next tick, after the patch is stored. + await new Promise((resolve) => setImmediate(resolve)); + await manager.flush(); + expect(shownSpeed(agentId)).toMatchObject(FAST); await runTurn(agentId, "priority"); await manager.setAgentOptions(agentId, { service_tier: "default" }); expect(shownSpeed(agentId)).toMatchObject({ - own: { fast_mode: true, service_tier: "default" }, + own: { service_tier: "default" }, selected: "default", feature: "default", }); diff --git a/packages/server/src/server/agent/providers/codex-app-server-agent.ts b/packages/server/src/server/agent/providers/codex-app-server-agent.ts index 5454bf4b97..86360f8cbc 100644 --- a/packages/server/src/server/agent/providers/codex-app-server-agent.ts +++ b/packages/server/src/server/agent/providers/codex-app-server-agent.ts @@ -3454,6 +3454,8 @@ export class CodexAppServerAgentSession implements AgentSession { private threadRollbackAvailable = true; private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private startupEvents: AgentStreamEvent[] | null = null; + /** How the opened session differs from the options it was given; reported at first subscribe. */ + private openOptionReport: Record = {}; private nextTurnOrdinal = 0; private activeForegroundTurnId: string | null = null; private activeClientMessageId: string | null = null; @@ -3589,7 +3591,6 @@ export class CodexAppServerAgentSession implements AgentSession { get controls(): AgentOptionControl[] { return buildCodexControls({ serviceTiers: this.currentServiceTiers(), - options: this.providerOptions, planModeAvailable: this.hasPlanCollaborationMode(), }); } @@ -3649,6 +3650,7 @@ export class CodexAppServerAgentSession implements AgentSession { this.speedModels = CodexModelListResponseSchema.parse(await client.request("model/list", {})).data ?? []; + this.normalizeLegacySpeed(); await this.loadResolvedWorkspaceWrite(); await this.loadCollaborationModes(); await this.loadSkills(); @@ -3861,6 +3863,22 @@ export class CodexAppServerAgentSession implements AgentSession { return selectedModel?.serviceTiers ?? []; } + /** + * COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast + * preferences have migrated. A saved `fast_mode: true` or `service_tier: "fast"` runs as the + * model's Fast tier; the session says so once, at subscribe, so the options and the control agree. + */ + private normalizeLegacySpeed(): void { + const requested = this.requestedServiceTier; + const resolved = this.resolveServiceTier(requested); + const legacy = this.providerOptions.fast_mode !== undefined || requested !== resolved; + if (!legacy || resolved === null) return; + const { fast_mode: _legacy, ...rest } = this.providerOptions; + this.providerOptions = { ...rest, service_tier: resolved }; + this.requestedServiceTier = resolved; + this.openOptionReport = { service_tier: resolved, fast_mode: null }; + } + private get serviceTier(): string | null { return this.resolveServiceTier(this.requestedServiceTier); } @@ -4577,6 +4595,10 @@ export class CodexAppServerAgentSession implements AgentSession { const startupEvents = this.startupEvents; this.startupEvents = null; for (const event of startupEvents ?? []) this.notifySubscribers(event); + if (Object.keys(this.openOptionReport).length > 0) { + callback({ type: "options_changed", provider: CODEX_PROVIDER, patch: this.openOptionReport }); + this.openOptionReport = {}; + } return () => { this.subscribers.delete(callback); }; @@ -4657,21 +4679,50 @@ export class CodexAppServerAgentSession implements AgentSession { } } + /** Applies only the options the patch sets; the rest of the running options stay as they are. */ async applyOptions(options: ProviderOptions): Promise { - const next = validateProviderOptions("codex", CodexProviderOptionsSchema, options) ?? {}; - const requestedTier = readCodexServiceTier(next); - if ( - requestedTier !== this.requestedServiceTier && - !this.isServiceTierAvailable(requestedTier) - ) { - throw new Error( - `Codex speed '${String(requestedTier)}' is not available for model '${this.config.model ?? "default"}'`, - ); + const { fast_mode: _legacyFast, ...running } = this.providerOptions; + const next = + validateProviderOptions("codex", CodexProviderOptionsSchema, { + ...running, + ...options, + }) ?? {}; + let requestedTier = this.requestedServiceTier; + if ("service_tier" in options || "fast_mode" in options) { + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier. + requestedTier = this.requestedTierFrom(options); + if ( + requestedTier !== this.requestedServiceTier && + !this.isServiceTierAvailable(requestedTier) + ) { + throw new Error( + `Codex speed '${String(requestedTier)}' is not available for model '${this.config.model ?? "default"}'`, + ); + } } - this.providerOptions = next; + this.providerOptions = { ...next, ...(requestedTier ? { service_tier: requestedTier } : {}) }; this.requestedServiceTier = requestedTier; - this.setPlanMode(next.plan_mode === true); + if ("plan_mode" in options) this.setPlanMode(options.plan_mode === true); this.cachedRuntimeInfo = null; + // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once clients use service_tier. + if ("fast_mode" in options && !("service_tier" in options)) { + setImmediate(() => this.reportSpeed({ fast_mode: null })); + } + } + + private requestedTierFrom(options: ProviderOptions): string | null { + if ("service_tier" in options) return readCodexServiceTier(options); + return options.fast_mode ? "fast" : "default"; + } + + private reportSpeed(extra: Record): void { + const tier = this.serviceTier; + if (tier === null) return; + this.emitEvent({ + type: "options_changed", + provider: CODEX_PROVIDER, + patch: { service_tier: tier, ...extra }, + }); } private isServiceTierAvailable(tier: string | null): boolean { diff --git a/packages/server/src/server/agent/providers/codex-feature-definitions.ts b/packages/server/src/server/agent/providers/codex-feature-definitions.ts index 9ce0e11999..d2ffdece1c 100644 --- a/packages/server/src/server/agent/providers/codex-feature-definitions.ts +++ b/packages/server/src/server/agent/providers/codex-feature-definitions.ts @@ -9,28 +9,8 @@ export const CodexServiceTierSchema = z.object({ }); export type CodexServiceTier = z.infer; -// Which speed applies, for the runtime and for this control: an explicit `service_tier` wins; -// the pre-v0.10 `fast_mode: true` boolean counts only while `service_tier` is absent -// (`readCodexServiceTier`). The control shows it as its default, because a client selects the -// option's value when `service_tier` is set and the default when it is not. -function buildCodexSpeedControl( - tiers: CodexServiceTier[], - options: Record | undefined, -): AgentOptionControl[] { +function buildCodexSpeedControl(tiers: CodexServiceTier[]): AgentOptionControl[] { if (tiers.length === 0) return []; - const choices = [ - { value: "default", label: "Normal" }, - ...tiers.map((tier) => ({ - value: tier.id, - label: tier.name, - // COMPAT(codexFastPreference): added in v0.10.0, remove after 2027-03-29 once saved Fast preferences have migrated. - ...(tier.id !== "fast" && tier.name.toLowerCase() === "fast" ? { aliases: ["fast"] } : {}), - })), - ]; - const implied = options?.service_tier === undefined ? readCodexServiceTier(options) : null; - const defaultChoice = choices.find( - (choice) => choice.value === implied || choice.aliases?.includes(implied ?? ""), - ); return [ { type: "select", @@ -40,8 +20,11 @@ function buildCodexSpeedControl( tooltip: "Select speed", icon: "zap", desktopTrigger: "icon", - defaultValue: defaultChoice?.value ?? "default", - choices, + defaultValue: "default", + choices: [ + { value: "default", label: "Normal" }, + ...tiers.map((tier) => ({ value: tier.id, label: tier.name })), + ], }, ]; } @@ -57,10 +40,9 @@ export const CODEX_PLAN_MODE_CONTROL: AgentOptionToggleControl = { export function buildCodexControls(input: { serviceTiers: CodexServiceTier[]; - options?: Record; planModeAvailable?: boolean; }): AgentOptionControl[] { - const controls = buildCodexSpeedControl(input.serviceTiers, input.options); + const controls = buildCodexSpeedControl(input.serviceTiers); if (input.planModeAvailable !== false) { controls.push({ ...CODEX_PLAN_MODE_CONTROL }); } diff --git a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts index 76ddd3dcb3..11c98d9db1 100644 --- a/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts +++ b/packages/server/src/server/agent/providers/cursor-acp-agent.test.ts @@ -183,7 +183,6 @@ describe("CursorACPAgentClient model discovery", () => { description: "Cursor fast mode", tooltip: "Select Cursor fast mode", icon: "zap", - defaultValue: "false", choices: [ { value: "false", label: "Off" }, { value: "true", label: "Fast" }, @@ -328,7 +327,7 @@ describe("CursorACPAgentClient session start", () => { ); }); - test("removing auto_accept stops answering permission requests on its own", async () => { + test("removing auto_accept sends nothing, so the session keeps answering permission requests", async () => { const session = createCursorSession({ model: "composer-2.5" }); await session.initializeNewSession(); const request = { @@ -338,16 +337,31 @@ describe("CursorACPAgentClient session start", () => { } as const; await session.applyOptions({ auto_accept: true }); + // The patch for a removal carries no key; the session stays as it was until next open. + await session.applyOptions({}); + await expect(session.requestPermission({ ...request })).resolves.toEqual({ outcome: { outcome: "selected", optionId: "allow-always" }, }); + }); - await session.applyOptions({}); - void session.requestPermission({ ...request }); + test("a rejected patch leaves auto_accept as it was", async () => { + const session = createCursorSession({ model: "kimi-k3" }); + await session.initializeNewSession(); + + await expect( + session.applyOptions({ auto_accept: true, [CURSOR_FAST_FEATURE_OPTION.id]: "true" }), + ).rejects.toThrow("acp does not expose ACP feature 'fast'"); + + void session.requestPermission({ + sessionId: "session-1", + toolCall: { toolCallId: "tool-1", title: "Run command", kind: "execute", status: "pending" }, + options: [{ optionId: "allow-always", name: "Always allow", kind: "allow_always" }], + }); await vi.waitFor(() => expect(session.getPendingPermissions()).toHaveLength(1)); }); - test("removing the Fast option returns the session to the value it started with", async () => { + test("applies only the selects the patch sets", async () => { const setSessionConfigOption = vi.fn(async (request: { value: string }) => ({ configOptions: [fastConfigOption(request.value === "true" ? "true" : "false")], })); @@ -362,16 +376,9 @@ describe("CursorACPAgentClient session start", () => { await session.initializeNewSession(); await session.applyOptions({ [CURSOR_FAST_FEATURE_OPTION.id]: "true" }); - // `{ fast: null }` with no config.json default reaches the provider as no key at all. await session.applyOptions({}); - expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual([ - "true", - "false", - ]); - expect(session.controls).toContainEqual( - expect.objectContaining({ key: "fast", defaultValue: "false" }), - ); + expect(setSessionConfigOption.mock.calls.map(([request]) => request.value)).toEqual(["true"]); }); test("still fails session start when the provider rejects a write it should accept", async () => { diff --git a/packages/server/src/server/agent/providers/omp/agent.test.ts b/packages/server/src/server/agent/providers/omp/agent.test.ts index 794dc41278..e81b07b812 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -615,7 +615,7 @@ describe("OMP agent client and session", () => { ); expect(omp.requireSession().controls).toEqual([ expect.objectContaining({ - defaultValue: true, + key: "fast_mode", description: expect.stringMatching(/does not apply/i), }), ]); @@ -631,32 +631,46 @@ describe("OMP agent client and session", () => { expect(session.controls).toEqual([ expect.objectContaining({ key: "fast_mode", - defaultValue: true, description: expect.stringMatching(/does not apply to this model/i), tooltip: expect.stringMatching(/does not apply to this model/i), }), ]); }); - test("removing the fast_mode option restores the runtime's own setting", async () => { + test("applies only the options it is given and leaves the runtime alone otherwise", async () => { const omp = new OmpHarness(); await omp.start(); const session = omp.requireSession(); - expect(omp.runtime().state.fastModeEnabled).toBe(false); omp.runtime().fastModeResult = { enabled: true, active: true }; await session.applyOptions?.({ fast_mode: true }); expect(omp.runtime().setFastModeRequests).toEqual([true]); - // `{ fast_mode: null }` with no config.json default reaches the provider as no key at all. - omp.runtime().fastModeResult = { enabled: false, active: false }; + // Removing the option sends nothing: the patch carries no key, so the runtime keeps Fast. await session.applyOptions?.({}); + expect(omp.runtime().setFastModeRequests).toEqual([true]); + }); - expect(omp.runtime().setFastModeRequests).toEqual([true, false]); - expect(session.controls).toEqual([expect.objectContaining({ defaultValue: false })]); - // A later change to an unrelated option leaves the runtime alone. - await session.applyOptions?.({}); - expect(omp.runtime().setFastModeRequests).toEqual([true, false]); + test("reports a Fast change the runtime made on its own", async () => { + const omp = new OmpHarness(); + await omp.start(); + const session = omp.requireSession(); + omp.runtime().fastModeResult = { enabled: true, active: true }; + await session.applyOptions?.({ fast_mode: true }); + expect(omp.eventTypes()).not.toContain("options_changed"); + + omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" }; + omp.runtime().queueStateReports([ + { + ...omp.runtime().state, + model: omp.runtime().setModelResult, + fastModeEnabled: false, + fastModeActive: false, + }, + ]); + await session.setModel?.("openrouter/other/model"); + + expect(omp.eventTypes().filter((type) => type === "options_changed")).toHaveLength(1); }); test("shows Fast only when OMP reports its state fields", async () => { @@ -683,7 +697,6 @@ describe("OMP agent client and session", () => { const session = omp.requireSession(); omp.runtime().fastModeResult = { enabled: true, active: true }; await session.applyOptions?.({ fast_mode: true }); - expect(session.controls).toEqual([expect.objectContaining({ defaultValue: true })]); omp.runtime().setModelResult = { provider: "openrouter", id: "other/model" }; omp.runtime().queueStateReports([ @@ -697,7 +710,7 @@ describe("OMP agent client and session", () => { await session.setModel?.("openrouter/other/model"); expect(session.controls).toEqual([ expect.objectContaining({ - defaultValue: true, + key: "fast_mode", description: expect.stringMatching(/does not apply/i), }), ]); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index 1bcf34d7f3..bab29a0d46 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -203,8 +203,6 @@ interface OmpAgentSessionOptions { }>; config: AgentSessionConfig; initialState: OmpSessionState; - /** Fast mode as the runtime reported it before any option was applied; removal restores it. */ - fastModeBaseline?: boolean; currentModeId?: string | null; logger: Logger; providerIdleScheduler?: OmpProviderIdleScheduler; @@ -503,10 +501,6 @@ function isOmpSteerTransportFailure(error: unknown): boolean { ); } -function fastModeBaselineOf(state: OmpSessionState): boolean | undefined { - return typeof state.fastModeEnabled === "boolean" ? state.fastModeEnabled : undefined; -} - function hasOmpFastMode(state: OmpSessionState): boolean { return typeof state.fastModeEnabled === "boolean" && typeof state.fastModeActive === "boolean"; } @@ -757,7 +751,7 @@ export class OmpAgentSession implements AgentSession { this.restartRuntime = options.restartRuntime; this.config = options.config; this.state = options.initialState; - this.fastModeBaseline = options.fastModeBaseline; + this.openOptionReport = this.fastModeDifference(options.config.providerOptions?.fast_mode); this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; this.live = options.live ?? true; @@ -820,8 +814,6 @@ export class OmpAgentSession implements AgentSession { key: "fast_mode", label: "Fast", icon: "zap", - // OMP owns fast mode; an unset option stands for what the runtime reports. - defaultValue: this.state.fastModeEnabled === true, ...(this.state.fastModeEnabled && !this.state.fastModeActive ? { description: OMP_FAST_INACTIVE_MESSAGE, tooltip: OMP_FAST_INACTIVE_MESSAGE } : {}), @@ -831,26 +823,29 @@ export class OmpAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise { const requested = options.fast_mode; - if (requested !== undefined && typeof requested !== "boolean") { - throw new Error("OMP fast mode requires a boolean"); - } - // An absent option means the runtime's own setting at session start. - const target = requested ?? this.fastModeBaseline; - if (target !== undefined && target !== this.state.fastModeEnabled) { - if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable"); - const result = await this.runtimeSession.setFastMode(target); + if (requested === undefined) return; + if (typeof requested !== "boolean") throw new Error("OMP fast mode requires a boolean"); + if (!hasOmpFastMode(this.state)) throw new Error("OMP fast mode is unavailable"); + if (requested !== this.state.fastModeEnabled) { + const result = await this.runtimeSession.setFastMode(requested); this.state = { ...this.state, fastModeEnabled: result.enabled, fastModeActive: result.active, }; } - const { fast_mode: _previous, ...others } = this.config.providerOptions ?? {}; - this.config.providerOptions = - requested === undefined ? others : { ...others, fast_mode: requested }; + this.config.providerOptions = { ...this.config.providerOptions, fast_mode: requested }; } - private readonly fastModeBaseline: boolean | undefined; + /** The option patch when the runtime's fast mode is not what `given` stands for; unset is off. */ + private fastModeDifference(given: unknown): Record { + if (!hasOmpFastMode(this.state)) return {}; + const enabled = this.state.fastModeEnabled === true; + return (given ?? false) === enabled ? {} : { fast_mode: enabled }; + } + + /** How the opened session differs from the options it was given; reported at first subscribe. */ + private openOptionReport: Record; async run(prompt: AgentPromptInput, options?: AgentRunOptions): Promise { return runProviderTurn({ @@ -1012,6 +1007,10 @@ export class OmpAgentSession implements AgentSession { subscribe(callback: (event: AgentStreamEvent) => void): () => void { this.subscribers.add(callback); + if (Object.keys(this.openOptionReport).length > 0) { + callback({ type: "options_changed", provider: this.provider, patch: this.openOptionReport }); + this.openOptionReport = {}; + } return () => { this.subscribers.delete(callback); }; @@ -2246,7 +2245,15 @@ export class OmpAgentSession implements AgentSession { } private async refreshState(): Promise { + const before = this.state.fastModeEnabled; this.state = await this.runtimeSession.getState(); + if (this.state.fastModeEnabled !== before && typeof this.state.fastModeEnabled === "boolean") { + this.emit({ + type: "options_changed", + provider: this.provider, + patch: { fast_mode: this.state.fastModeEnabled }, + }); + } } private async refreshAfterTurn(finalUsage: Promise): Promise { @@ -2348,10 +2355,12 @@ export class OmpAgentClient implements AgentClient { config, startInput.env, ); - const startState = await runtimeSession.getState(); - const initialState = await this.restoreFastMode(runtimeSession, config, startState); + const initialState = await this.restoreFastMode( + runtimeSession, + config, + await runtimeSession.getState(), + ); return new OmpAgentSession({ - fastModeBaseline: fastModeBaselineOf(startState), runtimeSession, hostTools, restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), @@ -2423,7 +2432,6 @@ export class OmpAgentClient implements AgentClient { }; const initialState = await this.restoreFastMode(runtimeSession, config, resumedState); return new OmpAgentSession({ - fastModeBaseline: fastModeBaselineOf(resumedState), runtimeSession, hostTools, restartRuntime: this.buildRestartRuntime(startInput, config, launchContext), 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 b7601bb3bb..ca0da89d45 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 @@ -290,7 +290,7 @@ describe("OpenCode auto_accept feature", () => { await session.close(); }); - test("stops auto-approving once auto_accept is removed from the options", async () => { + test("keeps auto-approving after auto_accept is removed from the options", async () => { const { openCodeClient, runtime } = mockOpenCodeClient({ events: [toolPermissionEvent(), idleEvent()], }); @@ -305,14 +305,12 @@ describe("OpenCode auto_accept feature", () => { providerOptions: { auto_accept: true }, }); - // The daemon passes effective options; with the key removed and no config default it is absent. + // Removal sends the provider nothing: it keeps its current setting until next open. await session.applyOptions?.({}); await session.run("Run verification"); - expect(openCodeClient.calls.permissionReply).toEqual([]); - expect(session.getPendingPermissions()).toEqual([ - expect.objectContaining({ id: "permission-1" }), - ]); + expect(openCodeClient.calls.permissionReply).toHaveLength(1); + expect(session.getPendingPermissions()).toEqual([]); await session.close(); }); diff --git a/packages/server/src/server/agent/providers/opencode-agent.ts b/packages/server/src/server/agent/providers/opencode-agent.ts index ea541f4542..d67a2f877d 100644 --- a/packages/server/src/server/agent/providers/opencode-agent.ts +++ b/packages/server/src/server/agent/providers/opencode-agent.ts @@ -4964,7 +4964,9 @@ class OpenCodeAgentSession implements AgentSession { } async applyOptions(options: ProviderOptions): Promise { - this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true); + if (OPENCODE_AUTO_ACCEPT_FEATURE_ID in options) { + this.setAutoAccept(options[OPENCODE_AUTO_ACCEPT_FEATURE_ID] === true); + } } private setAutoAccept(enabled: boolean): void { diff --git a/packages/server/src/server/agent/providers/opencode/v2/session.ts b/packages/server/src/server/agent/providers/opencode/v2/session.ts index 4bb3a2877f..867de53c90 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -360,8 +360,8 @@ export class OpenCodeV2Session implements AgentSession { this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant }); } async applyOptions(options: ProviderOptions) { - // An absent key is the default: approvals are asked for. - const value = options["auto_accept"] ?? false; + const value = options["auto_accept"]; + if (value === undefined) return; if (typeof value !== "boolean") throw new Error("OpenCode auto_accept requires a boolean"); this.config.providerOptions = { ...this.config.providerOptions, auto_accept: value }; if (value && !this.config.toolPolicy)