diff --git a/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-conversation-updates.patch.yml b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-conversation-updates.patch.yml new file mode 100644 index 0000000000..6d2a8dde06 --- /dev/null +++ b/apps/cli/tests/profiles/headless/tests/fixtures/pi-ai-conversation-updates.patch.yml @@ -0,0 +1,36 @@ +# pi-ai Kimi route with dynamic prompt and tool updates over the shipped headless profile. +- id: headless-startup + disabled: true + +- id: headless-runner + disabled: true + +- id: llm-deepseek + disabled: true + +- id: llm-pi-ai + config: + providers: + moonshotai: + apiKeyEnv: MOONSHOT_API_KEY + baseURL: !!js process.env.DSH_SNAPSHOT_BASE_URL + streamIdleTimeoutMs: 1000 + +- id: agent-loop + config: + agents: + - id: main + provider: moonshotai + model: kimi-k3 + cwd: !!js process.cwd() + +- id: agent-instructions + disabled: true + +# The fixture counts every model request, so title generation stays off. +- id: session-title-llm + disabled: true + +- insert: + - id: dynamic-tool-prompt-updates + name: '../../../../../../../packages/test-support/session-snapshot/tests/fixtures/dynamic-tool-prompt-updates.ts' diff --git a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts index 7a46b1dc89..57cfa244c2 100644 --- a/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts +++ b/apps/cli/tests/profiles/headless/tests/headless.expected.e2e.ts @@ -42,6 +42,7 @@ const tsconfigPath = fileURLToPath(new URL('../../../../../../tsconfig.json', im const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.patch.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.patch.yml', import.meta.url)) const piAiDefaultsConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-defaults.patch.yml', import.meta.url)) +const piAiConversationUpdatesConfigPath = fileURLToPath(new URL('./fixtures/pi-ai-conversation-updates.patch.yml', import.meta.url)) const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.patch.yml', import.meta.url)) const headlessSessionExpected = join(goldensDir, 'headless-profile', 'session.expected.jsonl') const headlessReasoningExpected = join(goldensDir, 'headless-profile', 'reasoning.stderr.expected.txt') @@ -148,6 +149,58 @@ async function deepseekDefaultsServer( } } +/** Script Chat Completions responses in request order: `read`, then `snapshot_ping`, then text. */ +async function piAiConversationServer(): Promise { + const requests: JsonObject[] = [] + const agentReplies = [ + { tool_calls: [{ index: 0, id: 'call_read', type: 'function', function: { name: 'read', arguments: '{"file_path":"task.txt"}' } }] }, + { tool_calls: [{ index: 0, id: 'call_ping', type: 'function', function: { name: 'snapshot_ping', arguments: '{}' } }] }, + { content: 'DONE' }, + ] + const server = createServer((request: IncomingMessage, response: ServerResponse) => { + let body = '' + request.setEncoding('utf8') + request.on('data', (chunk: string) => { body += chunk }) + request.on('end', () => { + requests.push(JSON.parse(body) as JsonObject) + const delta = agentReplies[requests.length - 1] ?? { content: 'UNEXPECTED' } + const finish = 'tool_calls' in delta ? 'tool_calls' : 'stop' + response.writeHead(200, { 'content-type': 'text/event-stream' }) + response.end([ + `data: ${JSON.stringify({ choices: [{ delta }] })}`, + `data: ${JSON.stringify({ choices: [{ delta: {}, finish_reason: finish }], usage: { prompt_tokens: 3, completion_tokens: 1 } })}`, + 'data: [DONE]', + '', + ].join('\n\n')) + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (address === null || typeof address === 'string') throw new Error('pi-ai conversation snapshot server has no port') + return { + url: `http://127.0.0.1:${address.port}`, + requests, + paths: [], + close: () => new Promise(resolve => server.close(() => { resolve() })), + } +} + +/** Project one Chat Completions request to its fixture-owned declarations and message roles, tools, and prompt markers. */ +function piAiTranscript(request: JsonObject): JsonObject { + const names = (tools: unknown): string[] => Array.isArray(tools) + ? tools.map(tool => ((tool as { function: { name: string } }).function.name)) + : [] + return { + // Profile tools change independently of this scenario; only the fixture's tool is recorded. + tools: names(request.tools).filter(name => name === 'snapshot_ping'), + messages: (request.messages as JsonObject[]).map(message => ({ + role: message.role, + ...message.tools === undefined ? {} : { tools: names(message.tools) }, + ...typeof message.content === 'string' && message.content.includes('Dynamic tool guidance') ? { guidance: true } : {}, + })), + } +} + function parseJsonl(content: string): JsonObject[] { return content.split('\n') .filter(line => line.trim().length > 0) @@ -745,6 +798,113 @@ describe('headless stream-json snapshots', () => { } }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('sends pi-ai Kimi prompt and tool updates in conversation history', async () => { + const server = await piAiConversationServer() + try { + const result = await runLoaderSmoke({ + label: 'pi-ai Kimi conversation updates headless stream-json snapshot', + tempDirPrefix: 'headless-snapshot-pi-ai-conversation-updates-', + binScript, + libBinScript: binScript, + configPath: piAiConversationUpdatesConfigPath, + binArgs: [ + piAiConversationUpdatesConfigPath, + 'read task.txt and follow it', + ], + tsconfigPath, + prepare: cwd => writeFile(join(cwd, 'task.txt'), 'Call snapshot_ping once.\n'), + env: { + MOONSHOT_API_KEY: 'snapshot-key', + DSH_SNAPSHOT_BASE_URL: server.url, + NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), + }, + }) + + expect(result.stderr).toBe('') + expect(parseJsonl(result.stdout).at(-1)).toMatchObject({ type: 'result', output: 'DONE' }) + expect(server.requests.map(piAiTranscript)).toMatchInlineSnapshot(` + [ + { + "messages": [ + { + "role": "system", + }, + { + "role": "user", + }, + { + "role": "user", + }, + ], + "tools": [], + }, + { + "messages": [ + { + "role": "system", + }, + { + "role": "user", + }, + { + "role": "user", + }, + { + "role": "assistant", + }, + { + "role": "tool", + }, + { + "guidance": true, + "role": "system", + }, + { + "role": "system", + "tools": [ + "snapshot_ping", + ], + }, + ], + "tools": [], + }, + { + "messages": [ + { + "role": "system", + }, + { + "role": "user", + }, + { + "role": "user", + }, + { + "role": "assistant", + }, + { + "role": "tool", + }, + { + "guidance": true, + "role": "system", + }, + { + "role": "assistant", + }, + { + "role": "tool", + }, + ], + "tools": [], + }, + ] + `) + } finally { + await server.close() + } + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + it('runs a keyless Agent Team with peer mail, dependent tasks, waiting, and Lead aggregation', async () => { let projection: unknown const result = await runLoaderSmoke({