@zhushanwen/pi-subagent-workflow 0.1.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.
Files changed (143) hide show
  1. package/agents/context-builder.md +17 -0
  2. package/agents/general-purpose.md +16 -0
  3. package/agents/oracle.md +17 -0
  4. package/agents/planner.md +17 -0
  5. package/agents/researcher.md +17 -0
  6. package/agents/reviewer.md +17 -0
  7. package/agents/scout.md +17 -0
  8. package/agents/worker.md +16 -0
  9. package/examples/README.md +43 -0
  10. package/examples/chain.example.js +92 -0
  11. package/examples/map-reduce.example.js +99 -0
  12. package/examples/parallel.example.js +82 -0
  13. package/examples/scatter-gather.example.js +106 -0
  14. package/index.ts +1 -0
  15. package/package.json +66 -0
  16. package/skills/workflow-script-format/SKILL.md +328 -0
  17. package/src/execution/__tests__/agent-registry.test.ts +164 -0
  18. package/src/execution/__tests__/agent-result-mapper.test.ts +128 -0
  19. package/src/execution/__tests__/alive-store.test.ts +147 -0
  20. package/src/execution/__tests__/bg-notify-render.test.ts +256 -0
  21. package/src/execution/__tests__/concurrency-pool.test.ts +217 -0
  22. package/src/execution/__tests__/config.test.ts +110 -0
  23. package/src/execution/__tests__/crash-recovery.test.ts +311 -0
  24. package/src/execution/__tests__/execute-nesting.test.ts +359 -0
  25. package/src/execution/__tests__/execute-options-mapper.test.ts +138 -0
  26. package/src/execution/__tests__/execution-record.test.ts +959 -0
  27. package/src/execution/__tests__/finalized-marker.test.ts +82 -0
  28. package/src/execution/__tests__/format-schema-instruction.test.ts +135 -0
  29. package/src/execution/__tests__/format.test.ts +320 -0
  30. package/src/execution/__tests__/helpers/mock-extension-api.ts +30 -0
  31. package/src/execution/__tests__/list-component.test.ts +347 -0
  32. package/src/execution/__tests__/model-resolver.test.ts +356 -0
  33. package/src/execution/__tests__/output-collector.test.ts +61 -0
  34. package/src/execution/__tests__/path-encoding.test.ts +75 -0
  35. package/src/execution/__tests__/pi-invocation.test.ts +73 -0
  36. package/src/execution/__tests__/record-store.test.ts +545 -0
  37. package/src/execution/__tests__/run-spawn-edges.test.ts +439 -0
  38. package/src/execution/__tests__/run-spawn-integration.test.ts +897 -0
  39. package/src/execution/__tests__/sdk-contract.test.ts +272 -0
  40. package/src/execution/__tests__/session-context-resolver.test.ts +167 -0
  41. package/src/execution/__tests__/session-file-gc.test.ts +247 -0
  42. package/src/execution/__tests__/session-reconstructor.test.ts +359 -0
  43. package/src/execution/__tests__/session-runner-schema-env.test.ts +314 -0
  44. package/src/execution/__tests__/session-start-reaper.test.ts +227 -0
  45. package/src/execution/__tests__/spawn-args.test.ts +244 -0
  46. package/src/execution/__tests__/spawn-event-adapter.test.ts +167 -0
  47. package/src/execution/__tests__/subagent-service.test.ts +678 -0
  48. package/src/execution/__tests__/subprocess-agent-runner.test.ts +389 -0
  49. package/src/execution/__tests__/temp-prompt.test.ts +53 -0
  50. package/src/execution/__tests__/timeout-integration.test.ts +381 -0
  51. package/src/execution/__tests__/tombstone-store.test.ts +73 -0
  52. package/src/execution/__tests__/tool-action.test.ts +330 -0
  53. package/src/execution/__tests__/turn-limiter.test.ts +65 -0
  54. package/src/execution/__tests__/worktree-manager.test.ts +423 -0
  55. package/src/execution/__tests__/worktree-registry.test.ts +161 -0
  56. package/src/execution/agent-registry.ts +252 -0
  57. package/src/execution/agent-result-mapper.ts +84 -0
  58. package/src/execution/alive-store.ts +92 -0
  59. package/src/execution/best-effort.ts +30 -0
  60. package/src/execution/concurrency-pool.ts +84 -0
  61. package/src/execution/config.ts +73 -0
  62. package/src/execution/execute-options-mapper.ts +86 -0
  63. package/src/execution/execution-record.ts +778 -0
  64. package/src/execution/finalized-marker.ts +51 -0
  65. package/src/execution/model-config-service.ts +225 -0
  66. package/src/execution/model-resolver.ts +247 -0
  67. package/src/execution/notifier.ts +168 -0
  68. package/src/execution/output-collector.ts +88 -0
  69. package/src/execution/path-encoding.ts +34 -0
  70. package/src/execution/pi-invocation.ts +70 -0
  71. package/src/execution/record-store.ts +350 -0
  72. package/src/execution/session-context-resolver.ts +64 -0
  73. package/src/execution/session-file-gc.ts +98 -0
  74. package/src/execution/session-reconstructor.ts +450 -0
  75. package/src/execution/session-runner.ts +725 -0
  76. package/src/execution/spawn-event-adapter.ts +150 -0
  77. package/src/execution/subagent-service.ts +973 -0
  78. package/src/execution/subprocess-agent-runner.ts +108 -0
  79. package/src/execution/temp-prompt.ts +57 -0
  80. package/src/execution/tombstone-store.ts +72 -0
  81. package/src/execution/turn-limiter.ts +88 -0
  82. package/src/execution/types.ts +634 -0
  83. package/src/execution/worktree-manager.ts +285 -0
  84. package/src/execution/worktree-registry.ts +144 -0
  85. package/src/index.ts +454 -0
  86. package/src/interface/bg-notify-render.ts +286 -0
  87. package/src/interface/commands.ts +157 -0
  88. package/src/interface/format.ts +501 -0
  89. package/src/interface/gui-adapter.ts +136 -0
  90. package/src/interface/helpers.ts +110 -0
  91. package/src/interface/list-component.ts +643 -0
  92. package/src/interface/list-shared.ts +84 -0
  93. package/src/interface/list-view.ts +373 -0
  94. package/src/interface/reentry-guard.ts +30 -0
  95. package/src/interface/subagent-actions.ts +294 -0
  96. package/src/interface/subagent-tool.ts +294 -0
  97. package/src/interface/subagents.ts +30 -0
  98. package/src/interface/tool-render.ts +333 -0
  99. package/src/interface/tool-workflow-script.ts +351 -0
  100. package/src/interface/tool-workflow.ts +485 -0
  101. package/src/interface/views/WorkflowsView.ts +944 -0
  102. package/src/interface/views/detail-content.ts +298 -0
  103. package/src/interface/views/format.ts +320 -0
  104. package/src/orchestration/__tests__/concurrency-gate.test.ts +125 -0
  105. package/src/orchestration/__tests__/config-loader.test.ts +381 -0
  106. package/src/orchestration/__tests__/error-recovery-handlers.test.ts +332 -0
  107. package/src/orchestration/__tests__/error-recovery-workflow-call.test.ts +166 -0
  108. package/src/orchestration/__tests__/launcher-nested-workflow.test.ts +248 -0
  109. package/src/orchestration/__tests__/lifecycle.test.ts +385 -0
  110. package/src/orchestration/__tests__/script-lint.test.ts +347 -0
  111. package/src/orchestration/__tests__/worker-script-builder.test.ts +42 -0
  112. package/src/orchestration/__tests__/workflow-nesting-e2e.test.ts +319 -0
  113. package/src/orchestration/agent-opts-resolver.ts +128 -0
  114. package/src/orchestration/concurrency-gate.ts +69 -0
  115. package/src/orchestration/config-loader.ts +313 -0
  116. package/src/orchestration/error-recovery.ts +578 -0
  117. package/src/orchestration/execute-agent-call.ts +174 -0
  118. package/src/orchestration/jsonl-run-store.ts +292 -0
  119. package/src/orchestration/launcher.ts +368 -0
  120. package/src/orchestration/lifecycle.ts +373 -0
  121. package/src/orchestration/models/__tests__/budget.test.ts +367 -0
  122. package/src/orchestration/models/agent-call.ts +76 -0
  123. package/src/orchestration/models/budget.ts +148 -0
  124. package/src/orchestration/models/ports.ts +165 -0
  125. package/src/orchestration/models/run-runtime.ts +91 -0
  126. package/src/orchestration/models/run-spec.ts +54 -0
  127. package/src/orchestration/models/run-state.ts +44 -0
  128. package/src/orchestration/models/trace.ts +102 -0
  129. package/src/orchestration/models/types.ts +242 -0
  130. package/src/orchestration/models/workflow-run.ts +275 -0
  131. package/src/orchestration/models/workflow-script-registry.ts +32 -0
  132. package/src/orchestration/models/workflow-script.ts +90 -0
  133. package/src/orchestration/node-ops.ts +192 -0
  134. package/src/orchestration/script-lint.ts +387 -0
  135. package/src/orchestration/skill-discovery.ts +60 -0
  136. package/src/orchestration/worker-handle.ts +115 -0
  137. package/src/orchestration/worker-host.ts +93 -0
  138. package/src/orchestration/worker-script-builder.ts +281 -0
  139. package/src/orchestration/workflow-files.ts +85 -0
  140. package/src/orchestration/workflow-script-registry-impl.ts +128 -0
  141. package/src/shared/__tests__/resource-discovery.test.ts +226 -0
  142. package/src/shared/agent-event.ts +13 -0
  143. package/src/shared/resource-discovery.ts +535 -0
