Repository navigation
fix(core): strip assistant-turn prefill and fix thinking.display on 4.6+ models - #8040
Conversation
….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.
Local verification report — maintainer reviewI built and tested this branch locally (isolated worktree at Recommendation: merge after the one-character fixture fix below. The other two items are optional / informational. What I ran
The 197/198 "pre-existing Premises checkBoth claims match Anthropic's current documented behaviour:
Correctness: the design holdsMy main worry reading the diff was phase 2 appending a text-only 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 Mutation matrixThe Gap 1 — the drop-empty test is vacuous (please fix)
One-character fix, verified: change the fixture to Gap 2 — no generator-level coverage of the prefill wiring (optional)
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 Note: M12 is an equivalent mutant, not a gapCollapsing phase 1's Open question — is
|
| 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 的测试。
有两点使得这值得显式确认而非假定:
- 锁定的
@anthropic-ai/sdk版本是 0.36.3,早于 adaptive thinking 出现 —— 它根本没有ThinkingConfigAdaptive类型。仓库自行手写了AnthropicThinkingParam,因此客户端不做任何校验,服务端是唯一关卡。 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.
|
Thanks for the thorough review — all three items addressed: Vacuous drop-empty fixture: fixed exactly as suggested ( Generator-level prefill wiring coverage: added the two assertions you outlined (trailing turn stripped +
( Full mutation matrix re-run: 198/198 passing, same as before. |
Local verification reportI built this branch locally and ran it against a real HTTP endpoint rather than only reading the diff. Both bugs reproduce on Verified at How I testedThe PR's own unit tests mock
The model→behaviour membership table in the stub is written out explicitly rather than derived from 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 ResultsNegative 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.
The Adversarial edge casesI then hardened the stub to also enforce Two things worth calling out:
Two pre-existing gaps (not regressions, not blockers)Both of these fail on
Checks
Minor notes
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 在 验证基于 我是怎么测的本 PR 自带的单测 mock 了
stub 里"模型→行为"的对照表是显式写死的,而不是从 需要坦白说明:这台机器上没有 Anthropic 凭据,所以这是一个忠实的 stub,而非线上真实 API。它编码的两条规则与 Anthropic 公布的行为一致 —— assistant prefill 在 Opus 4.6/4.7/4.8/5、Sonnet 4.6/5 和 Fable 5 上返回 400,但在 Haiku 4.5 上不会; 结果反向对照(negative control)。 只把两个生产文件回退到 merge-base、保持我的测试完全不变,4 个用例中有 3 个变红;恢复后又全部变绿。这说明测试确实能区分这个修复,而不只是"看到绿色"。
我最看重 对抗性边界用例随后我强化了 stub,额外校验 有两点值得特别说明:
两个既有缺口(非回归,非阻塞)这两项在
检查项
次要建议
以上都不阻塞合并。我的意见是:rebase、把描述里过时的测试说明去掉,就可以合了。 🤖 Generated with Claude Code — Claude Opus 5 (1M context) |
|
@qwen-code /triage |





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:
final message has
role: 'assistant'is rejected outright on every4.6+ model.
thinking.displaysilently defaults toomittedon newermodels, 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).
processContentsthen emits a request whose last message isrole: 'assistant', which Anthropic rejects on every 4.6+ model:Display: Anthropic's adaptive-thinking
displayfield defaultsdifferently across model generations —
summarizedon Sonnet 4.6,silently
omittedon Opus 4.7+ and every 5.x family.qwen-codenever sets it, so newer models return thinking blocks with empty text
and no error.
Approach
stripTrailingAssistantPrefill— new converter option, gated inthe 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 assistantmessage 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.6budget_tokensshape and the explicitreasoning.budget_tokensescape 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, andclaude-sonnet-5. The same request againstclaude-haiku-4.5(agenuinely 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 ratherthan being a blanket restriction.
Display, live against
claude-sonnet-5with a task requiringgenuine reasoning: without
displayset, thethinkingblock returnswith empty text; with
display: 'summarized'set, the identicalrequest returns full non-empty reasoning text.
Unit tests: 16 new/updated cases —
converter.test.tscases (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)
anthropicContentGenerator.test.tsassertions updatedfrom
{ type: 'adaptive' }to{ type: 'adaptive', display: 'summarized' }(all go through the real request-building code path)displayis never set on thebudget_tokensshape (pre-4.6 models and the explicit-overrideescape hatch)
Also clean:
npx tsc --noEmit -p packages/core/tsconfig.json(0errors),
npx eslinton all four touched files (0 warnings), fullanthropicContentGeneratortest directory (197/198 passing — the onefailure is a pre-existing, unrelated
User-Agentassertion, confirmedvia
git stashto fail identically onmainbefore this change), andthe repo's own
pre-commitlint-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.