graph-console-node-signals.test.js
7.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
/**
* Unit Tests — 控制台节点信号的读回层(27-P3B W12-UI)
*
* 三件被验的事,都是「产生 → 传递 → 落库 → 读回」里**读回那一环**:
*
* 1. `RUN_EVENT_KINDS` 必须覆盖引擎实际发出的每一种事件名。
* EventSource 对具名事件要逐个 addEventListener,名单漏一个 = 那一类事件
* **整类到不了浏览器**(这就是 node.error / node.triage / node.boundary 之前的下场:
* 引擎在发、stream.log 里有、前端一条都收不到 → 失败节点在界面上「无原因」)。
* 判据从 graph-engine.js / graph-run-sse.js 的**源码文本**里扫,取不到就抛,不静默空跑
* (手法对标 template-verdicts.test.js)。
* ⚠️ 引擎将来新增一种事件名,这条会红并报出精确名字 —— 修法就是把它加进
* web/src/components/graph-console/node-signals.js 的 RUN_EVENT_KINDS。
*
* 2. `describeEvent` 对每种事件都拼得出人话。SSE 帧里**没有** summary
* (graph-run-sse.emitTransition 只把它写进 timeline.ndjson),前端只能自己拼;
* 拼不出来就回落成原始 JSON,运行日志轨会退回「一堆花括号」。
*
* 3. `nodeEvents` / `attemptOf` 的过滤语义:按 node_run_id 认领、按 attempt 分轮。
* 失败原因的三级回落与「还活着」条都建在这两个函数上。
*/
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import {
RUN_EVENT_KINDS, nodeEvents, attemptOf, lastWhere, describeEvent, fmtDuration, since,
} from '../../../web/src/components/graph-console/node-signals.js';
const REPO = path.resolve(__dirname, '../../..');
/** 从源码文本里扫出引擎实际会发的事件名(emitEvent / timelineNote 两个入口 + 两个状态机事件) */
function emittedEventNames() {
const engine = fs.readFileSync(path.join(REPO, 'server/services/graph-engine.js'), 'utf8');
const sse = fs.readFileSync(path.join(REPO, 'server/services/graph-run-sse.js'), 'utf8');
const found = new Set();
// sse.emitEvent(runId, 'x.y', ...) / emitEvent(ctx.runId, 'x.y', ...)(跨行也匹配)
for (const m of engine.matchAll(/emitEvent\(\s*[^,]+,\s*['"]([a-z][a-z._]*)['"]/g)) found.add(m[1]);
// timelineNote(ctx, 'x.y', ...) —— 它内部同样走 sse.emitEvent,是审计轨与实时轨的合流点
for (const m of engine.matchAll(/timelineNote\(\s*[^,]+,\s*['"]([a-z][a-z._]*)['"]/g)) found.add(m[1]);
// 状态机那两条是 graph-run-sse 自己写死的事件名
for (const m of sse.matchAll(/event:\s*'((?:node|run)\.status)'/g)) found.add(m[1]);
if (found.size < 8) {
throw new Error(`事件名扫描失效:只扫到 ${found.size} 个(${[...found]})。`
+ '引擎的 emitEvent / timelineNote 调用形态变了,先修这个扫描器,别让它静默空跑。');
}
return found;
}
describe('控制台节点信号(W12-UI 读回层)', () => {
it('RUN_EVENT_KINDS 覆盖引擎实际发出的每一种事件名(漏一个 = 整类事件收不到)', () => {
const emitted = emittedEventNames();
const subscribed = new Set(RUN_EVENT_KINDS);
const missing = [...emitted].filter((k) => !subscribed.has(k)).sort();
expect(
missing,
`这些事件引擎在发、GraphRunPane 没订阅(EventSource 具名事件必须逐个 addEventListener):`
+ `${missing.join(', ')}。加进 node-signals.js 的 RUN_EVENT_KINDS。`,
).toEqual([]);
// 反向也别留死名字:订阅了一个引擎根本不发的事件名,说明名单在腐化
expect([...subscribed].filter((k) => !emitted.has(k))).toEqual([]);
});
it('describeEvent 对每种事件都拼得出人话,不回落成原始 JSON', () => {
const samples = {
'node.status': { from: 'ready', to: 'running' },
'run.status': { from: 'ready', to: 'running' },
'agent.call': { system_agent_id: 'coder', attempt: 1 },
'node.output': { status: 'fail', fail_reason: 'contract_violation', output_excerpt: 'boom' },
'node.error': { error_type: 'Error', error: 'Agent not found: coder' },
'node.triage': { verdict: 'attributed', fail_reason: 'wrong_impl', remediation_note: '改回去' },
'node.contract': { evidence: 'both', dirty: true, reset: ['n2'] },
'node.boundary': { violation: 'boundary_escape', count: 3 },
'node.subgraph': { child_run_id: 'r2', scope: 'node' },
'loop.registered': { loop_count: 2 },
'loop.terminated': { cap: 'edge' },
'loop.cross_layer': {},
'budget.spent': { delta: { agent_calls: 1, tokens: 42 } },
'plan.call': { agent: 'workgraph-planning', round: 1 },
'plan.done': { work_graph_id: 'wg-1', round: 1, nodes: 3, edges: 2 },
'plan.rejected': { round: 1, errors: [{ rule: 'reference', detail: '图不存在' }] },
};
// 样本表本身也要对齐名单,否则改了名单这条测试会悄悄少验几种
expect(Object.keys(samples).sort()).toEqual([...RUN_EVENT_KINDS].sort());
for (const [kind, data] of Object.entries(samples)) {
const text = describeEvent({ kind, data });
expect(text, kind).toBeTruthy();
expect(text.startsWith('{'), `${kind} 回落成了原始 JSON`).toBe(false);
}
// 具体内容抽查:真跑时最关键的两条
expect(describeEvent({ kind: 'node.error', data: samples['node.error'] }))
.toContain('Agent not found: coder');
expect(describeEvent({ kind: 'node.output', data: samples['node.output'] }))
.toContain('contract_violation');
// 引擎哪天把 summary 并进 data,前端自动优先用它
expect(describeEvent({ kind: 'node.output', data: { summary: '引擎给的一句话' } }))
.toBe('引擎给的一句话');
});
it('nodeEvents 按 node_run_id 认领、attemptOf 按 attempt 分轮', () => {
const node = { id: 'nr-1', attempt: 1 };
const events = [
{ kind: 'agent.call', data: { node_run_id: 'nr-1', attempt: 0 } },
{ kind: 'node.output', data: { node_run_id: 'nr-2', attempt: 0 } }, // 别的节点
{ kind: 'node.output', data: { node_run_id: 'nr-1', attempt: 1 } },
{ kind: 'budget.spent', data: { run_id: 'r1' } }, // 没有节点归属
{ kind: 'node.triage', data: { ref: 'nr-1' } }, // 只带 ref 的形态
];
const mine = nodeEvents(events, node);
expect(mine.map((e) => e.kind)).toEqual(['agent.call', 'node.output', 'node.triage']);
// 事件没带 attempt 时算在节点当前 attempt 上(本轮),带了就按它自己的
expect(mine.map((e) => attemptOf(e, node))).toEqual([0, 1, 1]);
expect(nodeEvents(events, null)).toEqual([]);
// 「最近一条」是倒着找的第一个
expect(lastWhere(mine, (e) => e.kind !== 'node.triage').kind).toBe('node.output');
expect(lastWhere(mine, () => false)).toBeNull();
});
it('fmtDuration / since 的秒表口径', () => {
expect(fmtDuration(0)).toBe('0s');
expect(fmtDuration(45_000)).toBe('45s');
expect(fmtDuration(187_000)).toBe('3m 07s');
expect(fmtDuration(3_840_000)).toBe('1h 04m');
expect(fmtDuration(-1)).toBeNull(); // 时钟偏斜时不显示,别报负数
expect(fmtDuration(NaN)).toBeNull();
const t0 = Date.parse('2026-08-17T00:00:00.000Z');
expect(since('2026-08-17T00:00:00.000Z', t0 + 12_000)).toBe('12s');
expect(since(null, t0)).toBeNull();
expect(since('不是时间', t0)).toBeNull();
});
});