@@ -0,0 +1,959 @@
1
+ // src/__tests__/execution-record.test.ts
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import {
5
+ completeRecord,
6
+ computeElapsedSeconds,
7
+ createRecord,
8
+ extractLabelFromArgs,
9
+ getAllToolCalls,
10
+ getCurrentActivity,
11
+ getEventLog,
12
+ getFullText,
13
+ getTotalUsage,
14
+ markReconstructedStatus,
15
+ project,
16
+ projectLiveProgress,
17
+ snapshot,
18
+ tryTransition,
19
+ updateFromEvent,
20
+ } from "../execution-record.ts";
21
+ import type { AgentResult, ExecutionRecord, Turn } from "../types.ts";
22
+
23
+ // ── 常量(与源码 module-private 值对齐,测试用字面量)──
24
+ const TURN_SUMMARY_MAX = 80;
25
+
26
+ // ── 工厂 ──
27
+ function emptyTurn(): Turn {
28
+ return { text: "", thinking: "", toolCalls: [], usageDelta: undefined, closed: false, closedTs: undefined };
29
+ }
30
+
31
+ function makeRecord(over: Partial<ExecutionRecord> = {}): ExecutionRecord {
32
+ return {
33
+ id: "test-1",
34
+ agent: "worker",
35
+ model: "test-model",
36
+ thinkingLevel: undefined,
37
+ mode: "sync",
38
+ task: "test task",
39
+ startedAt: 1000,
40
+ status: "running",
41
+ turns: [emptyTurn()],
42
+ turnCount: 0,
43
+ totalTokens: 0,
44
+ lastError: undefined,
45
+ endedAt: undefined,
46
+ result: undefined,
47
+ error: undefined,
48
+ agentResult: undefined,
49
+ controller: undefined,
50
+ ...over,
51
+ };
52
+ }
53
+
54
+ const SAMPLE_RESULT: AgentResult = {
55
+ text: "done",
56
+ turns: 1,
57
+ durationMs: 500,
58
+ success: true,
59
+ sessionId: "sess-1",
60
+ toolCalls: [],
61
+ };
62
+
63
+ // ============================================================
64
+ // createRecord
65
+ // ============================================================
66
+ describe("createRecord", () => {
67
+ it("creates a record with identity fields frozen and defaults", () => {
68
+ const r = createRecord("r1", {
69
+ agent: "reviewer",
70
+ model: "m1",
71
+ thinkingLevel: "high",
72
+ mode: "background",
73
+ task: "review PR",
74
+ startedAt: 2000,
75
+ });
76
+ expect(r.id).toBe("r1");
77
+ expect(r.agent).toBe("reviewer");
78
+ expect(r.model).toBe("m1");
79
+ expect(r.thinkingLevel).toBe("high");
80
+ expect(r.mode).toBe("background");
81
+ expect(r.task).toBe("review PR");
82
+ expect(r.startedAt).toBe(2000);
83
+
84
+ // defaults——turns[] 初始化为 [空 turn],turnCount=0
85
+ expect(r.status).toBe("running");
86
+ expect(r.turns).toHaveLength(1);
87
+ expect(r.turns[0]).toMatchObject({ text: "", thinking: "", toolCalls: [], closed: false });
88
+ expect(r.turnCount).toBe(0);
89
+ expect(r.totalTokens).toBe(0);
90
+ expect(r.lastError).toBeUndefined();
91
+ expect(r.endedAt).toBeUndefined();
92
+ expect(r.result).toBeUndefined();
93
+ expect(r.error).toBeUndefined();
94
+ expect(r.agentResult).toBeUndefined();
95
+ });
96
+
97
+ it("stores controller when provided (background)", () => {
98
+ const controller = new AbortController();
99
+ const r = createRecord("r1", {
100
+ agent: "w", model: "m", mode: "background", task: "t", startedAt: 0, controller,
101
+ });
102
+ expect(r.controller).toBe(controller);
103
+ });
104
+
105
+ it("stores rootSessionId when provided", () => {
106
+ const r = createRecord("r1", {
107
+ agent: "w", model: "m", mode: "sync", task: "t", startedAt: 0, rootSessionId: "sess-A",
108
+ });
109
+ expect(r.rootSessionId).toBe("sess-A");
110
+ });
111
+
112
+ it("defaults rootSessionId to undefined when omitted", () => {
113
+ const r = createRecord("r1", {
114
+ agent: "w", model: "m", mode: "sync", task: "t", startedAt: 0,
115
+ });
116
+ expect(r.rootSessionId).toBeUndefined();
117
+ });
118
+ });
119
+
120
+ // ============================================================
121
+ // updateFromEvent — turns accumulation
122
+ // ============================================================
123
+ describe("updateFromEvent", () => {
124
+ describe("turnCount accumulation", () => {
125
+ it("increments turnCount on turn_end", () => {
126
+ const r = makeRecord();
127
+ updateFromEvent(r, { type: "turn_end", summary: "done" });
128
+ expect(r.turnCount).toBe(1);
129
+ updateFromEvent(r, { type: "turn_end" });
130
+ expect(r.turnCount).toBe(2);
131
+ });
132
+
133
+ it("does not increment turnCount on other events", () => {
134
+ const r = makeRecord();
135
+ updateFromEvent(r, { type: "text_delta", delta: "hi" });
136
+ updateFromEvent(r, { type: "tool_start", toolName: "read" });
137
+ updateFromEvent(r, { type: "message_end" });
138
+ expect(r.turnCount).toBe(0);
139
+ });
140
+ });
141
+
142
+ describe("totalTokens accumulation", () => {
143
+ it("sums all usage fields on message_end", () => {
144
+ const r = makeRecord();
145
+ updateFromEvent(r, {
146
+ type: "message_end",
147
+ usage: { input: 10, output: 20, cacheRead: 5, cacheWrite: 3 },
148
+ });
149
+ expect(r.totalTokens).toBe(38);
150
+ });
151
+
152
+ it("accumulates across multiple message_end events", () => {
153
+ const r = makeRecord();
154
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 1, cacheRead: 1, cacheWrite: 1 } });
155
+ updateFromEvent(r, { type: "message_end", usage: { input: 2, output: 2, cacheRead: 2, cacheWrite: 2 } });
156
+ expect(r.totalTokens).toBe(12);
157
+ });
158
+
159
+ it("ignores message_end without usage", () => {
160
+ const r = makeRecord();
161
+ updateFromEvent(r, { type: "message_end" });
162
+ expect(r.totalTokens).toBe(0);
163
+ });
164
+
165
+ it("stores usageDelta on current turn", () => {
166
+ const r = makeRecord();
167
+ updateFromEvent(r, {
168
+ type: "message_end",
169
+ usage: { input: 10, output: 20, cacheRead: 5, cacheWrite: 3 },
170
+ });
171
+ expect(r.turns[0]?.usageDelta).toEqual({ input: 10, output: 20, cacheRead: 5, cacheWrite: 3 });
172
+ });
173
+ });
174
+
175
+ // ============================================================
176
+ // text / thinking accumulation (替代旧 chunking)
177
+ // ============================================================
178
+ describe("text accumulation", () => {
179
+ it("accumulates text_delta into current turn.text (完整内容,非切片)", () => {
180
+ const r = makeRecord();
181
+ updateFromEvent(r, { type: "text_delta", delta: "Hello " });
182
+ updateFromEvent(r, { type: "text_delta", delta: "world" });
183
+ expect(r.turns[0]?.text).toBe("Hello world");
184
+ });
185
+
186
+ it("text accumulation survives long delta (>100 chars, no chunking)", () => {
187
+ const r = makeRecord();
188
+ const longText = "y".repeat(350);
189
+ updateFromEvent(r, { type: "text_delta", delta: longText });
190
+ // 完整存储,不切片——这是收口设计的核心
191
+ expect(r.turns[0]?.text).toBe(longText);
192
+ expect(r.turns).toHaveLength(1);
193
+ });
194
+ });
195
+
196
+ describe("thinking accumulation", () => {
197
+ it("accumulates thinking_delta into current turn.thinking (完整内容)", () => {
198
+ const r = makeRecord();
199
+ updateFromEvent(r, { type: "thinking_delta", delta: "Analyzing " });
200
+ updateFromEvent(r, { type: "thinking_delta", delta: "the problem" });
201
+ expect(r.turns[0]?.thinking).toBe("Analyzing the problem");
202
+ });
203
+ });
204
+
205
+ describe("turn boundary", () => {
206
+ it("turn_end closes current turn; next delta opens new turn", () => {
207
+ const r = makeRecord();
208
+ updateFromEvent(r, { type: "text_delta", delta: "turn 1 text" });
209
+ updateFromEvent(r, { type: "turn_end" });
210
+ expect(r.turns[0]?.closed).toBe(true);
211
+ expect(r.turnCount).toBe(1);
212
+
213
+ // 新 delta 开新 turn
214
+ updateFromEvent(r, { type: "text_delta", delta: "turn 2 text" });
215
+ expect(r.turns).toHaveLength(2);
216
+ expect(r.turns[1]?.closed).toBe(false);
217
+ expect(r.turns[1]?.text).toBe("turn 2 text");
218
+ // turn 1 不受影响
219
+ expect(r.turns[0]?.text).toBe("turn 1 text");
220
+ });
221
+
222
+ it("turn_end closes turn and records closedTs (real wall-clock)", () => {
223
+ const r = makeRecord();
224
+ updateFromEvent(r, { type: "text_delta", delta: "partial" });
225
+ const before = Date.now();
226
+ updateFromEvent(r, { type: "turn_end" });
227
+ const after = Date.now();
228
+ expect(r.turns[0]?.closed).toBe(true);
229
+ expect(r.turns[0]?.closedTs).toBeGreaterThanOrEqual(before);
230
+ expect(r.turns[0]?.closedTs).toBeLessThanOrEqual(after);
231
+ // turn_end 不再覆盖已累积的 text(旧 dead branch 已移除)
232
+ expect(r.turns[0]?.text).toBe("partial");
233
+ expect(r.turnCount).toBe(1);
234
+ });
235
+
236
+ it("turn_end clears lastError (transient error recovery → success)", () => {
237
+ // 瞬态 error 到达后,若 turn 正常闭合,lastError 应清空——
238
+ // 否则 session-runner 会据残留 lastError 把成功误判为 success=false。
239
+ const r = makeRecord();
240
+ updateFromEvent(r, { type: "error", message: "transient" });
241
+ expect(r.lastError).toBe("transient");
242
+ updateFromEvent(r, { type: "turn_end" });
243
+ expect(r.lastError).toBeUndefined();
244
+ });
245
+
246
+ it("turn_end after turn_end: next delta opens 3rd turn (not mutate 2nd)", () => {
247
+ // 连续两次 turn_end 后再发 text_delta,应 push 第 3 个 turn,
248
+ // 而非回填已 closed 的第 2 个空 turn。
249
+ const r = makeRecord();
250
+ updateFromEvent(r, { type: "text_delta", delta: "t1" });
251
+ updateFromEvent(r, { type: "turn_end" });
252
+ updateFromEvent(r, { type: "turn_end" }); // 第 2 个空 turn 立即 closed
253
+ updateFromEvent(r, { type: "text_delta", delta: "t3" });
254
+ expect(r.turns).toHaveLength(3);
255
+ expect(r.turns[0]?.text).toBe("t1");
256
+ expect(r.turns[1]?.text).toBe(""); // 第 2 个空 turn 未被回填
257
+ expect(r.turns[2]?.text).toBe("t3");
258
+ });
259
+ });
260
+
261
+ // ============================================================
262
+ // tool events → turn.toolCalls
263
+ // ============================================================
264
+ describe("tool events", () => {
265
+ it("tool_start pushes a running ToolCall into current turn", () => {
266
+ const r = makeRecord();
267
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a/b/foo.ts" } });
268
+ expect(r.turns[0]?.toolCalls).toHaveLength(1);
269
+ expect(r.turns[0]?.toolCalls[0]).toMatchObject({
270
+ toolName: "read",
271
+ _status: "running",
272
+ });
273
+ });
274
+
275
+ it("tool_end matches back running toolCall and sets result/status", () => {
276
+ const r = makeRecord();
277
+ updateFromEvent(r, { type: "tool_start", toolName: "bash", args: { command: "ls" } });
278
+ const result = { content: [{ type: "text", text: "file.ts" }] };
279
+ updateFromEvent(r, { type: "tool_end", toolName: "bash", args: { command: "ls" }, result });
280
+ const tc = r.turns[0]?.toolCalls[0];
281
+ expect(tc).toMatchObject({ toolName: "bash", _status: "done", isError: false });
282
+ expect(tc?.result).toBe(result);
283
+ });
284
+
285
+ it("tool_end sets failed status when isError", () => {
286
+ const r = makeRecord();
287
+ updateFromEvent(r, { type: "tool_start", toolName: "bash", args: { command: "rm" } });
288
+ updateFromEvent(r, { type: "tool_end", toolName: "bash", args: { command: "rm" }, isError: true });
289
+ expect(r.turns[0]?.toolCalls[0]?._status).toBe("failed");
290
+ expect(r.turns[0]?.toolCalls[0]?.isError).toBe(true);
291
+ });
292
+
293
+ it("tool_end without matching tool_start pushes a completed ToolCall", () => {
294
+ const r = makeRecord();
295
+ updateFromEvent(r, { type: "tool_end", toolName: "external", args: {} });
296
+ expect(r.turns[0]?.toolCalls).toHaveLength(1);
297
+ expect(r.turns[0]?.toolCalls[0]?._status).toBe("done");
298
+ });
299
+
300
+ it("LIFO matching: same-name tool twice, tool_end matches last running", () => {
301
+ // 同 turn 内两次 tool_start: bash → tool_end: bash 倒序匹配最后一个 running。
302
+ // 正序匹配会错误地把 result 填到第一个 bash,留下第二个 running。
303
+ const r = makeRecord();
304
+ updateFromEvent(r, { type: "tool_start", toolName: "bash", args: { command: "cmd-a" } });
305
+ updateFromEvent(r, { type: "tool_start", toolName: "bash", args: { command: "cmd-b" } });
306
+ const resultA = { content: [{ type: "text", text: "A" }] };
307
+ updateFromEvent(r, { type: "tool_end", toolName: "bash", args: { command: "cmd-b" }, result: resultA });
308
+ const calls = r.turns[0]?.toolCalls ?? [];
309
+ expect(calls).toHaveLength(2);
310
+ // 第二个 bash(cmd-b)命中 LIFO,标记 done + result=A
311
+ expect(calls[1]?._status).toBe("done");
312
+ expect(calls[1]?.result).toBe(resultA);
313
+ // 第一个 bash(cmd-a)仍未匹配,仍 running
314
+ expect(calls[0]?._status).toBe("running");
315
+ expect(calls[0]?.result).toBeUndefined();
316
+ });
317
+
318
+ it("tool_end without result leaves result undefined (SDK may omit result)", () => {
319
+ // SDK 契约下 tool_end 的 result 可为 undefined——不应抛错,getEventLog 仍正常派生。
320
+ const r = makeRecord();
321
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
322
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
323
+ expect(r.turns[0]?.toolCalls[0]?.result).toBeUndefined();
324
+ expect(r.turns[0]?.toolCalls[0]?._status).toBe("done");
325
+ // getEventLog 仍能派生 tool_start/tool_end 对
326
+ const log = getEventLog(r);
327
+ expect(log.map((e) => e.type)).toEqual(["tool_start", "tool_end", "turn_end"].slice(0, 2));
328
+ });
329
+
330
+ it("tool_end matches running toolCall across turns (lagged SDK event)", () => {
331
+ // SDK 在 turn_end 后仍可能补发滞后的 tool_end——跨 turn 扫描兜底,
332
+ // 不误 push 幽灵 ToolCall。
333
+ const r = makeRecord();
334
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
335
+ updateFromEvent(r, { type: "turn_end" }); // turn[0] closed,read 仍 running
336
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
337
+ // 匹配到 turn[0] 的 read(跨 turn 扫描命中),未产生幽灵 ToolCall
338
+ expect(r.turns).toHaveLength(1); // 没有 turn[1]——tool_end 单独不开新 turn
339
+ expect(r.turns[0]?.toolCalls[0]?._status).toBe("done");
340
+ expect(r.turns[0]?.toolCalls).toHaveLength(1);
341
+ });
342
+ });
343
+
344
+ // ============================================================
345
+ // error event → record.lastError
346
+ // ============================================================
347
+ describe("error event", () => {
348
+ it("stores error message in record.lastError", () => {
349
+ const r = makeRecord();
350
+ updateFromEvent(r, { type: "error", message: "boom" });
351
+ expect(r.lastError).toBe("boom");
352
+ });
353
+
354
+ it("message_end with error field also sets lastError", () => {
355
+ const r = makeRecord();
356
+ updateFromEvent(r, { type: "message_end", error: "provider error" });
357
+ expect(r.lastError).toBe("provider error");
358
+ });
359
+ });
360
+
361
+ // ============================================================
362
+ // compaction (no-op)
363
+ // ============================================================
364
+ describe("compaction", () => {
365
+ it("compaction is a no-op", () => {
366
+ const r = makeRecord();
367
+ updateFromEvent(r, { type: "compaction" });
368
+ expect(r.turns).toHaveLength(1);
369
+ expect(r.turnCount).toBe(0);
370
+ expect(r.totalTokens).toBe(0);
371
+ });
372
+ });
373
+ });
374
+
375
+ // ============================================================
376
+ // getEventLog — 派生事件序列
377
+ // ============================================================
378
+ describe("getEventLog", () => {
379
+ it("returns empty array for fresh record (empty turn, not closed)", () => {
380
+ const r = makeRecord();
381
+ expect(getEventLog(r)).toEqual([]);
382
+ });
383
+
384
+ it("derives tool_start/tool_end pairs from turns[].toolCalls", () => {
385
+ const r = makeRecord();
386
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/x.ts" } });
387
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/x.ts" } });
388
+ const log = getEventLog(r);
389
+ expect(log.map((e) => e.type)).toEqual(["tool_start", "tool_end"]);
390
+ expect(log[0]).toMatchObject({ type: "tool_start", label: "read x.ts", status: "running" });
391
+ expect(log[1]).toMatchObject({ type: "tool_end", label: "read x.ts", status: "done" });
392
+ });
393
+
394
+ it("running toolCall (no tool_end yet) derives only tool_start", () => {
395
+ const r = makeRecord();
396
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/x.ts" } });
397
+ const log = getEventLog(r);
398
+ expect(log.map((e) => e.type)).toEqual(["tool_start"]);
399
+ });
400
+
401
+ it("derives turn_end after turn closes (label from turn text)", () => {
402
+ const r = makeRecord();
403
+ updateFromEvent(r, { type: "text_delta", delta: "Result is 42" });
404
+ updateFromEvent(r, { type: "turn_end" });
405
+ const log = getEventLog(r);
406
+ const turnEntry = log.find((e) => e.type === "turn_end");
407
+ expect(turnEntry?.label).toBe("Result is 42");
408
+ });
409
+
410
+ it("turn_end label defaults to 'turn' when turn has no text", () => {
411
+ const r = makeRecord();
412
+ updateFromEvent(r, { type: "turn_end" });
413
+ const log = getEventLog(r);
414
+ const turnEntry = log.find((e) => e.type === "turn_end");
415
+ expect(turnEntry?.label).toBe("turn");
416
+ });
417
+
418
+ it("truncates long turn text to TURN_SUMMARY_MAX in turn_end label", () => {
419
+ const r = makeRecord();
420
+ const longText = "s".repeat(TURN_SUMMARY_MAX + 20);
421
+ updateFromEvent(r, { type: "text_delta", delta: longText });
422
+ updateFromEvent(r, { type: "turn_end" });
423
+ const log = getEventLog(r);
424
+ const turnEntry = log.find((e) => e.type === "turn_end");
425
+ expect(turnEntry?.label.length).toBe(TURN_SUMMARY_MAX);
426
+ });
427
+
428
+ it("appends error entry when record.lastError is set", () => {
429
+ const r = makeRecord();
430
+ updateFromEvent(r, { type: "error", message: "crashed" });
431
+ const log = getEventLog(r);
432
+ expect(log[log.length - 1]).toMatchObject({ type: "error", label: "crashed" });
433
+ });
434
+
435
+ it("multi-turn: events ordered across turns", () => {
436
+ const r = makeRecord();
437
+ // turn 1: tool A + text
438
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
439
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
440
+ updateFromEvent(r, { type: "text_delta", delta: "done turn 1" });
441
+ updateFromEvent(r, { type: "turn_end" });
442
+ // turn 2: tool B
443
+ updateFromEvent(r, { type: "tool_start", toolName: "edit", args: { path: "/b.ts" } });
444
+ updateFromEvent(r, { type: "turn_end" });
445
+ const types = getEventLog(r).map((e) => e.type);
446
+ expect(types).toEqual([
447
+ "tool_start", "tool_end", "turn_end", // turn 1
448
+ "tool_start", "turn_end", // turn 2 (tool_start only, no tool_end)
449
+ ]);
450
+ });
451
+
452
+ it("uses real wall-clock ts (tool: startedTs, turn_end: closedTs)", () => {
453
+ // ts 不再是合成 +1,而是真实 Date.now()——消费方可按时序/时长分析。
454
+ const before = Date.now();
455
+ const r = makeRecord({ startedAt: before });
456
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
457
+ const afterToolStart = Date.now();
458
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
459
+ updateFromEvent(r, { type: "turn_end" });
460
+ const afterTurnEnd = Date.now();
461
+ const log = getEventLog(r);
462
+ const toolStartTs = log[0]?.ts;
463
+ const turnEndTs = log[2]?.ts;
464
+ expect(toolStartTs).toBeGreaterThanOrEqual(before);
465
+ expect(toolStartTs).toBeLessThanOrEqual(afterToolStart);
466
+ expect(turnEndTs).toBeGreaterThanOrEqual(afterToolStart);
467
+ expect(turnEndTs).toBeLessThanOrEqual(afterTurnEnd);
468
+ });
469
+ });
470
+
471
+ // ============================================================
472
+ // getCurrentActivity — 派生活动行
473
+ // ============================================================
474
+ describe("getCurrentActivity", () => {
475
+ it("returns undefined when status is not running", () => {
476
+ const r = makeRecord({ status: "done" });
477
+ expect(getCurrentActivity(r)).toBeUndefined();
478
+ });
479
+
480
+ it("returns undefined when turn is closed", () => {
481
+ const r = makeRecord();
482
+ updateFromEvent(r, { type: "turn_end" });
483
+ expect(getCurrentActivity(r)).toBeUndefined();
484
+ });
485
+
486
+ it("prefers running tool over thinking/text", () => {
487
+ const r = makeRecord();
488
+ updateFromEvent(r, { type: "tool_start", toolName: "edit", args: { path: "/a.ts" } });
489
+ r.turns[0]!.thinking = "thinking...";
490
+ r.turns[0]!.text = "text...";
491
+ expect(getCurrentActivity(r)).toEqual({ type: "tool", label: "edit a.ts" });
492
+ });
493
+
494
+ it("falls back to thinking when no running tool", () => {
495
+ const r = makeRecord();
496
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
497
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
498
+ r.turns[0]!.thinking = "pondering";
499
+ expect(getCurrentActivity(r)).toEqual({ type: "thinking", label: "pondering" });
500
+ });
501
+
502
+ it("falls back to text when no tool/thinking", () => {
503
+ const r = makeRecord();
504
+ r.turns[0]!.text = "writing output";
505
+ expect(getCurrentActivity(r)).toEqual({ type: "text", label: "writing output" });
506
+ });
507
+
508
+ it("text label takes START not tail fragment (regression: text: } bug)", () => {
509
+ // 原始 bug:compact view 显示流式文本的末尾碎片(如 "text: }")而非开头。
510
+ // getCurrentActivity 必须取 turn.text 开头——本测试在 bug 回归时会失败。
511
+ const r = makeRecord();
512
+ updateFromEvent(r, { type: "text_delta", delta: "Hello world this is the response start" });
513
+ updateFromEvent(r, { type: "text_delta", delta: " ... more content ... }" });
514
+ const activity = getCurrentActivity(r);
515
+ expect(activity?.type).toBe("text");
516
+ // label 以开头而非尾巴开始
517
+ expect(activity?.label.startsWith("Hello world")).toBe(true);
518
+ // 绝不以尾巴碎片开头
519
+ expect(activity?.label.startsWith("}")).toBe(false);
520
+ expect(activity?.label.startsWith(" ... more")).toBe(false);
521
+ });
522
+
523
+ it("truncates text label to ACTIVITY_LABEL_MAX (60)", () => {
524
+ const r = makeRecord();
525
+ const longText = "y".repeat(120);
526
+ r.turns[0]!.text = longText;
527
+ const activity = getCurrentActivity(r);
528
+ expect(activity?.label.length).toBe(60);
529
+ expect(activity?.label).toBe(longText.slice(0, 60));
530
+ });
531
+
532
+ it("returns undefined when idle (empty turn)", () => {
533
+ const r = makeRecord();
534
+ expect(getCurrentActivity(r)).toBeUndefined();
535
+ });
536
+ });
537
+
538
+ // ============================================================
539
+ // getFullText — 聚合文本
540
+ // ============================================================
541
+ describe("getFullText", () => {
542
+ it("returns empty string for fresh record", () => {
543
+ const r = makeRecord();
544
+ expect(getFullText(r)).toBe("");
545
+ });
546
+
547
+ it("returns single turn text", () => {
548
+ const r = makeRecord();
549
+ updateFromEvent(r, { type: "text_delta", delta: "Hello world" });
550
+ expect(getFullText(r)).toBe("Hello world");
551
+ });
552
+
553
+ it("joins multiple turns with double newline", () => {
554
+ const r = makeRecord();
555
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
556
+ updateFromEvent(r, { type: "turn_end" });
557
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 2" });
558
+ expect(getFullText(r)).toBe("Turn 1\n\nTurn 2");
559
+ });
560
+
561
+ it("skips empty turns", () => {
562
+ const r = makeRecord();
563
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 1" });
564
+ updateFromEvent(r, { type: "turn_end" });
565
+ updateFromEvent(r, { type: "turn_end" }); // 空 turn 2
566
+ updateFromEvent(r, { type: "text_delta", delta: "Turn 3" });
567
+ expect(getFullText(r)).toBe("Turn 1\n\nTurn 3");
568
+ });
569
+
570
+ it("aggregates multiple text_deltas within a single turn", () => {
571
+ const r = makeRecord();
572
+ updateFromEvent(r, { type: "text_delta", delta: "Hello " });
573
+ updateFromEvent(r, { type: "text_delta", delta: "world" });
574
+ updateFromEvent(r, { type: "text_delta", delta: "!" });
575
+ expect(getFullText(r)).toBe("Hello world!");
576
+ });
577
+ });
578
+
579
+ // ============================================================
580
+ // getAllToolCalls / getTotalUsage — 聚合派生
581
+ // ============================================================
582
+ describe("getAllToolCalls", () => {
583
+ it("flattens toolCalls across turns", () => {
584
+ const r = makeRecord();
585
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
586
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
587
+ updateFromEvent(r, { type: "turn_end" });
588
+ updateFromEvent(r, { type: "tool_start", toolName: "edit", args: { path: "/b.ts" } });
589
+ const calls = getAllToolCalls(r);
590
+ expect(calls).toHaveLength(2);
591
+ expect(calls.map((c) => c.toolName)).toEqual(["read", "edit"]);
592
+ });
593
+
594
+ it("strips internal _status / startedTs (exported shape is clean ToolCall)", () => {
595
+ // 导出的 ToolCall 不应泄漏内部状态机字段(_status / startedTs)——
596
+ // 这些是 execution-record 内部实现细节。
597
+ const r = makeRecord();
598
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
599
+ const calls = getAllToolCalls(r);
600
+ const tc = calls[0];
601
+ expect(tc).toBeDefined();
602
+ // 导出形状只有 4 个语义字段
603
+ expect(Object.keys(tc!).sort()).toEqual(["args", "isError", "result", "toolName"]);
604
+ // 内部字段不存在
605
+ expect((tc as Record<string, unknown>)._status).toBeUndefined();
606
+ expect((tc as Record<string, unknown>).startedTs).toBeUndefined();
607
+ });
608
+ });
609
+
610
+ describe("getTotalUsage", () => {
611
+ it("returns undefined when no usage", () => {
612
+ const r = makeRecord();
613
+ expect(getTotalUsage(r)).toBeUndefined();
614
+ });
615
+
616
+ it("aggregates usageDelta across turns", () => {
617
+ const r = makeRecord();
618
+ updateFromEvent(r, { type: "message_end", usage: { input: 10, output: 20, cacheRead: 5, cacheWrite: 3 } });
619
+ updateFromEvent(r, { type: "turn_end" });
620
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } });
621
+ const usage = getTotalUsage(r);
622
+ expect(usage).toEqual({ input: 11, output: 22, cacheRead: 5, cacheWrite: 3, total: 41, cost: 0 });
623
+ });
624
+
625
+ it("accumulates cost from message_end usage.cost", () => {
626
+ const r = makeRecord();
627
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: 0.5 } });
628
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0, cost: 0.25 } });
629
+ const usage = getTotalUsage(r);
630
+ expect(usage?.cost).toBe(0.75);
631
+ });
632
+
633
+ it("accumulates multiple message_end within same turn (no usage loss)", () => {
634
+ // 同 turn 内多次 message_end——usageDelta 按 field 累加(非覆盖),不丢 usage。
635
+ const r = makeRecord();
636
+ updateFromEvent(r, { type: "message_end", usage: { input: 10, output: 20, cacheRead: 5, cacheWrite: 3 } });
637
+ updateFromEvent(r, { type: "message_end", usage: { input: 1, output: 2, cacheRead: 0, cacheWrite: 0 } });
638
+ const usage = getTotalUsage(r);
639
+ expect(usage).toEqual({ input: 11, output: 22, cacheRead: 5, cacheWrite: 3, total: 41, cost: 0 });
640
+ });
641
+ });
642
+
643
+ // ============================================================
644
+ // tryTransition — CAS lock
645
+ // ============================================================
646
+ describe("tryTransition", () => {
647
+ it("returns true and sets status when transitioning from running", () => {
648
+ const r = makeRecord({ status: "running" });
649
+ expect(tryTransition(r, "done")).toBe(true);
650
+ expect(r.status).toBe("done");
651
+ });
652
+
653
+ it("returns false when already terminal (done)", () => {
654
+ const r = makeRecord({ status: "done" });
655
+ expect(tryTransition(r, "failed")).toBe(false);
656
+ expect(r.status).toBe("done");
657
+ });
658
+
659
+ it("returns false when already terminal (cancelled)", () => {
660
+ const r = makeRecord({ status: "cancelled" });
661
+ expect(tryTransition(r, "done")).toBe(false);
662
+ });
663
+
664
+ it("returns false when already terminal (failed)", () => {
665
+ const r = makeRecord({ status: "failed" });
666
+ expect(tryTransition(r, "done")).toBe(false);
667
+ });
668
+
669
+ it("first transition wins in concurrent race (running → done beats running → cancelled)", () => {
670
+ const r = makeRecord({ status: "running" });
671
+ expect(tryTransition(r, "done")).toBe(true);
672
+ expect(tryTransition(r, "cancelled")).toBe(false);
673
+ expect(r.status).toBe("done");
674
+ });
675
+
676
+ it("returns false when trying to transition from crashed to done", () => {
677
+ const r = makeRecord({ status: "crashed" });
678
+ expect(tryTransition(r, "done")).toBe(false);
679
+ expect(r.status).toBe("crashed");
680
+ });
681
+ });
682
+
683
+ describe("markReconstructedStatus", () => {
684
+ it("directly sets status without CAS check", () => {
685
+ const r = makeRecord({ status: "running" });
686
+ markReconstructedStatus(r, "crashed");
687
+ expect(r.status).toBe("crashed");
688
+ });
689
+
690
+ it("can overwrite terminal status (bypass CAS)", () => {
691
+ // 重建场景:旧 record 可能已有终态,重建时需要直接覆盖
692
+ const r = makeRecord({ status: "done" });
693
+ markReconstructedStatus(r, "crashed");
694
+ expect(r.status).toBe("crashed");
695
+ });
696
+
697
+ it("can overwrite running status", () => {
698
+ const r = makeRecord({ status: "running" });
699
+ markReconstructedStatus(r, "failed");
700
+ expect(r.status).toBe("failed");
701
+ });
702
+
703
+ it("can set crashed on running record", () => {
704
+ const r = makeRecord({ status: "running" });
705
+ markReconstructedStatus(r, "crashed");
706
+ expect(r.status).toBe("crashed");
707
+ });
708
+
709
+ it("can set crashed on done record (reconstruction override)", () => {
710
+ const r = makeRecord({ status: "done" });
711
+ markReconstructedStatus(r, "crashed");
712
+ expect(r.status).toBe("crashed");
713
+ });
714
+ });
715
+
716
+ // ============================================================
717
+ // completeRecord
718
+ // ============================================================
719
+ describe("completeRecord", () => {
720
+ it("writes outcome fields without resetting turnCount/totalTokens", () => {
721
+ const r = makeRecord({ turnCount: 5, totalTokens: 42 });
722
+ r.status = "done";
723
+ completeRecord(r, SAMPLE_RESULT, "done");
724
+ expect(r.status).toBe("done");
725
+ expect(r.endedAt).toBeTypeOf("number");
726
+ expect(r.agentResult).toBe(SAMPLE_RESULT);
727
+ expect(r.result).toBe("done");
728
+ expect(r.error).toBeUndefined();
729
+ expect(r.turnCount).toBe(5);
730
+ expect(r.totalTokens).toBe(42);
731
+ });
732
+
733
+ it("stores error from result", () => {
734
+ const r = makeRecord();
735
+ r.status = "failed";
736
+ const failedResult: AgentResult = { ...SAMPLE_RESULT, success: false, error: "oops" };
737
+ completeRecord(r, failedResult, "failed");
738
+ expect(r.error).toBe("oops");
739
+ });
740
+ });
741
+
742
+ // ============================================================
743
+ // project / snapshot — projections
744
+ // ============================================================
745
+ describe("projections", () => {
746
+ describe("project", () => {
747
+ it("returns SubagentToolDetails with all fields", () => {
748
+ const r = makeRecord({ turnCount: 3, totalTokens: 100 });
749
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/x.ts" } });
750
+ const d = project(r);
751
+ expect(d.status).toBe("running");
752
+ expect(d.agent).toBe("worker");
753
+ expect(d.model).toBe("test-model");
754
+ expect(d.turns).toBe(3);
755
+ expect(d.totalTokens).toBe(100);
756
+ expect(d.eventLog).toHaveLength(1); // tool_start derived
757
+ expect(d.currentActivity).toEqual({ type: "tool", label: "read x.ts" });
758
+ });
759
+
760
+ it("eventLog is a fresh array each call (派生,非存储引用)", () => {
761
+ const r = makeRecord();
762
+ updateFromEvent(r, { type: "tool_start", toolName: "read" });
763
+ const d1 = project(r);
764
+ const d2 = project(r);
765
+ expect(d1.eventLog).not.toBe(d2.eventLog); // 不同数组实例
766
+ expect(d1.eventLog).toEqual(d2.eventLog); // 内容相同
767
+ });
768
+
769
+ it("currentActivity is undefined when status is not running", () => {
770
+ const r = makeRecord({ status: "done" });
771
+ expect(project(r).currentActivity).toBeUndefined();
772
+ });
773
+
774
+ it("outputs mode + sessionFile", () => {
775
+ const r = makeRecord({ mode: "background", turnCount: 2 });
776
+ r.sessionFile = "bg-1-abc.jsonl";
777
+ const d = project(r);
778
+ expect(d.mode).toBe("background");
779
+ expect(d.sessionFile).toBe("bg-1-abc.jsonl");
780
+ });
781
+
782
+ it("sessionFile is undefined when record.sessionFile unset", () => {
783
+ const r = makeRecord();
784
+ expect(project(r).sessionFile).toBeUndefined();
785
+ });
786
+
787
+ it("currentActivity prefers tool over thinking over text", () => {
788
+ const r = makeRecord();
789
+ updateFromEvent(r, { type: "tool_start", toolName: "edit", args: { path: "/a.ts" } });
790
+ r.turns[0]!.thinking = "thinking...";
791
+ r.turns[0]!.text = "text...";
792
+ expect(project(r).currentActivity).toEqual({ type: "tool", label: "edit a.ts" });
793
+ });
794
+
795
+ it("currentActivity falls back to thinking when no running tool", () => {
796
+ const r = makeRecord();
797
+ updateFromEvent(r, { type: "tool_start", toolName: "read", args: { path: "/a.ts" } });
798
+ updateFromEvent(r, { type: "tool_end", toolName: "read", args: { path: "/a.ts" } });
799
+ r.turns[0]!.thinking = "pondering";
800
+ expect(project(r).currentActivity).toEqual({ type: "thinking", label: "pondering" });
801
+ });
802
+
803
+ it("currentActivity falls back to text when no tool/thinking", () => {
804
+ const r = makeRecord();
805
+ r.turns[0]!.text = "writing output";
806
+ expect(project(r).currentActivity).toEqual({ type: "text", label: "writing output" });
807
+ });
808
+
809
+ it("currentActivity is undefined when idle", () => {
810
+ const r = makeRecord();
811
+ expect(project(r).currentActivity).toBeUndefined();
812
+ });
813
+ });
814
+
815
+ describe("snapshot", () => {
816
+ it("returns a readonly snapshot with identity + status fields", () => {
817
+ const r = makeRecord({ turnCount: 2, status: "done", endedAt: 5000, result: "ok" });
818
+ const s = snapshot(r);
819
+ expect(s.id).toBe("test-1");
820
+ expect(s.agent).toBe("worker");
821
+ expect(s.mode).toBe("sync");
822
+ expect(s.task).toBe("test task");
823
+ expect(s.status).toBe("done");
824
+ expect(s.turns).toBe(2);
825
+ expect(s.endedAt).toBe(5000);
826
+ expect(s.result).toBe("ok");
827
+ });
828
+
829
+ it("outputs sessionFile", () => {
830
+ const r = makeRecord();
831
+ r.sessionFile = "s.jsonl";
832
+ expect(snapshot(r).sessionFile).toBe("s.jsonl");
833
+ });
834
+
835
+ it("sessionFile is undefined when unset", () => {
836
+ const r = makeRecord();
837
+ expect(snapshot(r).sessionFile).toBeUndefined();
838
+ });
839
+ });
840
+ });
841
+
842
+ // ============================================================
843
+ // extractLabelFromArgs
844
+ // ============================================================
845
+ describe("extractLabelFromArgs", () => {
846
+ it("returns bare toolName for non-object args", () => {
847
+ expect(extractLabelFromArgs("read", undefined)).toBe("read");
848
+ expect(extractLabelFromArgs("read", null)).toBe("read");
849
+ expect(extractLabelFromArgs("read", "string")).toBe("read");
850
+ });
851
+
852
+ it("returns bare toolName when no recognized field", () => {
853
+ expect(extractLabelFromArgs("custom", { foo: "bar" })).toBe("custom");
854
+ });
855
+
856
+ it("extracts basename from path", () => {
857
+ expect(extractLabelFromArgs("read", { path: "/home/user/foo.ts" })).toBe("read foo.ts");
858
+ expect(extractLabelFromArgs("edit", { file_path: "C:\\proj\\bar.js" })).toBe("edit bar.js");
859
+ expect(extractLabelFromArgs("write", { filePath: "baz.py" })).toBe("write baz.py");
860
+ });
861
+
862
+ it("extracts first line of command for bash", () => {
863
+ expect(extractLabelFromArgs("bash", { command: "ls -la\necho done" })).toBe("bash ls -la");
864
+ });
865
+
866
+ it("extracts query for web_search", () => {
867
+ expect(extractLabelFromArgs("web_search", { query: "hello world" })).toBe("web_search hello world");
868
+ });
869
+
870
+ it("extracts url for web_fetch", () => {
871
+ expect(extractLabelFromArgs("web_fetch", { url: "https://example.com" })).toBe("web_fetch https://example.com");
872
+ });
873
+
874
+ it("truncates long labels to TOOL_LABEL_MAX (TUI column-width stability)", () => {
875
+ // 设计意图:保持 TUI 列宽稳定,避免 10KB bash 命令撑爆 compact view。
876
+ const longCmd = "x".repeat(200);
877
+ const label = extractLabelFromArgs("bash", { command: longCmd });
878
+ // label = "bash " + 截断到 100 的 command
879
+ const expectedCmd = "x".repeat(100);
880
+ expect(label).toBe(`bash ${expectedCmd}`);
881
+ expect(label.length).toBe("bash ".length + 100);
882
+ });
883
+
884
+ it("truncates long path basename", () => {
885
+ const longName = "f".repeat(150) + ".ts";
886
+ const label = extractLabelFromArgs("read", { path: `/dir/${longName}` });
887
+ // basename 截断到 100
888
+ expect(label.length).toBe("read ".length + 100);
889
+ });
890
+
891
+ it("truncates long query and url", () => {
892
+ const longQuery = "q".repeat(150);
893
+ expect(extractLabelFromArgs("web_search", { query: longQuery }).length).toBe("web_search ".length + 100);
894
+ const longUrl = "u".repeat(150);
895
+ expect(extractLabelFromArgs("web_fetch", { url: longUrl }).length).toBe("web_fetch ".length + 100);
896
+ });
897
+ });
898
+
899
+ // ============================================================
900
+ // computeElapsedSeconds — 共享 helper
901
+ // ============================================================
902
+ describe("computeElapsedSeconds", () => {
903
+ it("computes floor((endedAt - startedAt) / 1000)", () => {
904
+ expect(computeElapsedSeconds({ startedAt: 0, endedAt: 1500 })).toBe(1);
905
+ expect(computeElapsedSeconds({ startedAt: 1000, endedAt: 1599 })).toBe(0);
906
+ expect(computeElapsedSeconds({ startedAt: 1000, endedAt: 2600 })).toBe(1);
907
+ });
908
+
909
+ it("uses Date.now() when endedAt is undefined (running state)", () => {
910
+ const startedAt = Date.now() - 3000;
911
+ const secs = computeElapsedSeconds({ startedAt });
912
+ // 至少 2 秒(允许调度延迟),不超过 10 秒(防止假死)
913
+ expect(secs).toBeGreaterThanOrEqual(2);
914
+ expect(secs).toBeLessThan(10);
915
+ });
916
+
917
+ it("handles identical startedAt/endedAt (0 seconds)", () => {
918
+ expect(computeElapsedSeconds({ startedAt: 5000, endedAt: 5000 })).toBe(0);
919
+ });
920
+ });
921
+
922
+ // ============================================================
923
+ // T3.21: projectLiveProgress 迁移保留(wave-3)
924
+ // ============================================================
925
+ describe("projectLiveProgress (T3.21)", () => {
926
+ it("projects live progress snapshot from running record", () => {
927
+ const record = makeRecord({
928
+ mode: "sync",
929
+ task: "test task",
930
+ startedAt: 1000,
931
+ status: "running",
932
+ turnCount: 2,
933
+ totalTokens: 500,
934
+ lastError: undefined,
935
+ turns: [
936
+ { ...emptyTurn(), closed: true, text: "turn 1", closedTs: 2000 },
937
+ { ...emptyTurn(), closed: false, text: "turn 2 in progress" },
938
+ ],
939
+ });
940
+
941
+ const result = projectLiveProgress(record);
942
+ expect(result.status).toBe("running");
943
+ expect(result.turns).toBe(2);
944
+ expect(result.totalTokens).toBe(500);
945
+ expect(result.elapsedSeconds).toBeGreaterThanOrEqual(0);
946
+ expect(result.eventLog).toBeInstanceOf(Array);
947
+ expect(result.currentActivity).toBeDefined();
948
+ expect(result.lastError).toBeUndefined();
949
+ });
950
+
951
+ it("projectLiveProgress returns lastError when set", () => {
952
+ const record = makeRecord({
953
+ mode: "sync", task: "failing", startedAt: 0, status: "running",
954
+ lastError: "something went wrong",
955
+ });
956
+ const result = projectLiveProgress(record);
957
+ expect(result.lastError).toBe("something went wrong");
958
+ });
959
+ });