Skip to content

🤖 feat: apply mid-turn thinking-level changes at the next model step#3718

Merged
ThomasK33 merged 5 commits into
mainfrom
reasoning-effort-anfe
Jul 13, 2026
Merged

🤖 feat: apply mid-turn thinking-level changes at the next model step#3718
ThomasK33 merged 5 commits into
mainfrom
reasoning-effort-anfe

Conversation

@ThomasK33

Copy link
Copy Markdown
Member

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/anthropic passes effort: "xhigh" and adaptive thinking.display through provider options directly, so mid-turn rebuilds produce correct wire output without a parallel hack path.

Implementation

  • Per-turn override holder (src/node/services/thinkingOverride.ts): a small mutable { pending, applied, onApplied } object created by AgentSession once 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).
  • New ORPC route workspace.setActiveTurnThinkingLevel: writes pending (last-write-wins) on the live session; returns accepted: false when idle/unknown. The frontend ThinkingContext fires it best-effort after the existing persistence path, so workspace-setting semantics are unchanged.
  • Consumption in 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.jsonc extras). The live request.providerOptions object is mutated in place because the AI SDK deep-merges per-step options and cannot remove stale nested keys (e.g. Anthropic thinking on a → off transition); the rebuilt options are also returned from prepareStep as defense in depth.
  • Model fallback rebinds the same holder with a closure bound to the fallback model; same-stream retries preserve holder and mutated options.
  • Out of scope (persisted setting still applies next turn): xAI grok-4-1-fast off↔non-off (requires a model-instance swap), model changes, OpenAI Pro reasoningMode, 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):

  • low → high mid-turn (Sonnet 4.5): budget_tokens 4000 → 20000 at the next step.
  • high → off mid-turn: thinking key deleted from subsequent requests, zero API errors (in-place deletion path).
  • off → medium mid-turn: thinking added mid-turn.
  • max → xhigh mid-turn (Sonnet 5, adaptive): output_config.effort "max" → "xhigh".
  • Post-turn slider change: route returns accepted: false; only the persisted setting updates.
  • Phase 0 gate: steady-state native xhigh sends output_config.effort + thinking: {type: "adaptive", display: "summarized"} directly — no effort header, no fetch-wrapper rewrite.
  • Persisted assistant metadata.thinkingLevel reflects the final applied level in every scenario; UI verified at 375px width.

