@zhushanwen/pi-base-tool-enhance 0.2.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 (35) hide show
  1. package/README.md +31 -0
  2. package/index.ts +1 -0
  3. package/package.json +54 -0
  4. package/skills/base-tool-enhance-ext-config/SKILL.md +76 -0
  5. package/src/__tests__/background-lifecycle.test.ts +634 -0
  6. package/src/__tests__/bash-tool.test.ts +573 -0
  7. package/src/__tests__/config.test.ts +193 -0
  8. package/src/__tests__/force-patterns.test.ts +230 -0
  9. package/src/__tests__/index.test.ts +133 -0
  10. package/src/__tests__/kill-tree.test.ts +76 -0
  11. package/src/__tests__/notify.test.ts +335 -0
  12. package/src/__tests__/pending-reconcile.test.ts +237 -0
  13. package/src/__tests__/reaper.test.ts +373 -0
  14. package/src/__tests__/registry.test.ts +149 -0
  15. package/src/__tests__/task-store.test.ts +156 -0
  16. package/src/__tests__/tool-error-audit.test.ts +92 -0
  17. package/src/background/notify.ts +218 -0
  18. package/src/background/output-tail.ts +84 -0
  19. package/src/background/pending-reconcile.ts +169 -0
  20. package/src/background/poller.ts +91 -0
  21. package/src/background/process-exit-guard.ts +106 -0
  22. package/src/background/registry.ts +203 -0
  23. package/src/background/spawn-background.ts +275 -0
  24. package/src/background/subagent-guard.ts +21 -0
  25. package/src/background/task-store.ts +125 -0
  26. package/src/background/types.ts +103 -0
  27. package/src/bash-kill-tool.ts +144 -0
  28. package/src/bash-output-tool.ts +131 -0
  29. package/src/bash-tool.ts +226 -0
  30. package/src/config.ts +167 -0
  31. package/src/force-patterns.ts +236 -0
  32. package/src/index.ts +90 -0
  33. package/src/kill-tree.ts +100 -0
  34. package/src/reaper.ts +313 -0
  35. package/src/tool-error-audit.ts +78 -0
