Skip to content

🤖 feat: add support for Claude Sonnet 5#3664

Merged
ThomasK33 merged 1 commit into
mainfrom
models-74ba
Jun 30, 2026
Merged

🤖 feat: add support for Claude Sonnet 5#3664
ThomasK33 merged 1 commit into
mainfrom
models-74ba

Conversation

@ammar-agent

@ammar-agent ammar-agent commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds first-class support for Claude Sonnet 5, released by Anthropic on June 30, 2026. The curated SONNET model and bare sonnet alias now resolve to anthropic:claude-sonnet-5, with matching capability wiring across context limits, thinking levels, provider request shaping, native web fetch, docs, mocks, and tests.

Background

This follows the same built-in Sonnet bump pattern used for the earlier Sonnet 4.6 update: update the curated model entry, preserve legacy model metadata, refresh docs/defaults/mocks, and cover changed model behavior with targeted tests. Sonnet 5 uses the API id claude-sonnet-5, has native 1M context, supports 128K max output, uses adaptive thinking with effort control including native xhigh, and standard pricing is $3/M input and $15/M output.

Implementation

  • Updated KNOWN_MODELS.SONNET and docs/generated skill content to point at claude-sonnet-5.
  • Added Sonnet 5 token metadata while retaining Sonnet 4.6 for explicit legacy references.
  • Added Sonnet 5 to native 1M-context detection and native-xhigh Anthropic thinking policy.
  • Generalized Anthropic provider-option and fetch-wrapper comments/locals from Opus-only native-xhigh handling to shared native-xhigh handling for Opus 4.7+ and Sonnet 5+.
  • Updated mux-gateway default models, mock model-status copy, slash-command expectations, and unit/E2E assertions.
  • Fixed Anthropic native web_fetch version parsing so major-only IDs like claude-sonnet-5 qualify while dated pre-4.6 IDs remain unsupported.

Validation

  • bun test src/common/utils/ai/models.test.ts src/common/utils/ai/providerOptions.test.ts src/common/utils/thinking/policy.test.ts src/common/utils/tools/tools.test.ts src/node/services/providerModelFactory.test.ts src/node/services/providerService.test.ts src/node/services/tools/task.test.ts src/browser/utils/slashCommands/parser.test.ts
  • make typecheck
  • make fmt-check
  • env MUX_ESLINT_CONCURRENCY=1 make lint
  • make static-check
  • git diff --check

Risks

Low. The behavioral changes are limited to the curated Sonnet alias and Sonnet 5 capability predicates. Legacy Sonnet 4.6 entries remain available for explicit model strings, and the new native web-fetch parser is covered for both major-only IDs and dated Claude 4 IDs.


Generated with mux • Model: openai:gpt-5.5 • Thinking: xhigh • Cost: $6.81

@ammar-agent

Copy link
Copy Markdown
Collaborator Author

@codex review

@mintlify

mintlify Bot commented Jun 30, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
Mux 🟢 Ready View Preview Jun 30, 2026, 6:22 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 0d640886d4

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

@ammar-agent

Copy link
Copy Markdown
Collaborator Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Already looking forward to the next diff.

Reviewed commit: c0b21b65b7

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

@ammar-agent

Copy link
Copy Markdown
Collaborator 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: b235d5c78f

ℹ️ 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/common/constants/knownModels.ts
@ammar-agent

Copy link
Copy Markdown
Collaborator Author

Addressed the Codex P2 (src/common/constants/knownModels.ts:58 thread re: native web fetch):

supportsAnthropicNativeWebFetch() only matched two-segment IDs (claude-{variant}-{major}-{minor}), so the new major-only claude-sonnet-5 — and claude-fable-5 / claude-mythos-5 — fell into the older-model path and lost the provider-native web_fetch override. Fixed in b6da973cf3 by making the minor segment optional (/^claude-\w+-(\d+)(?:-(\d{1,2}))?(?:-|$)/) so any major ≥ 5 qualifies, while date-based pre-4.6 IDs like claude-sonnet-4-20250514 still correctly parse as major=4 / no-minor and stay unsupported. Exported the helper and added a test.each table in tools.test.ts covering Sonnet 5 / Fable 5 / Mythos 5 / the 4.6 cutoff / the date-suffix edge case.

