@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.
- package/package.json +4 -4
- package/src/index.ts +26 -10
- package/src/injectors/__tests__/engine-section-stability.test.ts +0 -3
- package/src/interface/commands.ts +14 -3
- package/src/interface/format.ts +16 -0
- package/src/interface/subagent-tool-schema.ts +7 -0
- package/src/interface/subagent-tool.ts +2 -2
- package/src/interface/subagents.ts +1 -1
- package/src/interface/tool-workflow.ts +4 -2
- package/src/interface/views/WorkflowsView.ts +127 -47
- package/src/interface/views/__tests__/WorkflowsView-signature.test.ts +82 -95
- package/src/interface/views/__tests__/record-progress.test.ts +234 -0
- package/src/interface/views/detail-content.ts +72 -26
- package/src/jsonl-run-store.ts +1 -1
- package/src/session-lifecycle.ts +23 -7
|
@@ -6,14 +6,22 @@
|
|
|
6
6
|
* 字段。本测试证明「已入字段变化 → 签名变」+「静态 run 不变」(完备性无法靠
|
|
7
7
|
* 测试证明,字段核对表见 WorkflowsView.ts computeRenderSignature doc)。
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
}
|
|
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
|
|
90
|
-
const run
|
|
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
|
|
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 — 节点级字段(
|
|
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 =
|
|
160
|
-
const b =
|
|
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("
|
|
165
|
-
const
|
|
166
|
-
|
|
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 变化 →
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
const
|
|
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 =
|
|
191
|
-
const b =
|
|
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 追加(
|
|
196
|
-
const
|
|
197
|
-
|
|
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 =
|
|
209
|
-
const
|
|
210
|
-
|
|
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(
|
|
220
|
-
|
|
221
|
-
|
|
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 =
|
|
226
|
-
const b =
|
|
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"
|
|
227
|
+
makeNode({ stepIndex: 1, status: "running" }),
|
|
243
228
|
],
|
|
244
229
|
});
|
|
245
|
-
const
|
|
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"
|
|
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
|
+
});
|