/** * 最小闭环冒烟测试(不依赖 Electron、不依赖真实 API Key)。 * * 用一个本地假上游(OpenAI 兼容 SSE)驱动真实的 gateway + agent loop, * 验证:SSE 流式解析 → 流式工具调用组装 → 工具执行 → 上下文回填 → 终态。 * * 运行:npm run smoke */ import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { Gateway } from '../src/main/gateway' import { runTurn } from '../src/main/agent/loop' import { executeTool } from '../src/main/agent/tools' import type { ChatEvent, ChatMessage } from '../src/shared/types' let failures = 0 function check(label: string, condition: boolean, detail = ''): void { if (condition) { console.log(` ✓ ${label}`) } else { failures++ console.error(` ✗ ${label}${detail ? ` — ${detail}` : ''}`) } } function sseChunk(payload: unknown): string { return `data: ${JSON.stringify(payload)}\n\n` } /** 假上游:第 1 次请求返回工具调用,第 2 次返回最终文本 */ function startFakeUpstream(): Promise<{ server: Server; port: number; requests: unknown[] }> { const requests: unknown[] = [] const server = createServer((req: IncomingMessage, res: ServerResponse) => { let raw = '' req.on('data', (c) => (raw += c)) req.on('end', () => { const body = JSON.parse(raw || '{}') requests.push(body) const round = requests.length res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' }) if (round === 1) { // 分片下发工具调用,模拟真实上游的增量行为 res.write( sseChunk({ choices: [ { index: 0, delta: { tool_calls: [ { index: 0, id: 'call_abc', type: 'function', function: { name: 'list_dir', arguments: '{"pa' } } ] }, finish_reason: null } ] }) ) res.write( sseChunk({ choices: [ { index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: 'th":"."}' } }] }, finish_reason: null } ] }) ) res.write(sseChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }] })) } else { for (const piece of ['工作目录里有 ', 'a.txt', ' 和 ', 'b.txt', '。']) { res.write(sseChunk({ choices: [{ index: 0, delta: { content: piece } }] })) } res.write(sseChunk({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] })) } res.write('data: [DONE]\n\n') res.end() }) }) return new Promise((resolve) => { server.listen(0, '127.0.0.1', () => { const address = server.address() const port = typeof address === 'object' && address ? address.port : 0 resolve({ server, port, requests }) }) }) } async function main(): Promise { // ---------- 准备临时工作目录 ---------- const workspaceRoot = await mkdtemp(join(tmpdir(), 'ycode-smoke-')) await writeFile(join(workspaceRoot, 'a.txt'), 'hello a', 'utf8') await writeFile(join(workspaceRoot, 'b.txt'), 'hello b', 'utf8') const { server, port, requests } = await startFakeUpstream() try { const gateway = new Gateway({ baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model', apiKey: 'test-key', temperature: 0.7, maxToolRounds: 4 }) const events: ChatEvent[] = [] const history: ChatMessage[] = [] console.log('\n[1] Agent 闭环:工具调用 → 执行 → 回填 → 终态') const result = await runTurn({ runId: 'run-smoke', history, userText: '列出工作目录里有哪些文件', signal: new AbortController().signal, emit: (event) => events.push(event), gateway, model: 'fake-model', maxToolRounds: 4, workspaceRoot }) check('上游被调用 2 次(工具轮 + 收尾轮)', requests.length === 2, `实际 ${requests.length}`) const textDeltas = events.filter((e) => e.type === 'text') check('收到流式文本增量 ≥ 5 段', textDeltas.length >= 5, `实际 ${textDeltas.length}`) check( '最终文本拼接正确', result.content === '工作目录里有 a.txt 和 b.txt。', `实际「${result.content}」` ) check('产生 1 条工具调用记录', result.tools.length === 1, `实际 ${result.tools.length}`) const tool = result.tools[0] check('工具名为 list_dir', tool?.name === 'list_dir', `实际 ${tool?.name}`) check( '流式分片的 arguments 被正确拼成 JSON', JSON.stringify(tool?.args) === JSON.stringify({ path: '.' }), `实际 ${JSON.stringify(tool?.args)}` ) check('工具执行成功', tool?.status === 'ok', `实际 ${tool?.status}`) check('工具结果含 a.txt', tool?.summary?.includes('a.txt') === true) const doneEvent = events.find((e) => e.type === 'done') check('发出 done 终态事件', doneEvent !== undefined) // 第 2 次请求应包含回填的 assistant(tool_calls) 与 tool 结果 const second = requests[1] as { messages: Array> } check( '第 2 次请求回填了 assistant.tool_calls', Array.isArray(second?.messages) && second.messages.some((m) => m.role === 'assistant' && Array.isArray(m.tool_calls)) ) check( '第 2 次请求回填了 tool 角色结果', Array.isArray(second?.messages) && second.messages.some( (m) => m.role === 'tool' && String(m.content ?? '').includes('a.txt') ) ) console.log('\n[2] 工具安全边界:工作区逃逸必须被拒绝') const escape = await executeTool('read_file', { path: '../../../../etc/passwd' }, { workspaceRoot }) check('越界读取被拒绝', escape.ok === false, `实际 ok=${escape.ok}`) check('拒绝理由可读', escape.content.includes('工作区内')) const unknown = await executeTool('not_a_tool', {}, { workspaceRoot }) check('未知工具被拒绝', unknown.ok === false) const normal = await executeTool('read_file', { path: 'a.txt' }, { workspaceRoot }) check('工作区内读取正常', normal.ok === true && normal.content.includes('hello a')) console.log('\n[3] 取消与错误传播') const abortController = new AbortController() abortController.abort() const cancelled = await runTurn({ runId: 'run-cancel', history: [], userText: 'x', signal: abortController.signal, emit: () => {}, gateway, model: 'fake-model', maxToolRounds: 4, workspaceRoot }).then( () => 'resolved', (err: Error) => err.name ) check('已取消的信号会中断回合', cancelled === 'AbortError', `实际 ${cancelled}`) const badGateway = new Gateway({ baseUrl: `http://127.0.0.1:${port}/v1`, model: 'fake-model', apiKey: '', temperature: 0.7, maxToolRounds: 2 }) const missingKey = await runTurn({ runId: 'run-nokey', history: [], userText: 'x', signal: new AbortController().signal, emit: () => {}, gateway: badGateway, model: 'fake-model', maxToolRounds: 2, workspaceRoot }).then( () => 'resolved', (err: Error) => err.message ) check('缺少 Key 时给出明确错误', String(missingKey).includes('API Key'), `实际 ${missingKey}`) } finally { server.close() await rm(workspaceRoot, { recursive: true, force: true }) } console.log(failures === 0 ? '\n全部通过 ✓\n' : `\n${failures} 项失败 ✗\n`) process.exit(failures === 0 ? 0 : 1) } main().catch((err) => { console.error('冒烟测试异常终止:', err) process.exit(1) })