@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,193 @@
1
+ // src/__tests__/config.test.ts —— M4 配置 normalize 全矩阵 + 读时刷新热重载
2
+ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
7
+
8
+ // logger mock:断言「单键回退默认 + warn」的可证伪性(真实 logger 行为不在本文件验证面)
9
+ const { warnMock } = vi.hoisted(() => ({ warnMock: vi.fn() }));
10
+ vi.mock("@zhushanwen/pi-extension-logger", () => ({
11
+ getLogger: () => ({ debug: vi.fn(), warn: warnMock, error: vi.fn() }),
12
+ }));
13
+
14
+ // getAgentDir → 测试临时目录(loadConfig 经 llm-shared getConfigPath 用它推导路径)
15
+ const { agentDirRef } = vi.hoisted(() => ({ agentDirRef: { dir: "" } }));
16
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
17
+ getAgentDir: () => agentDirRef.dir,
18
+ }));
19
+
20
+ import { clearConfigCache } from "@zhushanwen/pi-llm-shared";
21
+
22
+ import {
23
+ DEFAULT_BASE_TOOL_ENHANCE_CONFIG,
24
+ getConfigFilePath,
25
+ loadBaseToolEnhanceConfig,
26
+ normalizeBaseToolEnhanceConfig,
27
+ } from "../config.ts";
28
+
29
+ let tempDir: string;
30
+
31
+ beforeEach(() => {
32
+ tempDir = mkdtempSync(join(tmpdir(), "bte-config-"));
33
+ agentDirRef.dir = tempDir;
34
+ warnMock.mockClear();
35
+ clearConfigCache();
36
+ });
37
+
38
+ afterEach(() => {
39
+ rmSync(tempDir, { recursive: true, force: true });
40
+ });
41
+
42
+ function writeConfigFile(content: string): void {
43
+ const path = getConfigFilePath();
44
+ mkdirSync(join(path, ".."), { recursive: true });
45
+ writeFileSync(path, content, "utf8");
46
+ }
47
+
48
+ describe("normalizeBaseToolEnhanceConfig(键级矩阵)", () => {
49
+ it("合法全量配置原样通过(含 null timeout 与正则数组)", () => {
50
+ const config = normalizeBaseToolEnhanceConfig({
51
+ forceBackgroundPatterns: ["sleep \\d+", "make .*"],
52
+ disableBuiltinForcePatterns: true,
53
+ foregroundTimeoutSeconds: 30,
54
+ backgroundTimeoutSeconds: 600,
55
+ maxConcurrentBackground: 16,
56
+ });
57
+ expect(config).toEqual({
58
+ forceBackgroundPatterns: ["sleep \\d+", "make .*"],
59
+ disableBuiltinForcePatterns: true,
60
+ foregroundTimeoutSeconds: 30,
61
+ backgroundTimeoutSeconds: 600,
62
+ maxConcurrentBackground: 16,
63
+ });
64
+ expect(warnMock).not.toHaveBeenCalled();
65
+ });
66
+
67
+ it("零输入(undefined/null/空对象)→ 全默认值", () => {
68
+ for (const raw of [undefined, null, {}]) {
69
+ expect(normalizeBaseToolEnhanceConfig(raw)).toEqual(DEFAULT_BASE_TOOL_ENHANCE_CONFIG);
70
+ }
71
+ // null/undefined 是合法缺省,不 warn;空对象也不 warn
72
+ expect(warnMock).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("根非对象(数组/字符串/数字)→ 全默认 + warn 指向路径", () => {
76
+ expect(normalizeBaseToolEnhanceConfig(["npm test"])).toEqual(DEFAULT_BASE_TOOL_ENHANCE_CONFIG);
77
+ expect(warnMock).toHaveBeenCalledTimes(1);
78
+ expect(warnMock.mock.calls[0]?.[0]).toContain("not a JSON object");
79
+ expect(warnMock.mock.calls[0]?.[0]).toContain("base-tool-enhance-ext-config.json");
80
+ });
81
+
82
+ it("timeout:负数/0/字符串/NaN/Infinity → 回退 null + warn", () => {
83
+ for (const bad of [-5, 0, "10", Number.NaN, Number.POSITIVE_INFINITY]) {
84
+ warnMock.mockClear();
85
+ const config = normalizeBaseToolEnhanceConfig({ foregroundTimeoutSeconds: bad });
86
+ expect(config.foregroundTimeoutSeconds).toBeNull();
87
+ expect(warnMock).toHaveBeenCalledTimes(1);
88
+ }
89
+ const config2 = normalizeBaseToolEnhanceConfig({ backgroundTimeoutSeconds: -1 });
90
+ expect(config2.backgroundTimeoutSeconds).toBeNull();
91
+ expect(warnMock).toHaveBeenCalled();
92
+ });
93
+
94
+ it("timeout:换算毫秒超 int32 上限 → clamp 至 int32 毫秒对应秒数 + warn", () => {
95
+ const config = normalizeBaseToolEnhanceConfig({ foregroundTimeoutSeconds: 1_000_000_000 });
96
+ // int32 ms 上限 2147483647 → 2147483.647s
97
+ expect(config.foregroundTimeoutSeconds).toBeCloseTo(2147483.647, 3);
98
+ expect(warnMock).toHaveBeenCalledTimes(1);
99
+ expect(warnMock.mock.calls[0]?.[0]).toContain("clamped");
100
+ // 上限内(2147483s = 2147483000ms < int32 max)不 clamp
101
+ expect(normalizeBaseToolEnhanceConfig({ foregroundTimeoutSeconds: 2_147_483 }).foregroundTimeoutSeconds).toBe(
102
+ 2_147_483,
103
+ );
104
+ });
105
+
106
+ it("forceBackgroundPatterns:非数组 → 空 + warn;缺省/null → 空且不 warn", () => {
107
+ expect(normalizeBaseToolEnhanceConfig({ forceBackgroundPatterns: "sleep \\d+" }).forceBackgroundPatterns).toEqual(
108
+ [],
109
+ );
110
+ expect(warnMock).toHaveBeenCalledTimes(1);
111
+ warnMock.mockClear();
112
+ expect(normalizeBaseToolEnhanceConfig({}).forceBackgroundPatterns).toEqual([]);
113
+ expect(normalizeBaseToolEnhanceConfig({ forceBackgroundPatterns: null }).forceBackgroundPatterns).toEqual([]);
114
+ expect(warnMock).not.toHaveBeenCalled();
115
+ });
116
+
117
+ it("forceBackgroundPatterns:单条坏正则仅丢弃该条,其余保留(一条坏正则不拖垮全部)", () => {
118
+ const config = normalizeBaseToolEnhanceConfig({
119
+ forceBackgroundPatterns: ["sleep \\d+", "([bad", "make .*", 42],
120
+ });
121
+ expect(config.forceBackgroundPatterns).toEqual(["sleep \\d+", "make .*"]);
122
+ expect(warnMock).toHaveBeenCalledTimes(2); // 坏正则 + 非字符串各一条
123
+ expect(warnMock.mock.calls[0]?.[0]).toContain("not a valid regex");
124
+ });
125
+
126
+ it("disableBuiltinForcePatterns:非 boolean → false + warn;boolean 原样", () => {
127
+ expect(normalizeBaseToolEnhanceConfig({ disableBuiltinForcePatterns: true }).disableBuiltinForcePatterns).toBe(
128
+ true,
129
+ );
130
+ expect(normalizeBaseToolEnhanceConfig({ disableBuiltinForcePatterns: false }).disableBuiltinForcePatterns).toBe(
131
+ false,
132
+ );
133
+ const config = normalizeBaseToolEnhanceConfig({ disableBuiltinForcePatterns: "yes" });
134
+ expect(config.disableBuiltinForcePatterns).toBe(false);
135
+ expect(warnMock).toHaveBeenCalledTimes(1);
136
+ });
137
+
138
+ it("maxConcurrentBackground:0/负数/字符串 → 默认 8 + warn;小数 floor;合法整数原样", () => {
139
+ for (const bad of [0, -1, "8", Number.NaN]) {
140
+ warnMock.mockClear();
141
+ expect(normalizeBaseToolEnhanceConfig({ maxConcurrentBackground: bad }).maxConcurrentBackground).toBe(8);
142
+ expect(warnMock).toHaveBeenCalledTimes(1);
143
+ }
144
+ expect(normalizeBaseToolEnhanceConfig({ maxConcurrentBackground: 4.9 }).maxConcurrentBackground).toBe(4);
145
+ expect(normalizeBaseToolEnhanceConfig({ maxConcurrentBackground: 16 }).maxConcurrentBackground).toBe(16);
146
+ });
147
+
148
+ it("未知键忽略(前向兼容,不 warn 不拒载)", () => {
149
+ const config = normalizeBaseToolEnhanceConfig({ futureKey: "x", foregroundTimeoutSeconds: 5 });
150
+ expect(config.foregroundTimeoutSeconds).toBe(5);
151
+ expect(warnMock).not.toHaveBeenCalled();
152
+ });
153
+ });
154
+
155
+ describe("loadBaseToolEnhanceConfig(llm-shared loadConfig 集成)", () => {
156
+ it("文件不存在 → 全默认值(工具照常工作)", () => {
157
+ expect(loadBaseToolEnhanceConfig()).toEqual(DEFAULT_BASE_TOOL_ENHANCE_CONFIG);
158
+ });
159
+
160
+ it("文件整体损坏(坏 JSON)→ 全默认值 + warn 指向路径", () => {
161
+ writeConfigFile("{ not json !!!");
162
+ const config = loadBaseToolEnhanceConfig();
163
+ expect(config).toEqual(DEFAULT_BASE_TOOL_ENHANCE_CONFIG);
164
+ expect(warnMock).toHaveBeenCalledTimes(1);
165
+ expect(warnMock.mock.calls[0]?.[0]).toContain("base-tool-enhance-ext-config.json");
166
+ });
167
+
168
+ it("键级坏配置经 loadConfig 读取同样键级回退(normalize 永不整体拒载)", () => {
169
+ writeConfigFile(JSON.stringify({ foregroundTimeoutSeconds: -3, maxConcurrentBackground: 4 }));
170
+ const config = loadBaseToolEnhanceConfig();
171
+ expect(config.foregroundTimeoutSeconds).toBeNull();
172
+ expect(config.maxConcurrentBackground).toBe(4);
173
+ });
174
+
175
+ it("热重载:改配置文件后(不重启、不手动刷新)再次 load 拿到新值(mtime+size 读时刷新)", () => {
176
+ writeConfigFile(JSON.stringify({ foregroundTimeoutSeconds: 11 }));
177
+ expect(loadBaseToolEnhanceConfig().foregroundTimeoutSeconds).toBe(11);
178
+
179
+ // 内容变化(size 变化触发 mtime+size 缓存失效)
180
+ writeConfigFile(JSON.stringify({ foregroundTimeoutSeconds: 222 }));
181
+ expect(loadBaseToolEnhanceConfig().foregroundTimeoutSeconds).toBe(222);
182
+
183
+ // 删除文件 → 回默认(缓存同样失效)
184
+ rmSync(getConfigFilePath());
185
+ expect(loadBaseToolEnhanceConfig()).toEqual(DEFAULT_BASE_TOOL_ENHANCE_CONFIG);
186
+ });
187
+
188
+ it("配置文件路径 = <agentDir>/config/base-tool-enhance-ext-config.json", () => {
189
+ expect(getConfigFilePath()).toBe(join(tempDir, "config", "base-tool-enhance-ext-config.json"));
190
+ writeConfigFile("{}");
191
+ expect(existsSync(getConfigFilePath())).toBe(true);
192
+ });
193
+ });
@@ -0,0 +1,230 @@
1
+ // src/__tests__/force-patterns.test.ts —— M4 白名单:内置两组命中样例 / 锚定语义 /
2
+ // 合并矩阵三态 / 用户正则。锚定四例(参数文本不误伤)是设计 §3.5 的强制断言。
3
+ import { describe, expect, it } from "vitest";
4
+
5
+ import {
6
+ compileForcePatterns,
7
+ describeForceMatch,
8
+ matchForceBackground,
9
+ type ForcePattern,
10
+ } from "../force-patterns.ts";
11
+
12
+ function match(command: string, patterns: ForcePattern[]) {
13
+ return matchForceBackground(command, patterns);
14
+ }
15
+
16
+ const DEFAULT_PATTERNS = compileForcePatterns([], false);
17
+
18
+ describe("内置 force-test 组命中样例(迁自 unified-hooks test-timeout-guard)", () => {
19
+ const cases: Array<[string, string]> = [
20
+ ["npm test", "npm test"],
21
+ ["pnpm test", "npm test"],
22
+ ["yarn test", "npm test"],
23
+ ["bun test", "npm test"],
24
+ ["npm run test:unit", "npm run test"],
25
+ ["pnpm --filter @x/y run test", "npm run test"],
26
+ ["npx vitest run src/foo.test.ts", "vitest"],
27
+ ["npx jest --silent", "jest"],
28
+ ["npx mocha test/", "mocha"],
29
+ ["npx playwright test", "e2e runner"],
30
+ ["npx cypress run", "e2e runner"],
31
+ ["npx vue-cli-service test:unit", "vue-cli test"],
32
+ ["npx react-scripts test", "react-scripts test"],
33
+ ["./node_modules/.bin/vitest", "direct test runner"],
34
+ ["pytest -q", "pytest"],
35
+ ["python -m pytest", "python test"],
36
+ ["python3 -m unittest discover", "python test"],
37
+ ["uv run pytest", "uv/poetry pytest"],
38
+ ["poetry run pytest -x", "uv/poetry pytest"],
39
+ ["nosetests tests/", "nosetests"],
40
+ ["mvn test", "maven test"],
41
+ ["./mvnw test", "maven test"],
42
+ ["mvn integration-test", "maven test"],
43
+ ["gradle test --info", "gradle test"],
44
+ ["./gradlew test", "gradle test"],
45
+ ["sbt test", "sbt test"],
46
+ ["go test ./...", "go test"],
47
+ ["cargo test --all", "cargo test"],
48
+ ["dotnet test", "dotnet test"],
49
+ ["rspec spec/", "rspec"],
50
+ ["bundle exec rspec", "rspec"],
51
+ ["rake test", "rake test"],
52
+ ];
53
+ for (const [command, label] of cases) {
54
+ it(`${command} → label '${label}'`, () => {
55
+ const result = match(command, DEFAULT_PATTERNS);
56
+ expect(result).toBeDefined();
57
+ expect(result?.source).toBe("builtin-test");
58
+ expect(result?.name).toBe("test");
59
+ expect(result?.label).toBe(label);
60
+ });
61
+ }
62
+ });
63
+
64
+ describe("内置 force-longrun 组命中样例(M4 定稿清单)", () => {
65
+ const hitCases: Array<[string, string]> = [
66
+ // dev server:npm run dev 系
67
+ ["npm run dev", "package run dev"],
68
+ ["pnpm run dev", "package run dev"],
69
+ ["yarn run dev", "package run dev"],
70
+ ["pnpm --filter web run dev", "package run dev"],
71
+ ["npm run dev:web", "package run dev"],
72
+ ["pnpm dev", "package dev"],
73
+ ["yarn dev", "package dev"],
74
+ ["bun dev", "package dev"],
75
+ // dev server:npx 直接调用
76
+ ["npx vite", "vite"],
77
+ ["npx vite --port 5173", "vite"],
78
+ ["npx next dev", "next dev"],
79
+ ["npx nuxt dev", "nuxt dev"],
80
+ ["npx ng serve", "ng serve"],
81
+ ["npx webpack serve", "webpack serve"],
82
+ ["npx webpack-dev-server", "webpack-dev-server"],
83
+ // dev server:语言内置 serve
84
+ ["python -m http.server 8080", "http.server"],
85
+ ["python3 manage.py runserver", "runserver"],
86
+ ["flask run", "flask run"],
87
+ ["rails server", "rails server"],
88
+ ["rails s -p 3001", "rails server"],
89
+ ["php artisan serve", "artisan serve"],
90
+ // 显式 watch flags(直跑形态;npx vitest/jest --watch 会被 force-test 组先命中,
91
+ // 两组动作一致都是强制后台,此处用直跑形态验证 watch 条目本身)
92
+ ["vitest --watch", "vitest/jest --watch"],
93
+ ["jest --watchAll", "vitest/jest --watch"],
94
+ ["tsc --watch", "tsc watch"],
95
+ ["tsc -w", "tsc watch"],
96
+ ["npx tsc --noEmit --watch", "tsc watch"],
97
+ ["sass --watch src:dist", "build --watch"],
98
+ ["webpack --watch", "build --watch"],
99
+ ["npx esbuild src.js --watch --outdir=dist", "build --watch"],
100
+ // 天然长驻
101
+ ["cargo watch -x test", "cargo watch"],
102
+ ["watchexec make build", "watchexec"],
103
+ ["nodemon server.js", "nodemon"],
104
+ ["tail -f /var/log/system.log", "tail -f"],
105
+ ["tail -n 100 -f app.log", "tail -f"],
106
+ ["tail -F /var/log/syslog", "tail -f"],
107
+ ["ngrok http 3000", "ngrok"],
108
+ ];
109
+ for (const [command, label] of hitCases) {
110
+ it(`${command} → label '${label}'`, () => {
111
+ const result = match(command, DEFAULT_PATTERNS);
112
+ expect(result).toBeDefined();
113
+ expect(result?.source).toBe("builtin-longrun");
114
+ expect(result?.name).toBe("longrun");
115
+ expect(result?.label).toBe(label);
116
+ });
117
+ }
118
+
119
+ const missCases = [
120
+ // dev server 误伤排除:build 有自然退出点
121
+ "npm run build",
122
+ "npx vite build",
123
+ "vite build --watch", // 非 npx 直跑 vite build(罕见形态)不做工具名白名单外的猜测
124
+ "npm run lint",
125
+ "npm run deploy",
126
+ // watch flag 形态排除:显式关 watch / 无 watch flag
127
+ "vitest --watch=false",
128
+ "jest --watchAll=false",
129
+ "tsc --noEmit",
130
+ "sass src:dist",
131
+ // tail 无 -f
132
+ "tail -n 20 app.log",
133
+ "tail app.log",
134
+ // 相似词不误伤
135
+ "tailwind -i input.css",
136
+ "cargo build",
137
+ "watch_node_modules.sh",
138
+ "dev_setup.sh",
139
+ ];
140
+ for (const command of missCases) {
141
+ it(`${command} 不命中任何组(build/lint/无 watch flag 有自然退出点或非命令文本)`, () => {
142
+ expect(match(command, DEFAULT_PATTERNS)).toBeUndefined();
143
+ });
144
+ }
145
+ });
146
+
147
+ describe("锚定语义(命令位置锚定,防参数文本误伤——§3.5 强制四例)", () => {
148
+ const cases: Array<{ command: string; hit: boolean; why: string }> = [
149
+ // ① 必须不命中:commit message 参数里的 "npm test" 是文本不是命令
150
+ { command: `git commit -m "fix: npm test"`, hit: false, why: "参数文本不是命令位置" },
151
+ // ② 必须不命中:watch 是 grep 的参数值,不是 flag
152
+ { command: "rg --files | grep watch", hit: false, why: "grep watch 的 watch 是参数不是 flag" },
153
+ // ③ 必须命中:&& 之后是新命令起始位
154
+ { command: "echo hi && npm test", hit: true, why: "&& 之后是命令位置" },
155
+ // ④ 必须命中:行首
156
+ { command: "npm test", hit: true, why: "行首命令位置" },
157
+ ];
158
+ for (const { command, hit, why } of cases) {
159
+ it(`${JSON.stringify(command)} ${hit ? "命中" : "不命中"}(${why})`, () => {
160
+ expect(match(command, DEFAULT_PATTERNS) !== undefined).toBe(hit);
161
+ });
162
+ }
163
+
164
+ it("其余命令位置分隔符(; / || / | / 换行)之后的命令同样命中", () => {
165
+ expect(match("cd /tmp; npm test", DEFAULT_PATTERNS)).toBeDefined();
166
+ expect(match("false || npx vitest", DEFAULT_PATTERNS)).toBeDefined();
167
+ expect(match("git pull\nnpm test", DEFAULT_PATTERNS)).toBeDefined();
168
+ expect(match("rg --files | npx jest", DEFAULT_PATTERNS)).toBeDefined();
169
+ // 引号内的命令文本不命中(引号不是命令分隔符)
170
+ expect(match(`echo "vitest --watch"`, DEFAULT_PATTERNS)).toBeUndefined();
171
+ expect(match(`echo "npm run dev"`, DEFAULT_PATTERNS)).toBeUndefined();
172
+ expect(match(`git commit -m "run dev server"`, DEFAULT_PATTERNS)).toBeUndefined();
173
+ expect(match(`grep tail -f README.md`, DEFAULT_PATTERNS)).toBeUndefined();
174
+ // 词中子串不命中
175
+ expect(match("npm testing", DEFAULT_PATTERNS)).toBeUndefined();
176
+ expect(match("mytsc --watchish", DEFAULT_PATTERNS)).toBeUndefined();
177
+ });
178
+ });
179
+
180
+ describe("合并矩阵(§3.5 配置开关 × 组)", () => {
181
+ it("零配置:两组内置均生效", () => {
182
+ const patterns = compileForcePatterns([], false);
183
+ expect(match("npm test", patterns)?.source).toBe("builtin-test");
184
+ expect(match("tail -f app.log", patterns)?.source).toBe("builtin-longrun");
185
+ });
186
+
187
+ it("disableBuiltinForcePatterns:true:两组内置关闭", () => {
188
+ const patterns = compileForcePatterns([], true);
189
+ expect(match("npm test", patterns)).toBeUndefined();
190
+ expect(match("tail -f app.log", patterns)).toBeUndefined();
191
+ expect(match("npx vite", patterns)).toBeUndefined();
192
+ });
193
+
194
+ it("用户正则追加(与内置并存,匹配任一即命中)", () => {
195
+ const patterns = compileForcePatterns(["sleep \\d+"], false);
196
+ expect(match("sleep 999", patterns)?.source).toBe("user");
197
+ // 内置两组不受影响
198
+ expect(match("npm test", patterns)?.source).toBe("builtin-test");
199
+ expect(match("ngrok http 3000", patterns)?.source).toBe("builtin-longrun");
200
+ });
201
+
202
+ it("disableBuiltin + 用户正则:仅用户正则生效", () => {
203
+ const patterns = compileForcePatterns(["sleep \\d+"], true);
204
+ expect(match("sleep 999", patterns)?.source).toBe("user");
205
+ expect(match("npm test", patterns)).toBeUndefined();
206
+ });
207
+
208
+ it("用户正则同样命令位置锚定:参数文本不误伤、链式后段命中", () => {
209
+ const patterns = compileForcePatterns(["sleep \\d+"], false);
210
+ expect(match("echo sleep 999", patterns)).toBeUndefined();
211
+ expect(match("git commit -m \"sleep 999\"", patterns)).toBeUndefined();
212
+ expect(match("echo hi && sleep 999", patterns)?.source).toBe("user");
213
+ });
214
+ });
215
+
216
+ describe("匹配结果报告(result 文案引用)", () => {
217
+ it("内置命中报组名 + 条目标签", () => {
218
+ expect(describeForceMatch(match("npm test", DEFAULT_PATTERNS)!)).toBe("pattern 'test' (npm test)");
219
+ expect(describeForceMatch(match("tail -f x", DEFAULT_PATTERNS)!)).toBe("pattern 'longrun' (tail -f)");
220
+ });
221
+
222
+ it("用户正则报字面量前 40 字符(超长截断加省略号)", () => {
223
+ const short = compileForcePatterns(["sleep \\d+"], true);
224
+ expect(describeForceMatch(match("sleep 1", short)!)).toBe("user pattern 'sleep \\d+'");
225
+ const longPattern = "a".repeat(45);
226
+ const long = compileForcePatterns([longPattern], true);
227
+ const described = describeForceMatch(match("a".repeat(45), long)!);
228
+ expect(described).toBe(`user pattern '${"a".repeat(40)}…'`);
229
+ });
230
+ });
@@ -0,0 +1,133 @@
1
+ // src/__tests__/index.test.ts —— 入口集成:工具注册(bash / bash_output / bash_kill)+
2
+ // 审计 hook 挂载 + M3 接线(D17 引用刷新 / exit 边沿通知 / session_start 对账链)
3
+ import { mkdtempSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ import { describe, expect, it, vi } from "vitest";
8
+
9
+ const { createBashToolDefinitionMock, dataDirRef } = vi.hoisted(() => ({
10
+ createBashToolDefinitionMock: vi.fn(),
11
+ dataDirRef: { dir: "/tmp/bte-fake-agent-dir" },
12
+ }));
13
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
14
+ createBashToolDefinition: createBashToolDefinitionMock,
15
+ getAgentDir: () => dataDirRef.dir,
16
+ }));
17
+
18
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
+
20
+ import { getRegistryPath, writeRegistryEntry } from "../background/registry.ts";
21
+ import { resetNotifyForTest } from "../background/notify.ts";
22
+ import baseToolEnhanceExtension from "../index.ts";
23
+
24
+ function createMockPi() {
25
+ return {
26
+ registerTool: vi.fn(),
27
+ on: vi.fn(),
28
+ appendEntry: vi.fn(),
29
+ events: { emit: vi.fn(), on: vi.fn() },
30
+ sendMessage: vi.fn(),
31
+ };
32
+ }
33
+
34
+ function setupOfficialFactory() {
35
+ createBashToolDefinitionMock.mockReset();
36
+ createBashToolDefinitionMock.mockReturnValue({
37
+ name: "bash",
38
+ label: "bash",
39
+ description: "official",
40
+ parameters: {},
41
+ execute: vi.fn(),
42
+ });
43
+ }
44
+
45
+ describe("baseToolEnhanceExtension entry", () => {
46
+ it("registers bash (override), bash_output and bash_kill tools by name", () => {
47
+ setupOfficialFactory();
48
+ const pi = createMockPi();
49
+
50
+ baseToolEnhanceExtension(pi as unknown as ExtensionAPI);
51
+
52
+ expect(pi.registerTool).toHaveBeenCalledTimes(3);
53
+ const names = pi.registerTool.mock.calls.map((call) => (call[0] as { name: string }).name);
54
+ expect(names).toEqual(["bash", "bash_output", "bash_kill"]);
55
+ });
56
+
57
+ it("mounts the tool error audit hook", () => {
58
+ setupOfficialFactory();
59
+ const pi = createMockPi();
60
+
61
+ baseToolEnhanceExtension(pi as unknown as ExtensionAPI);
62
+
63
+ expect(pi.on).toHaveBeenCalledWith("tool_execution_end", expect.any(Function));
64
+ });
65
+
66
+ it("registers a session_start handler (reaper + reconcile chain, M5/M3)", () => {
67
+ setupOfficialFactory();
68
+ const pi = createMockPi();
69
+
70
+ baseToolEnhanceExtension(pi as unknown as ExtensionAPI);
71
+
72
+ expect(pi.on).toHaveBeenCalledWith("session_start", expect.any(Function));
73
+ });
74
+ });
75
+
76
+ describe("session_start chain: reaper first, reconcile after (M3)", () => {
77
+ it("settles a zombie register via appendEntry on the SAME pi reference after reaper", async () => {
78
+ setupOfficialFactory();
79
+ // 独立临时 dataDir:reaper 对空目录 no-op,registry 预置终态僵尸条目
80
+ const dataDir = mkdtempSync(join(tmpdir(), "bte-index-"));
81
+ dataDirRef.dir = dataDir;
82
+ try {
83
+ const sessionId = "sess-index";
84
+ writeRegistryEntry(getRegistryPath(dataDir, sessionId), {
85
+ taskId: "bt-1700000000-idx001",
86
+ pid: 12345,
87
+ command: "sleep 3600",
88
+ outputFile: "/tmp/idx.log",
89
+ startedAt: 1_700_000_000_000,
90
+ state: "orphaned",
91
+ ownerPiPid: 1,
92
+ sessionId,
93
+ });
94
+ const pi = createMockPi();
95
+ baseToolEnhanceExtension(pi as unknown as ExtensionAPI);
96
+
97
+ const handler = pi.on.mock.calls.find((call) => call[0] === "session_start")?.[1] as (
98
+ event: unknown,
99
+ ctx: { sessionManager: { getSessionId: () => string; getEntries: () => unknown[] } },
100
+ ) => void;
101
+ expect(handler).toBeDefined();
102
+ handler(undefined, {
103
+ sessionManager: {
104
+ getSessionId: () => sessionId,
105
+ getEntries: () => [
106
+ {
107
+ customType: "pending:register",
108
+ data: { id: "bt-1700000000-idx001", type: "bash", name: "sleep 3600" },
109
+ },
110
+ ],
111
+ },
112
+ });
113
+
114
+ // handler 是 fire-and-forget(内部 await reaper 后同步对账)——等待落定
115
+ await vi.waitFor(() =>
116
+ expect(pi.appendEntry).toHaveBeenCalledWith("pending:unregister", {
117
+ id: "bt-1700000000-idx001",
118
+ reason: "cancelled",
119
+ status: "cancelled",
120
+ }),
121
+ );
122
+ // 对账之外尽力补一次 emit(listener 内存视图同步,失败无害)
123
+ expect(pi.events.emit).toHaveBeenCalledWith("pending:unregister", {
124
+ id: "bt-1700000000-idx001",
125
+ reason: "cancelled",
126
+ });
127
+ } finally {
128
+ rmSync(dataDir, { recursive: true, force: true });
129
+ dataDirRef.dir = "/tmp/bte-fake-agent-dir";
130
+ resetNotifyForTest();
131
+ }
132
+ });
133
+ });
@@ -0,0 +1,76 @@
1
+ // src/__tests__/kill-tree.test.ts —— 进程树 kill 真实进程验证(POSIX 进程组语义)
2
+ import { spawn } from "node:child_process";
3
+
4
+ import { describe, expect, it, vi } from "vitest";
5
+
6
+ vi.setConfig({ testTimeout: 20000 });
7
+
8
+ import { isPidAlive, killProcessTree } from "../kill-tree.ts";
9
+
10
+ function sleep(ms: number): Promise<void> {
11
+ return new Promise((resolve) => setTimeout(resolve, ms));
12
+ }
13
+
14
+ /** 真实 detached spawn(与本包后台任务同款形态:自成进程组)。 */
15
+ function spawnDetached(command: string) {
16
+ const child = spawn("/bin/sh", ["-c", command], {
17
+ detached: true,
18
+ stdio: "ignore",
19
+ });
20
+ child.on("error", () => {});
21
+ child.unref();
22
+ return child;
23
+ }
24
+
25
+ describe("isPidAlive", () => {
26
+ it("current process is alive", () => {
27
+ expect(isPidAlive(process.pid)).toBe(true);
28
+ });
29
+
30
+ it("exited process is dead (pid freed after reap)", async () => {
31
+ const child = spawnDetached("true");
32
+ const pid = child.pid;
33
+ if (pid === undefined) throw new Error("no pid");
34
+ // 等退出 + libuv reap(SIGCHLD → waitpid 后 pid 释放)
35
+ await sleep(300);
36
+ expect(isPidAlive(pid)).toBe(false);
37
+ });
38
+
39
+ it("invalid pid (0/negative) is treated as dead", () => {
40
+ expect(isPidAlive(0)).toBe(false);
41
+ expect(isPidAlive(-1)).toBe(false);
42
+ });
43
+ });
44
+
45
+ describe("killProcessTree (process group semantics)", () => {
46
+ it("kills the whole detached process group including grandchildren", async () => {
47
+ // 组长 sh + 两个子 sleep:进程组 kill 必须全部覆盖
48
+ const child = spawnDetached("sleep 30 & sleep 30 & wait");
49
+ const pid = child.pid;
50
+ if (pid === undefined) throw new Error("no pid");
51
+ await sleep(300); // 让子 sleep 起来
52
+ expect(isPidAlive(pid)).toBe(true);
53
+
54
+ killProcessTree(pid);
55
+ await sleep(300);
56
+
57
+ expect(isPidAlive(pid)).toBe(false);
58
+ // 子进程也不得残留:枚举验证(pgrep -P 组长;组长死后 reparent,改为全 pgrep 命令串兜底)
59
+ const leftover = spawn("/usr/bin/pgrep", ["-f", "sleep 30"]);
60
+ const stdout: string[] = [];
61
+ leftover.stdout?.on("data", (d: Buffer) => stdout.push(d.toString()));
62
+ const status = await new Promise<number | null>((resolve) => leftover.on("close", resolve));
63
+ // pgrep 自身命令行含 "sleep 30" 会自匹配吗:pgrep -f 匹配其他进程,不匹配自身(pgrep 默认排除自己)
64
+ const others = stdout.join("").split("\n").filter((line) => line.trim() !== "");
65
+ expect(others).toEqual([]);
66
+ expect(status).not.toBe(0); // 无匹配 → pgrep exit 1
67
+ });
68
+
69
+ it("killing an already-dead pid is a silent no-op", async () => {
70
+ const child = spawnDetached("true");
71
+ const pid = child.pid;
72
+ if (pid === undefined) throw new Error("no pid");
73
+ await sleep(300);
74
+ expect(() => killProcessTree(pid)).not.toThrow();
75
+ });
76
+ });