Risks

  • StreamManager prepareStep is 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).
  • In-place providerOptions mutation relies on the SDK reading the object captured at streamText() time. If a future SDK snapshot-copies options, the prepareStep return value covers it (asserted by test).
  • Anthropic xhigh hack removal changes the wire path for existing xhigh users of Opus 4.6/Sonnet 4.6 (non-native max fallback) 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)

  • Send path captures thinkingLevel in SendMessageOptions (src/browser/hooks/useSendMessageOptions.ts); backend clamps once via enforceThinkingPolicy in AgentSession (src/node/services/agentSession.ts:3616-3640, floor = config.json minThinkingLevelByModelresolveMinimumThinkingLevel).
  • AIService.streamMessage builds providerOptions once (buildProviderOptions, src/node/services/aiService.ts:2393-2405) and merges providers.jsonc extras (:2422-2449), then calls StreamManager.startStream with the fixed level (:2800-2832).
  • streamText() is invoked once per stream (StreamManager.createStreamResult, src/node/services/streamManager.ts:1617-1660) with providerOptions: request.providerOptions. Its prepareStep hook already returns per-step overrides (messages, activeTools) and follows the shared-mutable-state precedent of toolSearchState.
  • AI SDK 7 (ai@7.0.19): prepareStep may return per-step providerOptions, 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; undefined cannot delete a key. PrepareStepResult has no headers field.
  • The SDK reads the outer providerOptions reference on every step (mergeObjects(providerOptions, …)), so in-place mutation of streamInfo.request.providerOptions is visible to subsequent steps.
  • Slider chain: ThinkingSlideruseThinkingLevelThinkingContext.setThinkingLevel (src/browser/contexts/ThinkingContext.tsx:219-238) → persistAgentAiSettingsapi.workspace.updateAgentAISettings. Nothing reaches the active stream.
  • Precedent for renderer→in-flight-stream mutation: api.workspace.interruptStreamWorkspaceService.interruptStream (workspaceService.ts:8308-8381) → getOrCreateSession(workspaceId)AgentSession.interruptStreamAIService.stopStreamStreamManager.stopStream.
  • Metadata: streamInfo.thinkingLevel feeds 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).
  • Anthropic xhigh wire hack is obsolete (verified against installed @ai-sdk/anthropic@4.0.11 source):
    • The hack's own comments state it existed because the SDK Zod schema "doesn't accept xhigh yet" (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 sends effort: "max" through the SDK plus x-mux-anthropic-effort: xhigh, and the Anthropic fetch wrapper rewrites output_config.effort on the wire (providerModelFactory.ts:342-401).
    • SDK 4.0.11 now accepts it: effort: z.enum(['low','medium','high','xhigh','max']) (anthropic-language-model-options.ts:209). Explicit provider options bypass the SDK's supportsXhighEffort model gating — that gating only applies to the SDK's top-level reasoning CallOption mapping, which Mux does not use; explicit anthropicOptions.effort flows verbatim into output_config.effort (anthropic-language-model.ts:398-465). This also covers Mythos (absent from the SDK capability table) exactly like today's wrapper rewrite.
    • Same for thinking.display: the SDK schema now has display: 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's display ??= "summarized" injection is also expressible directly in provider options.
    • Route safety: the anthropic options branch runs iff formatProvider === "anthropic", which is the same condition as routePassesHeaders (resolveProviderOptionsNamespaceKey(origin, routeProvider) === origin). So every route that emits anthropic effort today also gets the header + wrapper rewrite — direct and mux-gateway requests already carry "xhigh" (and display) 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.
    • All Anthropic request builders go through buildProviderOptions (main stream aiService.ts:2393, fallback :2710, advisor tools/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):

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;
}
ThinkingContext.setThinkingLevel (mid-turn)
  ├─ persistAgentAiSettings(...)                     (unchanged; future turns)
  └─ api.workspace.setActiveTurnThinkingLevel        (new, fire-and-forget)
       → router → WorkspaceService → sessions.get(id)?.setActiveTurnThinkingLevel(level)
            └─ session.activeTurnThinkingOverride != null (turn active: PREPARING or STREAMING)
                 ? holder.pending = level → { accepted: true }
                 : { accepted: false }    (idle: persisted settings already cover next turn)

AgentSession turn lifecycle
  ├─ turn durably accepted, options finalized (sendMessage / resumeStream): create holder
  │    BEFORE the user-message emit / onAccepted / any await, then
  │    pass explicitly into streamWithHistory → StreamMessageOptions
  │    → AIService → buildStreamRequestConfig → request.thinkingOverrideState (same object)
  └─ turn end (setTurnPhase→IDLE, interrupt, dispose): identity-guarded clear
       (if (this.activeTurnThinkingOverride === holder) … = null) — pending expires

StreamManager prepareStep (runs before EVERY step, including step 1 —
so a change during PREPARING/startup applies to the turn's first provider request)
  ├─ pending? → rebuildForLevel(pending)   // closure supplied by AIService
  │     ├─ policy clamp + resolveEffectiveThinkingLevel for the STREAM's model
  │     ├─ not applicable / no-op → clear pending, keep current level
  │     └─ applicable →
  │           • in-place replace streamInfo.request.providerOptions content
  │           • state.applied = effectiveLevel; state.onApplied(effectiveLevel)
  │             (onApplied sets streamInfo.thinkingLevel → partials/final metadata)
  │           • return { providerOptions: rebuilt } from prepareStep (defense in depth)
  └─ no pending → existing behavior

Why both in-place mutation and a per-step return: the SDK deep-merge cannot delete keys (e.g. Anthropic thinking object when moving to off), so mutating the live request object is the authoritative mechanism; returning the rebuilt options from prepareStep is 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 prepareStep reads, closing the PREPARING window with zero extra consumption points; and because prepareStep also 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):

  1. Clamp first: target level is clamped with enforceThinkingPolicy(streamModel, target, minThinkingLevel, providersConfig). If clamped target == currently effective level → no-op.
  2. xAI grok-4-1-fast: off ↔ non-off selects 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.)
  3. Everything else applies at the next step: OpenAI reasoningEffort, all Anthropic levels including into/out of native xhigh (after Phase 0 removes the header hack — effort becomes pure provider options), Anthropic off↔on via in-place replacement, Google thinkingConfig, OpenRouter reasoning.

