mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 21:51:58 +00:00
3e311ec551
整合(3c-integrate) - 公共层:跨天时长(formatSpan / formatOffset / formatLapse)、本机署名(lib/actor)、检索分类色令牌、 数据源目录(useDatasources)收进公共层;失败说明能一次列出多个写坏的自定义工具,查询超时指到数据源的查询时限 - 管理页收尾:设置页接入统一的离开前确认;检查脚本不再把沙箱里的真实值打进输出 - 画布 ↔ 右栏:点出具印章、点节点能跳到右栏对应位置;「定位」直接聚焦到出问题的字段 - 回答流 / 问数据 / 记录页:失败信息带「去哪修」且不再被翻译两遍;协作团队的结局按执行截取; 等审批时断流重连、而运行已在空档里跑完的,补全事件 扫尾(3c-sweep) - 术语统一:开始运行 / 继续运行、工作流最大步数;未保存的运行统一叫「未保存的工作流」(后端新运行同步) - 沙箱报错不再带 Python 异常类名;新增 check-tokens,未定义的 CSS 变量为 0,两个旧别名迁移后删除 检查脚本(3c-checks) - check-ui 跟上新界面(运行列表按 data 属性找行),加上令牌与选中态的回归断言,输出里的名字打码 - 新增 check-all:按顺序跑全部检查、汇总退出码,--repeat 列出偶发项 - 单节出错只记这一节失败、不再带崩整个脚本;外壳遥测浮层「重新检测」时焦点不再掉到 body 网页工具与 fake-ip - Clash / mihomo 一类代理开 TUN + fake-ip 时,查什么都先拿到 198.18 段的假地址,被当成内网拦截。 新增 AGENTLAB_HTTP_TOOL_FAKE_IP_RANGES(默认空):落在段里的解析结果改向公共 DNS(DoH)核实真实地址, 公网放行、内网照拦、核实不了按拦截处理。DoH 默认先问境外的,境内公共 DNS 会对被封锁的域名返回污染结果 - 列表型环境变量接受逗号分隔(原来注释说逗号分隔、实际只认 JSON 数组);域名解析改为异步,不再阻塞事件循环 验证(沙箱):tsc 干净,pytest 866 passed(两遍),检查脚本全绿。
939 lines
62 KiB
JavaScript
939 lines
62 KiB
JavaScript
// 事件解码器的回归检查。
|
||
//
|
||
// 用**真实运行**导出的事件跑(fixtures.json 取自 data/agentlab.db),不是构造的样本:
|
||
// 这里要守的恰恰是真实事件的脏细节——审批会重放、成对事件字段不齐、
|
||
// 空 preview、节点没起过名字。构造的样本永远不会长成那样。
|
||
// 库名表名已脱敏(改的只是标识符字面量,事件的结构和脏细节原样保留)。
|
||
//
|
||
// 转译借 vite dev server(它 serve 的就是应用实际运行的那份)。
|
||
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时
|
||
// 带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-decode.mjs
|
||
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'))
|
||
|
||
// decode.ts 在运行时 import 了 lib/format、lib/terms。data: URL 里的模块没法解析
|
||
// "/src/..." 这种路径,所以把依赖也各自转成 data: URL 再替换进去(同 check-trace)
|
||
const loaded = new Map()
|
||
async function load(path) {
|
||
if (loaded.has(path)) return loaded.get(path)
|
||
const res = await fetch(`${WEB}${path}?t=${Date.now()}`)
|
||
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`)
|
||
let code = await res.text()
|
||
const deps = new Set([...code.matchAll(/from\s+["'](\/src\/[^"'?]+)(?:\?[^"']*)?["']/g)]
|
||
.map((m) => m[1]))
|
||
for (const dep of deps) {
|
||
const url = await load(dep)
|
||
const pattern = new RegExp(`(["'])${dep.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\?[^"']*)?\\1`, 'g')
|
||
code = code.replace(pattern, JSON.stringify(url))
|
||
}
|
||
const url = 'data:text/javascript;base64,' + Buffer.from(code).toString('base64')
|
||
loaded.set(path, url)
|
||
return url
|
||
}
|
||
|
||
let mod, synthetic
|
||
try {
|
||
mod = await import(await load('/src/run/decode.ts'))
|
||
synthetic = await import(await load('/src/run/__tests__/synthetic.ts'))
|
||
} catch (e) {
|
||
console.error(`✗ 拿不到转译结果(${WEB})——前端没起?先跑 ./scripts/dev.sh\n ${e.message}`)
|
||
process.exit(1)
|
||
}
|
||
|
||
let failed = 0
|
||
const check = (name, cond, detail = '') => {
|
||
console.log(` ${cond ? '✓' : '✗'} ${name}${detail ? ` — ${detail}` : ''}`)
|
||
if (!cond) failed++
|
||
}
|
||
|
||
/**
|
||
* 一节一节地跑:某一节里抛了异常,只记成这一节失败,接着跑下一节,
|
||
* 不让一处卡住把后面的检查一起吞掉。这里是纯函数的检查:
|
||
* 某种事件让翻译层直接抛了异常,也只算这一节
|
||
*/
|
||
async function section(name, fn) {
|
||
console.log(`\n=== ${name} ===`)
|
||
try {
|
||
await fn()
|
||
} catch (e) {
|
||
check(`${name} 中途出错`, false, String(e?.message ?? e).split('\n')[0])
|
||
}
|
||
}
|
||
const flatten = (steps) =>
|
||
steps.flatMap((s) => [s, ...(s.children ? flatten(s.children) : [])])
|
||
|
||
await section('数据库查询', async () => {
|
||
const steps = mod.decodeRun(fixtures.db)
|
||
const all = flatten(steps)
|
||
const q = all.find((s) => s.kind === 'query')
|
||
// 一次取数常有五六条查询,标题全写「在 X 上查询数据」只能靠 meta 分。
|
||
// 标题从 SQL 里取表名:这条是 SELECT * FROM ANALYTICS.v_device_kpi FETCH FIRST 50
|
||
check('查询步骤说人话,不是 db_query__warehouse(...)',
|
||
!!q && q.title.startsWith('查询') && !q.title.includes('db_query__'), q?.title)
|
||
check('查询标题说出查的是哪张表', !!q?.title.includes('v_device_kpi'), q?.title)
|
||
check('数据源名留给展开区,不挤标题', q?.source === 'warehouse' && !q.title.includes('warehouse'))
|
||
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')))
|
||
})
|
||
|
||
await section('知识检索', async () => {
|
||
// 以前检索只发一条 info 日志,轨迹里查不到"这句结论依据的是哪一段"。
|
||
// 现在它是一条正经事件,而且带工件 id 可以下钻。
|
||
const ev = (data) => [{ seq: 1, type: 'retrieve.end', node_id: 'kb', data }]
|
||
|
||
const hit = mod.decodeRun(ev({
|
||
collection: '手册', query: '管理员怎么定义', count: 3,
|
||
top_score: 0.82, artifact: 'abc123', degraded: false,
|
||
}))
|
||
const s1 = flatten(hit).find((x) => x.kind === 'schema')
|
||
check('检索步骤说人话', !!s1 && s1.title.includes('手册') && s1.title.includes('3 段'), s1?.title)
|
||
check('问的是什么留在详情里', s1?.detail === '管理员怎么定义')
|
||
check('带得出工件 id,能下钻', s1?.artifact === 'abc123')
|
||
check('最高分露出来', !!s1?.meta?.includes('0.82'))
|
||
|
||
const none = flatten(mod.decodeRun(ev({ collection: '手册', count: 0 })))
|
||
.find((x) => x.kind === 'schema')
|
||
check('没检索到要标成失败,不是静悄悄地过去', none?.status === 'failed')
|
||
|
||
const bad = flatten(mod.decodeRun(ev({ collection: '手册', count: 2, degraded: true })))
|
||
.find((x) => x.kind === 'schema')
|
||
check('退回关键词这件事标出来了', bad?.level === 'warn' && !!bad?.meta?.includes('关键词'))
|
||
|
||
// 兜底那条也要还在:新事件没补映射时宁可显示一条,但内部类型名不当标题
|
||
const unknown = flatten(mod.decodeRun([{ seq: 1, type: 'brand.new', node_id: 'x', data: { a: 1 } }]))
|
||
check('未知事件不被静默吞掉', unknown.some((x) => x.detail?.startsWith('事件类型:brand.new')),
|
||
unknown.map((x) => x.title).join(','))
|
||
check('未知事件的类型名不上标题和副标题', !unknown.some((x) => x.title.includes('brand.new') || x.sub?.includes('brand.new')))
|
||
})
|
||
|
||
await section('长期记忆', async () => {
|
||
// 写记忆以前只发 info 日志,而 info 在这一层是被丢弃的——系统往长期记忆里
|
||
// 存东西,界面上一点痕迹都没有。Copilot 会主动记之后,这条不可接受。
|
||
const ev = (data) => [{ seq: 1, type: 'memory.end', node_id: 'mem', data }]
|
||
|
||
const w = flatten(mod.decodeRun(ev({
|
||
action: 'write', scope: 'default', count: 1,
|
||
content: '用户姓名:张三;工作单位:示例科技',
|
||
})))[0]
|
||
check('写记忆看得见', !!w)
|
||
check('把记了什么说出来,不是只报条数',
|
||
!!w?.title?.includes('张三'), w?.title)
|
||
check('全文留在详情里', !!w?.detail?.includes('示例科技'))
|
||
|
||
const r = flatten(mod.decodeRun(ev({ action: 'recall', scope: 'default', count: 3 })))[0]
|
||
check('召回说人话', !!r?.title?.includes('想起 3 条'), r?.title)
|
||
const none = flatten(mod.decodeRun(ev({ action: 'recall', scope: 'default', count: 0 })))[0]
|
||
check('没想起来要标出来,不是静悄悄地过去', none?.status === 'failed')
|
||
|
||
const c = flatten(mod.decodeRun(ev({ action: 'clear', scope: 'default', count: 7 })))[0]
|
||
check('清空是破坏性的,标成警告', c?.level === 'warn' && c.title.includes('7'))
|
||
})
|
||
|
||
await section('被截断的结果集', async () => {
|
||
// 后端按字符数硬切预览,切点落在 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)
|
||
})
|
||
|
||
await section('人工审批(含重放)', async () => {
|
||
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'))
|
||
})
|
||
|
||
await section('停在审批时的状态', async () => {
|
||
// 只喂到中断为止,模拟"运行正卡在人工介入上"这一刻
|
||
const upto = fixtures.human.slice(0, fixtures.human.findIndex((e) => e.type === 'run.interrupted') + 1)
|
||
const all = flatten(mod.decodeRun(upto))
|
||
const host = all.find((s) => s.kind === 'node' && s.nodeId === 'h')
|
||
|
||
// 转圈说的是"它在忙,你等着",而实际情况正相反:它在等你。
|
||
// 两种状态的行动含义完全相反,不能共用一个图标
|
||
check('被中断的节点是"等你"而不是"在跑"', host?.status === 'waiting', host?.status)
|
||
check('那一刻没有任何东西在转圈',
|
||
!all.some((s) => s.kind === 'node' && s.status === 'running'),
|
||
all.filter((s) => s.status === 'running').map((s) => s.title).join(','))
|
||
|
||
// 恢复之后它得回到"在跑",否则整条流永远显示成在等人
|
||
const after = mod.decodeRun(fixtures.human)
|
||
const doneHost = flatten(after).find((s) => s.kind === 'node' && s.nodeId === 'h')
|
||
check('恢复后回到正常状态', doneHost?.status === 'done', doneHost?.status)
|
||
|
||
// "在不在等人"必须从事件推。查审批列表的话要等 4 秒轮询,中断后那几秒
|
||
// 界面会说"这次运行已结束",而它其实正等着你点通过
|
||
check('从事件就能判断正在等人', mod.isAwaitingHuman(mod.decodeRun(upto)) === true)
|
||
check('跑完了就不再说在等人', mod.isAwaitingHuman(after) === false)
|
||
check('压根没有人工节点的运行也不误报',
|
||
mod.isAwaitingHuman(mod.decodeRun(fixtures.db)) === false)
|
||
})
|
||
|
||
await section('循环里的多轮审批', async () => {
|
||
// 真实运行:驳回 → 改写 → 再驳回(带备注)→ 改写 → 放行。三轮,每轮都有
|
||
// 重放。按内容去重必然出错——三轮的 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.some((s) => s.title.includes('你')))
|
||
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 === '已驳回'))
|
||
// 同一个节点执行了三次(驳回两次后重来):仍然只占一行,每次执行记在 execs 里,
|
||
// 子步骤按轮标号——以前三轮的子步骤平铺在同一个父节点下,分不清哪条是哪一轮
|
||
const review = steps.find((s) => s.nodeId === 'review')
|
||
check('三轮审批记成同一节点的三次执行', review?.execs?.length === 3, `${review?.execs?.length}`)
|
||
check('每轮的审批行标着自己是第几次', asks.map((s) => s.iter).join(',') === '1,2,3',
|
||
asks.map((s) => s.iter).join(','))
|
||
const rewrite = steps.find((s) => s.nodeId === 'rewrite')
|
||
check('执行多次的节点行尾说几次、共多久', /^×2 · 共 /.test(rewrite?.meta ?? ''), rewrite?.meta)
|
||
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'))
|
||
})
|
||
|
||
await section('多 agent 协作', async () => {
|
||
// 真实运行 #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(','))
|
||
})
|
||
|
||
await section('出具判定', async () => {
|
||
const steps = mod.decodeRun(fixtures.issue)
|
||
check('出具档位翻译成中文', steps.some((s) => s.kind === 'issuance' && s.title.includes('出具')),
|
||
steps.find((s) => s.kind === 'issuance')?.title)
|
||
})
|
||
|
||
await section('失败运行', async () => {
|
||
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'))
|
||
})
|
||
|
||
await section('并行分支', async () => {
|
||
// 图上一个节点连出多条边就是 fan-out,LangGraph 在同一个 superstep 里并发
|
||
// 执行——这是真并发。但在时间线上它们原来只是穿插出现的几行,
|
||
// "这三路是同时跑的、因此省下了 3.6 秒"一个字都没说。
|
||
// 这份样本是真跑出来的:三路分别 sleep 1.8 / 2.6 / 1.1 秒。
|
||
const steps = mod.decodeRun(fixtures.fanout)
|
||
const group = steps.find((s) => /路并行/.test(s.title))
|
||
check('认出了并行的那一批', !!group, steps.map((s) => s.title).join(' | '))
|
||
check('三路都在组里', group?.children?.length === 3, `${group?.children?.length} 路`)
|
||
check('省下多少说出来了', /合计 6\.\d s,实际 3\.\d s/.test(group?.meta ?? ''), group?.meta)
|
||
// 顺序执行的图不能被误判成并行——误报比漏报更糟,它会让人以为省了时间
|
||
for (const name of ['db', 'human', 'loop_approve']) {
|
||
const seq = mod.decodeRun(fixtures[name])
|
||
check(`顺序执行的 ${name} 没有被误判`, !seq.some((s) => /路并行/.test(s.title)))
|
||
}
|
||
})
|
||
|
||
await section('通用规则', async () => {
|
||
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(','))
|
||
// 全站只有一套写法(lib/format):「820 ms」「7.6 s」「1 分 14 秒」。旧的「1m14s」
|
||
// 「2min」「12345ms」一个都不能再出现
|
||
const metas = all.map((s) => s.meta).filter(Boolean)
|
||
check('耗时是全站统一的写法', metas.every((m) => !/\d(ms|s|min)\b|\dm\d/.test(m)),
|
||
metas.filter((m) => /\d(ms|s|min)\b|\dm\d/.test(m)).join(' | '))
|
||
// 一屏"0 ms"看着像每步都被精确计时,实际只是这些步骤没花时间,
|
||
// 反而把真正慢的那一步淹了
|
||
check('不显示 0 ms 这种没信息量的耗时', !metas.some((m) => /(^|[^\d.])0 ms/.test(m)),
|
||
all.filter((s) => /(^|[^\d.])0 ms/.test(s.meta ?? '')).map((s) => s.title).join(','))
|
||
check('没有空 meta 占位', !all.some((s) => s.meta === ''))
|
||
})
|
||
|
||
await section('Copilot 操作流', async () => {
|
||
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('先看表结构'))
|
||
|
||
// 编排期那 50 秒里,界面上只有一行不动的"正在理解需求"。思考是流式的,
|
||
// 固定显示开头那句等于把一段活的叙述冻在起点上,看着就像卡住了
|
||
const thinkingNow = mod.decodeCopilot([
|
||
{ op: 'thinking', delta: '用户想要一个能查销量的流程。' },
|
||
{ op: 'thinking', delta: '先看看画布上现有的节点有哪些。' },
|
||
{ op: 'thinking', delta: '这里需要一个分支' },
|
||
])
|
||
const live = thinkingNow[thinkingNow.length - 1]
|
||
check('还在想的时候显示最新一句', live.title.includes('这里需要一个分支'), live.title)
|
||
check('还在想的时候是进行中状态', live.status === 'running', live.status)
|
||
check('全文一句不丢', live.detail?.includes('用户想要') && live.detail?.includes('现有的节点'))
|
||
|
||
// 想完了它就是条历史记录,开头那句最接近"这段在想什么"
|
||
const settled = mod.decodeCopilot([
|
||
...thinkingNow.length ? [
|
||
{ op: 'thinking', delta: '用户想要一个能查销量的流程。' },
|
||
{ op: 'thinking', delta: '先看看画布上现有的节点有哪些。' },
|
||
] : [],
|
||
{ op: 'plan', summary: '三步走' },
|
||
])
|
||
const done = settled.find((s) => s.kind === 'think')
|
||
check('想完了换成开头那句', done?.title.startsWith('用户想要'), done?.title)
|
||
check('想完了不再转圈', done?.status === 'done', done?.status)
|
||
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)
|
||
|
||
// 自查:服务端用运行时同一套规则过一遍,有问题交回模型改。多出来的这段得看得见,
|
||
// 而且它排在"搭好了"后面,不能把那一行的"共几步"挤没了
|
||
const checked = mod.decodeCopilot([
|
||
{ op: 'add_node', node: { id: 'a', type: 'input' } },
|
||
{ op: 'add_node', node: { id: 'lp', type: 'loop' } },
|
||
{ op: 'done', explanation: '' },
|
||
{ op: 'check', status: 'repairing', round: 1, issues: ['「lp」循环条件写错了:表达式里没有 | 过滤器'] },
|
||
{ op: 'heartbeat', phase: 'repairing', elapsed_ms: 3000 },
|
||
{ op: 'update_node', id: 'lp' },
|
||
{ op: 'check', status: 'passed', repaired: 1 },
|
||
{ op: 'final', graph: { nodes: [{ id: 'a' }, { id: 'lp' }] } },
|
||
])
|
||
const titles = checked.map((s) => s.title)
|
||
check('自查发现问题要说出来', titles.some((t) => t.includes('自查发现 1 处问题')), titles.join(' | '))
|
||
check('交回去改的是哪条要看得到',
|
||
checked.some((s) => (s.detail ?? '').includes('循环条件写错了')))
|
||
check('改好了要说', titles.includes('自查通过:问题已经改好'), titles.join(' | '))
|
||
check('自查插在后面也不挤掉"共几步"', titles.includes('工作流搭好了,共 2 步'), titles.join(' | '))
|
||
check('自查结束后没有还在转的行', !checked.some((s) => s.status === 'running'),
|
||
checked.filter((s) => s.status === 'running').map((s) => s.title).join(','))
|
||
|
||
const stuck = mod.decodeCopilot([
|
||
{ op: 'done', explanation: '' },
|
||
{ op: 'check', status: 'failed', issues: ['「lp」循环条件写错了', '「t」整形节点没有填表达式'] },
|
||
{ op: 'final', graph: { nodes: [{ id: 'lp' }, { id: 't' }] } },
|
||
])
|
||
const failedCheck = stuck.find((s) => s.kind === 'error')
|
||
check('改不好要明说、说清没有自动运行',
|
||
!!failedCheck?.title.includes('还有 2 处问题') && failedCheck.title.includes('没有自动运行'),
|
||
failedCheck?.title)
|
||
// 画布上从不自动运行:「没有自动运行」在那儿读起来像出了别的故障
|
||
const onCanvas = mod.decodeCopilot([
|
||
{ op: 'check', status: 'failed', issues: ['「lp」循环条件写错了'] },
|
||
], { context: 'canvas' }).find((s) => s.kind === 'error')
|
||
check('画布语境说「没能自动修好」', !!onCanvas?.title.includes('没能自动修好')
|
||
&& !onCanvas.title.includes('自动运行'), onCanvas?.title)
|
||
})
|
||
|
||
await section('协作团队:调度者的决策(新后端的 agent.route.*)', async () => {
|
||
// 以前没有映射,每一轮多出两行标题是「agent.route.start」「agent.route.end」的原始类型名
|
||
const done = mod.decodeRun(synthetic.teamRun('done'))
|
||
const all = flatten(done)
|
||
const routes = all.filter((s) => s.kind === 'branch' && s.nodeId === 'team')
|
||
check('每轮调度只有一行(route 事件和同一轮的 log 不重复)', routes.length === 2,
|
||
routes.map((s) => s.title).join(' | '))
|
||
check('说清交给谁、是不是并行', routes[0]?.title === '第 1 轮:交给 采购员、质检员、物流员(并行)', routes[0]?.title)
|
||
check('调度花了多久写在行尾', routes[0]?.meta === '2.4 s', routes[0]?.meta)
|
||
check('调度理由可展开', routes[0]?.detail === '三方面数据互不依赖,可以同时查')
|
||
check('结束协作说成人话', routes[1]?.title === '第 2 轮:结束协作', routes[1]?.title)
|
||
check('成员和调度者的 llm.end 不生成孤立的"思考并作答"',
|
||
!all.some((s) => s.kind === 'llm' && s.nodeId === 'team'))
|
||
const rawTitle = /^[a-z]+\.[a-z.]+$/
|
||
const everywhere = [
|
||
...Object.values(fixtures).flatMap((evts) => flatten(mod.decodeRun(evts))),
|
||
...flatten(done), ...flatten(mod.decodeRun(synthetic.mixedRun())),
|
||
...flatten(mod.decodeRun(synthetic.longLoop(12))), ...flatten(mod.decodeRun(synthetic.cancelledRun())),
|
||
]
|
||
check('没有任何一行的标题是内部类型名', !everywhere.some((s) => rawTitle.test(s.title) || s.title.startsWith('agent.')),
|
||
everywhere.filter((s) => rawTitle.test(s.title)).map((s) => s.title).join(','))
|
||
|
||
// 调度者还在想:那几十秒里得有一行在走
|
||
const routing = flatten(mod.decodeRun(synthetic.teamRun('routing')))
|
||
const thinking = routing.find((s) => s.kind === 'branch' && s.status === 'running')
|
||
check('调度期间有一行进行中的「调度者在想下一步」', !!thinking?.title.includes('调度者在想'), thinking?.title)
|
||
check('进行中的调度行带开始时刻(界面据此走秒表)', typeof thinking?.startedAt === 'number')
|
||
|
||
// 三人并行、只有一人交回:「省下」要等这一轮的人都交回来才说
|
||
const live = mod.decodeRun(synthetic.teamRun('members'))
|
||
const team = live.find((s) => s.nodeId === 'team')?.team
|
||
check('一轮没收齐时不报「省下」', team?.savedMs === 0, `${team?.savedMs}`)
|
||
check('还在跑的成员是 running', team?.rounds[0]?.members.filter((m) => m.status === 'running').length === 2)
|
||
const finished = done.find((s) => s.nodeId === 'team')?.team
|
||
check('收齐之后才算出省下的时间', (finished?.savedMs ?? 0) > 0, `${finished?.savedMs}`)
|
||
|
||
// 用量:运行中按 llm.end 累加(含成员、调度者),终态以 run.finished 为准
|
||
const partial = mod.usageOf(synthetic.teamRun('members'))
|
||
check('运行中的用量把调度者和成员都算上', partial.tokensIn === 820 + 640 && !partial.final,
|
||
`${partial.tokensIn} / final=${partial.final}`)
|
||
const total = mod.usageOf(synthetic.teamRun('done'))
|
||
check('终态用后端累计的总数', total.final && total.tokensIn === 4470 && total.tokensOut === 946,
|
||
`${total.tokensIn}/${total.tokensOut}`)
|
||
check('终态带着执行时长', total.activeMs === 12300, `${total.activeMs}`)
|
||
})
|
||
|
||
await section('被跳过的节点、思考、查询标题、技术细节、签批人、出具缺口', async () => {
|
||
const steps = mod.decodeRun(synthetic.mixedRun())
|
||
const all = flatten(steps)
|
||
const skipped = steps.find((s) => s.nodeId === 'kb')
|
||
// skip_if 成立的节点以前被整类丢掉,和"根本没轮到"分不开
|
||
check('被跳过的节点留痕', skipped?.status === 'skipped' && !!skipped.title.includes('跳过「背景检索」'),
|
||
`${skipped?.status} ${skipped?.title}`)
|
||
check('跳过的原因写出来', !!skipped?.sub?.includes('skip_if 成立'), skipped?.sub)
|
||
|
||
// 思考不单独成行:挂到它所属的那次模型调用上当副标题
|
||
check('思考不再单独成行', !all.some((s) => s.kind === 'think'))
|
||
const llm = all.find((s) => s.kind === 'llm' && s.sub)
|
||
check('思考成了模型调用的副标题', !!llm?.sub?.includes('先看一下考勤'), llm?.sub)
|
||
check('思考全文还在展开区', !!llm?.detail?.includes('按班次汇总出勤率'))
|
||
|
||
const q1 = all.find((s) => s.kind === 'query' && s.status === 'done')
|
||
check('查询标题从 SQL 派生:表名 + 汇总维度',
|
||
!!q1?.title.includes('v_device_kpi') && q1.title.includes('按 shift_name 汇总'), q1?.title)
|
||
check('查询带着工件 id,可以下钻', !!q1?.artifact?.startsWith('4790dbfc'))
|
||
const q2 = all.find((s) => s.kind === 'query' && s.status === 'failed')
|
||
check('查询报错的原始异常收进技术细节', !!q2?.raw?.includes('OperationalError'), q2?.raw?.slice(0, 40))
|
||
check('报错的人话还在正文', !!q2?.result?.includes('查询超时'))
|
||
|
||
// 一次看十几张表:标题摘要,完整清单逐行放进详情(以前标题撑破卡片、页面横向滚动)
|
||
const schema = all.find((s) => s.kind === 'schema')
|
||
check('多表结构的标题不超过 60 字', (schema?.title.length ?? 99) <= 60, `${schema?.title.length}:${schema?.title}`)
|
||
check('说出一共几张表', !!schema?.title.includes('12 张表'), schema?.title)
|
||
check('完整表名逐行在详情里', schema?.detail?.split('\n').length === 12)
|
||
|
||
const human = all.find((s) => s.kind === 'human')
|
||
check('签批人照事件写', !!human?.title.endsWith('→ 张工 放行了'), human?.title)
|
||
check('备注跟着那一轮', !!human?.detail?.includes('备注:晚班偏低要跟进'))
|
||
const resume = steps.find((s) => s.title === '继续运行')
|
||
check('续跑是谁发起的写在副标题', !!resume?.sub?.includes('张工'), resume?.sub)
|
||
|
||
const issuance = steps.find((s) => s.kind === 'issuance')
|
||
// 只有 gaps 时以前只写一个光秃秃的「降档出具」
|
||
check('降档的原因写进出具那一行', !!issuance?.title.includes('校验不完整') && issuance.title.includes('叙述模板渲染为空'),
|
||
issuance?.title)
|
||
check('出具档位用统一叫法', !!issuance?.title.startsWith('降档出具'))
|
||
|
||
const notify = steps.find((s) => s.nodeId === 'notify')
|
||
check('节点失败的原始异常收进技术细节', notify?.status === 'failed' && !!notify.raw?.includes('ConnectError'))
|
||
check('run.failed 和节点失败是同一句话时不说两遍',
|
||
all.filter((s) => s.title === '推送失败:企业微信机器人地址没配' || s.detail === '推送失败:企业微信机器人地址没配').length === 1)
|
||
|
||
// run.failed 单独到(节点没来得及报):能定位到节点
|
||
const alone = mod.decodeRun([
|
||
{ seq: 1, type: 'run.started', node_id: null, ts: 1, data: { nodes: 2 } },
|
||
{ seq: 2, type: 'run.failed', node_id: null, ts: 2, data: { error: '整体超时', node_id: 'notify', label: '推送企业微信' } },
|
||
]).find((s) => s.kind === 'error')
|
||
check('run.failed 带上出错的节点', alone?.nodeId === 'notify' && !!alone.sub?.includes('推送企业微信'), alone?.sub)
|
||
|
||
// 模型调用一出现就成行(以前要等 llm.end 才冒出来,想的那几十秒里什么都没有)
|
||
const ev = synthetic.mixedRun()
|
||
const upto = ev.slice(0, ev.findIndex((e) => e.type === 'llm.start') + 1)
|
||
const pending = flatten(mod.decodeRun(upto)).find((s) => s.kind === 'llm')
|
||
check('模型调用一开始就有一行进行中', pending?.status === 'running', pending?.status)
|
||
check('进行中的行带开始时刻', typeof pending?.startedAt === 'number')
|
||
|
||
const p = mod.progressOf(steps)
|
||
check('进度按不同节点数算', p.total === 6 && p.done >= 4 && p.done <= 6, `${p.done}/${p.total}`)
|
||
})
|
||
|
||
await section('停下来的运行', async () => {
|
||
// 用户主动停止:还在跑的查询收成中性的「已取消」,不画红色的失败,也不再转圈
|
||
const all = flatten(mod.decodeRun(synthetic.cancelledRun()))
|
||
const q = all.find((s) => s.kind === 'query')
|
||
check('取消时还在跑的查询收成已取消', q?.status === 'cancelled', q?.status)
|
||
check('取消不是失败', !all.some((s) => s.status === 'failed' || s.level === 'error'))
|
||
check('没有还在转的行', !all.some((s) => s.status === 'running'))
|
||
|
||
// 服务重启:WS 回放完发一条 stream.end。事件停在半路也得收住,而且不成行
|
||
const ev = synthetic.mixedRun()
|
||
const half = ev.slice(0, ev.findIndex((e) => e.type === 'tool.end'))
|
||
const ended = flatten(mod.decodeRun([...half,
|
||
{ seq: 999, type: 'stream.end', node_id: null, ts: 0, data: { status: 'interrupted', pending: false } }]))
|
||
check('stream.end 之后没有还在转的行', !ended.some((s) => s.status === 'running'),
|
||
ended.filter((s) => s.status === 'running').map((s) => s.title).join(','))
|
||
check('stream.end 本身不成行', !ended.some((s) => s.title.includes('stream') || s.title === '一条还没翻译的记录'))
|
||
check('挂起的步骤标成 suspended', ended.some((s) => s.status === 'suspended'))
|
||
})
|
||
|
||
await section('长运行:按轮折叠(runs-5)', async () => {
|
||
const ev = synthetic.longLoop(145)
|
||
const steps = mod.decodeRun(ev)
|
||
// 1100 多条事件、145 拍:顶层仍然只有「开始、参数、循环、读传感器、成果、完成」这几行
|
||
check('顶层行数不随拍数增长', steps.length <= 7, `${steps.length} 行(${ev.length} 条事件)`)
|
||
const poll = steps.find((s) => s.nodeId === 'poll')
|
||
check('同一节点 145 次执行记在一行里', poll?.execs?.length === 145, `${poll?.execs?.length}`)
|
||
check('行尾说执行了几次', !!poll?.meta?.startsWith('×145'), poll?.meta)
|
||
const groups = mod.childrenByExec(poll)
|
||
check('子步骤按轮分组,一轮一组', groups.length === 145 && groups.every((g) => g.steps.length >= 1))
|
||
check('每一轮的循环序号跟着 node.started.iteration', groups[36].exec.iteration === 37)
|
||
const st = mod.spread(groups.map((g) => g.exec.ms ?? 0))
|
||
check('最慢那一拍认得出来', st.maxAt === 36 && st.max === 65, `第 ${st.maxAt + 1} 拍 ${st.max} ms`)
|
||
check('提醒挂在它所在的那一轮', groups[9].steps.some((s) => s.level === 'warn'))
|
||
|
||
// 相邻的同一件事并成一行,保留 level
|
||
const rows = mod.compactSteps([
|
||
{ id: 'a', seq: 1, kind: 'code', title: '运行 Python 代码', status: 'done', ms: 10 },
|
||
{ id: 'b', seq: 2, kind: 'code', title: '运行 Python 代码', status: 'done', ms: 12 },
|
||
{ id: 'c', seq: 3, kind: 'code', title: '运行 Python 代码', status: 'done', ms: 63 },
|
||
{ id: 'd', seq: 4, kind: 'note', title: '循环达到上限', level: 'warn', status: 'done' },
|
||
{ id: 'e', seq: 5, kind: 'note', title: '循环达到上限', level: 'warn', status: 'done' },
|
||
])
|
||
check('相邻重复行并成一行', rows.length === 2, rows.map((r) => r.title).join(' | '))
|
||
check('并后的行写出次数、中位和最慢', rows[0].meta === '×3 · 中位 12 ms · 最慢 63 ms', rows[0].meta)
|
||
check('警告并完还是警告', rows[1].level === 'warn' && rows[1].repeat?.count === 2)
|
||
// 出了状况的行,副标题说的是为什么:两次修复凑出来的值不一样,并成一行就只剩第一次的
|
||
const why = mod.compactSteps([
|
||
{ id: 'r1', seq: 1, kind: 'llm', title: '让模型修复格式', level: 'warn', status: 'done', sub: '出现了原文没有的值(total_count=0)' },
|
||
{ id: 'r2', seq: 2, kind: 'llm', title: '让模型修复格式', level: 'warn', status: 'done', sub: '出现了原文没有的值(region=华东)' },
|
||
{ id: 'r3', seq: 3, kind: 'llm', title: '让模型修复格式', level: 'warn', status: 'done', sub: '出现了原文没有的值(region=华东)' },
|
||
])
|
||
check('原因不同的警告不并成一行,原因相同的照并', why.length === 2 && why[1].repeat?.count === 2,
|
||
why.map((r) => `${r.sub} ${r.meta ?? ''}`).join(' | '))
|
||
|
||
// 恢复后的重放(resumed)不算新的一轮;循环体真的又跑一次才算
|
||
const replay = mod.decodeRun([
|
||
{ seq: 1, type: 'node.started', node_id: 'x', ts: 1, data: { label: '取数' } },
|
||
{ seq: 2, type: 'node.failed', node_id: 'x', ts: 2, data: { error: '超时' } },
|
||
{ seq: 3, type: 'run.resumed', node_id: null, ts: 3, data: {} },
|
||
{ seq: 4, type: 'node.started', node_id: 'x', ts: 4, data: { label: '取数', resumed: true } },
|
||
{ seq: 5, type: 'node.finished', node_id: 'x', ts: 5, data: { duration_ms: 20 } },
|
||
]).find((s) => s.nodeId === 'x')
|
||
check('接着跑的重放不算新一轮', replay?.execs?.length === 1 && replay.status === 'done' && !replay.level,
|
||
`${replay?.execs?.length} ${replay?.status} ${replay?.level}`)
|
||
})
|
||
|
||
await section('从 SQL 认表名', async () => {
|
||
const g = mod.describeSql
|
||
check('JOIN 的两张表都认出来', g('SELECT a.x FROM orders a JOIN users u ON a.uid = u.id')?.tables.join(',') === 'orders,users')
|
||
check('WITH 里的临时名不算表', g('WITH t AS (SELECT * FROM sales) SELECT * FROM t')?.tables.join(',') === 'sales')
|
||
check('GROUP BY 1 这种位置序号不瞎猜', g('SELECT region, SUM(x) FROM s GROUP BY 1')?.groupBy.length === 0)
|
||
check('聚合认得出', g('SELECT COUNT(*) FROM s')?.aggregate === true)
|
||
check('函数包着的分组列取里面的列名', g('SELECT DATE(ts), COUNT(*) FROM s GROUP BY DATE(ts)')?.groupBy[0] === 'ts')
|
||
check('不是查询就不硬认', g('这不是 SQL') === null)
|
||
})
|
||
|
||
await section('Copilot:心跳穿插、回话、少了一步、报错', async () => {
|
||
const steps = mod.decodeCopilot(synthetic.COPILOT_STUCK, { context: 'canvas' })
|
||
check('心跳穿插的思考仍然并成一条', steps.filter((s) => s.kind === 'think').length === 1,
|
||
steps.filter((s) => s.kind === 'think').map((s) => s.title).join(' | '))
|
||
check('收尾后没有任何还在转的行', !steps.some((s) => s.status === 'running'),
|
||
steps.filter((s) => s.status === 'running').map((s) => s.title).join(','))
|
||
check('阶段行收尾后不再写「正在…」', !steps.some((s) => s.kind === 'lifecycle' && s.title.startsWith('正在')),
|
||
steps.filter((s) => s.kind === 'lifecycle').map((s) => s.title).join(' | '))
|
||
check('修正写明第几轮', steps.some((s) => s.title.includes('第 1/2 轮')))
|
||
check('少了一步说出来', steps.some((s) => s.level === 'warn' && s.title.includes('excel_export')))
|
||
check('节点类型说中文', steps.find((s) => s.kind === 'node')?.meta === '调用工具',
|
||
steps.find((s) => s.kind === 'node')?.meta)
|
||
check('建图的每一行都标着规划阶段', steps.every((s) => s.stage === 'plan'))
|
||
|
||
const out = mod.copilotOutcome(synthetic.COPILOT_STUCK)
|
||
check('结局:图放上去了', out.kind === 'built' && out.total === 2)
|
||
check('结局:自查没修好,问题带着节点 id', out.check?.status === 'failed'
|
||
&& out.check.issues[0]?.nodeId === 'lp' && out.check.issues[1]?.nodeId === 'q',
|
||
JSON.stringify(out.check?.issues))
|
||
check('结局:认出被跳过的类型', out.skipped.join(',') === 'excel_export')
|
||
|
||
const reply = mod.copilotOutcome([{ op: 'reply', text: '按 mode 分' }])
|
||
check('只回了一句话的轮次认得出', reply.kind === 'reply' && reply.reply === '按 mode 分')
|
||
check('只回话时没有残留的转圈', !mod.decodeCopilot([
|
||
{ op: 'heartbeat', phase: 'planning', elapsed_ms: 3000 }, { op: 'reply', text: 'x' },
|
||
]).some((s) => s.status === 'running'))
|
||
check('流结束了但什么都没给', mod.copilotOutcome([{ op: 'heartbeat', phase: 'planning' }]).kind === 'empty')
|
||
|
||
const err = mod.decodeCopilot([{ op: 'error', message: '助手这一轮没跑完:模型服务没响应', hint: '稍后重试', detail: 'httpx.ReadTimeout' }])
|
||
const row = err.find((s) => s.kind === 'error')
|
||
check('报错:人话当标题、怎么办当副标题、原文进技术细节',
|
||
row?.title.startsWith('助手这一轮没跑完') && row.sub === '稍后重试' && row.raw === 'httpx.ReadTimeout',
|
||
`${row?.title} / ${row?.sub} / ${row?.raw}`)
|
||
const eo = mod.copilotOutcome([{ op: 'error', message: 'm', hint: 'h', detail: 'd' }])
|
||
check('结局里的报错分开带着 hint 和 detail', eo.kind === 'error' && eo.error?.hint === 'h' && eo.error?.raw === 'd')
|
||
})
|
||
|
||
await section('协作团队用完轮数:最后那次判定不是新的一轮(REQ-3A-3、NI-5)', async () => {
|
||
const team = (evts) => mod.decodeRun(evts).find((s) => s.nodeId === 'team')
|
||
const routes = (evts) => flatten(mod.decodeRun(evts)).filter((s) => s.kind === 'branch' && s.nodeId === 'team')
|
||
|
||
// 以前 closing 判定被写成「第 3 轮:调度者在想下一步…」→「第 3 轮:结束协作」,done=false 时
|
||
// 读起来像团队正常收尾了;泳道还多出一列空的第 3 轮
|
||
const failRoutes = routes(synthetic.exhaustedTeam('fail'))
|
||
const closing = failRoutes[failRoutes.length - 1]
|
||
check('判定没完成:说「轮数用完 · 调度者判定:未完成」并带理由',
|
||
closing?.title === '轮数用完 · 调度者判定:未完成(还没有查到任何订单数据)', closing?.title)
|
||
check('判定没完成的那一行是提醒色', closing?.level === 'warn', closing?.level)
|
||
check('不再把判定写成「第 3 轮」', !failRoutes.some((s) => s.title.includes('第 3 轮')),
|
||
failRoutes.map((s) => s.title).join(' | '))
|
||
const judged = routes(synthetic.exhaustedTeam('judged'))
|
||
check('判定完成:说「轮数用完 · 调度者判定:已完成」',
|
||
judged[judged.length - 1]?.title === '轮数用完 · 调度者判定:已完成', judged[judged.length - 1]?.title)
|
||
const live = routes(synthetic.exhaustedTeam('closing'))
|
||
const pending = live[live.length - 1]
|
||
check('还在判定时有一行进行中,不说「第 3 轮」',
|
||
pending?.status === 'running' && pending.title === '轮数用完 · 调度者在做最后判定…', `${pending?.status} ${pending?.title}`)
|
||
|
||
for (const mode of ['fail', 'judged', 'degrade']) {
|
||
const t = team(synthetic.exhaustedTeam(mode))?.team
|
||
check(`${mode}:泳道只有派过活的 2 轮,判定不加列`, t?.rounds.length === 2, `${t?.rounds.length} 轮`)
|
||
}
|
||
// reduceTeam 是画布协作矩阵和右栏共用的那一份:直接喂事件也不能多出一列
|
||
let direct
|
||
for (const e of synthetic.exhaustedTeam('fail')) {
|
||
const next = mod.reduceTeam(direct, e)
|
||
if (next && e.node_id === 'team') direct = next
|
||
}
|
||
check('reduceTeam 直接喂也只有 2 轮', direct?.rounds.length === 2, `${direct?.rounds.length}`)
|
||
check('reduceTeam 记下了判定结论', direct?.verdict?.closing === true && direct.verdict.done === false
|
||
&& direct.verdict.reason === '还没有查到任何订单数据', JSON.stringify(direct?.verdict))
|
||
// 还在判定:泳道据此写「调度者在判定」,而不是空着或多一列
|
||
let judging
|
||
for (const e of synthetic.exhaustedTeam('closing')) {
|
||
const next = mod.reduceTeam(judging, e)
|
||
if (next && e.node_id === 'team') judging = next
|
||
}
|
||
check('判定进行中:记下在判定、还没有结论,也不加列', judging?.verdict?.closing === true && judging.verdict.done === undefined
|
||
&& judging.rounds.length === 2, JSON.stringify(judging?.verdict))
|
||
const judgedTeam = team(synthetic.exhaustedTeam('judged'))?.team
|
||
check('判定完成时团队算收尾了', judgedTeam?.finished === true && judgedTeam.verdict?.done === true)
|
||
|
||
// 成员把工具调用写成文字:这一步是失败,不是一个写着失败原因的「完成」格子
|
||
const r0 = direct?.rounds[0]?.members.find((m) => m.agent === '取数员')
|
||
check('失败的成员格子是 failed', r0?.status === 'failed', r0?.status)
|
||
check('失败的成员带着原因', !!r0?.error?.includes('没有真正调用工具'), r0?.error)
|
||
const r1 = direct?.rounds[1]?.members.find((m) => m.agent === '分析员')
|
||
check('同一轮做完的成员照常是 done', r1?.status === 'done', r1?.status)
|
||
const memberRow = flatten(mod.decodeRun(synthetic.exhaustedTeam('fail')))
|
||
.find((s) => s.title.startsWith('取数员:') && s.status === 'failed')
|
||
check('失败的成员那一行也画成失败', !!memberRow && memberRow.level === 'error', memberRow?.status)
|
||
check('失败的成员那一行说清为什么', !!memberRow?.sub?.includes('没有真正调用工具'), memberRow?.sub)
|
||
check('一轮里有人失败时不算「省下」', direct?.savedMs === 0, `${direct?.savedMs}`)
|
||
|
||
// 判失败:矩阵和右栏都要说「用完 N 轮未完成」,点出从没派到的成员
|
||
check('判失败:团队的结局是 failed', direct?.verdict?.outcome === 'failed', JSON.stringify(direct?.verdict))
|
||
check('判失败:认出轮数', direct?.verdict?.rounds === 2, `${direct?.verdict?.rounds}`)
|
||
check('判失败:点出从没派到的成员', direct?.verdict?.never?.join(',') === '汇总员', direct?.verdict?.never?.join(','))
|
||
|
||
// 降档交付:节点「完成」了,但不能是一个安静的勾
|
||
const degraded = mod.decodeRun(synthetic.exhaustedTeam('degrade'))
|
||
const node = degraded.find((s) => s.nodeId === 'team')
|
||
check('降档:团队节点行是提醒色', node?.status === 'done' && node.level === 'warn', `${node?.status} ${node?.level}`)
|
||
check('降档:团队节点行说清是降档交付', !!node?.sub?.includes('用完 2 轮') && node.sub.includes('降档'), node?.sub)
|
||
check('降档:泳道的结局是 degraded,点出没派到的成员',
|
||
node?.team?.verdict?.outcome === 'degraded' && node.team.verdict.never?.join(',') === '汇总员',
|
||
JSON.stringify(node?.team?.verdict))
|
||
const note = flatten(degraded).find((s) => s.code === 'team_exhausted')
|
||
check('降档那条提醒说人话', note?.title === '协作团队用完 2 轮仍未完成 · 按降档交付', note?.title)
|
||
check('降档那条提醒给出下一步', !!note?.next && note.fix === 'canvas', `${note?.next} / ${note?.fix}`)
|
||
// 认不出轮数(老后端、措辞变了)时照实说「用完了轮数」,不拿「?」凑一个数
|
||
const vague = flatten(mod.decodeRun([
|
||
{ seq: 1, type: 'node.started', node_id: 'team', ts: 1, data: { node_type: 'supervisor', label: '团队' } },
|
||
{ seq: 2, type: 'log', node_id: 'team', ts: 2, data: { level: 'warn', code: 'team_exhausted', message: '协作团队没做完,按降档交付' } },
|
||
{ seq: 3, type: 'node.finished', node_id: 'team', ts: 3, data: { duration_ms: 5, preview: { text: 'x', exhausted: true } } },
|
||
]))
|
||
const vagueNote = vague.find((s) => s.code === 'team_exhausted')
|
||
const vagueNode = vague.find((s) => s.kind === 'node' && s.nodeId === 'team')
|
||
check('认不出轮数时不写「?」', !vagueNote?.title.includes('?') && !vagueNode?.sub?.includes('?')
|
||
&& !!vagueNote?.title.includes('用完了轮数'), `${vagueNote?.title} / ${vagueNode?.sub}`)
|
||
})
|
||
|
||
await section('模型把工具调用写成了文字(NI-4)', async () => {
|
||
const all = flatten(mod.decodeRun(synthetic.markupRun()))
|
||
const warn = all.find((s) => s.code === 'tool_markup_leak')
|
||
check('提醒行说人话,不贴原始标记', warn?.title === '模型把工具调用写成了文字,没有真正执行'
|
||
&& !warn.title.includes('DSML'), warn?.title)
|
||
check('说清已经提醒它重试', !!warn?.sub?.includes('重试一次'), warn?.sub)
|
||
check('给出下一步:去画布绑定工具', !!warn?.next?.includes('绑定') && warn.fix === 'canvas', `${warn?.next} / ${warn?.fix}`)
|
||
check('原始标记留在展开区给排查', !!warn?.detail?.includes('DSML'))
|
||
const team = flatten(mod.decodeRun(synthetic.exhaustedTeam('fail'))).find((s) => s.code === 'tool_markup_leak')
|
||
check('成员写成文字时说是哪个成员', team?.title === '取数员把工具调用写成了文字,没有真正执行', team?.title)
|
||
const node = mod.decodeRun(synthetic.markupRun()).find((s) => s.nodeId === 'query')
|
||
check('失败的节点照常是失败,原因留在详情', node?.status === 'failed' && !!node.detail?.includes('原始标记'))
|
||
// 收尾轮那种:真调过工具,只是步数用完了还想接着查。节点照常完成,不能说成「没有真正执行」
|
||
const settle = flatten(mod.decodeRun([
|
||
{ seq: 1, type: 'node.started', node_id: 'q', ts: 1, data: { node_type: 'agent', label: '数据查询' } },
|
||
{ seq: 2, type: 'log', node_id: 'q', ts: 2, data: { level: 'warn', code: 'tool_markup_leak',
|
||
message: '模型把工具调用写成了文字(<tool_call>{"name": "db_query__shop"…),没有真正调用工具:步数用完后的收尾轮仍想调用工具,结论只基于之前查到的部分' } },
|
||
{ seq: 3, type: 'node.finished', node_id: 'q', ts: 3, data: { duration_ms: 9, preview: { text: '华东 1,204 单' } } },
|
||
])).find((s) => s.code === 'tool_markup_leak')
|
||
check('收尾轮还想查:说步数用完,不说没有真正执行', !!settle?.title.includes('步数用完') && !settle.title.includes('没有真正执行'),
|
||
settle?.title)
|
||
check('收尾轮还想查:下一步是调大最多步数', !!settle?.next?.includes('最多步数'), settle?.next)
|
||
})
|
||
|
||
await section('校验修复想凑数(NI-3)', async () => {
|
||
const all = flatten(mod.decodeRun(synthetic.repairRun()))
|
||
const repairs = all.filter((s) => s.code === 'repair')
|
||
check('每次修复是一行模型调用', repairs.length === 2 && repairs.every((s) => s.kind === 'llm' && s.title === '让模型修复格式'),
|
||
repairs.map((s) => s.title).join(' | '))
|
||
check('修复调用带着耗时', repairs[0]?.meta === '1.3 s', repairs[0]?.meta)
|
||
check('被作废的修复折进那一行,不另起孤行', !all.some((s) => s.code === 'repair_invented'),
|
||
all.filter((s) => s.code === 'repair_invented').map((s) => s.title).join(','))
|
||
check('作废的修复画成提醒,说出凑出来的值', repairs.every((s) => s.level === 'warn' && s.sub?.includes('total_count=0')),
|
||
repairs.map((s) => `${s.level} ${s.sub}`).join(' | '))
|
||
check('作废的修复给出下一步', !!repairs[0]?.next?.includes('上游'), repairs[0]?.next)
|
||
check('第一次校验失败那条照常显示', all.some((s) => s.code === 'validate_retry' && s.title.includes('第 1 次校验失败')))
|
||
// 老数据:没有 purpose=repair 的 llm.end,作废说明也不能丢
|
||
const legacy = flatten(mod.decodeRun(synthetic.repairRun().filter((e) => e.type !== 'llm.end')))
|
||
const orphan = legacy.find((s) => s.code === 'repair_invented')
|
||
check('没有修复行可折时单独成一行说人话', !!orphan?.title.includes('修复被作废') && orphan.level === 'warn', orphan?.title)
|
||
})
|
||
|
||
await section('工具时限(timeout_s / timed_out)', async () => {
|
||
const live = flatten(mod.decodeRun(synthetic.timeoutRun('live'))).find((s) => s.kind === 'query')
|
||
check('进行中的查询带着时限', live?.limitS === 30 && live.status === 'running', `${live?.limitS} ${live?.status}`)
|
||
const done = flatten(mod.decodeRun(synthetic.timeoutRun('done'))).find((s) => s.kind === 'query')
|
||
check('超时的查询是失败', done?.status === 'failed' && done.level === 'error', done?.status)
|
||
check('超时的查询说清超了多少上限、已放弃等待', done?.sub === '超过 30 s 上限,已放弃等待', done?.sub)
|
||
check('超时的原话留在结果里', !!done?.result?.includes('缩小范围'))
|
||
})
|
||
|
||
await section('放弃等审批的运行', async () => {
|
||
const steps = mod.decodeRun(synthetic.abandonedRun())
|
||
const row = steps.find((s) => s.kind === 'lifecycle' && s.status === 'cancelled')
|
||
check('取消那一行说清谁放弃的、一并关了什么', !!row?.sub?.includes('张工') && row.sub.includes('1 条待审批一并关闭'), row?.sub)
|
||
check('放弃之后审批不再是待办', !flatten(steps).some((s) => s.status === 'waiting' || s.status === 'running'))
|
||
const plain = mod.decodeRun(synthetic.cancelledRun()).find((s) => s.kind === 'lifecycle' && s.status === 'cancelled')
|
||
check('老数据的取消行不硬编副标题', plain && !plain.sub, plain?.sub)
|
||
})
|
||
|
||
await section('出具那一行记着档位', async () => {
|
||
const is = mod.decodeRun(synthetic.mixedRun()).find((s) => s.kind === 'issuance')
|
||
check('出具步骤带 tier,头部能据此提醒', is?.tier === 'degraded', is?.tier)
|
||
})
|
||
|
||
await section('Copilot 改图:工具绑定变化(NI-1)', async () => {
|
||
const steps = mod.decodeCopilot(synthetic.COPILOT_TOOLS_DROPPED, { context: 'chat' })
|
||
const change = steps.find((s) => s.code === 'tool_changes')
|
||
check('工具绑定变化单独成一行', !!change, steps.map((s) => s.title).join(' | '))
|
||
check('工具少了用提醒色,说清是哪个节点', change?.level === 'warn' && change.title.includes('数据查询'), change?.title)
|
||
check('逐项列出前后', !!change?.detail?.includes('db_query__shop') && change.detail.includes('→'), change?.detail)
|
||
const selfCheck = steps.find((s) => s.title.startsWith('自查通过'))
|
||
check('自查通过但有工具被去掉:自查那一行是提醒,不是安静的通过', selfCheck?.level === 'warn'
|
||
&& selfCheck.title.includes('工具'), `${selfCheck?.level} ${selfCheck?.title}`)
|
||
const out = mod.copilotOutcome(synthetic.COPILOT_TOOLS_DROPPED)
|
||
check('结局里带着工具绑定变化', out.toolChanges?.length === 1 && out.toolChanges[0].removed.length === 2,
|
||
JSON.stringify(out.toolChanges))
|
||
check('结局里带着「工具被去掉」的警告', out.dropped?.length === 1, JSON.stringify(out.dropped))
|
||
const plain = mod.copilotOutcome(synthetic.COPILOT_STUCK)
|
||
check('没有变化时两样都是空的', !plain.toolChanges?.length && !plain.dropped?.length)
|
||
})
|
||
|
||
await section('协作团队执行了不止一次:结局和泳道按这一次算(3C REQ-2/3)', async () => {
|
||
// 画布卡片和右栏泳道都读 reduceTeam 的结果:store 对每个事件都调它,没有别的清理
|
||
const feed = (evts) => {
|
||
let t
|
||
const seen = []
|
||
for (const e of evts) {
|
||
const next = mod.reduceTeam(t, e)
|
||
if (next && e.node_id === 'team') { t = next; seen.push([e, t]) }
|
||
}
|
||
return { team: t, seen }
|
||
}
|
||
|
||
// 用完轮数判失败 → 调大轮数接着跑(后端也标 resumed:true)→ 成功
|
||
const rerun = feed(synthetic.rerunTeam('rerun'))
|
||
check('接着跑成功后:结局不再是上一次的「用完 2 轮仍未完成」', !rerun.team?.verdict?.outcome
|
||
&& !rerun.team?.verdict?.never, JSON.stringify(rerun.team?.verdict))
|
||
check('接着跑成功后:团队收尾了', rerun.team?.finished === true)
|
||
check('接着跑成功后:泳道只剩这一次的 1 轮', rerun.team?.rounds.length === 1
|
||
&& rerun.team.rounds[0].members.length === 1 && rerun.team.rounds[0].members[0].result === '查到 128 单',
|
||
JSON.stringify(rerun.team?.rounds.map((r) => r.members.map((m) => m.result))))
|
||
const atFailure = rerun.seen.find(([e]) => e.type === 'node.failed')?.[1]
|
||
check('判失败的那一刻结局照常是 failed', atFailure?.verdict?.outcome === 'failed', JSON.stringify(atFailure?.verdict))
|
||
const rerunNode = mod.decodeRun(synthetic.rerunTeam('rerun')).find((s) => s.nodeId === 'team')
|
||
check('右栏泳道:接着跑成功后不画「用完 N 轮」的结局', !rerunNode?.team?.verdict?.outcome,
|
||
JSON.stringify(rerunNode?.team?.verdict))
|
||
|
||
// 循环里的团队:第 1 轮降档、第 2 轮正常
|
||
const loop = feed(synthetic.rerunTeam('loop'))
|
||
check('循环第 2 轮正常收尾:结局不再是上一轮的 degraded', !loop.team?.verdict?.outcome,
|
||
JSON.stringify(loop.team?.verdict))
|
||
check('循环第 2 轮:round 从 0 数,不并进上一轮同号的那一列', loop.team?.rounds.length === 1
|
||
&& loop.team.rounds[0].members.length === 1 && loop.team.rounds[0].members[0].result === '第 2 周的原话',
|
||
JSON.stringify(loop.team?.rounds.map((r) => r.members.map((m) => m.result))))
|
||
const degradedAt = loop.seen.find(([e]) => e.type === 'node.finished')?.[1]
|
||
// 产出里不带轮数(后端只给 exhausted / exhausted_reason / never_dispatched):从日志原话里认
|
||
check('降档那一刻:轮数从日志原话里认出来', degradedAt?.verdict?.outcome === 'degraded'
|
||
&& degradedAt.verdict.rounds === 2, JSON.stringify(degradedAt?.verdict))
|
||
const loopEvents = synthetic.rerunTeam('loop')
|
||
const firstEnd = loopEvents.findIndex((e) => e.type === 'node.finished' && e.node_id === 'team')
|
||
const midNode = flatten(mod.decodeRun(loopEvents.slice(0, firstEnd + 1))).find((s) => s.kind === 'node' && s.nodeId === 'team')
|
||
check('降档的节点行写「用完 2 轮」,不写「用完 ? 轮」', midNode?.sub === '用完 2 轮仍未完成,按降档交付'
|
||
&& midNode.level === 'warn', `${midNode?.sub} / ${midNode?.level}`)
|
||
const loopNode = flatten(mod.decodeRun(loopEvents)).find((s) => s.kind === 'node' && s.nodeId === 'team')
|
||
check('循环第 2 轮正常收尾:节点行不再挂着上一轮的降档说明', loopNode?.execs?.length === 2
|
||
&& !loopNode.sub && !loopNode.level, `${loopNode?.execs?.length} / ${loopNode?.sub} / ${loopNode?.level}`)
|
||
|
||
// 日志也没有轮数时,退到这一次实际派过几轮
|
||
const noLog = feed([
|
||
{ seq: 1, type: 'node.started', node_id: 'team', ts: 1, data: { node_type: 'supervisor' } },
|
||
{ seq: 2, type: 'agent.route.end', node_id: 'team', ts: 2, data: { round: 0, agents: ['分析员'], parallel: 1, done: false, reason: '看看' } },
|
||
{ seq: 3, type: 'agent.step.start', node_id: 'team', ts: 3, data: { agent: '分析员', round: 0 } },
|
||
{ seq: 4, type: 'agent.step.end', node_id: 'team', ts: 4, data: { agent: '分析员', round: 0, duration_ms: 5 } },
|
||
{ seq: 5, type: 'agent.step.start', node_id: 'team', ts: 5, data: { agent: '分析员', round: 1 } },
|
||
{ seq: 6, type: 'agent.step.end', node_id: 'team', ts: 6, data: { agent: '分析员', round: 1, duration_ms: 5 } },
|
||
{ seq: 7, type: 'node.finished', node_id: 'team', ts: 7, data: { duration_ms: 20, preview: { exhausted: true, exhausted_reason: '没做完' } } },
|
||
])
|
||
check('日志也没说几轮:用派过的轮数', noLog.team?.verdict?.rounds === 2, JSON.stringify(noLog.team?.verdict))
|
||
|
||
// 等过审批之后的重放是同一次执行:泳道不清空
|
||
const approval = feed(synthetic.rerunTeam('approval'))
|
||
check('审批放行后的重放不算新的一次:第 1 轮的成员还在', approval.team?.rounds.length === 1
|
||
&& approval.team.rounds[0].members[0]?.result === '改好了' && approval.team.finished === true,
|
||
JSON.stringify(approval.team?.rounds))
|
||
check('审批收尾后不再挂着「等重放」的记号', !approval.team?.paused, JSON.stringify(approval.team))
|
||
})
|
||
|
||
await section('Copilot 自查问题:对象形状、节点名、超出限定范围(3C REQ-6)', async () => {
|
||
const steps = mod.decodeCopilot(synthetic.COPILOT_OBJECT_ISSUES, { context: 'chat' })
|
||
const repairing = steps.find((s) => s.title.startsWith('自查发现'))
|
||
check('对象形状:交回去改的那一行写节点名,不写「lp」', !!repairing?.detail?.startsWith('「逐周循环」循环条件写错了')
|
||
&& !repairing.detail.includes('「lp」'), repairing?.detail)
|
||
const failed = steps.find((s) => s.kind === 'error')
|
||
check('对象形状:没修好的问题也写节点名', !!failed?.detail?.includes('「订单查询」用了限定范围之外的数据源'), failed?.detail)
|
||
check('认不出名字的节点退到 id', !!failed?.detail?.includes('「gone」'), failed?.detail)
|
||
check('超出限定范围:单独给一句下一步', failed?.code === 'datasource_out_of_scope' && !!failed.next?.includes('限定'),
|
||
`${failed?.code} / ${failed?.next}`)
|
||
const canvasNames = mod.decodeCopilot(synthetic.COPILOT_OBJECT_ISSUES, { context: 'canvas', labelOf: (id) => ({ old: '旧的汇总', gone: '质量门' })[id] })
|
||
check('画布给了节点名:操作流里没出现过的也写名字', !!canvasNames.find((s) => s.kind === 'error')?.detail?.includes('「质量门」'),
|
||
canvasNames.find((s) => s.kind === 'error')?.detail)
|
||
check('「去掉了」那一行也写名字', canvasNames.some((s) => s.title === '去掉了「旧的汇总」'),
|
||
canvasNames.filter((s) => s.title.startsWith('去掉了')).map((s) => s.title).join(','))
|
||
// 只改配置的 update_node 常常不带 label:「调整了」那一行也向画布要名字,不写「调整了「lp」」(3C REQ-27)
|
||
const bare = mod.decodeCopilot([{ op: 'update_node', id: 'lp', config: { max_iterations: 4 } }],
|
||
{ context: 'canvas', labelOf: (id) => ({ lp: '逐周循环' })[id] })
|
||
check('不带 label 的 update_node:写画布上的节点名', bare.some((s) => s.title === '调整了「逐周循环」'),
|
||
bare.map((s) => s.title).join(','))
|
||
const bareNoName = mod.decodeCopilot([{ op: 'update_node', id: 'lp', config: {} }], { context: 'canvas' })
|
||
check('画布上也认不出:退到 id', bareNoName.some((s) => s.title === '调整了「lp」'), bareNoName.map((s) => s.title).join(','))
|
||
|
||
const out = mod.copilotOutcome(synthetic.COPILOT_OBJECT_ISSUES)
|
||
const scoped = out.check?.issues[0]
|
||
check('结局里的问题留着 field 和 code', scoped?.nodeId === 'ask' && scoped.field === 'tools'
|
||
&& scoped.code === 'datasource_out_of_scope' && scoped.label === '订单查询', JSON.stringify(scoped))
|
||
check('field 为 null 的不带 field', out.check?.issues[1] && !('field' in out.check.issues[1]), JSON.stringify(out.check?.issues[1]))
|
||
check('issueLine 用名字', mod.issueLine(scoped) === '「订单查询」用了限定范围之外的数据源 sales_daily:这一轮只查 orders',
|
||
mod.issueLine(scoped))
|
||
// 老会话里存的还是字符串:照旧认出节点 id
|
||
const legacy = mod.copilotOutcome(synthetic.COPILOT_STUCK)
|
||
check('字符串形状照旧:节点 id 从开头的「」里认', legacy.check?.issues[0]?.nodeId === 'lp'
|
||
&& legacy.check.issues[0].label === '逐日循环' && !legacy.check.issues[0].field,
|
||
JSON.stringify(legacy.check?.issues[0]))
|
||
})
|
||
|
||
await section('术语', async () => {
|
||
// 没起名的节点退到类型名,类型名跟全站同一张表
|
||
const h = mod.decodeRun([{ seq: 1, type: 'node.started', node_id: 'h', ts: 1, data: { node_type: 'human', label: 'h' } }])
|
||
check('人工节点叫「人工审批」', h[0]?.title === '人工审批', h[0]?.title)
|
||
})
|
||
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 解码器全部通过')
|
||
process.exit(failed ? 1 : 0)
|