@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: b6da973cf3

ℹ️ 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/common/utils/tools/tools.ts Outdated
@ammar-agent

Copy link
Copy Markdown
Collaborator Author

Addressed the second Codex P2 (src/common/utils/tools/tools.ts:505 thread re: legacy 3.x IDs):

Making the minor segment optional let the regex misparse legacy major-first IDs like claude-3-5-sonnet-20241022 / claude-3-7-sonnet-20250219\w+ matched the leading 3, then the 5/7 was read as the major and the trailing -sonnet satisfied (?:-|$), wrongly enabling native web_fetch. Fixed in the latest commit by matching the variant segment as [a-z]+ (variant-first naming only), so major-first 3.x IDs no longer match and keep Mux's local fetch tool. Added regression cases for claude-3-5-sonnet-20241022, claude-3-7-sonnet-20250219, and claude-3-5-haiku-20241022 to the parser test table.

@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: ed36bbb30e

ℹ️ 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/common/constants/knownModels.ts
@ammar-agent

Copy link
Copy Markdown
Collaborator Author

Addressed the third Codex P2 (src/common/constants/knownModels.ts:58 thread re: sampling overrides):

Verified against Anthropic's Sonnet 5 "what's new": non-default temperature/top_p/top_k return a 400 on Sonnet 5, a constraint introduced with Opus 4.7. Since the sonnet alias now resolves to Sonnet 5, an existing "*": { "temperature": 0.7 } config would have started failing every request.

Fix (latest commit): resolveModelParameterOverrides now strips those three sampling knobs (keeping max_output_tokens and other settings) whenever anthropicRejectsNonDefaultSampling(targetModel) is true — i.e. the effort/adaptive-thinking generation (Opus 4.7+, Sonnet 5+, Fable/Mythos), which is exactly the native-xhigh model set. Centralized in the resolver so both the primary and fallback resolve paths are covered, and added table tests (drops for Sonnet 5 / Opus 4.7 / Opus 4.8 / Fable 5 / Mythos 5; keeps for Sonnet 4.6 / Opus 4.6 / Opus 4.5; honors the effective routed model id).

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 0d55958151

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

Copy link
Copy Markdown
Member

@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: 6ef83a7b8f

ℹ️ 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/common/utils/tools/tools.ts Outdated
Update the built-in Sonnet alias to claude-sonnet-5 and wire its native 1M context, adaptive thinking, native xhigh effort, native web_fetch, mux-gateway defaults, docs, mocks, and tests.\n\n---\n\n_Generated with `mux` • Model: `openai:gpt-5.5` • Thinking: `xhigh` • Cost: `2617797{MUX_COSTS_USD:-0}`_\n\n<!-- mux-attribution: model=openai:gpt-5.5 thinking=xhigh costs=6.81 -->
@ThomasK33

Copy link
Copy Markdown
Member

@codex review

Please take another look. Addressed the native web_fetch regex finding by requiring an alphabetic Claude variant segment and adding regression coverage for claude-3-5-sonnet-20241022 and claude-3-7-sonnet-20250219.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: 7a983e8048

ℹ️ 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 Jun 30, 2026
Merged via the queue into main with commit e1169d7 Jun 30, 2026
22 of 24 checks passed
@ThomasK33 ThomasK33 deleted the models-74ba branch June 30, 2026 21:07
@mux-bot mux-bot Bot mentioned this pull request Jul 1, 2026
jim-fung pushed a commit to jim-fung/mux that referenced this pull request Jul 1, 2026
## Summary

This is the long-lived **auto-cleanup** PR. Each run, the auto-cleanup
agent reviews new commits merged to `main`, rebases onto the latest
`main`, and applies at most one extremely low-risk, behavior-preserving
cleanup. The branch accumulates a small stack of independent cleanups
until it is merged.

**This run** extracts the duplicated model-string normalization chain
(`trim` → `toLowerCase` → strip `provider:` prefix → strip `namespace/`
segment) that `anthropicSupportsNativeXhigh`
(`src/common/types/thinking.ts`) and `getExplicitThinkingPolicy`
(`src/common/utils/thinking/policy.ts`) each inlined verbatim into a
shared `stripModelProviderPrefixes` helper. This area was just touched
by coder#3664 (Claude Sonnet 5), which relies on
`anthropicSupportsNativeXhigh` for capability detection.
Behavior-preserving.

## Included cleanups (diff vs `main`)

1. **`refactor: extract shared stripAnsiControlChars helper`** —
`src/node/services/bashMonitorWakeStore.ts` and
`src/node/services/backgroundProcessManager.ts` each defined a
byte-for-byte identical `ANSI_ESCAPE_PATTERN` regex and inlined the same
strip-ANSI + drop-control-chars expression
(`sanitizeBashMonitorWakeLine` / `sanitizeMonitorLine`). Both now import
the new `stripAnsiControlChars` from `src/node/utils/ansi.ts`.
2. **`refactor: dedupe InstructionsTab formatBytes into shared helper`**
— `src/browser/components/InstructionsTab/InstructionsTab.tsx` defined a
local `formatBytes(n)` that was byte-for-byte identical (only the
parameter name differed) to the already-shared `formatBytes` in
`src/common/utils/formatBytes.ts` (used by `FileReadToolCall`,
`WebFetchToolCall`, `AttachFileToolCall`, `AgentSkillReadFileToolCall`,
and the CLI tool formatters). The local copy is removed and the shared
helper is imported.
3. **`refactor: drop dead hasTrailingNewline ternary branches in
getOutput`** — `src/node/services/backgroundProcessManager.ts` had two
`hasTrailingNewline ? allLines.slice(0, -1) : allLines.slice(0, -1)`
ternaries inside `getOutput` whose two branches were identical, so the
condition never affected the result. Both are replaced with the
unconditional `allLines.slice(0, -1)`. In the first occurrence the
now-unused `hasTrailingNewline` local is also removed; in the second it
is kept because it is still consulted when computing
`incompleteLineBuffer`.
4. **`refactor: extract shared stripModelProviderPrefixes helper`** —
`anthropicSupportsNativeXhigh` (`src/common/types/thinking.ts`) and
`getExplicitThinkingPolicy` (`src/common/utils/thinking/policy.ts`) each
inlined the identical `trim → toLowerCase → strip 'provider:' → strip
'namespace/'` chain to reduce a model string to its bare model id for
capability matching. The chain moves into a new exported
`stripModelProviderPrefixes` helper in `thinking.ts`; both call sites
now use it. In `getExplicitThinkingPolicy` the two intermediate locals
(`normalized`, `withoutPrefix`) collapse into the single
`withoutProviderNamespace` the helper returns.

## Background

These cleanups remove duplicated or dead logic that drifts silently.

