diff --git a/packages/pi/package.json b/packages/pi/package.json index 06752b8..0dde1d3 100644 --- a/packages/pi/package.json +++ b/packages/pi/package.json @@ -32,6 +32,7 @@ "build": "rm -rf dist && tsc -p tsconfig.build.json", "typecheck": "tsc", "types": "tsc", + "test": "bun test src/tests", "prepublishOnly": "bun run build" }, "dependencies": { diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index 8d99642..9fa3413 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -48,14 +48,18 @@ export type AnthropicRequestBody = { } function sanitize(text: string): string { - return text.replace(/[\uD800-\uDFFF]/g, '\uFFFD') + return text.replace(/[\uD800-\uDFFF]/gu, '\uFFFD') } +/** + * Sanitize a tool-call ID to match Anthropic's `^[a-zA-Z0-9_-]+$` pattern. + * Cross-provider IDs (e.g. OpenAI Codex `call_xxx|fc_xxx`) contain characters + * Anthropic rejects. Deterministic — same input always yields the same output. + */ function sanitizeToolId(id: string): string { if (!id) return 'tool_call_unknown' - const sanitized = id.replace(/[^a-zA-Z0-9_-]/g, '_') - if (!sanitized) return 'tool_call_unknown' - return sanitized.slice(0, 256) + const cleaned = id.replace(/[^a-zA-Z0-9_-]/g, '_') + return cleaned.length > 256 ? cleaned.slice(0, 256) : cleaned } function toClaudeCodeToolName(name: string): string { @@ -157,17 +161,20 @@ function convertMessages( if (message.role === 'toolResult') { const toolResult = message as ToolResultMessage const toolResults: Array> = [] - const firstContent = convertTextAndImages(toolResult.content) - const firstContentArr = Array.isArray(firstContent) - ? firstContent - : [{ type: 'text', text: firstContent }] - if (toolResult.isError && firstContentArr.length === 0) { - firstContentArr.push({ type: 'text', text: 'Error' }) + let content = convertTextAndImages(toolResult.content) + // Anthropic rejects tool_result with is_error=true but empty content + if ( + toolResult.isError && + (!content || + (Array.isArray(content) && content.length === 0) || + content === '') + ) { + content = [{ type: 'text', text: 'Error' }] } toolResults.push({ type: 'tool_result', tool_use_id: sanitizeToolId(toolResult.toolCallId), - content: firstContentArr, + content: content, is_error: toolResult.isError, }) @@ -177,17 +184,20 @@ function convertMessages( messages[nextIndex]?.role === 'toolResult' ) { const next = messages[nextIndex] as ToolResultMessage - const nextContent = convertTextAndImages(next.content) - const nextContentArr = Array.isArray(nextContent) - ? nextContent - : [{ type: 'text', text: nextContent }] - if (next.isError && nextContentArr.length === 0) { - nextContentArr.push({ type: 'text', text: 'Error' }) + let nextContent = convertTextAndImages(next.content) + // Anthropic rejects tool_result with is_error=true but empty content + if ( + next.isError && + (!nextContent || + (Array.isArray(nextContent) && nextContent.length === 0) || + nextContent === '') + ) { + nextContent = [{ type: 'text', text: 'Error' }] } toolResults.push({ type: 'tool_result', tool_use_id: sanitizeToolId(next.toolCallId), - content: nextContentArr, + content: nextContent, is_error: next.isError, }) nextIndex += 1 @@ -275,6 +285,13 @@ export async function buildAnthropicRequest( identity?: ClaudeCodeIdentity, ): Promise<{ body: AnthropicRequestBody; bodyText: string }> { const messages = convertMessages(context.messages) + // Strip trailing assistant messages — Anthropic rejects prefill on some models + while ( + messages.length && + messages[messages.length - 1]?.role === 'assistant' + ) { + messages.pop() + } const system = [ { type: 'text', diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts new file mode 100644 index 0000000..7fd9fa1 --- /dev/null +++ b/packages/pi/src/tests/convert.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, test } from 'bun:test' +import type { Message } from '@earendil-works/pi-ai' +import { buildAnthropicRequest } from '../convert' + +function userMsg(text: string): Message { + return { role: 'user', content: text, timestamp: 0 } +} + +function assistantMsg(text: string): Message { + return { + role: 'assistant', + content: [{ type: 'text', text }], + timestamp: 0, + } as Message +} + +function toolCallMsg(id: string, name: string): Message { + return { + role: 'assistant', + content: [{ type: 'toolCall', id, name, arguments: {} }], + timestamp: 0, + } as Message +} + +function toolResultMsg(toolCallId: string, text: string): Message { + return { + role: 'toolResult', + toolCallId, + content: [{ type: 'text', text }], + timestamp: 0, + } as Message +} + +const defaultCache = { enabled: false, mode: 'hybrid' as const } + +async function buildMessages(messages: Message[]) { + const context = { + messages, + systemPrompt: 'test', + tools: [], + } + const { body } = await buildAnthropicRequest( + 'claude-sonnet-4-20250514', + context as any, + undefined, + defaultCache, + ) + return body.messages +} + +describe('buildAnthropicRequest — prefill stripping', () => { + test('strips single trailing assistant message', async () => { + const messages = await buildMessages([ + userMsg('hello'), + assistantMsg('I will help'), + ]) + expect(messages.length).toBe(1) + expect(messages[0]?.role).toBe('user') + }) + + test('strips multiple trailing assistant messages', async () => { + const messages = await buildMessages([ + userMsg('hello'), + assistantMsg('first'), + assistantMsg('second'), + ]) + expect(messages.length).toBe(1) + expect(messages[0]?.role).toBe('user') + }) + + test('preserves assistant message followed by user message', async () => { + const messages = await buildMessages([ + userMsg('hello'), + assistantMsg('response'), + userMsg('follow up'), + ]) + expect(messages.length).toBe(3) + expect(messages[0]?.role).toBe('user') + expect(messages[1]?.role).toBe('assistant') + expect(messages[2]?.role).toBe('user') + }) + + test('preserves assistant with tool_use followed by tool_result', async () => { + const messages = await buildMessages([ + userMsg('do something'), + toolCallMsg('tool_1', 'Bash'), + toolResultMsg('tool_1', 'output'), + ]) + expect(messages.length).toBe(3) + expect(messages[0]?.role).toBe('user') + expect(messages[1]?.role).toBe('assistant') + expect(messages[2]?.role).toBe('user') // tool_result maps to user role + }) + + test('strips trailing assistant after tool_result + assistant', async () => { + const messages = await buildMessages([ + userMsg('do something'), + toolCallMsg('tool_1', 'Bash'), + toolResultMsg('tool_1', 'output'), + assistantMsg('based on that output...'), + ]) + expect(messages.length).toBe(3) + expect(messages[2]?.role).toBe('user') + }) + + test('handles user-only conversation', async () => { + const messages = await buildMessages([userMsg('hello')]) + expect(messages.length).toBe(1) + expect(messages[0]?.role).toBe('user') + }) + + test('handles empty messages array', async () => { + const messages = await buildMessages([]) + expect(messages.length).toBe(0) + }) + + test('strips all-assistant conversation to empty', async () => { + const messages = await buildMessages([ + assistantMsg('first'), + assistantMsg('second'), + ]) + expect(messages.length).toBe(0) + }) +}) + +describe('convertMessages — basic transforms', () => { + test('converts user text message', async () => { + const messages = await buildMessages([userMsg('hello world')]) + expect(messages.length).toBe(1) + expect(messages[0]).toEqual({ role: 'user', content: 'hello world' }) + }) + + test('skips empty user messages', async () => { + const messages = await buildMessages([userMsg(''), userMsg('real message')]) + expect(messages.length).toBe(1) + expect(messages[0]).toEqual({ role: 'user', content: 'real message' }) + }) + + test('converts assistant text blocks', async () => { + const messages = await buildMessages([ + userMsg('hi'), + assistantMsg('hello back'), + userMsg('thanks'), + ]) + expect(messages[1]?.role).toBe('assistant') + const content = messages[1]?.content as Array> + expect(content[0]).toEqual({ type: 'text', text: 'hello back' }) + }) + + test('converts tool_use and tool_result round-trip', async () => { + const messages = await buildMessages([ + userMsg('run ls'), + toolCallMsg('call_1', 'Bash'), + toolResultMsg('call_1', 'file1.txt'), + userMsg('thanks'), + ]) + expect(messages.length).toBe(4) + + // assistant with tool_use + const assistantContent = messages[1]?.content as Array< + Record + > + expect(assistantContent[0]?.type).toBe('tool_use') + expect(assistantContent[0]?.name).toBe('Bash') + + // tool_result mapped to user role + const toolContent = messages[2]?.content as Array> + expect(toolContent[0]?.type).toBe('tool_result') + expect(toolContent[0]?.tool_use_id).toBe('call_1') + }) + + test('sanitizes surrogate pairs in text', async () => { + const messages = await buildMessages([userMsg('hello \uD800 world')]) + expect(messages[0]).toEqual({ + role: 'user', + content: 'hello \uFFFD world', + }) + }) + + test('sanitizes tool IDs with invalid characters', async () => { + const messages = await buildMessages([ + userMsg('run it'), + toolCallMsg('call_xxx|fc_yyy', 'Bash'), + toolResultMsg('call_xxx|fc_yyy', 'output'), + userMsg('done'), + ]) + const assistantContent = messages[1]?.content as Array< + Record + > + expect(assistantContent[0]?.id).toBe('call_xxx_fc_yyy') + const toolContent = messages[2]?.content as Array> + expect(toolContent[0]?.tool_use_id).toBe('call_xxx_fc_yyy') + }) +}) + +describe('convertMessages — empty base64 image guard', () => { + test('filters out image with empty data from user message', async () => { + const messages = await buildMessages([ + { + role: 'user', + content: [ + { type: 'text', text: 'see image' }, + { type: 'image', mimeType: 'image/png', data: '' }, + ], + timestamp: 0, + } as Message, + ]) + const content = messages[0]?.content as Array> + expect(content.length).toBe(1) + expect(content[0]?.type).toBe('text') + }) + + test('filters out image with null/undefined data', async () => { + const messages = await buildMessages([ + { + role: 'user', + content: [ + { type: 'text', text: 'screenshot' }, + { type: 'image', mimeType: 'image/png', data: null }, + ], + timestamp: 0, + } as Message, + ]) + const content = messages[0]?.content as Array> + expect(content.length).toBe(1) + expect(content[0]?.type).toBe('text') + }) + + test('adds placeholder text when all images filtered and no text remains', async () => { + const messages = await buildMessages([ + { + role: 'user', + content: [{ type: 'image', mimeType: 'image/png', data: '' }], + timestamp: 0, + } as Message, + ]) + const content = messages[0]?.content as Array> + expect(content[0]?.type).toBe('text') + expect(content[0]?.text).toBe('(see attached image)') + }) + + test('keeps valid image with actual data', async () => { + const messages = await buildMessages([ + { + role: 'user', + content: [ + { type: 'text', text: 'look' }, + { type: 'image', mimeType: 'image/png', data: 'aGVsbG8=' }, + ], + timestamp: 0, + } as Message, + ]) + const content = messages[0]?.content as Array> + expect(content.length).toBe(2) + expect(content[1]?.type).toBe('image') + }) +}) + +describe('convertMessages — empty error tool_result guard', () => { + test('injects Error placeholder when is_error=true and content is empty', async () => { + const messages = await buildMessages([ + userMsg('run it'), + toolCallMsg('tool_1', 'Bash'), + { + role: 'toolResult', + toolCallId: 'tool_1', + content: [], + isError: true, + timestamp: 0, + } as unknown as Message, + userMsg('ok'), + ]) + const toolContent = messages[2]?.content as Array> + expect(toolContent[0]?.is_error).toBe(true) + const innerContent = toolContent[0]?.content as Array< + Record + > + expect(innerContent).toEqual([{ type: 'text', text: 'Error' }]) + }) + + test('keeps content when is_error=true but content is non-empty', async () => { + const messages = await buildMessages([ + userMsg('run it'), + toolCallMsg('tool_1', 'Bash'), + toolResultMsg('tool_1', 'command failed: exit 1'), + userMsg('ok'), + ]) + // Patch isError onto the toolResult after creation + const toolContent = messages[2]?.content as Array> + // Non-empty content should be preserved as-is (string form) + expect(toolContent[0]?.content).toBe('command failed: exit 1') + }) + + test('injects Error placeholder for consecutive error tool_results', async () => { + const messages = await buildMessages([ + userMsg('run both'), + { + role: 'assistant', + content: [ + { type: 'toolCall', id: 'tool_1', name: 'Bash', arguments: {} }, + { type: 'toolCall', id: 'tool_2', name: 'Bash', arguments: {} }, + ], + timestamp: 0, + } as Message, + { + role: 'toolResult', + toolCallId: 'tool_1', + content: [], + isError: true, + timestamp: 0, + } as unknown as Message, + { + role: 'toolResult', + toolCallId: 'tool_2', + content: [], + isError: true, + timestamp: 0, + } as unknown as Message, + userMsg('ok'), + ]) + // Both tool_results should be in same user message (batched) + const toolContent = messages[2]?.content as Array> + expect(toolContent.length).toBe(2) + for (const tr of toolContent) { + expect(tr.is_error).toBe(true) + expect(tr.content).toEqual([{ type: 'text', text: 'Error' }]) + } + }) +})