Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -1268,7 +1268,15 @@ describe('AnthropicContentConverter', () => {
});
});

it('drops tool results that do not lead user content', () => {
it('reorders a tool_result ahead of other content in the same message rather than dropping it', () => {
// Anthropic requires tool_result to be the first content in a user
// message replying to a tool_use. A text part preceding the
// functionResponse part within the same Gemini Content used to be
// treated by cleanOrphanedToolCalls's own "seenNonToolResult" gate as
// if the tool_result never showed up at all -- silently discarding
// both the tool_result AND its paired tool_use, rather than fixing
// the order. Now the blocks are reordered before that gate runs, so
// the pairing is recognized and everything survives.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
Expand All @@ -1294,10 +1302,15 @@ describe('AnthropicContentConverter', () => {
});

expect(messages).toEqual([
{ role: 'user', content: [{ type: 'text', text: 'Hi' }] },
{
role: 'assistant',
content: [{ type: 'tool_use', id: 't1', name: 'tool', input: {} }],
},
{
role: 'user',
content: [
{ type: 'text', text: 'Hi' },
{ type: 'tool_result', tool_use_id: 't1', content: 'late' },
{
type: 'text',
text: 'preface',
Expand All @@ -1308,6 +1321,53 @@ describe('AnthropicContentConverter', () => {
]);
});

it('preserves relative order among multiple tool_result blocks when reordering ahead of text', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [
{ functionCall: { id: 't1', name: 'tool', args: {} } },
{ functionCall: { id: 't2', name: 'tool', args: {} } },
],
},
{
role: 'user',
parts: [
{ text: 'preface' },
{
functionResponse: {
id: 't1',
name: 'tool',
response: { output: 'first' },
},
},
{
functionResponse: {
id: 't2',
name: 'tool',
response: { output: 'second' },
},
},
],
},
],
});

const lastMsg = messages[messages.length - 1];
expect(lastMsg.content).toEqual([
{ type: 'tool_result', tool_use_id: 't1', content: 'first' },
{ type: 'tool_result', tool_use_id: 't2', content: 'second' },
{
type: 'text',
text: 'preface',
cache_control: { type: 'ephemeral' },
},
]);
});

it('deduplicates tool_use blocks by id during merge', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
Expand Down
18 changes: 18 additions & 0 deletions packages/core/src/core/anthropicContentGenerator/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,24 @@ export class AnthropicContentConverter {
}

if (contentBlocks.length > 0) {
// Anthropic requires tool_result to be the first content in a user
// message replying to a tool_use -- it doesn't scan past a leading
// non-tool_result block to find the result later in the same
// message. The source Gemini parts can arrive in any order (e.g. a
// text part preceding the functionResponse part within the same
// Content), so move tool_result blocks to the front of a user
// message whenever any are present. A stable sort preserves the
// relative order of multiple tool_result blocks against each other.
if (
role === 'user' &&
contentBlocks.some((b) => b.type === 'tool_result')
) {
contentBlocks.sort((a, b) => {
if (a.type === 'tool_result' && b.type !== 'tool_result') return -1;
if (a.type !== 'tool_result' && b.type === 'tool_result') return 1;
return 0;
});
}
messages.push({ role, content: contentBlocks });
}
}
Expand Down
Loading