Skip to content
Merged
Show file tree
Hide file tree
Changes from 20 commits
Commits
Show all changes
22 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
60672b8
fix(core): preserve complete Responses reasoning payloads
DragonnZhang Sep 9, 2026
891d04c
chore: merge main to refresh PR lint gate
DragonnZhang Sep 9, 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 +3247 to +3252

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

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