@zhushanwen/pi-subagent-workflow 8.11.0 → 8.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,14 +6,22 @@
6
6
  * 字段。本测试证明「已入字段变化 → 签名变」+「静态 run 不变」(完备性无法靠
7
7
  * 测试证明,字段核对表见 WorkflowsView.ts computeRenderSignature doc)。
8
8
  *
9
- * 确定性说明:签名非完全纯——live 节点 elapsedSeconds projectLiveProgress
10
- * computeElapsedSeconds 现算(record.endedAt ?? Date.now())。fake timers 控制
9
+ * [H2 W3] live 数据源切换:签名第 2 参从 node.live 直读改为 store record 投影
10
+ * (Map<stepIndex, LiveProgressView>,经 collectNodeLiveProgress 配对注入)——
11
+ * 字段口径不变(totalTokens/toolCallCount/elapsedSeconds/turns/eventLog.length/
12
+ * currentActivity/lastError),本文件用 makeLiveView 直接构造投影驱动「已入字段
13
+ * 变 → 签名变」;投影与旧 projectLiveProgress 的逐字段等价性见
14
+ * record-progress.test.ts(双路径对齐断言)。
15
+ *
16
+ * 确定性说明:签名非完全纯——live 投影的 elapsedSeconds 由 projectRecordProgress
17
+ * 内 computeElapsedSeconds 现算(record.endedAt ?? Date.now())。fake timers 控制
11
18
  * Date.now() 与 now 参数同源推进,保证确定性。
12
19
  */
13
20
  import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
14
21
 
15
22
  import { computeRenderSignature } from "../WorkflowsView.ts";
16
- import type { ExecutionRecord } from "@zhushanwen/subagent-core";
23
+ import type { LiveProgressView } from "../detail-content.ts";
24
+ import type { AgentEventLogEntry } from "@zhushanwen/subagent-core";
17
25
  import type { ExecutionTraceNode, WorkerLogEntry } from "@zhushanwen/subagent-core";
18
26
  import type { WorkflowRun } from "@zhushanwen/subagent-core";
19
27
 
@@ -21,30 +29,21 @@ import type { WorkflowRun } from "@zhushanwen/subagent-core";
21
29
 
22
30
  const T0 = 1_700_000_000_000; // 固定 epoch ms
23
31
 
24
- function makeLive(overrides: Partial<ExecutionRecord> = {}): ExecutionRecord {
32
+ function makeLiveView(overrides: Partial<LiveProgressView> = {}): LiveProgressView {
25
33
  return {
26
- id: "run-0",
27
- agent: "worker",
28
- model: "default",
29
- thinkingLevel: undefined,
30
- mode: "sync",
31
- task: "t",
32
- slug: "s",
33
- startedAt: T0,
34
- rootSessionId: undefined,
35
- parentRecordId: undefined,
36
- depth: 0,
37
- status: "running",
38
- turns: [],
39
- turnCount: 0,
40
34
  totalTokens: 0,
35
+ toolCallCount: 0,
36
+ elapsedSeconds: 5,
37
+ turns: 0,
38
+ eventLog: [],
39
+ currentActivity: undefined,
41
40
  lastError: undefined,
42
- endedAt: T0 + 5000,
43
- result: undefined,
44
- error: undefined,
45
- agentResult: undefined,
46
41
  ...overrides,
47
- } as unknown as ExecutionRecord;
42
+ };
43
+ }
44
+
45
+ function makeEventLog(entries: Partial<AgentEventLogEntry>[] = []): AgentEventLogEntry[] {
46
+ return entries.map((e, i) => ({ type: "tool_start", label: `t-${i}`, ts: T0, ...e })) as AgentEventLogEntry[];
48
47
  }
49
48
 
50
49
  function makeNode(overrides: Partial<ExecutionTraceNode> = {}): ExecutionTraceNode {
@@ -76,6 +75,15 @@ function makeRun(shape: RunShape = {}): WorkflowRun {
76
75
  } as unknown as WorkflowRun;
77
76
  }
78
77
 
