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 20fcf87003..2713ff0ac6 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -1014,6 +1014,25 @@ describe("ClaudeAgentSession features", () => { ).resolves.toEqual([]); }); + test("opens with Fast off on a model that has none, so unrelated options still apply", 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-sonnet-5", + providerOptions: { fast_mode: true }, + }); + + expect(session.openOptionsPatch).toEqual({ fast_mode: false }); + await expect(session.applyOptions?.({ allowedTools: ["Read"] })).resolves.toBeUndefined(); + await session.close(); + }); + test("passes initial fast mode through Claude flag settings", 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 76d534aebe..846206d3df 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -2397,6 +2397,7 @@ class ClaudeAgentSession implements AgentSession { private pendingFreshSessionId: string | null = null; private recentStderr = ""; private closed = false; + readonly openOptionsPatch: Record = {}; constructor(config: ClaudeAgentConfig, options: ClaudeAgentSessionOptions) { this.config = config; @@ -2440,6 +2441,13 @@ class ClaudeAgentSession implements AgentSession { if (this.currentMode !== "plan") { this.planResumeMode = this.currentMode; } + + // Fast cannot run on this model, so the session opens with it off. Off rather than removed + // keeps a `config.json` default of `fast_mode: true` from filling it back in. + if (config.providerOptions.fast_mode === true && !claudeModelSupportsFastMode(config.model)) { + this.config.providerOptions = { ...config.providerOptions, fast_mode: false }; + this.openOptionsPatch = { fast_mode: false }; + } } get id(): string | null { 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 a70c017b4c..832bd208a8 100644 --- a/packages/server/src/server/agent/providers/omp/agent.test.ts +++ b/packages/server/src/server/agent/providers/omp/agent.test.ts @@ -792,6 +792,22 @@ describe("OMP agent client and session", () => { expect(omp.optionsChanges()).toEqual([{ fast_mode: false }]); }); + test("a Fast change the runtime reports when a turn completes is published", async () => { + const omp = new OmpHarness(); + omp.acceptFastOnNextRuntime(); + await omp.start({ modeId: "full", providerOptions: { fast_mode: true } }); + omp.runtime().state = { ...omp.runtime().state, fastModeEnabled: false, fastModeActive: false }; + + const { completion } = await omp.startPromptUntilProviderIdle("hello", "done", { + isStreaming: false, + isCompacting: false, + }); + await completion; + await omp.requireSession().getRuntimeInfo(); + + expect(omp.optionsChanges()).toEqual([{ fast_mode: false }]); + }); + 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 149e7d475e..a5832135a5 100644 --- a/packages/server/src/server/agent/providers/omp/agent.ts +++ b/packages/server/src/server/agent/providers/omp/agent.ts @@ -1095,7 +1095,6 @@ export class OmpAgentSession implements AgentSession { const old = this.runtimeSession; const oldHostTools = this.hostTools; const previousSessionId = this.state.sessionId; - const fastModeBefore = this.state.fastModeEnabled; const restarted = await this.restartRuntime( this.config.internal ? null : (this.state.sessionFile ?? null), modeId, @@ -1111,12 +1110,11 @@ export class OmpAgentSession implements AgentSession { this.pendingPromptResults.clear(); this.runtimeSession = next; this.hostTools = restarted.hostTools; - this.state = state; + this.installState(state); this.currentModeId = modeId; this.config.modeId = modeId; this.runtimeDead = false; this.attachRuntime(next); - this.publishFastModeChange(fastModeBefore); this.subagentIndex.clear(old); await oldHostTools?.close(); await old.close().catch(() => undefined); @@ -2250,7 +2248,7 @@ export class OmpAgentSession implements AgentSession { } try { const state = await this.runtimeSession.getState(); - this.state = state; + this.installState(state); if (this.closed || !this.activeTurnStarted || this.currentTurnIdForEvent() !== turnId) { // An interrupt settled this turn while the state check was in flight. return; @@ -2267,13 +2265,14 @@ export class OmpAgentSession implements AgentSession { } private async refreshState(): Promise { - const before = this.state.fastModeEnabled; - this.state = await this.runtimeSession.getState(); - this.publishFastModeChange(before); + this.installState(await this.runtimeSession.getState()); } - // The runtime owns Fast: whatever it accepted is what the session now runs with. - private publishFastModeChange(before: boolean | undefined): void { + // The runtime owns Fast: whatever it reports is what the session now runs with, so every state + // read from the runtime is installed here and a difference is published once. + private installState(state: OmpSessionState): void { + const before = this.state.fastModeEnabled; + this.state = state; if (this.state.fastModeEnabled !== before && typeof this.state.fastModeEnabled === "boolean") { this.rememberFastMode(this.state.fastModeEnabled); this.emit({