事件解码器:全站唯一的"事件 → 人话"翻译层

UI 重构的地基。现有的 describe()(RunPanel.tsx:392)是工程师视角——
db_query__warehouse({"sql":...}) 是函数调用语法、1234+567 tok · $0.0021 是账单、
exit=0 · microvm 是退出码,而且 25 个事件类型里一半直接 return '' 没翻译。

做成独立模块而不是散在各页面,是因为画布时间线、助手栏、运行页要读同一个
故事。两套翻译的话同一次运行在不同地方讲得不一样,用户没法判断哪个是真的。

## 三条原则

- **不回写事件**:run 终态时对全部已落库事件算 manifest_hash,事后增删改会
  对不上。翻译只在前端内存里发生。
- **成对事件合并成一行**:tool.start + tool.end 是同一件事的两个时刻,拆两行
  等于把"查询数据"和"查完了"当成两件事。按 call_id 配对(并发调同名工具时
  按名字会错位),独立 Tool 节点没有 call_id 则回退到名字。
- **节点是天然的分组单位**:一个图节点内部可能有好几轮模型和工具调用,
  它们属于同一个步骤——这正是"简要思考节点"该有的结构。

## 用真实事件调出来的五个坑

从库里导出 5 类真实运行(带查询/带审批/带出具/失败/带思考)当 fixture,
构造的样本永远不会长成那样:

- **审批重复三次**。human.requested 没有 interrupt_id、run.interrupted 才有,
  两个 key 算不到一起;而审批恢复后 LangGraph 重放节点又发一遍 human.requested。
  改用两者共有的 node_id|mode|title 做键,三条收敛成一条。
- **节点重放让同名节点出现两次**。node.started 第二次来时复用原来那条。
- **"继续执行"说两遍**。run.resumed 和 run.started(resumed=true) 是同一件事。
- **标题退化成裸 id**。没起过名字的节点 label 等于 id(后端兜底),"in"/"h"/"out"
  对用户毫无意义——退到节点类型的中文名。
- **空 preview 当详情显示**。{} 不是详情,是噪音。

## 刻意不做的

- 不显示 token 数和美元:那是账单视角,不是"它干了什么"
- 不依赖 canvas/nodeDefs:解码器是纯翻译层,画布只是消费者之一。
  16 行的类型名映射重复得起,反向依赖会让运行页被迫加载整套节点定义
- 未知事件不静默吞掉:宁可显示一条原始的,也比让人觉得"什么都没发生"好

## 验证

