Files
ycode/scripts/smoke.ts
T

247 lines
8.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 最小闭环冒烟测试(不依赖 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)
})