diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index d6c2bfec..6b361cc3 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -75,6 +75,9 @@ export type AnthropicRequestBody = { function sanitize(text: string): string { return text.replace(/[\uD800-\uDFFF]/gu, '\uFFFD') } +function isNonEmptyText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 +} /** * Detect lone (unpaired) UTF-16 surrogates. With the `u` flag the character @@ -494,7 +497,18 @@ export async function buildAnthropicRequest( }, { type: 'text', text: CLAUDE_CODE_IDENTITY }, ] - if (context.systemPrompt?.trim()) { + // Pi's host type permits prompt segments even though pi-ai declares a string. + const systemPrompt = context.systemPrompt as unknown as + | string + | readonly string[] + | undefined + const normalizedSystemPrompt = Array.isArray(systemPrompt) + ? systemPrompt + .filter(isNonEmptyText) + .map((text) => text.trim()) + .join('\n\n') + : systemPrompt + if (isNonEmptyText(normalizedSystemPrompt)) { // Pi's prompt cannot sit whole in the top-level system[] array: two lines of // its documentation paragraph (the docs/*.md enumeration and the "follow .md // cross-references" instruction) are each independently sufficient to make @@ -519,7 +533,7 @@ export async function buildAnthropicRequest( // cache_control is set explicitly because addEphemeralCacheControl's // message-level breakpoint only fires for array content on the *last* user // message, which is not this one after the first turn. - const prompt = splitPiSystemPrompt(context.systemPrompt) + const prompt = splitPiSystemPrompt(normalizedSystemPrompt) if (prompt.systemText) { system.push({ type: 'text', text: prompt.systemText }) } diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index 984b8331..3d76b64a 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -343,6 +343,124 @@ describe('convertMessages — empty base64 image guard', () => { expect(content[1]?.type).toBe('image') }) }) +describe('buildAnthropicRequest — system prompt arrays', () => { + test('normalizes non-empty prompt strings before relocation', async () => { + const { body } = await buildAnthropicRequest( + 'claude-sonnet-4-20250514', + { + messages: [userMsg('hello')], + systemPrompt: [' first prompt ', 'second prompt'], + tools: [], + } as unknown as Parameters[1], + undefined, + defaultCache, + ) + + expect(body.system).toHaveLength(2) + expect(body.messages[0]?.content).toEqual([ + { + type: 'text', + text: 'first prompt\n\nsecond prompt', + cache_control: { type: 'ephemeral' }, + }, + { + type: 'text', + text: 'hello', + cache_control: { type: 'ephemeral' }, + }, + ]) + }) + + test('skips empty and whitespace-only prompt strings', async () => { + const { body } = await buildAnthropicRequest( + 'claude-sonnet-4-20250514', + { + messages: [userMsg('hello')], + systemPrompt: ['', ' ', 'valid'], + tools: [], + } as unknown as Parameters[1], + undefined, + defaultCache, + ) + + expect(body.system).toHaveLength(2) + expect(body.messages[0]?.content).toEqual([ + { + type: 'text', + text: 'valid', + cache_control: { type: 'ephemeral' }, + }, + { + type: 'text', + text: 'hello', + cache_control: { type: 'ephemeral' }, + }, + ]) + }) + + test('does not add prompt blocks for an empty prompt array', async () => { + const { body } = await buildAnthropicRequest( + 'claude-sonnet-4-20250514', + { + messages: [userMsg('hello')], + systemPrompt: [], + tools: [], + } as unknown as Parameters[1], + undefined, + defaultCache, + ) + + expect(body.system).toHaveLength(2) + }) + + test('routes array prompts through documentation relocation', async () => { + const segments = [ + 'KEEP ONE: you are an assistant.', + 'KEEP TWO: available tools.', + 'Pi documentation (read only when the user asks about pi itself):\n- MOVE THIS', + ] + const stringBody = ( + await buildAnthropicRequest( + 'claude-sonnet-4-20250514', + { + messages: [userMsg('hello')], + systemPrompt: segments.join('\n\n'), + tools: [], + } as any, + undefined, + defaultCache, + ) + ).body + const arrayBody = ( + await buildAnthropicRequest( + 'claude-sonnet-4-20250514', + { + messages: [userMsg('hello')], + systemPrompt: segments, + tools: [], + } as unknown as Parameters[1], + undefined, + defaultCache, + ) + ).body + const arraySystemText = JSON.stringify(arrayBody.system) + const firstUserContent = arrayBody.messages[0]?.content as Array< + Record + > + + expect(arraySystemText).not.toContain('MOVE THIS') + expect(firstUserContent[0]).toMatchObject({ + type: 'text', + text: segments[2], + cache_control: { type: 'ephemeral' }, + }) + expect(arrayBody.system?.slice(2).map((block) => block.text)).toEqual([ + 'KEEP ONE: you are an assistant.\n\nKEEP TWO: available tools.', + ]) + expect(arrayBody.system).toEqual(stringBody.system) + expect(arrayBody.messages).toEqual(stringBody.messages) + }) +}) describe('buildAnthropicRequest — Claude Code system[] shape', () => { // Anthropic rejects Pi's documentation paragraph inside the top-level