From f3c3461d674a66ee86bb29526d459313caaaeee3 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Nov 2026 15:52:08 +0100 Subject: [PATCH] fix(server): Claude rejects a live change to an option it reads when the query starts Replacing a started query to apply the option would end its background shells and tasks. Once the query exists only Fast can change; before the first prompt every option still applies, because it is read when the query is built. --- docs/providers.md | 3 +- .../agent/providers/claude/agent.test.ts | 95 +++---------------- .../server/agent/providers/claude/agent.ts | 29 +++--- 3 files changed, 24 insertions(+), 103 deletions(-) diff --git a/docs/providers.md b/docs/providers.md index 8fcf0833cb..9e8f7c0ce2 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -47,7 +47,8 @@ or without a control. there is none; the provider returns the setting to its own default if it can, or throws to reject. A key the running session cannot apply (one the provider reads only when it opens) is rejected too, so a stored option always describes what runs. Claude reads most options when it - starts a query: it applies them from the next turn and rejects them while a turn is running. It + starts its query, and replacing a started query would end its background work, so after the + first prompt only Fast can change. It returns the options it ended up with when they differ from the request. The daemon stores the patch and that result in one step 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 and 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 0068f448a3..6699662803 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1507,61 +1507,6 @@ 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(); - - // 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("a patch that lands while Fast is being applied is not overwritten by it", 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(); - let finishFast!: () => void; - queryMock.applyFlagSettings.mockImplementationOnce( - () => - new Promise((resolve) => { - finishFast = resolve; - }), - ); - - const fast = session.applyOptions?.({ fast_mode: true }); - await session.applyOptions?.({ allowedTools: ["Read"] }); - finishFast(); - await fast; - - expect( - (session as unknown as { config: { providerOptions: unknown } }).config.providerOptions, - ).toEqual({ fast_mode: true, allowedTools: ["Read"] }); - await session.close(); - }); - test("a patch before the first prompt keeps the restrictions the session opened with", async () => { const { queryFactory } = createQueryMock(); const client = new ClaudeAgentClient({ @@ -1589,7 +1534,7 @@ describe("ClaudeAgentSession features", () => { await session.close(); }); - test("an option Claude reads when it starts applies from the next query", async () => { + test("an option Claude reads when it starts changes before the query exists and is rejected after", async () => { const { queryFactory } = createQueryMock(); const client = new ClaudeAgentClient({ logger, @@ -1602,40 +1547,22 @@ describe("ClaudeAgentSession features", () => { model: "claude-opus-4-8", providerOptions: { disallowedTools: ["Bash"] }, }); - const ensureQuery = () => - (session as unknown as { ensureQuery(): Promise }).ensureQuery(); - await ensureQuery(); await session.applyOptions?.({ disallowedTools: ["Bash", "Write"] }); - await ensureQuery(); - - expect(queryFactory).toHaveBeenCalledTimes(2); - expect(queryFactory.mock.calls[1]?.[0].options).toMatchObject({ + await (session as unknown as { ensureQuery(): Promise }).ensureQuery(); + expect(queryFactory.mock.calls[0]?.[0].options).toMatchObject({ disallowedTools: ["Bash", "Write"], }); - await session.close(); - }); - - test("rejects a change to an option Claude reads at start while a turn is running", 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"] }, - }); - await session.startTurn("hello"); + // The query now runs with these; replacing it would end its background work. await expect(session.applyOptions?.({ disallowedTools: [] })).rejects.toThrow( - /disallowedTools.*cannot change while a turn is running/, + /disallowedTools.*cannot change on a running session/, ); - // The same value is not a change. - await expect(session.applyOptions?.({ disallowedTools: ["Bash"] })).resolves.toBeUndefined(); + // The same value is not a change, and Fast still applies. + await expect( + session.applyOptions?.({ disallowedTools: ["Bash", "Write"], fast_mode: true }), + ).resolves.toBeUndefined(); + expect(queryFactory).toHaveBeenCalledTimes(1); await session.close(); }); @@ -1666,7 +1593,7 @@ describe("ClaudeAgentSession features", () => { // still read Fast as off: a removed key would let the default fill `true` back in. const stored = applyProviderOptionsPatch({ fast_mode: true }, reported[0].patch); expect(mergeProviderOptions({ fast_mode: true }, stored)).toEqual({ fast_mode: false }); - await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined(); + await expect(session.applyOptions?.({ fast_mode: null })).resolves.toBeUndefined(); await session.close(); }); diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index 2ec9b9035b..10cf11db47 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -1,9 +1,8 @@ import { setImmediate } from "node:timers/promises"; -import { isDeepStrictEqual } from "node:util"; import { ClaudeRuntimeSafety } from "./runtime-safety.js"; import type { ProviderOptions } from "@getpaseo/protocol/agent-types"; import { applyProviderOptionsPatch } from "../../option-controls.js"; -import { ProviderOptionsValidationError, validateProviderOptions } from "../../provider-options.js"; +import { assertChangesAreLive, validateProviderOptions } from "../../provider-options.js"; import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import fs from "node:fs"; @@ -2810,6 +2809,16 @@ class ClaudeAgentSession implements AgentSession { } async applyOptions(options: ProviderOptions): Promise { + // Every option but Fast is read when the query is built, and replacing a started query would + // end its background work, so once it exists only Fast can change. + if (this.query) { + assertChangesAreLive({ + provider: "claude", + patch: options, + current: this.config.providerOptions, + live: ["fast_mode"], + }); + } const providerOptions = validateProviderOptions( "claude", @@ -2821,28 +2830,12 @@ class ClaudeAgentSession implements AgentSession { `Claude fast mode is not available for model '${this.config.model ?? "default"}'`, ); } - const current: ProviderOptions = this.config.providerOptions; - const next: ProviderOptions = providerOptions; - const changedLaunchKeys = Object.keys(options).filter( - (key) => key !== "fast_mode" && !isDeepStrictEqual(current[key] ?? null, next[key] ?? null), - ); - if (changedLaunchKeys.length > 0 && (this.activeForegroundTurnId || this.autonomousTurn)) { - throw new ProviderOptionsValidationError( - "claude", - changedLaunchKeys.map((key) => ({ - path: [key], - message: "cannot change while a turn is running; Claude reads it when a turn starts", - })), - ); - } const fastModeChanged = (providerOptions.fast_mode === true) !== (this.config.providerOptions.fast_mode === true); if (fastModeChanged) { await this.query?.applyFlagSettings({ fastMode: providerOptions.fast_mode === true }); this.cachedRuntimeInfo = null; } - // Every other option is read when the query is built, so it applies from the next turn. - if (changedLaunchKeys.length > 0) this.queryRestartNeeded = true; // Held until the runtime accepts, so a rejected change is retried instead of skipped. Only // this call's keys are applied, over the options as they are now: another patch may have // landed while the runtime was answering.