🤖 feat: apply mid-turn thinking-level changes at the next model step#3718
Conversation
…rovider 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).
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.
- 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.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 2e0c28160d
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
|
@codex review |
|
Codex Review: Didn't find any major issues. Nice work! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Summary
Changing the thinking/reasoning slider during an active turn now applies to the next model step (the next provider request after the current tool execution), instead of only taking effect on the next user message. Idle-time changes behave exactly as before.
Background
Each assistant turn spans multiple provider requests separated by tool executions, and long agentic turns can run for many minutes. Previously the thinking level was frozen at send time, so lowering effort on an obviously-mechanical stretch (or raising it when the agent got stuck) required interrupting the stream. Provider APIs are stateless per request — nothing prevents per-step reasoning changes; Mux just never rebuilt provider options mid-turn.
As a prerequisite, the Anthropic xhigh wire hack (header-marker + fetch-wrapper body rewrite) is removed: the installed
@ai-sdk/anthropicpasseseffort: "xhigh"and adaptivethinking.displaythrough provider options directly, so mid-turn rebuilds produce correct wire output without a parallel hack path.Implementation
src/node/services/thinkingOverride.ts): a small mutable{ pending, applied, onApplied }object created byAgentSessiononce a turn is durably accepted, shared by reference through AIService into StreamManager. Created before any async startup work, which closes the race where a slider change lands while the turn is still PREPARING (applies to its first request).workspace.setActiveTurnThinkingLevel: writespending(last-write-wins) on the live session; returnsaccepted: falsewhen idle/unknown. The frontendThinkingContextfires it best-effort after the existing persistence path, so workspace-setting semantics are unchanged.prepareStep(StreamManager): the pending level is consumed exactly once per change; provider options are rebuilt via an AIService-owned closure that repeats the send-time pipeline (min-level floor → policy clamp → effective-level resolution → provider options →providers.jsoncextras). The liverequest.providerOptionsobject is mutated in place because the AI SDK deep-merges per-step options and cannot remove stale nested keys (e.g. Anthropicthinkingon a → off transition); the rebuilt options are also returned fromprepareStepas defense in depth.grok-4-1-fastoff↔non-off (requires a model-instance swap), model changes, OpenAI ProreasoningMode, ACP sessions.Validation
Dogfooded against the live Anthropic API (direct route) in a dev-server sandbox, with per-step wire evidence from
devtools.jsonl(rawRequest.thinking/.output_config, steps identified by message-count progression within one turn):budget_tokens4000 → 20000 at the next step.thinkingkey deleted from subsequent requests, zero API errors (in-place deletion path).thinkingadded mid-turn.output_config.effort"max" → "xhigh".accepted: false; only the persisted setting updates.output_config.effort+thinking: {type: "adaptive", display: "summarized"}directly — no effort header, no fetch-wrapper rewrite.metadata.thinkingLevelreflects the final applied level in every scenario; UI verified at 375px width.Risks
prepareStepis on the hot path of every stream. The override branch is a cheap early-out when no holder/pending exists; regression risk concentrates in multi-step turns with a mid-turn change (mitigated by consume-once tests, fallback/retry tests, and live dogfooding above).providerOptionsmutation relies on the SDK reading the object captured atstreamText()time. If a future SDK snapshot-copies options, theprepareStepreturn value covers it (asserted by test).maxfallback) and native models; both shapes are covered by provider-option tests and were dogfooded live.📋 Implementation Plan
Mid-turn thinking-level changes: apply at the next model step
Goal
When the user changes the thinking/reasoning slider while a turn is streaming, the new level should take effect at the next model step of the active stream (a "step" = one provider request; steps are separated by tool executions). Today the level is captured once at send time and is fixed for the whole turn.
Chosen semantics (per user): auto-apply at next step — no separate "Apply now" button, no interrupt/continue. Changing the slider mid-turn both (a) persists the workspace setting as today and (b) requests a next-step override on the active stream.
Verified current behavior (evidence)
thinkingLevelinSendMessageOptions(src/browser/hooks/useSendMessageOptions.ts); backend clamps once viaenforceThinkingPolicyinAgentSession(src/node/services/agentSession.ts:3616-3640, floor =config.jsonminThinkingLevelByModel→resolveMinimumThinkingLevel).AIService.streamMessagebuildsproviderOptionsonce (buildProviderOptions,src/node/services/aiService.ts:2393-2405) and mergesproviders.jsoncextras (:2422-2449), then callsStreamManager.startStreamwith the fixed level (:2800-2832).streamText()is invoked once per stream (StreamManager.createStreamResult,src/node/services/streamManager.ts:1617-1660) withproviderOptions: request.providerOptions. ItsprepareStephook already returns per-step overrides (messages,activeTools) and follows the shared-mutable-state precedent oftoolSearchState.ai@7.0.19):prepareStepmay return per-stepproviderOptions, which are deep-merged (mergeObjects) with the request-level options each step (node_modules/ai/src/generate-text/stream-text.ts:1861-1864). Omitted keys survive;undefinedcannot delete a key.PrepareStepResulthas noheadersfield.providerOptionsreference on every step (mergeObjects(providerOptions, …)), so in-place mutation ofstreamInfo.request.providerOptionsis visible to subsequent steps.ThinkingSlider→useThinkingLevel→ThinkingContext.setThinkingLevel(src/browser/contexts/ThinkingContext.tsx:219-238) →persistAgentAiSettings→api.workspace.updateAgentAISettings. Nothing reaches the active stream.api.workspace.interruptStream→WorkspaceService.interruptStream(workspaceService.ts:8308-8381) →getOrCreateSession(workspaceId)→AgentSession.interruptStream→AIService.stopStream→StreamManager.stopStream.streamInfo.thinkingLevelfeeds partials (streamManager.ts:2223-2225), stream-end metadata (:3059-3061) and the persisted assistant message; the model-fallback path already mutates it mid-stream (:2425-2428).@ai-sdk/anthropic@4.0.11source):src/common/types/thinking.ts:188-190,providerOptions.ts:641-648); introduced with Opus 4.7 (🤖 feat: update default model to Claude Opus 4.7 #3180), extended for Sonnet 5 (🤖 feat: add support for Claude Sonnet 5 #3664). Mux sendseffort: "max"through the SDK plusx-mux-anthropic-effort: xhigh, and the Anthropic fetch wrapper rewritesoutput_config.efforton the wire (providerModelFactory.ts:342-401).effort: z.enum(['low','medium','high','xhigh','max'])(anthropic-language-model-options.ts:209). Explicit provider options bypass the SDK'ssupportsXhighEffortmodel gating — that gating only applies to the SDK's top-levelreasoningCallOption mapping, which Mux does not use; explicitanthropicOptions.effortflows verbatim intooutput_config.effort(anthropic-language-model.ts:398-465). This also covers Mythos (absent from the SDK capability table) exactly like today's wrapper rewrite.thinking.display: the SDK schema now hasdisplay: z.enum(['omitted','summarized'])on adaptive thinking and forwards it (anthropic-language-model-options.ts:95-102,anthropic-language-model.ts:433-457), so the wrapper'sdisplay ??= "summarized"injection is also expressible directly in provider options.formatProvider === "anthropic", which is the same condition asroutePassesHeaders(resolveProviderOptionsNamespaceKey(origin, routeProvider) === origin). So every route that emits anthropicefforttoday also gets the header + wrapper rewrite — direct and mux-gateway requests already carry"xhigh"(anddisplay) on the wire in production. Sending them directly via provider options is wire-field equivalent (same Anthropic body fields; the mux-internal header disappears, but the wrapper stripped it before upstream anyway). Non-passthrough gateways (OpenRouter/copilot) use different provider-options branches and are unaffected.buildProviderOptions(main streamaiService.ts:2393, fallback:2710, advisortools/advisor.ts:159), so no other path depends on the wrapper rewrites.Design overview
Core primitive: a session-owned, per-turn live holder (one object per turn, shared by reference down the whole pipeline — same pattern as
toolSearchState):Why both in-place mutation and a per-step return: the SDK deep-merge cannot delete keys (e.g. Anthropic
thinkingobject when moving tooff), so mutating the live request object is the authoritative mechanism; returning the rebuilt options fromprepareStepis redundant-but-harmless insurance if the SDK ever changes how it snapshots options.Why a session-owned holder instead of a StreamManager setter: a slider change during AIService startup (model creation, MCP setup — seconds) would miss a not-yet-registered stream. With the holder created at turn start and threaded down by reference, writes land in the same object
prepareStepreads, closing the PREPARING window with zero extra consumption points; and becauseprepareStepalso runs before step 1, no special pre-stream handling is needed.Scope rules (what can/cannot switch mid-turn)
Applied inside the rebuild closure; when a transition is not applicable, the pending override is dropped for this turn (the new level still applies to the next turn via persisted settings, exactly as today):
enforceThinkingPolicy(streamModel, target, minThinkingLevel, providersConfig). If clamped target == currently effective level → no-op.grok-4-1-fast:off↔ non-offselects a different model instance (providerModelFactory.ts:2014-2020) → skipped. (Non-off↔non-off is a no-op on xAI anyway — no effort field in provider options.)reasoningEffort, all Anthropic levels including into/out of nativexhigh(after Phase 0 removes the header hack — effort becomes pure provider options), Anthropic off↔on via in-place replacement, GooglethinkingConfig, OpenRouterreasoning.After Phase 0,
buildRequestHeadersis thinking-level-independent (the only remaining conditional header is the 1M-context beta, keyed on model+config), soprepareStep's inability to change headers no longer constrains any level transition.Out of scope (v1): OpenAI Pro
reasoningModetoggle mid-turn; model changes mid-turn; already-queued messages (they keep the options captured at queue time); an explicit "Apply now" interrupt+continue action; ACP sessions; emitting a dedicated chat event for the change (metadata carries the final level; can be added later if requested).Known cosmetic limitation: the already-emitted
stream-startevent keeps the initial level, soWorkspaceStore.currentThinkingLevelshows the starting level until stream end. The persisted assistant message metadata reflects the last applied level.Implementation
Phase 0 — Remove the Anthropic xhigh wire hack (~ −50 LoC)
Prerequisite refactor that makes Anthropic effort a pure provider-options concern (wire-field-equivalent output, verified above), unblocking mid-turn xhigh transitions and deleting two wrapper rewrites.
The refactor must stay model-aware: today the wire-level
"xhigh"anddisplayare applied only whenanthropicSupportsNativeXhigh(model)matches (header gating + wrapper detection). The provider-options replacement mirrors exactly that gating so non-native adaptive models (Opus 4.6 / Sonnet 4.6:effort: "max", nodisplay) keep identical request fields:src/common/types/thinking.ts:AnthropicEffortLevelgains"xhigh".getAnthropicEffortbecomes model-aware:getAnthropicEffort(level, capabilityModel)returns"xhigh"only whenlevel === "xhigh" && anthropicSupportsNativeXhigh(capabilityModel), else the current mapping (xhigh→"max"). TheANTHROPIC_EFFORTtable itself keepsxhigh: "max"as the non-native fallback.@ai-sdk/anthropic@4.0.11; add an upgrade-coupling note: the SDK'ssupportsXhighEffortgating applies only to top-levelreasoningmapping — re-verify on major SDK upgrades).src/common/utils/ai/providerOptions.ts:capabilityModeltogetAnthropicEffort, and setthinking: { type: "adaptive", display: "summarized" }only whenanthropicSupportsNativeXhigh(capabilityModel)(matching the wrapper'stargetsNativeXhighModelcondition; other adaptive models keep{ type: "adaptive" }with nodisplay, as today). Drop the stale comment.buildRequestHeaders: delete the xhigh header branch and the now-unusedthinkingLevelparameter (update both call sites,aiService.ts:2413-2420and:2726); delete theMUX_ANTHROPIC_EFFORT_OVERRIDE_HEADERexport.src/node/services/providerModelFactory.ts(Anthropic fetch wrapper): remove the effort-override header handling and thedisplay ??=injection block (incl. thetargetsNativeXhighModeldetection that exists solely for them). Cache-control injection stays untouched.providerOptions.test.ts(effort mapping/xhigh header assertions),providerModelFactory.test.ts(wrapper rewrite tests → assert pass-through/no rewrite). Add assertions for both sides of the gate:effort: "xhigh"+thinking.display: "summarized";effortunchanged from today ("max"/clamped) and nodisplayfield.output_config.effort,thinking.display), with the mux-internal header gone (it was stripped before reaching Anthropic anyway, so upstream requests are unchanged).Keep this phase a separate commit so it can be reverted independently of the mid-turn feature.
Phase 1 — StreamManager: consume the holder in prepareStep (~65 LoC)
src/node/services/streamManager.tsActiveTurnThinkingOverridelives in a small node-local shared module (e.g.src/node/services/thinkingOverride.ts) — it carries a callback and is node runtime state, not IPC/shared data, so it does not belong insrc/common/types/.StreamRequestConfigwith:thinkingOverrideState?: ActiveTurnThinkingOverride— the session's holder, by reference; never created inside StreamManager (a locally-created object would be invisible to the session's setter).rebuildProviderOptionsForThinkingLevel?: (level: ThinkingLevel) => { effectiveLevel: ThinkingLevel; providerOptions: Record<string, unknown> } | null(closure built by AIService;
null⇒ not applicable / no-op per scope rules)buildStreamRequestConfig→createStreamAtomically→startStreamparams (same plumbing asonChunk/toolSearchState).buildStreamRequestConfig, when a rebuild closure is present, normalizeproviderOptionsto a guaranteed mutable object (const finalProviderOptions = providerOptions ?? {}) before it is captured bystreamText(). Without this, an initially-undefinedoptions value would make later in-place mutation unobservable (the SDK closes over the value passed at creation). Unit-test this identity behavior.createStreamResult'sprepareStep(before the existingif (rewritten === stepMessages && activeTools === undefined) return undefined;early-return), add:...(thinkingOverride ? { providerOptions: thinkingOverride } : {})in the returned object (adjust the early-return condition accordingly). BecauseprepareStepruns before every step including the first, a change made during AIService startup is applied to the turn's first provider request.applyPendingThinkingOverride(request):request.thinkingOverrideState?.pending; returnsundefinedif unset;pending(consume-once; a failed/no-op application must not retry every step);request.rebuildProviderOptionsForThinkingLevel(pending); onnull→log.debug(include workspace, from/to level, skip reason) + returnundefined;request.providerOptions(delete own keys,Object.assignthe rebuilt object — object identity preserved) so the SDK's per-step deep-merge and all retry paths observe it;state.applied = effectiveLevel, invokesstate.onApplied?.(effectiveLevel), returns the rebuilt options object.createStreamAtomically, immediately afterstreamInfois constructed (and before any code path can trigger a step)::2223), stream-end metadata (:3059), and the final assistant message then pick up the new level with no polling in the process loop. Note:createStreamResultis called beforestreamInfoexists (:1698-1727); the SDK does not invokeprepareStepsynchronously duringstreamText()construction, but the catch-up sync makes ordering safe regardless.streamInfo.request, so the mutatedproviderOptions, holder reference, closure, andonAppliedsink carry forward automatically (streamManager.ts:2484-2521,:3540-3589). No changes needed beyond confirming in tests.:2331-2445): the fallback prepare context (second argument ofModelFallbackOptions.prepare, currently{ continuation?: … }) gainsthinkingLevelOverride?: ThinkingLevel, populated fromrequest.thinkingOverrideState?.pending ?? request.thinkingOverrideState?.applied(then clearpending— it is folded into the fallback's baseline).PreparedModelFallback(prepared.data) gains the fallback-model-boundrebuildProviderOptionsForThinkingLevel.nextRequestis built with the same holder object (so the session setter keeps working) and the new closure — both attached inbuildStreamRequestConfig(...)beforecreateStreamResult(nextRequest, …)is called, in case the SDK eagerly prepares the first fallback step;onAppliedstill points at the samestreamInfo, so no re-wiring is required.Phase 2 — AIService: rebuild closure (+ fallback) (~70 LoC)
src/node/services/aiService.ts(insidestreamMessage, aftermergedProviderOptionsis computed at ~:2449)resolveEffectiveThinkingLevel,:1097-1101), not justenforceThinkingPolicy:minThinkingLevel:AgentSessionalready computes the floor at:3616-3640; pass it down via a new optionalStreamMessageOptions.minThinkingLevelfield instead of re-resolving in AIService (single source of truth). Fallback when absent (internal callers):resolveMinimumThinkingLevel(modelString, undefined, providersConfig).currentEffectiveLevelRefis a tiny{ current: ThinkingLevel }initialized toeffectiveThinkingLevelso consecutive overrides diff against the live level, not the send-time one.:2431-2449into a localmergeModelParameterExtras(options)used by both the initial build and the closure (DRY, no behavior change).src/common/utils/thinking/policy.ts(literalgrok-4-1-fastcheck mirroringproviderModelFactory.ts:2016). No Anthropic predicate is needed after Phase 0.StreamMessageOptionsfields:minThinkingLevel?: ThinkingLevelandactiveTurnThinkingOverride?: ActiveTurnThinkingOverride(the session holder). Pass the holder andrebuildProviderOptionsForThinkingLevelintostartStream(new params, adjacent toonChunk). When no holder is supplied (internal callers: compaction, sub-agent paths that don't need it yet), the feature is inert for that stream.:2575-2765): accept the newthinkingLevelOverridecontext field from StreamManager; computenextThinkingLevel = resolveEffectiveThinkingLevel(nextModelString, enforceThinkingPolicy(nextModelString, thinkingLevelOverride ?? effectiveThinkingLevel, minThinkingLevelForNextModel, …), …), and include a new closure bound to the fallback model (with its owncurrentEffectiveLevelRefinitialized tonextThinkingLevel) in the returnedprepared.dataso mid-turn changes keep working after a fallback hop.Phase 3 — ORPC route + session holder lifecycle (~55 LoC)
src/common/orpc/schemas/api.ts— add to theworkspacenamespace (nearupdateAgentAISettings), using the repo-standardResultwrapper like sibling mutating routes:accepted: false(success) = nothing active to override — persisted settings still cover the next turn.accepted: true= the level will be used for the current turn's next model step if one occurs; it expires silently otherwise. Errors are reserved for invalid input/disposed session.src/node/orpc/router.ts— handler mirroringupdateAgentAISettings's shape, callingcontext.workspaceService.setActiveTurnThinkingLevel(input.workspaceId, input.thinkingLevel).src/node/services/workspaceService.ts:sessions.get, notgetOrCreateSession— creating a session to tell it "change the turn you don't have" is pointless.)src/node/services/agentSession.ts— owns the holder lifecycle:activeTurnThinkingOverride: ActiveTurnThinkingOverride | null = null.optionsForStreamis finalized — before emitting the user-message chat event, beforeinternal.onAccepted, and before any other await/callback that could let the renderer's slider route arrive. NOT insidestreamWithHistory(which runs only after runtime warmup). Concretely: insendMessage, just ahead of theemitChatEvent({ …userMessage })block (:2820-2828, well beforesetTurnPhase(TurnPhase.PREPARING); covers normal, edit, and queued-dispatch turns), and atresumeStream's equivalent point after its options are finalized. Assignthis.activeTurnThinkingOverride = holderand passholderexplicitly as a parameter intostreamWithHistory, which forwards it (plusminThinkingLevel) via the newStreamMessageOptionsfields intoaiService.streamMessage.onAcceptedthrows, startup abort viapreparedTurnAbortController,streamWithHistoryerror) must identity-clear the holder (if (this.activeTurnThinkingOverride === holder) this.activeTurnThinkingOverride = null) — the existingstartPreparedStreamfinally-block that resets PREPARING→IDLE is the natural place.isBusy()heuristics are needed — and messages sitting in the queue are untouched (they dispatch later with their own captured options and a fresh holder).if (this.activeTurnThinkingOverride === holder) this.activeTurnThinkingOverride = null;— in the turn's completion/abort paths (setTurnPhase→IDLEbookkeeping,interruptStream,dispose()). The identity guard ensures a stale aborted turn's cleanup (e.g. an edit preempting a PREPARING turn) cannot clear the replacement turn's holder.prepareStepruns before step 1, a change landing during PREPARING/startup is applied to the first provider request via the normal pending path — no separate consumption point instreamWithHistoryis needed.Phase 4 — Frontend wiring (~12 LoC)
src/browser/contexts/ThinkingContext.tsx, insidesetThinkingLevel(:219-238), afterpersistAgentAiSettings(...):useWorkspaceState(workspaceId).canInterruptwould break Storybook/test mounts ofThinkingProviderthat have no registered store workspace.INCREASE_THINKING/DECREASE_THINKING) already funnels throughsetThinkingLevel, so it inherits the behavior./model+levelsends andsetReasoningModeare untouched.Phase 5 — Tests (~150 LoC test code; not counted in product LoC)
src/node/services/streamManager.test.ts(follow the tool-search suite pattern:spyOn(aiSdk, "streamText"), captureprepareStepfrommock.calls[0][0], invoke directly):prepareStepreturns rebuiltproviderOptions,request.providerOptionsis replaced in place (same object identity, old provider-namespace keys gone),pendingcleared,appliedrecorded,onAppliedinvoked (streamInfo.thinkingLevel updated).undefinedproviderOptions: request config normalizes to a stable object sostreamTextreceives the same reference that later mutation targets (identity assertion).null→ noproviderOptionsin the step result,pendingcleared (no retry storm), request options untouched.streamInfo.request.providerOptions).preparereceivesthinkingLevelOverrideequal to the pending/applied level;nextRequestreuses the same holder object and gets the fallback-bound closure attached beforecreateStreamResultruns.src/node/services/aiService.test.ts: closure behavior — clamps to floor then appliesresolveEffectiveThinkingLevel, no-op when effective == current, skips xAI grok-4-1-fast off↔on, applies Anthropic transitions into/out ofxhighas plain rebuilds, produces merged extras identical in shape to the initial build for a plain level change.src/node/services/agentSession.*.test.ts/workspaceService.test.ts:setActiveTurnThinkingLevel→{ accepted: false }when idle (no holder) and for unknown workspace;{ accepted: true }while a turn is active; last-write-wins on consecutive calls (holder.pending reflects the last level).accepted: false); an edit-preempted turn's cleanup does not clear the replacement turn's holder.setTurnPhase(PREPARING)/streamWithHistory/ stream registration → slider route writespending→ the turn's first provider request consumes it (assert via capturedprepareStep); pre-stream failure after holder creation identity-clears the holder.src/browser/contexts/ThinkingContext.test.tsx:setThinkingLevelfiresapi.workspace.setActiveTurnThinkingLevelwith the workspaceId and level; NOT fired when the provider has noworkspaceId(project/global scope).No tautological tests: every assertion above exercises branching (queued vs not, applied vs skipped, merge-deletion semantics), not copy.
Phase 6 — Validation gates & dogfooding
Local gates (run between phases, not only at the end):
bun test src/common/utils/ai/providerOptions.test.ts src/node/services/providerModelFactory.test.ts+ manual xhigh wire-bytes regression check (step 0 below).bun test src/node/services/streamManager.test.ts src/node/services/aiService.test.ts(targeted; QuickJS-heavy suites excluded as usual).MUX_ESLINT_CONCURRENCY=1 make static-check+bun test src/browser/contexts/ThinkingContext.test.tsx.Dogfooding (evidence required; use
dev-server-sandboxskill +agent-browser):devtools.jsonland diffoutput_config.effort+thinking.displayagainst a pre-change capture — must match ("xhigh"/"summarized"), and thex-mux-anthropic-effortheader must be absent fromupdate.requestHeaders. Also capture one Opus/Sonnet 4.6 request at its top level —effortunchanged and nodisplayfield.dev-server-sandboxskill); ensure a direct provider route (disable sandbox gateway block or setroutePriority: ["direct"]) sodevtools.jsonlshows wire-level requests.agent-browser screenshot).~/.mux/sessions/<ws>/devtools.jsonlstep-update.update.rawRequestentries — step N (pre-change) must show the low reasoning config, step N+1 the high config (e.g. OpenAIreasoning.effort, Anthropicthinking.budget_tokens/output_config.effort). Capture the two JSON excerpts.chat.jsonl) hasthinkingLevel: "high"(last applied).output_config.effortflips to"xhigh"mid-turn — the transition Phase 0 unblocked).attach_fileand include the two rawRequest JSON excerpts in the summary.Risks & mitigations
request.providerOptionsobject is authoritative; per-step return is only defense-in-depth. Unit test asserts old namespace keys are gone.streamText()timeproviderOptionsstill overrides conflicting leaves; add a code comment flagging the coupling for SDK upgrades (like the MCP OAuth note).promptCacheKey/scope captured). Verified:buildProviderOptionsderives caching from workspace/scope, not level.prepareProviderRequestMessagesused the send-time level for Anthropic reasoning-part replayANTHROPIC_EFFORT(like the MCP OAuth note); Phase 0 test assertseffort: "xhigh"survives to the request; wire-field regression check in dogfooding.xhigh/displayin provider optionsxhigh/displayto non-native models (Opus/Sonnet 4.6)anthropicSupportsNativeXhigh(capabilityModel)in bothgetAnthropicEffortand thedisplayfield, mirroring today's header/wrapper conditions; both-sides-of-the-gate unit tests + 4.6 dogfooding capture.prepareStepruns before step 1, so the first provider request already honors it (Phase 3.4).undefinedproviderOptions makes in-place mutation invisible to the SDKbuildStreamRequestConfig(Phase 1.4) + identity unit test.Net LoC estimate (product code)
Acceptance criteria
devtools.jsonl).accepted: false).xhighapply mid-turn; steady-state requests are wire-field equivalent to pre-refactor (native-xhigh:output_config.effort: "xhigh"+thinking.display: "summarized"; non-native adaptive: unchangedeffort, nodisplay; nox-mux-anthropic-effortheader anywhere).make static-checkpass.Generated with
mux• Model:anthropic:claude-fable-5• Thinking:xhigh• Cost:$150.32