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
229 changes: 229 additions & 0 deletions packages/core/src/core/anthropicContentGenerator/converter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1189,6 +1189,107 @@ describe('AnthropicContentConverter', () => {
]);
});

it('drops a duplicate tool_result sharing a tool_use_id within one message', () => {
// Anthropic rejects a message with two tool_result blocks for the
// same tool_use_id ("each `tool_use` block must have a single
// result" -- HTTP 400). This can happen when a tool call's result
// is recorded twice in history.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [{ functionCall: { id: 'dup', name: 'tool', args: {} } }],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'dup',
name: 'tool',
response: { output: 'first' },
},
},
{
functionResponse: {
id: 'dup',
name: 'tool',
response: { output: 'second' },
},
},
],
},
],
});

expect(messages).toHaveLength(3);
const toolResults = (
messages[2]!.content as Array<{ type: string; tool_use_id?: string }>
).filter((b) => b.type === 'tool_result');
expect(toolResults).toHaveLength(1);
expect(toolResults[0]).toMatchObject({
tool_use_id: 'dup',
content: 'first',
});
});

it('drops a duplicate tool_result for one id while keeping a different id in the same message', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [
{ functionCall: { id: 'dup', name: 'tool', args: {} } },
{ functionCall: { id: 'other', name: 'tool', args: {} } },
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'dup',
name: 'tool',
response: { output: 'first' },
},
},
{
functionResponse: {
id: 'dup',
name: 'tool',
response: { output: 'second' },
},
},
{
functionResponse: {
id: 'other',
name: 'tool',
response: { output: 'other-result' },
},
},
],
},
],
});

const toolResults = (
messages[2]?.content as Array<{
type: string;
tool_use_id?: string;
content?: string;
}>
).filter((b) => b.type === 'tool_result');
expect(toolResults).toHaveLength(2);
expect(toolResults.map((b) => [b.tool_use_id, b.content])).toEqual([
['dup', 'first'],
['other', 'other-result'],
]);
});

describe('tool_use id sanitization', () => {
// Anthropic validates tool_use.id / tool_result.tool_use_id against
// ^[a-zA-Z0-9_-]+$ server-side (HTTP 400 otherwise), but the Gemini
Expand Down Expand Up @@ -1400,6 +1501,134 @@ describe('AnthropicContentConverter', () => {
});
});

it('drops a duplicate tool_result for the same id across two consecutive user messages', () => {
// cleanOrphanedToolCalls only dedupes tool_result blocks within a
// single message; mergeConsecutiveUserMessages runs afterward and
// can combine two originally-separate user messages that each
// independently carried a (individually valid) tool_result for the
// same tool_use_id. Without a second dedup pass at the merge site,
// the merged message would resurface the exact "two tool_result
// blocks for one tool_use_id" shape Anthropic rejects.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [{ functionCall: { id: 'dup', name: 'tool', args: {} } }],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'dup',
name: 'tool',
response: { output: 'first' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'dup',
name: 'tool',
response: { output: 'second' },
},
},
{ text: 'a follow-up note' },
],
},
],
});

// Full merged content, not just the filtered tool_result blocks --
// confirms the non-tool_result sibling from the second message
// survives the merge and still sorts after the (deduped) results.
expect(messages).toHaveLength(3);
expect(messages[2]).toEqual({
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'dup', content: 'first' },
{
type: 'text',
text: 'a follow-up note',
cache_control: { type: 'ephemeral' },
},
],
});
});

it('drops duplicate tool_result blocks across three consecutive user messages', () => {
// Pins that the merge-site dedup accumulates across the whole
// `combined` array on every iteration, not just pairwise between
// the two most recently merged messages -- with three originally
// separate user turns each carrying a tool_result for the same
// tool_use_id, only the first should survive.
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
contents: [
{ role: 'user', parts: [{ text: 'Hi' }] },
{
role: 'model',
parts: [{ functionCall: { id: 'dup3', name: 'tool', args: {} } }],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'dup3',
name: 'tool',
response: { output: 'first' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'dup3',
name: 'tool',
response: { output: 'second' },
},
},
],
},
{
role: 'user',
parts: [
{
functionResponse: {
id: 'dup3',
name: 'tool',
response: { output: 'third' },
},
},
],
},
],
});

