From ab603d3677cec84dea1c2f43b59d04b929eccf31 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:18:30 +0200 Subject: [PATCH 1/2] fix(pi): handle array systemPrompt in request conversion Pi's context.systemPrompt permits an array of segments, but buildAnthropicRequest only handled a single string. Each non-empty segment now becomes its own sanitized text block in top-level system[], with empty and whitespace-only entries skipped. Co-authored-by: randomvariable --- packages/pi/src/convert.ts | 14 ++++++- packages/pi/src/tests/convert.test.ts | 57 +++++++++++++++++++++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index d6c2bfec..65751054 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -75,6 +75,14 @@ export type AnthropicRequestBody = { function sanitize(text: string): string { return text.replace(/[\uD800-\uDFFF]/gu, '\uFFFD') } +function extractSystemPromptTexts(systemPrompt: unknown): string[] { + const values = Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt] + return values.flatMap((value) => { + if (typeof value !== 'string') return [] + const text = value.trim() + return text ? [text] : [] + }) +} /** * Detect lone (unpaired) UTF-16 surrogates. With the `u` flag the character @@ -494,7 +502,11 @@ export async function buildAnthropicRequest( }, { type: 'text', text: CLAUDE_CODE_IDENTITY }, ] - if (context.systemPrompt?.trim()) { + if (Array.isArray(context.systemPrompt)) { + for (const text of extractSystemPromptTexts(context.systemPrompt)) { + system.push({ type: 'text', text: sanitize(text) }) + } + } else if (context.systemPrompt?.trim()) { // 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 diff --git a/packages/pi/src/tests/convert.test.ts b/packages/pi/src/tests/convert.test.ts index 984b8331..0d1c3f6c 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -343,6 +343,63 @@ describe('convertMessages — empty base64 image guard', () => { expect(content[1]?.type).toBe('image') }) }) +describe('buildAnthropicRequest — system prompt arrays', () => { + test('adds each non-empty prompt string as a separate text block', 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(4) + expect(body.system?.map((block) => block.type)).toEqual([ + 'text', + 'text', + 'text', + 'text', + ]) + expect(body.system?.slice(2).map((block) => block.text)).toEqual([ + 'first prompt', + 'second prompt', + ]) + }) + + 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(3) + expect(body.system?.[2]?.text).toBe('valid') + }) + + 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) + }) +}) describe('buildAnthropicRequest — Claude Code system[] shape', () => { // Anthropic rejects Pi's documentation paragraph inside the top-level From 7da8919efad3de283eb1c8d1e6bc69ff6b115508 Mon Sep 17 00:00:00 2001 From: iceteaSA <171169159+iceteaSA@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:18:30 +0200 Subject: [PATCH 2/2] fix(pi): route array system prompts through the shared relocation path Normalize host-provided prompt segments into the same paragraph-aware path as string prompts so Pi documentation stays out of top-level system[]. Update array expectations for the shared relocation and add byte-equivalence coverage. Co-authored-by: randomvariable --- packages/pi/src/convert.ts | 28 +++++---- packages/pi/src/tests/convert.test.ts | 87 +++++++++++++++++++++++---- 2 files changed, 89 insertions(+), 26 deletions(-) diff --git a/packages/pi/src/convert.ts b/packages/pi/src/convert.ts index 65751054..6b361cc3 100644 --- a/packages/pi/src/convert.ts +++ b/packages/pi/src/convert.ts @@ -75,13 +75,8 @@ export type AnthropicRequestBody = { function sanitize(text: string): string { return text.replace(/[\uD800-\uDFFF]/gu, '\uFFFD') } -function extractSystemPromptTexts(systemPrompt: unknown): string[] { - const values = Array.isArray(systemPrompt) ? systemPrompt : [systemPrompt] - return values.flatMap((value) => { - if (typeof value !== 'string') return [] - const text = value.trim() - return text ? [text] : [] - }) +function isNonEmptyText(value: unknown): value is string { + return typeof value === 'string' && value.trim().length > 0 } /** @@ -502,11 +497,18 @@ export async function buildAnthropicRequest( }, { type: 'text', text: CLAUDE_CODE_IDENTITY }, ] - if (Array.isArray(context.systemPrompt)) { - for (const text of extractSystemPromptTexts(context.systemPrompt)) { - system.push({ type: 'text', text: sanitize(text) }) - } - } else 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 @@ -531,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 0d1c3f6c..3d76b64a 100644 --- a/packages/pi/src/tests/convert.test.ts +++ b/packages/pi/src/tests/convert.test.ts @@ -344,7 +344,7 @@ describe('convertMessages — empty base64 image guard', () => { }) }) describe('buildAnthropicRequest — system prompt arrays', () => { - test('adds each non-empty prompt string as a separate text block', async () => { + test('normalizes non-empty prompt strings before relocation', async () => { const { body } = await buildAnthropicRequest( 'claude-sonnet-4-20250514', { @@ -356,16 +356,18 @@ describe('buildAnthropicRequest — system prompt arrays', () => { defaultCache, ) - expect(body.system).toHaveLength(4) - expect(body.system?.map((block) => block.type)).toEqual([ - 'text', - 'text', - 'text', - 'text', - ]) - expect(body.system?.slice(2).map((block) => block.text)).toEqual([ - 'first prompt', - 'second prompt', + 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' }, + }, ]) }) @@ -381,8 +383,19 @@ describe('buildAnthropicRequest — system prompt arrays', () => { defaultCache, ) - expect(body.system).toHaveLength(3) - expect(body.system?.[2]?.text).toBe('valid') + 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 () => { @@ -399,6 +412,54 @@ describe('buildAnthropicRequest — system prompt arrays', () => { 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', () => {