@zhushanwen/pi-permission 0.0.1

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 (51) hide show
  1. package/README.md +455 -0
  2. package/index.ts +2 -0
  3. package/package.json +52 -0
  4. package/src/__tests__/approval.test.ts +456 -0
  5. package/src/__tests__/commands.test.ts +487 -0
  6. package/src/__tests__/config.test.ts +229 -0
  7. package/src/__tests__/e2e-modes.test.ts +253 -0
  8. package/src/__tests__/extension.test.ts +170 -0
  9. package/src/__tests__/index-integration.test.ts +282 -0
  10. package/src/__tests__/model-picker.test.ts +371 -0
  11. package/src/__tests__/pipeline.test.ts +976 -0
  12. package/src/__tests__/production.test.ts +102 -0
  13. package/src/__tests__/rule-editor-component.test.ts +475 -0
  14. package/src/__tests__/rule-editor.test.ts +252 -0
  15. package/src/__tests__/rule-templates.test.ts +264 -0
  16. package/src/__tests__/statusline.test.ts +221 -0
  17. package/src/__tests__/types.test.ts +59 -0
  18. package/src/approval.ts +352 -0
  19. package/src/ast/__tests__/analyzer.test.ts +262 -0
  20. package/src/ast/__tests__/loader.test.ts +136 -0
  21. package/src/ast/analyzer.ts +326 -0
  22. package/src/ast/index.ts +7 -0
  23. package/src/ast/loader.ts +113 -0
  24. package/src/classifier/__tests__/classifier.test.ts +285 -0
  25. package/src/classifier/__tests__/json-parser.test.ts +118 -0
  26. package/src/classifier/__tests__/model-resolver.test.ts +173 -0
  27. package/src/classifier/__tests__/prompt.test.ts +59 -0
  28. package/src/classifier/classifier.ts +242 -0
  29. package/src/classifier/index.ts +28 -0
  30. package/src/classifier/json-parser.ts +127 -0
  31. package/src/classifier/model-resolver.ts +295 -0
  32. package/src/classifier/prompt.ts +52 -0
  33. package/src/commands.ts +234 -0
  34. package/src/config.ts +216 -0
  35. package/src/index.ts +291 -0
  36. package/src/model-picker.ts +398 -0
  37. package/src/pipeline.ts +541 -0
  38. package/src/production.ts +102 -0
  39. package/src/rule-editor-component.ts +883 -0
  40. package/src/rule-editor.ts +262 -0
  41. package/src/rule-templates.ts +332 -0
  42. package/src/rules/__tests__/builtins.test.ts +614 -0
  43. package/src/rules/__tests__/index.test.ts +26 -0
  44. package/src/rules/__tests__/matcher.test.ts +378 -0
  45. package/src/rules/__tests__/wildcard.test.ts +84 -0
  46. package/src/rules/builtins.ts +680 -0
  47. package/src/rules/index.ts +27 -0
  48. package/src/rules/matcher.ts +160 -0
  49. package/src/rules/wildcard.ts +43 -0
  50. package/src/statusline.ts +226 -0
  51. package/src/types.ts +249 -0
