Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c23d899
fix(core): preserve every reasoning episode's signature during histor…
Jul 31, 2026
b7fe21c
fix(anthropic): keep manual-mode thinking history leading with thinking
Jul 31, 2026
2a76e3e
chore(core): merge main into anthropic reasoning-signature consolidat…
qwen-code-dev-bot Aug 1, 2026
da4b3e3
fix(core): align XML-recovery text predicates to stop leaking raw XML
Aug 3, 2026
8a2a7ed
Merge remote-tracking branch 'origin/main' into fix/geminichat-though…
Aug 4, 2026
c403fb3
fix(core): drop dangling unsigned thought episode on truncated tool t…
Aug 4, 2026
f907a0f
fix(core): close recovery-coalescing and pipeline-ordering gaps in th…
Aug 4, 2026
a7f4df4
Merge branch 'main' into fix/geminichat-thought-consolidation
wenshao Aug 4, 2026
11b7458
Merge branch 'main' into fix/geminichat-thought-consolidation
wenshao Aug 6, 2026
9b84e79
fix(core): normalize leading thinking on every tool_use turn, not jus…
Aug 11, 2026
13867dc
chore(core): merge main into reasoning-signature consolidation fix
qwen-code-dev-bot Aug 12, 2026
60d4103
fix(core): run the XML-recovery dangling-episode drop before remainin…
Aug 12, 2026
ad1c54b
test(core): close the mutation escapes round 4 found in this PR's own…
Aug 12, 2026
2fe2ee3
test(core): pin the five unguarded branches round 5 found, and fix an…
Aug 12, 2026
6b3e68a
fix(core): gate the dangling-episode drop on genuine trailing-ness, a…
Aug 16, 2026
14292a7
test(core): cover quiet signed reasoning persistence
Aug 26, 2026
36cdca8
chore(core): merge main into reasoning-signature consolidation fix
Aug 26, 2026
ce58a20
Merge remote-tracking branch 'origin/main' into fix/geminichat-though…
wenshao Aug 29, 2026
345f0f9
Merge branch 'main' into fix/geminichat-thought-consolidation
netbrah Sep 2, 2026
19dcebf
Merge branch 'main' into fix/geminichat-thought-consolidation
wenshao Sep 4, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2874,6 +2874,336 @@ describe('AnthropicContentGenerator', () => {
});
});

