@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,359 @@
1
+ // src/__tests__/execute-nesting.test.ts
2
+ //
3
+ // D-030~D-033 嵌套 / 并发池 / 节流回归锁。独立于 execute-integration.test.ts。
4
+ //
5
+ // 用例:
6
+ // D-032 background 进并发池(分层配额:max(1, maxConcurrent - depth))
7
+ // D-033 execute 入口通用嵌套护栏(execCtxAls 计 fork+非 fork 嵌套,深度>MAX 拒)
8
+ // 嵌套抑制 background onUpdate 恒 undefined(防 spinner 堆叠)
9
+ // 节流清理 background finalize 后 throttleState 无残留(clearThrottle 生效)
10
+ //
11
+ // ── mock 策略 ──
12
+ //
13
+ // [关键] runSpawn(session-runner.ts)通过 child_process.spawn("pi",...) 启动子进程,
14
+ // 事件经 stdout JSON 流回流。它 **不走 getSdk / createAgentSession**。
15
+ // 因此本文件 mock 的是 node:child_process.spawn(返回 FakeChild),而非 getSdk/fakeSession
16
+ // (那是对 in-process run() 的旧 mock,在 spawn 改造后是死代码)。
17
+ //
18
+ // mock 模式参考 run-spawn-integration.test.ts(该文件是 spawn 改造后的正确 mock 范式):
19
+ // - node:child_process.spawn → FakeChild(EventEmitter + PassThrough),测试控制器
20
+ // emit stdout JSON 行(header + SdkEvent)/ stderr / close 时序。
21
+ // - node:child_process.execFileSync → ""(buildEnvBlock 的 git branch 调用避免副作用)。
22
+ // - node:fs 同步方法 → mock(mkdirSync/existsSync/appendFileSync/writeFileSync/readdirSync),
23
+ // 避免 sessionDir/sessionFile 触碰真实文件系统。
24
+ // - fs.promises.* → 保留真实实现(temp-prompt 整体被 mock,不触发真实 I/O)。
25
+ // - temp-prompt → mock(writePromptToTempFile 返回固定路径,消除 fake-timers flaky)。
26
+ // - alive-store.writeAliveMarker → mock(避免写 .alive sidecar)。
27
+ //
28
+ // 所有断言语义不变:它们测的是 SubagentService 的 **编排逻辑**
29
+ // (pool.acquire / execCtxAls 深度 / onUpdate 抑制 / throttle 清理),这些逻辑
30
+ // 无论事件来自 fakeSession.subscribe 还是 FakeChild.stdout 都一致。
31
+
32
+ import type { PassThrough } from "node:stream";
33
+
34
+ import { afterEach, describe, expect, it, vi } from "vitest";
35
+
36
+ // ── mock modules ──
37
+ //
38
+ // vitest 会把 vi.mock 提升到文件顶部(早于其他 import / 声明)。mock 工厂若要引用
39
+ // FakeChild,需在工厂内部 import(async 工厂可用 await import),而非引用顶部
40
+ // 顶层 import(它们在 vi.mock 执行时尚未绑定)。
41
+
42
+ vi.mock("node:child_process", async () => {
43
+ const { EventEmitter } = await import("node:events");
44
+ const { PassThrough } = await import("node:stream");
45
+
46
+ // FakeChild:模拟 ChildProcess(EventEmitter + PassThrough streams)。
47
+ // 测试通过 lastSpawnedChild() 取回实例,控制 emit stdout JSON 行 / close 时序。
48
+ class FakeChild extends EventEmitter {
49
+ pid = 12345;
50
+ stdout = new PassThrough();
51
+ stderr = new PassThrough();
52
+ killed = false;
53
+ killSignal: string | undefined;
54
+ kill(sig?: string): boolean {
55
+ this.killed = true;
56
+ this.killSignal = sig;
57
+ return true;
58
+ }
59
+ }
60
+
61
+ return {
62
+ spawn: vi.fn(() => new FakeChild()),
63
+ execFileSync: vi.fn(() => ""), // buildEnvBlock 的 git branch 调用,返回空避免副作用
64
+ };
65
+ });
66
+
67
+ // node:fs:同步方法 mock(runSpawn 用到的全部),promises 保留真实实现(temp-prompt 用)。
68
+ vi.mock("node:fs", async () => {
69
+ const actual = await import("node:fs");
70
+ return {
71
+ default: {
72
+ ...actual,
73
+ mkdirSync: vi.fn(),
74
+ existsSync: vi.fn(() => false),
75
+ appendFileSync: vi.fn(),
76
+ writeFileSync: vi.fn(),
77
+ readdirSync: vi.fn(() => []),
78
+ },
79
+ // 具名导出与 default 保持一致
80
+ mkdirSync: vi.fn(),
81
+ existsSync: vi.fn(() => false),
82
+ appendFileSync: vi.fn(),
83
+ writeFileSync: vi.fn(),
84
+ readdirSync: vi.fn(() => []),
85
+ // promises 保留真实实现——temp-prompt 已被 mock(见下方 vi.mock),不再触发真实 I/O
86
+ promises: actual.promises,
87
+ };
88
+ });
89
+
90
+ // alive-store:mock writeAliveMarker(runSpawn 写 .alive sidecar)+ removeAliveMarker
91
+ // (finalizeRecord 收尾删 .alive)。其余导出(readAliveMarker/isProcessAlive)保留真实实现
92
+ // (worktree-manager/record-store 用,本组用例不涉及但保留以避免间接报错)。
93
+ vi.mock("../alive-store.ts", async (importOriginal) => {
94
+ const actual = await importOriginal<typeof import("../runtime/execution/alive-store.ts")>();
95
+ return {
96
+ ...actual,
97
+ writeAliveMarker: vi.fn(),
98
+ removeAliveMarker: vi.fn(),
99
+ };
100
+ });
101
+
102
+ // finalized-marker mock:避免真实 fs 写 sidecar(测试不关心 finalized 行为)
103
+ vi.mock("../finalized-marker.ts", () => ({
104
+ writeFinalized: vi.fn(),
105
+ readFinalized: vi.fn(() => false),
106
+ }));
107
+
108
+ // temp-prompt:mock 掉真实 fs.promises I/O,消除 fake-timers 下的 flaky 竞态
109
+ // (详见 run-spawn-integration.test.ts 同名 mock 的注释)。
110
+ vi.mock("../temp-prompt.ts", () => ({
111
+ writePromptToTempFile: vi.fn(async (agent: string) => {
112
+ const safeName = agent.replace(/[^\w.-]+/g, "_");
113
+ return { dir: `/tmp/fake-${safeName}`, filePath: `/tmp/fake-${safeName}/prompt-${safeName}.md` };
114
+ }),
115
+ cleanupTempPrompt: vi.fn(async () => {}),
116
+ }));
117
+
118
+ import { spawn } from "node:child_process";
119
+
120
+ import type { ModelInfo, ModelRegistryLike } from "../model-resolver.ts";
121
+ import { MAX_FORK_DEPTH } from "../session-context-resolver.ts";
122
+ import { ModelConfigService } from "../model-config-service.ts";
123
+ import { SubagentService } from "../subagent-service.ts";
124
+
125
+ const mockSpawn = vi.mocked(spawn);
126
+
127
+ /**
128
+ * spawn mock 返回的 fake child 类型。
129
+ * 由于 FakeChild 定义在 vi.mock 工厂内部(作用域隔离),此处用结构子集类型描述,
130
+ * 测试代码通过此类型访问 stdout/stderr/kill 等成员。
131
+ */
132
+ interface FakeChild {
133
+ pid: number;
134
+ stdout: PassThrough;
135
+ stderr: PassThrough;
136
+ killed: boolean;
137
+ killSignal: string | undefined;
138
+ kill(sig?: string): boolean;
139
+ emit(event: string, ...args: unknown[]): boolean;
140
+ }
141
+
142
+ /** 从最近一次 spawn 调用取回返回的 FakeChild(测试控制器)。 */
143
+ function lastSpawnedChild(): FakeChild {
144
+ const result = mockSpawn.mock.results.at(-1);
145
+ if (!result) throw new Error("spawn was not called yet");
146
+ return result.value as FakeChild;
147
+ }
148
+
149
+ /**
150
+ * 等待 execute → runSpawn 内部调到 spawn(拿到 child 控制器)。
151
+ *
152
+ * runSpawn 是 async,spawn 在 mkdirSync + writePromptToTempFile 之后才调(均有微任务/
153
+ * I/O 延迟)。用 setInterval 轮询 mockSpawn.mock.results,比 vi.waitFor 在该 vitest 版本
154
+ * 下更可靠(vi.waitFor 偶发过早 resolve 导致后续读取竞态)。
155
+ */
156
+ async function waitForSpawn(timeoutMs = 1000): Promise<void> {
157
+ const start = Date.now();
158
+ while (mockSpawn.mock.results.length === 0) {
159
+ if (Date.now() - start > timeoutMs) {
160
+ throw new Error(`spawn was not called within ${timeoutMs}ms`);
161
+ }
162
+ await new Promise((r) => setTimeout(r, 2));
163
+ }
164
+ }
165
+
166
+ // ============================================================
167
+ // 辅助:FakeChild stdout 驱动(替代旧 fakeSession.subscribe 的事件注入)
168
+ // ============================================================
169
+
170
+ /** 构造 session header 行(stdout 首行,runSpawn 据此回填 record.sessionFile)。 */
171
+ function sessionHeader(id = "nest-session"): Record<string, unknown> {
172
+ return {
173
+ type: "session",
174
+ id,
175
+ timestamp: "2026-07-03T12-00-00-000Z",
176
+ cwd: "/tmp/test",
177
+ };
178
+ }
179
+
180
+ /** 向 stdout 写一行 JSON(自动补换行,runSpawn 按 \n split 行)。 */
181
+ function emitStdoutLine(child: FakeChild, obj: Record<string, unknown>): void {
182
+ child.stdout.write(`${JSON.stringify(obj)}\n`);
183
+ }
184
+
185
+ /**
186
+ * 驱动 FakeChild 完成 session:写 header + 可选事件 + close(0)。
187
+ *
188
+ * 这是「让 runSpawn 自然 resolve」的标准收尾路径。runSpawn 在 close 后判定 success
189
+ * (exitCode=0 → success=true),并跑 identity 补写 + finalizeRecord。
190
+ *
191
+ * @param events header 之后、close 之前 emit 的 SdkEvent 行(tool/message/turn 等)
192
+ */
193
+ async function driveChildToCompletion(child: FakeChild, events: Record<string, unknown>[] = []): Promise<void> {
194
+ emitStdoutLine(child, sessionHeader());
195
+ for (const e of events) emitStdoutLine(child, e);
196
+ child.stdout.end();
197
+ child.stderr.end();
198
+ child.emit("close", 0);
199
+ }
200
+
201
+ // ============================================================
202
+ // 辅助:service 构造(与旧 setup 等价,但不再装配 fakeSdk)
203
+ // ============================================================
204
+
205
+ function makeEmptyRegistry(): ModelRegistryLike {
206
+ return { getAvailable: () => [], find: () => undefined, hasConfiguredAuth: () => true };
207
+ }
208
+
209
+ function makePi() {
210
+ return { sendMessage: vi.fn(), appendEntry: vi.fn(), events: { emit: vi.fn() } };
211
+ }
212
+
213
+ interface SetupResult {
214
+ service: SubagentService;
215
+ }
216
+
217
+ function setup(): SetupResult {
218
+ const agentDir = "/tmp/nest-it"; // fs 已 mock,路径不需真实存在
219
+ const modelService = new ModelConfigService({ agentDir });
220
+ modelService.initModel({
221
+ modelRegistry: makeEmptyRegistry(),
222
+ sessionId: "nest-it",
223
+ ctxModel: { id: "m", name: "M", provider: "p", reasoning: false },
224
+ });
225
+ const service = new SubagentService({
226
+ cwd: agentDir,
227
+ modelService,
228
+ getMainSessionFile: () => "/mock/main-session.jsonl",
229
+ });
230
+ service.initSession({ pi: makePi(), sessionId: "nest-it" });
231
+ return { service };
232
+ }
233
+
234
+ const ctxModel: ModelInfo = { id: "m", name: "M", provider: "p", reasoning: false };
235
+
236
+ /** execCtxAls.run 的 duck-type(绕过 import AsyncLocalStorage,足够本组用例)。 */
237
+ interface ExecCtxAls {
238
+ run: <T>(store: { recordId: string | undefined; depth: number }, cb: () => T) => T;
239
+ }
240
+
241
+ describe("嵌套护栏 / 并发池 / 节流(D-030~D-033 回归锁)", () => {
242
+ afterEach(() => {
243
+ vi.clearAllMocks();
244
+ });
245
+
246
+ // ============================================================
247
+ // D-032: background execute 进并发池(分层配额)
248
+ // ============================================================
249
+
250
+ it("[D-032] background execute 调 pool.acquire(进池限流)", async () => {
251
+ const { service } = setup();
252
+
253
+ const pool = Reflect.get(service, "pool") as { acquire: ReturnType<typeof vi.fn>; release: ReturnType<typeof vi.fn> };
254
+ const acquireSpy = vi.spyOn(pool, "acquire");
255
+
256
+ const execPromise = service.execute({ task: "bg in pool", ctxModel });
257
+ // detached runAndFinalize → acquire。等 spawn 拿到 child 再驱动完成。
258
+ await waitForSpawn();
259
+ await driveChildToCompletion(lastSpawnedChild());
260
+
261
+ // 等 detached promise 链跑完(kickOffBackground 的 .then notify)
262
+ await new Promise<void>((r) => setTimeout(r, 10));
263
+
264
+ expect(acquireSpy).toHaveBeenCalled();
265
+ // background execute 立即返回 handle(不等完成)
266
+ const handle = await execPromise;
267
+ expect(handle.mode).toBe("background");
268
+ });
269
+
270
+ // ============================================================
271
+ // D-033: 通用嵌套护栏(execute 入口,execCtxAls 非 fork 路径)
272
+ // ============================================================
273
+
274
+ it("[D-033] execCtxAls depth=MAX 时 execute 抛错(nestingDepth=MAX+1 被拒)", async () => {
275
+ const { service } = setup();
276
+
277
+ const execCtxAls = Reflect.get(service, "execCtxAls") as ExecCtxAls;
278
+
279
+ await expect(
280
+ execCtxAls.run({ recordId: "parent", depth: MAX_FORK_DEPTH }, () =>
281
+ service.execute({ task: "too deep", ctxModel }),
282
+ ),
283
+ ).rejects.toThrow(/nesting depth/);
284
+
285
+ // 无副作用:guard 在 createRecordForMode 之前,record 未创建
286
+ expect(service.collectRecords(10)).toHaveLength(0);
287
+ // guard 在 spawn 之前——不应 spawn 任何子进程
288
+ expect(mockSpawn).not.toHaveBeenCalled();
289
+ });
290
+
291
+ it("[D-033] execCtxAls depth=MAX-1 时 execute 不抛(nestingDepth=MAX 允许)", async () => {
292
+ const { service } = setup();
293
+
294
+ const execCtxAls = Reflect.get(service, "execCtxAls") as ExecCtxAls;
295
+
296
+ const execPromise = execCtxAls.run({ recordId: "parent", depth: MAX_FORK_DEPTH - 1 }, () =>
297
+ service.execute({ task: "at limit", ctxModel }),
298
+ );
299
+ await waitForSpawn();
300
+ await driveChildToCompletion(lastSpawnedChild(), [
301
+ { type: "turn_end" },
302
+ { type: "message_end", message: { usage: { input: 1 } } },
303
+ ]);
304
+ const result = await execPromise;
305
+
306
+ expect(result.mode).toBe("background");
307
+ });
308
+
309
+ // ============================================================
310
+ // 嵌套 sync onUpdate 抑制(nestingDepth>0 → onUpdate undefined)
311
+ // ============================================================
312
+
313
+ it("[嵌套抑制] 嵌套 sync(execCtxAls depth>0)不回流 onUpdate", async () => {
314
+ const { service } = setup();
315
+
316
+ const execCtxAls = Reflect.get(service, "execCtxAls") as ExecCtxAls;
317
+
318
+ const updates: unknown[] = [];
319
+ const execPromise = execCtxAls.run({ recordId: "parent", depth: 1 }, () =>
320
+ service.execute({ task: "nested sync", ctxModel, onUpdate: (d) => updates.push(d) }),
321
+ );
322
+ await waitForSpawn();
323
+ // emit 会触发 onUpdate 的事件(tool_start/tool_end 是 TRIGGERING_EVENT),
324
+ // 但嵌套层 onUpdate 被抑制(undefined)→ onEventThrottled 包装不挂载 → 0 次
325
+ await driveChildToCompletion(lastSpawnedChild(), [
326
+ { type: "tool_execution_start", toolCallId: "t1", toolName: "read" },
327
+ { type: "tool_execution_end", toolCallId: "t1", toolName: "read" },
328
+ { type: "message_end", message: { usage: { input: 1 } } },
329
+ ]);
330
+ await execPromise;
331
+
332
+ // tool_end 是 TRIGGERING_EVENT,但嵌套层 onUpdate 被抑制(undefined)→ 0 次
333
+ expect(updates).toHaveLength(0);
334
+ });
335
+
336
+ // ============================================================
337
+ // clearThrottle:sync 完成后 throttleState 清理
338
+ // ============================================================
339
+
340
+ it("[节流清理] sync 完成后 throttleState 无残留(clearThrottle 生效)", async () => {
341
+ const { service } = setup();
342
+
343
+ const execPromise = service.execute({
344
+ task: "throttle clear",
345
+ ctxModel,
346
+ onUpdate: () => {},
347
+ });
348
+ await waitForSpawn();
349
+ await driveChildToCompletion(lastSpawnedChild(), [
350
+ { type: "tool_execution_end", toolCallId: "t1", toolName: "read" },
351
+ { type: "message_end", message: { usage: { input: 1 } } },
352
+ ]);
353
+ await execPromise;
354
+
355
+ // finalizeRecord → clearThrottle 清掉该 record 的节流 entry(防 Map 无限增长 + trailing 误发陈旧)
356
+ const throttleState = Reflect.get(service, "throttleState") as Map<string, unknown>;
357
+ expect(throttleState.size).toBe(0);
358
+ });
359
+ });
@@ -0,0 +1,138 @@
1
+ // src/execution/__tests__/execute-options-mapper.test.ts
2
+ //
3
+ // T3.4 (边界): cwd 透传(非 git worktree)
4
+ // T3.5 (边界): model 填底——opts.model 空 → ctxModel(D-008)
5
+ // T3.9 (边界): schemaEnv 透传——opts.schemaEnv → ExecuteOptions.schemaEnv
6
+ // T3.6 (异常): timeoutMs 超时 → 合并 signal abort → T3.17 listener 清理
7
+ //
8
+ // 测试范围: mapToExecuteOptions 全字段映射 + mergeTimeoutSignal 行为
9
+
10
+ import { describe, expect, it, vi } from "vitest";
11
+
12
+ import type { AgentCallOpts } from "../../orchestration/models/types.ts";
13
+ import type { ModelInfo } from "../model-resolver.ts";
14
+ import { mapToExecuteOptions, mergeTimeoutSignal } from "../execute-options-mapper.ts";
15
+
16
+ describe("mapToExecuteOptions (D-A2)", () => {
17
+ const baseOpts: AgentCallOpts = {
18
+ prompt: "test task",
19
+ agent: "worker",
20
+ schema: { type: "object" },
21
+ cwd: "/tmp/work",
22
+ skillPath: "/path/to/skill.md",
23
+ };
24
+
25
+ it("T3.4 基本映射: prompt→task, agent→agent, cwd→cwd", () => {
26
+ const result = mapToExecuteOptions(baseOpts);
27
+ expect(result.task).toBe("test task");
28
+ expect(result.agent).toBe("worker");
29
+ expect(result.cwd).toBe("/tmp/work");
30
+ });
31
+
32
+ it("T3.4 schema 透传", () => {
33
+ const result = mapToExecuteOptions(baseOpts);
34
+ expect(result.schema).toEqual({ type: "object" });
35
+ });
36
+
37
+ it("T3.9 schemaEnv 透传 (D-A6 bridge)", () => {
38
+ const opts: AgentCallOpts = { ...baseOpts, schemaEnv: '{"type":"object"}' };
39
+ const result = mapToExecuteOptions(opts);
40
+ expect((result as unknown as { schemaEnv?: string }).schemaEnv).toBe('{"type":"object"}');
41
+ });
42
+
43
+ it("T3.9 schemaEnv 不传 → schemaEnv undefined", () => {
44
+ const result = mapToExecuteOptions(baseOpts);
45
+ expect((result as unknown as { schemaEnv?: string }).schemaEnv).toBeUndefined();
46
+ });
47
+
48
+ it("T3.5 model 填底: opts.model 优先", () => {
49
+ const opts: AgentCallOpts = { ...baseOpts, model: "explicit-model" };
50
+ const ctxModel: ModelInfo = { id: "ctx-model", provider: "test", input: [] } as ModelInfo;
51
+ const result = mapToExecuteOptions(opts, ctxModel);
52
+ expect(result.model).toBe("explicit-model");
53
+ });
54
+
55
+ it("T3.5 model 填底: opts.model 空 → ctxModel (D-008)", () => {
56
+ const ctxModel: ModelInfo = { id: "ctx-model", provider: "test", input: [] } as ModelInfo;
57
+ const result = mapToExecuteOptions(baseOpts, ctxModel);
58
+ expect(result.model).toBe("ctx-model");
59
+ });
60
+
61
+ it("T3.5 model 填底: opts.model 空且 ctxModel 空 → model undefined", () => {
62
+ const result = mapToExecuteOptions(baseOpts);
63
+ expect(result.model).toBeUndefined();
64
+ });
65
+
66
+ it("skillPath 透传", () => {
67
+ const result = mapToExecuteOptions(baseOpts);
68
+ expect(result.skillPath).toBe("/path/to/skill.md");
69
+ });
70
+
71
+ it("忽略 systemPromptFiles(不映射到 ExecuteOptions)", () => {
72
+ const opts: AgentCallOpts = { ...baseOpts, systemPromptFiles: ["/tmp/a.txt"] };
73
+ const result = mapToExecuteOptions(opts);
74
+ // systemPromptFiles 不应在 ExecuteOptions 中出现
75
+ expect((result as Record<string, unknown>).appendSystemPrompt).toBeUndefined();
76
+ });
77
+ });
78
+
79
+ describe("mergeTimeoutSignal (D-A9)", () => {
80
+ it("T3.6 timeoutMs===undefined → 原样返回 external signal", () => {
81
+ const ctrl = new AbortController();
82
+ const result = mergeTimeoutSignal(ctrl.signal, undefined);
83
+ expect(result).toBe(ctrl.signal);
84
+ });
85
+
86
+ it("T3.6 timeoutMs<=0 → 原样返回 external signal", () => {
87
+ const ctrl = new AbortController();
88
+ const result = mergeTimeoutSignal(ctrl.signal, 0);
89
+ expect(result).toBe(ctrl.signal);
90
+ });
91
+
92
+ it("T3.6 timeoutMs>0 → 返回新 signal(合并外部+超时两路)", () => {
93
+ const ctrl = new AbortController();
94
+ const result = mergeTimeoutSignal(ctrl.signal, 50);
95
+ expect(result).not.toBe(ctrl.signal);
96
+ expect(result.aborted).toBe(false);
97
+ });
98
+
99
+ it("T3.6 timeoutMs 到期 → merged signal abort", async () => {
100
+ vi.useFakeTimers();
101
+ const ctrl = new AbortController();
102
+ const merged = mergeTimeoutSignal(ctrl.signal, 50);
103
+
104
+ expect(merged.aborted).toBe(false);
105
+ vi.advanceTimersByTime(51);
106
+ expect(merged.aborted).toBe(true);
107
+ vi.useRealTimers();
108
+ });
109
+
110
+ it("T3.6 外部 signal abort → merged signal abort", () => {
111
+ const ctrl = new AbortController();
112
+ const merged = mergeTimeoutSignal(ctrl.signal, 5000);
113
+ ctrl.abort();
114
+ expect(merged.aborted).toBe(true);
115
+ });
116
+
117
+ it("T3.6 外部 signal 已 abort → 返回已 abort 的 signal", () => {
118
+ const ctrl = new AbortController();
119
+ ctrl.abort();
120
+ const merged = mergeTimeoutSignal(ctrl.signal, 5000);
121
+ expect(merged.aborted).toBe(true);
122
+ });
123
+
124
+ it("T3.17 NFR: merged signal abort → timeout timer 清理", () => {
125
+ vi.useFakeTimers();
126
+ const ctrl = new AbortController();
127
+ const merged = mergeTimeoutSignal(ctrl.signal, 50);
128
+
129
+ ctrl.abort(); // 外部 abort → merged 也 abort
130
+ expect(merged.aborted).toBe(true);
131
+
132
+ // 推进时间,不应再有副作用
133
+ vi.advanceTimersByTime(100);
134
+ // timer 应被清理(通过 abort event listener)
135
+ // 无异常 = timer 已正确清理
136
+ vi.useRealTimers();
137
+ });
138
+ });