@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,330 @@
1
+ // src/__tests__/tool-action.test.ts
2
+ //
3
+ // tool action 路由 + adapter 出参结构测试(AC-2/AC-3/AC-9)。
4
+ // 用 stub SubagentService(不依赖真实 SDK),测 handler + adapter 纯逻辑。
5
+
6
+ import { describe, expect, it, vi } from "vitest";
7
+
8
+ import type { SubagentService } from "../subagent-service.ts";
9
+ import { adapter, cancelHandler, listHandler, startHandler } from "../../interface/subagent-actions.ts";
10
+ import type {
11
+ ExecutionHandle,
12
+ RecordSnapshot,
13
+ SubagentRecord,
14
+ SubagentToolDetails,
15
+ } from "../types.ts";
16
+
17
+ // ── stub 工厂 ──
18
+
19
+ function makeDetails(over: Partial<SubagentToolDetails> = {}): SubagentToolDetails {
20
+ return {
21
+ status: "done",
22
+ mode: "background",
23
+ agent: "worker",
24
+ model: "test/model",
25
+ thinkingLevel: undefined,
26
+ turns: 1,
27
+ totalTokens: 10,
28
+ elapsedSeconds: 1,
29
+ eventLog: [],
30
+ result: "ok",
31
+ ...over,
32
+ };
33
+ }
34
+
35
+ function makeSnapshot(over: Partial<RecordSnapshot> = {}): RecordSnapshot {
36
+ return {
37
+ id: "run-1",
38
+ agent: "worker",
39
+ model: "test/model",
40
+ thinkingLevel: undefined,
41
+ mode: "background",
42
+ task: "t",
43
+ status: "done",
44
+ eventLog: [],
45
+ turns: 1,
46
+ totalTokens: 10,
47
+ startedAt: 1000,
48
+ endedAt: 2000,
49
+ result: "ok",
50
+ error: undefined,
51
+ sessionFile: undefined,
52
+ ...over,
53
+ };
54
+ }
55
+
56
+ function makeService(over: Partial<SubagentService> = {}): SubagentService {
57
+ return {
58
+ execute: vi.fn(),
59
+ findRecord: vi.fn(() => undefined),
60
+ cancel: vi.fn(() => false),
61
+ collectRecords: vi.fn(() => [] as SubagentRecord[]),
62
+ ...over,
63
+ } as SubagentService;
64
+ }
65
+
66
+ // ============================================================
67
+ // startHandler
68
+ // ============================================================
69
+ describe("startHandler", () => {
70
+ it("缺 startParam → throw", async () => {
71
+ const svc = makeService();
72
+ await expect(startHandler(svc, undefined, undefined)).rejects.toThrow(/startParam is required/);
73
+ });
74
+
75
+ it("task 空白 → throw", async () => {
76
+ const svc = makeService();
77
+ await expect(startHandler(svc, { task: " " }, undefined)).rejects.toThrow(/task is required/);
78
+ });
79
+
80
+ it("background 启动 → kind=bg + bgResponse.message 含 detached", async () => {
81
+ const svc = makeService({
82
+ execute: vi.fn(async (): Promise<ExecutionHandle> => ({
83
+ mode: "background",
84
+ subagentId: "bg-1-123",
85
+ sessionFile: undefined,
86
+ details: makeDetails({ status: "running", mode: "background" }),
87
+ })),
88
+ });
89
+ const r = await startHandler(svc, { task: "long" }, undefined);
90
+ expect(r.kind).toBe("bg");
91
+ if (r.kind !== "bg") return;
92
+ expect(r.subagentId).toBe("bg-1-123");
93
+ expect(r.response.message).toMatch(/detached/);
94
+ });
95
+ });
96
+
97
+ // ============================================================
98
+ // listHandler
99
+ // ============================================================
100
+ describe("listHandler", () => {
101
+ it("空 → running:0, items:[]", () => {
102
+ const svc = makeService({ collectRecords: vi.fn(() => [] as SubagentRecord[]) });
103
+ const r = listHandler(svc, undefined);
104
+ expect(r.response).toEqual({ running: 0, items: [] });
105
+ });
106
+
107
+ it("limit 夹紧 [1,100]——collectRecords 收到夹紧后的值(C1 回归)", () => {
108
+ const collect = vi.fn(() => [] as SubagentRecord[]);
109
+ const svc = makeService({ collectRecords: collect });
110
+ // includeFinished=true 时 filter="all",验证 limit 夹紧:
111
+ // 0 → 1
112
+ listHandler(svc, { includeFinished: true, limit: 0 });
113
+ expect(collect).toHaveBeenLastCalledWith(1, "all");
114
+ // 100000 → 100
115
+ listHandler(svc, { includeFinished: true, limit: 100000 });
116
+ expect(collect).toHaveBeenLastCalledWith(100, "all");
117
+ // undefined → 20(默认)
118
+ listHandler(svc, { includeFinished: true });
119
+ expect(collect).toHaveBeenLastCalledWith(20, "all");
120
+ // 负数 → 1
121
+ listHandler(svc, { includeFinished: true, limit: -5 });
122
+ expect(collect).toHaveBeenLastCalledWith(1, "all");
123
+ });
124
+
125
+ it("includeFinished=false → collectRecords 收到 filter='running'(C2 回归)", () => {
126
+ const collect = vi.fn(() => [] as SubagentRecord[]);
127
+ const svc = makeService({ collectRecords: collect });
128
+ // includeFinished=false → filter="running"(防截断下沉到 store)
129
+ listHandler(svc, { includeFinished: false, limit: 5 });
130
+ expect(collect).toHaveBeenLastCalledWith(5, "running");
131
+ // includeFinished=true → filter="all"
132
+ listHandler(svc, { includeFinished: true, limit: 5 });
133
+ expect(collect).toHaveBeenLastCalledWith(5, "all");
134
+ });
135
+
136
+ it("includeFinished=false → collectRecords 返回 running-only(过滤在 store 层)", () => {
137
+ // store 层已过滤,listHandler 只做透传——mock 返回的即 running-only。
138
+ const records: SubagentRecord[] = [
139
+ { id: "r1", agent: "w", status: "running", mode: "background", startedAt: 1, endedAt: undefined, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [] },
140
+ ];
141
+ const svc = makeService({ collectRecords: vi.fn(() => records) });
142
+ const r = listHandler(svc, { includeFinished: false });
143
+ expect(r.response.items).toHaveLength(1);
144
+ expect(r.response.items[0].subagentId).toBe("r1");
145
+ expect(r.response.running).toBe(1);
146
+ });
147
+
148
+ it("item 8 字段齐全(含 duration 实时计算)", () => {
149
+ const records: SubagentRecord[] = [
150
+ { id: "r1", agent: "w", status: "done", mode: "background", startedAt: 1000, endedAt: 2500, turns: 2, totalTokens: 50, model: "m", thinkingLevel: "high", eventLog: [], sessionFile: "x.jsonl" },
151
+ ];
152
+ const svc = makeService({ collectRecords: vi.fn(() => records) });
153
+ const r = listHandler(svc, { includeFinished: true });
154
+ const item = r.response.items[0];
155
+ expect(item).toMatchObject({
156
+ subagentId: "r1", agent: "w", status: "done", mode: "background",
157
+ duration: 1, model: "m", totalTokens: 50, sessionFile: "x.jsonl",
158
+ });
159
+ });
160
+
161
+ it("items 超过 limit → 截断在 store 层(listHandler 透传 limit)", () => {
162
+ // 截断责任在 collectRecords(store 层),listHandler 只透传 limit + map。
163
+ // mock 返回 3 条(模拟 store 未截断),验证 listHandler 不自行截断——全量透传。
164
+ const records: SubagentRecord[] = [
165
+ { id: "r1", agent: "w", status: "running", mode: "background", startedAt: 1, endedAt: undefined, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [] },
166
+ { id: "r2", agent: "w", status: "running", mode: "background", startedAt: 2, endedAt: undefined, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [] },
167
+ { id: "r3", agent: "w", status: "running", mode: "background", startedAt: 3, endedAt: undefined, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [] },
168
+ ];
169
+ const collect = vi.fn(() => records);
170
+ const svc = makeService({ collectRecords: collect });
171
+ const r = listHandler(svc, { includeFinished: true, limit: 2 });
172
+ // listHandler 透传 collectRecords 的结果(截断是 store 的责任)。
173
+ expect(r.response.items).toHaveLength(3);
174
+ // 验证 limit 确实透传给了 collectRecords。
175
+ expect(collect).toHaveBeenCalledWith(2, "all");
176
+ });
177
+
178
+ // TC-2: running 态实时 duration(Date.now()-startedAt)随时间增长,
179
+ // 区别于终态 endedAt-startedAt。喂给 live TUI,更易出 bug。
180
+ it("running 态 duration 实时计算:两次 list 间隔 2s,duration 差 = 2(TC-2)", () => {
181
+ vi.useFakeTimers({ now: 10_000 });
182
+ try {
183
+ const records: SubagentRecord[] = [
184
+ { id: "r1", agent: "w", status: "running", mode: "background", startedAt: 7_000, endedAt: undefined, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [] },
185
+ ];
186
+ const svc = makeService({ collectRecords: vi.fn(() => records) });
187
+ const r1 = listHandler(svc, { includeFinished: true });
188
+ expect(r1.response.items[0].duration).toBe(3); // (10-7)s
189
+ vi.advanceTimersByTime(2_000);
190
+ const r2 = listHandler(svc, { includeFinished: true });
191
+ expect(r2.response.items[0].duration).toBe(5); // (12-7)s
192
+ } finally {
193
+ vi.useRealTimers();
194
+ }
195
+ });
196
+
197
+ // TC-3: listHandler 保持 collectRecords 返回的顺序(排序责任在 service 层,
198
+ // listHandler 是顺序透传)。故意传入反序 list 验证 listHandler 不自行排序。
199
+ it("保持 collectRecords 顺序,不自行重排(TC-3)", () => {
200
+ // 故意按 startedAt 升序(与 desc 相反)
201
+ const records: SubagentRecord[] = [
202
+ { id: "old", agent: "w", status: "running", mode: "background", startedAt: 1, endedAt: undefined, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [] },
203
+ { id: "new", agent: "w", status: "running", mode: "background", startedAt: 5, endedAt: undefined, turns: 0, totalTokens: 0, model: "m", thinkingLevel: undefined, eventLog: [] },
204
+ ];
205
+ const svc = makeService({ collectRecords: vi.fn(() => records) });
206
+ const r = listHandler(svc, { includeFinished: true });
207
+ expect(r.response.items.map((i) => i.subagentId)).toEqual(["old", "new"]);
208
+ });
209
+ });
210
+
211
+ // ============================================================
212
+ // cancelHandler
213
+ // ============================================================
214
+ describe("cancelHandler", () => {
215
+ it("缺 subagentId → throw", async () => {
216
+ const svc = makeService();
217
+ await expect(cancelHandler(svc, undefined)).rejects.toThrow(/subagentId is required/);
218
+ await expect(cancelHandler(svc, { subagentId: " " })).rejects.toThrow(/subagentId is required/);
219
+ });
220
+
221
+ it("id 不存在 → throw No subagent record", async () => {
222
+ const svc = makeService({ findRecord: vi.fn(() => undefined) });
223
+ await expect(cancelHandler(svc, { subagentId: "nope" })).rejects.toThrow(/No subagent record with id "nope"/);
224
+ });
225
+
226
+ it("已终态(cancel 返回 false)→ throw could not be cancelled", async () => {
227
+ const svc = makeService({
228
+ findRecord: vi.fn(() => makeSnapshot({ id: "bg-1", mode: "background", status: "done" })),
229
+ cancel: vi.fn(() => false),
230
+ });
231
+ await expect(cancelHandler(svc, { subagentId: "bg-1" })).rejects.toThrow(/could not be cancelled.*status: done/);
232
+ });
233
+
234
+ it("成功 → cancelled:true", async () => {
235
+ const svc = makeService({
236
+ findRecord: vi.fn(() => makeSnapshot({ id: "bg-1", mode: "background", status: "running" })),
237
+ cancel: vi.fn(() => true),
238
+ });
239
+ const r = await cancelHandler(svc, { subagentId: "bg-1" });
240
+ expect(r.subagentId).toBe("bg-1");
241
+ expect(r.response.cancelled).toBe(true);
242
+ });
243
+
244
+ // TC-1: 并发 CAS 场景——同一 id 两次 cancel,第一次抢锁成功,第二次 CAS 失败后
245
+ // re-query 到终态。覆盖 cancelHandler 的 re-query 分支(subagent-actions.ts:267-272)。
246
+ it("并发 CAS:两次 cancel 同一 id,第二次 CAS 失败 re-query 到终态", async () => {
247
+ const running = makeSnapshot({ id: "bg-cas", mode: "background", status: "running" });
248
+ const done = makeSnapshot({ id: "bg-cas", mode: "background", status: "done" });
249
+
250
+ let cancelCalls = 0;
251
+ const svc = makeService({
252
+ // findRecord 调用序列:
253
+ // call 1: 第一次 cancel 初始查询 → running
254
+ // call 2: 第二次 cancel 初始查询 → running(快照尚未过期)
255
+ // call 3: 第二次 cancel CAS 失败后 re-query → done(状态已变)
256
+ findRecord: vi.fn()
257
+ .mockReturnValueOnce(running)
258
+ .mockReturnValueOnce(running)
259
+ .mockReturnValueOnce(done),
260
+ cancel: vi.fn(() => {
261
+ cancelCalls++;
262
+ return cancelCalls === 1; // 第一次 true,第二次 false(CAS 失败)
263
+ }),
264
+ });
265
+
266
+ // 第一次 cancel:抢锁成功
267
+ const r1 = await cancelHandler(svc, { subagentId: "bg-cas" });
268
+ expect(r1.response.cancelled).toBe(true);
269
+
270
+ // 第二次 cancel:CAS 失败,re-query 返回 done(不是初始的 running)
271
+ await expect(cancelHandler(svc, { subagentId: "bg-cas" }))
272
+ .rejects.toThrow(/could not be cancelled.*status: done/);
273
+
274
+ // 验证 re-query 确实发生(3 次 findRecord:1 初始 + 1 初始 + 1 re-query)
275
+ expect(svc.findRecord).toHaveBeenCalledTimes(3);
276
+ expect(svc.cancel).toHaveBeenCalledTimes(2);
277
+ });
278
+
279
+ // BL-3 回归:CAS 失败后 re-query 时 record 已被 archive 移出内存,
280
+ // 文案诚实报告 "evicted" 而非回落到可能过期的 stale status。
281
+ it("CAS 失败 + record 被内存淘汰 → 文案诚实报告 evicted(BL-3)", async () => {
282
+ const svc = makeService({
283
+ // 第一次 findRecord:初始查询 → running
284
+ // 第二次 findRecord:CAS 失败后 re-query → undefined(已被淘汰)
285
+ findRecord: vi.fn()
286
+ .mockReturnValueOnce(makeSnapshot({ id: "bg-evict", mode: "background", status: "running" }))
287
+ .mockReturnValueOnce(undefined),
288
+ cancel: vi.fn(() => false),
289
+ });
290
+ await expect(cancelHandler(svc, { subagentId: "bg-evict" }))
291
+ .rejects.toThrow(/could not be cancelled.*status: unknown \(evicted from memory\)/);
292
+ expect(svc.findRecord).toHaveBeenCalledTimes(2);
293
+ });
294
+ });
295
+
296
+ // ============================================================
297
+ // adapter
298
+ // ============================================================
299
+ describe("adapter", () => {
300
+ it("start bg → SubagentToolResult.bgResponse + content 合法 JSON(C3 回归)", () => {
301
+ const r = adapter({
302
+ action: "start",
303
+ domain: {
304
+ kind: "bg", subagentId: "bg-1", sessionFile: undefined,
305
+ response: { status: "running", mode: "background", message: "detached, will notify on completion" },
306
+ },
307
+ });
308
+ expect(r.details.action).toBe("start");
309
+ expect(r.details.subagentId).toBe("bg-1");
310
+ expect(r.details.bgResponse).toBeDefined();
311
+ expect(r.details.sessionFile).toBeNull();
312
+ // content JSON round-trip(bg 序列化回归)
313
+ const parsed = JSON.parse(r.content[0]!.text);
314
+ expect(parsed.bgResponse.message).toMatch(/detached/);
315
+ });
316
+
317
+ it("list → 最外层 subagentId/sessionFile 为 null", () => {
318
+ const r = adapter({ action: "list", domain: { response: { running: 0, items: [] } } });
319
+ expect(r.details.action).toBe("list");
320
+ expect(r.details.subagentId).toBeNull();
321
+ expect(r.details.sessionFile).toBeNull();
322
+ expect(r.details.listResponse).toEqual({ running: 0, items: [] });
323
+ });
324
+
325
+ it("cancel → cancelResponse.cancelled:true 字面量", () => {
326
+ const r = adapter({ action: "cancel", domain: { subagentId: "bg-1", response: { cancelled: true } } });
327
+ expect(r.details.cancelResponse).toEqual({ cancelled: true });
328
+ expect(r.details.subagentId).toBe("bg-1");
329
+ });
330
+ });
@@ -0,0 +1,65 @@
1
+ // src/__tests__/turn-limiter.test.ts
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import { createTurnLimiter } from "../turn-limiter.ts";
5
+
6
+ describe("createTurnLimiter", () => {
7
+ it("does nothing before maxTurns", () => {
8
+ const steer = vi.fn();
9
+ const abort = vi.fn();
10
+ const limiter = createTurnLimiter({ maxTurns: 3, graceTurns: 2, steer, abort });
11
+ limiter.onTurnEnd(1);
12
+ limiter.onTurnEnd(2);
13
+ expect(steer).not.toHaveBeenCalled();
14
+ expect(abort).not.toHaveBeenCalled();
15
+ expect(limiter.didSteer).toBe(false);
16
+ expect(limiter.didAbort).toBe(false);
17
+ });
18
+
19
+ it("steers on maxTurns and aborts after graceTurns", () => {
20
+ const steer = vi.fn();
21
+ const abort = vi.fn();
22
+ const limiter = createTurnLimiter({ maxTurns: 3, graceTurns: 2, steer, abort });
23
+ limiter.onTurnEnd(3); // 达到 maxTurns → steer
24
+ expect(steer).toHaveBeenCalledWith(expect.stringContaining("You have reached your turn limit"));
25
+ expect(abort).not.toHaveBeenCalled();
26
+ expect(limiter.didSteer).toBe(true);
27
+ limiter.onTurnEnd(4); // grace turn 1
28
+ expect(abort).not.toHaveBeenCalled();
29
+ limiter.onTurnEnd(5); // grace turn 2 → abort
30
+ expect(abort).toHaveBeenCalledTimes(1);
31
+ expect(limiter.didAbort).toBe(true);
32
+ });
33
+
34
+ it("disables when maxTurns is 0", () => {
35
+ const steer = vi.fn();
36
+ const abort = vi.fn();
37
+ const limiter = createTurnLimiter({ maxTurns: 0, graceTurns: 2, steer, abort });
38
+ limiter.onTurnEnd(100);
39
+ expect(steer).not.toHaveBeenCalled();
40
+ expect(abort).not.toHaveBeenCalled();
41
+ expect(limiter.didSteer).toBe(false);
42
+ });
43
+
44
+ it("steers only once", () => {
45
+ const steer = vi.fn();
46
+ const abort = vi.fn();
47
+ const limiter = createTurnLimiter({ maxTurns: 2, graceTurns: 3, steer, abort });
48
+ limiter.onTurnEnd(2);
49
+ limiter.onTurnEnd(3);
50
+ expect(steer).toHaveBeenCalledTimes(1);
51
+ expect(limiter.didSteer).toBe(true);
52
+ });
53
+
54
+ it("aborts on the same turn as steer when graceTurns is 0", () => {
55
+ const steer = vi.fn();
56
+ const abort = vi.fn();
57
+ const limiter = createTurnLimiter({ maxTurns: 2, graceTurns: 0, steer, abort });
58
+ // grace=0 → limit+grace == limit,steer 后同一 turn 即满足 abort 条件
59
+ limiter.onTurnEnd(2); // maxTurns → steer + abort(同 turn)
60
+ expect(steer).toHaveBeenCalledTimes(1);
61
+ expect(abort).toHaveBeenCalledTimes(1);
62
+ expect(limiter.didSteer).toBe(true);
63
+ expect(limiter.didAbort).toBe(true);
64
+ });
65
+ });