From 2d9840e31f61562417649bb5ebc59da3090aaefe Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 13 Jul 2026 11:29:52 +0000 Subject: [PATCH 1/5] refactor: remove Anthropic xhigh wire hack, send effort/display via provider options @ai-sdk/anthropic 4.0.11 accepts effort "xhigh" and thinking.display in its Zod schema, and explicit providerOptions.anthropic values flow verbatim into output_config.effort (the SDK's supportsXhighEffort gating applies only to the top-level reasoning CallOption mapping, which Mux does not use). This makes the x-mux-anthropic-effort header + fetch-wrapper body rewrites unnecessary: - getAnthropicEffort is now model-aware: xhigh maps to native "xhigh" only on anthropicSupportsNativeXhigh models, else falls back to "max" as before - buildProviderOptions sets thinking.display="summarized" for native-xhigh adaptive requests directly (mirrors the wrapper's previous gating) - buildRequestHeaders drops the thinkingLevel param and header branch - wrapFetchWithAnthropicCacheControl now only normalizes cache_control Wire-field equivalent for steady-state requests; unblocks mid-turn thinking level changes into/out of native xhigh (effort becomes pure provider options). --- src/common/types/thinking.ts | 36 ++++-- src/common/utils/ai/providerOptions.test.ts | 111 ++++++++---------- src/common/utils/ai/providerOptions.ts | 50 ++------ src/node/services/aiService.ts | 6 +- .../services/providerModelFactory.test.ts | 107 ++++------------- src/node/services/providerModelFactory.ts | 80 ++----------- 6 files changed, 113 insertions(+), 277 deletions(-) diff --git a/src/common/types/thinking.ts b/src/common/types/thinking.ts index a8a4b13b8e..f8139247c4 100644 --- a/src/common/types/thinking.ts +++ b/src/common/types/thinking.ts @@ -168,14 +168,8 @@ export const ANTHROPIC_THINKING_BUDGETS: Record = { /** * Anthropic effort type - matches SDK's AnthropicProviderOptions["effort"]. - * - * Note: Opus 4.7 and Sonnet 5 introduced a native "xhigh" effort level in the API, - * but the SDK's Zod validator still rejects "xhigh". Mux handles this by sending - * "max" through the SDK and rewriting `output_config.effort` to "xhigh" in a fetch - * wrapper for native-xhigh Anthropic models when the user selected the xhigh ThinkingLevel. - * See `wrapFetchWithAnthropicCacheControl` and `buildRequestHeaders`. */ -export type AnthropicEffortLevel = "low" | "medium" | "high" | "max"; +export type AnthropicEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; /** * Anthropic effort parameter mapping (Opus 4.5+) @@ -183,22 +177,38 @@ export type AnthropicEffortLevel = "low" | "medium" | "high" | "max"; * The effort parameter controls how much computational work the model applies. * - Opus 4.5 supports: low, medium, high (policy clamps xhigh → high) * - Opus 4.6 supports: low, medium, high, max (xhigh maps to "max" effort) - * - Opus 4.7+ and Sonnet 5+ support: low, medium, high, xhigh, max (xhigh requires wire override) + * - Opus 4.7+ and Sonnet 5+ support: low, medium, high, xhigh, max (native xhigh) + * + * The table keeps `xhigh: "max"` as the non-native fallback; `getAnthropicEffort` + * upgrades it to the native "xhigh" wire value on models that support it. * - * Because the @ai-sdk/anthropic Zod schema doesn't accept "xhigh" yet, we send - * "max" through the SDK for native-xhigh Anthropic models and rewrite - * `output_config.effort` to "xhigh" in the Anthropic fetch wrapper. + * SDK coupling note: explicit `providerOptions.anthropic.effort` values flow + * verbatim into `output_config.effort` as of @ai-sdk/anthropic 4.0.11 (its Zod + * schema accepts "xhigh"; its `supportsXhighEffort` model gating applies only to + * the SDK's top-level `reasoning` CallOption mapping, which Mux does not use). + * Re-verify this pass-through on major SDK upgrades. */ const ANTHROPIC_EFFORT: Record = { off: "low", low: "low", medium: "medium", high: "high", - xhigh: "max", // SDK placeholder; fetch wrapper rewrites to "xhigh" on native-xhigh models + xhigh: "max", // Non-native fallback; native-xhigh models get "xhigh" via getAnthropicEffort max: "max", }; -export function getAnthropicEffort(level: ThinkingLevel): AnthropicEffortLevel { +/** + * Model-aware Anthropic effort resolution: `xhigh` maps to the native "xhigh" + * wire value only on models that support it (Opus 4.7+, Sonnet 5+, Mythos); + * everywhere else it falls back to "max" per the table above. + */ +export function getAnthropicEffort( + level: ThinkingLevel, + capabilityModel: string +): AnthropicEffortLevel { + if (level === "xhigh" && anthropicSupportsNativeXhigh(capabilityModel)) { + return "xhigh"; + } return ANTHROPIC_EFFORT[level]; } diff --git a/src/common/utils/ai/providerOptions.test.ts b/src/common/utils/ai/providerOptions.test.ts index 93426c502d..64cb6ffdf4 100644 --- a/src/common/utils/ai/providerOptions.test.ts +++ b/src/common/utils/ai/providerOptions.test.ts @@ -16,7 +16,6 @@ import { preserveAnthropic1MContextForFollowUp, resolveProviderOptionsNamespaceKey, ANTHROPIC_1M_CONTEXT_HEADER, - MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER, MUX_WORKSPACE_ID_HEADER, } from "./providerOptions"; @@ -111,7 +110,9 @@ describe("buildProviderOptions - Anthropic", () => { }); }); - for (const model of ["claude-opus-4-6", "claude-sonnet-4-6", "claude-sonnet-5"] as const) { + // Non-native-xhigh adaptive models: xhigh falls back to "max" effort and + // adaptive thinking carries no `display` field. + for (const model of ["claude-opus-4-6", "claude-sonnet-4-6"] as const) { describe(`${model} (adaptive thinking + effort)`, () => { for (const { thinking, expectedThinking, effort } of [ { thinking: "medium", expectedThinking: { type: "adaptive" }, effort: "medium" }, @@ -134,12 +135,49 @@ describe("buildProviderOptions - Anthropic", () => { }); } + // Native-xhigh models (Opus 4.7+ / Sonnet 5+): xhigh is a distinct native + // effort and adaptive thinking requires `display: "summarized"` to return + // thinking content. + for (const model of ["claude-opus-4-7", "claude-sonnet-5"] as const) { + describe(`${model} (native xhigh effort + summarized display)`, () => { + for (const { thinking, expectedThinking, effort } of [ + { + thinking: "medium", + expectedThinking: { type: "adaptive", display: "summarized" }, + effort: "medium", + }, + { + thinking: "xhigh", + expectedThinking: { type: "adaptive", display: "summarized" }, + effort: "xhigh", + }, + { + thinking: "max", + expectedThinking: { type: "adaptive", display: "summarized" }, + effort: "max", + }, + { thinking: "off", expectedThinking: { type: "disabled" }, effort: "low" }, + ] as const) { + test(`maps ${thinking} to ${effort} effort`, () => { + const anthropic = anthropicProviderOptions( + buildProviderOptions(`anthropic:${model}`, thinking) + ); + + expect(anthropic.thinking).toEqual(expectedThinking); + expect(anthropic.effort).toBe(effort); + }); + } + }); + } + describe("claude-fable-5 (Mythos-class: API rejects disabled thinking)", () => { test("maps medium to adaptive thinking like other adaptive models", () => { const anthropic = anthropicProviderOptions( buildProviderOptions("anthropic:claude-fable-5", "medium") ); - expect(anthropic.thinking).toEqual({ type: "adaptive" }); + // Mythos-class models are native-xhigh tier, so adaptive thinking carries + // the summarized display flag. + expect(anthropic.thinking).toEqual({ type: "adaptive", display: "summarized" }); expect(anthropic.effort).toBe("medium"); }); @@ -1590,67 +1628,12 @@ describe("buildRequestHeaders", () => { }); } - describe("Opus 4.7+ xhigh effort override", () => { - for (const { name, model, routeProvider, thinkingLevel, expected } of [ - { - name: "emits override header when thinkingLevel=xhigh for Opus 4.7", - model: "anthropic:claude-opus-4-7", - routeProvider: undefined, - thinkingLevel: "xhigh", - expected: { [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh" }, - }, - { - name: "emits override header for gateway-routed Opus 4.7 with xhigh (passthrough)", - model: "mux-gateway:anthropic/claude-opus-4-7", - routeProvider: "mux-gateway", - thinkingLevel: "xhigh", - expected: { [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh" }, - }, - { - name: "emits override header for Opus 4.8", - model: "anthropic:claude-opus-4-8", - routeProvider: undefined, - thinkingLevel: "xhigh", - expected: { [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh" }, - }, - { - // Sonnet 5 added native xhigh for the Sonnet tier, so it needs the wire rewrite too. - name: "emits override header for Sonnet 5", - model: "anthropic:claude-sonnet-5", - routeProvider: undefined, - thinkingLevel: "xhigh", - expected: { [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh" }, - }, - { - name: "does not emit override header for Opus 4.7 with thinkingLevel=max", - model: "anthropic:claude-opus-4-7", - routeProvider: undefined, - thinkingLevel: "max", - expected: undefined, - }, - { - name: "does not emit override header for Opus 4.6 with xhigh", - // Opus 4.6 maps xhigh -> "max" effort; SDK accepts "max" so no wire rewrite needed. - model: "anthropic:claude-opus-4-6", - routeProvider: undefined, - thinkingLevel: "xhigh", - expected: undefined, - }, - { - name: "does not emit override header for non-passthrough gateway (openrouter)", - // Non-passthrough gateways must not receive this Mux-internal header. - model: "anthropic:claude-opus-4-7", - routeProvider: "openrouter", - thinkingLevel: "xhigh", - expected: undefined, - }, - ] as const) { - test(name, () => { - expect( - buildRequestHeaders(model, undefined, undefined, undefined, routeProvider, thinkingLevel) - ).toEqual(expected); - }); - } + // Native xhigh effort no longer needs a Mux-internal override header: the + // SDK accepts effort "xhigh" directly (see buildProviderOptions tests above), + // so buildRequestHeaders is thinking-level-independent. + test("does not emit any Mux-internal effort header for native-xhigh models", () => { + expect(buildRequestHeaders("anthropic:claude-opus-4-7")).toBeUndefined(); + expect(buildRequestHeaders("anthropic:claude-sonnet-5")).toBeUndefined(); }); describe("openaiProModeAvailable", () => { diff --git a/src/common/utils/ai/providerOptions.ts b/src/common/utils/ai/providerOptions.ts index e439e42c9f..77c7882037 100644 --- a/src/common/utils/ai/providerOptions.ts +++ b/src/common/utils/ai/providerOptions.ts @@ -42,15 +42,6 @@ import { export { resolveProviderOptionsNamespaceKey } from "./models"; export { openaiProModeAvailable } from "./proMode"; -/** - * Request header used to override Anthropic's `output_config.effort` at the - * wire level. The @ai-sdk/anthropic Zod schema rejects "xhigh", so for - * Opus 4.7+ with xhigh ThinkingLevel we send "max" through the SDK and ask the - * fetch wrapper to rewrite it to "xhigh" via this header (which is then stripped - * before the request reaches Anthropic). - */ -export const MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER = "x-mux-anthropic-effort"; - /** * OpenRouter reasoning options * @see https://openrouter.ai/docs/use-cases/reasoning-tokens @@ -281,11 +272,9 @@ export function buildProviderOptions( const usesAdaptiveThinking = isOpus46 || supportsNativeXhigh || isSonnet46; if (isOpus45 || usesAdaptiveThinking) { - // Map to SDK-accepted effort. For Opus 4.7+ / Sonnet 5+ with xhigh ThinkingLevel, the - // SDK gets "max" as a placeholder and the Anthropic fetch wrapper rewrites - // `output_config.effort` to "xhigh" via the X-Mux-Anthropic-Effort header - // (added in buildRequestHeaders). - const effortLevel = getAnthropicEffort(effectiveThinking); + // Model-aware effort: native-xhigh models (Opus 4.7+ / Sonnet 5+ / Mythos) + // get the native "xhigh" wire value; other adaptive models keep xhigh → "max". + const effortLevel = getAnthropicEffort(effectiveThinking, capabilityModel); const budgetTokens = ANTHROPIC_THINKING_BUDGETS[effectiveThinking]; // Opus 4.6+ / Sonnet 4.6 / Sonnet 5: adaptive thinking when on, disabled when off // Opus 4.5: enabled thinking with budgetTokens ceiling (only when not "off") @@ -293,12 +282,19 @@ export function buildProviderOptions( // excludes "off" for them and AIService clamps the effective level via // resolveEffectiveThinkingLevel, so "off" should not reach here — but if a stray // path does, omit `thinking` (API defaults to adaptive) rather than hard-erroring. + // + // Native-xhigh models require `thinking.display: "summarized"` to return + // thinking content on adaptive requests; non-native adaptive models + // (Opus 4.6 / Sonnet 4.6) must not receive `display`. + // See https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking#summarized-thinking const thinking: AnthropicProviderOptions["thinking"] = usesAdaptiveThinking ? effectiveThinking === "off" ? anthropicRejectsDisabledThinking(capabilityModel) ? undefined : { type: "disabled" } - : { type: "adaptive" } + : supportsNativeXhigh + ? { type: "adaptive", display: "summarized" } + : { type: "adaptive" } : budgetTokens > 0 ? { type: "enabled", budgetTokens } : undefined; @@ -309,10 +305,6 @@ export function buildProviderOptions( thinkingLevel: effectiveThinking, }); - // Note: Opus 4.7+ requires `thinking.display: "summarized"` to receive - // thinking content, but the SDK's Zod schema strips unknown keys so we - // can't add it here. The Anthropic fetch wrapper injects `display` on the - // wire for Opus 4.7+ adaptive thinking requests. const anthropicOptions: AnthropicProviderOptions = { disableParallelToolUse: false, sendReasoning: true, @@ -614,8 +606,7 @@ export function buildRequestHeaders( muxProviderOptions?: MuxProviderOptions, workspaceId?: string, providersConfig?: ProvidersConfigMap | null, - routeProvider?: ProviderName, - thinkingLevel?: ThinkingLevel + routeProvider?: ProviderName ): Record | undefined { const headers: Record = {}; @@ -638,22 +629,5 @@ export function buildRequestHeaders( headers["anthropic-beta"] = ANTHROPIC_1M_CONTEXT_HEADER; } - // Native-xhigh Anthropic models use an "xhigh" effort level that the - // @ai-sdk/anthropic Zod schema doesn't accept yet. Emit a Mux-internal header - // so the Anthropic fetch wrapper can rewrite `output_config.effort` to "xhigh" on the wire. - // - // Only emit when the route will pass through our Anthropic fetch wrapper - // (direct Anthropic or passthrough gateways like mux-gateway). Non-passthrough - // gateways (OpenRouter, Bedrock, github-copilot) must not see this header — - // they would forward it externally verbatim and strict proxies may reject it. - if ( - origin === "anthropic" && - routePassesHeaders && - thinkingLevel === "xhigh" && - anthropicSupportsNativeXhigh(modelString) - ) { - headers[MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER] = "xhigh"; - } - return Object.keys(headers).length > 0 ? headers : undefined; } diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 2d915016db..7011f3406e 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -2416,8 +2416,7 @@ export class AIService extends EventEmitter { effectiveMuxProviderOptions, workspaceId, this.providerService.getConfig(), - routeProvider, - effectiveThinkingLevel + routeProvider ); // --- Model parameter overrides from providers.jsonc --- @@ -2729,8 +2728,7 @@ export class AIService extends EventEmitter { effectiveMuxProviderOptions, workspaceId, this.providerService.getConfig(), - next.routeProvider, - nextThinkingLevel + next.routeProvider ); if (pendingRunMetadataId != null) { // Keep DevTools run correlation on fallback requests too. diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index 09e61dcacb..54b3595a9f 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -21,7 +21,6 @@ import { resolveOpenAIWebSocketResponsesUrl, wrapFetchWithAnthropicCacheControl, } from "./providerModelFactory"; -import { MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER } from "@/common/utils/ai/providerOptions"; import { hasLanguageModelCleanup } from "./languageModelCleanup"; import type { DevToolsService } from "./devToolsService"; import { CodexOauthService } from "./codexOauthService"; @@ -1634,82 +1633,44 @@ function parseSentBody(call: CapturedFetchCall): Record { return JSON.parse(call.init.body as string) as Record; } -describe("wrapFetchWithAnthropicCacheControl — Opus 4.7+ / Sonnet 5+ wire transforms", () => { - for (const model of ["claude-opus-4-7", "claude-opus-4-8", "claude-sonnet-5"] as const) { - it(`injects thinking.display=summarized for ${model} adaptive thinking`, async () => { - const { calls, fakeFetch } = createCapturingFetch(); - const wrapped = wrapFetchWithAnthropicCacheControl(fakeFetch); - const body = JSON.stringify({ - model, - thinking: { type: "adaptive" }, - output_config: { effort: "medium" }, - }); - await wrapped("https://api.anthropic.com/v1/messages", { method: "POST", body }); - expect(calls.length).toBe(1); - const sent = parseSentBody(calls[0]); - expect(sent.thinking).toEqual({ type: "adaptive", display: "summarized" }); - }); - } - - it("preserves a user-supplied display value on Opus 4.7", async () => { +// Effort "xhigh" and thinking.display flow through the SDK directly as of +// @ai-sdk/anthropic 4.0.11 (see buildProviderOptions), so the wrapper must NOT +// rewrite reasoning fields — it only normalizes cache_control. +describe("wrapFetchWithAnthropicCacheControl — reasoning fields pass through unchanged", () => { + it("passes native xhigh effort and summarized display through on the direct body", async () => { const { calls, fakeFetch } = createCapturingFetch(); - const wrapped = wrapFetchWithAnthropicCacheControl(fakeFetch); + const wrapped = wrapFetchWithAnthropicCacheControl(fakeFetch, null, { + injectCacheControl: false, + }); const body = JSON.stringify({ model: "claude-opus-4-7", - thinking: { type: "adaptive", display: "omitted" }, + thinking: { type: "adaptive", display: "summarized" }, + output_config: { effort: "xhigh" }, }); await wrapped("https://api.anthropic.com/v1/messages", { method: "POST", body }); - const sent = parseSentBody(calls[0]) as { thinking: { display: string } }; - expect(sent.thinking.display).toBe("omitted"); + expect(calls.length).toBe(1); + const sent = parseSentBody(calls[0]); + expect(sent.thinking).toEqual({ type: "adaptive", display: "summarized" }); + expect(sent.output_config).toEqual({ effort: "xhigh" }); }); - it("rewrites output_config.effort to xhigh when override header is present", async () => { + it("does not inject display or rewrite effort for adaptive requests without them", async () => { const { calls, fakeFetch } = createCapturingFetch(); - const wrapped = wrapFetchWithAnthropicCacheControl(fakeFetch); - const body = JSON.stringify({ - model: "claude-opus-4-7", - thinking: { type: "adaptive" }, - output_config: { effort: "max" }, - }); - await wrapped("https://api.anthropic.com/v1/messages", { - method: "POST", - body, - headers: { [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh" }, + const wrapped = wrapFetchWithAnthropicCacheControl(fakeFetch, null, { + injectCacheControl: false, }); - const sent = parseSentBody(calls[0]) as { output_config: { effort: string } }; - expect(sent.output_config.effort).toBe("xhigh"); - // Override header is stripped before forwarding - const outHeaders = new Headers(calls[0].init.headers); - expect(outHeaders.get(MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER)).toBeNull(); - }); - - it("does not inject display for Opus 4.6 adaptive thinking", async () => { - const { calls, fakeFetch } = createCapturingFetch(); - const wrapped = wrapFetchWithAnthropicCacheControl(fakeFetch); const body = JSON.stringify({ model: "claude-opus-4-6", thinking: { type: "adaptive" }, + output_config: { effort: "max" }, }); await wrapped("https://api.anthropic.com/v1/messages", { method: "POST", body }); const sent = parseSentBody(calls[0]); expect(sent.thinking).toEqual({ type: "adaptive" }); + expect(sent.output_config).toEqual({ effort: "max" }); }); - it("does not inject display when thinking is disabled", async () => { - const { calls, fakeFetch } = createCapturingFetch(); - const wrapped = wrapFetchWithAnthropicCacheControl(fakeFetch); - const body = JSON.stringify({ - model: "claude-opus-4-7", - thinking: { type: "disabled" }, - }); - await wrapped("https://api.anthropic.com/v1/messages", { method: "POST", body }); - const sent = parseSentBody(calls[0]); - expect(sent.thinking).toEqual({ type: "disabled" }); - }); -}); - -describe("wrapFetchWithAnthropicCacheControl — gateway (AI SDK) body shape", () => { - it("injects display=summarized into providerOptions.anthropic.thinking for Opus 4.7 via gateway", async () => { + it("passes gateway (AI SDK) body providerOptions through unchanged", async () => { const { calls, fakeFetch } = createCapturingFetch(); const wrapped = wrapFetchWithAnthropicCacheControl(fakeFetch, null, { injectCacheControl: false, @@ -1717,7 +1678,7 @@ describe("wrapFetchWithAnthropicCacheControl — gateway (AI SDK) body shape", ( const body = JSON.stringify({ prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], providerOptions: { - anthropic: { thinking: { type: "adaptive" }, effort: "medium" }, + anthropic: { thinking: { type: "adaptive", display: "summarized" }, effort: "xhigh" }, }, }); await wrapped("https://gateway.example.com/v1/language-model", { @@ -1726,36 +1687,12 @@ describe("wrapFetchWithAnthropicCacheControl — gateway (AI SDK) body shape", ( headers: { "ai-model-id": "anthropic/claude-opus-4-7" }, }); const sent = parseSentBody(calls[0]) as { - providerOptions: { anthropic: { thinking: unknown } }; + providerOptions: { anthropic: { thinking: unknown; effort: string } }; }; expect(sent.providerOptions.anthropic.thinking).toEqual({ type: "adaptive", display: "summarized", }); - }); - - it("rewrites providerOptions.anthropic.effort to xhigh via gateway", async () => { - const { calls, fakeFetch } = createCapturingFetch(); - const wrapped = wrapFetchWithAnthropicCacheControl(fakeFetch, null, { - injectCacheControl: false, - }); - const body = JSON.stringify({ - prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], - providerOptions: { - anthropic: { thinking: { type: "adaptive" }, effort: "max" }, - }, - }); - await wrapped("https://gateway.example.com/v1/language-model", { - method: "POST", - body, - headers: { - "ai-model-id": "anthropic/claude-opus-4-7", - [MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER]: "xhigh", - }, - }); - const sent = parseSentBody(calls[0]) as { - providerOptions: { anthropic: { effort: string } }; - }; expect(sent.providerOptions.anthropic.effort).toBe("xhigh"); }); }); diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index 959e2e5620..d7404556f9 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -3,7 +3,7 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import type { XaiProviderOptions } from "@ai-sdk/xai"; import { fromNodeProviderChain } from "@aws-sdk/credential-providers"; import { wrapLanguageModel, type LanguageModel } from "ai"; -import { anthropicSupportsNativeXhigh, type ThinkingLevel } from "@/common/types/thinking"; +import type { ThinkingLevel } from "@/common/types/thinking"; import { Ok, Err } from "@/common/types/result"; import type { Result } from "@/common/types/result"; import type { SendMessageError } from "@/common/types/errors"; @@ -49,10 +49,7 @@ import { } from "@/node/services/languageModelCleanup"; import { createOpenAIWebSocketTransportFetch } from "@/node/services/openAIWebSocketTransportFetch"; import { log } from "@/node/services/log"; -import { - MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER, - resolveProviderOptionsNamespaceKey, -} from "@/common/utils/ai/providerOptions"; +import { resolveProviderOptionsNamespaceKey } from "@/common/utils/ai/providerOptions"; import { resolveRoute, type RouteContext } from "@/common/routing"; import { getExplicitGatewayPrefix as getExplicitGatewayProvider, @@ -337,67 +334,9 @@ export function wrapFetchWithAnthropicCacheControl( return baseFetch(input, init); } - // Detect and strip Mux-internal headers before forwarding. - const incomingHeaders = new Headers(init.headers); - const effortOverride = incomingHeaders.get(MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER); - let headersModified = false; - if (effortOverride != null) { - incomingHeaders.delete(MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER); - headersModified = true; - } - try { const json = JSON.parse(init.body) as Record; - // Native-xhigh adaptive-thinking models (Opus 4.7+ and Sonnet 5+) require - // `thinking.display: "summarized"` to return thinking content in the response. - // Inject it on the wire for any adaptive thinking request targeting those models. - // See https://platform.claude.com/docs/en/build-with-claude/adaptive-thinking#summarized-thinking - // - // Two body shapes to handle: - // - Direct Anthropic API: `{ model, thinking: { type: "adaptive" }, output_config }` - // - AI SDK gateway (mux-gateway): `{ prompt, providerOptions: { anthropic: { thinking } } }` - // with the model exposed via the ai-model-id header. - const directModel = typeof json.model === "string" ? json.model : ""; - const headerModelId = incomingHeaders.get("ai-model-id") ?? ""; - // Reuse the shared native-xhigh detector so the wire-level regex stays in - // one place (src/common/types/thinking.ts) — it also normalizes provider - // prefixes (e.g., `anthropic/claude-opus-4-7`, `anthropic/claude-sonnet-5`). - const targetsNativeXhighModel = [directModel, headerModelId].some( - anthropicSupportsNativeXhigh - ); - - const directThinking = isRecord(json.thinking) ? json.thinking : undefined; - const providerOpts = isRecord(json.providerOptions) ? json.providerOptions : undefined; - const anthropicOpts = - providerOpts && isRecord(providerOpts.anthropic) ? providerOpts.anthropic : undefined; - const gatewayThinking = - anthropicOpts && isRecord(anthropicOpts.thinking) ? anthropicOpts.thinking : undefined; - - if (targetsNativeXhighModel) { - if (directThinking?.type === "adaptive") { - directThinking.display ??= "summarized"; - log.debug("Anthropic wrapper: injected thinking.display=summarized (direct body)"); - } - if (gatewayThinking?.type === "adaptive") { - gatewayThinking.display ??= "summarized"; - log.debug("Anthropic wrapper: injected thinking.display=summarized (gateway body)"); - } - } - - // Native-xhigh Anthropic models use an "xhigh" effort level. The @ai-sdk/anthropic - // Zod schema still rejects "xhigh", so providerOptions sends "max" through - // the SDK and we rewrite effort here based on the Mux-internal override - // header — for both direct and gateway body shapes. - if (effortOverride) { - if (isRecord(json.output_config)) { - json.output_config.effort = effortOverride; - } - if (anthropicOpts) { - anthropicOpts.effort = effortOverride; - } - } - // Inject cache_control on the last tool if tools array exists. // If the SDK already populated cache_control, preserve it but override ttl // when a higher-level cacheTtl is configured. @@ -444,15 +383,11 @@ export function wrapFetchWithAnthropicCacheControl( // Update body with modified JSON const newBody = JSON.stringify(json); - const outHeaders = incomingHeaders; + const outHeaders = new Headers(init.headers); outHeaders.delete("content-length"); // Body size changed return baseFetch(input, { ...init, headers: outHeaders, body: newBody }); } catch { - // If JSON parsing fails we can't touch the body, but we still need to - // strip Mux-internal headers if any were present so Anthropic doesn't see them. - if (headersModified) { - return baseFetch(input, { ...init, headers: incomingHeaders }); - } + // If JSON parsing fails we can't touch the body; forward unchanged. return baseFetch(input, init); } }; @@ -1202,8 +1137,7 @@ export class ProviderModelFactory { // Use getProviderFetch to preserve any user-configured custom fetch (e.g., proxies) const baseFetch = getProviderFetch(providerConfig); const disableBeta = muxProviderOptions?.anthropic?.disableBetaFeatures === true; - // Always wrap to apply Opus 4.7+ wire-level transforms (display + effort - // override); skip cache_control injection when beta features are off. + // Wrap for cache_control normalization; skip injection when beta features are off. const fetchWithCacheControl = wrapFetchWithAnthropicCacheControl( baseFetch, effectiveAnthropicCacheTtl, @@ -1716,8 +1650,8 @@ export class ProviderModelFactory { const baseFetch = getProviderFetch(providerConfig); const isAnthropicModel = modelId.startsWith("anthropic/"); const disableBeta = muxProviderOptions?.anthropic?.disableBetaFeatures === true; - // For Anthropic models via gateway, always wrap to apply Opus 4.7+ wire - // transforms; skip cache_control injection when beta features are off. + // For Anthropic models via gateway, wrap for cache_control normalization; + // skip injection when beta features are off. const fetchWithCacheControl = isAnthropicModel ? wrapFetchWithAnthropicCacheControl(baseFetch, effectiveAnthropicCacheTtl, { injectCacheControl: !disableBeta, From 624e3dda8f0d7632dd63e98e22715a877336a3ae Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 13 Jul 2026 11:48:34 +0000 Subject: [PATCH 2/5] feat: apply mid-turn thinking-level changes at the next model step Changing the thinking slider while a turn is streaming now takes effect at the next model step of the active stream (a step = one provider request), instead of only applying to future turns: - AgentSession owns a per-turn ActiveTurnThinkingOverride holder created at durable turn acceptance (sendMessage/resumeStream) and cleared on turn end (setTurnPhase -> IDLE), so changes during the PREPARING window apply to the turn's first provider request. - New workspace.setActiveTurnThinkingLevel ORPC route writes the pending level into the holder; accepted:false when idle (persisted settings cover the next turn as before). - AIService builds a rebuild closure that re-runs the effective-level pipeline (floor clamp -> resolveEffectiveThinkingLevel -> buildProviderOptions -> providers.jsonc extras merge) for the stream's model; skips no-ops and the xAI grok-4-1-fast off<->on model-instance swap. - StreamManager consumes the pending override in prepareStep (runs before every step incl. step 1), replacing request.providerOptions content in place (deep-merge cannot delete keys) and returning the rebuilt options as defense in depth; applied levels flow into streamInfo.thinkingLevel for partials and final metadata. - Model fallback folds pending/applied overrides into the fallback baseline and rebinds the rebuild closure to the fallback model; empty-output and previousResponseId retries carry the holder via streamInfo.request reuse. - ThinkingContext.setThinkingLevel fires the new route best-effort alongside the existing persistence. --- src/browser/contexts/ThinkingContext.tsx | 12 ++ src/common/orpc/schemas/api.ts | 11 ++ src/common/utils/thinking/policy.ts | 19 ++ src/node/orpc/router.ts | 9 + src/node/services/agentSession.ts | 62 ++++++- src/node/services/aiService.ts | 214 +++++++++++++++++++---- src/node/services/streamManager.ts | 172 ++++++++++++++++-- src/node/services/thinkingOverride.ts | 40 +++++ src/node/services/workspaceService.ts | 21 +++ 9 files changed, 511 insertions(+), 49 deletions(-) create mode 100644 src/node/services/thinkingOverride.ts diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 9dfea68d0e..864e36e662 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -226,12 +226,24 @@ export const ThinkingProvider: React.FC = (props) => { thinkingLevel: level, reasoningMode: getCurrentReasoningMode(), }); + // Mid-turn change: also request the new level for the active turn's next + // model step. No streaming gate here — the backend no-ops cheaply when + // idle, and gating on store state would break non-workspace mounts. + if (props.workspaceId) { + api?.workspace + .setActiveTurnThinkingLevel({ workspaceId: props.workspaceId, thinkingLevel: level }) + .catch(() => { + // Best-effort: no active stream / transient IPC failure. + }); + } }, [ + api, defaultModel, getCurrentReasoningMode, metadataSettings.model, persistAgentAiSettings, + props.workspaceId, scopeId, setThinkingLevelInternal, ] diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 7e759783fa..3c55ef5eb3 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -1202,6 +1202,17 @@ export const workspace = { }), output: ResultSchema(z.void(), z.string()), }, + // Mid-turn thinking change: request that the active turn's NEXT model step + // uses this level. `accepted: false` (success) = no turn active — persisted + // settings already cover the next turn. `accepted: true` = the level applies + // to the current turn's next step if one occurs (expires silently otherwise). + setActiveTurnThinkingLevel: { + input: z.object({ + workspaceId: z.string(), + thinkingLevel: ThinkingLevelSchema, + }), + output: ResultSchema(z.object({ accepted: z.boolean() }), z.string()), + }, preflightArchive: { input: z.object({ workspaceId: z.string() }), output: ResultSchema(ArchivePreflightResultSchema, z.string()), diff --git a/src/common/utils/thinking/policy.ts b/src/common/utils/thinking/policy.ts index 874a679d7a..efe58b78ce 100644 --- a/src/common/utils/thinking/policy.ts +++ b/src/common/utils/thinking/policy.ts @@ -347,6 +347,25 @@ export function enforceThinkingPolicy( return closest; } +/** + * Whether a thinking-level transition would swap the underlying model instance + * for xAI's grok-4-1-fast (providerModelFactory maps off → non-reasoning and + * non-off → reasoning variants at model creation). Such transitions cannot be + * applied to an in-flight stream via provider options, so mid-turn overrides + * skip them (the persisted setting still applies to the next turn). + */ +export function isXaiGrokFastVariantSwap( + modelString: string, + currentLevel: ThinkingLevel, + nextLevel: ThinkingLevel +): boolean { + const [provider, modelId] = modelString.trim().toLowerCase().split(":", 2); + if (provider !== "xai" || modelId !== "grok-4-1-fast") { + return false; + } + return (currentLevel === "off") !== (nextLevel === "off"); +} + /** * Resolve a parsed thinking input to a concrete ThinkingLevel for a given model. * diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 266e099a6a..79e791b21a 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -4095,6 +4095,15 @@ export const router = (authToken?: string) => { input.aiSettings ); }), + setActiveTurnThinkingLevel: t + .input(schemas.workspace.setActiveTurnThinkingLevel.input) + .output(schemas.workspace.setActiveTurnThinkingLevel.output) + .handler(({ context, input }) => { + return context.workspaceService.setActiveTurnThinkingLevel( + input.workspaceId, + input.thinkingLevel + ); + }), updateTitle: t .input(schemas.workspace.updateTitle.input) .output(schemas.workspace.updateTitle.output) diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index d31d0de360..601a7665a5 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -60,6 +60,7 @@ import { type ThinkingLevel, } from "@/common/types/thinking"; import { enforceThinkingPolicy, resolveMinimumThinkingLevel } from "@/common/utils/thinking/policy"; +import type { ActiveTurnThinkingOverride } from "@/node/services/thinkingOverride"; import { createMuxMessage, dedupeAgentSkillRefs, @@ -359,6 +360,14 @@ export class AgentSession { private disposed = false; private turnPhase: TurnPhase = TurnPhase.IDLE; private activePreparedTurnAbortController: AbortController | null = null; + /** + * Per-turn holder for mid-turn thinking-level overrides. Created when a turn + * is durably accepted (before any await that could let the renderer's slider + * route race in), threaded by reference into StreamManager, and cleared when + * the turn ends (setTurnPhase → IDLE). Null while idle: the slider route then + * reports accepted:false and persisted settings cover the next turn. + */ + private activeTurnThinkingOverride: ActiveTurnThinkingOverride | null = null; // When true, stream-end skips auto-flushing queued messages so an edit can truncate first. private deferQueuedFlushUntilAfterEdit = false; // Provider-executed tools (for example native web_search/web_fetch) complete inside one @@ -2806,6 +2815,13 @@ export class AgentSession { return Ok(undefined); } + // Turn durably accepted + options finalized: open the mid-turn thinking + // override window BEFORE the user-message emit / onAccepted / any further + // await, so a slider change during PREPARING (runtime warmup, model + // creation) lands in the holder the stream's prepareStep will read. + const turnThinkingOverride: ActiveTurnThinkingOverride = {}; + this.activeTurnThinkingOverride = turnThinkingOverride; + // Emit snapshots only for immediately-sent turns. On on-send compaction paths, // snapshots are deferred with the follow-up message to avoid duplicate ephemeral // snapshot rows that were never persisted. @@ -2848,6 +2864,11 @@ export class AgentSession { try { await internal?.onAccepted?.(); } catch (error) { + // Pre-stream failure: identity-guarded so a replacement turn's holder + // (created while this one unwound) is never cleared by mistake. + if (this.activeTurnThinkingOverride === turnThinkingOverride) { + this.activeTurnThinkingOverride = null; + } return Err(createUnknownSendMessageError(getErrorMessage(error))); } @@ -2887,7 +2908,8 @@ export class AgentSession { undefined, agentInitiated, preparedTurnAbortController.signal, - goalKind + goalKind, + turnThinkingOverride ); } finally { // Success should advance via stream events; if startup never emitted any, don't leave the @@ -2963,6 +2985,10 @@ export class AgentSession { // accept its options, even if startup fails before the stream fully begins. this.setAutoRetryResumeState(optionsForStream, internal?.agentInitiated, internal?.goalKind); this.setTurnPhase(TurnPhase.PREPARING); + // Open the mid-turn thinking override window for the resumed turn (after + // setTurnPhase(PREPARING), which clears the holder on the IDLE transition). + const turnThinkingOverride: ActiveTurnThinkingOverride = {}; + this.activeTurnThinkingOverride = turnThinkingOverride; try { // Must await here so the finally block runs after streaming completes, // not immediately when the Promise is returned. @@ -2973,7 +2999,8 @@ export class AgentSession { undefined, internal?.agentInitiated, undefined, - internal?.goalKind + internal?.goalKind, + turnThinkingOverride ); if (!result.success) { return result; @@ -3507,7 +3534,11 @@ export class AgentSession { disablePostCompactionAttachments?: boolean, agentInitiated?: boolean, abortSignal?: AbortSignal, - goalKind?: GoalSyntheticMessageKind + goalKind?: GoalSyntheticMessageKind, + // Session-owned per-turn holder for mid-turn thinking changes. Passed + // explicitly (not read from the field) so a preempted turn can never pick + // up its replacement's holder. Absent for internal retry paths. + activeTurnThinkingOverride?: ActiveTurnThinkingOverride ): Promise> { const isStartupAbortRequested = (): boolean => abortSignal?.aborted === true; @@ -3692,6 +3723,10 @@ export class AgentSession { disableWorkspaceAgents: options?.disableWorkspaceAgents, hasQueuedMessages: this.hasQueuedMessages.bind(this), openaiTruncationModeOverride, + // Mid-turn thinking overrides clamp against the same floor as the + // send-time level above (single source of truth for the floor). + minThinkingLevel, + activeTurnThinkingOverride, }); if (!streamResult.success) { @@ -5014,6 +5049,10 @@ export class AgentSession { this.emitStreamLifecycleIfChanged(); if (next === TurnPhase.IDLE) { + // Turn ended: expire any mid-turn thinking override. Safe unconditionally + // because a replacement turn (e.g. an edit) only creates its holder after + // the preempted turn has already been transitioned to IDLE. + this.activeTurnThinkingOverride = null; const waiters = this.idleWaiters; this.idleWaiters = []; for (const resolve of waiters) { @@ -5026,6 +5065,23 @@ export class AgentSession { return this.turnPhase !== TurnPhase.IDLE; } + /** + * Mid-turn thinking change: request that the active turn's next model step + * uses `level`. Returns accepted:false when no turn is active — the caller + * already persisted the setting, which covers the next turn. Last write wins + * across consecutive calls; the pending value expires silently if the turn + * ends before another model step occurs. + */ + setActiveTurnThinkingLevel(level: ThinkingLevel): { accepted: boolean } { + this.assertNotDisposed("setActiveTurnThinkingLevel"); + const holder = this.activeTurnThinkingOverride; + if (!holder) { + return { accepted: false }; + } + holder.pending = level; + return { accepted: true }; + } + isPreparingTurn(): boolean { return this.turnPhase === TurnPhase.PREPARING; } diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 7011f3406e..377e7d6297 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -116,9 +116,14 @@ import { } from "@/common/types/thinking"; import { enforceThinkingPolicy, + isXaiGrokFastVariantSwap, resolveEffectiveThinkingLevel, resolveMinimumThinkingLevel, } from "@/common/utils/thinking/policy"; +import type { + ActiveTurnThinkingOverride, + RebuildProviderOptionsForThinkingLevel, +} from "@/node/services/thinkingOverride"; import type { ErrorEvent, @@ -269,6 +274,19 @@ export interface StreamMessageOptions { hasQueuedMessages?: (dispatchMode?: "tool-end" | "turn-end") => boolean; muxMetadata?: MuxMessageMetadata; openaiTruncationModeOverride?: "auto" | "disabled"; + /** + * Model floor already resolved by AgentSession (config.json + * minThinkingLevelByModel → resolveMinimumThinkingLevel). Passed down so + * mid-turn overrides clamp against the same floor as the send-time level; + * internal callers may omit it (re-resolved from defaults). + */ + minThinkingLevel?: ThinkingLevel; + /** + * Session-owned per-turn holder for mid-turn thinking-level overrides. + * When absent (compaction, sub-agent paths), the feature is inert for the + * stream. See src/node/services/thinkingOverride.ts. + */ + activeTurnThinkingOverride?: ActiveTurnThinkingOverride; } /** @@ -1027,6 +1045,8 @@ export class AIService extends EventEmitter { hasQueuedMessages, openaiTruncationModeOverride, muxMetadata, + minThinkingLevel: providedMinThinkingLevel, + activeTurnThinkingOverride, } = opts; // Support interrupts during startup (before StreamManager emits stream-start). // We register an AbortController up-front and let stopStream() abort it. @@ -2432,21 +2452,29 @@ export class AIService extends EventEmitter { // Recursive merge within the provider namespace preserves non-conflicting nested // subfields (e.g., user reasoning.max_tokens alongside Mux reasoning.enabled). // Mux-built values win on leaf conflicts for safety of thinking/reasoning/cache. + // Shared by the initial build and mid-turn thinking-level rebuilds so both + // produce identically-shaped options. const providerOptionsNamespaceKey = resolveProviderOptionsNamespaceKey( canonicalProviderName, routeProvider ); - const muxProviderNamespace = (providerOptions as Record)?.[ - providerOptionsNamespaceKey - ]; - const mergedProviderOptions = resolvedOverrides.providerExtras - ? { - ...providerOptions, - [providerOptionsNamespaceKey]: isPlainObject(muxProviderNamespace) - ? mergeProviderExtrasUnderMux(resolvedOverrides.providerExtras, muxProviderNamespace) - : resolvedOverrides.providerExtras, - } - : providerOptions; + const mergeModelParameterExtras = ( + builtOptions: Record + ): Record => { + if (!resolvedOverrides.providerExtras) { + return builtOptions; + } + const muxProviderNamespace = builtOptions[providerOptionsNamespaceKey]; + return { + ...builtOptions, + [providerOptionsNamespaceKey]: isPlainObject(muxProviderNamespace) + ? mergeProviderExtrasUnderMux(resolvedOverrides.providerExtras, muxProviderNamespace) + : resolvedOverrides.providerExtras, + }; + }; + const mergedProviderOptions = mergeModelParameterExtras( + providerOptions as Record + ); recordStartupPhaseTiming("buildRequestConfigMs", buildRequestConfigStartedAt); @@ -2457,6 +2485,63 @@ export class AIService extends EventEmitter { ); } + // --- Mid-turn thinking-level override support --- + // Floor resolved by AgentSession when present; internal callers fall back + // to the model default so clamping never loosens below policy. + const minThinkingLevel = + providedMinThinkingLevel ?? + resolveMinimumThinkingLevel(modelString, undefined, this.providerService.getConfig()); + // Rebuilds provider options for a new level using the exact same pipeline + // as the initial build (policy clamp → resolveEffectiveThinkingLevel → + // buildProviderOptions → providers.jsonc extras merge). Consumed by + // StreamManager's prepareStep; `null` ⇒ skip (no-op or model-swap level). + const currentEffectiveLevelRef = { current: effectiveThinkingLevel }; + const rebuildProviderOptionsForThinkingLevel: RebuildProviderOptionsForThinkingLevel = ( + level + ) => { + const clamped = enforceThinkingPolicy( + modelString, + level, + minThinkingLevel, + this.providerService.getConfig() + ); + const effective = resolveEffectiveThinkingLevel( + modelString, + clamped, + this.providerService.getConfig() + ); + if (effective === currentEffectiveLevelRef.current) { + return null; + } + // off ↔ non-off on grok-4-1-fast selects a different model instance — + // not expressible via provider options on the in-flight stream. + if ( + isXaiGrokFastVariantSwap( + canonicalModelString, + currentEffectiveLevelRef.current, + effective + ) + ) { + return null; + } + const rebuilt = buildProviderOptions( + modelString, + effective, + providerRequestMessages, + (id) => this.streamManager.isResponseIdLost(id), + effectiveMuxProviderOptions, + workspaceId, + truncationMode, + this.providerService.getConfig(), + routeProvider, + promptCacheScope, + reasoningMode + ); + const merged = mergeModelParameterExtras(rebuilt as Record); + currentEffectiveLevelRef.current = effective; + return { effectiveLevel: effective, providerOptions: merged }; + }; + // Debug dump: Log the complete LLM request when MUX_DEBUG_LLM_REQUEST is set if (process.env.MUX_DEBUG_LLM_REQUEST === "1") { log.info( @@ -2576,16 +2661,19 @@ export class AIService extends EventEmitter { // clamped level may violate the next model's policy/floor (the // providerOptions builders require a policy-valid level, e.g. an // "off" source level on a fixed-effort model like gpt-5-pro). + // A mid-turn thinking override folded in by StreamManager wins + // over the send-time level. + const nextMinThinkingLevel = resolveMinimumThinkingLevel( + nextModelString, + this.config.loadConfigOrDefault().minThinkingLevelByModel?.[ + normalizeToCanonical(nextModelString) + ], + this.providerService.getConfig() + ); const nextThinkingLevel = enforceThinkingPolicy( nextModelString, - effectiveThinkingLevel, - resolveMinimumThinkingLevel( - nextModelString, - this.config.loadConfigOrDefault().minThinkingLevelByModel?.[ - normalizeToCanonical(nextModelString) - ], - this.providerService.getConfig() - ), + prepareOptions?.thinkingLevelOverride ?? effectiveThinkingLevel, + nextMinThinkingLevel, this.providerService.getConfig() ); @@ -2748,20 +2836,76 @@ export class AIService extends EventEmitter { next.canonicalProviderName, next.routeProvider ); - const nextMuxNamespace = (nextProviderOptions as Record)?.[ - nextNamespaceKey - ]; - const nextMergedProviderOptions = nextOverrides.providerExtras - ? { - ...nextProviderOptions, - [nextNamespaceKey]: isPlainObject(nextMuxNamespace) - ? mergeProviderExtrasUnderMux( - nextOverrides.providerExtras, - nextMuxNamespace - ) - : nextOverrides.providerExtras, + // Mirrors mergeModelParameterExtras for the fallback model; + // shared by this baseline build and mid-turn rebuilds below. + const mergeNextModelParameterExtras = ( + builtOptions: Record + ): Record => { + if (!nextOverrides.providerExtras) { + return builtOptions; + } + const nextMuxNamespace = builtOptions[nextNamespaceKey]; + return { + ...builtOptions, + [nextNamespaceKey]: isPlainObject(nextMuxNamespace) + ? mergeProviderExtrasUnderMux( + nextOverrides.providerExtras, + nextMuxNamespace + ) + : nextOverrides.providerExtras, + }; + }; + const nextMergedProviderOptions = mergeNextModelParameterExtras( + nextProviderOptions as Record + ); + + // Rebuild closure bound to the FALLBACK model so mid-turn + // thinking changes keep working after the hop. + const nextCurrentEffectiveLevelRef = { current: nextThinkingLevel }; + const rebuildNextProviderOptionsForThinkingLevel: RebuildProviderOptionsForThinkingLevel = + (level) => { + const clamped = enforceThinkingPolicy( + next.canonicalModelString, + level, + nextMinThinkingLevel, + this.providerService.getConfig() + ); + const effective = resolveEffectiveThinkingLevel( + next.canonicalModelString, + clamped, + this.providerService.getConfig() + ); + if (effective === nextCurrentEffectiveLevelRef.current) { + return null; + } + if ( + isXaiGrokFastVariantSwap( + next.canonicalModelString, + nextCurrentEffectiveLevelRef.current, + effective + ) + ) { + return null; } - : nextProviderOptions; + const rebuilt = buildProviderOptions( + next.canonicalModelString, + effective, + nextProviderRequestMessages, + (id) => this.streamManager.isResponseIdLost(id), + effectiveMuxProviderOptions, + workspaceId, + truncationMode, + this.providerService.getConfig(), + next.routeProvider, + promptCacheScope, + reasoningMode + ); + const merged = mergeNextModelParameterExtras( + rebuilt as Record + ); + nextCurrentEffectiveLevelRef.current = effective; + return { effectiveLevel: effective, providerOptions: merged }; + }; return Ok({ model: next.model, @@ -2774,6 +2918,8 @@ export class AIService extends EventEmitter { callSettingsOverrides: nextOverrides.standard, anthropicCacheTtl: effectiveMuxProviderOptions.anthropic?.cacheTtl ?? undefined, thinkingLevel: nextThinkingLevel, + rebuildProviderOptionsForThinkingLevel: + rebuildNextProviderOptionsForThinkingLevel, initialMetadataPatch: { routedThroughGateway: next.routedThroughGateway, ...(next.routeProvider != null ? { routeProvider: next.routeProvider } : {}), @@ -2841,7 +2987,9 @@ export class AIService extends EventEmitter { : undefined, runtimeTempDir, modelFallback, - toolSearchRuntime?.state + toolSearchRuntime?.state, + activeTurnThinkingOverride, + rebuildProviderOptionsForThinkingLevel ); recordStartupPhaseTiming("startStreamMs", startStreamStartedAt); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 9fdb103e64..3ae4f72938 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -1,3 +1,4 @@ +import assert from "node:assert"; import { EventEmitter } from "events"; import * as path from "path"; import { PlatformPaths } from "@/common/utils/paths"; @@ -14,6 +15,7 @@ import { RetryError, } from "ai"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; +import type { ProviderOptions } from "@ai-sdk/provider-utils"; import type { Result } from "@/common/types/result"; import assert from "@/common/utils/assert"; import { Ok, Err } from "@/common/types/result"; @@ -32,6 +34,10 @@ import type { import type { SendMessageError, StreamErrorType } from "@/common/types/errors"; import type { MuxMetadata, MuxMessage, PersistedToolModelUsage } from "@/common/types/message"; import type { ThinkingLevel } from "@/common/types/thinking"; +import type { + ActiveTurnThinkingOverride, + RebuildProviderOptionsForThinkingLevel, +} from "@/node/services/thinkingOverride"; import type { NestedToolCall } from "@/common/orpc/schemas/message"; import type { ProvidersConfigMap } from "@/common/orpc/types"; import { @@ -190,6 +196,19 @@ interface StreamRequestConfig { * `activeTools`. Absent when the feature is inactive. */ toolSearchState?: ToolSearchStreamState; + /** + * Mid-turn thinking override holder — the SESSION'S object, by reference + * (never created inside StreamManager: a locally-created object would be + * invisible to AgentSession.setActiveTurnThinkingLevel). prepareStep + * consumes `pending` before every model step. + */ + thinkingOverrideState?: ActiveTurnThinkingOverride; + /** + * Closure built by AIService that re-runs the effective-level pipeline + * (policy clamp + resolveEffectiveThinkingLevel) and rebuilds provider + * options for the stream's model. `null` ⇒ not applicable / no-op. + */ + rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel; } /** @@ -220,6 +239,12 @@ export interface PreparedModelFallback { thinkingLevel?: string; /** Route attribution corrections (routedThroughGateway, routeProvider, costsIncluded). */ initialMetadataPatch?: Partial; + /** + * Rebuild closure bound to the FALLBACK model so mid-turn thinking changes + * keep working after a fallback hop (the source model's closure would build + * options for the wrong model). + */ + rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel; } export interface ModelFallbackPrepareOptions { @@ -229,6 +254,12 @@ export interface ModelFallbackPrepareOptions { * receives it so provider-message preparation cannot mutate live UI parts. */ continuation?: { assistantMessage: MuxMessage }; + /** + * Mid-turn thinking level to fold into the fallback's baseline: pending (not + * yet applied) or already-applied override from the refused stream. The + * fallback prepare re-clamps it for the next model. + */ + thinkingLevelOverride?: ThinkingLevel; } export interface ModelFallbackOptions { @@ -1573,9 +1604,16 @@ export class StreamManager extends EventEmitter { onChunk?: StreamTextOnChunk, onStepMessages?: (messages: ModelMessage[]) => void, toolSearchState?: ToolSearchStreamState, - onToolExecutionStart?: (toolCallId: string) => void + onToolExecutionStart?: (toolCallId: string) => void, + thinkingOverrideState?: ActiveTurnThinkingOverride, + rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel ): StreamRequestConfig { - const finalProviderOptions = providerOptions; + // Mid-turn thinking overrides mutate providerOptions IN PLACE (the SDK's + // per-step deep-merge reads the object passed at streamText() time, so + // identity must stay stable). An initially-undefined value would make that + // mutation unobservable — normalize to a guaranteed mutable object. + const finalProviderOptions = + rebuildProviderOptionsForThinkingLevel != null ? (providerOptions ?? {}) : providerOptions; // Apply cache control for Anthropic models let finalMessages = messages; @@ -1648,6 +1686,8 @@ export class StreamManager extends EventEmitter { onStepMessages, toolPolicy, toolSearchState, + thinkingOverrideState, + rebuildProviderOptionsForThinkingLevel, }; } @@ -1703,6 +1743,57 @@ export class StreamManager extends EventEmitter { ]; } + /** + * Consume a pending mid-turn thinking-level override for the next step. + * + * Consume-once: `pending` is always cleared (a failed/no-op application must + * not retry on every subsequent step). On success the CONTENT of + * `request.providerOptions` is replaced in place — object identity is + * preserved because the SDK's per-step deep-merge reads the reference passed + * at streamText() time, and deep-merge alone cannot delete keys (e.g. the + * Anthropic `thinking` object when moving to "off"). + * + * Returns the rebuilt options (for the prepareStep return value) or + * undefined when there is nothing to apply. + */ + private applyPendingThinkingOverride( + request: StreamRequestConfig + ): Record | undefined { + const state = request.thinkingOverrideState; + const pending = state?.pending; + if (state == null || pending == null) { + return undefined; + } + state.pending = undefined; + const rebuild = request.rebuildProviderOptionsForThinkingLevel; + if (rebuild == null) { + return undefined; + } + const rebuilt = rebuild(pending); + if (rebuilt == null) { + log.debug("Mid-turn thinking override skipped (not applicable / no-op)", { + requestedLevel: pending, + appliedLevel: state.applied, + }); + return undefined; + } + const target = request.providerOptions; + // buildStreamRequestConfig normalizes providerOptions to a stable object + // whenever a rebuild closure is present, so target must exist here. + assert(target != null, "providerOptions must be normalized when a rebuild closure is present"); + for (const key of Object.keys(target)) { + delete target[key]; + } + Object.assign(target, rebuilt.providerOptions); + state.applied = rebuilt.effectiveLevel; + state.onApplied?.(rebuilt.effectiveLevel); + log.debug("Mid-turn thinking override applied", { + requestedLevel: pending, + effectiveLevel: rebuilt.effectiveLevel, + }); + return rebuilt.providerOptions; + } + private createStreamResult( request: StreamRequestConfig, abortController: AbortController, @@ -1740,10 +1831,25 @@ export class StreamManager extends EventEmitter { // undefined when the feature is inactive, keeping the return value // byte-identical to the pre-feature behavior. const activeTools = computeActiveToolNames(request.toolSearchState); - if (rewritten === stepMessages && activeTools === undefined) return undefined; + // Mid-turn thinking-level change: consume a pending override before + // this step's provider request is built. + const thinkingOverride = this.applyPendingThinkingOverride(request); + if ( + rewritten === stepMessages && + activeTools === undefined && + thinkingOverride === undefined + ) { + return undefined; + } return { ...(rewritten === stepMessages ? {} : { messages: rewritten }), ...(activeTools !== undefined ? { activeTools } : {}), + // Defense in depth: the in-place request mutation is authoritative + // (per-step deep-merge cannot delete keys); returning the rebuilt + // options also covers any future SDK options snapshotting. + ...(thinkingOverride !== undefined + ? { providerOptions: thinkingOverride as ProviderOptions } + : {}), }; }, onChunk: request.onChunk, @@ -1786,7 +1892,9 @@ export class StreamManager extends EventEmitter { onChunk?: StreamTextOnChunk, onStepMessages?: (messages: ModelMessage[]) => void, modelFallback?: ModelFallbackOptions, - toolSearchState?: ToolSearchStreamState + toolSearchState?: ToolSearchStreamState, + thinkingOverrideState?: ActiveTurnThinkingOverride, + rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel ): WorkspaceStreamInfo { // abortController is created and linked to the caller-provided abortSignal in startStream(). @@ -1809,7 +1917,9 @@ export class StreamManager extends EventEmitter { onChunk, onStepMessages, toolSearchState, - (toolCallId) => this.handleToolExecutionStart(workspaceId, messageId, toolCallId) + (toolCallId) => this.handleToolExecutionStart(workspaceId, messageId, toolCallId), + thinkingOverrideState, + rebuildProviderOptionsForThinkingLevel ); // Start streaming - this can throw immediately if API key is missing @@ -1872,6 +1982,19 @@ export class StreamManager extends EventEmitter { cumulativeProviderMetadata: undefined, }; + // Mid-turn thinking override: route applied levels into this stream's + // metadata (partials, stream-end, final assistant message). Wired before + // any step can run; the catch-up sync covers a holder that already applied + // a level (e.g. re-attachment on retry paths). + if (request.thinkingOverrideState) { + request.thinkingOverrideState.onApplied = (level) => { + streamInfo.thinkingLevel = level; + }; + if (request.thinkingOverrideState.applied) { + streamInfo.thinkingLevel = request.thinkingOverrideState.applied; + } + } + // Atomically register the stream this.workspaceStreams.set(workspaceId, streamInfo); @@ -2436,14 +2559,28 @@ export class StreamManager extends EventEmitter { // it would be categorized as a retryable api/unknown error and re-enter the // unbounded auto-retry loop this feature exists to prevent. Aborts rethrow so // a user interrupt during prepare stays an abort instead of a refusal. + // Fold a mid-turn thinking override (pending or already applied) into the + // fallback's baseline so the hop doesn't silently revert the user's + // mid-turn change. Pending is cleared here — prepare() re-clamps it for + // the fallback model and bakes it into the rebuilt provider options. + const overrideHolder = streamInfo.request.thinkingOverrideState; + const thinkingLevelOverride = overrideHolder?.pending ?? overrideHolder?.applied; + if (overrideHolder?.pending != null) { + overrideHolder.pending = undefined; + } + if (overrideHolder != null && thinkingLevelOverride != null) { + // Keep the override visible to later hops: prepare() bakes it into this + // hop's baseline, but a second hop re-folds from `applied`. + overrideHolder.applied = thinkingLevelOverride; + } let prepared: Result; try { - prepared = await fallbackState.options.prepare( - nextModelString, - continuation?.success === true + prepared = await fallbackState.options.prepare(nextModelString, { + ...(continuation?.success === true ? { continuation: { assistantMessage: continuation.data } } - : undefined - ); + : {}), + ...(thinkingLevelOverride != null ? { thinkingLevelOverride } : {}), + }); } catch (error) { if (streamInfo.abortController.signal.aborted) { throw error; @@ -2485,7 +2622,12 @@ export class StreamManager extends EventEmitter { // Same state object: aiService's fallback prepare() rebuilt it in place // against the fallback toolset, so prepareStep keeps reading live state. streamInfo.request.toolSearchState, - (toolCallId) => this.handleToolExecutionStart(workspaceId, streamInfo.messageId, toolCallId) + (toolCallId) => this.handleToolExecutionStart(workspaceId, streamInfo.messageId, toolCallId), + // Same holder object (the session's setter keeps working across the + // hop) with a closure bound to the FALLBACK model. Attached before + // createStreamResult below in case the SDK eagerly prepares step 1. + streamInfo.request.thinkingOverrideState, + prepared.data.rebuildProviderOptionsForThinkingLevel ); // createStreamResult may eagerly prepare the first fallback step and update // latestMessages. Clear stale source-step messages before starting it so a @@ -3935,7 +4077,9 @@ export class StreamManager extends EventEmitter { onStepMessages?: (messages: ModelMessage[]) => void, providedRuntimeTempDir?: string, modelFallback?: ModelFallbackOptions, - toolSearchState?: ToolSearchStreamState + toolSearchState?: ToolSearchStreamState, + thinkingOverrideState?: ActiveTurnThinkingOverride, + rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel ): Promise> { const typedWorkspaceId = workspaceId as WorkspaceId; @@ -4019,7 +4163,9 @@ export class StreamManager extends EventEmitter { onChunk, onStepMessages, modelFallback, - toolSearchState + toolSearchState, + thinkingOverrideState, + rebuildProviderOptionsForThinkingLevel ); // Guard against a narrow race: diff --git a/src/node/services/thinkingOverride.ts b/src/node/services/thinkingOverride.ts new file mode 100644 index 0000000000..46348811e5 --- /dev/null +++ b/src/node/services/thinkingOverride.ts @@ -0,0 +1,40 @@ +/** + * Mid-turn thinking-level override state. + * + * One holder object is created per turn by AgentSession (at durable turn + * acceptance) and threaded BY REFERENCE down through AIService into + * StreamManager's request config — the same shared-mutable-state pattern as + * ToolSearchStreamState. The renderer's thinking slider writes `pending` via + * AgentSession.setActiveTurnThinkingLevel; StreamManager consumes it at the + * next prepareStep (which runs before every model step, including step 1, so + * changes during the PREPARING window apply to the turn's first request). + * + * Node-runtime-local: carries a callback, so it does not belong in + * src/common/types (never crosses IPC). + */ +import type { ThinkingLevel } from "@/common/types/thinking"; + +export interface ActiveTurnThinkingOverride { + /** Raw level requested mid-turn; consumed at the next prepareStep (incl. step 1). */ + pending?: ThinkingLevel; + /** Effective level after the most recent successful application. */ + applied?: ThinkingLevel; + /** Sink wired by StreamManager to the owning streamInfo (metadata). */ + onApplied?: (level: ThinkingLevel) => void; +} + +/** + * Result of rebuilding provider options for a mid-turn thinking-level change. + * `null` from the rebuild closure means "not applicable / no-op" (e.g. the + * clamped level equals the current one, or the transition would require a + * different model instance) — the pending override is then dropped for this + * turn and the persisted setting still covers the next turn. + */ +export interface RebuiltThinkingProviderOptions { + effectiveLevel: ThinkingLevel; + providerOptions: Record; +} + +export type RebuildProviderOptionsForThinkingLevel = ( + level: ThinkingLevel +) => RebuiltThinkingProviderOptions | null; diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index a7647ea41b..5557d90015 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -7125,6 +7125,27 @@ export class WorkspaceService extends EventEmitter { } } + /** + * Mid-turn thinking change: forward the requested level to the workspace's + * active turn (if any). Deliberately `sessions.get`, not getOrCreateSession — + * creating a session to tell it "change the turn you don't have" is pointless; + * persisted settings already cover the next turn. + */ + setActiveTurnThinkingLevel( + workspaceId: string, + level: ThinkingLevel + ): Result<{ accepted: boolean }, string> { + try { + const session = this.sessions.get(workspaceId.trim()); + if (!session) { + return Ok({ accepted: false }); + } + return Ok(session.setActiveTurnThinkingLevel(level)); + } catch (error) { + return Err(`Failed to set active-turn thinking level: ${getErrorMessage(error)}`); + } + } + async fork( sourceWorkspaceId: string, newName?: string, From 8485f9cbf6606f0cdafc089f42556c0167486797 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 13 Jul 2026 12:03:56 +0000 Subject: [PATCH 3/5] test: cover mid-turn thinking override lifecycle end to end - StreamManager: in-place providerOptions replacement (identity + key deletion), consume-once semantics for skipped rebuilds, providerOptions normalization when a rebuild closure exists, feature-off byte-identical prepareStep, and fallback fold + holder/closure rebinding. - AIService: rebuild closure threads the session holder by reference, diffs against the live effective level, clamps to the session floor, applies native-xhigh transitions via provider options, and skips the grok-4-1-fast variant swap. - AgentSession: accepted:false while idle, last-write-wins during active turns, PREPARING-window delivery, holder expiry on turn end and on pre-stream/onAccepted failures, fresh holder per turn. - WorkspaceService: accepted:false for sessionless workspaces. - ThinkingContext: slider fires the route with workspaceId, not in project scope. - policy: isXaiGrokFastVariantSwap branch coverage. --- src/browser/contexts/ThinkingContext.test.tsx | 75 ++++ src/common/utils/thinking/policy.test.ts | 14 + .../agentSession.thinkingOverride.test.ts | 163 +++++++++ src/node/services/aiService.test.ts | 159 +++++++++ src/node/services/streamManager.test.ts | 329 ++++++++++++++++++ src/node/services/streamManager.ts | 16 +- src/node/services/workspaceService.test.ts | 10 + 7 files changed, 760 insertions(+), 6 deletions(-) create mode 100644 src/node/services/agentSession.thinkingOverride.test.ts diff --git a/src/browser/contexts/ThinkingContext.test.tsx b/src/browser/contexts/ThinkingContext.test.tsx index aacc694331..7ca00695aa 100644 --- a/src/browser/contexts/ThinkingContext.test.tsx +++ b/src/browser/contexts/ThinkingContext.test.tsx @@ -590,6 +590,81 @@ describe("ThinkingContext", () => { } }); + test("requests a mid-turn override for the active workspace turn on slider changes", async () => { + const workspaceId = "ws-set-thinking-mid-turn"; + const setActiveTurnThinkingLevel = mock< + (args: { + workspaceId: string; + thinkingLevel: ThinkingLevel; + }) => Promise<{ success: true; data: { accepted: boolean } }> + >(() => Promise.resolve({ success: true as const, data: { accepted: true } })); + currentClientMock = { + workspace: { + updateAgentAISettings: mock(() => + Promise.resolve({ success: true as const, data: undefined }) + ), + setActiveTurnThinkingLevel, + }, + }; + + setWorkspaceMetadata(createWorkspaceMetadata({ id: workspaceId })); + + const view = renderWithWorkspaceMetadata({ + workspaceId, + modelOverride: null, + children: ( + + + + ), + }); + + const button = await view.findByTestId("set-thinking-medium", undefined, METADATA_WAIT_OPTIONS); + act(() => { + button.click(); + }); + + await waitFor(() => { + expect(setActiveTurnThinkingLevel).toHaveBeenCalledWith({ + workspaceId, + thinkingLevel: "medium", + }); + }, METADATA_WAIT_OPTIONS); + }); + + test("does not request a mid-turn override in project scope (no workspaceId)", async () => { + const projectPath = "/Users/dev/mid-turn-scope"; + const setActiveTurnThinkingLevel = mock(() => + Promise.resolve({ success: true as const, data: { accepted: false } }) + ); + currentClientMock = { + workspace: { setActiveTurnThinkingLevel }, + }; + + const view = renderWithAPI( + + + + ); + + const button = await view.findByTestId("set-thinking-medium", undefined, METADATA_WAIT_OPTIONS); + act(() => { + button.click(); + }); + + // Project/global scopes have no active turn to override; the route must + // not fire (persisted settings alone drive the next turn). + await waitFor(() => { + expect( + readPersistedState( + getThinkingLevelKey(getProjectScopeId(projectPath)), + null + ) + ).toBe("medium"); + }, METADATA_WAIT_OPTIONS); + expect(setActiveTurnThinkingLevel).not.toHaveBeenCalled(); + }); + test("cycles thinking level via keybind in project-scoped (creation) flow", async () => { const projectPath = "/Users/dev/my-project"; diff --git a/src/common/utils/thinking/policy.test.ts b/src/common/utils/thinking/policy.test.ts index b7256e7411..08abefeb3d 100644 --- a/src/common/utils/thinking/policy.test.ts +++ b/src/common/utils/thinking/policy.test.ts @@ -9,6 +9,7 @@ import { resolveMinimumThinkingLevel, resolveEffectiveThinkingLevel, getAvailableThinkingLevels, + isXaiGrokFastVariantSwap, } from "./policy"; describe("getThinkingPolicyForModel", () => { @@ -922,3 +923,16 @@ describe("enforceThinkingPolicy with a minimum floor", () => { expect(enforceThinkingPolicy("anthropic:claude-sonnet-4-5", "off")).toBe("off"); }); }); + +describe("isXaiGrokFastVariantSwap", () => { + test("flags only off<->on transitions on xai:grok-4-1-fast", () => { + // off <-> non-off swaps the underlying reasoning/non-reasoning variant. + expect(isXaiGrokFastVariantSwap("xai:grok-4-1-fast", "off", "high")).toBe(true); + expect(isXaiGrokFastVariantSwap("xai:grok-4-1-fast", "high", "off")).toBe(true); + // non-off -> non-off stays on the reasoning variant (no swap). + expect(isXaiGrokFastVariantSwap("xai:grok-4-1-fast", "low", "high")).toBe(false); + // Other models never swap instances on thinking-level changes. + expect(isXaiGrokFastVariantSwap("xai:grok-4-1-fast-reasoning", "off", "high")).toBe(false); + expect(isXaiGrokFastVariantSwap("anthropic:claude-sonnet-4-5", "off", "high")).toBe(false); + }); +}); diff --git a/src/node/services/agentSession.thinkingOverride.test.ts b/src/node/services/agentSession.thinkingOverride.test.ts new file mode 100644 index 0000000000..e4ccca47b7 --- /dev/null +++ b/src/node/services/agentSession.thinkingOverride.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, it, mock } from "bun:test"; + +import type { MuxMessage } from "@/common/types/message"; +import { Ok, Err } from "@/common/types/result"; +import type { AIService, StreamMessageOptions } from "@/node/services/aiService"; +import { createAgentSessionHarness } from "./agentSession.testHarness"; +import type { ActiveTurnThinkingOverride } from "./thinkingOverride"; + +const MODEL = "anthropic:claude-sonnet-4-5"; + +describe("AgentSession.setActiveTurnThinkingLevel", () => { + it("reports accepted:false while idle (persisted settings cover the next turn)", async () => { + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId: "thinking-override-idle", + }); + try { + expect(session.setActiveTurnThinkingLevel("high")).toEqual({ accepted: false }); + } finally { + session.dispose(); + await cleanup(); + } + }); + + it("accepts writes during PREPARING and streaming, threads the holder to the stream, and expires it on turn end", async () => { + const capturedHolders: Array = []; + const capturedPendingAtStreamStart: Array = []; + let sessionRef: { + setActiveTurnThinkingLevel: (level: "low" | "high" | "medium") => { accepted: boolean }; + } | null = null; + + const streamMessage = mock((opts: StreamMessageOptions) => { + capturedHolders.push(opts.activeTurnThinkingOverride); + capturedPendingAtStreamStart.push(opts.activeTurnThinkingOverride?.pending); + // Simulate a slider change while the stream is active: both writes must + // land in the SAME holder object the stream received (last write wins). + const first = sessionRef?.setActiveTurnThinkingLevel("high"); + const second = sessionRef?.setActiveTurnThinkingLevel("low"); + expect(first).toEqual({ accepted: true }); + expect(second).toEqual({ accepted: true }); + expect(opts.activeTurnThinkingOverride?.pending).toBe("low"); + return Promise.resolve(Ok(undefined)); + }); + + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId: "thinking-override-active", + aiServiceOverrides: { + streamMessage: streamMessage as unknown as AIService["streamMessage"], + }, + }); + sessionRef = session; + + try { + const result = await session.sendMessage("hello", { model: MODEL, agentId: "exec" }); + expect(result.success).toBe(true); + expect(streamMessage.mock.calls).toHaveLength(1); + expect(capturedHolders[0]).toBeDefined(); + + // The turn ended (mocked stream resolved without events -> IDLE): the + // holder expired, so late writes fall back to persisted settings. + await session.waitForIdle(); + expect(session.setActiveTurnThinkingLevel("high")).toEqual({ accepted: false }); + + // A follow-up turn gets a FRESH holder with no inherited pending level. + const second = await session.sendMessage("again", { model: MODEL, agentId: "exec" }); + expect(second.success).toBe(true); + expect(capturedHolders[1]).toBeDefined(); + expect(capturedHolders[1]).not.toBe(capturedHolders[0]); + expect(capturedPendingAtStreamStart[1]).toBeUndefined(); + } finally { + session.dispose(); + await cleanup(); + } + }); + + it("delivers a change made during the PREPARING window to the turn's stream options", async () => { + let pendingSeenByStream: string | undefined; + const streamMessage = mock((opts: StreamMessageOptions) => { + pendingSeenByStream = opts.activeTurnThinkingOverride?.pending; + return Promise.resolve(Ok(undefined)); + }); + + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId: "thinking-override-preparing", + aiServiceOverrides: { + streamMessage: streamMessage as unknown as AIService["streamMessage"], + }, + }); + + try { + // onAccepted runs after the holder is created but before the stream + // starts — exactly the PREPARING window a fast slider change can hit. + const result = await session.sendMessage( + "hello", + { model: MODEL, agentId: "exec" }, + { + onAccepted: () => { + expect(session.setActiveTurnThinkingLevel("high")).toEqual({ accepted: true }); + }, + } + ); + expect(result.success).toBe(true); + expect(streamMessage.mock.calls).toHaveLength(1); + // The pre-stream write reaches the turn's first provider request via the + // holder (prepareStep runs before step 1). + expect(pendingSeenByStream).toBe("high"); + } finally { + session.dispose(); + await cleanup(); + } + }); + + it("clears the holder when a pre-stream failure aborts the accepted turn", async () => { + const streamMessage = mock((_opts: StreamMessageOptions) => + Promise.resolve(Err({ type: "unknown" as const, raw: "startup failed" })) + ); + + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId: "thinking-override-prestream-failure", + aiServiceOverrides: { + streamMessage: streamMessage as unknown as AIService["streamMessage"], + }, + }); + + try { + const result = await session.sendMessage("hello", { model: MODEL, agentId: "exec" }); + expect(result.success).toBe(false); + await session.waitForIdle(); + expect(session.setActiveTurnThinkingLevel("high")).toEqual({ accepted: false }); + } finally { + session.dispose(); + await cleanup(); + } + }); + + it("clears the holder when an onAccepted failure aborts the turn before streaming", async () => { + const streamMessage = mock((_history: MuxMessage[]) => Promise.resolve(Ok(undefined))); + const { session, cleanup } = await createAgentSessionHarness({ + workspaceId: "thinking-override-onaccepted-failure", + aiServiceOverrides: { + streamMessage: streamMessage as unknown as AIService["streamMessage"], + }, + }); + + try { + const result = await session.sendMessage( + "hello", + { model: MODEL, agentId: "exec" }, + { + onAccepted: () => { + throw new Error("acceptance hook failed"); + }, + } + ); + expect(result.success).toBe(false); + // The stream never started; the holder must not leak into idle state. + expect(streamMessage.mock.calls).toHaveLength(0); + expect(session.setActiveTurnThinkingLevel("high")).toEqual({ accepted: false }); + } finally { + session.dispose(); + await cleanup(); + } + }); +}); diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 69ba723c46..c7b1b3c8bf 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -57,6 +57,10 @@ import type { import { log } from "./log"; import type { SessionUsageService } from "./sessionUsageService"; import type { ModelFallbackOptions, StreamManager } from "./streamManager"; +import type { + ActiveTurnThinkingOverride, + RebuildProviderOptionsForThinkingLevel, +} from "./thinkingOverride"; import { ExperimentsService } from "./experimentsService"; import type { DevToolsService } from "./devToolsService"; import { TelemetryService } from "@/node/services/telemetryService"; @@ -2955,6 +2959,161 @@ describe("AIService.streamMessage compaction boundary slicing", () => { }) ); }); + + describe("mid-turn thinking override rebuild closure", () => { + const START_STREAM_THINKING_OVERRIDE_STATE_INDEX = 26; + const START_STREAM_THINKING_REBUILD_INDEX = 27; + + function getThinkingOverrideStartStreamArgs(harness: StreamMessageHarness): { + holder: unknown; + rebuild: RebuildProviderOptionsForThinkingLevel; + } { + expect(harness.startStreamCalls).toHaveLength(1); + const call = harness.startStreamCalls[0]; + if (!call) { + throw new Error("Expected streamManager.startStream call arguments"); + } + const holder = call[START_STREAM_THINKING_OVERRIDE_STATE_INDEX]; + const rebuild = call[START_STREAM_THINKING_REBUILD_INDEX]; + expect(typeof rebuild).toBe("function"); + return { holder, rebuild: rebuild as RebuildProviderOptionsForThinkingLevel }; + } + + it("threads the session holder by reference and rebuilds options through the same pipeline", async () => { + using muxHome = new DisposableTempDir("ai-service-thinking-override"); + const projectPath = path.join(muxHome.path, "project"); + await fs.mkdir(projectPath, { recursive: true }); + + const workspaceId = "workspace-thinking-override"; + const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); + const harness = createHarness(muxHome.path, metadata, { + useRequestedModelString: true, + canonicalProviderName: "anthropic", + }); + + const sessionHolder: ActiveTurnThinkingOverride = {}; + const result = await harness.service.streamMessage({ + messages: [createMuxMessage("latest-user", "user", "hello")], + workspaceId, + // Budget-token Anthropic model (no adaptive effort): level changes show + // up as thinking.budgetTokens differences. + modelString: "anthropic:claude-sonnet-4-5", + thinkingLevel: "low", + minThinkingLevel: "off", + activeTurnThinkingOverride: sessionHolder, + }); + expect(result.success).toBe(true); + + const { holder, rebuild } = getThinkingOverrideStartStreamArgs(harness); + // Same object: AgentSession's setter writes must be visible to prepareStep. + expect(holder).toBe(sessionHolder); + + // No-op: requested level equals the current effective level. + expect(rebuild("low")).toBeNull(); + + // Real transition: rebuilt provider options reflect the new level. + const rebuilt = rebuild("high"); + expect(rebuilt?.effectiveLevel).toBe("high"); + const anthropic = rebuilt?.providerOptions.anthropic as + | { thinking?: { type: string; budgetTokens?: number } } + | undefined; + expect(anthropic?.thinking).toEqual({ type: "enabled", budgetTokens: 20000 }); + + // The closure diffs against the LIVE level, not the send-time one: + // repeating the applied level is now a no-op. + expect(rebuild("high")).toBeNull(); + }); + + it("clamps mid-turn requests against the session-provided floor", async () => { + using muxHome = new DisposableTempDir("ai-service-thinking-floor"); + const projectPath = path.join(muxHome.path, "project"); + await fs.mkdir(projectPath, { recursive: true }); + + const workspaceId = "workspace-thinking-floor"; + const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); + const harness = createHarness(muxHome.path, metadata, { + useRequestedModelString: true, + canonicalProviderName: "anthropic", + }); + + const result = await harness.service.streamMessage({ + messages: [createMuxMessage("latest-user", "user", "hello")], + workspaceId, + modelString: KNOWN_MODELS.SONNET.id, + thinkingLevel: "medium", + minThinkingLevel: "medium", + activeTurnThinkingOverride: {}, + }); + expect(result.success).toBe(true); + + const { rebuild } = getThinkingOverrideStartStreamArgs(harness); + // Below-floor requests clamp up to the floor, which equals the current + // level here — so they must be treated as no-ops, not as downgrades. + expect(rebuild("off")).toBeNull(); + expect(rebuild("low")).toBeNull(); + // Above-floor requests still apply. + expect(rebuild("high")?.effectiveLevel).toBe("high"); + }); + + it("applies Anthropic native-xhigh transitions as plain provider-option rebuilds", async () => { + using muxHome = new DisposableTempDir("ai-service-thinking-xhigh"); + const projectPath = path.join(muxHome.path, "project"); + await fs.mkdir(projectPath, { recursive: true }); + + const workspaceId = "workspace-thinking-xhigh"; + const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); + const harness = createHarness(muxHome.path, metadata, { + useRequestedModelString: true, + canonicalProviderName: "anthropic", + }); + + const result = await harness.service.streamMessage({ + messages: [createMuxMessage("latest-user", "user", "hello")], + workspaceId, + modelString: "anthropic:claude-opus-4-7", + thinkingLevel: "high", + activeTurnThinkingOverride: {}, + }); + expect(result.success).toBe(true); + + const { rebuild } = getThinkingOverrideStartStreamArgs(harness); + const rebuilt = rebuild("xhigh"); + expect(rebuilt?.effectiveLevel).toBe("xhigh"); + const anthropic = rebuilt?.providerOptions.anthropic as + | { effort?: string; thinking?: unknown } + | undefined; + // Post-wire-hack: the native effort flows directly via provider options. + expect(anthropic?.effort).toBe("xhigh"); + expect(anthropic?.thinking).toEqual({ type: "adaptive", display: "summarized" }); + }); + + it("skips the grok-4-1-fast off<->on transition (model-instance swap)", async () => { + using muxHome = new DisposableTempDir("ai-service-thinking-grok"); + const projectPath = path.join(muxHome.path, "project"); + await fs.mkdir(projectPath, { recursive: true }); + + const workspaceId = "workspace-thinking-grok"; + const metadata = createLocalWorkspaceMetadata(workspaceId, projectPath); + const harness = createHarness(muxHome.path, metadata, { + useRequestedModelString: true, + canonicalProviderName: "xai" as ProviderName, + }); + + const result = await harness.service.streamMessage({ + messages: [createMuxMessage("latest-user", "user", "hello")], + workspaceId, + modelString: "xai:grok-4-1-fast", + thinkingLevel: "off", + activeTurnThinkingOverride: {}, + }); + expect(result.success).toBe(true); + + const { rebuild } = getThinkingOverrideStartStreamArgs(harness); + // off -> high selects a different model instance at creation time; the + // in-flight stream cannot express it via provider options. + expect(rebuild("high")).toBeNull(); + }); + }); }); describe("AIService.streamMessage multi-project trust gating", () => { diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 8a7af7d8d4..0765225499 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -15,6 +15,10 @@ import { Ok, Err } from "@/common/types/result"; import type { ToolPolicy } from "@/common/utils/tools/toolPolicy"; import type { ToolSearchStreamState } from "@/common/utils/tools/toolCatalog"; import { StreamManager, type ModelFallbackPrepareOptions } from "./streamManager"; +import type { + ActiveTurnThinkingOverride, + RebuildProviderOptionsForThinkingLevel, +} from "./thinkingOverride"; import { stripEncryptedContent } from "@/node/utils/messages/stripEncryptedContent"; import * as aiSdk from "ai"; import { @@ -5215,3 +5219,328 @@ describe("StreamManager - tool search activeTools scoping", () => { expect(request.toolSearchState).toBe(toolSearchState); }); }); + +describe("StreamManager - mid-turn thinking override", () => { + interface OverrideRequestForTests { + model: unknown; + messages: ModelMessage[]; + system?: string; + providerOptions?: Record; + thinkingOverrideState?: ActiveTurnThinkingOverride; + rebuildProviderOptionsForThinkingLevel?: RebuildProviderOptionsForThinkingLevel; + } + + type BuildStreamRequestConfig = (...args: unknown[]) => OverrideRequestForTests; + type CreateStreamResult = ( + request: OverrideRequestForTests, + abortController: AbortController + ) => unknown; + type CapturedPrepareStep = (options: { messages: ModelMessage[] }) => Promise< + | { + messages?: ModelMessage[]; + activeTools?: string[]; + providerOptions?: Record; + } + | undefined + >; + + const model = createAnthropic({ apiKey: "test" })("claude-sonnet-4-5"); + const messages: ModelMessage[] = [{ role: "user", content: "hello" }]; + + function getRequestHelpers(streamManager: StreamManager): { + buildRequestConfig: BuildStreamRequestConfig; + createStreamResult: CreateStreamResult; + } { + const buildRequestConfig = Reflect.get(streamManager, "buildStreamRequestConfig") as + | ((...args: unknown[]) => OverrideRequestForTests) + | undefined; + const createStreamResultMethod = Reflect.get(streamManager, "createStreamResult") as + | CreateStreamResult + | undefined; + expect(typeof buildRequestConfig).toBe("function"); + expect(typeof createStreamResultMethod).toBe("function"); + if (!buildRequestConfig || !createStreamResultMethod) { + throw new Error("Expected StreamManager private helpers to exist"); + } + return { + buildRequestConfig: (...args) => buildRequestConfig.apply(streamManager, args), + createStreamResult: (request, abortController) => + createStreamResultMethod.call(streamManager, request, abortController), + }; + } + + function setupStreamTextSpy() { + return spyOn(aiSdk, "streamText").mockReturnValue({ + fullStream: (async function* asyncGenerator() { + yield* [] as unknown[]; + await Promise.resolve(); + })(), + usage: Promise.resolve(undefined), + providerMetadata: Promise.resolve(undefined), + totalUsage: Promise.resolve(undefined), + steps: Promise.resolve([]), + } as unknown as ReturnType); + } + + function capturePrepareStep( + streamTextSpy: ReturnType + ): CapturedPrepareStep { + const prepareStep = streamTextSpy.mock.calls[0]?.[0]?.prepareStep as + | CapturedPrepareStep + | undefined; + expect(typeof prepareStep).toBe("function"); + if (!prepareStep) { + throw new Error("Expected prepareStep to be captured"); + } + return prepareStep; + } + + afterEach(() => { + mock.restore(); + }); + + test("applies a pending override in place: same object identity, old keys deleted, sink invoked", async () => { + const streamManager = new StreamManager(historyService); + const { createStreamResult } = getRequestHelpers(streamManager); + const streamTextSpy = setupStreamTextSpy(); + + const appliedLevels: string[] = []; + const state: ActiveTurnThinkingOverride = { + pending: "high", + onApplied: (level) => appliedLevels.push(level), + }; + const originalProviderOptions: Record = { + anthropic: { effort: "low", thinking: { type: "enabled", budgetTokens: 4000 } }, + staleNamespace: { key: "must-be-deleted" }, + }; + const rebuilt = { anthropic: { effort: "high", thinking: { type: "enabled" } } }; + const rebuild = mock((level: string) => + level === "high" ? { effectiveLevel: "high" as const, providerOptions: rebuilt } : null + ); + + const request: OverrideRequestForTests = { + model, + messages, + system: "system", + providerOptions: originalProviderOptions, + thinkingOverrideState: state, + rebuildProviderOptionsForThinkingLevel: + rebuild as unknown as RebuildProviderOptionsForThinkingLevel, + }; + createStreamResult(request, new AbortController()); + const prepareStep = capturePrepareStep(streamTextSpy); + + const step = await prepareStep({ messages }); + // Rebuilt options are returned for the step (defense in depth) … + expect(step?.providerOptions).toEqual(rebuilt); + // … and the live request object is replaced IN PLACE (same identity), + // deleting keys the SDK's deep-merge could never remove. + expect(request.providerOptions).toBe(originalProviderOptions); + expect(request.providerOptions).toEqual(rebuilt); + expect("staleNamespace" in originalProviderOptions).toBe(false); + // Consume-once bookkeeping + metadata sink. + expect(state.pending).toBeUndefined(); + expect(state.applied).toBe("high"); + expect(appliedLevels).toEqual(["high"]); + // Without a new pending value the next step is a no-op again. + expect(await prepareStep({ messages })).toBeUndefined(); + expect(rebuild).toHaveBeenCalledTimes(1); + }); + + test("clears pending without touching options when the rebuild reports not-applicable", async () => { + const streamManager = new StreamManager(historyService); + const { createStreamResult } = getRequestHelpers(streamManager); + const streamTextSpy = setupStreamTextSpy(); + + const state: ActiveTurnThinkingOverride = { pending: "off" }; + const originalProviderOptions: Record = { xai: { some: "config" } }; + const rebuild = mock(() => null); + + const request: OverrideRequestForTests = { + model, + messages, + system: "system", + providerOptions: originalProviderOptions, + thinkingOverrideState: state, + rebuildProviderOptionsForThinkingLevel: + rebuild as unknown as RebuildProviderOptionsForThinkingLevel, + }; + createStreamResult(request, new AbortController()); + const prepareStep = capturePrepareStep(streamTextSpy); + + expect(await prepareStep({ messages })).toBeUndefined(); + expect(request.providerOptions).toEqual({ xai: { some: "config" } }); + // Consume-once: a skipped application must not retry on every later step. + expect(state.pending).toBeUndefined(); + expect(state.applied).toBeUndefined(); + expect(await prepareStep({ messages })).toBeUndefined(); + expect(rebuild).toHaveBeenCalledTimes(1); + }); + + test("stays byte-identical to the feature-off behavior when no override state exists", async () => { + const streamManager = new StreamManager(historyService); + const { createStreamResult } = getRequestHelpers(streamManager); + const streamTextSpy = setupStreamTextSpy(); + + createStreamResult({ model, messages, system: "system" }, new AbortController()); + const prepareStep = capturePrepareStep(streamTextSpy); + expect(await prepareStep({ messages })).toBeUndefined(); + }); + + test("buildStreamRequestConfig normalizes providerOptions to a stable mutable object only when a rebuild closure exists", () => { + const streamManager = new StreamManager(historyService); + const { buildRequestConfig, createStreamResult } = getRequestHelpers(streamManager); + const streamTextSpy = setupStreamTextSpy(); + + const state: ActiveTurnThinkingOverride = {}; + const rebuild: RebuildProviderOptionsForThinkingLevel = () => null; + + // Without the closure, an absent providerOptions stays absent (no behavior change). + const plainRequest = buildRequestConfig( + model, + KNOWN_MODELS.SONNET.id, + messages, + "system", + undefined, // routeProvider + undefined, // tools + undefined, // providerOptions + undefined, // maxOutputTokens + undefined, // callSettingsOverrides + undefined, // toolPolicy + undefined, // hasQueuedMessages + undefined, // headers + undefined, // anthropicCacheTtlOverride + undefined, // onChunk + undefined, // onStepMessages + undefined // toolSearchState + ); + expect(plainRequest.providerOptions).toBeUndefined(); + + // With the closure, undefined normalizes to a mutable object whose identity + // is exactly what streamText() captures — otherwise in-place mutation at + // prepareStep time would be unobservable to the SDK's per-step merge. + const request = buildRequestConfig( + model, + KNOWN_MODELS.SONNET.id, + messages, + "system", + undefined, + undefined, + undefined, // providerOptions intentionally absent + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + state, + rebuild + ); + expect(request.providerOptions).toEqual({}); + expect(request.thinkingOverrideState).toBe(state); + expect(request.rebuildProviderOptionsForThinkingLevel).toBe(rebuild); + + createStreamResult(request, new AbortController()); + const streamTextArgs = streamTextSpy.mock.calls[0]?.[0]; + expect(streamTextArgs?.providerOptions).toBe( + request.providerOptions as NonNullable["providerOptions"] + ); + }); + + test("model fallback folds the pending override into prepare() and rebinds holder + closure on the swapped request", async () => { + const streamManager = new StreamManager(historyService); + Reflect.set(streamManager, "tokenTracker", { + setModel: () => Promise.resolve(undefined), + countTokens: () => Promise.resolve(0), + }); + + const workspaceId = "thinking-fallback-workspace"; + const messageId = "thinking-fallback-message"; + const historySequence = 1; + const fallbackModel = KNOWN_MODELS.GPT.id; + + await appendPartialAssistantForTests(workspaceId, messageId, historySequence); + const processStreamWithCleanup = getProcessStreamWithCleanupForTests(streamManager); + + const capturedNextRequests: OverrideRequestForTests[] = []; + const createStreamResult = mock((request: OverrideRequestForTests) => { + capturedNextRequests.push(request); + return createStreamResultForTests( + (async function* () { + await Promise.resolve(); + yield { type: "text-delta", text: "fallback answer" }; + yield { type: "finish", finishReason: "stop" }; + })(), + { inputTokens: 5, outputTokens: 3, totalTokens: 8 } + ); + }); + expect(Reflect.set(streamManager, "createStreamResult", createStreamResult)).toBe(true); + + const fallbackLanguageModel = createTestLanguageModel("fallback-model"); + const fallbackRebuild: RebuildProviderOptionsForThinkingLevel = () => null; + const prepare = mock((nextModelString: string, options?: ModelFallbackPrepareOptions) => { + expect(options?.thinkingLevelOverride).toBe("high"); + return Promise.resolve( + Ok({ + model: fallbackLanguageModel, + modelString: nextModelString, + messages: [], + system: "fallback system", + tools: undefined, + thinkingLevel: "high", + rebuildProviderOptionsForThinkingLevel: fallbackRebuild, + }) + ); + }); + + // Pending override that never got a next step on the refusing stream: the + // fallback hop must not silently revert it. + const holder: ActiveTurnThinkingOverride = { pending: "high" }; + const startTime = Date.now() - 250; + const streamInfo = createStreamInfoForTests({ + streamResult: createStreamResultForTests( + (async function* () { + await Promise.resolve(); + yield { type: "finish", finishReason: "content-filter", rawFinishReason: "refusal" }; + })(), + { inputTokens: 10, outputTokens: 0, totalTokens: 10 } + ), + messageId, + startTime, + lastPartTimestamp: startTime, + model: KNOWN_MODELS.SONNET.id, + metadataModel: KNOWN_MODELS.SONNET.id, + historySequence, + runtime: LOCAL_TEST_RUNTIME, + request: { + model: createTestLanguageModel("refused-model"), + messages: [], + providerOptions: undefined, + thinkingOverrideState: holder, + }, + modelFallback: { + options: { chain: [fallbackModel], prepare }, + requestedModel: KNOWN_MODELS.SONNET.id, + refusedModels: [], + original: { maxOutputTokens: undefined }, + }, + }); + + await processStreamWithCleanup.call(streamManager, workspaceId, streamInfo, historySequence); + + expect(prepare).toHaveBeenCalledTimes(1); + // Pending was folded into the fallback baseline (consumed, kept as applied + // for potential second hops). + expect(holder.pending).toBeUndefined(); + expect(holder.applied).toBe("high"); + // The swapped request carries the SAME holder (the session setter keeps + // working) and the fallback-bound rebuild closure. + expect(createStreamResult).toHaveBeenCalledTimes(1); + const nextRequest = capturedNextRequests[0]; + expect(nextRequest?.thinkingOverrideState).toBe(holder); + expect(nextRequest?.rebuildProviderOptionsForThinkingLevel).toBe(fallbackRebuild); + }); +}); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 3ae4f72938..2a72ae4904 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -2573,14 +2573,18 @@ export class StreamManager extends EventEmitter { // hop's baseline, but a second hop re-folds from `applied`. overrideHolder.applied = thinkingLevelOverride; } + const prepareCallOptions: ModelFallbackPrepareOptions | undefined = + continuation?.success === true || thinkingLevelOverride != null + ? { + ...(continuation?.success === true + ? { continuation: { assistantMessage: continuation.data } } + : {}), + ...(thinkingLevelOverride != null ? { thinkingLevelOverride } : {}), + } + : undefined; let prepared: Result; try { - prepared = await fallbackState.options.prepare(nextModelString, { - ...(continuation?.success === true - ? { continuation: { assistantMessage: continuation.data } } - : {}), - ...(thinkingLevelOverride != null ? { thinkingLevelOverride } : {}), - }); + prepared = await fallbackState.options.prepare(nextModelString, prepareCallOptions); } catch (error) { if (streamInfo.abortController.signal.aborted) { throw error; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 2126b580c9..56a3719d37 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -218,6 +218,16 @@ function createFrontendWorkspaceMetadata( }; } +describe("WorkspaceService.setActiveTurnThinkingLevel", () => { + test("returns accepted:false when the workspace has no session", () => { + const workspaceService = createWorkspaceServiceForTest({ config: {} }); + // No session was ever created for this workspace: nothing is running, so + // the mid-turn override is a no-op and persisted settings cover the next turn. + const result = workspaceService.setActiveTurnThinkingLevel("unknown-workspace", "high"); + expect(result).toEqual(Ok({ accepted: false })); + }); +}); + describe("WorkspaceService bash monitor wakes", () => { test("sends a synthetic wake and marks the record delivered when monitor output matches", async () => { const { config, cleanup } = await createTestHistoryService(); From 2e0c28160d613d8c3ec285ce3689a95904c119be Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 13 Jul 2026 12:57:42 +0000 Subject: [PATCH 4/5] fix: integrate with onToolExecutionStart param and shared assert from main --- src/node/services/streamManager.test.ts | 1 + src/node/services/streamManager.ts | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 0765225499..cb0af7106c 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -5436,6 +5436,7 @@ describe("StreamManager - mid-turn thinking override", () => { undefined, undefined, undefined, + undefined, // onToolExecutionStart state, rebuild ); diff --git a/src/node/services/streamManager.ts b/src/node/services/streamManager.ts index 2a72ae4904..e3b41efb24 100644 --- a/src/node/services/streamManager.ts +++ b/src/node/services/streamManager.ts @@ -1,4 +1,3 @@ -import assert from "node:assert"; import { EventEmitter } from "events"; import * as path from "path"; import { PlatformPaths } from "@/common/utils/paths"; From 96f1ad376fa7b44e7b0eb8ee20296f3422d006d2 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 13 Jul 2026 13:10:49 +0000 Subject: [PATCH 5/5] fix: route palette/keybind thinking changes to the active turn (Codex P2) --- src/browser/App.tsx | 5 +++++ src/browser/contexts/ThinkingContext.tsx | 11 ++++------- src/browser/utils/activeTurnThinking.ts | 25 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 7 deletions(-) create mode 100644 src/browser/utils/activeTurnThinking.ts diff --git a/src/browser/App.tsx b/src/browser/App.tsx index e8c5b67331..a0e13d545c 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -77,6 +77,7 @@ import { useTelemetry } from "./hooks/useTelemetry"; import { getRuntimeTypeForTelemetry } from "@/common/telemetry"; import { useStartWorkspaceCreation } from "./hooks/useStartWorkspaceCreation"; import { useAPI } from "@/browser/contexts/API"; +import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking"; import { clearPendingWorkspaceAiSettings, markPendingWorkspaceAiSettings, @@ -534,6 +535,10 @@ function AppInner() { clearPendingWorkspaceAiSettings(workspaceId, normalizedAgentId); // Best-effort only. }); + + // Mid-turn change: also apply to the active turn's next model step so + // the palette/keybind path behaves like the slider (ThinkingProvider). + requestActiveTurnThinkingLevel(api, workspaceId, normalized); } // Dispatch toast notification event for UI feedback diff --git a/src/browser/contexts/ThinkingContext.tsx b/src/browser/contexts/ThinkingContext.tsx index 864e36e662..bc59b88cf8 100644 --- a/src/browser/contexts/ThinkingContext.tsx +++ b/src/browser/contexts/ThinkingContext.tsx @@ -27,6 +27,7 @@ import { enforceThinkingPolicy, getAvailableThinkingLevels } from "@/common/util import { useMinThinkingLevels } from "@/browser/hooks/useMinThinkingLevels"; import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig"; import { useAPI } from "@/browser/contexts/API"; +import { requestActiveTurnThinkingLevel } from "@/browser/utils/activeTurnThinking"; import { clearPendingWorkspaceAiSettings, getWorkspaceAiSettingsFromMetadata, @@ -227,14 +228,10 @@ export const ThinkingProvider: React.FC = (props) => { reasoningMode: getCurrentReasoningMode(), }); // Mid-turn change: also request the new level for the active turn's next - // model step. No streaming gate here — the backend no-ops cheaply when - // idle, and gating on store state would break non-workspace mounts. + // model step. Non-workspace (project/global) mounts have no workspaceId + // and skip this inside the helper. if (props.workspaceId) { - api?.workspace - .setActiveTurnThinkingLevel({ workspaceId: props.workspaceId, thinkingLevel: level }) - .catch(() => { - // Best-effort: no active stream / transient IPC failure. - }); + requestActiveTurnThinkingLevel(api, props.workspaceId, level); } }, [ diff --git a/src/browser/utils/activeTurnThinking.ts b/src/browser/utils/activeTurnThinking.ts new file mode 100644 index 0000000000..e82b94775c --- /dev/null +++ b/src/browser/utils/activeTurnThinking.ts @@ -0,0 +1,25 @@ +import type { APIClient } from "@/browser/contexts/API"; +import type { ThinkingLevel } from "@/common/types/thinking"; + +/** + * Best-effort request to apply a thinking-level change to the active turn's + * NEXT model step (mid-turn override). Shared by every UI path that changes a + * workspace's thinking level (slider, keybinds, command palette) so they all + * behave identically during a stream. + * + * No streaming gate on purpose: the backend no-ops cheaply (accepted: false) + * when the workspace is idle or unknown, and the persisted workspace setting + * (updated separately by callers) still covers future turns. + */ +export function requestActiveTurnThinkingLevel( + api: APIClient | null | undefined, + workspaceId: string, + thinkingLevel: ThinkingLevel +): void { + if (!api || !workspaceId) { + return; + } + api.workspace.setActiveTurnThinkingLevel({ workspaceId, thinkingLevel }).catch(() => { + // Best-effort: transient IPC failure loses only the mid-turn nudge. + }); +}