247 lines
8.1 KiB
TypeScript
247 lines
8.1 KiB
TypeScript
/**
|
||
* 最小闭环冒烟测试(不依赖 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<void> {
|
||
// ---------- 准备临时工作目录 ----------
|
||
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<Record<string, unknown>> }
|
||
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)
|
||
})
|