expect(messages).toHaveLength(3);
expect(messages[2]).toEqual({
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'dup3',
content: 'first',
cache_control: { type: 'ephemeral' },
},
],
});
});

it('merges users when dropping an orphan-only assistant turn', () => {
const { messages } = converter.convertGeminiRequestToAnthropic({
model: 'models/test',
Expand Down
49 changes: 45 additions & 4 deletions packages/core/src/core/anthropicContentGenerator/converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,33 @@ function mergeConsecutiveAssistantMessages(
return merged;
}

/**
* Builds a first-wins predicate for deduplicating tool_result blocks by
* tool_use_id. Anthropic rejects a message with more than one tool_result
* for the same tool_use_id ("each `tool_use` block must have a single
* result" -- HTTP 400); a duplicate can happen when a tool call's result is
* recorded twice in history (a retried conversion pass, or a history source
* that double-appends a function response). In the cases observed so far
* the duplicate blocks are byte-identical, so first-wins vs. last-wins is
* indistinguishable in practice -- first-wins is chosen only because it
* requires no lookahead. id-less blocks always pass through unfiltered,
* preserving prior behavior for blocks Anthropic doesn't validate this way.
*
* Two independent call sites need this: `cleanOrphanedToolCalls` (the
* common case, a duplicate within one message) and
* `mergeConsecutiveUserMessages` (a duplicate that only becomes
* co-located after two originally-separate messages are combined).
*/
function makeToolResultDeduper(): (id: string | undefined) => boolean {
const seen = new Set<string>();
return (id) => {
if (!id) return true;
if (seen.has(id)) return false;
seen.add(id);
return true;
};
}

/**
* Remove tool_use blocks that have no matching tool_result in the
* immediately following user message, and remove tool_result blocks that
Expand Down Expand Up @@ -1498,6 +1525,8 @@ function cleanOrphanedToolCalls(
continue;
}

const keepToolResult = makeToolResultDeduper();

const filtered = blocks.filter((b) => {
const t = (b as { type?: string }).type;
if (t === 'tool_use') {
Expand All @@ -1506,7 +1535,9 @@ function cleanOrphanedToolCalls(
}
if (t === 'tool_result') {
const id = (b as { tool_use_id?: string }).tool_use_id;
return !id || validToolResultBlocks.has(b as object);
if (!id) return true;
if (!validToolResultBlocks.has(b as object)) return false;
return keepToolResult(id);
}
return true;
});
Expand Down Expand Up @@ -1540,10 +1571,20 @@ function mergeConsecutiveUserMessages(
...(lastMessage.content as AnthropicContentBlockParam[]),
...(message.content as AnthropicContentBlockParam[]),
];
// Two originally-separate user messages can each carry a valid
// tool_result for the same tool_use_id (cleanOrphanedToolCalls only
// dedupes within a single message, before this merge combines
// several into one). Re-apply the same first-wins dedup here so a
// cross-message duplicate can't survive the merge and reach the
// wire as two tool_result blocks for one tool_use_id.
const keepToolResult = makeToolResultDeduper();
const toolResults = combined.filter((b) => {
if ((b as { type?: string }).type !== 'tool_result') return false;
const id = (b as { tool_use_id?: string }).tool_use_id;
return keepToolResult(id);
});
lastMessage.content = [
...combined.filter(
(b) => (b as { type?: string }).type === 'tool_result',
),
...toolResults,
...combined.filter(
(b) => (b as { type?: string }).type !== 'tool_result',
),
Expand Down
Loading