Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
210 changes: 207 additions & 3 deletions packages/core/src/core/anthropicContentGenerator/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1067,6 +1067,10 @@ describe('AnthropicContentConverter', () => {
});

it('cleans orphaned tool_use blocks without matching tool_result', () => {
// A genuine orphan requires a subsequent message that was actually
// scanned and found lacking a matching tool_result -- not merely the
// absence of any subsequent message (see the "trailing tool_use"
// test below for that case).
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
Expand All @@ -1078,20 +1082,98 @@ describe('AnthropicContentConverter', () => {
{ functionCall: { id: 'orphan', name: 'tool', args: {} } },
],
},
{ role: 'user', parts: [{ text: 'never mind' }] },
],
});

expect(messages).toEqual([
{
role: 'user',
content: [
{ type: 'text', text: 'Hi', cache_control: { type: 'ephemeral' } },
],
content: [{ type: 'text', text: 'Hi' }],
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Let me help' }],
},
{
role: 'user',
content: [
{
type: 'text',
text: 'never mind',
cache_control: { type: 'ephemeral' },
},
],
},
]);
});

it('does not strip a trailing tool_use that has no subsequent message yet (unresolved, not orphaned)', () => {
// "History ends on a pending tool_use" is not evidence the call is
// orphaned -- the tool may simply not have finished executing yet,
// or this conversion may be happening for a reason other than
// sending the completed turn to Anthropic (token counting, a
// resumed/replayed session snapshot, ...). Regression test for the
// bug where this exact shape had its tool_use silently deleted.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'What is the weather in Paris?' }] },
{
role: 'model',
parts: [
{ text: 'Let me check the weather.' },
{
functionCall: {
id: 'toolu_pending',
name: 'get_weather',
args: { city: 'Paris' },
},
},
],
},
],
});

const lastMsg = messages[messages.length - 1];
expect(lastMsg.role).toBe('assistant');
expect(lastMsg.content).toEqual([
{ type: 'text', text: 'Let me check the weather.' },
{
type: 'tool_use',
id: 'toolu_pending',
name: 'get_weather',
input: { city: 'Paris' },
},
]);
});

it('cascade-strips a signed thinking block when its sibling tool_use is orphaned in the same pass', () => {
// A thinking block's signature is computed over the full sibling
// content of its turn. If a sibling tool_use is stripped as an
// orphan, the signature no longer matches and replaying it 400s:
// "thinking blocks in the latest assistant message cannot be
// modified". So the thinking block must go with it.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [
{ text: 'reasoning', thought: true, thoughtSignature: 'sig' },
{ text: 'Let me help' },
{ functionCall: { id: 'orphan', name: 'tool', args: {} } },
],
},
{ role: 'user', parts: [{ text: 'never mind' }] },
],
});

const assistantMsg = messages.find((m) => m.role === 'assistant');
expect(assistantMsg).toBeDefined();
expect(assistantMsg!.content).toEqual([
{ type: 'text', text: 'Let me help' },
]);
});

Expand Down Expand Up @@ -1548,6 +1630,128 @@ describe('AnthropicContentConverter', () => {
});
});

describe('cross-turn stale thinking signatures (pruneUntrustworthyThinking)', () => {
// Complements cleanOrphanedToolCalls's same-turn cascade: catches a
// non-latest assistant turn whose thinking survived earlier trims but
// whose tool_use was already gone by the time it entered this
// request's history (e.g. history compaction dropped the old tool
// call). That turn's thinking signature no longer matches any current
// content and Anthropic 400s the same way as the same-turn case.
it('downgrades a thinking-only non-latest turn (no tool_use, no other text) to plain text', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [
{
text: 'stale reasoning',
thought: true,
thoughtSignature: 'sig',
},
],
},
{ role: 'user', parts: [{ text: 'anything else?' }] },
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
],
});

const olderAssistant = messages[1];
expect(olderAssistant.role).toBe('assistant');
expect(olderAssistant.content).toEqual([
{ type: 'text', text: 'stale reasoning' },
]);
});

it('drops an empty redacted_thinking-derived turn entirely (defensive, no plaintext fallback)', () => {
// convertAnthropicResponseToGemini represents a redacted_thinking
// block as `{ text: '', thought: true }` (its opaque `data` doesn't
// survive the Gemini-Part round trip -- see that method's doc). When
// this round-trips back through processContent it becomes an
// empty-text `thinking` block on the wire, which the defensive
// empty-text guard drops outright. pruneUntrustworthyThinking's own
// dedicated `redacted_thinking`-type branch mirrors the same
// never-a-plaintext-fallback handling for a literal redacted_thinking
// block, matching the type-check pattern already used elsewhere in
// this file (stripThinkingFromAssistantMessages,
// dropUnsignedThinkingFromAssistantMessages) -- not independently
// reachable through this converter's own request-building path, but
// defensive against a future path constructing one directly.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [{ text: '', thought: true }],
},
{ role: 'user', parts: [{ text: 'anything else?' }] },
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
],
});

const assistantMessages = messages.filter((m) => m.role === 'assistant');
expect(assistantMessages).toHaveLength(1);
expect(assistantMessages[0].content).toEqual([
{ type: 'text', text: 'Sure, here you go.' },
]);
});

it('leaves the latest assistant turn untouched even with no surviving tool_use', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [
{
text: 'final reasoning',
thought: true,
thoughtSignature: 'sig',
},
],
},
],
});

const lastMsg = messages[messages.length - 1];
expect(lastMsg.role).toBe('assistant');
expect(lastMsg.content).toEqual([
{ type: 'thinking', thinking: 'final reasoning', signature: 'sig' },
]);
});

it('leaves a non-latest turn with real accompanying text untouched (narrower than a blanket rewrite)', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [
{
text: 'stale reasoning',
thought: true,
thoughtSignature: 'sig',
},
{ text: 'Here is my answer.' },
],
},
{ role: 'user', parts: [{ text: 'anything else?' }] },
{ role: 'model', parts: [{ text: 'Sure, here you go.' }] },
],
});

const olderAssistant = messages[1];
expect(olderAssistant.content).toEqual([
{ type: 'thinking', thinking: 'stale reasoning', signature: 'sig' },
{ type: 'text', text: 'Here is my answer.' },
]);
});
});

// 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
Loading
Loading