test(llm-pi-ai): cover conversation update serializer paths

This commit is contained in:
Yixiang Tu committed 2026-10-08 14:38:55 +08:00
1 parent 321417988b
commit 3cde24b5f5
5 files changed
+73 -9

No files matched your search

@@ -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)
+1 -1
View File
@@ -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: [
@@ -52,7 +52,7 @@ describe('conversation capability mapping', () => {
afterEach(closeMockServers)
async function send(provider: string, modelId: string, options: Omit<GenerateOptions, 'provider' | 'model'>) {
async function send(provider: string, modelId: string, options: Omit<GenerateOptions, 'provider' | 'model'>, 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<GenerateOpt
await runtime
// The serializer requires an API key even though the local server does not authenticate.
const adapter = new LlmPiAi.PiAiAdapter({
profiles: () => 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' }] })
+1 -2
View File
@@ -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 () => {
+6 -2
View File
@@ -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)