After Phase 0, buildRequestHeaders is thinking-level-independent (the only remaining conditional header is the 1M-context beta, keyed on model+config), so prepareStep's inability to change headers no longer constrains any level transition.

Out of scope (v1): OpenAI Pro reasoningMode toggle 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-start event keeps the initial level, so WorkspaceStore.currentThinkingLevel shows 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" and display are applied only when anthropicSupportsNativeXhigh(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", no display) keep identical request fields:

  1. src/common/types/thinking.ts:
    • AnthropicEffortLevel gains "xhigh". getAnthropicEffort becomes model-aware: getAnthropicEffort(level, capabilityModel) returns "xhigh" only when level === "xhigh" && anthropicSupportsNativeXhigh(capabilityModel), else the current mapping (xhigh→"max"). The ANTHROPIC_EFFORT table itself keeps xhigh: "max" as the non-native fallback.
    • Replace the stale "SDK doesn't accept xhigh yet" comment with the new rationale (explicit provider options pass through verbatim as of @ai-sdk/anthropic@4.0.11; add an upgrade-coupling note: the SDK's supportsXhighEffort gating applies only to top-level reasoning mapping — re-verify on major SDK upgrades).
  2. src/common/utils/ai/providerOptions.ts:
    • Anthropic adaptive branch: pass capabilityModel to getAnthropicEffort, and set thinking: { type: "adaptive", display: "summarized" } only when anthropicSupportsNativeXhigh(capabilityModel) (matching the wrapper's targetsNativeXhighModel condition; other adaptive models keep { type: "adaptive" } with no display, as today). Drop the stale comment.
    • buildRequestHeaders: delete the xhigh header branch and the now-unused thinkingLevel parameter (update both call sites, aiService.ts:2413-2420 and :2726); delete the MUX_ANTHROPIC_EFFORT_OVERRIDE_HEADER export.
  3. src/node/services/providerModelFactory.ts (Anthropic fetch wrapper): remove the effort-override header handling and the display ??= injection block (incl. the targetsNativeXhighModel detection that exists solely for them). Cache-control injection stays untouched.
  4. Update existing tests: 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:
    • native-xhigh model at xhigh → effort: "xhigh" + thinking.display: "summarized";
    • non-native adaptive model (e.g. Opus 4.6) at its ladder top → effort unchanged from today ("max"/clamped) and no display field.
  5. Gate: targeted tests + one manual devtools.jsonl check that a steady-state xhigh request is wire-field equivalent to today: same Anthropic body fields (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.ts

  1. Type: ActiveTurnThinkingOverride lives 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 in src/common/types/.
  2. Extend StreamRequestConfig with:
    • thinkingOverrideState?: ActiveTurnThinkingOverridethe 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)
  3. Thread both through buildStreamRequestConfigcreateStreamAtomicallystartStream params (same plumbing as onChunk / toolSearchState).
  4. Stable providerOptions object: in buildStreamRequestConfig, when a rebuild closure is present, normalize providerOptions to a guaranteed mutable object (const finalProviderOptions = providerOptions ?? {}) before it is captured by streamText(). Without this, an initially-undefined options value would make later in-place mutation unobservable (the SDK closes over the value passed at creation). Unit-test this identity behavior.
  5. In createStreamResult's prepareStep (before the existing if (rewritten === stepMessages && activeTools === undefined) return undefined; early-return), add:
    const thinkingOverride = applyPendingThinkingOverride(request); // helper below
    and include ...(thinkingOverride ? { providerOptions: thinkingOverride } : {}) in the returned object (adjust the early-return condition accordingly). Because prepareStep runs before every step including the first, a change made during AIService startup is applied to the turn's first provider request.
  6. New private helper applyPendingThinkingOverride(request):
    • reads request.thinkingOverrideState?.pending; returns undefined if unset;
    • always clears pending (consume-once; a failed/no-op application must not retry every step);
    • calls request.rebuildProviderOptionsForThinkingLevel(pending); on nulllog.debug (include workspace, from/to level, skip reason) + return undefined;
    • in-place replaces the contents of request.providerOptions (delete own keys, Object.assign the rebuilt object — object identity preserved) so the SDK's per-step deep-merge and all retry paths observe it;
    • sets state.applied = effectiveLevel, invokes state.onApplied?.(effectiveLevel), returns the rebuilt options object.
  7. Wire the metadata sink in createStreamAtomically, immediately after streamInfo is constructed (and before any code path can trigger a step):
    if (request.thinkingOverrideState) {
      request.thinkingOverrideState.onApplied = (level) => { streamInfo.thinkingLevel = level; };
      if (request.thinkingOverrideState.applied) streamInfo.thinkingLevel = request.thinkingOverrideState.applied;
    }
    (The catch-up sync covers re-attachment of a holder that already applied a level.) Partial writes (:2223), stream-end metadata (:3059), and the final assistant message then pick up the new level with no polling in the process loop. Note: createStreamResult is called before streamInfo exists (:1698-1727); the SDK does not invoke prepareStep synchronously during streamText() construction, but the catch-up sync makes ordering safe regardless.
  8. No StreamManager setter method is needed — the session writes to the shared holder directly (see Phase 3).
  9. Retry/fallback interplay:
    • Empty-output retry & previousResponseId retry: both reuse/spread streamInfo.request, so the mutated providerOptions, holder reference, closure, and onApplied sink carry forward automatically (streamManager.ts:2484-2521, :3540-3589). No changes needed beyond confirming in tests.
    • Model fallback (:2331-2445): the fallback prepare context (second argument of ModelFallbackOptions.prepare, currently { continuation?: … }) gains thinkingLevelOverride?: ThinkingLevel, populated from request.thinkingOverrideState?.pending ?? request.thinkingOverrideState?.applied (then clear pending — it is folded into the fallback's baseline). PreparedModelFallback (prepared.data) gains the fallback-model-bound rebuildProviderOptionsForThinkingLevel. nextRequest is built with the same holder object (so the session setter keeps working) and the new closure — both attached in buildStreamRequestConfig(...) before createStreamResult(nextRequest, …) is called, in case the SDK eagerly prepares the first fallback step; onApplied still points at the same streamInfo, so no re-wiring is required.

Phase 2 — AIService: rebuild closure (+ fallback) (~70 LoC)

src/node/services/aiService.ts (inside streamMessage, after mergedProviderOptions is computed at ~:2449)

  1. Build the closure, capturing everything already in scope. It must reproduce the exact initial effective-level pipeline (AgentSession floor clamp → AIService resolveEffectiveThinkingLevel, :1097-1101), not just enforceThinkingPolicy:
    const rebuildProviderOptionsForThinkingLevel = (level: ThinkingLevel) => {
      const clamped = enforceThinkingPolicy(modelString, level, minThinkingLevel, providersConfig);
      const effective = resolveEffectiveThinkingLevel(modelString, clamped, this.providerService.getConfig());
      if (effective === currentEffectiveLevelRef.current) return null;          // no-op
      if (isXaiGrokFastVariantSwap(modelString, 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); // shared helper, see note below
      currentEffectiveLevelRef.current = effective;
      return { effectiveLevel: effective, providerOptions: merged };
    };
    Notes:
    • minThinkingLevel: AgentSession already computes the floor at :3616-3640; pass it down via a new optional StreamMessageOptions.minThinkingLevel field instead of re-resolving in AIService (single source of truth). Fallback when absent (internal callers): resolveMinimumThinkingLevel(modelString, undefined, providersConfig).
    • currentEffectiveLevelRef is a tiny { current: ThinkingLevel } initialized to effectiveThinkingLevel so consecutive overrides diff against the live level, not the send-time one.
    • Factor the extras-merge at :2431-2449 into a local mergeModelParameterExtras(options) used by both the initial build and the closure (DRY, no behavior change).
    • The grok skip predicate is a small pure helper colocated in src/common/utils/thinking/policy.ts (literal grok-4-1-fast check mirroring providerModelFactory.ts:2016). No Anthropic predicate is needed after Phase 0.
  2. New optional StreamMessageOptions fields: minThinkingLevel?: ThinkingLevel and activeTurnThinkingOverride?: ActiveTurnThinkingOverride (the session holder). Pass the holder and rebuildProviderOptionsForThinkingLevel into startStream (new params, adjacent to onChunk). When no holder is supplied (internal callers: compaction, sub-agent paths that don't need it yet), the feature is inert for that stream.
  3. Fallback prepare (:2575-2765): accept the new thinkingLevelOverride context field from StreamManager; compute nextThinkingLevel = resolveEffectiveThinkingLevel(nextModelString, enforceThinkingPolicy(nextModelString, thinkingLevelOverride ?? effectiveThinkingLevel, minThinkingLevelForNextModel, …), …), and include a new closure bound to the fallback model (with its own currentEffectiveLevelRef initialized to nextThinkingLevel) in the returned prepared.data so mid-turn changes keep working after a fallback hop.

Phase 3 — ORPC route + session holder lifecycle (~55 LoC)

  1. src/common/orpc/schemas/api.ts — add to the workspace namespace (near updateAgentAISettings), using the repo-standard Result wrapper like sibling mutating routes:
    setActiveTurnThinkingLevel: {
      input: z.object({ workspaceId: z.string(), thinkingLevel: ThinkingLevelSchema }),
      output: ResultSchema(z.object({ accepted: z.boolean() }), z.string()),
    },
    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.
  2. src/node/orpc/router.ts — handler mirroring updateAgentAISettings's shape, calling context.workspaceService.setActiveTurnThinkingLevel(input.workspaceId, input.thinkingLevel).
  3. src/node/services/workspaceService.ts:
    setActiveTurnThinkingLevel(workspaceId: string, level: ThinkingLevel): Result<{ accepted: boolean }, string> {
      const session = this.sessions.get(workspaceId.trim());
      if (!session) return Ok({ accepted: false });   // no session ⇒ nothing running
      return Ok(session.setActiveTurnThinkingLevel(level));
    }
    (Deliberately sessions.get, not getOrCreateSession — creating a session to tell it "change the turn you don't have" is pointless.)
  4. src/node/services/agentSession.ts — owns the holder lifecycle:
    • New private field activeTurnThinkingOverride: ActiveTurnThinkingOverride | null = null.
    • Create after the user-message append/persistence succeeds and optionsForStream is finalized — before emitting the user-message chat event, before internal.onAccepted, and before any other await/callback that could let the renderer's slider route arrive. NOT inside streamWithHistory (which runs only after runtime warmup). Concretely: in sendMessage, just ahead of the emitChatEvent({ …userMessage }) block (:2820-2828, well before setTurnPhase(TurnPhase.PREPARING); covers normal, edit, and queued-dispatch turns), and at resumeStream's equivalent point after its options are finalized. Assign this.activeTurnThinkingOverride = holder and pass holder explicitly as a parameter into streamWithHistory, which forwards it (plus minThinkingLevel) via the new StreamMessageOptions fields into aiService.streamMessage.
    • Pre-stream failure cleanup: any path that returns after holder creation without reaching a stream (onAccepted throws, startup abort via preparedTurnAbortController, streamWithHistory error) must identity-clear the holder (if (this.activeTurnThinkingOverride === holder) this.activeTurnThinkingOverride = null) — the existing startPreparedStream finally-block that resets PREPARING→IDLE is the natural place.
    • Setter:
      setActiveTurnThinkingLevel(level: ThinkingLevel): { accepted: boolean } {
        this.assertNotDisposed("setActiveTurnThinkingLevel");
        const holder = this.activeTurnThinkingOverride;
        if (!holder) return { accepted: false }; // idle: persisted settings cover the next turn
        holder.pending = level;                   // last write wins
        return { accepted: true };
      }
      The holder exists exactly while a turn is active (PREPARING through STREAMING), so no isBusy() heuristics are needed — and messages sitting in the queue are untouched (they dispatch later with their own captured options and a fresh holder).
    • Clear with an identity guard wherever the turn ends — if (this.activeTurnThinkingOverride === holder) this.activeTurnThinkingOverride = null; — in the turn's completion/abort paths (setTurnPhaseIDLE bookkeeping, 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.
    • Note: because prepareStep runs 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 in streamWithHistory is needed.

Phase 4 — Frontend wiring (~12 LoC)

src/browser/contexts/ThinkingContext.tsx, inside setThinkingLevel (:219-238), after persistAgentAiSettings(...):

if (props.workspaceId) {
  api?.workspace
    .setActiveTurnThinkingLevel({ workspaceId: props.workspaceId, thinkingLevel: level })
    .catch(() => {/* best-effort: no active stream / transient IPC failure */});
}
  • No renderer gating on "is streaming" — the backend no-ops cheaply when idle, and gating via useWorkspaceState(workspaceId).canInterrupt would break Storybook/test mounts of ThinkingProvider that have no registered store workspace.
  • Keybind path (INCREASE_THINKING/DECREASE_THINKING) already funnels through setThinkingLevel, so it inherits the behavior.
  • One-shot /model+level sends and setReasoningMode are untouched.

Phase 5 — Tests (~150 LoC test code; not counted in product LoC)

  1. src/node/services/streamManager.test.ts (follow the tool-search suite pattern: spyOn(aiSdk, "streamText"), capture prepareStep from mock.calls[0][0], invoke directly):
    • pending override → prepareStep returns rebuilt providerOptions, request.providerOptions is replaced in place (same object identity, old provider-namespace keys gone), pending cleared, applied recorded, onApplied invoked (streamInfo.thinkingLevel updated).
    • initially-undefined providerOptions: request config normalizes to a stable object so streamText receives the same reference that later mutation targets (identity assertion).
    • rebuild returns null → no providerOptions in the step result, pending cleared (no retry storm), request options untouched.
    • accepted-but-no-next-step: pending set after the final step → stream ends normally, final metadata keeps the last applied (or original) level, no leak into a later stream.
    • empty-output retry / previousResponseId retry: after application, re-created stream still carries mutated options (assert on streamInfo.request.providerOptions).
    • model fallback: prepare receives thinkingLevelOverride equal to the pending/applied level; nextRequest reuses the same holder object and gets the fallback-bound closure attached before createStreamResult runs.
  2. src/node/services/aiService.test.ts: closure behavior — clamps to floor then applies resolveEffectiveThinkingLevel, no-op when effective == current, skips xAI grok-4-1-fast off↔on, applies Anthropic transitions into/out of xhigh as plain rebuilds, produces merged extras identical in shape to the initial build for a plain level change.
  3. 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).
    • holder lifecycle: created immediately after durable acceptance/options finalization (before emit/onAccepted/any await), identity-cleared on pre-stream failure / turn end / interrupt / dispose (a post-turn setter call returns accepted: false); an edit-preempted turn's cleanup does not clear the replacement turn's holder.
    • PREPARING window: turn durably accepted, before setTurnPhase(PREPARING) / streamWithHistory / stream registration → slider route writes pending → the turn's first provider request consumes it (assert via captured prepareStep); pre-stream failure after holder creation identity-clears the holder.
    • queued messages are NOT retroactively changed: queue a follow-up mid-turn, change the level, let the queue dispatch → the follow-up turn streams with its captured options (fresh holder, no inherited pending).
  4. src/browser/contexts/ThinkingContext.test.tsx: setThinkingLevel fires api.workspace.setActiveTurnThinkingLevel with the workspaceId and level; NOT fired when the provider has no workspaceId (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):

  • After Phase 0: bun test src/common/utils/ai/providerOptions.test.ts src/node/services/providerModelFactory.test.ts + manual xhigh wire-bytes regression check (step 0 below).
  • After Phase 1–2: bun test src/node/services/streamManager.test.ts src/node/services/aiService.test.ts (targeted; QuickJS-heavy suites excluded as usual).
  • After Phase 3–4: MUX_ESLINT_CONCURRENCY=1 make static-check + bun test src/browser/contexts/ThinkingContext.test.tsx.

Dogfooding (evidence required; use dev-server-sandbox skill + agent-browser):

  1. Phase 0 regression check: on an Anthropic native-xhigh model at steady xhigh (no mid-turn change), capture one request in devtools.jsonl and diff output_config.effort + thinking.display against a pre-change capture — must match ("xhigh" / "summarized"), and the x-mux-anthropic-effort header must be absent from update.requestHeaders. Also capture one Opus/Sonnet 4.6 request at its top level — effort unchanged and no display field.
  2. Start a sandboxed dev server (dev-server-sandbox skill); ensure a direct provider route (disable sandbox gateway block or set routePriority: ["direct"]) so devtools.jsonl shows wire-level requests.
  3. Enable API Debug Logs. In a test workspace, send a prompt guaranteed to run several tool steps (e.g. "read these 3 files then summarize") with thinking = low.
  4. While the agent is mid-tool, move the slider to high (screenshot the slider + streaming state via agent-browser screenshot).
  5. After the turn: inspect ~/.mux/sessions/<ws>/devtools.jsonl step-update.update.rawRequest entries — step N (pre-change) must show the low reasoning config, step N+1 the high config (e.g. OpenAI reasoning.effort, Anthropic thinking.budget_tokens/output_config.effort). Capture the two JSON excerpts.
  6. Verify the persisted assistant message metadata (chat.jsonl) has thinkingLevel: "high" (last applied).
  7. Repeat once on an Anthropic model for the off→high in-place-replacement case (key deletion path), and once for high→xhigh on a native-xhigh model (output_config.effort flips to "xhigh" mid-turn — the transition Phase 0 unblocked).
  8. Mobile check (repo rule): capture a ~375px-wide viewport screenshot of the chat footer with the slider mid-turn — no layout change is expected, but narrow-viewport verification is mandatory for UI-adjacent changes.
  9. Record a screen capture of the slider change mid-turn (ffmpeg x11grab per repo notes; compress with libvpx to fit the 10MB attach limit). If recording is environmentally blocked, document the exact blocker and rely on the step-by-step screenshots + devtools excerpts.
  10. Attach all screenshots/video via attach_file and include the two rawRequest JSON excerpts in the summary.

Risks & mitigations

Risk Mitigation
SDK deep-merge can't delete keys (off-transitions) In-place mutation of the live request.providerOptions object is authoritative; per-step return is only defense-in-depth. Unit test asserts old namespace keys are gone.
SDK later snapshots/clones providerOptions at streamText() time Per-step returned providerOptions still overrides conflicting leaves; add a code comment flagging the coupling for SDK upgrades (like the MCP OAuth note).
Prompt-cache churn (Anthropic breakpoints / OpenAI implicit caching are level-independent; only reasoning params change) No cache keys are touched by the rebuild (same promptCacheKey/scope captured). Verified: buildProviderOptions derives caching from workspace/scope, not level.
prepareProviderRequestMessages used the send-time level for Anthropic reasoning-part replay Mid-turn steps use the SDK's accumulated step messages, not re-prepared history; divergence only affects reasoning-part filtering of old history within the same turn. Accept + document as v1 limitation in a code comment.
Fallback/retry paths dropping the override Explicit carry-through (Phase 1.9 / Phase 2.3) + tests.
Model-swap (grok off↔on) transitions silently doing the wrong thing Explicit skip predicate + debug log; level then applies next turn exactly as today.
Phase 0: SDK upgrade re-introduces effort gating for explicit provider options Coupling comment at ANTHROPIC_EFFORT (like the MCP OAuth note); Phase 0 test asserts effort: "xhigh" survives to the request; wire-field regression check in dogfooding.
Phase 0: mux-gateway rejects direct xhigh/display in provider options Already receiving these exact values today via the wrapper rewrite of the gateway body shape — proven in production; regression check covers it.
Phase 0: over-broad mapping sends xhigh/display to non-native models (Opus/Sonnet 4.6) Model-aware gating on anthropicSupportsNativeXhigh(capabilityModel) in both getAnthropicEffort and the display field, mirroring today's header/wrapper conditions; both-sides-of-the-gate unit tests + 4.6 dogfooding capture.
Slider change lands during PREPARING / AIService startup (stream not yet registered) and is lost Session-owned per-turn holder created at turn start and threaded down by reference; prepareStep runs before step 1, so the first provider request already honors it (Phase 3.4).
Initially-undefined providerOptions makes in-place mutation invisible to the SDK Normalize to a stable object in buildStreamRequestConfig (Phase 1.4) + identity unit test.
Override bleeding into queued follow-up turns Holder is per-turn and cleared at turn end; queued messages dispatch with their own captured options + fresh holder. Regression test in Phase 5.3.

Net LoC estimate (product code)

Phase Est. net LoC
0 Remove xhigh wire hack ~ −50
1 StreamManager ~65
2 AIService ~70
3 ORPC/session holder lifecycle ~55
4 Frontend ~12
Total ~150 net (upper bound ~200 with type plumbing overhead)

Acceptance criteria

  1. Changing the thinking slider during an active multi-step turn changes the reasoning parameters of the next provider request within the same turn (verified in devtools.jsonl).
  2. Changing the slider when idle behaves exactly as today (persisted settings only; route returns accepted: false).
  3. A change during the PREPARING window (turn accepted, stream not yet started) applies to that turn's first model request.
  4. Clamping: a mid-turn request below the model floor / above the ceiling applies the clamped effective level; a no-op change leaves the request untouched.
  5. Anthropic transitions into/out of native xhigh apply mid-turn; steady-state requests are wire-field equivalent to pre-refactor (native-xhigh: output_config.effort: "xhigh" + thinking.display: "summarized"; non-native adaptive: unchanged effort, no display; no x-mux-anthropic-effort header anywhere).
  6. The one excluded transition (xAI grok-4-1-fast off↔on) does not alter the in-flight stream and still applies on the next turn.
  7. Final assistant message metadata records the last applied level; interrupted/error partials also carry it.
  8. Empty-output retry, previousResponseId retry, and model fallback preserve the applied override.
  9. All targeted tests + make static-check pass.

Generated with mux • Model: anthropic:claude-fable-5 • Thinking: xhigh • Cost: $150.32

…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.
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread src/browser/contexts/ThinkingContext.tsx Outdated
@ThomasK33

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Nice work!

Reviewed commit: 96f1ad376f

ℹ️ 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".

@ThomasK33 ThomasK33 added this pull request to the merge queue Jul 13, 2026
Merged via the queue into main with commit 87e1187 Jul 13, 2026
40 of 42 checks passed
@ThomasK33 ThomasK33 deleted the reasoning-effort-anfe branch July 13, 2026 14:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant