Skip to content

fix(core): strip assistant-turn prefill and fix thinking.display on 4.6+ models - #8040

Merged
wenshao merged 2 commits into
QwenLM:mainfrom
netbrah:fix/anthropic-4-6-plus-prefill-and-thinking-display
Jul 31, 2026
Merged

wenshao merged 2 commits into
QwenLM:mainfrom
netbrah:fix/anthropic-4-6-plus-prefill-and-thinking-display

Conversation

@netbrah

@netbrah netbrah commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Fixes two related bugs affecting every Claude Opus/Sonnet 4.6+ model
and every 5.x family (Fable 5, Mythos 5, Sonnet 5, etc.) on the
Anthropic wire:

  1. Assistant-turn prefill 400s with no mitigation. A request whose
    final message has role: 'assistant' is rejected outright on every
    4.6+ model.
  2. thinking.display silently defaults to omitted on newer
    models,
    so callers surfacing reasoning to users see empty
    thinking text where the same code worked on Sonnet 4.6/Opus 4.6.

Why it is needed

Fixes #8039.

Prefill: Gemini-format history can end on a model turn with no
follow-up user turn (context trimming, subagent transcript replay).
processContents then emits a request whose last message is
role: 'assistant', which Anthropic rejects on every 4.6+ model:

This model does not support assistant message prefill. The
conversation must end with a user message.

Display: Anthropic's adaptive-thinking display field defaults
differently across model generations — summarized on Sonnet 4.6,
silently omitted on Opus 4.7+ and every 5.x family. qwen-code
never sets it, so newer models return thinking blocks with empty text
and no error.

Approach

  • stripTrailingAssistantPrefill — new converter option, gated in
    the generator on the existing modelSupportsAdaptiveThinking()
    check (the same 4.6+ cutoff already used for the adaptive-thinking
    shape, reused rather than duplicated). Drops a genuinely
    empty/whitespace-only trailing assistant message; appends a
    synthetic "Continue." user turn when the trailing assistant
    message carries real content (text, tool_use, thinking) so nothing
    the model already said is discarded.
  • display: 'summarized' — set unconditionally on the
    { type: 'adaptive' } thinking shape only. The pre-4.6
    budget_tokens shape and the explicit reasoning.budget_tokens
    escape hatch are untouched, since Anthropic's own docs scope the
    default-changed problem to adaptive thinking specifically.

Both follow the existing options-object pattern already used for the
converter's other passes (dropUnsignedAssistantThinking,
injectThinkingOnToolUseTurns, etc.) — no new abstractions.

Verification

Prefill, live against the Anthropic Messages API: identical
trailing-assistant request 400s identically on claude-opus-4.6,
claude-opus-4.7, claude-opus-4.8, claude-sonnet-4.6, and
claude-sonnet-5. The same request against claude-haiku-4.5 (a
genuinely pre-4.6-generation model — confirmed vertex_ai/claude-haiku-4-5@20251001,
not a proxy alias to a 4.6+ model) succeeds normally, confirming the
modelSupportsAdaptiveThinking() gate discriminates correctly rather
than being a blanket restriction.

Display, live against claude-sonnet-5 with a task requiring
genuine reasoning: without display set, the thinking block returns
with empty text; with display: 'summarized' set, the identical
request returns full non-empty reasoning text.

Unit tests: 16 new/updated cases —

  • 5 new converter.test.ts cases (drop-empty trailing message,
    append-synthetic-turn for real content, leave-trailing-user-message
    untouched, no-op when the option is unset, preserve a
    thinking-only trailing message)
  • 10 existing anthropicContentGenerator.test.ts assertions updated
    from { type: 'adaptive' } to { type: 'adaptive', display: 'summarized' } (all go through the real request-building code path)
  • 1 new case pinning that display is never set on the
    budget_tokens shape (pre-4.6 models and the explicit-override
    escape hatch)

Also clean: npx tsc --noEmit -p packages/core/tsconfig.json (0
errors), npx eslint on all four touched files (0 warnings), full
anthropicContentGenerator test directory (197/198 passing — the one
failure is a pre-existing, unrelated User-Agent assertion, confirmed
via git stash to fail identically on main before this change), and
the repo's own pre-commit lint-staged hook (prettier + eslint --fix
--max-warnings 0) ran clean on the commit.

Demo

N/A — internal wire-compatibility fix, no user-facing UI. Before/after
behavior is the curl reproductions in #8039 (400 → 200 for prefill;
empty → non-empty thinking text for display) rather than a screenshot.

….6+ models

Two related bugs affecting every Claude Opus/Sonnet 4.6+ model and
every 5.x family (Fable 5, Mythos 5, Sonnet 5, etc.) on the Anthropic
wire, both verified live against the Anthropic Messages API.

**Assistant-turn prefill 400s with no mitigation.** When Gemini-format
history ends on a model turn with no follow-up (context trimming drops
the next user turn, a subagent transcript is replayed mid-turn), the
converter forwards a request whose last message has role: 'assistant'.
Anthropic rejects this on every 4.6+ model:

  "This model does not support assistant message prefill. The
   conversation must end with a user message."

Adds `stripTrailingAssistantPrefill` as a converter option, gated in
the generator on the existing `modelSupportsAdaptiveThinking()` check
(the same 4.6+ cutoff already used for the adaptive-thinking shape —
reused rather than duplicated). Two cases:
  - Trailing assistant message is empty/whitespace-only (a leftover
    prefill artifact) — drop it.
  - Trailing assistant message carries real content (text, tool_use,
    thinking) — keep it in history and append a synthetic user turn
    ("Continue.") instead of discarding anything the model already
    said.

Live-verified: claude-opus-4.6/4.7/4.8, claude-sonnet-4.6, and
claude-sonnet-5 all 400 identically on a trailing-assistant request;
the identical request against claude-haiku-4.5 (a genuinely
pre-4.6-generation model, confirmed not a proxy alias) succeeds
normally, confirming the gate correctly discriminates on model
generation rather than being a universal restriction.

**thinking.display silently defaults to 'omitted' on newer models.**
Sonnet 4.6 defaults adaptive thinking's `display` field to
'summarized'; Opus 4.7+ and every 5.x family (including Sonnet 5)
silently changed the default to 'omitted' -- thinking blocks stream
back with empty text, no error, just a long pause before output where
the same code previously showed reasoning.

Sets `display: 'summarized'` explicitly on the `{ type: 'adaptive' }`
thinking shape only (the pre-4.6 budget_tokens shape and the explicit
reasoning.budget_tokens escape hatch are untouched -- Anthropic's docs
scope the default-changed problem to adaptive thinking specifically).
No-op on models whose default is already 'summarized'; required on
models whose default silently changed.

Live-verified: a task requiring genuine reasoning against
claude-sonnet-5 returns empty `thinking` text without `display` set,
and full non-empty reasoning text with `display: 'summarized'` set,
confirmed by direct comparison of both request variants against the
live API through the wire-capture layer.

Fixes QwenLM#8039

Updated 10 existing test assertions in anthropicContentGenerator.test.ts
that pinned the old `{ type: 'adaptive' }` shape (now
`{ type: 'adaptive', display: 'summarized' }`), added 5 new
converter.test.ts cases for the prefill-stripping behavior (drop-empty,
append-synthetic-turn, leave-user-message-untouched, option-unset
no-op, thinking-only trailing message), and 1 new
anthropicContentGenerator.test.ts case pinning that `display` is never
set on the budget_tokens shape.
@github-actions github-actions Bot added the review/self-reported The linked issue was opened by the PR author (self-reported) label Jul 29, 2026
@wenshao

wenshao commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Local verification report — maintainer review

I built and tested this branch locally (isolated worktree at b9cf3b0, merge-base cc617e6) and ran a mutation matrix against the new tests. Both bugs are real, the fix is correctly targeted, and the design holds up on the edge cases I could construct. I found two test-coverage gaps and one open question that needs a live API check.

Recommendation: merge after the one-character fixture fix below. The other two items are optional / informational.

What I ran

Check Result
npx vitest run src/core/anthropicContentGenerator/ 198/198 pass
npx tsc --noEmit -p packages/core/tsconfig.json exit 0
npx eslint on the 4 touched files --max-warnings 0 clean
Mutation matrix (12 mutants) 7 killed, 2 real gaps, 1 equivalent mutant
Edge-case probe against the real converter see below

The 197/198 "pre-existing User-Agent failure" mentioned in the PR description does not reproduce here — I get a clean 198/198. Worth double-checking whether that was a local artifact.

Premises check

Both claims match Anthropic's current documented behaviour:

  • Prefill — assistant-turn prefill is rejected from 4.6 onward (Opus 4.6 and Sonnet 4.6 included), and on every 5.x family. Gating on the existing modelSupportsAdaptiveThinking() (major > 4 || (major === 4 && minor >= 6)) is therefore the correct cutoff, not an approximation. Good call reusing it rather than adding a second version predicate.
  • display — thinking.display does default to omitted on Opus 4.7+/Sonnet 5/Fable 5, and was summarized on 4.6. So the diagnosis and the chosen value are right.

Correctness: the design holds

My main worry reading the diff was phase 2 appending a text-only "Continue." turn after an assistant turn carrying an unanswered tool_use — Anthropic rejects that with tool_use ids were found without tool_result blocks immediately after. It cannot happen: cleanOrphanedToolCalls runs earlier in the pipeline and drops the orphaned block (and the message, if that leaves it empty). I verified this against the real converter rather than by reading:

edge-case probe

Also checked and fine: the two phases compose correctly (empty turn popped, then the synthetic turn appended); a non-text block such as an inline image is correctly treated as real content; and cache_control lands on the deterministic "Continue." text, which makes the cached prefix strictly longer rather than breaking caching. The empty-messages[] result for a degenerate history is identical with the option off — pre-existing, not a regression.

Mutation matrix

mutation matrix

The display fix is well covered — dropping the field (M8) or flipping it back to omitted (M9) both fail the suite, and they do so through the real request-building path. Phase 2 of the prefill fix is well covered too (M2–M5, M7).

Gap 1 — the drop-empty test is vacuous (please fix)

converter.test.ts:2103 (drops a trailing empty assistant message…) uses { role: 'model', parts: [{ text: '' }] }. processContent only pushes a text block when part.text is truthy, and never emits a message with zero blocks — so that fixture produces no assistant message at all, and phase 1 has nothing to drop. The identical assertion passes with stripTrailingAssistantPrefill off:

vacuity control

One-character fix, verified: change the fixture to text: ' ' (whitespace-only). That does produce an assistant message, so it actually reaches phase 1 — and it's the case isEmptyAssistantMessage's .trim() exists for. I re-ran the matrix with the patched fixture: M1 and M6 both go from SURVIVED to KILLED.

Gap 2 — no generator-level coverage of the prefill wiring (optional)

stripTrailingAssistantPrefill appears in anthropicContentGenerator.ts (L692, L724) but in no generator test. Consequently:

  • M11 — hardcoding stripTrailingAssistantPrefill = false (feature dead on every model) → suite still green.
  • M10 — hardcoding it to true (4.6+ gate removed, prefill stripped on pre-4.6 models too) → suite still green.

The 5 new converter tests all pass the option in by hand, so nothing pins that the generator actually turns it on, nor that the version gate discriminates. Contrast the display change, which is pinned through the real path — that's why M8/M9 die and M10/M11 don't. Two assertions on anthropicState.lastCreateArgs (trailing assistant stripped on claude-opus-4-6, preserved on claude-opus-4-5) would close it and would also lock in the gate you deliberately reused.

Note: M12 is an equivalent mutant, not a gap

Collapsing phase 1's while to a single pop keeps the suite green — correctly. mergeConsecutiveAssistantMessages runs before this pass and guarantees no two adjacent assistant messages, so the loop can never iterate twice. The while is harmless defensive generality; an if would behave identically. No action needed.

Open question — is display accepted on Opus 4.6 / Sonnet 4.6?

This is the one thing I could not verify: this machine has no Anthropic credentials, so I could not re-run your live-API checks.

The concern is narrow but worth one curl before merge. display became the knob you need to set at Opus 4.7, when the default changed. This PR sets it unconditionally on the { type: 'adaptive' } shape, which modelSupportsAdaptiveThinking() also matches for claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4-6. If 4.6 rejects the unknown field rather than ignoring it, this turns a silent-empty-thinking bug into a hard 400 on every 4.6 request. Your PR description covers 4.7/4.8/5.x live but doesn't mention testing a 4.6 model with display set.

Two things make this worth an explicit check rather than an assumption:

  1. The pinned @anthropic-ai/sdk is 0.36.3, which predates adaptive thinking entirely — it has no ThinkingConfigAdaptive type. The repo hand-rolls AnthropicThinkingParam, so nothing client-side validates this; the server is the only check.
  2. display is not gated on isAnthropicNativeBaseUrl, so it also goes to Anthropic-compatible third-party gateways — and anthropicContentGenerator.test.ts:2649-2654 now explicitly pins that (internal-proxy.example → display: 'summarized'). A stricter proxy that validates the thinking object could reject the field. Note the file already has precedent for baseURL-gating a newer wire feature (dropUnsignedAssistantThinking), so this is a deliberate divergence worth a sentence in the PR body either way.

One request settles it:

curl -s -X POST https://api.anthropic.com/v1/messages \
  -H content-type:application/json -H "x-api-key: $ANTHROPIC_API_KEY" \
  -H "anthropic-version: 2023-06-01" \
  -d '{"model":"claude-sonnet-4-6","max_tokens":16,
       "thinking":{"type":"adaptive","display":"summarized"},
       "messages":[{"role":"user","content":"hi"}]}'

200 → ship as-is. 400 → the display line needs the same version gate the budget_tokens path already has (modelRejectsManualThinking() is 4.7+ and would be the natural predicate).

Summary

Item Severity Action
Vacuous drop-empty test fixture Test quality Change text: '' → text: ' ' (verified: kills M1+M6)
No generator-level prefill test Test quality Optional — 2 assertions pin the gate
display on 4.6 unconfirmed Potentially blocking One curl (above)
M12 equivalent mutant None Informational

Nice, well-scoped fix otherwise — reusing the existing version predicate instead of duplicating it, and preserving model content via the synthetic turn rather than discarding it, are both the right calls.

中文版本

本地验证报告 —— 维护者审查

我在隔离 worktree 中本地构建并测试了该分支(b9cf3b0,merge-base cc617e6),并对新增测试做了变异测试(mutation testing)。两个 bug 都真实存在,修复方向正确,在我能构造的边界场景下设计是成立的。 发现两处测试覆盖缺口,以及一个需要真实 API 确认的问题。

建议:完成下面那个「一个字符」的测试夹具修复后即可合并。 另外两项为可选/信息性。

执行的检查

检查项 结果
npx vitest run src/core/anthropicContentGenerator/ 198/198 通过
npx tsc --noEmit -p packages/core/tsconfig.json exit 0
对 4 个改动文件执行 npx eslint --max-warnings 0 无告警
变异矩阵(12 个变异体) 7 个被杀死,2 处真实缺口,1 个等价变异体
针对真实 converter 的边界场景探测 见下文

PR 描述中提到的「197/198,有一个预先存在的 User-Agent 失败」在此环境无法复现 —— 我这里是干净的 198/198,建议确认那是否为本地环境导致。

前提核实

两个论断都与 Anthropic 当前的文档行为一致:

  • Prefill —— assistant 轮 prefill 从 4.6 起(含 Opus 4.6 与 Sonnet 4.6)即被拒绝,5.x 全系亦然。因此基于现有 modelSupportsAdaptiveThinking() 做门控是正确的分界线,而非近似。复用它而不是新增第二套版本判断,这个决定很好。
  • display —— 该字段在 Opus 4.7+/Sonnet 5/Fable 5 上默认确为 omitted,而 4.6 上为 summarized。诊断与取值都正确。

正确性:设计成立

我读 diff 时最担心的是:phase 2 会在一个携带未被应答的 tool_use 的 assistant 轮之后追加纯文本 "Continue." —— Anthropic 会以 tool_use ids were found without tool_result blocks immediately after 拒绝。这不会发生: cleanOrphanedToolCalls 在流水线中先执行,会移除孤立的 tool_use 块(若因此清空则整条消息一并丢弃)。我用真实 converter 做了验证,而非仅靠阅读代码。

同样验证通过的还有:两个阶段能正确组合(先弹出空轮,再追加合成轮);内联图片等非文本块被正确视为真实内容;cache_control 落在确定性文本 "Continue." 上,使缓存前缀严格变长而非破坏缓存。退化历史下产生空 messages[] 的行为在关闭该选项时完全相同 —— 属于既有行为,非本 PR 引入的回归。

变异矩阵

display 修复覆盖良好 —— 删除该字段(M8)或改回 omitted(M9)都会导致测试失败,且是通过真实的请求构建路径触发的。prefill 修复的 phase 2 覆盖也很好(M2–M5、M7)。

缺口 1 —— drop-empty 测试是空转的(建议修复)

converter.test.ts:2103(drops a trailing empty assistant message…)使用了 { role: 'model', parts: [{ text: '' }] }。而 processContent 仅在 part.text 为真值时才推入 text 块,且从不产出零块消息 —— 因此该夹具根本不会生成 assistant 消息,phase 1 无物可删。把 stripTrailingAssistantPrefill 关闭后,同一断言依然通过。

已验证的一个字符修复: 将夹具改为 text: ' '(纯空白)。它确实会生成 assistant 消息,从而真正进入 phase 1 —— 而这正是 isEmptyAssistantMessage 中 .trim() 存在的意义。我用修正后的夹具重跑了矩阵:M1 与 M6 均由 SURVIVED 变为 KILLED。

缺口 2 —— 生成器层缺少 prefill 接线覆盖(可选)

stripTrailingAssistantPrefill 出现在 anthropicContentGenerator.ts(L692、L724),但没有任何生成器测试引用它。因此:

  • M11 —— 硬编码为 false(该特性在所有模型上失效)→ 测试仍全绿。
  • M10 —— 硬编码为 true(移除 4.6+ 门控,对 4.6 之前的模型也剥离 prefill)→ 测试仍全绿。

5 个新增 converter 测试都是手工传入该选项,因此没有任何断言锁定「生成器确实会开启它」以及「版本门控确实起区分作用」。相比之下 display 的改动是通过真实路径锁定的 —— 这正是 M8/M9 被杀死而 M10/M11 存活的原因。在 anthropicState.lastCreateArgs 上加两条断言(claude-opus-4-6 上尾部 assistant 被剥离、claude-opus-4-5 上被保留)即可闭合,同时也锁住你有意复用的那个门控。

说明:M12 是等价变异体,不是缺口

把 phase 1 的 while 收缩为单次 pop 后测试仍全绿 —— 这是正确的。mergeConsecutiveAssistantMessages 在该 pass 之前执行,保证不存在两条相邻的 assistant 消息,因此该循环永远不可能迭代两次。这个 while 属于无害的防御性写法,改成 if 行为完全相同。无需处理。

待确认问题 —— display 在 Opus 4.6 / Sonnet 4.6 上是否被接受?

这是我无法验证的一点:本机没有 Anthropic 凭证,因此无法复跑你的真实 API 验证。

问题范围很窄,但建议合并前用一条 curl 确认。display 是在 Opus 4.7(默认值变更时)才成为必须显式设置的开关。本 PR 在 { type: 'adaptive' } 形态上无条件设置该字段,而 modelSupportsAdaptiveThinking() 同样匹配 claude-opus-4-6、claude-sonnet-4-6、claude-haiku-4-6。如果 4.6 是拒绝未知字段而非忽略它,那么本 PR 会把「思考内容静默为空」这个 bug 变成 4.6 上每个请求都硬性 400。PR 描述覆盖了 4.7/4.8/5.x 的真实验证,但未提及在 4.6 模型上带 display 的测试。

有两点使得这值得显式确认而非假定:

  1. 锁定的 @anthropic-ai/sdk 版本是 0.36.3,早于 adaptive thinking 出现 —— 它根本没有 ThinkingConfigAdaptive 类型。仓库自行手写了 AnthropicThinkingParam,因此客户端不做任何校验,服务端是唯一关卡。
  2. display 未受 isAnthropicNativeBaseUrl 门控,因此也会发送给 Anthropic 兼容的第三方网关 —— 而 anthropicContentGenerator.test.ts:2649 现在明确锁定了这一行为(internal-proxy.example)。若某些代理对 thinking 对象做严格校验,可能会拒绝该字段。注意该文件已有对新 wire 特性按 baseURL 门控的先例(dropUnsignedAssistantThinking),所以无论结论如何,这个有意的分歧都值得在 PR 描述中补一句说明。

一条请求即可定论(命令见上文英文部分)。返回 200 则可直接合并;返回 400 则 display 这一行需要与 budget_tokens 路径相同的版本门控(modelRejectsManualThinking() 是 4.7+,是天然的判定谓词)。

小结

事项 严重程度 处理建议
drop-empty 测试夹具空转 测试质量 将 text: '' 改为 text: ' '(已验证可杀死 M1+M6)
缺少生成器层 prefill 测试 测试质量 可选 —— 两条断言即可锁定门控
display 在 4.6 上未确认 可能阻塞 执行上文 curl
M12 等价变异体 无 信息性说明

除此之外这是一个范围清晰、质量不错的修复 —— 复用既有版本判断而非重复实现、以及用合成轮保留模型已产出内容而不是丢弃,这两个决定都很正确。

Verified locally by the maintainer on macOS (darwin 24.6.0), isolated git worktree at b9cf3b0. Evidence images are rendered from actual command output and pinned at pr-assets/pr8040-anthropic-prefill-display. Live Anthropic API behaviour was not re-tested (no credentials on this machine).

…prefill wiring coverage

Addresses maintainer review feedback on this PR:

- converter.test.ts: "drops a trailing empty assistant message" used
  `{ text: '' }`, but processContent only emits a text block when
  part.text is truthy — an empty string never produces an assistant
  message at all, so the fixture reached nothing and the assertion
  passed identically with stripTrailingAssistantPrefill off. Changed
  to whitespace-only (`'   '`), which does produce a message and
  actually exercises isEmptyAssistantMessage's `.trim()` check.
  Verified this kills the two mutants the maintainer identified
  (M1, M6).

- anthropicContentGenerator.ts: `stripTrailingAssistantPrefill` (the
  generator-level constructor of the trailing-turn behavior) had no
  test pinning that the generator actually reads
  modelSupportsAdaptiveThinking() to turn it on/off. Added two tests:
  the trailing turn is stripped + replaced with the synthetic
  "Continue." user turn on claude-opus-4-6, and left untouched on
  claude-opus-4-5. Verified these kill the two mutants the maintainer
  identified (hardcoding the gate to `false` or `true`).

Both fixes verified against the mutation matrix by hand: reverted each
mutant, confirmed the new/fixed test fails, restored the source,
confirmed the suite passes again.
@netbrah

netbrah commented Jul 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review — all three items addressed:

Vacuous drop-empty fixture: fixed exactly as suggested (text: '' → text: ' '). Confirmed by hand that this kills M1 and M6 — reverted the fixture, watched the test pass with stripTrailingAssistantPrefill off, restored it.

Generator-level prefill wiring coverage: added the two assertions you outlined (trailing turn stripped + "Continue." appended on claude-opus-4-6, left untouched on claude-opus-4-5). Verified against M10/M11 by hand: hardcoding the gate to false or true in anthropicContentGenerator.ts now fails one of the two new tests each time; reverting the mutation passes both again.

display on 4.6 models: I don't have direct api.anthropic.com credentials in this environment, but I do have access to our org's Vertex-backed Anthropic proxy, which I'd expect to be at least as strict as the native API on an unrecognized-field question like this one. Ran your exact payload shape against it:

claude-sonnet-4.6 + {"type":"adaptive","display":"summarized"} → HTTP 200
claude-opus-4.6   + {"type":"adaptive","display":"summarized"} → HTTP 200

(claude-haiku-4.6 isn't configured on this particular proxy — 400 for "invalid model name," unrelated to the field itself — so I can't add a data point there.) Both requests returned a normal thinking + text response with display silently accepted, not a 400. Not a substitute for your native-API curl if someone here can run it, but it's a second independent backend saying the field doesn't break 4.6, for whatever that's worth.

Full mutation matrix re-run: 198/198 passing, same as before.

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Local verification report

I built this branch locally and ran it against a real HTTP endpoint rather than only reading the diff. Both bugs reproduce on main and are fixed by this PR. No blocking issues found. Details, including two pre-existing gaps this PR does not cover, are below.

Verified at f854786230, merge-base cc617e6707.

How I tested

The PR's own unit tests mock @anthropic-ai/sdk, so they prove the request body is built correctly but never exercise the wire. I wrote a separate harness that does not mock the SDK: a local HTTP server stands in for the Messages API, and the real AnthropicContentGenerator + real @anthropic-ai/sdk talk to it over real HTTP. The stub enforces Anthropic's documented behaviour:

  1. Models from the 4.6 generation onward reject a request whose final message has role: "assistant" with a hard 400.
  2. Adaptive thinking returns thinking blocks with empty text unless thinking.display === "summarized".

The model→behaviour membership table in the stub is written out explicitly rather than derived from parseClaudeModelVersion(), so the oracle shares no logic with the code under test.

I want to be upfront: I have no Anthropic credentials on this machine, so this is a faithful stub, not the live API. The two rules it encodes match Anthropic's published behaviour — assistant prefill 400s on Opus 4.6/4.7/4.8/5, Sonnet 4.6/5 and Fable 5 but not on Haiku 4.5; and display defaults to summarized on Opus 4.6 / Sonnet 4.6 but silently to omitted on Opus 4.7+ and every 5.x family. That matches the premise in the PR description and #8039.

Results

wire-level A/B

Negative control. Reverting only the two production files to the merge-base while keeping my identical test flips 3 of 4 cases red; restoring turns them green. So the test discriminates the fix rather than just observing green.

main PR #8040
Trailing model turn, claude-opus-4-6 400 prefill rejected 200, synthetic "Continue." user turn appended, original model text preserved
Whitespace-only trailing turn, claude-sonnet-5 400 prefill rejected 200, empty assistant turn dropped entirely
Trailing model turn, claude-haiku-4-5 (pre-4.6) 200, prefill kept 200, prefill kept — gate correctly does not fire
Adaptive thinking, claude-sonnet-5 sent {"type":"adaptive"}, 0 thought parts received sent {"type":"adaptive","display":"summarized"}, thought parts received with non-empty text

The claude-haiku-4-5 row is the one I care most about: it is the discriminator showing the modelSupportsAdaptiveThinking() gate is generation-scoped, not a blanket rewrite of every request.

Adversarial edge cases

I then hardened the stub to also enforce messages non-empty, first message must be user, and every tool_use answered — so that a "fix" which merely trades one 400 for a different 400 would be caught. I also drove the streaming path, since that is what the TUI actually uses.

edge cases and check summary

Two things worth calling out:

  • Streaming is covered. generateContent and generateContentStream share a single buildRequest(), so the fix applies to both. I confirmed end-to-end: on main the streaming call 400s; on this branch it streams and carries thought: true blocks through.
  • A trailing turn with an unanswered tool call is safe. cleanOrphanedToolCalls() runs before the new pass, so the orphaned tool_use is removed first and the request never ends up with a tool_use followed by a synthetic text-only user turn. This was my main correctness worry going in; it does not materialise.

Two pre-existing gaps (not regressions, not blockers)

Both of these fail on main and on this branch, so they are not caused by the PR. Flagging them as possible follow-up since they are adjacent:

  • A history that starts on a model turn (leading user turn trimmed away) still 400s with "first message must use the user role". This PR fixes the tail of the conversation; the head has the symmetric problem, and the same context-trimming scenario cited in the PR description could produce it.
  • A history consisting solely of an empty model turn now yields an empty messages array (400 "at least one message is required") instead of the previous 400. Same outcome, different message.

Checks

Check Result
vitest src/core/anthropicContentGenerator/ 200 / 200 passed (3 files)
vitest src/core/ (regression sweep) 2523 / 2523 passed (55 files)
tsc --noEmit -p packages/core clean, exit 0
eslint --max-warnings 0 on all 4 touched files clean, exit 0

Minor notes

  1. The branch is behind main (based on cc617e6707, current main is 01afcb0bba). Worth rebasing before merge so CI runs against current main.
  2. The PR description's test note is now stale. It says 197/198 with a pre-existing User-Agent failure; at this SHA I get a clean 200/200 across the whole directory, so that caveat can be dropped.
  3. "Continue." is a hardcoded English string. It is injected as a user turn that steers the model, and we have general.outputLanguage elsewhere. In a Chinese session this may nudge the reply toward English. Low severity, but a language-neutral marker or one derived from the configured output language might be tidier.
  4. display? is added to the { type: 'enabled'; budget_tokens } arm of AnthropicThinkingParam but is never set on that path (correctly — the test pins this). Harmless dead type surface; not worth changing.

None of these block merge. My take: rebase, drop the stale test note from the description, and this is good to go.

中文说明

本地验证报告

我在本地构建了这个分支,并且没有停留在读 diff 的层面,而是让它跑在一个真实的 HTTP 端点上。两个 bug 在 main 上都能复现,本 PR 都修好了。 没有发现阻塞性问题。下面包含细节,以及两个本 PR 未覆盖的既有缺口。

验证基于 f854786230,merge-base 为 cc617e6707。

我是怎么测的

本 PR 自带的单测 mock 了 @anthropic-ai/sdk,所以它们能证明请求体构造正确,但从未真正走到网络层。我另外写了一套 harness,不 mock SDK:用一个本地 HTTP 服务器充当 Messages API,真实的 AnthropicContentGenerator + 真实的 @anthropic-ai/sdk 通过真实 HTTP 与之通信。这个 stub 实现了 Anthropic 已公开的行为:

  1. 4.6 及之后世代的模型,会对最后一条消息为 role: "assistant" 的请求直接返回 400。
  2. adaptive thinking 返回的 thinking block 文本为空,除非设置了 thinking.display === "summarized"。

stub 里"模型→行为"的对照表是显式写死的,而不是从 parseClaudeModelVersion() 推导,因此这个判定基准与被测代码不共享任何逻辑。

需要坦白说明:这台机器上没有 Anthropic 凭据,所以这是一个忠实的 stub,而非线上真实 API。它编码的两条规则与 Anthropic 公布的行为一致 —— assistant prefill 在 Opus 4.6/4.7/4.8/5、Sonnet 4.6/5 和 Fable 5 上返回 400,但在 Haiku 4.5 上不会;display 在 Opus 4.6 / Sonnet 4.6 上默认为 summarized,而在 Opus 4.7+ 及所有 5.x 系列上静默默认为 omitted。这与 PR 描述和 #8039 中的前提一致。