- Runs 1–2 deduped byte-identical helpers (`stripAnsiControlChars`,
`formatBytes`) that had been copied across files.
- Run 3 followed up coder#3663, which simplified the `hasTrailingNewline ? X
: X` pattern in `processMonitorContent` but left two identical instances
in `getOutput` untouched.
- Run 4 targets the model-string normalization that coder#3664 (Claude Sonnet
5) leaned on: `getExplicitThinkingPolicy` and
`anthropicSupportsNativeXhigh` both need the bare model id and had
copy-pasted the same normalization. Consolidating it keeps capability
predicates consistent about how `provider:` prefixes and gateway
`namespace/` segments are stripped.

## Implementation

- `stripAnsiControlChars` (run 1): new `src/node/utils/ansi.ts` strips
ANSI/VT escape sequences and drops non-printable control characters
(keeping tab and any code point >= 0x20). The regex was copied verbatim
from the prior inline definitions, so it is byte-identical.
- `formatBytes` dedup (run 2): the local function in
`InstructionsTab.tsx` is deleted and `formatBytes` is imported from
`@/common/utils/formatBytes`; the shared helper is
character-for-character equivalent, so the single call site renders
identical output.
- `hasTrailingNewline` dedup (run 3): the two dead ternaries in
`getOutput` collapse to `allLines.slice(0, -1)`. The polling-loop
occurrence dropped its `hasTrailingNewline` declaration (it had no other
use in that scope); the final-processing occurrence keeps the
declaration since `proc.incompleteLineBuffer` still reads
`!hasTrailingNewline`.
- `stripModelProviderPrefixes` (run 4): the new exported helper runs the
same two `.replace()` calls in the same order as the prior inline
chains, so it is behavior-identical. `getThinkingDisplayLabel` in the
same file is intentionally left alone — it only strips the `provider:`
prefix and still inspects `withoutPrefix.startsWith("openai/")`, so it
needs the un-stripped namespace and does not fit this helper.

## Validation

- `bun test src/common/utils/thinking/policy.test.ts
src/common/utils/ai/providerOptions.test.ts
src/common/utils/ai/models.test.ts src/common/utils/tools/tools.test.ts`
(226 pass) exercises the normalization paths.
- `make static-check` passes for these TS-only changes (ESLint, both
TypeScript configs, and Prettier all green). The run's only failure is
`fmt-shell-check` aborting because `shfmt` is not installed in this
environment; the changes touch no shell/Docker files.

## Risks

None. All four cleanups are behavior-preserving: the shared helpers
reuse the verbatim prior logic, and collapsing a `cond ? X : X` ternary
cannot change the result since both branches are identical.

---

Auto-cleanup checkpoint: e1169d7

---

_Generated with `mux` • Model: `anthropic:claude-opus-4-8` • Thinking:
`xhigh` • Cost: `$0.00`_

<!-- mux-attribution: model=anthropic:claude-opus-4-8 thinking=xhigh
costs=0.00 -->

---------

Co-authored-by: mux-bot[bot] <264182336+mux-bot[bot]@users.noreply.github.com>
kaannsaydamm pushed a commit to kaannsaydamm/steward that referenced this pull request Jul 14, 2026
…oder#3718)

## 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.

---

<details>
<summary>📋 Implementation Plan</summary>

# 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`
`minThinkingLevelByModel` → `resolveMinimumThinkingLevel`).
- `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: `ThinkingSlider` → `useThinkingLevel` →
`ThinkingContext.setThinkingLevel`
(`src/browser/contexts/ThinkingContext.tsx:219-238`) →
`persistAgentAiSettings` → `api.workspace.updateAgentAISettings`.
Nothing reaches the active stream.
- Precedent for renderer→in-flight-stream mutation:
`api.workspace.interruptStream` → `WorkspaceService.interruptStream`
(`workspaceService.ts:8308-8381`) → `getOrCreateSession(workspaceId)` →
`AgentSession.interruptStream` → `AIService.stopStream` →
`StreamManager.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 (coder#3180),
extended for Sonnet 5 (coder#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`):

```ts
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;
}
```

```text
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?: 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)
3. Thread both through `buildStreamRequestConfig` →
`createStreamAtomically` → `startStream` 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:
   ```ts
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
`null` → `log.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):
   ```ts
   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`:
   ```ts
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:
   ```ts
   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`:
   ```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**:
     ```ts
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 (`setTurnPhase`→`IDLE` 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(...)`:

```ts
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`):

0. **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.
1. 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.
2. 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.
3. While the agent is mid-tool, move the slider to high (screenshot the
slider + streaming state via `agent-browser screenshot`).
4. 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.
5. Verify the persisted assistant message metadata (`chat.jsonl`) has
`thinkingLevel: "high"` (last applied).
6. 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).
7. 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.
8. 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.
9. 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.

</details>

---

_Generated with `mux` • Model: `anthropic:claude-fable-5` • Thinking:
`xhigh` • Cost: `$150.32`_

<!-- mux-attribution: model=anthropic:claude-fable-5 thinking=xhigh
costs=150.32 -->
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.

2 participants