mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 05:41:57 +00:00
f0fd63f0b9
用户的原话是「copilot(包括问答)可以是侧边栏的效果」「显示他的简要思考节点, 使得用户知道他大致在想什么,以及用了哪些工具」。 画布右栏从「运行 / 属性 → 时间线 / 成果 / 事件」(两层嵌套 5 个视图)变成 「助手 / 属性」一层。助手栏里是一条流:你让它做什么、它做了什么、结果是什么。 Copilot 建图和跑图在协议上完全无关(SSE 操作流 vs 事件 WebSocket,不共享 seq 和 node_id),但对用户是一件事的两半,decode.ts 把两种流译成同一种 Step 之后才能排进同一条时间轴。 Copilot 不再是弹窗。弹窗的问题不是样式:每改一次图都要重开一次,关掉之后 需求文本就没了,想微调只能重打;而画布左下角那条浮条(z-30)和 Toast (z-100)、Modal(z-50)三层各自为政,本身也是重复的一份 Copilot 状态渲染。 两个都删了,输入框就在栏里,说完一句接着说下一句。右栏没删——它是发起运行 和处理审批的唯一入口,RunLauncher 原样保留,只是跑起来会自动收起给执行过程 让位。tab 用 hidden 切换而不是卸载,不然点一下「属性」看个节点再切回来, 输入框草稿和展开状态全丢。 问数据页和运行页换成同一个 AssistantStream + 同一个 decode.ts。RunPanel 里 那套平行的 EVENT_META + describe() 删了:留着两套翻译,同一次运行在不同页面 会读出不同的故事,而用户没法判断哪个是真的。同理合并了两个耗时格式化函数 (画布节点显示"0ms",右边同一步什么都不显示,两块还并排放着)。运行页列表 加了内容摘要——原本每行只有"临时图 · 1.2s · 340 tok",十条长得一模一样, 要分清哪条是哪条只能挨个点开。 跑真实运行时发现两个缺陷: 1. 后端丢事件(app/core/bus.py)。WebSocket 先读历史、再订阅,两步之间图 正好跑出来的事件既不在历史里也不在实时流里,永远丢了。而 subscribe() 是 异步生成器,真正注册还要等到第一次迭代,窗口比看上去更大。实测一次跑图 开头的 node.finished/node.started 消失,界面上表现为某个节点永远在转圈、 它的子步骤跑到了顶层。改成先 attach 占位、再读历史,重叠部分按 seq 去重。 tests/test_bus_attach.py 里有一条刻画旧行为的测试,说明这个窗口确实存在。 2. 循环里的多轮审批被算错。我在 S1 按内容去重,但三轮审批的 title 一模一样, 和 LangGraph 的重放无法区分——结果第 2、3 次真实审批被当成重放吞掉,而 三次"你驳回了/你放行了"各自成行。改成开/闭配对:开着就是同一次,闭了才是 新一次;决定折进那条审批行而不是另起一行。真实运行(驳回 → 改写 → 再驳回 → 改写 → 放行)已收进 fixtures。 验证:三层各守一件事,共 76 条断言全过。check-decode 守翻译(含新的多轮审批 和截断结果集),check-stream 守渲染,新增 check-ui 守"接起来还对不对"——真 浏览器真后端,包括「切走再切回来草稿还在」「唯一的运行入口还在」「原始事件 切得到也切得回」。另外真跑了一次带三轮审批的图和两次 Copilot 生成(60s, deepseek-v4-pro),截图逐帧看过。后端 78 个测试全过。
206 lines
11 KiB
JavaScript
206 lines
11 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=== 出具判定 ===')
|
||
{
|
||
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)
|