// Regression for the manual-mode leading-thinking requirement: Anthropic
// rejects a thinking-enabled tool loop whose final assistant turn doesn't
// begin with a thinking block. mergeConsecutiveAssistantMessages's
// straight concatenation can otherwise leave a leading text block ahead
// of a later thinking run (see converter.ts's
// ensureLeadingAssistantThinking doc).
describe('manual-mode leading-thinking normalization', () => {
it('reorders the latest assistant turn to lead with thinking under an explicit-budget (manual) configuration', async () => {
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue({
id: 'msg-1',
model: 'claude-opus-4-6',
content: [{ type: 'text', text: 'ok' }],
});

const generator = new AnthropicContentGenerator(
{
model: 'claude-opus-4-6',
apiKey: 'test-key',
baseUrl: 'https://api.anthropic.com',
timeout: 10_000,
maxRetries: 2,
// Must stay above budget_tokens below: Anthropic requires
// budget_tokens < max_tokens (documented in buildThinkingConfig)
// and buildSamplingParameters does not clamp between the two, so a
// smaller value here would make this canonical manual-mode fixture
// model a request the real API rejects -- passing only because the
// client is mocked.
samplingParams: { max_tokens: 64_000 },
schemaCompliance: 'auto',
// Explicit budget_tokens is the escape hatch that keeps
// claude-opus-4-6 (a 4.6+ model that otherwise defaults to
// adaptive) on the manual `{ type: 'enabled', budget_tokens }`
// shape -- see "honors explicit reasoning.budget_tokens" above.
reasoning: { budget_tokens: 42_000 },
},
mockConfig,
);

await generator.generateContent({
model: 'models/ignored',
contents: [
{ role: 'user' as const, parts: [{ text: 'Run tool' }] },
{
// No leading thinking block on this earlier turn -- adaptive
// mode explicitly permits this, and it's what the merge
// concatenates ahead of the next turn's thinking block.
role: 'model' as const,
parts: [{ text: 'Sure, one moment.' }],
},
{
role: 'model' as const,
parts: [
{
text: 'reasoning about the tool call',
thought: true,
thoughtSignature: 'sig-1',
},
{ functionCall: { id: 't1', name: 'tool', args: {} } },
],
},
{
role: 'user' as const,
parts: [
{
functionResponse: {
id: 't1',
name: 'tool',
response: { output: 'ok' },
},
},
],
},
],
} as unknown as GenerateContentParameters);

const [rawRequest, options] =
anthropicState.lastCreateArgs as AnthropicCreateArgs;
const anthropicRequest = rawRequest as {
thinking?: unknown;
messages: Array<{ role: string; content: unknown[] }>;
};

expect(anthropicRequest.thinking).toEqual({
type: 'enabled',
budget_tokens: 42_000,
});
expect(
(options as { headers?: Record<string, string> })?.headers?.[
'anthropic-beta'
],
).toContain('interleaved-thinking-2025-05-14');

// The two model turns are merged into one assistant message by
// mergeConsecutiveAssistantMessages; the merged turn is also the
// request's latest assistant message, and manual mode requires it
// to begin with thinking.
const assistantMessages = anthropicRequest.messages.filter(
(m) => m.role === 'assistant',
);
const latestAssistant = assistantMessages.at(-1) as {
content: Array<{
type: string;
text?: string;
thinking?: string;
signature?: string;
}>;
};
expect(latestAssistant.content[0]?.type).toBe('thinking');
expect(latestAssistant.content[0]?.thinking).toBe(
'reasoning about the tool call',
);
expect(latestAssistant.content[0]?.signature).toBe('sig-1');
expect(latestAssistant.content[1]).toEqual({
type: 'text',
text: 'Sure, one moment.',
});
expect(latestAssistant.content[2]?.type).toBe('tool_use');
});

it('reorders the latest assistant turn to lead with thinking under an effort-ladder (manual) configuration on a pre-4.6 model', async () => {
// buildThinkingConfig reaches `{ type: 'enabled' }` two ways: the
// explicit-budget escape hatch (covered by the test above) and the
// effort ladder for pre-4.6 / unversioned ids (covered here). The
// generator gates ensureLeadingAssistantThinking on the BUILT config's
// `type === 'enabled'`, not on the presence of reasoning.budget_tokens,
// so the ladder path must reorder the same way. claude-opus-4-5 with
// effort only (no budget_tokens) resolves to the manual budget shape
// `{ type: 'enabled', budget_tokens: 32_000 }` -- see "keeps the
// budget_tokens config for older 4.x models" above.
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue({
id: 'msg-1b',
model: 'claude-opus-4-5',
content: [{ type: 'text', text: 'ok' }],
});

const generator = new AnthropicContentGenerator(
{
model: 'claude-opus-4-5',
apiKey: 'test-key',
baseUrl: 'https://api.anthropic.com',
timeout: 10_000,
maxRetries: 2,
// Must stay above the effort-ladder budget_tokens (32_000 for
// medium): Anthropic requires budget_tokens < max_tokens, so a
// smaller value would model a request the real API rejects.
samplingParams: { max_tokens: 64_000 },
schemaCompliance: 'auto',
// Effort only, no budget_tokens: claude-opus-4-5 (pre-4.6) takes
// the effort ladder to `{ type: 'enabled', budget_tokens: 32_000 }`.
reasoning: { effort: 'medium' },
},
mockConfig,
);

await generator.generateContent({
model: 'models/ignored',
contents: [
{ role: 'user' as const, parts: [{ text: 'Run tool' }] },
{
// No leading thinking block on this earlier turn -- it's what the
// merge concatenates ahead of the next turn's thinking block.
role: 'model' as const,
parts: [{ text: 'Sure, one moment.' }],
},
{
role: 'model' as const,
parts: [
{
text: 'reasoning about the tool call',
thought: true,
thoughtSignature: 'sig-1',
},
{ functionCall: { id: 't1', name: 'tool', args: {} } },
],
},
{
role: 'user' as const,
parts: [
{
functionResponse: {
id: 't1',
name: 'tool',
response: { output: 'ok' },
},
},
],
},
],
} as unknown as GenerateContentParameters);

const [rawRequest, options] =
anthropicState.lastCreateArgs as AnthropicCreateArgs;
const anthropicRequest = rawRequest as {
thinking?: unknown;
messages: Array<{ role: string; content: unknown[] }>;
};

expect(anthropicRequest.thinking).toEqual({
type: 'enabled',
budget_tokens: 32_000,
});
expect(
(options as { headers?: Record<string, string> })?.headers?.[
'anthropic-beta'
],
).toContain('interleaved-thinking-2025-05-14');

// The two model turns are merged into one assistant message; the merged
// turn is also the request's latest assistant message, and manual mode
// requires it to begin with thinking.
const assistantMessages = anthropicRequest.messages.filter(
(m) => m.role === 'assistant',
);
const latestAssistant = assistantMessages.at(-1) as {
content: Array<{
type: string;
text?: string;
thinking?: string;
signature?: string;
}>;
};
expect(latestAssistant.content[0]?.type).toBe('thinking');
expect(latestAssistant.content[0]?.thinking).toBe(
'reasoning about the tool call',
);
expect(latestAssistant.content[0]?.signature).toBe('sig-1');
expect(latestAssistant.content[1]).toEqual({
type: 'text',
text: 'Sure, one moment.',
});
expect(latestAssistant.content[2]?.type).toBe('tool_use');
});

it('leaves the latest assistant turn in chronological order under adaptive thinking (no explicit budget)', async () => {
// Guards the generator's `ensureLeadingAssistantThinking:
// thinking?.type === 'enabled'` gate: a regression to `!!thinking`
// (truthy for both `{type:'enabled'}` and `{type:'adaptive'}`) would
// silently reintroduce the hoist-every-thinking corruption this PR
// removed from the merge path, but only on adaptive-thinking models
// -- which the manual-mode test above cannot catch.
const { AnthropicContentGenerator } = await importGenerator();
anthropicState.createImpl.mockResolvedValue({
id: 'msg-2',
model: 'claude-opus-4-6',
content: [{ type: 'text', text: 'ok' }],
});

const generator = new AnthropicContentGenerator(
{
model: 'claude-opus-4-6',
apiKey: 'test-key',
baseUrl: 'https://api.anthropic.com',
timeout: 10_000,
maxRetries: 2,
samplingParams: { max_tokens: 500 },
schemaCompliance: 'auto',
// No explicit budget_tokens: claude-opus-4-6 (a 4.6+ model)
// defaults to adaptive thinking.
},
mockConfig,
);

await generator.generateContent({
model: 'models/ignored',
contents: [
{ role: 'user' as const, parts: [{ text: 'Run tool' }] },
{
role: 'model' as const,
parts: [{ text: 'Sure, one moment.' }],
},
{
role: 'model' as const,
parts: [
{
text: 'reasoning about the tool call',
thought: true,
thoughtSignature: 'sig-1',
},
{ functionCall: { id: 't1', name: 'tool', args: {} } },
],
},
{
// Answer t1 so the request ends on a tool_result rather than an
// unanswered tool_use: without this, stripTrailingAssistantPrefill
// appends a synthetic 'Continue.' user turn, leaving a tool_use
// with no tool_result after it -- the exact HTTP 400 shape
// mergeConsecutiveAssistantMessages documents. Mirrors the
// manual-mode sibling above; the assertion is unaffected.
role: 'user' as const,
parts: [
{
functionResponse: {
id: 't1',
name: 'tool',
response: { output: 'ok' },
},
},
],
},
],
} as unknown as GenerateContentParameters);

const [rawRequest] = anthropicState.lastCreateArgs as AnthropicCreateArgs;
const anthropicRequest = rawRequest as {
thinking?: unknown;
messages: Array<{
role: string;
content: Array<{ type: string; text?: string }>;
}>;
};

expect(anthropicRequest.thinking).toEqual({
type: 'adaptive',
display: 'summarized',
});

const assistantMessages = anthropicRequest.messages.filter(
(m) => m.role === 'assistant',
);
const latestAssistant = assistantMessages.at(-1)!;
expect(latestAssistant.content.map((b) => b.type)).toEqual([
'text',
'thinking',
'tool_use',
]);
Comment on lines +3198 to +3203

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This adaptive-mode fixture ends on a model turn with an unanswered functionCall t1: stripTrailingAssistantPrefill keeps the trailing assistant and appends a 'Continue.' user turn, so the converted request carries tool_use t1 followed by a text-only user message — the exact shape mergeConsecutiveAssistantMessages's own doc block quotes as HTTP 400 ("tool_use ids were found without tool_result blocks immediately after"); cleanOrphanedToolCalls runs earlier in the pipeline and protects the trailing message. Probe-verified at this commit: the converted messages end [assistant [text, thinking, tool_use t1], user 'Continue.']. Also verified the assertion is not load-bearing on the unrealistic tail — appending the functionResponse for t1 yields an API-valid request whose latest assistant is still ['text','thinking','tool_use'].

Failure scenario: the fixture models a request the real API rejects, passing only because the client is mocked — contradicting the realism standard its sibling manual-mode test documents in this same describe block; it would also resist a legitimate future fix (re-running cleanOrphanedToolCalls after the prefill pass would drop the tool_use and fail this assertion).

Suggested fix: mirror the manual-mode test by appending the functionResponse user turn for t1 after the tool turn; the assertion is unchanged.

中文说明

该 adaptive 模式 fixture 以一个带未应答 functionCall t1 的模型轮次结尾:stripTrailingAssistantPrefill 会保留这条尾部 assistant 消息并追加 'Continue.' 用户轮,于是转换后的请求变成 tool_use t1 后面紧跟一条纯文本用户消息——正是 mergeConsecutiveAssistantMessages 文档块中引用的 HTTP 400 形状("tool_use ids were found without tool_result blocks immediately after");cleanOrphanedToolCalls 在流水线更早处运行,且对尾部消息有保护。已在本 commit 上用探针验证:转换结果以 [assistant [text, thinking, tool_use t1], user 'Continue.'] 结尾。另已验证该断言并不依赖这个不真实的尾部——为 t1 补上 functionResponse 后请求变为 API 合法形状,最新 assistant 仍为 ['text','thinking','tool_use']

失败场景:fixture 模拟的是真实 API 会拒绝的请求,只因客户端被 mock 才通过——与同一 describe 块中 manual 模式兄弟测试明确记录的真实性标准相矛盾;它还会阻碍未来合理的修复(若在 prefill 之后重跑 cleanOrphanedToolCallstool_use 会被清掉,本断言随之失败)。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 2fe2ee32b.

You were right that the assertion doesn't depend on the unrealistic tail. Appended the functionResponse user turn for t1, mirroring the manual-mode sibling: the request now ends on a tool_result, no synthetic 'Continue.' is appended, and the latest assistant is still ['text','thinking','tool_use'].

The assertion is byte-identical — the commit is 293 insertions with zero deletions, so nothing existing was weakened. Also agree on the forward-compatibility point: had cleanOrphanedToolCalls later been re-run after the prefill pass, the old fixture would have started failing for a reason unrelated to what it tests.

});
});

