diff --git a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts index 2e410bf98f..70cd11734c 100644 --- a/packages/core/agent-loop/tests/system-prompt-admission.spec.ts +++ b/packages/core/agent-loop/tests/system-prompt-admission.spec.ts @@ -189,7 +189,7 @@ describe('prepared-route prompt admission', () => { const adapter = provider === 'capable' ? h.capable : h.plain const cleared = adapter.requests.at(-1)! expect(systemTexts(cleared)).toEqual([]) - expect(toPiContext(cleared).systemPrompt).toBeUndefined() + expect(toPiContext(cleared).messages.filter(message => message.role === 'system')).toEqual([]) expect(JSON.stringify(toPiContext(cleared))).not.toContain('prompt ') const clearEvents = h.agent.session.snapshotEvents().filter(event => event.type === 'system/message').filter(event => event.data.turn === 4) expect(clearEvents).toHaveLength(3) diff --git a/packages/llm/llm-pi-ai/tests/context.spec.ts b/packages/llm/llm-pi-ai/tests/context.spec.ts index 18d8e3eb12..84064598bf 100644 --- a/packages/llm/llm-pi-ai/tests/context.spec.ts +++ b/packages/llm/llm-pi-ai/tests/context.spec.ts @@ -596,7 +596,7 @@ describe('pi-ai system prompt source', () => { await expect(toPiContext(options, imageContext(attachments), undefined, 'in-history')).rejects.toThrow(error) }) - it('keeps developer text and tool changes together at their activation position', async () => { + it('keeps developer text and tool changes together after the following user turn', async () => { const lookup = { name: 'lookup', description: 'initial', parameters: { type: 'object' } } const search = { name: 'search', description: 'later', parameters: { type: 'object' }, deferLoading: true } const update = createDeveloperMessage({ source: { kind: 'test' }, content: [ diff --git a/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts b/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts index 8fad1ee407..b185ff7033 100644 --- a/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts +++ b/packages/llm/llm-pi-ai/tests/conversation-updates.spec.ts @@ -52,7 +52,7 @@ describe('conversation capability mapping', () => { afterEach(closeMockServers) -async function send(provider: string, modelId: string, options: Omit) { +async function send(provider: string, modelId: string, options: Omit, apiKey = 'fixture-key') { const server = await mockServer([{ status: 401, body: JSON.stringify({ error: { message: 'fixture rejection' } }) }]) const ctx = new Context() const runtime = ctx.plugin(LlmRuntime) @@ -60,8 +60,8 @@ async function send(provider: string, modelId: string, options: Omit resolveProfiles({ [provider]: { baseURL: server.url } }), - resolveApiKey: () => Promise.resolve('fixture-key'), + profiles: () => resolveProfiles({ [provider]: { baseURL: server.url, apiKeyEnv: 'PI_CONVERSATION_TEST_KEY', transport: 'sse' } }), + resolveApiKey: () => Promise.resolve(apiKey), auth: memoryAuth(), }) // Use the runtime's own tool projection with this explicitly authenticated adapter. @@ -79,6 +79,27 @@ const addition = () => createDeveloperMessage({ source: { kind: 'tool-registry' ] }) describe('serialized conversation updates', () => { + it.each(['anthropic', 'openai'] as const)('omits an empty initial prompt while retaining the later update through %s', async (provider) => { + const modelId = provider === 'anthropic' ? 'claude-opus-4-8' : 'gpt-5.4' + const { requests } = await send(provider, modelId, { + messages: [system(''), user('first'), system('later prompt')], + }) + expect(requests).toHaveLength(1) + const request = requests[0] + expect(request).not.toHaveProperty('system') + if (provider === 'anthropic') { + expect(request).toMatchObject({ messages: [ + { role: 'user', content: 'first' }, + { role: 'system', content: [{ type: 'text', text: 'later prompt' }] }, + ] }) + } else { + expect(request).toMatchObject({ input: [ + { role: 'user', content: [{ type: 'input_text', text: 'first' }] }, + { role: 'developer', content: 'later prompt' }, + ] }) + } + }) + it('sends full prompt snapshots and grouped tool additions through Anthropic', async () => { const update = addition() const { requests } = await send('anthropic', 'claude-opus-4-8', { @@ -130,6 +151,46 @@ describe('serialized conversation updates', () => { }) describe('historical tool availability on the wire', () => { + it('anchors Kimi tool additions after user input in Chat Completions', async () => { + const update = addition() + const { requests } = await send('moonshotai', 'kimi-k3', { + messages: [user('first'), update], tools: [baseline, added], + toolHistory: { tools: [baseline], updates: [{ messageId: update.id, additions: [added] }] }, + }) + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + tools: [{ type: 'function', function: { name: 'read' } }], + messages: [ + { role: 'user', content: 'first' }, + { role: 'system', tools: [{ type: 'function', function: { name: 'search', description: added.description, parameters: added.parameters } }] }, + { role: 'system', content: 'Instruction one.\nInstruction two.' }, + ], + }) + }) + + it('loads Codex tool additions through tool-search output', async () => { + // Codex extracts an account id from the token before contacting the mock server. + const payload = Buffer.from(JSON.stringify({ 'https://api.openai.com/auth': { chatgpt_account_id: 'fixture-account' } })).toString('base64url') + const token = `e30.${payload}.fixture` + const update = addition() + const { requests } = await send('openai-codex', 'gpt-5.5', { + messages: [user('first'), update], tools: [baseline, added], + toolHistory: { tools: [baseline], updates: [{ messageId: update.id, additions: [added] }] }, + }, token) + expect(requests).toHaveLength(1) + expect(requests[0]).toMatchObject({ + tools: [{ type: 'function', name: 'read' }], + input: [ + { role: 'user', content: [{ type: 'input_text', text: 'first' }] }, + { type: 'tool_search_call', execution: 'client', arguments: { query: 'search', limit: 1 } }, + { type: 'tool_search_output', execution: 'client', tools: [ + { type: 'function', name: 'search', description: added.description, parameters: added.parameters, defer_loading: true }, + ] }, + { role: 'developer', content: 'Instruction one.\nInstruction two.' }, + ], + }) + }) + it('retains removed declarations and reactivates them through Anthropic history', async () => { const remove = createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [{ type: 'tool-removal', toolName: 'search' }] }) const restore = createDeveloperMessage({ source: { kind: 'tool-registry' }, content: [{ type: 'tool-addition', toolName: 'search' }] }) diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index d9a3a52ced..c679e311e0 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -93,8 +93,7 @@ describe('toPiContext', () => { it('omits empty tools and absent system prompt', () => { const context = toPiContext({ provider: 'deepseek', model: 'm', messages: [], tools: [] }) - expect(context.systemPrompt).toBeUndefined() - expect(context.tools).toBeUndefined() + expect(context).toEqual({ messages: [] }) }) it('resolves durable image references into native pi-ai image content', async () => { diff --git a/packages/llm/llm-pi-ai/tests/mock-server.ts b/packages/llm/llm-pi-ai/tests/mock-server.ts index da3ed68387..1aae135e2b 100644 --- a/packages/llm/llm-pi-ai/tests/mock-server.ts +++ b/packages/llm/llm-pi-ai/tests/mock-server.ts @@ -1,4 +1,5 @@ import { createServer } from 'node:http' +import { zstdDecompressSync } from 'node:zlib' import type { IncomingMessage, Server, ServerResponse } from 'node:http' export interface MockServer { @@ -81,9 +82,12 @@ export async function mockServer(script: { closedResponses += 1 responseClosed.resolve(undefined) }) - let body = '' - request.on('data', (chunk: Buffer) => { body += chunk.toString('utf8') }) + const chunks: Buffer[] = [] + request.on('data', (chunk: Buffer) => { chunks.push(chunk) }) request.on('end', () => { + const bytes = Buffer.concat(chunks) + // Codex SSE requests use zstd compression when the host supports it. + const body = (request.headers['content-encoding'] === 'zstd' ? zstdDecompressSync(bytes) : bytes).toString('utf8') paths.push(request.url ?? '') requests.push(body.length === 0 ? undefined : JSON.parse(body)) headers.push(request.headers)