diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 4a7ab77c33..41b7ff784e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1258,7 +1258,7 @@ export class AgentManager { const client = this.requireClient(normalizedConfig.provider); const options = this.effectiveOptionsFor( normalizedConfig.provider, - this.convertLegacyOptions(normalizedConfig.provider, config.providerOptions), + normalizedConfig.providerOptions, ); if (!normalizedConfig.model && !client.listControls) { return { controls: [], options }; @@ -5617,7 +5617,7 @@ export class AgentManager { config: AgentSessionConfig, options: NormalizeConfigOptions = {}, ): Promise { - const normalized: AgentSessionConfig = { ...config }; + const normalized: AgentSessionConfig = { ...this.withConvertedOptions(config) }; // Always resolve cwd to absolute path for consistent history file lookup if (normalized.cwd) { @@ -5722,13 +5722,10 @@ export class AgentManager { agentId: string, options: { env?: Record; purpose?: AgentResumePurpose } = {}, ): Promise { - const storedConfig = await this.normalizeConfig( - this.withConvertedOptions(stripInternalPaseoMcpServer(config)), - { - env: options.env, - purpose: options.purpose, - }, - ); + const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { + env: options.env, + purpose: options.purpose, + }); const paseoToolPolicy = this.paseoToolsEnabled ? this.resolvePaseoToolPolicy(storedConfig.provider) : { enabled: false }; diff --git a/packages/server/src/server/agent/option-controls.ts b/packages/server/src/server/agent/option-controls.ts index 7d9e81d418..87b8d4253b 100644 --- a/packages/server/src/server/agent/option-controls.ts +++ b/packages/server/src/server/agent/option-controls.ts @@ -152,7 +152,10 @@ export function foldProfileFeatureValues(input: { source: "stored" | "client write"; }): Omit { const { profile, stored, source } = input; - const { featureValues, ...rest } = profile; + const { featureValues: legacyValues, ...rest } = profile; + // The app reads a profile's speed from its controls, so the old key is converted here for the + // provider id the profile names; a profile on a custom provider is converted when it is applied. + const featureValues = convertLegacyOptions({ provider: profile.provider, options: legacyValues }); if (source === "stored") { return featureValues ? { ...rest, options: { ...featureValues, ...rest.options } } : rest; } 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 35e4c2113d..c862e34f3d 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 @@ -873,6 +873,23 @@ describe("Codex app-server provider controls", () => { await runTurn(agentId, "default"); }); + test.each([CODEX_PROVIDER, "my-codex"])( + "draft controls for %s accept a legacy fast_mode", + async (providerId) => { + const { manager } = createLegacyFastHarness({}, providerId); + + const draft = await manager.listDraftControls({ + provider: providerId, + cwd: tmpdir(), + model: "gpt-5.4", + providerOptions: { fast_mode: true }, + }); + + expect(draft.options).toEqual({ service_tier: "fast" }); + expect(draft.controls).toContainEqual(expect.objectContaining({ key: "service_tier" })); + }, + ); + test("an older client turning fast_mode off selects Normal", async () => { const { manager, create, shownSpeed, runTurn } = createLegacyFastHarness(); const agentId = await create({ service_tier: "priority" }); 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 74c6558022..38a54294b2 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -767,6 +767,19 @@ describe("OMP agent client and session", () => { expect(omp.runtime().setFastModeRequests).toEqual([]); }); + test("a replacement runtime starts with the Fast the session opened with, not the one it was asked for", async () => { + const omp = new OmpHarness(); + // The fake runtime answers the open-time Fast request with Fast off. + await omp.start({ modeId: "full", providerOptions: { fast_mode: true } }); + expect(omp.requireSession().openOptionsPatch).toEqual({ fast_mode: false }); + omp.runtime().fastModeResult = { enabled: true, active: true }; + + await omp.setMode("ask"); + + expect(omp.runtimeLaunches()).toHaveLength(2); + expect(omp.runtime().setFastModeRequests).toEqual([]); + }); + test("restores Fast from the initial OMP state on create and resume", async () => { const created = new OmpHarness(); await created.start({ providerOptions: { fast_mode: true } }); diff --git a/packages/server/src/server/agent/providers/omp/agent.ts b/packages/server/src/server/agent/providers/omp/agent.ts index c0e0905089..7fe3e517fa 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -755,6 +755,9 @@ export class OmpAgentSession implements AgentSession { this.config = options.config; this.state = options.initialState; this.openOptionsPatch = this.fastModeDifference(options.config.providerOptions?.fast_mode); + // A replacement runtime starts from the Fast this one opened with, not the one it was asked for. + if (typeof this.openOptionsPatch.fast_mode === "boolean") + this.rememberFastMode(this.openOptionsPatch.fast_mode); this.currentModeId = options.currentModeId ?? null; this.logger = options.logger; this.live = options.live ?? true; diff --git a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts index 8435dccd90..044314b005 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/agent.test.ts @@ -88,6 +88,47 @@ describe("OpenCode v2 session lifecycle", () => { } }); + test("overlapping auto_accept applies do not undo each other when one fails", async () => { + const harness = new V2Harness(); + const permissions = [ + { id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }, + ]; + harness.api.permission.list = async () => permissions; + let failFirst!: () => void; + let calls = 0; + harness.api.permission.reply = async () => { + calls += 1; + if (calls > 1) return; + await new Promise((_, reject) => { + failFirst = () => reject(new Error("upstream refused")); + }); + }; + const client = new OpenCodeV2AgentClient({ + logger: createTestLogger(), + runtime: harness.runtime, + }); + const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); + try { + const first = session.applyOptions?.({ auto_accept: true }); + const second = session.applyOptions?.({ auto_accept: true }); + await expect.poll(() => calls).toBe(1); + failFirst(); + + await expect(first).rejects.toThrow("upstream refused"); + await expect(second).resolves.toBeUndefined(); + + // The accepted second apply is in force: the next permission is approved, not asked. + const next = { id: "next", sessionID: "session", action: "shell", resources: ["ls"] }; + permissions.push(next); + harness.push({ id: "asked", created: 2, type: "permission.asked", data: next }); + await expect.poll(() => calls).toBeGreaterThan(2); + const pendingIds = () => session.getPendingPermissions().map(({ id }) => id); + expect(pendingIds()).not.toContain("next"); + } finally { + await session.close(); + } + }); + test("rejects a live permission change the running session cannot apply", async () => { const harness = new V2Harness(); const client = new OpenCodeV2AgentClient({ 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 a86f0c3619..8771d618f7 100644 --- a/packages/server/src/server/agent/providers/opencode/v2/session.ts +++ b/packages/server/src/server/agent/providers/opencode/v2/session.ts @@ -362,7 +362,14 @@ export class OpenCodeV2Session implements AgentSession { this.info = await this.client.session.get({ sessionID: this.id }); this.emit({ type: "thinking_option_changed", provider: "opencode", thinkingOptionId: variant }); } - async applyOptions(options: ProviderOptions) { + /** Overlapping applies run one after another, so a failed one restores exactly what it changed. */ + private applying: Promise = Promise.resolve(); + 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 { assertChangesAreLive({ provider: "opencode", patch: options, diff --git a/packages/server/src/server/daemon-config-store.test.ts b/packages/server/src/server/daemon-config-store.test.ts index 02e64236fd..7a7f567850 100644 --- a/packages/server/src/server/daemon-config-store.test.ts +++ b/packages/server/src/server/daemon-config-store.test.ts @@ -268,7 +268,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "P", provider: "codex", - options: { sandbox_mode: "read-only", fast_mode: false }, + options: { sandbox_mode: "read-only", plan_mode: false }, }, ], }); @@ -279,7 +279,7 @@ describe("DaemonConfigStore", () => { await store.patch({ agentProfiles: [ - { id: "p", name: "Renamed", provider: "codex", featureValues: { fast_mode: true } }, + { id: "p", name: "Renamed", provider: "codex", featureValues: { plan_mode: true } }, ], }); @@ -288,7 +288,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "Renamed", provider: "codex", - options: { sandbox_mode: "read-only", fast_mode: true }, + options: { sandbox_mode: "read-only", plan_mode: true }, }, ]); }); @@ -303,7 +303,7 @@ describe("DaemonConfigStore", () => { id: "p", name: "Renamed", provider: "codex", - options: { sandbox_mode: "read-only", fast_mode: false }, + options: { sandbox_mode: "read-only", plan_mode: false }, }, ]); }); @@ -340,7 +340,7 @@ describe("DaemonConfigStore", () => { name: "P", provider: "codex", options: { sandbox_mode: "workspace-write" }, - featureValues: { fast_mode: true }, + featureValues: { plan_mode: true }, }, ], }); @@ -350,7 +350,26 @@ describe("DaemonConfigStore", () => { id: "p", name: "P", provider: "codex", - options: { sandbox_mode: "workspace-write", fast_mode: true }, + options: { sandbox_mode: "workspace-write", plan_mode: true }, + }, + ]); + }); + + test("converts a Codex profile's legacy fast_mode to service_tier", async () => { + const store = storeWithProfile(); + + await store.patch({ + agentProfiles: [ + { id: "p", name: "P", provider: "codex", featureValues: { fast_mode: true } }, + ], + }); + + expect(store.get().agentProfiles).toEqual([ + { + id: "p", + name: "P", + provider: "codex", + options: { sandbox_mode: "read-only", plan_mode: false, service_tier: "fast" }, }, ]); }); @@ -360,7 +379,7 @@ describe("DaemonConfigStore", () => { await store.patch({ agentProfiles: [ - { id: "p", name: "P", provider: "codex", featureValues: { fast_mode: null } }, + { id: "p", name: "P", provider: "codex", featureValues: { plan_mode: null } }, ], });