// https://github.com/QwenLM/qwen-code/issues/3786 — DeepSeek's
// anthropic-compatible API rejects requests in thinking mode when a prior
// assistant turn carrying `tool_use` omits a thinking block. Plain-text
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,12 @@ export class AnthropicContentGenerator implements ContentGenerator {
dropUnsignedAssistantThinking,
stripAssistantThinking,
stripTrailingAssistantPrefill,
// Manual (non-adaptive) extended thinking requires an assistant
// turn to begin with a thinking block whenever a tool_use remains
// in it; adaptive thinking relaxes this. Applied to every such turn
// in history, not just the latest -- see
// ensureLeadingAssistantThinking's doc in the converter.
ensureLeadingAssistantThinking: thinking?.type === 'enabled',
Comment thread
netbrah marked this conversation as resolved.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This gate is pinned by tests for only one of the two buildThinkingConfig paths that return {type:'enabled'} — the explicit-budget_tokens escape hatch (the claude-opus-4-6 fixture). The effort-ladder path (pre-4.6 Claude ids and unversioned proxy ids; the existing 'keeps the budget_tokens config for older 4.x models' test confirms that shape) has no test asserting the leading-thinking normalization is actually applied. Probe-verified at this commit: deriving the gate from the escape-hatch input (reasoning?.budget_tokens !== undefined && !modelRejectsManualThinking()) instead of the built config leaves all 229 generator+converter tests green, while a ladder-mode probe (claude-opus-4-5, effort only) shows normalization disabled — a text-leading tool_use turn ships unreordered.

Failure scenario: such a refactor would silently disable normalization for manual-mode ladder models — the generation where the manual leading-thinking wire rule originated — and since this PR's removal of the parts[0] hoist makes text-before-thinking tool turns reachable, those turns would 400. Mutation-coverage gap, not a live bug — the shipped gate is correct.

Suggested fix: add a third generator-level test mirroring the manual-mode one with a pre-4.6 model (e.g. claude-opus-4-5) and reasoning effort only (no budget_tokens), asserting the merged latest assistant message still leads with the thinking block.

中文说明

该门控只有"显式 budget_tokens 逃生通道"这一条 buildThinkingConfig 返回 {type:'enabled'} 的路径被测试钉住(claude-opus-4-6 fixture)。另一条 effort 阶梯路径(4.6 之前的 Claude 型号与无版本号的代理型号;现有测试 'keeps the budget_tokens config for older 4.x models' 已确认该形状)没有任何测试断言 leading-thinking 归一化确实生效。已在本 commit 上用探针验证:把门控改为基于逃生通道输入(reasoning?.budget_tokens !== undefined && !modelRejectsManualThinking())而非构建出的 config 时,全部 229 个 generator+converter 测试保持绿色,而阶梯模式探针(claude-opus-4-5、仅 effort)显示归一化被关闭——text 打头的 tool_use 轮次未经重排就发出去了。

失败场景:这样的重构会静默关闭 manual 模式阶梯型号的归一化——而 manual leading-thinking 链路规则正是起源于这一代型号;且本 PR 移除了 parts[0] 提升逻辑后,"先文本后思考"的工具轮次已经可达,这些轮次会收到 400。这是变异覆盖缺口,不是线上 bug——当前发布的门控是正确的。

— qwen3.8-max via Qwen Code /review (v0.21.10)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Confirmed and fixed in 2fe2ee32b.

Applied your exact gate mutation (deriving from reasoning?.budget_tokens !== undefined && !modelRejectsManualThinking() instead of the built config): the existing suite stays green, and the new ladder-mode test fails — 118 passed | 1 failed, the single failure being the new test. That matches your claim that normalization is silently disabled for effort-ladder models while everything else passes.

Added a third generator-level test using claude-opus-4-5 with reasoning effort only and no budget_tokens, asserting the built config is {type:'enabled', budget_tokens:32_000} and the merged latest assistant still leads with the thinking block.

enableCacheControl,
useGlobalCacheScope,
cacheRetention,
Expand Down
Loading
Loading