78
+ /** 单节点 run + 该节点的 live 投影(最常见断言形态的便捷封装)。 */
79
+ function runWithLive(nodeOverrides: Partial<ExecutionTraceNode>, live: LiveProgressView) {
80
+ const node = makeNode(nodeOverrides);
81
+ return {
82
+ run: makeRun({ nodes: [node] }),
83
+ live: new Map<number, LiveProgressView>([[node.stepIndex, live]]),
84
+ };
85
+ }
86
+
79
87
  beforeEach(() => {
80
88
  vi.useFakeTimers();
81
89
  vi.setSystemTime(T0);
@@ -86,19 +94,19 @@ afterEach(() => {
86
94
  });
87
95
 
88
96
  describe("computeRenderSignature — 基础确定性", () => {
89
- it("同输入同签名(含 live 节点,endedAt 固定使 elapsed 确定)", () => {
90
- const run = makeRun({ nodes: [makeNode({ live: makeLive() })] });
91
- expect(computeRenderSignature(run, T0)).toBe(computeRenderSignature(run, T0));
97
+ it("同输入同签名(含 live 投影,elapsedSeconds 固定值确定)", () => {
98
+ const { run, live } = runWithLive({}, makeLiveView());
99
+ expect(computeRenderSignature(run, live, T0)).toBe(computeRenderSignature(run, live, T0));
92
100
  });
93
101
 
94
102
  it("静态 run(无 live、同秒桶)200ms 内签名不变", () => {
95
103
  const run = makeRun({ nodes: [makeNode({ status: "completed" })] });
96
- expect(computeRenderSignature(run, T0)).toBe(computeRenderSignature(run, T0 + 200));
104
+ expect(computeRenderSignature(run, new Map(), T0)).toBe(computeRenderSignature(run, new Map(), T0 + 200));
97
105
  });
98
106
 
99
107
  it("秒桶跨秒(now 相差 1s 桶)→ 签名变", () => {
100
108
  const run = makeRun();
101
- expect(computeRenderSignature(run, T0)).not.toBe(computeRenderSignature(run, T0 + 1000));
109
+ expect(computeRenderSignature(run, new Map(), T0)).not.toBe(computeRenderSignature(run, new Map(), T0 + 1000));
102
110
  });
103
111
  });
104
112
 
@@ -106,131 +114,108 @@ describe("computeRenderSignature — run 级字段", () => {
106
114
  it("run.state.status 变化 → 签名变", () => {
107
115
  const a = makeRun({ status: "running" });
108
116
  const b = makeRun({ status: "done" });
109
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
117
+ expect(computeRenderSignature(a, new Map(), T0)).not.toBe(computeRenderSignature(b, new Map(), T0));
110
118
  });
111
119
 
112
120
  it("completed/total 变化(节点 status 推导)→ 签名变", () => {
113
121
  const a = makeRun({ nodes: [makeNode({ status: "running" })] });
114
122
  const b = makeRun({ nodes: [makeNode({ status: "completed" })] });
115
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
123
+ expect(computeRenderSignature(a, new Map(), T0)).not.toBe(computeRenderSignature(b, new Map(), T0));
116
124
  });
117
125
 
118
126
  it("budget tokens 量化值变化 → 签名变", () => {
119
127
  const a = makeRun({ budget: { usedTokens: 1500, maxTokens: 200_000, usedCost: 0.01 } });
120
128
  const b = makeRun({ budget: { usedTokens: 2500, maxTokens: 200_000, usedCost: 0.01 } });
121
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
129
+ expect(computeRenderSignature(a, new Map(), T0)).not.toBe(computeRenderSignature(b, new Map(), T0));
122
130
  });
123
131
 
124
132
  it("budget cost 第 4 位小数变化(toFixed(4) 可见精度)→ 签名变", () => {
125
133
  // 0.0100 vs 0.0101:量化展示相同到第 3 位,第 4 位是渲染可见精度
126
134
  const a = makeRun({ budget: { usedTokens: 0, maxTokens: 200_000, usedCost: 0.0100 } });
127
135
  const b = makeRun({ budget: { usedTokens: 0, maxTokens: 200_000, usedCost: 0.0101 } });
128
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
136
+ expect(computeRenderSignature(a, new Map(), T0)).not.toBe(computeRenderSignature(b, new Map(), T0));
129
137
  });
130
138
 
131
139
  it("run.state.errorLogs 追加 → 签名变(末条内容入指纹)", () => {
132
140
  const a = makeRun({ errorLogs: [{ level: "error", message: "E-0" }] });
133
141
  const b = makeRun({ errorLogs: [{ level: "error", message: "E-0" }, { level: "warn", message: "W-1" }] });
134
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
142
+ expect(computeRenderSignature(a, new Map(), T0)).not.toBe(computeRenderSignature(b, new Map(), T0));
135
143
  });
136
144
 
137
145
  it("errorLogs 封顶后 length 不变内容移(push+slice(-MAX_ERROR_LOGS))→ 签名仍变(指纹非 length)", () => {
138
146
  // 模拟 error-recovery 的变异路径:push 后 slice(-500) 截断。两次 state 均 500 条
139
147
  // (length 相同),仅末条/窗口内容不同——指纹含末条内容才不漏失效。
140
148
  const MAX_ERROR_LOGS = 500;
141
- const mk = (n: number): WorkerLogEntry[] => ({ level: "error", message: `E-${n}` });
149
+ const mk = (n: number): WorkerLogEntry => ({ level: "error", message: `E-${n}` });
142
150
  const before: WorkerLogEntry[] = Array.from({ length: MAX_ERROR_LOGS }, (_, i) => mk(i));
143
151
  const after = [...before, mk(MAX_ERROR_LOGS)].slice(-MAX_ERROR_LOGS); // E-1..E-500
144
152
  expect(after).toHaveLength(MAX_ERROR_LOGS); // 前置校验:封顶后 length 不变
145
153
  const a = makeRun({ errorLogs: before });
146
154
  const b = makeRun({ errorLogs: after });
147
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
155
+ expect(computeRenderSignature(a, new Map(), T0)).not.toBe(computeRenderSignature(b, new Map(), T0));
148
156
  });
149
157
  });
150
158
 
151
- describe("computeRenderSignature — 节点级字段(live 投影)", () => {
159
+ describe("computeRenderSignature — 节点级字段(store record 投影)", () => {
152
160
  it("节点 status 变化 → 签名变", () => {
153
161
  const a = makeRun({ nodes: [makeNode({ status: "running" })] });
154
162
  const b = makeRun({ nodes: [makeNode({ status: "failed" })] });
155
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
163
+ expect(computeRenderSignature(a, new Map(), T0)).not.toBe(computeRenderSignature(b, new Map(), T0));
156
164
  });
157
165
 
158
166
  it("live.totalTokens 变化 → 签名变", () => {
159
- const a = makeRun({ nodes: [makeNode({ live: makeLive({ totalTokens: 1000 }) })] });
160
- const b = makeRun({ nodes: [makeNode({ live: makeLive({ totalTokens: 2000 }) })] });
161
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
167
+ const a = runWithLive({}, makeLiveView({ totalTokens: 1000 }));
168
+ const b = runWithLive({}, makeLiveView({ totalTokens: 2000 }));
169
+ expect(computeRenderSignature(a.run, a.live, T0)).not.toBe(computeRenderSignature(b.run, b.live, T0));
162
170
  });
163
171
 
164
- it("getAllToolCalls(node.live).length 变化 签名变(toolCalls 追加)", () => {
165
- const live1 = makeLive({
166
- turns: [{ text: "", closed: false, toolCalls: [{ toolName: "read", args: {}, result: "", isError: false, _status: "completed", startedTs: T0 }] }],
167
- });
168
- const live2 = makeLive({
169
- turns: [{ text: "", closed: false, toolCalls: [
170
- { toolName: "read", args: {}, result: "", isError: false, _status: "completed", startedTs: T0 },
171
- { toolName: "bash", args: {}, result: "", isError: false, _status: "completed", startedTs: T0 },
172
- ] }],
173
- });
174
- const a = makeRun({ nodes: [makeNode({ live: live1 })] });
175
- const b = makeRun({ nodes: [makeNode({ live: live2 })] });
176
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
172
+ it("live.toolCallCount 变化(store 投影:eventLog tool_start 计数)→ 签名变", () => {
173
+ const a = runWithLive({}, makeLiveView({ toolCallCount: 1 }));
174
+ const b = runWithLive({}, makeLiveView({ toolCallCount: 2 }));
175
+ expect(computeRenderSignature(a.run, a.live, T0)).not.toBe(computeRenderSignature(b.run, b.live, T0));
177
176
  });
178
177
 
179
- it("live.elapsedSeconds 变化 → 签名变(running 节点跨秒)", () => {
180
- // endedAt undefined computeElapsedSeconds 用真实 Date.now();fake timers 推进 1s
181
- const live = makeLive({ endedAt: undefined });
182
- const run = makeRun({ nodes: [makeNode({ live })] });
183
- const before = computeRenderSignature(run, T0);
184
- vi.setSystemTime(T0 + 1000);
185
- const after = computeRenderSignature(run, T0 + 1000);
178
+ it("live.elapsedSeconds 变化 → 签名变(跨秒推进)", () => {
179
+ const run = makeRun({ nodes: [makeNode()] });
180
+ const before = computeRenderSignature(run, new Map([[0, makeLiveView({ elapsedSeconds: 5 })]]), T0);
181
+ const after = computeRenderSignature(run, new Map([[0, makeLiveView({ elapsedSeconds: 6 })]]), T0);
186
182
  expect(after).not.toBe(before);
187
183
  });
188
184
 
189
185
  it("live.turns 计数变化 → 签名变", () => {
190
- const a = makeRun({ nodes: [makeNode({ live: makeLive({ turnCount: 1 }) })] });
191
- const b = makeRun({ nodes: [makeNode({ live: makeLive({ turnCount: 2 }) })] });
192
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
186
+ const a = runWithLive({}, makeLiveView({ turns: 1 }));
187
+ const b = runWithLive({}, makeLiveView({ turns: 2 }));
188
+ expect(computeRenderSignature(a.run, a.live, T0)).not.toBe(computeRenderSignature(b.run, b.live, T0));
193
189
  });
194
190
 
195
- it("live.eventLog 追加(turn 闭合产生 turn_end 条目)→ 签名变", () => {
196
- const live1 = makeLive({
197
- turns: [{ text: "partial", closed: false, toolCalls: [] }],
198
- });
199
- const live2 = makeLive({
200
- turns: [{ text: "partial", closed: true, closedTs: T0, toolCalls: [] }],
201
- });
202
- const a = makeRun({ nodes: [makeNode({ live: live1 })] });
203
- const b = makeRun({ nodes: [makeNode({ live: live2 })] });
204
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
191
+ it("live.eventLog 追加(length 变化)→ 签名变", () => {
192
+ const a = runWithLive({}, makeLiveView({ eventLog: makeEventLog([{ type: "tool_start", label: "read" }]) }));
193
+ const b = runWithLive({}, makeLiveView({ eventLog: makeEventLog([{ type: "tool_start", label: "read" }, { type: "tool_end", label: "read" }]) }));
194
+ expect(computeRenderSignature(a.run, a.live, T0)).not.toBe(computeRenderSignature(b.run, b.live, T0));
205
195
  });
206
196
 
207
197
  it("live.currentActivity 出现 / 变化(type+label)→ 签名变", () => {
208
- const noActivity = makeLive({ turns: [{ text: "", closed: false, toolCalls: [] }] });
209
- const toolRunning = makeLive({
210
- turns: [{ text: "", closed: false, toolCalls: [{ toolName: "write", args: {}, result: "", isError: false, _status: "running", startedTs: T0 }] }],
211
- });
212
- const toolRunningOtherLabel = makeLive({
213
- turns: [{ text: "", closed: false, toolCalls: [{ toolName: "bash", args: {}, result: "", isError: false, _status: "running", startedTs: T0 }] }],
214
- });
215
- const base = makeRun({ nodes: [makeNode({ live: noActivity })] });
216
- const withTool = makeRun({ nodes: [makeNode({ live: toolRunning })] });
217
- const otherLabel = makeRun({ nodes: [makeNode({ live: toolRunningOtherLabel })] });
198
+ const noActivity = runWithLive({}, makeLiveView({ currentActivity: undefined }));
199
+ const withTool = runWithLive({}, makeLiveView({ currentActivity: { type: "tool", label: "write" } }));
200
+ const otherLabel = runWithLive({}, makeLiveView({ currentActivity: { type: "tool", label: "bash" } }));
218
201
 
219
- const s0 = computeRenderSignature(base, T0);
220
- expect(computeRenderSignature(withTool, T0)).not.toBe(s0); // 出现
221
- expect(computeRenderSignature(otherLabel, T0)).not.toBe(computeRenderSignature(withTool, T0)); // label 变
202
+ const s0 = computeRenderSignature(noActivity.run, noActivity.live, T0);
203
+ const s1 = computeRenderSignature(withTool.run, withTool.live, T0);
204
+ const s2 = computeRenderSignature(otherLabel.run, otherLabel.live, T0);
205
+ expect(s1).not.toBe(s0); // 出现
206
+ expect(s2).not.toBe(s1); // label 变
222
207
  });
223
208
 
224
209
  it("live.lastError 出现(内容入签名)→ 签名变", () => {
225
- const a = makeRun({ nodes: [makeNode({ live: makeLive({ lastError: undefined }) })] });
226
- const b = makeRun({ nodes: [makeNode({ live: makeLive({ lastError: "EPIPE: broken pipe" }) })] });
227
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
210
+ const a = runWithLive({}, makeLiveView({ lastError: undefined }));
211
+ const b = runWithLive({}, makeLiveView({ lastError: "EPIPE: broken pipe" }));
212
+ expect(computeRenderSignature(a.run, a.live, T0)).not.toBe(computeRenderSignature(b.run, b.live, T0));
228
213
  });
229
214
 
230
215
  it("node.sessionFile 出现 → 签名变", () => {
231
216
  const a = makeRun({ nodes: [makeNode({})] });
232
217
  const b = makeRun({ nodes: [makeNode({ sessionFile: "/tmp/sessions/run-0.jsonl" })] });
233
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
218
+ expect(computeRenderSignature(a, new Map(), T0)).not.toBe(computeRenderSignature(b, new Map(), T0));
234
219
  });
235
220
  });
236
221
 
@@ -239,10 +224,11 @@ describe("computeRenderSignature — 多节点与无 live 终态", () => {
239
224
  const run = makeRun({
240
225
  nodes: [
241
226
  makeNode({ stepIndex: 0, status: "completed" }),
242
- makeNode({ stepIndex: 1, status: "running", live: makeLive({ totalTokens: 500 }) }),
227
+ makeNode({ stepIndex: 1, status: "running" }),
243
228
  ],
244
229
  });
245
- const sig = computeRenderSignature(run, T0);
230
+ const live = new Map([[1, makeLiveView({ totalTokens: 500 })]]);
231
+ const sig = computeRenderSignature(run, live, T0);
246
232
  expect(sig).toContain("0:completed:-:-1:-1:-1:-1:-1:-:-");
247
233
  expect(sig).toContain("1:running:-:500:0:5:0:0:-:-");
248
234
  });
@@ -251,14 +237,15 @@ describe("computeRenderSignature — 多节点与无 live 终态", () => {
251
237
  // 同一节点集、字段值均不变,仅 trace 数组顺序对调——nodeParts 按 trace 序拼接,
252
238
  // 首字段 stepIndex 随之换位,签名必变(不漏失效)。
253
239
  const first = makeNode({ stepIndex: 0, status: "completed" });
254
- const second = makeNode({ stepIndex: 1, status: "running", live: makeLive() });
240
+ const second = makeNode({ stepIndex: 1, status: "running" });
241
+ const live = new Map([[1, makeLiveView()]]);
255
242
  const a = makeRun({ nodes: [first, second] });
256
243
  const b = makeRun({ nodes: [second, first] });
257
- expect(computeRenderSignature(a, T0)).not.toBe(computeRenderSignature(b, T0));
244
+ expect(computeRenderSignature(a, live, T0)).not.toBe(computeRenderSignature(b, live, T0));
258
245
  });
259
246
 
260
247
  it("无节点 run 签名仅含 run 级五段(status/秒桶/completed-total/budget/errorLogs)", () => {
261
- const sig = computeRenderSignature(makeRun({ nodes: [] }), T0);
248
+ const sig = computeRenderSignature(makeRun({ nodes: [] }), new Map(), T0);
262
249
  expect(sig.split("|")).toHaveLength(5);
263
250
  });
264
251
  });
@@ -0,0 +1,234 @@
1
+ /**
2
+ * [H2 W3] store record live 进度投影单测(设计 D2 进度源切换)。
3
+ *
4
+ * 三组面:
5
+ * 1. 双路径对齐(S1 等价表依据):同一 ExecutionRecord(同构造事件序列累积出的
6
+ * turns 形态)→ 旧投影 projectLiveProgress(record) + getAllToolCalls(record).length
7
+ * vs 新投影 projectRecordProgress(SubagentRecord 投影面)。逐字段断言相等
8
+ * ——「数据源换 store、字段口径不变」的可证伪锁定。
9
+ * 2. 派生恒等:toolCallCount(eventLog tool_start 计数)=== getAllToolCalls 长度
10
+ * (getEventLog 对每个 toolCall 恰产一条 tool_start);lastError(eventLog 末条
11
+ * error label)=== record.lastError(getEventLog 仅 lastError 非空时追加末条)。
12
+ * 3. collectNodeLiveProgress 配对:task 标签匹配 + startedAt 最近邻 + 贪心唯一;
13
+ * 终态 node 不配;无匹配 record(重试间隙)返回空。
14
+ *
15
+ * ExecutionRecord 经 duck-typed 构造(对齐 WorkflowsView-signature.test.ts 先例);
16
+ * 新路径输入 SubagentRecord 的 eventLog/currentActivity/turns/totalTokens 取自
17
+ * recordToSubagent 的同源投影(getEventLog/getCurrentActivity/turnCount/totalTokens
18
+ * ——其投影恒等由 core 侧 record-store 测试锁定,此处消费投影面)。
19
+ */
20
+ import { describe, it, expect } from "vitest";
21
+
22
+ import { computeElapsedSeconds, getAllToolCalls, projectLiveProgress } from "@zhushanwen/subagent-core";
23
+ import type { ExecutionRecord, SubagentRecord, WorkflowRun } from "@zhushanwen/subagent-core";
24
+ import { collectNodeLiveProgress } from "../WorkflowsView.ts";
25
+ import { projectRecordProgress } from "../detail-content.ts";
26
+
27
+ // ── Fixtures ──────────────────────────────────────────────────
28
+
29
+ const T0 = 1_700_000_000_000;
30
+
31
+ /** duck-typed ExecutionRecord(turns 形态对齐 updateFromEvent 累积产物)。 */
32
+ function makeRecord(overrides: Partial<ExecutionRecord> = {}): ExecutionRecord {
33
+ return {
34
+ id: "sa-x",
35
+ agent: "worker",
36
+ model: "default",
37
+ thinkingLevel: undefined,
38
+ mode: "background",
39
+ task: "调研 A",
40
+ slug: "research-a",
41
+ startedAt: T0,
42
+ rootSessionId: undefined,
43
+ parentRecordId: undefined,
44
+ depth: 0,
45
+ status: "running",
46
+ turns: [],
47
+ turnCount: 0,
48
+ totalTokens: 0,
49
+ lastError: undefined,
50
+ endedAt: undefined,
51
+ result: undefined,
52
+ error: undefined,
53
+ agentResult: undefined,
54
+ ...overrides,
55
+ } as unknown as ExecutionRecord;
56
+ }
57
+
58
+ /** recordToSubagent 同源投影:SubagentRecord 的实时字段面(eventLog/currentActivity
59
+ * 在真实链经 getEventLog/getCurrentActivity 派生,此处消费旧投影输出(同一函数产物)。 */
60
+ function toSubagentFace(record: ExecutionRecord, oldView: ReturnType<typeof projectLiveProgress>): SubagentRecord {
61
+ return {
62
+ id: record.id,
63
+ agent: record.agent,
64
+ task: record.task,
65
+ slug: record.slug,
66
+ status: record.status,
67
+ mode: record.mode,
68
+ startedAt: record.startedAt,
69
+ rootSessionId: undefined,
70
+ parentRecordId: undefined,
71
+ depth: 0,
72
+ endedAt: record.endedAt,
73
+ turns: oldView.turns,
74
+ totalTokens: oldView.totalTokens,
75
+ model: record.model,
76
+ thinkingLevel: undefined,
77
+ eventLog: oldView.eventLog,
78
+ displayItems: [],
79
+ currentActivity: oldView.currentActivity,
80
+ result: undefined,
81
+ error: undefined,
82
+ } as SubagentRecord;
83
+ }
84
+
85
+ /** 双 turn + running tool + lastError 的复合形态(覆盖七字段全维度)。 */
86
+ function compositeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
87
+ return makeRecord({
88
+ turnCount: 2,
89
+ totalTokens: 12345,
90
+ lastError: "EPIPE: broken pipe",
91
+ turns: [
92
+ {
93
+ text: "turn one done",
94
+ thinking: "",
95
+ closed: true,
96
+ closedTs: T0 + 1000,
97
+ toolCalls: [
98
+ { toolName: "read", args: { path: "/a/b.ts" }, result: "ok", isError: false, _status: "completed", startedTs: T0 + 100 },
99
+ { toolName: "bash", args: { command: "ls" }, result: "out", isError: false, _status: "completed", startedTs: T0 + 300 },
100
+ ],
101
+ usageDelta: undefined,
102
+ },
103
+ {
104
+ text: "",
105
+ thinking: "planning next",
106
+ closed: false,
107
+ toolCalls: [
108
+ { toolName: "write", args: { path: "/c.ts" }, result: "", isError: false, _status: "running", startedTs: T0 + 2000 },
109
+ ],
110
+ usageDelta: undefined,
111
+ },
112
+ ],
113
+ ...over,
114
+ });
115
+ }
116
+
117
+ // ── 1. 双路径对齐 ─────────────────────────────────────────────
118
+
119
+ describe("projectRecordProgress × projectLiveProgress 双路径对齐", () => {
120
+ it("复合形态(双 turn + running tool + lastError)七字段逐项相等", () => {
121
+ const record = compositeRecord();
122
+ const oldView = projectLiveProgress(record);
123
+ const newView = projectRecordProgress(toSubagentFace(record, oldView));
124
+
125
+ expect(newView.totalTokens).toBe(oldView.totalTokens);
126
+ expect(newView.toolCallCount).toBe(getAllToolCalls(record).length); // 工具计数恒等(旧路径口径)
127
+ expect(newView.elapsedSeconds).toBe(oldView.elapsedSeconds);
128
+ expect(newView.turns).toBe(oldView.turns);
129
+ expect(newView.eventLog).toEqual(oldView.eventLog);
130
+ expect(newView.currentActivity).toEqual(oldView.currentActivity);
131
+ expect(newView.lastError).toBe(oldView.lastError);
132
+ });
133
+
134
+ it("空形态(零 turn 零 token)两边同退化为零值", () => {
135
+ const record = makeRecord();
136
+ const oldView = projectLiveProgress(record);
137
+ const newView = projectRecordProgress(toSubagentFace(record, oldView));
138
+ expect(newView.totalTokens).toBe(0);
139
+ expect(newView.toolCallCount).toBe(0);
140
+ expect(newView.turns).toBe(0);
141
+ expect(newView.eventLog).toEqual([]);
142
+ expect(newView.currentActivity).toBeUndefined();
143
+ expect(newView.lastError).toBeUndefined();
144
+ });
145
+
146
+ it("elapsedSeconds 同源:endedAt 固定时两边都由 computeElapsedSeconds 现算", () => {
147
+ const record = compositeRecord({ endedAt: T0 + 9000 });
148
+ const oldView = projectLiveProgress(record);
149
+ const newView = projectRecordProgress(toSubagentFace(record, oldView));
150
+ const direct = computeElapsedSeconds({ startedAt: T0, endedAt: T0 + 9000 });
151
+ expect(newView.elapsedSeconds).toBe(direct);
152
+ expect(oldView.elapsedSeconds).toBe(direct);
153
+ });
154
+ });
155
+
156
+ // ── 2. 派生恒等(构造性论证的行为锁定) ────────────────────────
157
+
158
+ describe("projectRecordProgress 派生恒等", () => {
159
+ it("toolCallCount = eventLog tool_start 计数 = getAllToolCalls 长度", () => {
160
+ const record = compositeRecord();
161
+ const oldView = projectLiveProgress(record);
162
+ const starts = oldView.eventLog.filter((e) => e.type === "tool_start").length;
163
+ expect(starts).toBe(3); // read + bash + write
164
+ expect(projectRecordProgress(toSubagentFace(record, oldView)).toolCallCount).toBe(getAllToolCalls(record).length);
165
+ });
166
+
167
+ it("lastError 从 eventLog 末条 error 派生;无 error 条目 → undefined", () => {
168
+ const withErr = compositeRecord();
169
+ const oldErr = projectLiveProgress(withErr);
170
+ expect(oldErr.eventLog.at(-1)?.type).toBe("error"); // 前置:getEventLog 把 lastError 追加为末条
171
+ expect(projectRecordProgress(toSubagentFace(withErr, oldErr)).lastError).toBe("EPIPE: broken pipe");
172
+
173
+ const noErr = compositeRecord({ lastError: undefined });
174
+ const oldNo = projectLiveProgress(noErr);
175
+ expect(oldNo.eventLog.some((e) => e.type === "error")).toBe(false);
176
+ expect(projectRecordProgress(toSubagentFace(noErr, oldNo)).lastError).toBeUndefined();
177
+ });
178
+ });
179
+
180
+ // ── 3. collectNodeLiveProgress 配对(record ↔ trace node) ─────
181
+
182
+ function makeRunShape(nodes: { stepIndex: number; task: string; status: string; startedAt: string }[]): WorkflowRun {
183
+ return {
184
+ state: { trace: { toArray: () => nodes } },
185
+ } as unknown as WorkflowRun;
186
+ }
187
+
188
+ function makeSub(over: Partial<SubagentRecord> = {}): SubagentRecord {
189
+ return {
190
+ id: "sa-match",
191
+ task: "调研 A",
192
+ status: "running",
193
+ startedAt: T0,
194
+ turns: 0,
195
+ totalTokens: 0,
196
+ eventLog: [],
197
+ ...over,
198
+ } as SubagentRecord;
199
+ }
200
+
201
+ describe("collectNodeLiveProgress 配对", () => {
202
+ it("task 全等匹配:running node ↔ running record,投影挂到 stepIndex", () => {
203
+ const run = makeRunShape([{ stepIndex: 0, task: "调研 A", status: "running", startedAt: new Date(T0).toISOString() }]);
204
+ const live = collectNodeLiveProgress(run, [makeSub({ totalTokens: 42 })]);
205
+ expect(live.get(0)?.totalTokens).toBe(42);
206
+ });
207
+
208
+ it("多候选(parallel 同 prompt)取 startedAt 最近邻,贪心一一不重复消费", () => {
209
+ const run = makeRunShape([
210
+ { stepIndex: 0, task: "调研 A", status: "running", startedAt: new Date(T0).toISOString() },
211
+ { stepIndex: 1, task: "调研 A", status: "running", startedAt: new Date(T0 + 5000).toISOString() },
212
+ ]);
213
+ const near0 = makeSub({ id: "sa-near0", startedAt: T0 + 10, totalTokens: 100 });
214
+ const near1 = makeSub({ id: "sa-near1", startedAt: T0 + 5010, totalTokens: 200 });
215
+ const live = collectNodeLiveProgress(run, [near1, near0]);
216
+ expect(live.get(0)?.totalTokens).toBe(100); // node0(startedAt=T0) 最近 = near0
217
+ expect(live.get(1)?.totalTokens).toBe(200); // node1(T0+5s) 最近 = near1
218
+ });
219
+
220
+ it("终态 node 不配对;task 不匹配(不同 prompt)不配对", () => {
221
+ const run = makeRunShape([
222
+ { stepIndex: 0, task: "调研 A", status: "completed", startedAt: new Date(T0).toISOString() },
223
+ { stepIndex: 1, task: "写总结", status: "running", startedAt: new Date(T0).toISOString() },
224
+ ]);
225
+ const live = collectNodeLiveProgress(run, [makeSub({ task: "调研 A", totalTokens: 99 })]);
226
+ expect(live.size).toBe(0); // node0 终态跳过;node1 task 不匹配
227
+ });
228
+
229
+ it("重试间隙(无 running record)→ 空 map(views 走终态 fallback 渲染)", () => {
230
+ const run = makeRunShape([{ stepIndex: 0, task: "调研 A", status: "running", startedAt: new Date(T0).toISOString() }]);
231
+ const live = collectNodeLiveProgress(run, [makeSub({ status: "closed" })]);
232
+ expect(live.size).toBe(0);
233
+ });
234
+ });