From 9ff021e42b4739b2721ec4f1bd28a0a526686255 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 16 Mar 2026 14:03:51 -0400 Subject: [PATCH] fix(cursor): harden anthropic translation edge cases --- src/cursor/cursor-anthropic-translator.ts | 22 +++++- .../cursor-anthropic-translator.test.ts | 78 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) diff --git a/src/cursor/cursor-anthropic-translator.ts b/src/cursor/cursor-anthropic-translator.ts index 30c8db5b..ce245bb7 100644 --- a/src/cursor/cursor-anthropic-translator.ts +++ b/src/cursor/cursor-anthropic-translator.ts @@ -13,6 +13,9 @@ export interface TranslatedAnthropicRequest { messages: CursorOpenAIMessage[]; } +const TOOL_RESULT_SERIALIZATION_FALLBACK = '[unserializable content]'; +const TOOL_USE_ARGUMENTS_FALLBACK = '{}'; + function assertObject(value: unknown, label: string): Record { if (typeof value !== 'object' || value === null) { throw new Error(`${label} must be an object`); @@ -20,6 +23,19 @@ function assertObject(value: unknown, label: string): Record { return value as Record; } +function safeJsonStringify(value: unknown, fallback: string): string { + try { + const serialized = JSON.stringify(value); + return typeof serialized === 'string' ? serialized : fallback; + } catch { + return fallback; + } +} + +function createFallbackToolId(messageIndex: number, blockIndex: number): string { + return `toolu_ccs_fallback_${messageIndex}_${blockIndex}`; +} + function flattenTextContent(content: unknown, label: string): string { if (typeof content === 'string') { return content; @@ -49,7 +65,7 @@ function toToolResultContent(content: unknown, label: string): string { if (Array.isArray(content)) { return flattenTextContent(content, label); } - return JSON.stringify(content); + return safeJsonStringify(content, TOOL_RESULT_SERIALIZATION_FALLBACK); } function mapThinkingToReasoningEffort( @@ -125,11 +141,11 @@ export function translateAnthropicRequest(raw: unknown): TranslatedAnthropicRequ id: typeof parsed.id === 'string' && parsed.id.length > 0 ? parsed.id - : `toolu_${messageIndex}_${blockIndex}`, + : createFallbackToolId(messageIndex, blockIndex), type: 'function', function: { name: typeof parsed.name === 'string' ? parsed.name : 'tool', - arguments: JSON.stringify(parsed.input ?? {}), + arguments: safeJsonStringify(parsed.input ?? {}, TOOL_USE_ARGUMENTS_FALLBACK), }, }); return; diff --git a/tests/unit/cursor/cursor-anthropic-translator.test.ts b/tests/unit/cursor/cursor-anthropic-translator.test.ts index 8ae0e619..63d91201 100644 --- a/tests/unit/cursor/cursor-anthropic-translator.test.ts +++ b/tests/unit/cursor/cursor-anthropic-translator.test.ts @@ -89,6 +89,67 @@ describe('translateAnthropicRequest', () => { { role: 'tool', tool_call_id: 'toolu_1', content: 'done' }, ]); }); + + it('uses a distinct fallback prefix for missing tool_use ids', () => { + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', name: 'search', input: { q: 'x' } }], + }, + ], + }); + + expect(translated.messages[0]?.tool_calls?.[0]?.id).toBe('toolu_ccs_fallback_0_0'); + }); + + it('falls back when tool_result content cannot be serialized', () => { + const circular: Record = {}; + circular.self = circular; + + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: circular }], + }, + ], + }); + + expect(translated.messages).toEqual([ + { + role: 'tool', + tool_call_id: 'toolu_1', + content: '[unserializable content]', + }, + ]); + }); + + it('falls back when tool_use input cannot be serialized', () => { + const circular: Record = {}; + circular.self = circular; + + const translated = translateAnthropicRequest({ + messages: [ + { + role: 'assistant', + content: [{ type: 'tool_use', name: 'search', input: circular }], + }, + ], + }); + + expect(translated.messages[0]).toEqual({ + role: 'assistant', + content: '', + tool_calls: [ + { + id: 'toolu_ccs_fallback_0_0', + type: 'function', + function: { name: 'search', arguments: '{}' }, + }, + ], + }); + }); }); describe('createAnthropicProxyResponse', () => { @@ -166,4 +227,21 @@ describe('createAnthropicProxyResponse', () => { expect(body).toContain('"type":"text_delta"'); expect(body).toContain('event: message_stop'); }); + + it('emits Anthropic-style error events when SSE translation fails', async () => { + const oversizedChunk = `data: ${'x'.repeat(1024 * 1024 + 32)}`; + + const transformed = await createAnthropicProxyResponse( + new Response(oversizedChunk, { + status: 200, + headers: { 'Content-Type': 'text/event-stream' }, + }) + ); + + const body = await transformed.text(); + expect(body).toContain('event: error'); + expect(body).toContain('"type":"error"'); + expect(body).toContain('"error":{"type":"api_error"'); + expect(body).toContain('Failed to translate Cursor SSE response'); + }); });