scripts/check-decode.mjs 用真实 fixture 跑 21 条断言(转译借 vite dev server,
测的就是应用实际运行的那份)。守的是"审批只出现一次""没有裸 id""没有事件名
泄漏到标题"这类会悄悄退化的性质。
This commit is contained in:
ThearchyHelios
2026-09-17 16:25:33 +08:00
parent 5fdb55defe
commit ca6e672dfa
3 changed files with 683 additions and 0 deletions
File diff suppressed because one or more lines are too long
+566
View File
@@ -0,0 +1,566 @@
import type { RunEvent } from '../types'
/**
* 事件 → 人能看懂的步骤。
*
* 这是全站唯一的翻译层:画布时间线、助手栏、运行页都用它。分两套翻译的话,
* 同一次运行在不同地方会读出不同的故事,而用户没法判断哪个是真的。
*
* 三条原则:
*
* 1. **不回写事件**。事件表是审计凭证——run 终态时对全部已落库事件算
* manifest_hash,事后增删改会对不上。翻译只发生在前端内存里。
* 2. **成对事件合并成一行**。tool.start + tool.end 是同一件事的两个时刻,
* 拆成两行等于把"查询数据"和"查完了"当成两件事讲。
* 3. **节点是天然的分组单位**。一个图节点内部可能有好几轮模型调用和工具
* 调用,它们属于同一个"步骤"——这也正是用户说的"简要思考节点":
* 节点本身就是那个节点,里面的动作是它的过程。
*/
export type StepKind =
| 'node' // 一个图节点的执行(可能带子步骤)
| 'think' // 模型的思考
| 'llm' // 模型调用
| 'query' // 数据库查询
| 'schema' // 看表结构
| 'tool' // 其他工具
| 'code' // 沙箱代码
| 'branch' // 分支走向
| 'human' // 人工介入
| 'issuance' // 出具判定
| 'note' // 日志/提示
| 'error'
| 'lifecycle' // 开始/结束
export type StepStatus = 'running' | 'done' | 'failed' | 'waiting'
export interface Step {
id: string
seq: number
kind: StepKind
/** 一句人话,不带技术黑话 */
title: string
/** 展开后看的东西:SQL 原文、工具参数、结果预览、思考全文 */
detail?: string
/** 行尾的次要信息:耗时、行数 */
meta?: string
status?: StepStatus
level?: 'info' | 'warn' | 'error'
nodeId?: string
/** 工件 id,可下钻到完整证据 */
artifact?: string
children?: Step[]
}
// 这两个是流式增量,后端根本不落库(_EPHEMERAL)。单次运行的 delta 量级会
// 把列表淹掉,而且刷新页面后它们不会回来——UI 不能建立在它们之上。
const EPHEMERAL = new Set(['llm.token', 'llm.thinking.delta'])
// 节点类型的中文名。刻意内联而不是 import canvas/nodeDefs:解码器是纯粹的
// 翻译层,画布只是它的消费者之一,反过来依赖画布会让运行页也被迫加载
// 整套节点定义(含图标、表单 schema)。这张表只有 16 行,重复得起。
const TYPE_LABEL: Record<string, string> = {
input: '输入', output: '成果', llm: '模型调用', agent: 'Agent',
supervisor: '多 Agent 协作', tool: '调用工具', code: '沙箱代码',
branch: '条件分支', loop: '循环', subgraph: '子工作流',
memory: '长期记忆', retrieve: '知识检索', transform: '数据整形',
human: '人工介入', validate: '结构校验', metrics: '口径卡',
}
// 只对开发者有意义、对使用者是噪音的。不是丢弃事件本身(原始事件另有视图),
// 只是不进这条给人看的流。
const SILENT = new Set(['usage', 'node.skipped'])
const num = (v: unknown): number | undefined =>
typeof v === 'number' && Number.isFinite(v) ? v : undefined
export function formatDuration(ms?: number): string {
if (ms == null) return ''
if (ms < 1000) return `${Math.round(ms)}ms`
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`
return `${Math.floor(ms / 60_000)}m${Math.round((ms % 60_000) / 1000)}s`
}
/** 从 db_query 的结果预览里抠出行数——用户关心的是"查到多少",不是 JSON。 */
function rowCountOf(preview: string): number | undefined {
const m = preview.match(/"row_count":\s*(\d+)/)
return m ? Number(m[1]) : undefined
}
/** 把查询结果预览转成小表格能用的形状;解析不了就算了,原样展示。 */
export function parseQueryResult(preview: string):
{ columns: string[]; rows: unknown[][]; truncated: boolean } | null {
try {
const raw = preview.trim().startsWith('"') ? JSON.parse(preview) : preview
const data = typeof raw === 'string' ? JSON.parse(raw) : raw
if (Array.isArray(data?.columns) && Array.isArray(data?.rows)) {
return { columns: data.columns, rows: data.rows, truncated: !!data.truncated }
}
} catch {
/* 不是标准查询结果,当普通文本 */
}
return null
}
function toolStep(seq: number, tool: string, args: Record<string, any>): Step {
const base = { id: `tool-${seq}`, seq, status: 'running' as StepStatus }
// 数据库工具单独认:它是这个产品最主要的取数方式,"调用 db_query__warehouse"
// 这种说法等于没翻译
if (tool.startsWith('db_query')) {
const source = tool.replace(/^db_query__/, '')
return { ...base, kind: 'query', title: `在 ${source} 上查询数据`,
detail: String(args?.sql ?? '') }
}
if (tool.startsWith('db_schema')) {
const table = String(args?.table ?? '')
return { ...base, kind: 'schema',
title: table ? `查看 ${table} 的字段` : '列出有哪些表' }
}
const argText = Object.keys(args ?? {}).length
? JSON.stringify(args, null, 2) : ''
return { ...base, kind: 'tool', title: `调用 ${tool}`, detail: argText }
}
/**
* 把一次运行的事件解码成步骤树。
*
* 顶层是节点,节点内部的模型/工具调用是子步骤。没有 node_id 的事件
* (run.*、issuance 这类)留在顶层,按时间顺序穿插。
*/
export function decodeRun(events: RunEvent[]): Step[] {
const out: Step[] = []
/** node_id → 该节点的顶层 Step,用于把子步骤挂进去 */
const nodeSteps = new Map<string, Step>()
/** call_id(或工具名兜底)→ 待配对的工具 Step */
const pendingTools = new Map<string, Step>()
/** node_id → 待配对的模型调用 Step */
const pendingLlm = new Map<string, Step>()
/** 已见过的 interrupt_id,避免 human.requested 与 run.interrupted 各出一行 */
const seenInterrupts = new Set<string>()
const push = (step: Step, nodeId?: string) => {
const parent = nodeId ? nodeSteps.get(nodeId) : undefined
if (parent) (parent.children ??= []).push(step)
else out.push(step)
}
for (const event of events) {
const type = String(event.type)
if (EPHEMERAL.has(type) || SILENT.has(type)) continue
const d: any = event.data ?? {}
const seq = event.seq ?? 0
const nodeId = event.node_id ?? undefined
switch (type) {
case 'run.started':
case 'run.resumed': {
const resumed = type === 'run.resumed' || !!d.resumed
// 恢复一次会同时来 run.resumed 和 run.started(resumed=true),
// 说两遍"继续执行"只会让人以为恢复了两次
if (resumed && out.some((s) => s.kind === 'lifecycle' && s.title === '继续执行')
&& !out.some((s) => s.status === 'waiting')) {
break
}
if (resumed) {
// 恢复意味着之前那条"等你确认"已经过去了
out.forEach((s) => { if (s.status === 'waiting') s.status = 'done' })
nodeSteps.forEach((s) => {
s.children?.forEach((c) => { if (c.status === 'waiting') c.status = 'done' })
})
}
out.push({
id: `s-${seq}`, seq, kind: 'lifecycle', status: 'running',
title: resumed ? '继续执行' : `开始执行(${d.nodes ?? '?'} 步)`,
})
break
}
case 'node.started': {
// data.label 是节点标题**随事件传递的唯一来源**。靠画布 nodes 反查的话,
// 助手栏和运行页没加载画布,标题会退化成裸 node_id。
// label 是节点标题随事件传递的唯一来源,但没起过名字的节点它等于
// 节点 id(后端兜底),"in"/"h"/"out" 对用户毫无意义——退到节点
// 类型的中文名,至少能看出这步在干嘛
const label = String(d.label ?? '')
const typeName = TYPE_LABEL[String(d.node_type ?? '')]
const title = (label && label !== nodeId ? label : typeName) || nodeId || '执行步骤'
// 人工审批恢复后 LangGraph 会重放该节点,node.started 因此来第二遍。
// 那不是"又执行了一个步骤",是同一步继续——复用原来那条,
// 否则时间线上每审批一次就多一个同名节点。
const existing = nodeId ? nodeSteps.get(nodeId) : undefined
if (existing) {
existing.status = 'running'
break
}
const step: Step = {
id: `n-${nodeId ?? seq}`, seq, kind: 'node', nodeId, title, status: 'running',
}
if (nodeId) nodeSteps.set(nodeId, step)
out.push(step)
break
}
case 'node.finished': {
const step = nodeId ? nodeSteps.get(nodeId) : undefined
if (step) {
step.status = 'done'
step.meta = formatDuration(num(d.duration_ms))
step.artifact = d.artifact || step.artifact
// 空对象/空串不是"详情",显示出来只是噪音
const raw = d.preview
const empty = raw == null || raw === '' ||
(typeof raw === 'object' && Object.keys(raw).length === 0)
if (!empty) {
const preview = typeof raw === 'string' ? raw : JSON.stringify(raw, null, 2)
step.detail = preview.slice(0, 4000)
}
}
break
}
case 'node.failed': {
const step = nodeId ? nodeSteps.get(nodeId) : undefined
if (step) {
step.status = 'failed'
step.level = 'error'
step.detail = String(d.error ?? '')
step.meta = formatDuration(num(d.duration_ms))
} else {
push({ id: `e-${seq}`, seq, kind: 'error', level: 'error',
title: String(d.error ?? '这一步失败了'), nodeId }, nodeId)
}
break
}
case 'llm.start':
pendingLlm.set(nodeId ?? '_', {
id: `llm-${seq}`, seq, kind: 'llm', title: '思考并作答',
status: 'running', nodeId,
})
break
case 'llm.end': {
const step = pendingLlm.get(nodeId ?? '_')
if (step) {
step.status = 'done'
// 不给 token 数和美元——那是账单视角,不是"它干了什么"。
// 成本在运行详情的用量区单独看。
step.meta = formatDuration(num(d.duration_ms))
push(step, nodeId)
pendingLlm.delete(nodeId ?? '_')
}
break
}
case 'llm.thinking': {
// 只有 Anthropic 系模型会产出(thinking_text 认的是 thinking 块)。
// 换个 provider 就没有——所以它是增强信息,主干靠上面那些步骤撑着。
const text = String(d.text ?? d.delta ?? '')
if (!text.trim()) break
push({
id: `th-${seq}`, seq, kind: 'think', nodeId, status: 'done',
title: text.replace(/\s+/g, ' ').slice(0, 60) + (text.length > 60 ? '…' : ''),
detail: text + (d.truncated ? '\n\n(已截断)' : ''),
}, nodeId)
break
}
case 'tool.start': {
const step = toolStep(seq, String(d.tool ?? ''), d.args ?? {})
step.nodeId = nodeId
// call_id 才是可靠的配对键:同一节点并发调同名工具时,按名字配会错位。
// 独立 Tool 节点不带 call_id,回退到工具名。
pendingTools.set(String(d.call_id || d.tool || seq), step)
push(step, nodeId)
break
}
case 'tool.end':
case 'tool.error': {
const key = String(d.call_id || d.tool || '')
const step = pendingTools.get(key)
const preview = String(d.preview ?? d.error ?? '')
if (step) {
const failed = type === 'tool.error' || preview.startsWith('SQL 被拒绝')
|| preview.startsWith('查询失败')
step.status = failed ? 'failed' : 'done'
if (failed) step.level = 'error'
// tool.error 只有 {tool,error},没有 duration_ms/preview——不能假设统一形状
const rows = rowCountOf(preview)
const parts = [
rows != null ? `${rows} 行` : '',
formatDuration(num(d.duration_ms)),
].filter(Boolean)
step.meta = parts.join(' · ')
step.artifact = d.artifact
if (step.kind === 'query' || step.kind === 'schema' || failed) {
// 查询保留 SQL 作为 detail,结果另挂;失败时结果就是错误原因
step.detail = failed ? `${step.detail ?? ''}\n\n${preview}`.trim() : step.detail
;(step as any).result = preview
} else {
step.detail = preview.slice(0, 4000)
}
pendingTools.delete(key)
}
break
}
case 'sandbox.start':
pendingTools.set(`sandbox-${nodeId ?? seq}`, (() => {
const step: Step = {
id: `sb-${seq}`, seq, kind: 'code', nodeId, status: 'running',
title: `运行${d.language === 'python' ? ' Python' : d.language ? ` ${d.language}` : ''}代码`,
}
push(step, nodeId)
return step
})())
break
case 'sandbox.end': {
const step = pendingTools.get(`sandbox-${nodeId ?? seq}`)
if (step) {
step.status = d.ok ? 'done' : 'failed'
if (!d.ok) step.level = 'error'
step.meta = formatDuration(num(d.duration_ms))
const body = [d.stdout, d.stderr].filter(Boolean).join('\n').slice(0, 4000)
step.detail = body || (d.ok ? '(无输出)' : `退出码 ${d.exit_code}`)
pendingTools.delete(`sandbox-${nodeId ?? seq}`)
}
break
}
case 'edge.taken':
push({
id: `br-${seq}`, seq, kind: 'branch', nodeId, status: 'done',
title: `走「${d.branch}」这条路`
+ (d.iteration != null ? `(第 ${Number(d.iteration) + 1} 轮)` : ''),
detail: d.reason ? String(d.reason) : undefined,
}, nodeId)
break
case 'human.requested':
case 'run.interrupted': {
// 同一次中断会来两条事件,而且审批恢复后节点重放还会再来一遍
// (LangGraph 的重放语义,不是 bug)。三条都指向同一次"等你确认",
// 界面上只该有一个。
//
// 不能用 interrupt_id 做键:human.requested 压根没这个字段,
// run.interrupted 才有。两者共有的是 payload 里的 node_id + mode + title,
// 用它们才能让三条算出同一个键。
const payload = d.payload ?? d
const key = [payload.node_id ?? nodeId, payload.mode ?? '', payload.title ?? ''].join('|')
if (seenInterrupts.has(key)) break
seenInterrupts.add(key)
push({
id: `hm-${seq}`, seq, kind: 'human', nodeId, status: 'waiting', level: 'warn',
title: String(payload.title || d.title || '等你确认'),
detail: [payload.message, payload.tool ? `工具:${payload.tool}` : '']
.filter(Boolean).join('\n') || undefined,
}, nodeId)
break
}
case 'human.resolved': {
const r = d.response ?? {}
const approved = typeof r === 'object' ? r.approved : undefined
push({
id: `hr-${seq}`, seq, kind: 'human', nodeId, status: 'done',
title: approved === false ? '你驳回了' : '你放行了',
detail: typeof r === 'object' && r.note ? String(r.note) : undefined,
}, nodeId)
break
}
case 'issuance': {
const tier = String(d.tier ?? '')
const label = { formal: '正式出具', degraded: '降档出具', withheld: '不予出具' }[tier] ?? tier
const gaps: string[] = []
if (d.missing_required?.length) gaps.push(`缺必需指标 ${d.missing_required.join('、')}`)
if (d.missing_expected?.length) gaps.push(`缺期望指标 ${d.missing_expected.join('、')}`)
if (num(d.unmatched)) gaps.push(`${d.unmatched} 个数字无法回指指标集`)
out.push({
id: `is-${seq}`, seq, kind: 'issuance', status: 'done',
level: tier === 'formal' ? 'info' : tier === 'withheld' ? 'error' : 'warn',
title: gaps.length ? `${label}:${gaps.join(';')}` : label,
})
break
}
case 'caliber.upgrade':
out.push({
id: `cu-${seq}`, seq, kind: 'note', level: 'warn', status: 'done',
title: `口径卡 v${d.pinned} → v${d.latest} 有新版,按「${d.policy_label ?? d.policy}」处置`,
})
break
case 'agent.step.start':
push({
id: `as-${seq}`, seq, kind: 'note', nodeId, status: 'running',
title: `${d.agent}:${String(d.instruction ?? '').slice(0, 80)}`,
}, nodeId)
break
case 'agent.step.end':
push({
id: `ae-${seq}`, seq, kind: 'note', nodeId, status: 'done',
title: `${d.agent} 回复`, meta: formatDuration(num(d.duration_ms)),
detail: String(d.preview ?? '').slice(0, 2000),
}, nodeId)
break
case 'log': {
const level = String(d.level ?? 'info')
// info 级日志是给排查用的,不进主流程——但 warn/error 用户必须看到
if (level === 'info') break
push({
id: `lg-${seq}`, seq, kind: 'note', nodeId, status: 'done',
level: level === 'error' ? 'error' : 'warn',
title: String(d.message ?? ''),
}, nodeId)
break
}
case 'run.failed': {
const msg = String(d.error ?? '运行失败')
// 节点失败会先报一次,run.failed 往往是同一句话——说两遍不会让人
// 更明白,只会让人以为出了两个错
const dup = out.some((s) => s.status === 'failed' &&
(s.detail === msg || s.title === msg))
out.forEach((s) => { if (s.kind === 'lifecycle' && s.status === 'running') s.status = 'failed' })
if (!dup) {
out.push({
id: `rf-${seq}`, seq, kind: 'error', level: 'error', status: 'failed', title: msg,
})
}
break
}
case 'run.cancelled':
out.push({ id: `rc-${seq}`, seq, kind: 'lifecycle', status: 'failed', title: '已取消' })
break
case 'run.finished': {
// 开头那条"开始执行"要收尾,否则跑完了还挂着一个转圈的图标
const opener = out.find((s) => s.kind === 'lifecycle' && s.status === 'running')
if (opener) opener.status = 'done'
out.push({
id: `rd-${seq}`, seq, kind: 'lifecycle', status: 'done',
title: '完成', meta: formatDuration(num(d.duration_ms)),
})
break
}
default:
// 新增事件类型时不要静默吞掉——宁可显示一条原始的,也比让人觉得
// "什么都没发生"好。加了新事件记得回来补一条映射。
push({
id: `x-${seq}`, seq, kind: 'note', nodeId, status: 'done',
title: type, detail: JSON.stringify(d, null, 2).slice(0, 1000),
}, nodeId)
}
}
// 还没收到 end 的工具/模型调用保持 running——它们正在进行,不是丢了
return out
}
// -------------------------------------------------------------------------
// Copilot 建图阶段:操作流 → 同一套 Step
// -------------------------------------------------------------------------
export interface CopilotOp {
op: string
[k: string]: any
}
const PHASE_LABEL: Record<string, string> = {
connecting: '正在连接模型',
planning: '正在理解需求、规划步骤',
building: '正在搭建流程',
wiring: '正在连接数据流',
finalizing: '正在排版和校验',
}
/**
* Copilot 的操作流解码。
*
* 和运行事件是两套完全独立的协议(不共享 seq、node_id、不落库),只在这里
* 统一成同一种 Step,让用户看到的是一条连续的过程,而不是"建图"和"执行"
* 两段风格迥异的日志。
*/
export function decodeCopilot(ops: CopilotOp[]): Step[] {
const out: Step[] = []
let i = 0
let nodeCount = 0
for (const op of ops) {
i += 1
switch (op.op) {
case 'thinking': {
const text = String(op.delta ?? '')
if (!text.trim()) break
const last = out[out.length - 1]
// 思考是连续流,一片 delta 一行会碎成几十条。同一段连续思考并成一条,
// 标题取开头、详情是全文——这才是"一次思考是一个节点"。
if (last?.kind === 'think') {
last.detail = (last.detail ?? '') + text
last.title = last.detail.replace(/\s+/g, ' ').slice(0, 60)
+ (last.detail.length > 60 ? '…' : '')
} else {
out.push({ id: `ct-${i}`, seq: i, kind: 'think', status: 'done',
title: text.replace(/\s+/g, ' ').slice(0, 60), detail: text })
}
break
}
case 'heartbeat': {
// 心跳只更新"还活着"的状态,不该每 3 秒堆一行
const last = out[out.length - 1]
const label = PHASE_LABEL[String(op.phase)] ?? '正在处理'
const elapsed = num(op.elapsed_ms)
if (last?.kind === 'lifecycle' && last.status === 'running') {
last.title = label
last.meta = elapsed ? formatDuration(elapsed) : last.meta
} else {
out.push({ id: `hb-${i}`, seq: i, kind: 'lifecycle', status: 'running',
title: label, meta: elapsed ? formatDuration(elapsed) : undefined })
}
break
}
case 'plan':
out.push({ id: `cp-${i}`, seq: i, kind: 'note', status: 'done',
title: String(op.summary ?? '想好了怎么做') })
break
case 'add_node':
nodeCount += 1
out.push({
id: `cn-${i}`, seq: i, kind: 'node', status: 'done',
title: String(op.node?.data?.label || op.node?.label || op.node?.id || '新步骤'),
meta: String(op.node?.type ?? ''),
})
break
case 'update_node':
out.push({ id: `cu-${i}`, seq: i, kind: 'note', status: 'done',
title: `调整了「${op.id}」` })
break
case 'remove_node':
out.push({ id: `cr-${i}`, seq: i, kind: 'note', status: 'done',
title: `去掉了「${op.id}」` })
break
// 连线不单独成行:用户关心有哪些步骤,不关心箭头
case 'add_edge':
case 'remove_edge':
break
case 'done':
out.push({ id: `cd-${i}`, seq: i, kind: 'lifecycle', status: 'done',
title: `流程搭好了,共 ${nodeCount} 步`,
detail: String(op.explanation ?? '') || undefined })
break
case 'error':
out.push({ id: `ce-${i}`, seq: i, kind: 'error', level: 'error', status: 'failed',
title: String(op.message ?? '生成失败') })
break
default:
break
}
}
return out
}
+116
View File
@@ -0,0 +1,116 @@
// 事件解码器的回归检查。
//
// 用**真实运行**导出的事件跑(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('工具调用挂在节点下,不是平铺',
steps.some((s) => s.kind === 'node' && s.children?.some((c) => c.kind === 'query')))
}
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('驳回')))
}
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)))
}
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 steps = mod.decodeCopilot(ops)
check('心跳不堆行', steps.filter((s) => s.kind === 'lifecycle' && s.status === 'running').length === 1)
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 === '取数'))
}
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
process.exit(failed ? 1 : 0)