chore: establish YCode replication baseline and TODO
This commit is contained in:
commit
6dc59f89ba
1118 files changed
+196002
No files matched your search
@@ -0,0 +1,246 @@
|
||||
/**
|
||||
* 最小闭环冒烟测试(不依赖 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)
|
||||
})
|
||||
Reference in new issue
Block a user