@@ -0,0 +1,285 @@
1
+ /**
2
+ * CT 系列:classifier.ts 单元测试(createClassifier + classifyRisk 主流程)。
3
+ *
4
+ * mock 策略:鸭子类型 mock streamSimple(返回只带 result() 的 AssistantMessageEventStream)。
5
+ *
6
+ * 重点验证:
7
+ * - C1-C4 主流程 happy path(allow/deny/ask)
8
+ * - C5-C7 fail-closed(resolveModel null / streamSimple 抛错 / 解析失败)
9
+ * - C8 timeout 兜底
10
+ * - C9 abort signal
11
+ * - C10 timeout 秒→毫秒传递
12
+ * - C11 G3 关键修正:stopReason='error' / 'aborted' → fallback(即使 result() resolve)
13
+ */
14
+ import type { AssistantMessageEventStream, SimpleStreamOptions } from "@earendil-works/pi-ai";
15
+ import { describe, expect, it } from "vitest";
16
+
17
+ import type { ClassifierConfig, ToolInvocationContext } from "../../types.js";
18
+ import type { ClassifierDeps } from "../classifier.js";
19
+ import { createClassifier } from "../classifier.js";
20
+ import type { ResolvedModel } from "../model-resolver.js";
21
+
22
+ // ──────────────────────── fixtures ────────────────────────
23
+
24
+ const FIXED_MODEL: ResolvedModel = {
25
+ provider: "test-co",
26
+ id: "test-model",
27
+ api: "openai-completions",
28
+ name: "Test Model",
29
+ baseUrl: "http://localhost",
30
+ inputCost: 0,
31
+ };
32
+
33
+ const CONFIG: ClassifierConfig = {
34
+ enabled: true,
35
+ model: "auto",
36
+ timeout: 90,
37
+ autoApproveLowRisk: true,
38
+ autoDenyHighRisk: true,
39
+ };
40
+
41
+ const CTX: ToolInvocationContext = {
42
+ toolName: "bash",
43
+ command: "ls",
44
+ cwd: "/tmp",
45
+ };
46
+
47
+ // ──────────────────────── mock helpers ────────────────────────
48
+
49
+ /** 构造「立刻 resolve 给定 AssistantMessage」的 mock EventStream(鸭子类型) */
50
+ function mockStreamFromMessage(message: unknown): AssistantMessageEventStream {
51
+ return {
52
+ result: () => Promise.resolve(message),
53
+ };
54
+ }
55
+
56
+ /** 构造「永不 resolve」的 mock EventStream(用于 timeout/abort 测试) */
57
+ function mockStreamNever(): AssistantMessageEventStream {
58
+ return {
59
+ result: () => new Promise(() => {}),
60
+ };
61
+ }
62
+
63
+ /** 构造「streamSimple 同步抛错」的 mock 函数 */
64
+ function throwingStreamSimple(error: Error): ClassifierDeps["streamSimple"] {
65
+ return () => {
66
+ throw error;
67
+ };
68
+ }
69
+
70
+ interface AssistantMessageMock {
71
+ stopReason?: string;
72
+ content?: { type: string; text?: string }[];
73
+ }
74
+
75
+ function textMessage(text: string, stopReason = "stop"): AssistantMessageMock {
76
+ return {
77
+ stopReason,
78
+ content: [{ type: "text", text }],
79
+ };
80
+ }
81
+
82
+ function makeDeps(over: Partial<ClassifierDeps> = {}): ClassifierDeps {
83
+ return {
84
+ resolveModel: () => FIXED_MODEL,
85
+ streamSimple: () => mockStreamFromMessage(textMessage('{"outcome":"allow","risk_level":"low","reasoning":"ok","confidence":0.9}')),
86
+ ...over,
87
+ };
88
+ }
89
+
90
+ /** 捕获 streamSimple 调用参数的 spy(options 含 apiKey 等)。 */
91
+ function capturingStreamSimple(message: unknown): {
92
+ spy: ClassifierDeps["streamSimple"];
93
+ getLastOptions: () => SimpleStreamOptions | undefined;
94
+ } {
95
+ let lastOptions: SimpleStreamOptions | undefined;
96
+ const spy: ClassifierDeps["streamSimple"] = (_model, _context, options?) => {
97
+ lastOptions = options;
98
+ return mockStreamFromMessage(message);
99
+ };
100
+ return { spy, getLastOptions: () => lastOptions };
101
+ }
102
+
103
+ // ──────────────────────── C1-C4: happy path ────────────────────────
104
+
105
+ describe("CT1: happy path — streamSimple resolve 正常文本", () => {
106
+ it("allow JSON → ClassifierResult.allow", async () => {
107
+ const classifier = createClassifier(makeDeps({
108
+ streamSimple: () => mockStreamFromMessage(textMessage('{"outcome":"allow","risk_level":"low","reasoning":"safe","confidence":0.9}')),
109
+ }));
110
+ const r = await classifier.classifyRisk(CTX, CONFIG);
111
+ expect(r.outcome).toBe("allow");
112
+ expect(r.risk_level).toBe("low");
113
+ expect(r.confidence).toBeCloseTo(0.9);
114
+ });
115
+
116
+ it("deny JSON → ClassifierResult.deny", async () => {
117
+ const classifier = createClassifier(makeDeps({
118
+ streamSimple: () => mockStreamFromMessage(textMessage('{"outcome":"deny","risk_level":"high","reasoning":"rm -rf /","confidence":0.95}')),
119
+ }));
120
+ const r = await classifier.classifyRisk(CTX, CONFIG);
121
+ expect(r.outcome).toBe("deny");
122
+ expect(r.risk_level).toBe("high");
123
+ });
124
+
125
+ it("ask JSON → ClassifierResult.ask", async () => {
126
+ const classifier = createClassifier(makeDeps({
127
+ streamSimple: () => mockStreamFromMessage(textMessage('{"outcome":"ask","risk_level":"medium","reasoning":"uncertain","confidence":0.5}')),
128
+ }));
129
+ const r = await classifier.classifyRisk(CTX, CONFIG);
130
+ expect(r.outcome).toBe("ask");
131
+ });
132
+
133
+ it("LLM 输出带 code fence → 正则提取仍成功", async () => {
134
+ const classifier = createClassifier(makeDeps({
135
+ streamSimple: () => mockStreamFromMessage(textMessage('```json\n{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.8}\n```')),
136
+ }));
137
+ const r = await classifier.classifyRisk(CTX, CONFIG);
138
+ expect(r.outcome).toBe("allow");
139
+ });
140
+ });
141
+
142
+ // ──────────────────────── C5-C7: fail-closed ────────────────────────
143
+
144
+ describe("CT2: fail-closed 路径", () => {
145
+ it("C5: resolveModel 返回 null → fallback ask", async () => {
146
+ const classifier = createClassifier(makeDeps({ resolveModel: () => null }));
147
+ const r = await classifier.classifyRisk(CTX, CONFIG);
148
+ expect(r.outcome).toBe("ask");
149
+ expect(r.confidence).toBe(0);
150
+ });
151
+
152
+ it("C6: streamSimple 同步抛错 → fallback ask", async () => {
153
+ const classifier = createClassifier(makeDeps({
154
+ streamSimple: throwingStreamSimple(new Error("network down")),
155
+ }));
156
+ const r = await classifier.classifyRisk(CTX, CONFIG);
157
+ expect(r.outcome).toBe("ask");
158
+ });
159
+
160
+ it("C7: LLM 输出无法解析 → parser fallback ask", async () => {
161
+ const classifier = createClassifier(makeDeps({
162
+ streamSimple: () => mockStreamFromMessage(textMessage("totally not json")),
163
+ }));
164
+ const r = await classifier.classifyRisk(CTX, CONFIG);
165
+ expect(r.outcome).toBe("ask");
166
+ expect(r.reasoning).toContain("parse failed");
167
+ });
168
+ });
169
+
170
+ // ──────────────────────── C8-C10: timeout / abort ────────────────────────
171
+
172
+ describe("CT3: timeout 与 abort 兜底", () => {
173
+ it("C8: result() 永挂 + 短 timeout → fallback ask(不卡死)", async () => {
174
+ const shortTimeoutConfig: ClassifierConfig = { ...CONFIG, timeout: 1 };
175
+ const classifier = createClassifier(makeDeps({
176
+ streamSimple: () => mockStreamNever(),
177
+ }));
178
+ const r = await classifier.classifyRisk(CTX, shortTimeoutConfig);
179
+ expect(r.outcome).toBe("ask");
180
+ }, 10000);
181
+
182
+ it("C9: abort signal 已 abort → 立刻 fallback ask", async () => {
183
+ const ac = new AbortController();
184
+ ac.abort();
185
+ const classifier = createClassifier(makeDeps({
186
+ streamSimple: () => mockStreamNever(),
187
+ }));
188
+ const r = await classifier.classifyRisk(CTX, CONFIG, ac.signal);
189
+ expect(r.outcome).toBe("ask");
190
+ });
191
+
192
+ it("C10: timeout=0 → 不设外层超时,result() 正常返回", async () => {
193
+ const noTimeoutConfig: ClassifierConfig = { ...CONFIG, timeout: 0 };
194
+ const classifier = createClassifier(makeDeps({
195
+ streamSimple: () => mockStreamFromMessage(textMessage('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.5}')),
196
+ }));
197
+ const r = await classifier.classifyRisk(CTX, noTimeoutConfig);
198
+ expect(r.outcome).toBe("allow");
199
+ });
200
+ });
201
+
202
+ // ──────────────────────── C11: G3 stopReason 修正(关键) ────────────────────────
203
+
204
+ describe("CT4 / C11: G3 — result() 不 reject,必须显式检查 stopReason", () => {
205
+ it("stopReason='error' → fallback ask(即使 result() 成功 resolve)", async () => {
206
+ const classifier = createClassifier(makeDeps({
207
+ streamSimple: () => mockStreamFromMessage(textMessage('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}', "error")),
208
+ }));
209
+ const r = await classifier.classifyRisk(CTX, CONFIG);
210
+ // 关键:result() resolve 了,但 stopReason=error → 不能当成功 allow
211
+ expect(r.outcome).toBe("ask");
212
+ expect(r.confidence).toBe(0);
213
+ });
214
+
215
+ it("stopReason='aborted' → fallback ask", async () => {
216
+ const classifier = createClassifier(makeDeps({
217
+ streamSimple: () => mockStreamFromMessage(textMessage('{"outcome":"deny","risk_level":"high","reasoning":"x","confidence":0.9}', "aborted")),
218
+ }));
219
+ const r = await classifier.classifyRisk(CTX, CONFIG);
220
+ expect(r.outcome).toBe("ask");
221
+ });
222
+
223
+ it("stopReason='stop'(正常)→ 正常解析,不受 G3 影响", async () => {
224
+ const classifier = createClassifier(makeDeps({
225
+ streamSimple: () => mockStreamFromMessage(textMessage('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}', "stop")),
226
+ }));
227
+ const r = await classifier.classifyRisk(CTX, CONFIG);
228
+ expect(r.outcome).toBe("allow");
229
+ });
230
+
231
+ it("stopReason 缺省(undefined)→ 不触发 G3 兜底,走正常解析", async () => {
232
+ const classifier = createClassifier(makeDeps({
233
+ streamSimple: () => mockStreamFromMessage(textMessage('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}')),
234
+ }));
235
+ const r = await classifier.classifyRisk(CTX, CONFIG);
236
+ expect(r.outcome).toBe("allow");
237
+ });
238
+ });
239
+
240
+ // ──────────────────────── CT5: apiKey 透传到 streamSimple options ────────────────────────
241
+
242
+ describe("CT5: resolved.apiKey 透传到 streamSimple options", () => {
243
+ it("resolved model 含 apiKey → streamSimple 收到的 options.apiKey 是该值", async () => {
244
+ const { spy, getLastOptions } = capturingStreamSimple(
245
+ textMessage('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}'),
246
+ );
247
+ const classifier = createClassifier(makeDeps({
248
+ resolveModel: () => ({ ...FIXED_MODEL, apiKey: "sk-test-secret-key-12345" }),
249
+ streamSimple: spy,
250
+ }));
251
+ const r = await classifier.classifyRisk(CTX, CONFIG);
252
+ expect(r.outcome).toBe("allow");
253
+ const options = getLastOptions();
254
+ expect(options).toBeDefined();
255
+ expect(options?.apiKey).toBe("sk-test-secret-key-12345");
256
+ });
257
+
258
+ it("resolved model 无 apiKey → streamSimple options 不含 apiKey 字段", async () => {
259
+ // FIXED_MODEL 无 apiKey → options 不含 apiKey 键(spread 条件不触发)
260
+ const { spy, getLastOptions } = capturingStreamSimple(
261
+ textMessage('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}'),
262
+ );
263
+ const classifier = createClassifier(makeDeps({ streamSimple: spy }));
264
+ await classifier.classifyRisk(CTX, CONFIG);
265
+ const options = getLastOptions();
266
+ expect(options).toBeDefined();
267
+ expect("apiKey" in (options as object)).toBe(false);
268
+ });
269
+
270
+ it("apiKey 与 timeoutMs/signal 共存于 options", async () => {
271
+ // 验证 apiKey 与 timeoutMs 同时透传不互相覆盖(spread 合并正确)
272
+ const { spy, getLastOptions } = capturingStreamSimple(
273
+ textMessage('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}'),
274
+ );
275
+ const classifier = createClassifier(makeDeps({
276
+ resolveModel: () => ({ ...FIXED_MODEL, apiKey: "k9" }),
277
+ streamSimple: spy,
278
+ }));
279
+ // CONFIG.timeout=90 → timeoutMs=90000
280
+ await classifier.classifyRisk(CTX, CONFIG);
281
+ const options = getLastOptions();
282
+ expect(options?.apiKey).toBe("k9");
283
+ expect(options?.timeoutMs).toBe(90_000);
284
+ });
285
+ });
@@ -0,0 +1,118 @@
1
+ /**
2
+ * JT 系列:json-parser.ts 单元测试(三层容错)。
3
+ *
4
+ * 覆盖:
5
+ * - 正常 JSON 提取与字段验证
6
+ * - 三层容错:正则提取 / JSON.parse / 字段验证 各层失败路径
7
+ * - G5 多 JSON 对象 limitation
8
+ * - fail-closed fallback
9
+ */
10
+ import { describe, expect, it } from "vitest";
11
+
12
+ import { PARSE_FALLBACK_RESULT, parseClassifierResponse } from "../json-parser.js";
13
+
14
+ describe("JT1: 正常 JSON(裸 JSON)", () => {
15
+ it("裸 JSON 对象 → 解析成功", () => {
16
+ const r = parseClassifierResponse('{"outcome":"allow","risk_level":"low","reasoning":"safe","confidence":0.9}');
17
+ expect(r).toEqual({ outcome: "allow", risk_level: "low", reasoning: "safe", confidence: 0.9 });
18
+ });
19
+
20
+ it("JSON 含大写枚举 → 小写归一", () => {
21
+ const r = parseClassifierResponse('{"outcome":"DENY","risk_level":"HIGH","reasoning":"x","confidence":0.8}');
22
+ expect(r.outcome).toBe("deny");
23
+ expect(r.risk_level).toBe("high");
24
+ });
25
+ });
26
+
27
+ describe("JT2: 第一层 — 正则提取(前后有 prose / code fence)", () => {
28
+ it("markdown code fence 包裹的 JSON → 提取成功", () => {
29
+ const text = 'Here is the result:\n```json\n{"outcome":"ask","risk_level":"medium","reasoning":"maybe","confidence":0.5}\n```\n';
30
+ const r = parseClassifierResponse(text);
31
+ expect(r.outcome).toBe("ask");
32
+ expect(r.risk_level).toBe("medium");
33
+ expect(r.confidence).toBeCloseTo(0.5);
34
+ });
35
+
36
+ it("JSON 后有尾部 prose → 贪婪匹配到最后的 }", () => {
37
+ const text = '{"outcome":"allow","risk_level":"low","reasoning":"ok","confidence":1.0} That looks safe.';
38
+ const r = parseClassifierResponse(text);
39
+ expect(r.outcome).toBe("allow");
40
+ });
41
+
42
+ it("无 { } → fallback", () => {
43
+ expect(parseClassifierResponse("no json here at all")).toEqual(PARSE_FALLBACK_RESULT);
44
+ });
45
+ });
46
+
47
+ describe("JT3: 第二层 — JSON.parse 失败", () => {
48
+ it("提取到非法 JSON(缺引号)→ fallback", () => {
49
+ expect(parseClassifierResponse('{outcome:"allow"}')).toEqual(PARSE_FALLBACK_RESULT);
50
+ });
51
+
52
+ it("G5 多 JSON 对象(贪婪吞掉中间)→ fallback", () => {
53
+ // 两个独立对象:贪婪正则匹配成 '{...}{...}',JSON.parse 失败
54
+ const text = '{"outcome":"allow","risk_level":"low","reasoning":"a","confidence":0.1} {"outcome":"deny","risk_level":"high","reasoning":"b","confidence":0.9}';
55
+ expect(parseClassifierResponse(text)).toEqual(PARSE_FALLBACK_RESULT);
56
+ });
57
+ });
58
+
59
+ describe("JT4: 第三层 — 字段验证失败 → fallback", () => {
60
+ it("outcome 非法值 → fallback", () => {
61
+ const r = parseClassifierResponse('{"outcome":"maybe","risk_level":"low","reasoning":"x","confidence":0.5}');
62
+ expect(r).toEqual(PARSE_FALLBACK_RESULT);
63
+ });
64
+
65
+ it("risk_level 非法值 → fallback", () => {
66
+ const r = parseClassifierResponse('{"outcome":"allow","risk_level":"critical","reasoning":"x","confidence":0.5}');
67
+ expect(r).toEqual(PARSE_FALLBACK_RESULT);
68
+ });
69
+
70
+ it("confidence 非数字 → fallback", () => {
71
+ const r = parseClassifierResponse('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":"high"}');
72
+ expect(r).toEqual(PARSE_FALLBACK_RESULT);
73
+ });
74
+ });
75
+
76
+ describe("JT5: confidence 归一化", () => {
77
+ it("confidence 字符串数字 → 接受", () => {
78
+ const r = parseClassifierResponse('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":"0.7"}');
79
+ expect(r.confidence).toBeCloseTo(0.7);
80
+ });
81
+
82
+ it("confidence > 1 → clamp 到 1", () => {
83
+ const r = parseClassifierResponse('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":1.5}');
84
+ expect(r.confidence).toBe(1);
85
+ });
86
+
87
+ it("confidence < 0 → clamp 到 0", () => {
88
+ const r = parseClassifierResponse('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":-0.3}');
89
+ expect(r.confidence).toBe(0);
90
+ });
91
+ });
92
+
93
+ describe("JT6: 兼容字段 + 边界", () => {
94
+ it("reason 别名(reasoning 缺省时)→ 接受", () => {
95
+ const r = parseClassifierResponse('{"outcome":"allow","risk_level":"low","reason":"alt","confidence":0.5}');
96
+ expect(r.reasoning).toBe("alt");
97
+ });
98
+
99
+ it("score 别名 confidence → 接受", () => {
100
+ const r = parseClassifierResponse('{"outcome":"allow","risk_level":"low","reasoning":"x","score":0.4}');
101
+ expect(r.confidence).toBeCloseTo(0.4);
102
+ });
103
+
104
+ it("空字符串输入 → fallback", () => {
105
+ expect(parseClassifierResponse("")).toEqual(PARSE_FALLBACK_RESULT);
106
+ });
107
+
108
+ it("reasoning 缺省 → 用 fallback reasoning", () => {
109
+ const r = parseClassifierResponse('{"outcome":"allow","risk_level":"low","confidence":0.5}');
110
+ expect(r.reasoning).toBe(PARSE_FALLBACK_RESULT.reasoning);
111
+ });
112
+
113
+ it("返回的对象是深拷贝(修改不影响 PARSE_FALLBACK_RESULT)", () => {
114
+ const r = parseClassifierResponse("garbage");
115
+ r.reasoning = "mutated";
116
+ expect(PARSE_FALLBACK_RESULT.reasoning).not.toBe("mutated");
117
+ });
118
+ });
@@ -0,0 +1,173 @@
1
+ /**
2
+ * MRT 系列:model-resolver.ts 单元测试。
3
+ *
4
+ * 覆盖:
5
+ * - G2:agentDir 解析(env override / 默认)
6
+ * - loadModelsJson(文件缺失返回 null + onWarning)
7
+ * - flattenModels(拍平 + hasApiKey 推断)
8
+ * - findCheapestModel(过滤 hasApiKey + 按 input cost 升序)
9
+ * - resolveClassifierModel('auto' / 'provider/model-id' / 非法格式)
10
+ *
11
+ * 用真实 fs + 临时目录(不用 mock fs),与 config.test.ts 风格一致。
12
+ */
13
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
18
+
19
+ import { findCheapestModel, flattenModels, loadModelsJson, resolveClassifierModel } from "../model-resolver.js";
20
+
21
+ let tempDir: string;
22
+ let modelsJsonPath: string;
23
+
24
+ beforeEach(() => {
25
+ tempDir = mkdtempSync(join(tmpdir(), "pi-perm-mr-"));
26
+ modelsJsonPath = join(tempDir, "models.json");
27
+ });
28
+
29
+ afterEach(() => {
30
+ rmSync(tempDir, { recursive: true, force: true });
31
+ });
32
+
33
+ const MODELS_JSON = {
34
+ providers: {
35
+ "cheap-co": {
36
+ baseUrl: "http://x",
37
+ apiKey: "k1",
38
+ api: "openai-completions",
39
+ models: [
40
+ { id: "mini", cost: { input: 0.1, output: 0.1, cacheRead: 0, cacheWrite: 0 } },
41
+ { id: "big", cost: { input: 1.0, output: 1.0, cacheRead: 0, cacheWrite: 0 } },
42
+ ],
43
+ },
44
+ "noauth-co": {
45
+ baseUrl: "http://y",
46
+ // 无 apiKey
47
+ api: "openai-completions",
48
+ models: [{ id: "ultra-cheap", cost: { input: 0.001, output: 0, cacheRead: 0, cacheWrite: 0 } }],
49
+ },
50
+ "noapi-co": {
51
+ baseUrl: "http://z",
52
+ apiKey: "k3",
53
+ // 无 api
54
+ models: [{ id: "ghost", cost: { input: 0.5, output: 0, cacheRead: 0, cacheWrite: 0 } }],
55
+ },
56
+ },
57
+ };
58
+
59
+ function writeModels(data: unknown): void {
60
+ writeFileSync(modelsJsonPath, JSON.stringify(data), "utf-8");
61
+ }
62
+
63
+ describe("MRT1: loadModelsJson", () => {
64
+ it("文件不存在 → null(不抛错)", () => {
65
+ expect(existsSync(modelsJsonPath)).toBe(false);
66
+ expect(loadModelsJson(undefined, modelsJsonPath)).toBeNull();
67
+ });
68
+
69
+ it("合法 JSON → 返回解析对象", () => {
70
+ writeModels(MODELS_JSON);
71
+ const data = loadModelsJson(undefined, modelsJsonPath);
72
+ expect(data?.providers).toBeDefined();
73
+ });
74
+
75
+ it("损坏 JSON → null + onWarning 被调用", () => {
76
+ writeFileSync(modelsJsonPath, "{ broken json", "utf-8");
77
+ const warnings: string[] = [];
78
+ const data = loadModelsJson((m) => warnings.push(m), modelsJsonPath);
79
+ expect(data).toBeNull();
80
+ expect(warnings.length).toBe(1);
81
+ expect(warnings[0]).toContain("models.json");
82
+ });
83
+ });
84
+
85
+ describe("MRT2: flattenModels", () => {
86
+ it("拍平所有 provider.model,过滤无 api 的 model", () => {
87
+ const entries = flattenModels(MODELS_JSON);
88
+ const ids = entries.map((e) => e.id).sort();
89
+ // noapi-co/ghost 无 api → 过滤;其余保留
90
+ expect(ids).toEqual(["big", "mini", "ultra-cheap"]);
91
+ });
92
+
93
+ it("hasApiKey 从 provider.apiKey 推断", () => {
94
+ const entries = flattenModels(MODELS_JSON);
95
+ const cheap = entries.find((e) => e.id === "mini");
96
+ const noauth = entries.find((e) => e.id === "ultra-cheap");
97
+ expect(cheap?.hasApiKey).toBe(true);
98
+ expect(noauth?.hasApiKey).toBe(false);
99
+ });
100
+
101
+ it("apiKey 值从 provider.apiKey 透传(MRT2 补充:不只断言 hasApiKey 布尔)", () => {
102
+ const entries = flattenModels(MODELS_JSON);
103
+ const cheap = entries.find((e) => e.id === "mini");
104
+ const noauth = entries.find((e) => e.id === "ultra-cheap");
105
+ // 有 apiKey 的 provider:apiKey 字段透传真实值("k1")
106
+ expect(cheap?.apiKey).toBe("k1");
107
+ // 无 apiKey 的 provider:apiKey 字段为 undefined(而非空串)
108
+ expect(noauth?.apiKey).toBeUndefined();
109
+ // 同 provider 下所有 model 共享 provider.apiKey
110
+ const big = entries.find((e) => e.id === "big");
111
+ expect(big?.apiKey).toBe("k1");
112
+ });
113
+ });
114
+
115
+ describe("MRT3: findCheapestModel", () => {
116
+ it("过滤无 apiKey 的,返回 input cost 最低", () => {
117
+ const cheapest = findCheapestModel(MODELS_JSON);
118
+ // cheap-co/mini (0.1) < cheap-co/big (1.0);noauth-co/ultra-cheap 被 hasApiKey 过滤掉
119
+ expect(cheapest?.id).toBe("mini");
120
+ expect(cheapest?.cost.input).toBeCloseTo(0.1);
121
+ });
122
+
123
+ it("全部无 apiKey → null", () => {
124
+ const data = {
125
+ providers: {
126
+ x: { baseUrl: "http://x", api: "openai-completions", models: [{ id: "m", cost: { input: 0.1, output: 0, cacheRead: 0, cacheWrite: 0 } }] },
127
+ },
128
+ };
129
+ expect(findCheapestModel(data)).toBeNull();
130
+ });
131
+ });
132
+
133
+ describe("MRT4: resolveClassifierModel", () => {
134
+ it("'auto' → 最便宜可用模型", () => {
135
+ const r = resolveClassifierModel("auto", MODELS_JSON);
136
+ expect(r?.id).toBe("mini");
137
+ expect(r?.provider).toBe("cheap-co");
138
+ });
139
+
140
+ it("'provider/model-id' → 精确匹配", () => {
141
+ const r = resolveClassifierModel("cheap-co/big", MODELS_JSON);
142
+ expect(r?.id).toBe("big");
143
+ expect(r?.provider).toBe("cheap-co");
144
+ });
145
+
146
+ it("'provider/model-id' 未匹配 → null", () => {
147
+ expect(resolveClassifierModel("cheap-co/nonexistent", MODELS_JSON)).toBeNull();
148
+ });
149
+
150
+ it("非法格式(无斜线)→ null", () => {
151
+ expect(resolveClassifierModel("just-a-name", MODELS_JSON)).toBeNull();
152
+ });
153
+
154
+ it("非法格式(斜线在首/尾)→ null", () => {
155
+ expect(resolveClassifierModel("/leading", MODELS_JSON)).toBeNull();
156
+ expect(resolveClassifierModel("trailing/", MODELS_JSON)).toBeNull();
157
+ });
158
+
159
+ it("ResolvedModel 携带 baseUrl/name/inputCost(G4)", () => {
160
+ const r = resolveClassifierModel("cheap-co/mini", MODELS_JSON);
161
+ expect(r?.baseUrl).toBe("http://x");
162
+ expect(r?.name).toBe("mini"); // 无 name → fallback 到 id
163
+ expect(r?.inputCost).toBeCloseTo(0.1);
164
+ });
165
+
166
+ it("ResolvedModel 携带 apiKey 值(MRT4 补充:透传给 streamSimple 用)", () => {
167
+ // reviewer 指出 MRT4 只断言 hasApiKey,没断言 apiKey 值。补断言。
168
+ const auto = resolveClassifierModel("auto", MODELS_JSON);
169
+ expect(auto?.apiKey).toBe("k1"); // cheap-co 的 apiKey
170
+ const explicit = resolveClassifierModel("cheap-co/big", MODELS_JSON);
171
+ expect(explicit?.apiKey).toBe("k1");
172
+ });
173
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * PT 系列:prompt.ts 单元测试。
3
+ *
4
+ * 验证 CLASSIFIER_SYSTEM_PROMPT 约束 + buildClassifierUserPrompt 字段拼接。
5
+ */
6
+ import { describe, expect, it } from "vitest";
7
+
8
+ import type { ToolInvocationContext } from "../../types.js";
9
+ import { buildClassifierUserPrompt, CLASSIFIER_SYSTEM_PROMPT } from "../prompt.js";
10
+
11
+ function ctx(over: Partial<ToolInvocationContext> = {}): ToolInvocationContext {
12
+ return {
13
+ toolName: "bash",
14
+ command: "ls",
15
+ cwd: "/tmp",
16
+ ...over,
17
+ };
18
+ }
19
+
20
+ describe("PT1: CLASSIFIER_SYSTEM_PROMPT", () => {
21
+ it("包含 JSON 字段约束(outcome/risk_level/reasoning/confidence)", () => {
22
+ expect(CLASSIFIER_SYSTEM_PROMPT).toContain("outcome");
23
+ expect(CLASSIFIER_SYSTEM_PROMPT).toContain("risk_level");
24
+ expect(CLASSIFIER_SYSTEM_PROMPT).toContain("reasoning");
25
+ expect(CLASSIFIER_SYSTEM_PROMPT).toContain("confidence");
26
+ });
27
+
28
+ it("包含三态动作说明(allow/deny/ask)", () => {
29
+ expect(CLASSIFIER_SYSTEM_PROMPT).toContain("allow");
30
+ expect(CLASSIFIER_SYSTEM_PROMPT).toContain("deny");
31
+ expect(CLASSIFIER_SYSTEM_PROMPT).toContain("ask");
32
+ });
33
+ });
34
+
35
+ describe("PT2: buildClassifierUserPrompt 字段拼接", () => {
36
+ it("默认拼接 tool/command/path/cwd", () => {
37
+ const p = buildClassifierUserPrompt(ctx({ toolName: "bash", command: "ls -la", cwd: "/home" }));
38
+ expect(p).toContain("tool: bash");
39
+ expect(p).toContain("command: ls -la");
40
+ expect(p).toContain("cwd: /home");
41
+ });
42
+
43
+ it("command 缺省时显示 (none)", () => {
44
+ const p = buildClassifierUserPrompt(ctx({ toolName: "read", command: undefined }));
45
+ expect(p).toContain("command: (none)");
46
+ });
47
+
48
+ it("path 缺省时显示 (none)", () => {
49
+ const p = buildClassifierUserPrompt(ctx({ toolName: "read", path: undefined }));
50
+ expect(p).toContain("path: (none)");
51
+ });
52
+
53
+ it("agentName 存在时附 agent 行;缺省则无", () => {
54
+ const withAgent = buildClassifierUserPrompt(ctx({ agentName: "coder" }));
55
+ expect(withAgent).toContain("agent: coder");
56
+ const noAgent = buildClassifierUserPrompt(ctx({ agentName: undefined }));
57
+ expect(noAgent).not.toContain("agent:");
58
+ });
59
+ });