mirror of
https://github.com/ThearchyHelios/AgentLab.git
synced 2026-11-15 07:02:00 +00:00
23cdc986fc
- types.ts 末尾按前后端约定加 Inquiry、InquiryRequest/Response、查询与快照的类型,字段名一个不改 - api/inquiry.ts:提问(POST,不设超时,服务端自有时长上限)、列出问答、取质询引用过的查询快照(外层包不包一层都交给界面认) - decode.ts:approval.inquiry 不再落进「未识别的事件」,解读成顶层一行「审批人质询」:标题带问题前 40 字,副标题说回答完成 / 不完整(写明哪个上限)/ 未能回答、查了几次,封存之后问的写明封存后追加;缺字段、类型不对的照实少说,不拿 0 顶查询次数 - check-decode 断言上述说法与缺字段的兜底;check-trace 加「运行停在审批时来了一条质询,相位仍是等待」:等待段不被收掉、执行时长不涨、恢复后照常跑完、每个前缀上 trace 与 decodePhase 一致
721 lines
46 KiB
JavaScript
721 lines
46 KiB
JavaScript
// 运行航迹(run/trace.ts)和图推导(run/derive.ts)的回归检查。
|
||
//
|
||
// 和 check-decode 一样用**真实运行**导出的事件跑(fixtures.json),不用构造的样本:
|
||
// 要守的恰恰是真实事件的脏细节——审批恢复会对同一节点重发 node.started、
|
||
// 老运行的事件没有 ts、协作成员的 end 事件晚发。夹具里没有图,下面按节点 id
|
||
// 搭了和那次运行一致的最小图;分支、容错、取消这几种夹具里没有的结局才用构造的。
|
||
//
|
||
// 转译借 vite dev server(它 serve 的就是应用实际运行的那份)。
|
||
// 跑之前前端得起着(./scripts/dev.sh),默认连 5273。对别的实例(比如一份沙箱拷贝)跑时
|
||
// 带上地址:AGENTLAB_WEB=http://localhost:<前端端口> node scripts/check-trace.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'))
|
||
|
||
// trace.ts 在运行时 import 了 decode.ts 和 derive.ts。data: URL 里的模块没法
|
||
// 解析 "/src/..." 这种路径,所以把依赖也各自转成 data: URL 再替换进去
|
||
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 trace, derive, decode
|
||
try {
|
||
trace = await import(await load('/src/run/trace.ts'))
|
||
derive = await import(await load('/src/run/derive.ts'))
|
||
decode = await import(await load('/src/run/decode.ts'))
|
||
} catch (e) {
|
||
console.error(`✗ 拿不到转译结果(${WEB})——前端没起?先跑 ./scripts/dev.sh\n ${e.message}`)
|
||
process.exit(1)
|
||
}
|
||
const { emptyTrace, foldEvent, finalizeTrace, project, isSettled } = trace
|
||
const { topology, activeEdgesOf, heldEdgesOf, walkedEdges, applyDerived } = derive
|
||
|
||
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])
|
||
}
|
||
}
|
||
|
||
/** store 里的那条路:折一条、推导一次,终态时收尾 */
|
||
const run = (events, graph) => events.reduce((t, ev) => {
|
||
const folded = foldEvent(t, ev)
|
||
if (!graph) return folded
|
||
return isSettled(folded.phase) ? finalizeTrace(folded, graph) : applyDerived(folded, graph)
|
||
}, emptyTrace())
|
||
const upto = (events, seq) => events.filter((e) => e.seq <= seq)
|
||
/** decodeRun 的步骤树摊平:收尾要递归到子步骤,断言也得看到每一行 */
|
||
const flat = (steps) => steps.flatMap(function walk(s) { return [s, ...(s.children ?? []).flatMap(walk)] })
|
||
const g = (nodes, edges) => ({
|
||
nodes: nodes.map((n) => (typeof n === 'string' ? { id: n } : n)),
|
||
edges: edges.map(([source, target, sourceHandle]) => ({ source, target, sourceHandle: sourceHandle ?? null })),
|
||
})
|
||
const ms = (x) => `${Math.round(x)}ms`
|
||
const LATER = Date.now() + 86_400_000
|
||
|
||
// 各夹具对应的图(按那次运行的节点 id 搭的)
|
||
const G = {
|
||
db: g(['input', 'query_kpi', 'output'], [['input', 'query_kpi'], ['query_kpi', 'output']]),
|
||
// 人工审批带通过 / 驳回两个出口,这次走的是通过:驳回那条下游就是"分支落空"
|
||
human: g([{ id: 'in', type: 'input' }, { id: 'h', type: 'human' }, 'out', 'rejected_note'],
|
||
[['in', 'h'], ['h', 'out', 'approved'], ['h', 'rejected_note', 'rejected']]),
|
||
// 驳回 → 改写 → 再审:rewrite → review 是回边
|
||
loop_approve: g([{ id: 'start', type: 'input' }, 'draft', { id: 'review', type: 'human' }, 'rewrite', 'done'],
|
||
[['start', 'draft'], ['draft', 'review'], ['review', 'done', 'approved'],
|
||
['review', 'rewrite', 'rejected'], ['rewrite', 'review']]),
|
||
fanout: g([{ id: 'start', type: 'input' }, 'leg0', 'leg1', 'leg2', 'merge', 'out'],
|
||
[['start', 'leg0'], ['start', 'leg1'], ['start', 'leg2'],
|
||
['leg0', 'merge'], ['leg1', 'merge'], ['leg2', 'merge'], ['merge', 'out']]),
|
||
// 循环条件写错了,循环节点当场失败:循环体和 done 出口之后的都被堵住
|
||
failed: g([{ id: 'start', type: 'input' }, 'init_state', { id: 'loop_sum', type: 'loop' }, 'add_one', 'result'],
|
||
[['start', 'init_state'], ['init_state', 'loop_sum'], ['loop_sum', 'add_one', 'body'],
|
||
['add_one', 'loop_sum'], ['loop_sum', 'result', 'done']]),
|
||
think: g([{ id: 'start', type: 'input' }, { id: 'each', type: 'loop' }, 'handle', 'collect', 'done'],
|
||
[['start', 'each'], ['each', 'handle', 'body'], ['handle', 'collect'], ['collect', 'each'],
|
||
['each', 'done', 'done']]),
|
||
}
|
||
|
||
await section('段', async () => {
|
||
const t = run(fixtures.db, G.db)
|
||
const q = t.nodes.query_kpi
|
||
const started = fixtures.db.find((e) => e.type === 'node.started' && e.node_id === 'query_kpi')
|
||
const finished = fixtures.db.find((e) => e.type === 'node.finished' && e.node_id === 'query_kpi')
|
||
check('一个节点一段,起止取事件的 ts', q?.segments.length === 1
|
||
&& q.segments[0].start === started.ts * 1000 && q.segments[0].end === finished.ts * 1000,
|
||
q?.segments.map((s) => `${s.kind}:${s.status}`).join(','))
|
||
check('节点跑完是 done、次数 1', q?.state === 'done' && q.count === 1, `${q?.state} ×${q?.count}`)
|
||
check('工具调用计数、跑完没有挂着的', q?.tools === 1 && q.toolsRunning === 0)
|
||
check('相位是成功', t.phase === 'succeeded', t.phase)
|
||
const p = project(t, LATER)
|
||
check('完成 3 / 共 3', p.nodesDone === 3 && p.nodesTotal === 3, `${p.nodesDone}/${p.nodesTotal}`)
|
||
check('跑完了没有东西还开着', !Object.values(t.nodes).some((n) => n.segments.some((s) => s.end == null)))
|
||
check('llm.token 不改航迹引用',
|
||
foldEvent(t, { seq: 999, type: 'llm.token', node_id: 'query_kpi', ts: 1, data: { delta: 'x' } }) === t)
|
||
check('收过的 seq 再来一遍原样返回', foldEvent(t, fixtures.db[3]) === t)
|
||
})
|
||
|
||
await section('并行度', async () => {
|
||
const t = run(fixtures.fanout, G.fanout)
|
||
const peak = Math.max(...t.parallelSeries.map(([, v]) => v))
|
||
check('三路同时在跑,峰值 3', peak === 3, `峰值 ${peak}`)
|
||
const at = (seq) => fixtures.fanout.find((e) => e.seq === seq).ts * 1000
|
||
const mid = project(t, at(9) + 1)
|
||
check('三路都开跑后投影出 3 路并行', mid.parallelNow === 3, `${mid.parallelNow}`)
|
||
check('那一刻三路都是 running',
|
||
['leg0', 'leg1', 'leg2'].every((id) => mid.nodes[id]?.state === 'running'),
|
||
['leg0', 'leg1', 'leg2'].map((id) => mid.nodes[id]?.state).join(','))
|
||
const later = project(t, at(11) + 1)
|
||
check('先跑完的那路落成 done,并行度降到 2',
|
||
later.nodes.leg2?.state === 'done' && later.parallelNow === 2,
|
||
`${later.nodes.leg2?.state} / ${later.parallelNow}`)
|
||
check('跑完后此刻并行度是 0', project(t, LATER).parallelNow === 0)
|
||
check('回放到开始之前:什么都没跑', project(t, at(1) - 1).nodes.leg0?.state === 'idle')
|
||
})
|
||
|
||
await section('defer 汇合', async () => {
|
||
// 汇合节点等其余任务都跑完再跑一次。一路回来了、其余还在跑时它是"待汇合"
|
||
const e = fixtures.fanout
|
||
const s3 = run(upto(e, 3), G.fanout)
|
||
check('入口跑完、三路还没开始:三路都在排队',
|
||
['leg0', 'leg1', 'leg2'].every((id) => s3.nodes[id]?.state === 'queued'),
|
||
['leg0', 'leg1', 'leg2'].map((id) => s3.nodes[id]?.state).join(','))
|
||
const s4 = run(upto(e, 4), G.fanout)
|
||
check('开跑的那路不再排队', s4.nodes.leg0?.state === 'running' && s4.nodes.leg1?.state === 'queued')
|
||
check('上游一路都没回来时汇合节点不排队', (s4.nodes.merge?.state ?? 'idle') === 'idle')
|
||
const s11 = run(upto(e, 11), G.fanout)
|
||
check('一路回来了:汇合节点待汇合', s11.nodes.merge?.state === 'queued', s11.nodes.merge?.state)
|
||
check('待汇合时指向它的边不算活跃(它还没在跑)',
|
||
!activeEdgesOf(s11, G.fanout).some((id) => id.includes('merge')))
|
||
const s16 = run(upto(e, 16), G.fanout)
|
||
check('汇合开跑后三条入边都在流', activeEdgesOf(s16, G.fanout).filter((id) => id.endsWith('|merge|')).length === 3,
|
||
activeEdgesOf(s16, G.fanout).join(' '))
|
||
const done = run(e, G.fanout)
|
||
check('汇合节点只跑了一次', done.nodes.merge?.count === 1, `×${done.nodes.merge?.count}`)
|
||
})
|
||
|
||
await section('墙钟 / 执行 / 等人', async () => {
|
||
const e = fixtures.human
|
||
const t = run(e, G.human)
|
||
const ts = (type) => e.find((x) => x.type === type).ts * 1000
|
||
const wall = e[e.length - 1].ts * 1000 - e[0].ts * 1000
|
||
const wait = ts('run.resumed') - ts('run.interrupted')
|
||
const p = project(t, LATER)
|
||
check('墙钟 = 第一条到最后一条', Math.abs(p.elapsedMs - wall) < 1, `${ms(p.elapsedMs)} vs ${ms(wall)}`)
|
||
check('等人 = 中断到恢复', Math.abs(p.waitMs - wait) < 1, `${ms(p.waitMs)} vs ${ms(wait)}`)
|
||
check('执行 = 墙钟 − 等人', Math.abs(p.activeMs - (wall - wait)) < 1,
|
||
`${ms(p.activeMs)} vs ${ms(wall - wait)}`)
|
||
check('不用 run.finished.duration_ms 的那个 12ms', p.elapsedMs > 500)
|
||
const h = t.nodes.h
|
||
check('审批恢复后的重放不算第二次执行', h?.count === 1, `×${h?.count}`)
|
||
check('等人节点一行三段:跑 → 等 → 接着跑',
|
||
h?.segments.map((s) => s.kind).join(',') === 'run,wait,run' && h.segments[2].resumed === true,
|
||
h?.segments.map((s) => `${s.kind}:${s.status}`).join(','))
|
||
|
||
const waiting = run(upto(e, 6), G.human)
|
||
check('停在审批上:相位 waiting、记下等的是谁', waiting.phase === 'waiting' && waiting.waitingNodeId === 'h')
|
||
check('等待节点的入边没有光点', activeEdgesOf(waiting, G.human).length === 0,
|
||
activeEdgesOf(waiting, G.human).join(' '))
|
||
check('入边改记成停住的边(闸门)', heldEdgesOf(waiting, G.human).join(' ') === 'in|h|',
|
||
heldEdgesOf(waiting, G.human).join(' '))
|
||
const later = project(waiting, ts('run.interrupted') + 5000)
|
||
check('等的时候墙钟和等人一起涨,执行不涨',
|
||
later.waitMs >= 4999 && later.elapsedMs - later.waitMs < 50,
|
||
`墙钟 ${ms(later.elapsedMs)} · 等人 ${ms(later.waitMs)} · 执行 ${ms(later.activeMs)}`)
|
||
check('回放到等待中间:h 是 waiting',
|
||
project(t, ts('run.interrupted') + 1).nodes.h?.state === 'waiting')
|
||
})
|
||
|
||
await section('等待审批时来了一条质询(approval.inquiry)', async () => {
|
||
// 审批人在批准前就报告提问:服务端在运行停在审批上时追加一条 approval.inquiry(node_id 为空),运行本身没动。
|
||
// 它不能把相位从等待里带出来(标题变成「正在执行」、工具栏挂出必然 409 的「停止」),也不能把等待的节点、
|
||
// 等待的那段时长收掉;恢复之后照常接着走
|
||
const e = fixtures.human
|
||
const before = upto(e, 6)
|
||
const last = before[before.length - 1]
|
||
const inquiry = {
|
||
seq: last.seq + 1, type: 'approval.inquiry', node_id: null, ts: last.ts + 0.5,
|
||
data: { question: '本月销售额是否扣除了退款?', quote: null, report: 'write', unit: 'u3', approval_id: 'ap-1',
|
||
answer_text: '已扣除退款。', answer_doc: null, ok: true, limit: null, error: null,
|
||
inquiry_artifacts: ['a'.repeat(64)], queries: [{ alias: 'Q1', artifact: 'a'.repeat(64), sql: 'SELECT 1', row_count: 1,
|
||
truncated: false, source: 'shop' }], model: 'verify-mini', usage: { calls: 2 } },
|
||
}
|
||
const asked = [...before, inquiry]
|
||
const t = run(asked, G.human)
|
||
check('相位仍是 waiting,等的还是 h', t.phase === 'waiting' && t.waitingNodeId === 'h' && t.nodes.h?.state === 'waiting',
|
||
`${t.phase} · ${t.waitingNodeId} · ${t.nodes.h?.state}`)
|
||
check('decodePhase 同样是 waiting', decode.decodePhase(asked) === 'waiting', decode.decodePhase(asked))
|
||
check('等待那一段没有被收掉', t.nodes.h?.segments.map((s) => `${s.kind}:${s.end == null ? 'open' : 'closed'}`).join(',') === 'run:closed,wait:open',
|
||
t.nodes.h?.segments.map((s) => `${s.kind}:${s.status}`).join(','))
|
||
check('等待节点的入边仍是闸门、没有光点', activeEdgesOf(t, G.human).length === 0 && heldEdgesOf(t, G.human).join(' ') === 'in|h|',
|
||
`流动 ${activeEdgesOf(t, G.human).join(' ')} · 闸门 ${heldEdgesOf(t, G.human).join(' ')}`)
|
||
const later = project(t, inquiry.ts * 1000 + 5000)
|
||
check('质询不算执行:之后墙钟和等待一起涨,执行时长不涨',
|
||
later.waitMs >= 5000 && later.elapsedMs - later.waitMs < 50,
|
||
`墙钟 ${ms(later.elapsedMs)} · 等待 ${ms(later.waitMs)} · 执行 ${ms(later.activeMs)}`)
|
||
const steps = decode.decodeRun(asked)
|
||
check('右栏:审批那一行仍在等待', decode.isAwaitingHuman(steps)
|
||
&& flat(steps).some((s) => s.kind === 'human' && s.nodeId === 'h' && s.status === 'waiting'),
|
||
flat(steps).map((s) => `${s.kind}:${s.status}`).join(','))
|
||
check('右栏:质询单独成一行,挂在顶层,不当成未识别的事件', steps.some((s) => s.code === 'approval_inquiry' && s.status === 'done')
|
||
&& !flat(steps).some((s) => s.title === '未识别的事件'), steps.map((s) => s.title).join(' | '))
|
||
// 恢复之后:后面的事件序号往后挪一位,接在质询后面
|
||
const resumed = [...asked, ...e.filter((x) => x.seq > 6).map((x) => ({ ...x, seq: x.seq + 1 }))]
|
||
const done = run(resumed, G.human)
|
||
check('恢复后照常跑完:h 只执行一次,一行三段(跑 → 等 → 接着跑)', done.phase === 'succeeded' && done.nodes.h?.count === 1
|
||
&& done.nodes.h.segments.map((s) => s.kind).join(',') === 'run,wait,run',
|
||
`${done.phase} · ×${done.nodes.h?.count} · ${done.nodes.h?.segments.map((s) => s.kind).join(',')}`)
|
||
const p = project(done, LATER)
|
||
const wait = (e.find((x) => x.type === 'run.resumed').ts - e.find((x) => x.type === 'run.interrupted').ts) * 1000
|
||
check('等待时长照旧是中断到恢复(质询那一刻不切断等待)', Math.abs(p.waitMs - wait) < 1, `${ms(p.waitMs)} vs ${ms(wait)}`)
|
||
let same = true
|
||
let where = ''
|
||
let tt = emptyTrace()
|
||
for (let i = 0; i < resumed.length; i++) {
|
||
tt = foldEvent(tt, resumed[i])
|
||
const expect = decode.decodePhase(resumed.slice(0, i + 1))
|
||
if (tt.phase !== expect) { same = false; where = `#${resumed[i].seq}: ${tt.phase} vs ${expect}` }
|
||
}
|
||
check('每个前缀上 trace.phase === decodePhase(含质询那一条)', same, where)
|
||
})
|
||
|
||
await section('循环轮次', async () => {
|
||
const e = fixtures.think
|
||
const t = run(e, G.think)
|
||
const each = t.nodes.each
|
||
check('走 done 出口不再 +1:3 项跑完是第 3 轮', each?.iteration === 3, `第 ${each?.iteration} 轮`)
|
||
check('总项数取 edge.taken.total', each?.iterTotal === 3, `${each?.iterTotal}`)
|
||
check('循环走完了', each?.loopDone === true && each.state === 'done')
|
||
check('循环体一行三段,标号 1 2 3',
|
||
t.nodes.handle?.segments.filter((s) => s.kind === 'run').map((s) => s.iteration).join(',') === '1,2,3',
|
||
t.nodes.handle?.segments.map((s) => s.iteration).join(','))
|
||
const mid = run(upto(e, 47), G.think)
|
||
check('两轮之间循环节点是"容器进行中",不是打勾',
|
||
mid.nodes.each?.state === 'running' && mid.nodes.each.looping === true, mid.nodes.each?.state)
|
||
check('容器进行中时指向它的边不流动', !activeEdgesOf(mid, G.think).some((id) => id.includes('|each|')))
|
||
check('第二轮时轮次是 2', mid.nodes.each?.iteration === 2)
|
||
check('用量从 llm.end 累加、终态以后端总数校正',
|
||
t.tokensIn === 177 && t.tokensOut === 922, `${t.tokensIn} / ${t.tokensOut}`)
|
||
})
|
||
|
||
await section('循环回边', async () => {
|
||
const topo = topology(G.loop_approve)
|
||
check('认出改写连回审批那条是回边', topo.back.size === 1 && topo.back.has('rewrite|review|'),
|
||
[...topo.back].join(' '))
|
||
check('去掉回边后拓扑序成立', topo.order.indexOf('review') < topo.order.indexOf('rewrite')
|
||
&& topo.order.indexOf('draft') < topo.order.indexOf('review'), topo.order.join(' → '))
|
||
check('层级:审批在第 2 层', topo.rank.review === 2, JSON.stringify(topo.rank))
|
||
const t = run(fixtures.loop_approve, G.loop_approve)
|
||
check('审了三轮就是三次,重放不算', t.nodes.review?.count === 3, `×${t.nodes.review?.count}`)
|
||
check('改写两次', t.nodes.rewrite?.count === 2)
|
||
const walked = walkedEdges(t, G.loop_approve)
|
||
check('走过的边包含回边和两条出口', walked.size === 5, [...walked].join(' '))
|
||
})
|
||
|
||
await section('失败后下游被阻断', async () => {
|
||
const t = run(fixtures.failed, G.failed)
|
||
check('失败的是循环节点', t.failedNodeId === 'loop_sum' && t.nodes.loop_sum.state === 'failed')
|
||
check('循环体和 done 出口之后都是 blocked',
|
||
t.nodes.add_one?.state === 'blocked' && t.nodes.result?.state === 'blocked',
|
||
`${t.nodes.add_one?.state} / ${t.nodes.result?.state}`)
|
||
check('跑过的上游不受影响', t.nodes.init_state?.state === 'done')
|
||
check('相位是失败,没有活跃的边', t.phase === 'failed' && activeEdgesOf(t, G.failed).length === 0)
|
||
})
|
||
|
||
await section('on_error=continue', async () => {
|
||
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 1000 + seq / 10, data })
|
||
const base = [
|
||
ev(1, 'run.started', null, { nodes: 3 }),
|
||
ev(2, 'node.started', 'a'), ev(3, 'node.finished', 'a', { duration_ms: 10 }),
|
||
ev(4, 'node.started', 'b'), ev(5, 'node.failed', 'b', { error: '接口超时', duration_ms: 20 }),
|
||
]
|
||
const tolerant = g(['a', { id: 'b', data: { config: { on_error: 'continue' } } }, 'c'], [['a', 'b'], ['b', 'c']])
|
||
const strict = g(['a', 'b', 'c'], [['a', 'b'], ['b', 'c']])
|
||
const mid = run(base, tolerant)
|
||
check('容错失败后下游照常排队', mid.nodes.c?.state === 'queued', mid.nodes.c?.state)
|
||
const ok = run([...base, ev(6, 'node.started', 'c'), ev(7, 'node.finished', 'c'),
|
||
ev(8, 'run.finished', null, { usage: {} })], tolerant)
|
||
check('跑完:失败的那个仍是 failed,下游 done,不传播阻断',
|
||
ok.phase === 'succeeded' && ok.nodes.b.state === 'failed' && ok.nodes.c.state === 'done')
|
||
const bad = run([...base, ev(6, 'run.failed', null, { error: '接口超时' })], strict)
|
||
check('不容错的失败:下游 blocked', bad.nodes.c?.state === 'blocked', bad.nodes.c?.state)
|
||
check('没有 on_error=continue 时失败节点不放行', mid.nodes.c && run(base, strict).nodes.c?.state !== 'queued')
|
||
})
|
||
|
||
await section('分支落空', async () => {
|
||
const t = run(fixtures.human, G.human)
|
||
check('审批走了通过:驳回那条下游是 unreached', t.nodes.rejected_note?.state === 'unreached',
|
||
t.nodes.rejected_note?.state)
|
||
check('走过的边只有入口和通过那条',
|
||
[...walkedEdges(t, G.human)].sort().join(' ') === 'h|out|approved in|h|',
|
||
[...walkedEdges(t, G.human)].join(' '))
|
||
const loop = run(fixtures.think, G.think)
|
||
check('循环跑完所有节点都走到了', !Object.values(loop.nodes).some((n) => n.state === 'unreached'))
|
||
})
|
||
|
||
await section('终态清扫', async () => {
|
||
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 2000 + seq, data })
|
||
const team = [
|
||
ev(1, 'run.started', null, { nodes: 3 }),
|
||
ev(2, 'node.started', 'in'), ev(3, 'node.finished', 'in'),
|
||
ev(4, 'node.started', 'team'),
|
||
ev(5, 'log', 'team', { level: 'info', round: 0, agents: ['甲', '乙'], done: false }),
|
||
ev(6, 'agent.step.start', 'team', { agent: '甲', round: 0 }),
|
||
ev(7, 'agent.step.start', 'team', { agent: '乙', round: 0 }),
|
||
]
|
||
const gr = g([{ id: 'in', type: 'input' }, { id: 'team', type: 'supervisor' }, 'out'], [['in', 'team'], ['team', 'out']])
|
||
const live = run(team, gr)
|
||
check('两个成员在跑时并行度是 2', live.parallelSeries.at(-1)[1] === 2)
|
||
check('调度段是推算的', live.nodes.team.segments.some((s) => s.kind === 'dispatch' && s.estimated))
|
||
const stop = run([...team, ev(8, 'run.cancelled', null)], gr)
|
||
check('取消:节点收成 cancelled', stop.phase === 'cancelled' && stop.nodes.team.state === 'cancelled',
|
||
stop.nodes.team.state)
|
||
check('取消:成员段一个不剩地收掉', !stop.nodes.team.segments.some((s) => s.end == null))
|
||
check('取消:没有活跃的边', activeEdgesOf(stop, gr).length === 0)
|
||
check('取消:没走到的是 unreached,不是 blocked', stop.nodes.out?.state === 'unreached', stop.nodes.out?.state)
|
||
check('取消后并行度归零', stop.parallelSeries.at(-1)[1] === 0)
|
||
|
||
const down = run([...team, ev(8, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关停' })], gr)
|
||
check('服务重启:挂起,不是失败', down.phase === 'suspended' && down.nodes.team.state === 'suspended')
|
||
check('挂起不推"没走到"——还能接着跑', (down.nodes.out?.state ?? 'idle') === 'idle')
|
||
const back = run([...team, ev(8, 'log', null, { level: 'warn', code: 'server_shutdown' }),
|
||
ev(9, 'run.resumed', null, {}), ev(10, 'run.started', null, { resumed: true }),
|
||
ev(11, 'node.started', 'team')], gr)
|
||
check('挂起后接着跑:重新开跑、算第二次执行', back.phase === 'running'
|
||
&& back.nodes.team.state === 'running' && back.nodes.team.count === 2)
|
||
check('接着跑之后结束时刻清掉', back.endedAt == null)
|
||
|
||
const lost = run([...team, { seq: 0, type: 'stream.end', node_id: null, ts: 2008, data: { status: 'interrupted', pending: false } }], gr)
|
||
check('对账:interrupted 且没有待审批 = 挂起', lost.phase === 'suspended' && lost.nodes.team.state === 'suspended')
|
||
|
||
const human = fixtures.human
|
||
const held = run([...upto(human, 6), { seq: 0, type: 'stream.end', node_id: null, ts: 0,
|
||
data: { status: 'interrupted', pending: true } }], G.human)
|
||
check('对账:有待审批就还是在等人', held.phase === 'waiting' && held.nodes.h.state === 'waiting')
|
||
const gone = run([...upto(human, 6), ev(99, 'run.cancelled', null)], G.human)
|
||
check('等人时取消:等待节点收成 cancelled、等人段闭合',
|
||
gone.nodes.h.state === 'cancelled' && !gone.nodes.h.segments.some((s) => s.end == null) && gone.waitingSince == null)
|
||
const fail = run([...team, ev(8, 'run.failed', null, { error: '超时' })], gr)
|
||
check('失败但没有节点报错(超时):在跑的收成 cancelled', fail.nodes.team.state === 'cancelled'
|
||
&& fail.failedNodeId == null, fail.nodes.team.state)
|
||
})
|
||
|
||
await section('老数据没有 ts', async () => {
|
||
for (const name of ['loop_approve', 'supervisor']) {
|
||
let t
|
||
try { t = run(fixtures[name]) } catch (e) { check(`${name} 折叠不抛错`, false, e.message); continue }
|
||
check(`${name} 标成无时间戳`, t.timed === false)
|
||
const p = project(t, Date.now())
|
||
check(`${name} 投影出结局`, p.phase === 'succeeded', p.phase)
|
||
check(`${name} 时长只来自 duration_ms、是有限值`, Number.isFinite(p.elapsedMs) && p.elapsedMs > 0
|
||
&& p.waitMs === 0, `${ms(p.elapsedMs)}`)
|
||
check(`${name} 段按顺序排、没有 NaN`, Object.values(t.nodes).every((n) => n.segments.every((s) =>
|
||
Number.isFinite(s.start) && (s.end == null || Number.isFinite(s.end)))))
|
||
}
|
||
const sup = run(fixtures.supervisor)
|
||
const researcher = sup.nodes.research_supervisor.segments.find((s) => s.kind === 'member')
|
||
check('成员段按 duration 定宽', researcher && researcher.end - researcher.start === 6660,
|
||
researcher && `${researcher.end - researcher.start}`)
|
||
check('等人节点在无 ts 时也只算一次', sup.nodes.human_review?.count === 1)
|
||
})
|
||
|
||
await section('相位和右栏同一个来源', async () => {
|
||
let same = true
|
||
let where = ''
|
||
for (const [name, events] of Object.entries(fixtures)) {
|
||
let t = emptyTrace()
|
||
for (let i = 0; i < events.length; i++) {
|
||
t = foldEvent(t, events[i])
|
||
const expect = decode.decodePhase(events.slice(0, i + 1))
|
||
if (t.phase !== expect) { same = false; where = `${name}#${events[i].seq}: ${t.phase} vs ${expect}` }
|
||
}
|
||
}
|
||
check('每个前缀上 trace.phase === decodePhase', same, where)
|
||
const upToWait = upto(fixtures.human, 6)
|
||
check('停在审批上的相位是 waiting', decode.decodePhase(upToWait) === 'waiting')
|
||
check('服务强杀后对账:interrupted 且没有待审批是 suspended',
|
||
decode.decodePhase(upto(fixtures.db, 7), { status: 'interrupted', pending: false }) === 'suspended')
|
||
// store 写回 run.status 的是 runStatusOf(phase),挂起写的是 suspended。消费方照
|
||
// decodePhase(events, {status: run.status}) 传进来,得认得这个值,不然强杀的运行一直转圈
|
||
const cut = upto(fixtures.db, 7)
|
||
check('run.status 写回的 suspended 也认得', decode.decodePhase(cut, { status: 'suspended' }) === 'suspended',
|
||
decode.decodePhase(cut, { status: 'suspended' }))
|
||
check('decodeRun 按 suspended 收尾:没有还在转的行',
|
||
!flat(decode.decodeRun(cut, { status: 'suspended' })).some((s) => s.status === 'running'),
|
||
flat(decode.decodeRun(cut, { status: 'suspended' })).map((s) => `${s.kind}:${s.status}`).join(','))
|
||
|
||
// 人工审批:右栏在 human.requested 就把宿主节点标成等你,画布得同一时刻变,
|
||
// 不能等到 run.interrupted(并行时它要等整个超步跑完才来)。恢复后的重放不算新一轮
|
||
let same2 = true
|
||
let where2 = ''
|
||
for (const [name, events] of Object.entries(fixtures)) {
|
||
for (let i = 0; i < events.length; i++) {
|
||
if (!['human.requested', 'human.resolved', 'run.interrupted', 'run.resumed'].includes(events[i].type)) continue
|
||
const cutAt = events.slice(0, i + 1)
|
||
const t = run(cutAt)
|
||
const onCanvas = Object.values(t.nodes).filter((n) => n.state === 'waiting').map((n) => n.id).sort().join()
|
||
const inPanel = decode.decodeRun(cutAt).filter((s) => s.kind === 'node' && s.status === 'waiting')
|
||
.map((s) => s.nodeId).sort().join()
|
||
if (events[i].type !== 'run.resumed' && onCanvas !== inPanel) {
|
||
same2 = false; where2 = `${name}#${events[i].seq}: 画布 [${onCanvas}] 右栏 [${inPanel}]`
|
||
}
|
||
}
|
||
}
|
||
check('审批事件之后画布和右栏认定的"在等人"是同一批节点', same2, where2)
|
||
})
|
||
|
||
await section('并行时 human.requested 先到', async () => {
|
||
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 6000 + seq / 10, data })
|
||
const gr = g([{ id: 'in', type: 'input' }, { id: 'h', type: 'human' }, 'x', 'ok'],
|
||
[['in', 'h'], ['in', 'x'], ['h', 'ok', 'approved']])
|
||
const asked = [
|
||
ev(1, 'run.started', null, { nodes: 4 }),
|
||
ev(2, 'node.started', 'in'), ev(3, 'node.finished', 'in'),
|
||
ev(4, 'node.started', 'h'), ev(5, 'node.started', 'x'),
|
||
ev(6, 'human.requested', 'h', { kind: 'human_node', node_id: 'h', mode: 'approve', title: '确认' }),
|
||
]
|
||
const t = run(asked, gr)
|
||
check('另一路还在跑:审批节点已经是 waiting', t.nodes.h?.state === 'waiting', t.nodes.h?.state)
|
||
check('相位还是 running(整条流水线没停)', t.phase === 'running' && decode.decodePhase(asked) === 'running', t.phase)
|
||
check('右栏同一时刻也是等你', decode.decodeRun(asked).find((s) => s.nodeId === 'h')?.status === 'waiting')
|
||
check('指向它的边不流动、改记成闸门',
|
||
!activeEdgesOf(t, gr).includes('in|h|') && heldEdgesOf(t, gr).includes('in|h|') && activeEdgesOf(t, gr).includes('in|x|'),
|
||
`流动 ${activeEdgesOf(t, gr).join(' ')} · 闸门 ${heldEdgesOf(t, gr).join(' ')}`)
|
||
check('它不算并行里在跑的那几个', t.parallelSeries.at(-1)[1] === 1, `${t.parallelSeries.at(-1)[1]}`)
|
||
const paused = run([...asked, ev(7, 'node.finished', 'x'),
|
||
ev(8, 'run.interrupted', 'h', { payload: { node_id: 'h' } })], gr)
|
||
check('run.interrupted 来了不再开第二段等待',
|
||
paused.nodes.h.segments.map((s) => s.kind).join(',') === 'run,wait', paused.nodes.h.segments.map((s) => s.kind).join(','))
|
||
const resumed = run([...asked, ev(7, 'node.finished', 'x'), ev(8, 'run.interrupted', 'h', { payload: { node_id: 'h' } }),
|
||
ev(9, 'run.resumed', null, {}), ev(10, 'run.started', null, { resumed: true }),
|
||
ev(11, 'node.started', 'h'),
|
||
ev(12, 'human.requested', 'h', { kind: 'human_node', node_id: 'h', mode: 'approve', title: '确认' })], gr)
|
||
check('恢复后重放的 human.requested 不把它打回等待', resumed.nodes.h.state === 'running' && resumed.nodes.h.count === 1,
|
||
`${resumed.nodes.h.state} ×${resumed.nodes.h.count}`)
|
||
const again = run(upto(fixtures.loop_approve, 75))
|
||
check('驳回后真的第二轮审批:human.requested 一到就是等待', again.nodes.review?.state === 'waiting', again.nodes.review?.state)
|
||
})
|
||
|
||
await section('画布的节点形状(type 是渲染器 card,类型在 data.nodeType)', async () => {
|
||
// store 传进 derive 的是 FlowNode。以前只读 type,拿到的全是 'card',分支、循环、
|
||
// 审批的出口路由一概不认,没选的出口后面全显示成排队
|
||
const fn = (id, nodeType, config = {}) => ({ id, type: 'card', data: { nodeType, label: id, config } })
|
||
const edge = (id, source, target, sourceHandle = null) => ({ id, source, target, sourceHandle })
|
||
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 7000 + seq / 10, data })
|
||
const flow = {
|
||
nodes: [fn('in', 'input'), fn('br', 'branch'), fn('a', 'llm'), fn('b', 'llm'), fn('out', 'output')],
|
||
// 分支命中 yes → a;default → b;a 之后也连到 b(b 另有来路)
|
||
edges: [edge('e1', 'in', 'br'), edge('e2', 'br', 'a', 'yes'), edge('e3', 'br', 'b', 'default'),
|
||
edge('e4', 'a', 'b'), edge('e5', 'b', 'out')],
|
||
}
|
||
const spec = { nodes: flow.nodes.map((n) => ({ id: n.id, type: n.data.nodeType, data: { config: n.data.config } })),
|
||
edges: flow.edges }
|
||
const head = [
|
||
ev(1, 'run.started', null, { nodes: 5 }), ev(2, 'node.started', 'in'), ev(3, 'node.finished', 'in'),
|
||
ev(4, 'node.started', 'br'), ev(5, 'edge.taken', 'br', { branch: 'yes' }), ev(6, 'node.finished', 'br'),
|
||
]
|
||
const mid = run(head, flow)
|
||
check('分支没选的出口:目标不排队', (mid.nodes.b?.state ?? 'idle') === 'idle', mid.nodes.b?.state)
|
||
check('选中的出口:目标排队', mid.nodes.a?.state === 'queued', mid.nodes.a?.state)
|
||
check('FlowNode 和 GraphSpec 推出同一个结果',
|
||
JSON.stringify(derive.deriveStates(mid, flow)) === JSON.stringify(derive.deriveStates(mid, spec)),
|
||
`${JSON.stringify(derive.deriveStates(mid, flow))} vs ${JSON.stringify(derive.deriveStates(mid, spec))}`)
|
||
// 画布上随手拖出来、还没连线的节点也没有入边:入口得按类型认,不能退到"没有入边的"
|
||
const loose = { nodes: [...flow.nodes, fn('draft', 'llm')], edges: flow.edges }
|
||
check('入口按 data.nodeType 认', topology(loose).entries.join() === 'in', topology(loose).entries.join())
|
||
const all = run([...head, ev(7, 'node.started', 'a'), ev(8, 'node.finished', 'a'),
|
||
ev(9, 'node.started', 'b'), ev(10, 'node.finished', 'b'),
|
||
ev(11, 'node.started', 'out'), ev(12, 'node.finished', 'out'),
|
||
ev(13, 'run.finished', null, { usage: {} })], flow)
|
||
const walked = walkedEdges(all, flow)
|
||
check('b 是从 a 那路到的:分支没选的出口不算走过', !walked.has('e3') && walked.has('e2') && walked.has('e4'),
|
||
[...walked].join(' '))
|
||
|
||
const loopG = {
|
||
nodes: [fn('s', 'input'), fn('lp', 'loop', { max_iterations: 5 }), fn('body', 'llm'), fn('after', 'output')],
|
||
edges: [edge('l1', 's', 'lp'), edge('l2', 'lp', 'body', 'body'), edge('l3', 'body', 'lp'),
|
||
edge('l4', 'lp', 'after', 'done')],
|
||
}
|
||
const lp = run([ev(1, 'run.started', null, { nodes: 4 }), ev(2, 'node.started', 's'), ev(3, 'node.finished', 's'),
|
||
ev(4, 'node.started', 'lp'), ev(5, 'edge.taken', 'lp', { branch: 'body', iteration: 0, total: 3 }),
|
||
ev(6, 'node.finished', 'lp'), ev(7, 'node.started', 'body')], loopG)
|
||
check('循环还在 body 里转:done 出口的目标不排队', (lp.nodes.after?.state ?? 'idle') === 'idle', lp.nodes.after?.state)
|
||
|
||
const hg = {
|
||
nodes: [fn('i', 'input'), fn('h', 'human'), fn('ok', 'output'), fn('no', 'output')],
|
||
edges: [edge('h1', 'i', 'h'), edge('h2', 'h', 'ok', 'approved'), edge('h3', 'h', 'no', 'rejected')],
|
||
}
|
||
const hv = run([ev(1, 'run.started', null, { nodes: 4 }), ev(2, 'node.started', 'i'), ev(3, 'node.finished', 'i'),
|
||
ev(4, 'node.started', 'h'),
|
||
ev(5, 'node.finished', 'h', { preview: { approved: true, __decision__: 'approved' } })], hg)
|
||
check('审批通过:驳回出口的目标不排队、通过出口的排队',
|
||
(hv.nodes.no?.state ?? 'idle') === 'idle' && hv.nodes.ok?.state === 'queued',
|
||
`${hv.nodes.no?.state} / ${hv.nodes.ok?.state}`)
|
||
})
|
||
|
||
await section('失败后接着跑', async () => {
|
||
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 8000 + seq / 10, data })
|
||
const failed = [
|
||
ev(1, 'run.started', null, { nodes: 2 }),
|
||
ev(2, 'node.started', 'a'), ev(3, 'node.failed', 'a', { error: '模型 id 写错了' }),
|
||
ev(4, 'run.failed', null, { error: '模型 id 写错了' }),
|
||
]
|
||
const gr = g(['a', 'b'], [['a', 'b']])
|
||
check('失败时记下是谁', run(failed, gr).failedNodeId === 'a')
|
||
const cont = [...failed, ev(5, 'run.resumed', null, {}), ev(6, 'run.started', null, { resumed: true })]
|
||
check('接着跑一开始,上一回合的失败节点就清掉', run(cont, gr).failedNodeId == null, run(cont, gr).failedNodeId)
|
||
const ok = run([...cont, ev(7, 'node.started', 'a'), ev(8, 'node.finished', 'a'),
|
||
ev(9, 'node.started', 'b'), ev(10, 'node.finished', 'b'), ev(11, 'run.finished', null, { usage: {} })], gr)
|
||
check('接着跑成功了:不再挂着"失败于 a"', ok.phase === 'succeeded' && ok.failedNodeId == null, `${ok.phase} / ${ok.failedNodeId}`)
|
||
})
|
||
|
||
await section('右栏收尾(decodeRun)', async () => {
|
||
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 9000 + seq, data })
|
||
const live = [
|
||
ev(1, 'run.started', null, { nodes: 4 }),
|
||
ev(2, 'node.started', 'in', { label: '输入' }), ev(3, 'node.finished', 'in'),
|
||
ev(4, 'node.started', 'q', { label: '查询' }),
|
||
ev(5, 'tool.start', 'q', { tool: 'db_query__shop', call_id: 'c1', args: { sql: 'select 1' } }),
|
||
ev(6, 'tool.start', 'q', { tool: 'web_search', call_id: 'c2', args: { q: 'x' } }),
|
||
ev(7, 'node.started', 'team', { label: '协作' }),
|
||
ev(8, 'agent.step.start', 'team', { agent: '甲', round: 0, instruction: '查一下' }),
|
||
]
|
||
const rows = (steps) => flat(steps).map((s) => `${s.kind}:${s.status}`).join(',')
|
||
const before = decode.decodeRun(live)
|
||
check('进行中:节点、工具、成员、开始运行都在转',
|
||
['node', 'query', 'tool', 'note', 'lifecycle'].every((k) => flat(before).some((s) => s.kind === k && s.status === 'running')),
|
||
rows(before))
|
||
|
||
const stop = decode.decodeRun([...live, ev(9, 'run.cancelled', null)])
|
||
check('取消:一行都不再转', !flat(stop).some((s) => s.status === 'running'), rows(stop))
|
||
check('取消:不画成失败', !flat(stop).some((s) => s.status === 'failed'), rows(stop))
|
||
check('取消:节点下面的查询、工具行也收成已取消',
|
||
['query', 'tool'].every((k) => flat(stop).find((s) => s.kind === k)?.status === 'cancelled')
|
||
&& flat(stop).find((s) => s.nodeId === 'q' && s.kind === 'node')?.status === 'cancelled')
|
||
check('取消:开始运行那行收成已取消', stop.find((s) => s.kind === 'lifecycle')?.status === 'cancelled')
|
||
const member = stop.find((s) => s.nodeId === 'team')?.team?.rounds[0]?.members[0]
|
||
check('取消:协作成员一起收', member?.status === 'cancelled', member?.status)
|
||
|
||
const down = decode.decodeRun([...live, ev(9, 'log', null, { level: 'warn', code: 'server_shutdown', message: '服务关停' })])
|
||
check('服务重启:转着的收成挂起', !flat(down).some((s) => s.status === 'running')
|
||
&& flat(down).filter((s) => ['node', 'query', 'tool'].includes(s.kind) && s.nodeId !== 'in').every((s) => s.status === 'suspended'),
|
||
rows(down))
|
||
|
||
const failedRun = decode.decodeRun([...live, ev(9, 'node.failed', 'q', { error: '查询超时' }),
|
||
ev(10, 'run.failed', null, { error: '查询超时' })])
|
||
check('失败:只有失败的那个是红的,连带停下的是已取消',
|
||
flat(failedRun).find((s) => s.nodeId === 'team' && s.kind === 'node')?.status === 'cancelled'
|
||
&& flat(failedRun).find((s) => s.nodeId === 'q' && s.kind === 'node')?.status === 'failed'
|
||
&& !flat(failedRun).some((s) => s.status === 'running'), rows(failedRun))
|
||
|
||
const waiting = decode.decodeRun(upto(fixtures.human, 6))
|
||
check('停在审批上:开始运行收成 done,不再转圈',
|
||
waiting.find((s) => s.kind === 'lifecycle')?.status === 'done', rows(waiting))
|
||
const killed = decode.decodeRun(live, { status: 'interrupted', pending: false })
|
||
check('强杀后对账(interrupted、无待审批):没有还在转的行',
|
||
!flat(killed).some((s) => s.status === 'running') && flat(killed).some((s) => s.status === 'suspended'), rows(killed))
|
||
|
||
const start = (agent, seq) => ev(seq, 'agent.step.start', 'team', { agent, round: 0, instruction: '' })
|
||
const end = (agent, seq, ms) => ev(seq, 'agent.step.end', 'team', { agent, round: 0, duration_ms: ms })
|
||
const fold = (evs) => evs.reduce((t, e) => decode.reduceTeam(t ?? undefined, e) ?? t, undefined)
|
||
check('一轮刚开始:省下 0,不报还没发生的收益', fold([start('甲', 1), start('乙', 2)]).savedMs === 0)
|
||
check('只回来一个:还是 0', fold([start('甲', 1), start('乙', 2), end('甲', 3, 100)]).savedMs === 0)
|
||
check('整轮都交回了:省下 = 各人之和 − 最慢的',
|
||
fold([start('甲', 1), start('乙', 2), end('甲', 3, 100), end('乙', 4, 300)]).savedMs === 100)
|
||
})
|
||
|
||
await section('金额估不出来的调用', async () => {
|
||
// 模型不在价格目录中:token 记到了,金额只能是 0。运行页写 $0 像是没花钱,改写「金额无法估算」
|
||
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 5500 + seq, data })
|
||
const head = [
|
||
ev(1, 'run.started', null, { nodes: 1 }),
|
||
ev(2, 'node.started', 'agent'),
|
||
ev(3, 'llm.end', 'agent', { model: 'vendor-unlisted', input_tokens: 900, output_tokens: 90, cost_usd: 0, unpriced: true }),
|
||
ev(4, 'llm.end', 'agent', { model: 'vendor-unlisted', input_tokens: 700, output_tokens: 70, cost_usd: 0, unpriced: true }),
|
||
]
|
||
const gr = g(['agent'], [])
|
||
const live = run(head, gr)
|
||
check('运行中:llm.end 标了 unpriced 的逐次数进来', live.unpricedCalls === 2 && live.costUsd === 0, `${live.unpricedCalls}`)
|
||
const done = run([...head, ev(5, 'node.finished', 'agent', { duration_ms: 10 }),
|
||
ev(6, 'run.finished', null, { usage: { input_tokens: 1600, output_tokens: 160, cost_usd: 0, unpriced_calls: 3 } })], gr)
|
||
check('终态以后端总数为准(老后端少发的也算上)', done.unpricedCalls === 3, `${done.unpricedCalls}`)
|
||
const plain = run([ev(1, 'run.started', null, { nodes: 1 }), ev(2, 'node.started', 'agent'),
|
||
ev(3, 'llm.end', 'agent', { input_tokens: 10, output_tokens: 1, cost_usd: 0.001 })], gr)
|
||
check('目录里有价的调用不算', plain.unpricedCalls === 0)
|
||
|
||
const { runCostText, COST_TEXT } = await import(await load('/src/lib/terms.ts'))
|
||
check('一分都没估出来:写「金额无法估算」,不写 $0', runCostText(0, 3) === COST_TEXT.unpriced, runCostText(0, 3))
|
||
check('……没拿到金额时也一样', runCostText(null, 1) === COST_TEXT.unpriced)
|
||
check('估出了一部分:写「≥」', runCostText(0.012, 2) === '≥ $0.012', runCostText(0.012, 2))
|
||
check('全都有价:照常写', runCostText(0.012, 0) === '$0.012' && runCostText(0, 0) === '$0', `${runCostText(0.012, 0)} / ${runCostText(0, 0)}`)
|
||
check('运行中只算已上报的:写「≥」', runCostText(0.012, 0, true) === '≥ $0.012')
|
||
check('没有金额也没有估不出的:交给调用方写占位', runCostText(undefined, 0) === null)
|
||
check('悬停说明写清几次调用、为什么', COST_TEXT.unpricedTitle(3).includes('3 次调用') && COST_TEXT.unpricedTitle(3).includes('价格目录'))
|
||
})
|
||
|
||
await section('回放时的用量和工具数(逐刻度)', async () => {
|
||
// 回放到半路,卡片和 HUD 要写「那一刻」的 tokens 和工具数,而不是拿终值冒充
|
||
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 5000 + seq, data })
|
||
const events = [
|
||
ev(1, 'run.started', null, { nodes: 2 }),
|
||
ev(2, 'node.started', 'agent'),
|
||
ev(3, 'llm.end', 'agent', { input_tokens: 100, output_tokens: 20, cost_usd: 0.001 }),
|
||
ev(4, 'tool.start', 'agent', { tool: 'db_query__shop' }),
|
||
ev(5, 'tool.end', 'agent', { tool: 'db_query__shop' }),
|
||
ev(6, 'tool.start', 'agent', { tool: 'db_query__shop' }),
|
||
ev(7, 'tool.end', 'agent', { tool: 'db_query__shop' }),
|
||
ev(8, 'llm.end', 'agent', { input_tokens: 300, output_tokens: 80, cost_usd: 0.004 }),
|
||
ev(9, 'node.finished', 'agent', { duration_ms: 7000 }),
|
||
ev(10, 'node.started', 'out'), ev(11, 'node.finished', 'out'),
|
||
ev(12, 'run.finished', null, { usage: { input_tokens: 400, output_tokens: 100, cost_usd: 0.005 } }),
|
||
]
|
||
const gr = g(['agent', 'out'], [['agent', 'out']])
|
||
const t = run(events, gr)
|
||
const at = (seq) => (5000 + seq) * 1000
|
||
const p4 = project(t, at(4) + 1)
|
||
check('第一次调工具那一刻:1 次工具、1 个在跑、tokens 只有第一轮',
|
||
p4.nodes.agent.tools === 1 && p4.nodes.agent.toolsRunning === 1 && p4.nodes.agent.tokensIn === 100
|
||
&& p4.nodes.agent.tokensOut === 20,
|
||
JSON.stringify(p4.nodes.agent))
|
||
const p6 = project(t, at(6) + 0.5)
|
||
check('第二次调工具时:2 次,第一次已经回来', p6.nodes.agent.tools === 2 && p6.nodes.agent.toolsRunning === 1,
|
||
JSON.stringify(p6.nodes.agent))
|
||
const p9 = project(t, at(9) + 1)
|
||
check('节点跑完:用量是这个节点的最终值、没有在跑的工具', p9.nodes.agent.tokensIn === 400
|
||
&& p9.nodes.agent.tokensOut === 100 && p9.nodes.agent.tools === 2 && p9.nodes.agent.toolsRunning === 0,
|
||
JSON.stringify(p9.nodes.agent))
|
||
check('回放到开始之前:0,不是终值', project(t, at(1)).nodes.agent.tools === 0
|
||
&& project(t, at(1)).nodes.agent.tokensIn === 0)
|
||
check('整次运行的用量也按刻度走', p4.tokensIn === 100 && p4.tokensOut === 20 && p9.tokensIn === 400,
|
||
`${p4.tokensIn}/${p4.tokensOut} → ${p9.tokensIn}`)
|
||
const live = project(t, trace.lastEventAt(t))
|
||
check('此刻(实时)读节点的当前值', live.nodes.agent.tokensIn === 400 && live.nodes.agent.tools === 2
|
||
&& live.tokensIn === 400)
|
||
// 老后端:agent 的调用不发 llm.end,run.finished 的总数比刻度加起来多——回放时那一刻的
|
||
// 整次用量不可信,给 undefined,不拿终值冒充
|
||
const old = run([...events.slice(0, 11),
|
||
ev(12, 'run.finished', null, { usage: { input_tokens: 9000, output_tokens: 900, cost_usd: 0.05 } })], gr)
|
||
const op = project(old, at(4) + 1)
|
||
check('刻度对不上后端总数:回放时整次用量写不出', op.tokensIn === undefined && op.tokensOut === undefined,
|
||
`${op.tokensIn}/${op.tokensOut}`)
|
||
check('……实时时照样是后端的总数', project(old, trace.lastEventAt(old)).tokensIn === 9000)
|
||
check('没有 tool / llm 事件的节点刻度为空、读数是 0',
|
||
t.nodes.out.marks === undefined && project(t, at(11)).nodes.out.tools === 0)
|
||
})
|
||
|
||
await section('最后一条事件的时刻(lastEventAt)', async () => {
|
||
const ev = (seq, type, node_id, data = {}) => ({ seq, type, node_id, ts: 6000 + seq, data })
|
||
const gr = g([{ id: 'in', type: 'input' }, 'boom', 'after'], [['in', 'boom'], ['boom', 'after']])
|
||
const t = run([
|
||
ev(1, 'run.started', null, { nodes: 3 }),
|
||
ev(2, 'node.started', 'in'), ev(3, 'node.finished', 'in'),
|
||
ev(4, 'node.started', 'boom'), ev(5, 'node.failed', 'boom', { error: 'x' }),
|
||
ev(6, 'run.failed', null, { error: 'x', timing: { wall_ms: 5000, active_ms: 4000, wait_ms: 0 } }),
|
||
], gr)
|
||
check('lastEventAt 是最后一条事件的 ts(毫秒)', trace.lastEventAt(t) === 6006 * 1000, `${trace.lastEventAt(t)}`)
|
||
const settled = project(t, trace.lastEventAt(t))
|
||
check('投影到这一刻 = 此刻:推导出的阻断还在', settled.nodes.after?.state === 'blocked', settled.nodes.after?.state)
|
||
check('……后端给的权威时长也在', settled.elapsedMs === 5000 && settled.activeMs === 4000,
|
||
`${settled.elapsedMs}/${settled.activeMs}`)
|
||
check('早一毫秒就是按段重建的回放(阻断不在)', project(t, trace.lastEventAt(t) - 1).nodes.after?.state !== 'blocked')
|
||
check('空航迹的 lastEventAt 是 0', trace.lastEventAt(emptyTrace()) === 0)
|
||
})
|
||
|
||
await section('性能', async () => {
|
||
// 256 段的大循环(真实运行里见过):一次折叠 + 投影都要快,拖游标才不卡
|
||
const ev = []
|
||
let seq = 0
|
||
const push = (type, node_id, data = {}) => ev.push({ seq: ++seq, type, node_id, ts: 3000 + seq / 100, data })
|
||
push('run.started', null, { nodes: 4 })
|
||
for (let i = 0; i < 128; i++) {
|
||
push('node.started', 'loop'); push('edge.taken', 'loop', { branch: 'body', iteration: i, total: 128 })
|
||
push('node.finished', 'loop', { duration_ms: 1 })
|
||
push('node.started', 'body'); push('llm.end', 'body', { input_tokens: 1, output_tokens: 2 })
|
||
push('node.finished', 'body', { duration_ms: 5 })
|
||
}
|
||
push('node.started', 'loop'); push('edge.taken', 'loop', { branch: 'done', iteration: 128, total: 128 })
|
||
push('node.finished', 'loop'); push('run.finished', null, { usage: {} })
|
||
const gr = g([{ id: 'loop', type: 'loop' }, 'body', 'after'], [['loop', 'body', 'body'], ['body', 'loop'], ['loop', 'after', 'done']])
|
||
const t0 = performance.now()
|
||
const t = run(ev, gr)
|
||
const foldMs = performance.now() - t0
|
||
const t1 = performance.now()
|
||
for (let i = 0; i < 60; i++) project(t, 3000_000 + i * 50)
|
||
const projMs = (performance.now() - t1) / 60
|
||
check('折叠 ~770 条事件(含 256 段)', foldMs < 100, `${foldMs.toFixed(1)}ms`)
|
||
check('一次投影 < 5ms', projMs < 5, `${projMs.toFixed(2)}ms`)
|
||
check('循环轮次对得上', t.nodes.loop.iteration === 128 && t.nodes.body.count === 128)
|
||
})
|
||
|
||
console.log(failed ? `\n✗ ${failed} 项未通过` : '\n✓ 航迹全部通过')
|
||
process.exit(failed ? 1 : 0)
|