Files
AgentLab/scripts/check-decode.mjs
T
ThearchyHelios f0fd63f0b9 UI 重构:Copilot 进侧栏,三个页面共用一条助手流
用户的原话是「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 个测试全过。
2026-09-17 18:29:16 +08:00

206 lines
11 KiB
JavaScript
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.
// 事件解码器的回归检查。
//
// 用**真实运行**导出的事件跑(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)