@@ -0,0 +1,573 @@
1
+ // src/__tests__/bash-tool.test.ts —— M1 前台委托回归 + M2 background 分支接入 +
2
+ // M4 白名单强制后台(D3/D13/D14)与双模式 timeout 注入
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { afterEach, describe, expect, it, vi } from "vitest";
8
+
9
+ // mock pi 官方工厂:前台行为是委托(方案 B),测试断言「透传面」而非真实 spawn
10
+ // 行为——真实行为由 pi 上游保证 + 探针 P2 实测。
11
+ const { createBashToolDefinitionMock, officialExecuteMock, agentDirRef } = vi.hoisted(() => ({
12
+ createBashToolDefinitionMock: vi.fn(),
13
+ officialExecuteMock: vi.fn(),
14
+ // 可变 agentDir:M4 配置用例按需切到临时目录写 <agentDir>/config/*.json
15
+ agentDirRef: { dir: "/tmp/bte-fake-agent-dir" },
16
+ }));
17
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
18
+ createBashToolDefinition: createBashToolDefinitionMock,
19
+ getAgentDir: () => agentDirRef.dir,
20
+ }));
21
+
22
+ // mock spawn-background:background 分支的生命周期由 background-lifecycle.test.ts
23
+ // 真实测,这里只断言「分支路由正确 + 参数传递正确」。resolveBackgroundTimeoutSec
24
+ // 镜像真实实现(显式 > 配置默认 > 不限,M4 双参数签名)。
25
+ const { spawnBackgroundTaskMock, resolveTimeoutMock, isSubagentMock } = vi.hoisted(() => ({
26
+ spawnBackgroundTaskMock: vi.fn(),
27
+ resolveTimeoutMock: vi.fn((sec: number | undefined, defaultSec?: number) => {
28
+ if (sec === undefined) return defaultSec;
29
+ if (!Number.isFinite(sec) || sec <= 0) {
30
+ throw new Error("Invalid timeout: must be a finite number of seconds");
31
+ }
32
+ return sec;
33
+ }),
34
+ isSubagentMock: vi.fn(() => false),
35
+ }));
36
+ vi.mock("../background/spawn-background.ts", async (importOriginal) => {
37
+ const orig = await importOriginal<typeof import("../background/spawn-background.ts")>();
38
+ return {
39
+ ...orig,
40
+ spawnBackgroundTask: spawnBackgroundTaskMock,
41
+ resolveBackgroundTimeoutSec: resolveTimeoutMock,
42
+ };
43
+ });
44
+ vi.mock("../background/subagent-guard.ts", () => ({
45
+ isSubagentProcess: isSubagentMock,
46
+ }));
47
+
48
+ import { clearConfigCache } from "@zhushanwen/pi-llm-shared";
49
+
50
+ import { createBashOverrideToolDefinition } from "../bash-tool.ts";
51
+
52
+ // M4:临时 agentDir 生命周期——写配置用例切 agentDirRef,afterEach 统一还原 + 清缓存
53
+ const configTempDirs: string[] = [];
54
+
55
+ /** 切到全新临时 agentDir 并写入配置文件(loadBaseToolEnhanceConfig 读时刷新即生效)。 */
56
+ function useConfig(config: Record<string, unknown>): void {
57
+ const dir = mkdtempSync(join(tmpdir(), "bte-bashtool-cfg-"));
58
+ configTempDirs.push(dir);
59
+ agentDirRef.dir = dir;
60
+ const configPath = join(dir, "config", "base-tool-enhance-ext-config.json");
61
+ mkdirSync(join(configPath, ".."), { recursive: true });
62
+ writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, "utf8");
63
+ }
64
+
65
+ afterEach(() => {
66
+ for (const dir of configTempDirs.splice(0)) {
67
+ rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ agentDirRef.dir = "/tmp/bte-fake-agent-dir";
70
+ clearConfigCache();
71
+ });
72
+
73
+ function createOfficialFactoryResult(cwd: string) {
74
+ return {
75
+ name: "bash",
76
+ label: "bash",
77
+ description: `official description for ${cwd}`,
78
+ promptSnippet: "Execute bash commands (ls, grep, find, etc.)",
79
+ promptGuidelines: ["You can inspect PI_* environment variables for current model and session details."],
80
+ parameters: { type: "object", properties: {} },
81
+ execute: officialExecuteMock,
82
+ };
83
+ }
84
+
85
+ function setupFactory() {
86
+ createBashToolDefinitionMock.mockReset();
87
+ officialExecuteMock.mockReset();
88
+ spawnBackgroundTaskMock.mockReset();
89
+ resolveTimeoutMock.mockClear();
90
+ isSubagentMock.mockReset();
91
+ isSubagentMock.mockReturnValue(false);
92
+ resolveTimeoutMock.mockImplementation((sec: number | undefined, defaultSec?: number) => {
93
+ if (sec === undefined) return defaultSec;
94
+ if (!Number.isFinite(sec) || sec <= 0) {
95
+ throw new Error("Invalid timeout: must be a finite number of seconds");
96
+ }
97
+ return sec;
98
+ });
99
+ createBashToolDefinitionMock.mockImplementation((_cwd: string) => createOfficialFactoryResult(_cwd));
100
+ }
101
+
102
+ const CWD = "/tmp/bte-workdir";
103
+
104
+ function createCtx() {
105
+ return { cwd: CWD, sessionManager: { getSessionId: () => "sess-1" } };
106
+ }
107
+
108
+ describe("createBashOverrideToolDefinition", () => {
109
+ it("overrides the builtin bash tool by name and passes through official label", () => {
110
+ setupFactory();
111
+ const tool = createBashOverrideToolDefinition();
112
+ expect(tool.name).toBe("bash");
113
+ expect(tool.label).toBe("bash");
114
+ });
115
+
116
+ it("extends the official schema with optional background (command required, timeout/background optional)", () => {
117
+ setupFactory();
118
+ const tool = createBashOverrideToolDefinition();
119
+ // typebox JSON Schema 形态:Optional 键不进 required
120
+ expect(tool.parameters.type).toBe("object");
121
+ const params = tool.parameters as unknown as {
122
+ required: string[];
123
+ properties: Record<string, { type: string }>;
124
+ };
125
+ expect(params.required).toEqual(["command"]);
126
+ expect(params.properties.command?.type).toBe("string");
127
+ expect(params.properties.timeout?.type).toBe("number");
128
+ expect(params.properties.background?.type).toBe("boolean");
129
+ });
130
+
131
+ it("rewrites description to cover background usage (task_id / bash_output / bash_kill / whitelist / timeout)", () => {
132
+ setupFactory();
133
+ const tool = createBashOverrideToolDefinition();
134
+ expect(tool.description).not.toContain("official description");
135
+ expect(tool.description).toContain("background: true");
136
+ expect(tool.description).toContain("task_id");
137
+ expect(tool.description).toContain("bash_output");
138
+ expect(tool.description).toContain("bash_kill");
139
+ // 白名单自动转后台 + timeout 显式值被尊重(除白名单强转后台例外)
140
+ expect(tool.description).toContain("whitelist");
141
+ expect(tool.description).toContain("timeout in seconds");
142
+ });
143
+
144
+ it("passes through official promptSnippet/promptGuidelines (system-prompt parity)", () => {
145
+ setupFactory();
146
+ const tool = createBashOverrideToolDefinition();
147
+ expect(tool.promptSnippet).toBe("Execute bash commands (ls, grep, find, etc.)");
148
+ expect(tool.promptGuidelines).toEqual([
149
+ "You can inspect PI_* environment variables for current model and session details.",
150
+ ]);
151
+ });
152
+
153
+ it("passes through official renderCall/renderResult (TUI render parity, pi 0.84.1 fields)", () => {
154
+ setupFactory();
155
+ const renderCall = vi.fn();
156
+ const renderResult = vi.fn();
157
+ createBashToolDefinitionMock.mockImplementation(() => ({
158
+ ...createOfficialFactoryResult("render-probe"),
159
+ renderCall,
160
+ renderResult,
161
+ }));
162
+ const tool = createBashOverrideToolDefinition();
163
+ // 引用级相等证明 delegate 的 render 闭包原样透传(未覆写未丢弃)——官方
164
+ // renderCall(命令格式化)/ renderResult(elapsed 计时/富结果组件)是 TUI
165
+ // 渲染面,独立 pi 用户安装本包后不降级为通用组件
166
+ expect(tool.renderCall).toBe(renderCall);
167
+ expect(tool.renderResult).toBe(renderResult);
168
+ });
169
+
170
+ it("delegates execute to the official factory execute with recognized fields only (background stripped)", async () => {
171
+ setupFactory();
172
+ const expectedResult = { content: [{ type: "text" as const, text: "hi" }], details: undefined };
173
+ officialExecuteMock.mockResolvedValue(expectedResult);
174
+
175
+ const tool = createBashOverrideToolDefinition();
176
+ const ctx = createCtx();
177
+ const signal = new AbortController().signal;
178
+ const onUpdate = vi.fn();
179
+
180
+ const result = await tool.execute(
181
+ "call-1",
182
+ { command: "echo hi", timeout: 30 },
183
+ signal,
184
+ onUpdate,
185
+ ctx as never,
186
+ );
187
+
188
+ // 前台委托:全部参数透传,返回值透传;background 是本包增量字段,不进官方入参
189
+ expect(officialExecuteMock).toHaveBeenCalledTimes(1);
190
+ expect(officialExecuteMock).toHaveBeenCalledWith(
191
+ "call-1",
192
+ { command: "echo hi", timeout: 30 },
193
+ signal,
194
+ onUpdate,
195
+ ctx,
196
+ );
197
+ expect(result).toBe(expectedResult);
198
+ expect(spawnBackgroundTaskMock).not.toHaveBeenCalled();
199
+ });
200
+
201
+ it("delegates without timeout when absent (undefined preserved)", async () => {
202
+ setupFactory();
203
+ officialExecuteMock.mockResolvedValue({ content: [], details: undefined });
204
+ const tool = createBashOverrideToolDefinition();
205
+
206
+ await tool.execute("call-2", { command: "ls" }, undefined, undefined, createCtx() as never);
207
+
208
+ expect(officialExecuteMock).toHaveBeenCalledWith(
209
+ "call-2",
210
+ { command: "ls", timeout: undefined },
211
+ undefined,
212
+ undefined,
213
+ expect.anything(),
214
+ );
215
+ });
216
+
217
+ it("builds the delegate with ctx.cwd (authoritative) and caches per cwd", async () => {
218
+ setupFactory();
219
+ officialExecuteMock.mockResolvedValue({ content: [], details: undefined });
220
+ const tool = createBashOverrideToolDefinition();
221
+
222
+ // load 时刻初始 delegate 用 process.cwd()
223
+ expect(createBashToolDefinitionMock).toHaveBeenCalledTimes(1);
224
+ expect(createBashToolDefinitionMock).toHaveBeenCalledWith(process.cwd());
225
+
226
+ // execute 的 ctx.cwd 与缓存不一致 → 以 ctx.cwd 重建
227
+ await tool.execute("c1", { command: "ls" }, undefined, undefined, { cwd: "/tmp/alt" } as never);
228
+ expect(createBashToolDefinitionMock).toHaveBeenCalledWith("/tmp/alt");
229
+ expect(createBashToolDefinitionMock).toHaveBeenCalledTimes(2);
230
+
231
+ // 同 cwd 复用缓存(不再新建)
232
+ await tool.execute("c2", { command: "ls" }, undefined, undefined, { cwd: "/tmp/alt" } as never);
233
+ expect(createBashToolDefinitionMock).toHaveBeenCalledTimes(2);
234
+
235
+ // cwd 再变 → 重建
236
+ await tool.execute("c3", { command: "ls" }, undefined, undefined, { cwd: "/tmp/alt2" } as never);
237
+ expect(createBashToolDefinitionMock).toHaveBeenCalledTimes(3);
238
+ });
239
+ });
240
+
241
+ describe("background branch routing (M2)", () => {
242
+ it("background:true routes to spawnBackgroundTask with ctx-derived paths and returns task_id message", async () => {
243
+ setupFactory();
244
+ spawnBackgroundTaskMock.mockReturnValue({
245
+ ok: true,
246
+ task: {
247
+ taskId: "bt-1724589012-a3f7",
248
+ pid: 12345,
249
+ command: "sleep 5 && echo done",
250
+ outputFile: "/tmp/bte-fake-agent-dir/base-tool-enhance/sess-1/bt-1724589012-a3f7.log",
251
+ registryPath: "/tmp/registry.json",
252
+ startedAt: 1,
253
+ state: "running",
254
+ ownerPiPid: process.pid,
255
+ sessionId: "sess-1",
256
+ },
257
+ });
258
+
259
+ const tool = createBashOverrideToolDefinition();
260
+ const result = await tool.execute(
261
+ "call-bg",
262
+ { command: "sleep 5 && echo done", background: true, timeout: 60 },
263
+ new AbortController().signal,
264
+ undefined,
265
+ createCtx() as never,
266
+ );
267
+
268
+ // 不走前台委托
269
+ expect(officialExecuteMock).not.toHaveBeenCalled();
270
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledWith({
271
+ command: "sleep 5 && echo done",
272
+ cwd: CWD,
273
+ dataDir: "/tmp/bte-fake-agent-dir",
274
+ sessionId: "sess-1",
275
+ timeoutSec: 60,
276
+ maxConcurrent: 8,
277
+ });
278
+
279
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
280
+ expect(text).toContain("bt-1724589012-a3f7");
281
+ expect(text).toContain("pid: 12345");
282
+ expect(text).toContain("Output file:");
283
+ expect(text).toContain('bash_output {task_id:"bt-1724589012-a3f7"}');
284
+ });
285
+
286
+ it("background spawn failure surfaces the error", async () => {
287
+ setupFactory();
288
+ spawnBackgroundTaskMock.mockReturnValue({
289
+ ok: false,
290
+ error: "Background task limit reached (max 8 concurrent).",
291
+ });
292
+ const tool = createBashOverrideToolDefinition();
293
+ await expect(
294
+ tool.execute("call-bg-err", { command: "x", background: true }, undefined, undefined, createCtx() as never),
295
+ ).rejects.toThrow(/limit reached/);
296
+ });
297
+
298
+ it("D14: subagent process ignores background and delegates to foreground", async () => {
299
+ setupFactory();
300
+ isSubagentMock.mockReturnValue(true);
301
+ officialExecuteMock.mockResolvedValue({ content: [{ type: "text", text: "sync" }], details: undefined });
302
+
303
+ const tool = createBashOverrideToolDefinition();
304
+ const result = await tool.execute(
305
+ "call-sub",
306
+ { command: "echo sync", background: true },
307
+ undefined,
308
+ undefined,
309
+ createCtx() as never,
310
+ );
311
+
312
+ expect(spawnBackgroundTaskMock).not.toHaveBeenCalled();
313
+ expect(officialExecuteMock).toHaveBeenCalledTimes(1);
314
+ const text = result.content[0]?.type === "text" ? result.content[0].text : "";
315
+ expect(text).toBe("sync");
316
+ });
317
+ });
318
+
319
+ // ──────────────────────── M4:白名单强制后台 + timeout 注入 ────────────────────────
320
+
321
+ function makeSpawnedTask(command: string) {
322
+ return {
323
+ ok: true as const,
324
+ task: {
325
+ taskId: "bt-1724589012-ffff",
326
+ pid: 4242,
327
+ command,
328
+ outputFile: "/tmp/bte-fake-agent-dir/base-tool-enhance/sess-1/bt-1724589012-ffff.log",
329
+ registryPath: "/tmp/registry.json",
330
+ startedAt: 1,
331
+ state: "running" as const,
332
+ ownerPiPid: process.pid,
333
+ sessionId: "sess-1",
334
+ },
335
+ };
336
+ }
337
+
338
+ function resultText(result: unknown): string {
339
+ const content = (result as { content?: Array<{ type: string; text?: string }> }).content ?? [];
340
+ return content[0]?.type === "text" ? (content[0].text ?? "") : "";
341
+ }
342
+
343
+ describe("whitelist force-background routing (M4, D3/D13/D14)", () => {
344
+ it("force-test 命中(background 缺省)→ 强制后台,result 注明 pattern 命中", async () => {
345
+ setupFactory();
346
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("npm test"));
347
+ const tool = createBashOverrideToolDefinition();
348
+ const result = await tool.execute("call-f1", { command: "npm test" }, undefined, undefined, createCtx() as never);
349
+
350
+ expect(officialExecuteMock).not.toHaveBeenCalled();
351
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledTimes(1);
352
+ const text = resultText(result);
353
+ expect(text).toContain("task_id: bt-1724589012-ffff");
354
+ expect(text).toContain("Forced to background");
355
+ expect(text).toContain("matched force-background whitelist pattern 'test' (npm test)");
356
+ // 未带显式 timeout 时无「忽略」注记
357
+ expect(text).not.toContain("Ignored explicit timeout");
358
+ });
359
+
360
+ it("D3:白名单命中无视显式 background:false", async () => {
361
+ setupFactory();
362
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("npx vitest run"));
363
+ const tool = createBashOverrideToolDefinition();
364
+ const result = await tool.execute(
365
+ "call-f2",
366
+ { command: "npx vitest run", background: false },
367
+ undefined,
368
+ undefined,
369
+ createCtx() as never,
370
+ );
371
+
372
+ expect(officialExecuteMock).not.toHaveBeenCalled();
373
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledTimes(1);
374
+ expect(resultText(result)).toContain("Forced to background");
375
+ });
376
+
377
+ it("D13:命中时忽略 LLM 显式 timeout(无配置默认 → 不限)且 result 注明", async () => {
378
+ setupFactory();
379
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("npm test"));
380
+ const tool = createBashOverrideToolDefinition();
381
+ const result = await tool.execute(
382
+ "call-f3",
383
+ { command: "npm test", timeout: 120 },
384
+ undefined,
385
+ undefined,
386
+ createCtx() as never,
387
+ );
388
+
389
+ // 显式 120 被忽略、无配置默认 → timeoutSec undefined(不限)
390
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledWith(expect.objectContaining({ timeoutSec: undefined }));
391
+ const text = resultText(result);
392
+ expect(text).toContain("Ignored explicit timeout 120s");
393
+ expect(text).toContain("unlimited");
394
+ });
395
+
396
+ it("D13 + 配置默认:忽略显式 120,取 backgroundTimeoutSeconds=300 并注明", async () => {
397
+ setupFactory();
398
+ useConfig({ backgroundTimeoutSeconds: 300 });
399
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("npm test"));
400
+ const tool = createBashOverrideToolDefinition();
401
+ const result = await tool.execute(
402
+ "call-f4",
403
+ { command: "npm test", timeout: 120 },
404
+ undefined,
405
+ undefined,
406
+ createCtx() as never,
407
+ );
408
+
409
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledWith(expect.objectContaining({ timeoutSec: 300 }));
410
+ const text = resultText(result);
411
+ expect(text).toContain("Ignored explicit timeout 120s");
412
+ expect(text).toContain("300s (config default)");
413
+ });
414
+
415
+ it("disableBuiltinForcePatterns:true → 白名单不命中,走前台委托", async () => {
416
+ setupFactory();
417
+ useConfig({ disableBuiltinForcePatterns: true });
418
+ officialExecuteMock.mockResolvedValue({ content: [{ type: "text", text: "fg" }], details: undefined });
419
+ const tool = createBashOverrideToolDefinition();
420
+ await tool.execute("call-f5", { command: "npm test" }, undefined, undefined, createCtx() as never);
421
+
422
+ expect(spawnBackgroundTaskMock).not.toHaveBeenCalled();
423
+ expect(officialExecuteMock).toHaveBeenCalledTimes(1);
424
+ });
425
+
426
+ it("用户正则命中 → 强制后台且 result 引用用户 pattern 字面量", async () => {
427
+ setupFactory();
428
+ useConfig({ disableBuiltinForcePatterns: true, forceBackgroundPatterns: ["sleep \\d+"] });
429
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("sleep 999"));
430
+ const tool = createBashOverrideToolDefinition();
431
+ const result = await tool.execute("call-f6", { command: "sleep 999" }, undefined, undefined, createCtx() as never);
432
+
433
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledTimes(1);
434
+ expect(resultText(result)).toContain("matched force-background whitelist user pattern 'sleep \\d+'");
435
+ });
436
+
437
+ it("非命中 + 显式 background:true → 正常显式后台分支(非 forced 注记)", async () => {
438
+ setupFactory();
439
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("sleep 5"));
440
+ const tool = createBashOverrideToolDefinition();
441
+ const result = await tool.execute(
442
+ "call-f7",
443
+ { command: "sleep 5", background: true },
444
+ undefined,
445
+ undefined,
446
+ createCtx() as never,
447
+ );
448
+
449
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledTimes(1);
450
+ const text = resultText(result);
451
+ expect(text).toContain("task_id: bt-1724589012-ffff");
452
+ expect(text).not.toContain("Forced to background");
453
+ });
454
+
455
+ it("D14:subagent 降级全量——白名单不生效、background 忽略、显式 timeout 前台透传", async () => {
456
+ setupFactory();
457
+ isSubagentMock.mockReturnValue(true);
458
+ officialExecuteMock.mockResolvedValue({ content: [{ type: "text", text: "sync" }], details: undefined });
459
+ const tool = createBashOverrideToolDefinition();
460
+ await tool.execute(
461
+ "call-f8",
462
+ { command: "npm test", background: true, timeout: 120 },
463
+ undefined,
464
+ undefined,
465
+ createCtx() as never,
466
+ );
467
+
468
+ expect(spawnBackgroundTaskMock).not.toHaveBeenCalled();
469
+ expect(officialExecuteMock).toHaveBeenCalledWith(
470
+ "call-f8",
471
+ { command: "npm test", timeout: 120 },
472
+ undefined,
473
+ undefined,
474
+ expect.anything(),
475
+ );
476
+ });
477
+ });
478
+
479
+ describe("timeout injection dual-mode (M4, 优先级 LLM 显式 > 配置默认 > 不限)", () => {
480
+ it("前台:未填 timeout + foregroundTimeoutSeconds=42 → 注入官方委托", async () => {
481
+ setupFactory();
482
+ useConfig({ foregroundTimeoutSeconds: 42 });
483
+ officialExecuteMock.mockResolvedValue({ content: [], details: undefined });
484
+ const tool = createBashOverrideToolDefinition();
485
+ await tool.execute("t1", { command: "ls" }, undefined, undefined, createCtx() as never);
486
+
487
+ expect(officialExecuteMock).toHaveBeenCalledWith("t1", { command: "ls", timeout: 42 }, undefined, undefined, expect.anything());
488
+ });
489
+
490
+ it("D14 × G3 正交:subagent 降级不关前台超时注入(未填 timeout → 配置默认照常注入前台委托)", async () => {
491
+ // 锁行为:前台注入路径不检查 subagent(bash-tool.ts G3 语义)——降级只废
492
+ // background/白名单,subagent 内长命令仍受全局前台默认超时挂死保护
493
+ setupFactory();
494
+ isSubagentMock.mockReturnValue(true);
495
+ useConfig({ foregroundTimeoutSeconds: 42 });
496
+ officialExecuteMock.mockResolvedValue({ content: [], details: undefined });
497
+ const tool = createBashOverrideToolDefinition();
498
+ await tool.execute(
499
+ "t-sub-fg",
500
+ { command: "sleep 30", background: true },
501
+ undefined,
502
+ undefined,
503
+ createCtx() as never,
504
+ );
505
+
506
+ expect(spawnBackgroundTaskMock).not.toHaveBeenCalled();
507
+ expect(officialExecuteMock).toHaveBeenCalledWith(
508
+ "t-sub-fg",
509
+ { command: "sleep 30", timeout: 42 },
510
+ undefined,
511
+ undefined,
512
+ expect.anything(),
513
+ );
514
+ });
515
+
516
+ it("前台:显式 7 优先于配置默认 42", async () => {
517
+ setupFactory();
518
+ useConfig({ foregroundTimeoutSeconds: 42 });
519
+ officialExecuteMock.mockResolvedValue({ content: [], details: undefined });
520
+ const tool = createBashOverrideToolDefinition();
521
+ await tool.execute("t2", { command: "ls", timeout: 7 }, undefined, undefined, createCtx() as never);
522
+
523
+ expect(officialExecuteMock).toHaveBeenCalledWith("t2", { command: "ls", timeout: 7 }, undefined, undefined, expect.anything());
524
+ });
525
+
526
+ it("前台:均未配置 → 不注入(timeout undefined = pi 原生不限时,D4)", async () => {
527
+ setupFactory();
528
+ officialExecuteMock.mockResolvedValue({ content: [], details: undefined });
529
+ const tool = createBashOverrideToolDefinition();
530
+ await tool.execute("t3", { command: "ls" }, undefined, undefined, createCtx() as never);
531
+
532
+ expect(officialExecuteMock).toHaveBeenCalledWith("t3", { command: "ls", timeout: undefined }, undefined, undefined, expect.anything());
533
+ });
534
+
535
+ it("后台:未填 timeout + backgroundTimeoutSeconds=45 → 注入 spawn", async () => {
536
+ setupFactory();
537
+ useConfig({ backgroundTimeoutSeconds: 45 });
538
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("sleep 5"));
539
+ const tool = createBashOverrideToolDefinition();
540
+ await tool.execute("t4", { command: "sleep 5", background: true }, undefined, undefined, createCtx() as never);
541
+
542
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledWith(expect.objectContaining({ timeoutSec: 45 }));
543
+ });
544
+
545
+ it("后台:显式 9 优先于配置默认 45", async () => {
546
+ setupFactory();
547
+ useConfig({ backgroundTimeoutSeconds: 45 });
548
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("sleep 5"));
549
+ const tool = createBashOverrideToolDefinition();
550
+ await tool.execute("t5", { command: "sleep 5", background: true, timeout: 9 }, undefined, undefined, createCtx() as never);
551
+
552
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledWith(expect.objectContaining({ timeoutSec: 9 }));
553
+ });
554
+
555
+ it("后台:均未配置 → 不限(timeoutSec undefined)", async () => {
556
+ setupFactory();
557
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("sleep 5"));
558
+ const tool = createBashOverrideToolDefinition();
559
+ await tool.execute("t6", { command: "sleep 5", background: true }, undefined, undefined, createCtx() as never);
560
+
561
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledWith(expect.objectContaining({ timeoutSec: undefined }));
562
+ });
563
+
564
+ it("并发上限走配置 maxConcurrentBackground(缺省 8)", async () => {
565
+ setupFactory();
566
+ useConfig({ maxConcurrentBackground: 3 });
567
+ spawnBackgroundTaskMock.mockReturnValue(makeSpawnedTask("sleep 5"));
568
+ const tool = createBashOverrideToolDefinition();
569
+ await tool.execute("t7", { command: "sleep 5", background: true }, undefined, undefined, createCtx() as never);
570
+
571
+ expect(spawnBackgroundTaskMock).toHaveBeenCalledWith(expect.objectContaining({ maxConcurrent: 3 }));
572
+ });
573
+ });