From d5953d2ea795e55e099728b77b2d1151c7e25181 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 13:11:41 +0100 Subject: [PATCH] fix(server): Claude and Codex merge supplied options into their running options setAgentOptions hands the session only the agent's own options, so Claude's applyOptions no longer replaces the restrictions merged in at open (disallowed tools, sandbox), and Codex derives its speed from the running record so an explicit service_tier still beats a legacy fast_mode on an unrelated patch. --- .../agent/providers/claude/agent.test.ts | 28 +++++++++++++++++++ .../server/agent/providers/claude/agent.ts | 7 +++-- .../codex-app-server-agent.features.test.ts | 12 ++++++++ .../agent/providers/codex-app-server-agent.ts | 8 ++++-- 4 files changed, 49 insertions(+), 6 deletions(-) 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 ecfc29cd30..7ed7d359e5 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -867,6 +867,34 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); + test("applying options before the first query keeps the session's restrictions", async () => { + const { queryFactory } = 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", + providerOptions: { + disallowedTools: ["Bash"], + sandbox: { enabled: true, failIfUnavailable: true }, + }, + }); + + // The manager hands the session only the agent's own options. + await session.applyOptions?.({ fast_mode: true }); + await (session as unknown as { ensureQuery(): Promise }).ensureQuery(); + + expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ + disallowedTools: ["Bash"], + sandbox: { enabled: true, failIfUnavailable: true }, + }); + await session.close(); + }); + test("passes extra Claude Code CLI arguments to the SDK", async () => { const { queryFactory, launches } = 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 de70858db5..6d483aa2b6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2800,9 +2800,10 @@ class ClaudeAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise { const providerOptions = validateProviderOptions("claude", ClaudeProviderOptionsSchema, options) ?? {}; + // Supplied keys update the running options; absent keys keep their running value. + const running = { ...this.config.providerOptions, ...providerOptions }; const hadFast = this.config.providerOptions.fast_mode === true; - // An absent option leaves Fast as it is; only an explicit value changes it. - const wantsFast = providerOptions.fast_mode === undefined ? hadFast : providerOptions.fast_mode; + const wantsFast = running.fast_mode === true; const modelSupportsFast = claudeModelSupportsFastMode(this.config.model); if (wantsFast && !hadFast && !modelSupportsFast) { throw new Error( @@ -2817,7 +2818,7 @@ class ClaudeAgentSession implements AgentSession { this.cachedRuntimeInfo = null; } // Held until the runtime accepts, so a rejected change is retried instead of skipped. - this.config.providerOptions = { ...providerOptions, ...(wantsFast ? { fast_mode: true } : {}) }; + this.config.providerOptions = running; } getPendingPermissions(): AgentPermissionRequest[] { 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 01c2b87ce9..d01fe5e739 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 @@ -559,6 +559,18 @@ describe("Codex app-server provider controls", () => { }); }); + test("an explicit tier still beats a legacy fast_mode on a patch that supplies neither", async () => { + const { session, appServer } = await createConnectedSession({ + providerOptions: { service_tier: "default", fast_mode: true }, + }); + + // The manager hands the session only the agent's own options. + await session.applyOptions?.({ fast_mode: true, plan_mode: true }); + await session.startTurn("hello"); + + await expect(appServer.waitForTurnStart()).resolves.toMatchObject({ serviceTier: "default" }); + }); + test("startTurn includes serviceTier when fast mode is enabled", async () => { const { session, appServer } = await createConnectedSession(); 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 40315c05b3..052fca7cc7 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 @@ -4661,8 +4661,11 @@ export class CodexAppServerAgentSession implements AgentSession { async applyOptions(options: ProviderOptions): Promise { const next = validateProviderOptions("codex", CodexProviderOptionsSchema, options) ?? {}; // An absent speed or plan option leaves the session as it is. + // Supplied keys update the running options; absent keys keep their running value, so an + // explicit tier still beats a legacy `fast_mode` on a patch that supplies neither. + const running = { ...this.providerOptions, ...next }; const speedGiven = next.service_tier !== undefined || next.fast_mode !== undefined; - const requestedTier = speedGiven ? readCodexServiceTier(next) : this.requestedServiceTier; + const requestedTier = speedGiven ? readCodexServiceTier(running) : this.requestedServiceTier; if ( requestedTier !== this.requestedServiceTier && !this.isServiceTierAvailable(requestedTier) @@ -4671,8 +4674,7 @@ export class CodexAppServerAgentSession implements AgentSession { `Codex speed '${String(requestedTier)}' is not available for model '${this.config.model ?? "default"}'`, ); } - // Supplied keys update the running options; absent keys keep their running value. - this.providerOptions = { ...this.providerOptions, ...next }; + this.providerOptions = running; this.requestedServiceTier = requestedTier; this.setPlanMode(next.plan_mode === undefined ? this.planModeEnabled : next.plan_mode === true); this.cachedRuntimeInfo = null;