Skip to content
Open
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
18 changes: 16 additions & 2 deletions packages/pi/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 })
}
Expand Down
118 changes: 118 additions & 0 deletions packages/pi/src/tests/convert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof buildAnthropicRequest>[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<typeof buildAnthropicRequest>[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<typeof buildAnthropicRequest>[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<typeof buildAnthropicRequest>[1],
undefined,
defaultCache,
)
).body
const arraySystemText = JSON.stringify(arrayBody.system)
const firstUserContent = arrayBody.messages[0]?.content as Array<
Record<string, unknown>
>

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
Expand Down