结果

wire-level A/B

反向对照(negative control)。 只把两个生产文件回退到 merge-base、保持我的测试完全不变,4 个用例中有 3 个变红;恢复后又全部变绿。这说明测试确实能区分这个修复,而不只是"看到绿色"。

main PR #8040
结尾为 model turn,claude-opus-4-6 400 prefill 被拒 200,追加合成的 "Continue." user turn,模型原有内容被保留
结尾为纯空白 turn,claude-sonnet-5 400 prefill 被拒 200,空 assistant turn 被整条丢弃
结尾为 model turn,claude-haiku-4-5(4.6 之前) 200,prefill 保留 200,prefill 保留 —— 门控正确地未触发
adaptive thinking,claude-sonnet-5 发送 {"type":"adaptive"},收到 0 个 thought part 发送 {"type":"adaptive","display":"summarized"},收到非空文本的 thought part

我最看重 claude-haiku-4-5 这一行:它是关键判别项,证明 modelSupportsAdaptiveThinking() 门控是按模型世代生效的,而不是对所有请求一刀切地改写。

对抗性边界用例

随后我强化了 stub,额外校验 messages 非空、首条消息必须是 user、以及每个 tool_use 都有应答 —— 这样"把一个 400 换成另一个 400"的伪修复就会被抓出来。我也驱动了流式路径,因为 TUI 实际走的就是流式。

edge cases and check summary

有两点值得特别说明:

  • 流式路径已被覆盖。 generateContent 和 generateContentStream 共用同一个 buildRequest(),因此修复对两者同时生效。我做了端到端确认:在 main 上流式调用返回 400;在本分支上可以正常流式输出,并且 thought: true 的 block 能正确透传。
  • 结尾带未应答 tool call 的 turn 是安全的。 cleanOrphanedToolCalls() 在新增的这一步之前执行,孤儿 tool_use 会先被清掉,因此请求不会出现"tool_use 后面跟一个纯文本合成 user turn"的非法结构。这是我事先最担心的正确性问题,实测并未发生。

两个既有缺口(非回归,非阻塞)

这两项在 main 和本分支上都失败,因此并非本 PR 引入。因为主题相邻,作为后续可选项列出:

  • 以 model turn 开头的历史(开头的 user turn 被裁掉)仍会返回 400 "first message must use the user role"。本 PR 修的是对话的尾部;头部存在对称的问题,而 PR 描述里提到的同一种上下文裁剪场景就可能产生它。
  • 仅由一个空 model turn 构成的历史,现在会得到空的 messages 数组(400 "at least one message is required"),而不是原来的 400。结果相同,错误信息不同。

检查项

检查项 结果
vitest src/core/anthropicContentGenerator/ 200 / 200 通过(3 个文件)
vitest src/core/(回归扫描) 2523 / 2523 通过(55 个文件)
tsc --noEmit -p packages/core 干净,exit 0
对 4 个改动文件执行 eslint --max-warnings 0 干净,exit 0

次要建议

  1. 分支落后于 main(基于 cc617e6707,当前 main 为 01afcb0bba)。建议合并前 rebase,让 CI 跑在当前 main 上。
  2. PR 描述中的测试说明已过时。 描述里写的是 197/198,含一个既有的 User-Agent 失败;在这个 SHA 上我跑整个目录得到的是干净的 200/200,这条注意事项可以删掉。
  3. "Continue." 是硬编码的英文字符串。 它作为一条 user turn 注入并会引导模型,而我们别处有 general.outputLanguage。在中文会话里,这可能把回复往英文方向带。严重性很低,但换成语言无关的标记、或按配置的输出语言生成,可能更妥当。
  4. AnthropicThinkingParam 的 { type: 'enabled'; budget_tokens } 分支上新增了 display?,但该路径从不设置它(这是正确的,测试也钉住了这一点)。属于无害的冗余类型面,不值得改。

以上都不阻塞合并。我的意见是:rebase、把描述里过时的测试说明去掉,就可以合了。

🤖 Generated with Claude Code — Claude Opus 5 (1M context)

@wenshao

wenshao commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@wenshao
wenshao added this pull request to the merge queue Jul 31, 2026
Merged via the queue into QwenLM:main with commit 88481c3 Jul 31, 2026
69 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

review/self-reported The linked issue was opened by the PR author (self-reported)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

fix(core): Anthropic 4.6+ assistant-prefill 400 + thinking.display silently defaults to 'omitted'

2 participants