mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 04:21:57 +00:00
3ff6840b99
运行 #6038f5:supervisor 节点跑完 31.7s 之后,researcher 那一行还在转圈。 agent.step.start / agent.step.end 是这套事件里**唯一**一对没做配对的 (node、llm、tool、sandbox、human 都配了)。start 那条建出来就是 running, end 另起一行,于是没人去关它。这正是 decode.ts 自己文档里的第 2 条原则 ——成对事件合并成一行——我写了却没用在 agent 上。 配对键用 (node_id, agent, round):光用 agent 名不够,supervisor 会在多轮里 反复派给同一个 agent,第二轮的 end 会错配到第一轮的 start 上。 顺带把 supervisor 的调度决策捞出来。后端把它标成 info 日志,而 info 是整类 丢弃的,结果"为什么派给 researcher"、"为什么只跑一轮就结束"在界面上完全 没有交代——那个节点就是一个跑了 31 秒的黑盒。带 round 字段的 info 才是调度 决策,和普通排查日志区分得开。FINISH 译成"结束协作"而不是"交给 FINISH", 后者会让人以为还有个叫 FINISH 的成员。 真实运行已收进 fixtures,check-decode 加 9 条(含"普通 info 日志仍然不进 主流程"和"warn 日志照常显示"这两条反向约束)。
238 lines
12 KiB
JavaScript
238 lines
12 KiB
JavaScript
// 事件解码器的回归检查。
|
||
//
|
||
// 用**真实运行**导出的事件跑(fixtures.json 取自 data/agentlab.db),不是构造的样本:
|
||
// 这里要守的恰恰是真实事件的脏细节——审批会重放、成对事件字段不齐、
|
||
// 空 preview、节点没起过名字。构造的样本永远不会长成那样。
|
||
//
|
||
// 转译借 vite dev server(它 serve 的就是应用实际运行的那份),所以跑之前
|
||
// 前端得起着:./scripts/dev.sh
|
||
import { readFileSync } from 'node:fs'
|
||
|
||
const WEB = process.env.AGENTLAB_WEB ?? 'http://localhost:5273'
|
||
const root = new URL('..', import.meta.url).pathname
|
||
const fixtures = JSON.parse(
|
||
readFileSync(`${root}frontend/src/run/__tests__/fixtures.json`, 'utf8'))
|
||
|
||
const res = await fetch(`${WEB}/src/run/decode.ts?t=${Date.now()}`)
|
||
if (!res.ok) {
|
||
console.error(`✗ 拿不到转译结果(${WEB})——前端没起?先跑 ./scripts/dev.sh`)
|
||
process.exit(1)
|
||
}
|
||
const mod = await import(
|
||
'data:text/javascript;base64,' + Buffer.from(await res.text()).toString('base64'))
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
const flatten = (steps) =>
|
||
steps.flatMap((s) => [s, ...(s.children ? flatten(s.children) : [])])
|
||
|
||
console.log('=== 数据库查询 ===')
|
||
{
|
||
const steps = mod.decodeRun(fixtures.db)
|
||
const all = flatten(steps)
|
||
const q = all.find((s) => s.kind === 'query')
|
||
check('查询步骤说人话,不是 db_query__warehouse(...)',
|
||
!!q && q.title.includes('查询数据') && !q.title.includes('db_query__'), q?.title)
|
||
check('SQL 原文留在详情里可展开', !!q?.detail?.includes('SELECT'))
|
||
check('查询结果和 SQL 分开存,不互相覆盖', !!q?.result && q.result !== q.detail)
|
||
check('工具调用挂在节点下,不是平铺',
|
||
steps.some((s) => s.kind === 'node' && s.children?.some((c) => c.kind === 'query')))
|
||
}
|
||
|
||
console.log('\n=== 被截断的结果集 ===')
|
||
{
|
||
// 后端按字符数硬切预览,切点落在 JSON 中间是常态。严格 JSON.parse 一律失败,
|
||
// 于是最典型的一次取数运行,成果会变成满屏 \"attribute01\"。这几条守的就是它。
|
||
const fin = fixtures.db.find((e) => e.type === 'run.finished')
|
||
const raw = Object.values(fin.data.output).find((v) => typeof v === 'string')
|
||
check('样本确实是坏 JSON(不然这组断言等于没测)', (() => {
|
||
try { JSON.parse(raw); return false } catch { return true }
|
||
})(), `${raw.length} 字`)
|
||
|
||
const t = mod.parseQueryResult(raw)
|
||
check('截断的结果集仍能解析出表格', !!t)
|
||
check('列名完整', Array.isArray(t?.columns) && t.columns.length > 10, `${t?.columns?.length} 列`)
|
||
check('至少救回一条完整记录', (t?.rows?.length ?? 0) >= 1, `${t?.rows?.length} 行`)
|
||
check('每行长度和列数对得上', t?.rows?.every((r) => r.length === t.columns.length))
|
||
check('标出这是被切断的预览,而不是查询上限', t?.clipped === true && !t?.truncated)
|
||
|
||
// 完整的结果集不该被误判成截断
|
||
const whole = JSON.stringify({ columns: ['a', 'b'], rows: [[1, 2], [3, 4]] })
|
||
const w = mod.parseQueryResult(whole)
|
||
check('完整结果集不标截断', w?.rows.length === 2 && !w.clipped)
|
||
check('普通文本不会被硬认成表格', mod.parseQueryResult('查到 3 条记录') === null)
|
||
check('空串不报错', mod.parseQueryResult('') === null)
|
||
}
|
||
|
||
console.log('\n=== 人工审批(含重放)===')
|
||
{
|
||
const steps = mod.decodeRun(fixtures.human)
|
||
const all = flatten(steps)
|
||
// 一次审批会产生 human.requested ×2(重放)+ run.interrupted,界面上只该有一条
|
||
check('"等你确认"只出现一次',
|
||
all.filter((s) => s.kind === 'human' && s.title.includes('确认')).length === 1,
|
||
`${all.filter((s) => s.kind === 'human' && s.title.includes('确认')).length} 条`)
|
||
check('"继续执行"只出现一次',
|
||
steps.filter((s) => s.title === '继续执行').length === 1,
|
||
`${steps.filter((s) => s.title === '继续执行').length} 条`)
|
||
// 节点重放不该让同一个节点在时间线上出现两遍
|
||
const nodeIds = steps.filter((s) => s.kind === 'node').map((s) => s.nodeId)
|
||
check('节点不因重放而重复', new Set(nodeIds).size === nodeIds.length,
|
||
nodeIds.join(','))
|
||
check('审批结果有交代', all.some((s) => s.title.includes('放行') || s.title.includes('驳回')))
|
||
// 恢复过的运行有"开始执行"+"继续执行"两条生命周期。只收第一条的话,
|
||
// "继续执行"会永远转圈——明明整条已经跑完了
|
||
check('恢复过的运行不会留下转圈的行',
|
||
!all.some((s) => s.status === 'running'),
|
||
all.filter((s) => s.status === 'running').map((s) => s.title).join(','))
|
||
check('处理完的审批不再是待办色',
|
||
!all.some((s) => s.status === 'done' && s.level === 'warn'))
|
||
}
|
||
|
||
console.log('\n=== 循环里的多轮审批 ===')
|
||
{
|
||
// 真实运行:驳回 → 改写 → 再驳回(带备注)→ 改写 → 放行。三轮,每轮都有
|
||
// 重放。按内容去重必然出错——三轮的 title 一模一样,和重放无法区分。
|
||
// 这条只有拿真实运行才测得出来:构造样本不会长成这样。
|
||
const steps = mod.decodeRun(fixtures.loop_approve)
|
||
const all = flatten(steps)
|
||
const asks = all.filter((s) => s.kind === 'human')
|
||
check('三轮审批就是三条,不多不少', asks.length === 3, `${asks.length} 条`)
|
||
check('每条都带着你的决定', asks.every((s) => /你(放行|驳回)了/.test(s.title)),
|
||
asks.map((s) => s.title).join(' | '))
|
||
check('两次驳回一次放行,顺序没错',
|
||
asks.filter((s) => s.title.includes('驳回')).length === 2
|
||
&& asks[2]?.title.includes('放行'))
|
||
// detail 里是被审的草稿全文,打出来会刷屏——只报哪一轮带了备注
|
||
check('备注跟着那一轮走', asks.some((s) => s.detail?.includes('不高级')),
|
||
asks.map((s, i) => (s.detail?.includes('你的备注') ? `#${i + 1} 有备注` : '')).filter(Boolean).join(' '))
|
||
check('没有落单的"你放行了/你驳回了"孤行',
|
||
!all.some((s) => s.title === '你放行了' || s.title === '你驳回了'))
|
||
check('重放不制造重复节点', (() => {
|
||
const ids = steps.filter((s) => s.kind === 'node').map((s) => s.nodeId)
|
||
return new Set(ids).size === ids.length
|
||
})())
|
||
check('跑完了没有还在转的行', !all.some((s) => s.status === 'running'))
|
||
}
|
||
|
||
console.log('\n=== 多 agent 协作 ===')
|
||
{
|
||
// 真实运行 #6038f5:supervisor 派给 researcher 一次就 FINISH 了。
|
||
// 界面上那个多智能体节点跑完 31.7s 之后,researcher 那行还在转圈。
|
||
const steps = mod.decodeRun(fixtures.supervisor)
|
||
const all = flatten(steps)
|
||
|
||
const agentRows = all.filter((s) => s.title.startsWith('researcher'))
|
||
check('一个 agent 步骤就是一行,不是"派任务"+"回复"两行',
|
||
agentRows.length === 1, `${agentRows.length} 行:${agentRows.map((s) => s.title.slice(0, 20)).join(' | ')}`)
|
||
check('回复挂在同一行里可展开', !!agentRows[0]?.result)
|
||
check('跑完了就不转圈了', agentRows[0]?.status === 'done', agentRows[0]?.status)
|
||
|
||
// supervisor 的调度理由被后端标成了 info 日志,整类丢掉的话,
|
||
// 多 agent 节点在界面上就是一个跑了 31 秒的黑盒
|
||
const routes = all.filter((s) => s.kind === 'branch')
|
||
check('调度决策要说出来', routes.length === 2, `${routes.length} 条`)
|
||
check('说清楚第几轮交给谁',
|
||
routes[0]?.title.includes('第 1 轮') && routes[0]?.title.includes('researcher'),
|
||
routes[0]?.title)
|
||
// FINISH 是协议里的收尾标记,不是某个 agent
|
||
check('为什么结束也要有交代,且不说"交给 FINISH"',
|
||
routes[1]?.title.includes('结束协作') && !routes[1]?.title.includes('FINISH')
|
||
&& !!routes[1]?.detail,
|
||
`${routes[1]?.title} / ${routes[1]?.detail?.slice(0, 24)}`)
|
||
check('普通 info 日志仍然不进主流程',
|
||
!all.some((s) => s.title.includes('校验通过')))
|
||
check('warn 日志照常显示', all.some((s) => s.title.includes('校验失败')))
|
||
check('整条流跑完没有转圈的行', !all.some((s) => s.status === 'running'),
|
||
all.filter((s) => s.status === 'running').map((s) => s.title.slice(0, 24)).join(','))
|
||
}
|
||
|
||
console.log('\n=== 出具判定 ===')
|
||
{
|
||
const steps = mod.decodeRun(fixtures.issue)
|
||
check('出具档位翻译成中文', steps.some((s) => s.kind === 'issuance' && s.title.includes('出具')),
|
||
steps.find((s) => s.kind === 'issuance')?.title)
|
||
}
|
||
|
||
console.log('\n=== 失败运行 ===')
|
||
{
|
||
const steps = mod.decodeRun(fixtures.failed)
|
||
const all = flatten(steps)
|
||
const errs = all.filter((s) => s.level === 'error')
|
||
check('错误只说一遍(节点失败与 run.failed 是同一件事)',
|
||
new Set(errs.map((s) => s.detail ?? s.title)).size === errs.length,
|
||
`${errs.length} 条错误`)
|
||
check('开头那条不会一直转圈',
|
||
!steps.some((s) => s.kind === 'lifecycle' && s.status === 'running'))
|
||
}
|
||
|
||
console.log('\n=== 通用规则 ===')
|
||
{
|
||
const all = Object.values(fixtures).flatMap((evts) => flatten(mod.decodeRun(evts)))
|
||
check('没有裸节点 id 当标题',
|
||
!all.some((s) => s.kind === 'node' && /^(in|out|h|m|t\d|n\d)$/.test(s.title)),
|
||
all.filter((s) => s.kind === 'node' && /^(in|out|h|m)$/.test(s.title)).map(s=>s.title).join(','))
|
||
check('没有空详情', !all.some((s) => s.detail === '{}' || s.detail === ''))
|
||
check('没有原始事件名泄漏到标题',
|
||
!all.some((s) => /^(node|run|llm|tool)\./.test(s.title)),
|
||
all.filter((s) => /^(node|run|llm|tool)\./.test(s.title)).map(s=>s.title).join(','))
|
||
check('耗时是人类单位', all.filter((s) => s.meta).every((s) => /(\d+ms|\d+\.\d+s|\d+m\d+s|\d+ 行)/.test(s.meta)))
|
||
// 一屏"0ms"看着像每步都被精确计时,实际只是这些步骤没花时间,
|
||
// 反而把真正慢的那一步淹了
|
||
// 用词边界,不然 "130ms" 里的 "0ms" 会误判
|
||
check('不显示 0ms 这种没信息量的耗时', !all.some((s) => /\b0ms\b/.test(s.meta ?? '')),
|
||
all.filter((s) => /\b0ms\b/.test(s.meta ?? '')).map((s) => s.title).join(','))
|
||
check('没有空 meta 占位', !all.some((s) => s.meta === ''))
|
||
}
|
||
|
||
console.log('\n=== Copilot 操作流 ===')
|
||
{
|
||
const ops = [
|
||
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 3000 },
|
||
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 6000 },
|
||
{ op: 'thinking', delta: '用户要查销量。' },
|
||
{ op: 'thinking', delta: '先看表结构。' },
|
||
{ op: 'plan', summary: '三步:取数→汇总→出具' },
|
||
{ op: 'add_node', node: { id: 'q', type: 'tool', data: { label: '取数' } } },
|
||
{ op: 'add_edge', edge: { source: 'a', target: 'q' } },
|
||
{ op: 'done', explanation: '完成' },
|
||
]
|
||
const mid = mod.decodeCopilot(ops.slice(0, 2))
|
||
check('心跳不堆行', mid.filter((s) => s.kind === 'lifecycle' && s.status === 'running').length === 1)
|
||
|
||
const steps = mod.decodeCopilot(ops)
|
||
check('连续思考并成一条', steps.filter((s) => s.kind === 'think').length === 1,
|
||
`${steps.filter((s) => s.kind === 'think').length} 条`)
|
||
check('思考全文保留在详情', steps.find((s) => s.kind === 'think')?.detail?.includes('先看表结构'))
|
||
check('连线不单独成行', !steps.some((s) => s.title.includes('edge')))
|
||
check('节点用标签不用 id', steps.some((s) => s.kind === 'node' && s.title === '取数'))
|
||
// 生成完了那条"正在理解需求…"还在转圈的话,看上去像卡住了
|
||
check('生成结束后没有还在转的行', !steps.some((s) => s.status === 'running'),
|
||
steps.filter((s) => s.status === 'running').map((s) => s.title).join(','))
|
||
|
||
// 改图场景:这一轮加了 1 个节点,但整张图有 4 个。说"共 1 步"是错的
|
||
const edited = mod.decodeCopilot([
|
||
...ops,
|
||
{ op: 'final', graph: { nodes: [{ id: 'a' }, { id: 'q' }, { id: 'b' }, { id: 'c' }] } },
|
||
])
|
||
const tail = edited[edited.length - 1]
|
||
check('改图时说清"加了几步"和"整张图几步"',
|
||
tail.title.includes('加了 1 步') && tail.title.includes('共 4 步'), tail.title)
|
||
|
||
const built = mod.decodeCopilot([
|
||
{ op: 'add_node', node: { id: 'a', type: 'input' } },
|
||
{ op: 'add_node', node: { id: 'b', type: 'output' } },
|
||
{ op: 'done', explanation: '' },
|
||
{ op: 'final', graph: { nodes: [{ id: 'a' }, { id: 'b' }] } },
|
||
])
|
||
check('从零新建时不啰嗦,只说共几步',
|
||
built[built.length - 1].title === '流程搭好了,共 2 步',
|
||
built[built.length - 1].title)
|
||
}
|
||
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
|
||
process.exit(failed ? 1 : 0)
|