@zhushanwen/pi-permission 1.4.2 → 1.4.4

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-permission",
3
- "version": "1.4.2",
3
+ "version": "1.4.4",
4
4
  "description": "Pi permission extension — four permission modes (yolo/auto/approve/strict) with three-layer pipeline (AST + rules + AI classifier).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -50,9 +50,9 @@
50
50
  "dependencies": {
51
51
  "tree-sitter-bash": "^0.25.1",
52
52
  "web-tree-sitter": "^0.26.11",
53
- "@zhushanwen/pi-ext-guards": "0.2.1",
54
- "@zhushanwen/pi-extension-logger": "0.4.1",
55
- "@zhushanwen/pi-llm-shared": "0.6.0"
53
+ "@zhushanwen/pi-ext-guards": "0.4.0",
54
+ "@zhushanwen/pi-llm-shared": "0.8.0",
55
+ "@zhushanwen/pi-extension-logger": "0.6.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@earendil-works/pi-ai": "^0.84.4",
@@ -154,6 +154,53 @@ describe("requestUserApproval: rpc 模式", () => {
154
154
  expect(decision.approved).toBe(true);
155
155
  expect(selectMock).toHaveBeenCalledOnce();
156
156
  });
157
+
158
+ it("E6/M6:有 preClassification → title 6 行,含 AI 摘要行 + reasoning 行(漂移修复)", async () => {
159
+ const pc: ClassifierResult = {
160
+ risk_level: "high",
161
+ outcome: "deny",
162
+ reasoning: "dangerous pipe to shell",
163
+ confidence: 0.95,
164
+ };
165
+ const reqWithPc: ApprovalRequest = { ...req, preClassification: pc };
166
+ let capturedTitle = "";
167
+ const selectMock = vi.fn((title: string) => {
168
+ capturedTitle = title;
169
+ return Promise.resolve("Deny");
170
+ });
171
+ const approvalCtx = makeApprovalCtx({
172
+ mode: "rpc",
173
+ ui: { notify: vi.fn(), select: selectMock, custom: vi.fn() },
174
+ });
175
+ await requestUserApproval(reqWithPc, ctx, undefined, approvalCtx);
176
+ const lines = capturedTitle.split("\n");
177
+ // RPC title 5→6 行:补 ` reasoning: <AI 理由>` 行(与 TUI 字段集同源,M6 漂移修复)
178
+ expect(lines).toHaveLength(6);
179
+ expect(lines[0]).toBe("[pi-permission] Approval required");
180
+ expect(lines[1]).toBe("Tool: bash");
181
+ expect(lines[2]).toBe("Command: rm -rf /tmp");
182
+ expect(lines[3]).toBe("Reason: no allow rule");
183
+ expect(lines[4]).toBe("AI: risk=high outcome=deny (conf=0.95)");
184
+ expect(lines[5]).toBe(" reasoning: dangerous pipe to shell");
185
+ });
186
+
187
+ it("E6/M6:无 preClassification → title 仍 4 行(无 AI 摘要/reasoning 行)", async () => {
188
+ let capturedTitle = "";
189
+ const selectMock = vi.fn((title: string) => {
190
+ capturedTitle = title;
191
+ return Promise.resolve("Approve (once)");
192
+ });
193
+ const approvalCtx = makeApprovalCtx({
194
+ mode: "rpc",
195
+ ui: { notify: vi.fn(), select: selectMock, custom: vi.fn() },
196
+ });
197
+ await requestUserApproval(req, ctx, undefined, approvalCtx);
198
+ const lines = capturedTitle.split("\n");
199
+ // 无 AI 预分类:title/Tool/Command/Reason 共 4 行(设计「5→6」指有 preClassification 时)
200
+ expect(lines).toHaveLength(4);
201
+ expect(lines.join("\n")).not.toContain("reasoning");
202
+ expect(lines.join("\n")).not.toContain("AI:");
203
+ });
157
204
  });
158
205
 
159
206
  // ──────────────────────── tui 分支 + G3 ────────────────────────
@@ -200,7 +247,7 @@ describe("ApprovalComponent(G4 invalidate)", () => {
200
247
  const first = comp.render(80);
201
248
  expect(first.length).toBeGreaterThan(0);
202
249
 
203
- // invalidate 清缓存(不直接 requestRender;rerender 才 requestRender)
250
+ // invalidate 清缓存(不直接 requestRender)
204
251
  requestRender.mockClear();
205
252
  comp.invalidate();
206
253
  // 再次 render → 重新计算(非缓存)
@@ -7,19 +7,37 @@
7
7
  * - invalid mode → 错误提示
8
8
  * - status → 详细配置
9
9
  * - 当前状态(已是目标模式)→ "Already in X mode"
10
+ * - model/rule 异步 handler(D4 后直调模块函数,测试在模块边界 vi.mock 控制)
10
11
  *
11
12
  * handlePermissionCommand 是纯函数(输入 args + config + onSave,输出 string),
12
13
  * 不依赖 Pi 运行时,便于单测。
13
14
  */
14
- import { describe, expect, it, vi } from "vitest";
15
-
16
- import type { ResolvedModelEntry } from "../classifier/model-resolver.js";
17
- import { handlePermissionCommand, handlePermissionModelCommand, handlePermissionRuleCommand, type PermissionModelCommandDeps, type PermissionRuleCommandDeps } from "../commands.js";
15
+ import { beforeEach, describe, expect, it, vi } from "vitest";
16
+
17
+ // D4:model/rule handler 直调模块函数,测试边界移到模块真实依赖位置
18
+ // (model-resolver / rule-editor)。vi.mock factory 被提升到文件顶部,不能引用
19
+ // 外部变量,因此默认返回值统一在 beforeEach 设置(每用例可覆盖)。
20
+ vi.mock("../classifier/model-resolver.js", () => ({
21
+ listAvailableModels: vi.fn(),
22
+ }));
23
+ vi.mock("../rule-editor.js", () => ({
24
+ editRulesViaOverlay: vi.fn(),
25
+ }));
26
+
27
+ import { listAvailableModels } from "../classifier/model-resolver.js";
28
+ import { handlePermissionCommand, handlePermissionModelCommand, handlePermissionRuleCommand } from "../commands.js";
18
29
  import type { ModelPickerContext } from "../model-picker.js";
19
- import type { RuleEditorContext } from "../rule-editor.js";
30
+ import { editRulesViaOverlay, type RuleEditorContext } from "../rule-editor.js";
20
31
  import type { RuleOp } from "../rule-templates.js";
21
32
  import { DEFAULT_CONFIG, type PermissionConfig, type Rule } from "../types.js";
22
33
 
34
+ beforeEach(() => {
35
+ vi.mocked(listAvailableModels).mockReset();
36
+ vi.mocked(listAvailableModels).mockReturnValue(new Map());
37
+ vi.mocked(editRulesViaOverlay).mockReset();
38
+ vi.mocked(editRulesViaOverlay).mockResolvedValue(undefined);
39
+ });
40
+
23
41
  // ──────────────────────── mock helpers ────────────────────────
24
42
 
25
43
  /** 构造一个可变 config 副本(默认 yolo)。 */
@@ -210,16 +228,19 @@ describe("/permission 边界", () => {
210
228
  // ──────────────────────── /permission model(W7 T6) ────────────────────────
211
229
 
212
230
  /** 构造一条 ResolvedModelEntry(测试 helper)。 */
213
- function makeModelEntry(provider: string, id: string, inputCost: number): ResolvedModelEntry {
231
+ function makeModelEntry(provider: string, id: string): ResolvedModelEntry {
214
232
  return {
215
233
  provider,
216
234
  id,
217
- name: id,
218
235
  api: "openai-completions",
219
- cost: { input: inputCost, output: 0, cacheRead: 0, cacheWrite: 0 },
220
236
  };
221
237
  }
222
238
 
239
+ /** 默认可用模型:co → [m1](非空,供正常选择流程用例)。 */
240
+ function defaultModels(): Map<string, ResolvedModelEntry[]> {
241
+ return new Map([["co", [makeModelEntry("co", "m1")]]]);
242
+ }
243
+
223
244
  /** 构造 mock ctx(mode + modelRegistry + ui.notify/custom/select)。 */
224
245
  function makeModelPickerCtx(overrides: Partial<ModelPickerContext> = {}): ModelPickerContext {
225
246
  const base: ModelPickerContext = {
@@ -241,20 +262,12 @@ function makeModelPickerCtx(overrides: Partial<ModelPickerContext> = {}): ModelP
241
262
  };
242
263
  }
243
264
 
244
- /** 构造 mock deps(listModels + save)。 */
245
- function makeModelDeps(overrides: Partial<PermissionModelCommandDeps> = {}): PermissionModelCommandDeps {
246
- return {
247
- listModels: overrides.listModels ?? (() => new Map([["co", [makeModelEntry("co", "m1", 0.1)]]])),
248
- save: overrides.save ?? vi.fn(() => ({ success: true })),
249
- };
250
- }
251
-
252
265
  describe("/permission model(W7)", () => {
253
266
  it("无可用 model(空 Map)→ notify 降级提示,不调 save", async () => {
254
267
  const ctx = makeModelPickerCtx();
255
268
  const save = vi.fn(() => ({ success: true }));
256
- const deps = makeModelDeps({ listModels: () => new Map(), save });
257
- await handlePermissionModelCommand(ctx, makeConfig(), deps);
269
+ vi.mocked(listAvailableModels).mockReturnValue(new Map());
270
+ await handlePermissionModelCommand(ctx, makeConfig(), save);
258
271
  expect(ctx.ui.notify).toHaveBeenCalledOnce();
259
272
  const [msg, level] = (ctx.ui.notify as ReturnType<typeof vi.fn>).mock.calls[0]!;
260
273
  expect(msg).toContain("No available models");
@@ -271,9 +284,9 @@ describe("/permission model(W7)", () => {
271
284
  },
272
285
  });
273
286
  const save = vi.fn(() => ({ success: true }));
274
- const deps = makeModelDeps({ save });
287
+ vi.mocked(listAvailableModels).mockReturnValue(defaultModels());
275
288
  const config = makeConfig({ classifier: { enabled: true, model: "co/old", timeout: 90, autoApproveLowRisk: true, autoDenyHighRisk: true } });
276
- await handlePermissionModelCommand(ctx, config, deps);
289
+ await handlePermissionModelCommand(ctx, config, save);
277
290
  expect(save).toHaveBeenCalledOnce();
278
291
  const savedConfig = save.mock.calls[0]![0] as PermissionConfig;
279
292
  expect(savedConfig.classifier.model).toBe("auto");
@@ -288,8 +301,8 @@ describe("/permission model(W7)", () => {
288
301
  ui: { notify: vi.fn(), select: selectMock, custom: vi.fn() },
289
302
  });
290
303
  const save = vi.fn(() => ({ success: true }));
291
- const deps = makeModelDeps({ save });
292
- await handlePermissionModelCommand(ctx, makeConfig(), deps);
304
+ vi.mocked(listAvailableModels).mockReturnValue(defaultModels());
305
+ await handlePermissionModelCommand(ctx, makeConfig(), save);
293
306
  expect(save).toHaveBeenCalledOnce();
294
307
  const savedConfig = save.mock.calls[0]![0] as PermissionConfig;
295
308
  expect(savedConfig.classifier.model).toBe("co/m1");
@@ -305,8 +318,8 @@ describe("/permission model(W7)", () => {
305
318
  },
306
319
  });
307
320
  const save = vi.fn(() => ({ success: true }));
308
- const deps = makeModelDeps({ save });
309
- await handlePermissionModelCommand(ctx, makeConfig(), deps);
321
+ vi.mocked(listAvailableModels).mockReturnValue(defaultModels());
322
+ await handlePermissionModelCommand(ctx, makeConfig(), save);
310
323
  expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("cancelled"), "info");
311
324
  expect(save).not.toHaveBeenCalled();
312
325
  });
@@ -320,15 +333,16 @@ describe("/permission model(W7)", () => {
320
333
  },
321
334
  });
322
335
  const save = vi.fn(() => ({ success: false, error: "disk full" }));
323
- const deps = makeModelDeps({ save });
324
- await handlePermissionModelCommand(ctx, makeConfig(), deps);
336
+ vi.mocked(listAvailableModels).mockReturnValue(defaultModels());
337
+ await handlePermissionModelCommand(ctx, makeConfig(), save);
325
338
  expect(ctx.ui.notify).toHaveBeenCalledWith(expect.stringContaining("disk full"), "error");
326
339
  });
327
340
 
328
341
  it("handler 返回 Promise(async 签名)", () => {
329
342
  const ctx = makeModelPickerCtx();
330
- const deps = makeModelDeps();
331
- const result = handlePermissionModelCommand(ctx, makeConfig(), deps);
343
+ const save = vi.fn(() => ({ success: true }));
344
+ vi.mocked(listAvailableModels).mockReturnValue(defaultModels());
345
+ const result = handlePermissionModelCommand(ctx, makeConfig(), save);
332
346
  // Promise.is 兼容检查
333
347
  expect(result).toBeInstanceOf(Promise);
334
348
  // 避免 unhandled rejection
@@ -344,14 +358,14 @@ describe("/permission model(W7)", () => {
344
358
  },
345
359
  });
346
360
  const save = vi.fn(() => ({ success: true }));
347
- const deps = makeModelDeps({ save });
361
+ vi.mocked(listAvailableModels).mockReturnValue(defaultModels());
348
362
  const config = makeConfig({
349
363
  mode: "strict",
350
364
  enabled: false,
351
365
  classifier: { enabled: false, model: "co/old", timeout: 30, autoApproveLowRisk: false, autoDenyHighRisk: false },
352
366
  userRules: [{ id: "u1", tool: "bash", pattern: "rm *", action: "deny", source: "user" }],
353
367
  });
354
- await handlePermissionModelCommand(ctx, config, deps);
368
+ await handlePermissionModelCommand(ctx, config, save);
355
369
  const saved = save.mock.calls[0]![0] as PermissionConfig;
356
370
  expect(saved.mode).toBe("strict"); // 保留
357
371
  expect(saved.enabled).toBe(false); // 保留
@@ -376,14 +390,6 @@ function makeRuleEditorCtx(overrides: Partial<RuleEditorContext> = {}): RuleEdit
376
390
  };
377
391
  }
378
392
 
379
- /** 构造 PermissionRuleCommandDeps mock。 */
380
- function makeRuleDeps(overrides: Partial<PermissionRuleCommandDeps> = {}): PermissionRuleCommandDeps {
381
- return {
382
- save: overrides.save ?? vi.fn(() => ({ success: true })),
383
- editRulesViaOverlay: overrides.editRulesViaOverlay ?? vi.fn(() => Promise.resolve(undefined)),
384
- };
385
- }
386
-
387
393
  function makeRule(overrides: Partial<Rule> = {}): Rule {
388
394
  return {
389
395
  id: "user-1",
@@ -405,11 +411,8 @@ describe("/permission rule(W8)", () => {
405
411
  const notify = vi.fn();
406
412
  const save = vi.fn(() => ({ success: true }));
407
413
  const ctx = makeRuleEditorCtx({ ui: { notify, select: vi.fn(), custom: vi.fn() } });
408
- const deps = makeRuleDeps({
409
- save,
410
- editRulesViaOverlay: vi.fn(() => Promise.resolve(undefined)),
411
- });
412
- await handlePermissionRuleCommand(ctx, makeConfig(), makeCounter(), deps);
414
+ vi.mocked(editRulesViaOverlay).mockResolvedValue(undefined);
415
+ await handlePermissionRuleCommand(ctx, makeConfig(), makeCounter(), save);
413
416
  expect(notify).toHaveBeenCalledWith(expect.stringContaining("No changes"), "info");
414
417
  expect(save).not.toHaveBeenCalled();
415
418
  });
@@ -418,11 +421,8 @@ describe("/permission rule(W8)", () => {
418
421
  const notify = vi.fn();
419
422
  const save = vi.fn(() => ({ success: true }));
420
423
  const ctx = makeRuleEditorCtx({ ui: { notify, select: vi.fn(), custom: vi.fn() } });
421
- const deps = makeRuleDeps({
422
- save,
423
- editRulesViaOverlay: vi.fn(() => Promise.resolve([])),
424
- });
425
- await handlePermissionRuleCommand(ctx, makeConfig(), makeCounter(), deps);
424
+ vi.mocked(editRulesViaOverlay).mockResolvedValue([]);
425
+ await handlePermissionRuleCommand(ctx, makeConfig(), makeCounter(), save);
426
426
  expect(notify).toHaveBeenCalledWith(expect.stringContaining("No changes"), "info");
427
427
  expect(save).not.toHaveBeenCalled();
428
428
  });
@@ -432,12 +432,9 @@ describe("/permission rule(W8)", () => {
432
432
  const save = vi.fn(() => ({ success: true }));
433
433
  const ops: RuleOp[] = [{ kind: "add", rule: makeRule({ id: "user-1", pattern: "git *" }) }];
434
434
  const ctx = makeRuleEditorCtx({ ui: { notify, select: vi.fn(), custom: vi.fn() } });
435
- const deps = makeRuleDeps({
436
- save,
437
- editRulesViaOverlay: vi.fn(() => Promise.resolve(ops)),
438
- });
435
+ vi.mocked(editRulesViaOverlay).mockResolvedValue(ops);
439
436
  const config = makeConfig({ userRules: [] });
440
- await handlePermissionRuleCommand(ctx, config, makeCounter(), deps);
437
+ await handlePermissionRuleCommand(ctx, config, makeCounter(), save);
441
438
  expect(save).toHaveBeenCalledOnce();
442
439
  expect(notify).toHaveBeenCalledWith(expect.stringContaining("1 change(s)"), "info");
443
440
  // save 收到的 config.userRules 含新规则
@@ -451,11 +448,8 @@ describe("/permission rule(W8)", () => {
451
448
  const save = vi.fn(() => ({ success: false, error: "disk full" }));
452
449
  const ops: RuleOp[] = [{ kind: "add", rule: makeRule({ id: "user-1" }) }];
453
450
  const ctx = makeRuleEditorCtx({ ui: { notify, select: vi.fn(), custom: vi.fn() } });
454
- const deps = makeRuleDeps({
455
- save,
456
- editRulesViaOverlay: vi.fn(() => Promise.resolve(ops)),
457
- });
458
- await handlePermissionRuleCommand(ctx, makeConfig(), makeCounter(), deps);
451
+ vi.mocked(editRulesViaOverlay).mockResolvedValue(ops);
452
+ await handlePermissionRuleCommand(ctx, makeConfig(), makeCounter(), save);
459
453
  expect(notify).toHaveBeenCalledWith(expect.stringContaining("disk full"), "error");
460
454
  });
461
455
 
@@ -463,16 +457,13 @@ describe("/permission rule(W8)", () => {
463
457
  const save = vi.fn(() => ({ success: true }));
464
458
  const ops: RuleOp[] = [{ kind: "add", rule: makeRule({ id: "user-1" }) }];
465
459
  const ctx = makeRuleEditorCtx({ ui: { notify: vi.fn(), select: vi.fn(), custom: vi.fn() } });
466
- const deps = makeRuleDeps({
467
- save,
468
- editRulesViaOverlay: vi.fn(() => Promise.resolve(ops)),
469
- });
460
+ vi.mocked(editRulesViaOverlay).mockResolvedValue(ops);
470
461
  const config = makeConfig({
471
462
  mode: "strict",
472
463
  enabled: false,
473
464
  classifier: { enabled: false, model: "custom", timeout: 10, autoApproveLowRisk: false, autoDenyHighRisk: false },
474
465
  });
475
- await handlePermissionRuleCommand(ctx, config, makeCounter(), deps);
466
+ await handlePermissionRuleCommand(ctx, config, makeCounter(), save);
476
467
  const saved = save.mock.calls[0]![0] as PermissionConfig;
477
468
  expect(saved.mode).toBe("strict");
478
469
  expect(saved.enabled).toBe(false);
@@ -480,14 +471,14 @@ describe("/permission rule(W8)", () => {
480
471
  });
481
472
 
482
473
  it("editRulesViaOverlay 收到正确的 initialRules 和 sessionIdCounter", async () => {
483
- const editMock = vi.fn(() => Promise.resolve(undefined));
474
+ const save = vi.fn(() => ({ success: true }));
484
475
  const ctx = makeRuleEditorCtx({ ui: { notify: vi.fn(), select: vi.fn(), custom: vi.fn() } });
485
476
  const existingRules = [makeRule({ id: "user-5" })];
486
477
  const counter = makeCounter();
487
- const deps = makeRuleDeps({ editRulesViaOverlay: editMock });
488
- await handlePermissionRuleCommand(ctx, makeConfig({ userRules: existingRules }), counter, deps);
489
- expect(editMock).toHaveBeenCalledOnce();
490
- const [_callCtx, callRules, callCounter] = editMock.mock.calls[0]!;
478
+ vi.mocked(editRulesViaOverlay).mockResolvedValue(undefined);
479
+ await handlePermissionRuleCommand(ctx, makeConfig({ userRules: existingRules }), counter, save);
480
+ expect(editRulesViaOverlay).toHaveBeenCalledOnce();
481
+ const [_callCtx, callRules, callCounter] = vi.mocked(editRulesViaOverlay).mock.calls[0]!;
491
482
  expect(callRules).toEqual(existingRules);
492
483
  expect(typeof callCounter).toBe("function");
493
484
  });
@@ -17,23 +17,21 @@ import type { Api, Model } from "@earendil-works/pi-ai";
17
17
  import type { ResolvedModelEntry } from "../classifier/model-resolver.js";
18
18
  import { listAvailableModels } from "../classifier/model-resolver.js";
19
19
  import {
20
- DEFAULT_SELECT_THEME,
21
20
  type ModelPickerContext,
22
21
  pickModelViaOverlay,
23
22
  ProviderModelSelectorComponent,
24
23
  type SelectionResult,
25
24
  } from "../model-picker.js";
25
+ import { DEFAULT_SELECT_THEME } from "../select-theme.js";
26
26
 
27
27
  // ──────────────────────── mock fixtures(listAvailableModels) ────────────────────────
28
28
 
29
- /** 构造一条 ResolvedModelEntry(测试 helper)。 */
30
- function makeEntry(provider: string, id: string, inputCost: number): ResolvedModelEntry {
29
+ /** 构造一条 ResolvedModelEntry(测试 helper;E5 后仅 provider/id/api)。 */
30
+ function makeEntry(provider: string, id: string): ResolvedModelEntry {
31
31
  return {
32
32
  provider,
33
33
  id,
34
- name: id,
35
34
  api: "openai-completions",
36
- cost: { input: inputCost, output: 0, cacheRead: 0, cacheWrite: 0 },
37
35
  };
38
36
  }
39
37
 
@@ -116,7 +114,7 @@ function makePickerCtx(overrides: Partial<ModelPickerContext> = {}): ModelPicker
116
114
  describe("MPT2: pickModelViaOverlay 分发", () => {
117
115
  it("headless(json)→ 返回 undefined(降级)", async () => {
118
116
  const ctx = makePickerCtx({ mode: "json" });
119
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
117
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
120
118
  const result = await pickModelViaOverlay(ctx, "auto", models);
121
119
  expect(result).toBeUndefined();
122
120
  });
@@ -136,7 +134,7 @@ describe("MPT2: pickModelViaOverlay 分发", () => {
136
134
  custom: vi.fn(),
137
135
  },
138
136
  });
139
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
137
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
140
138
  const result = await pickModelViaOverlay(ctx, "auto", models);
141
139
  expect(result).toBe("auto");
142
140
  expect(ctx.ui.select).toHaveBeenCalledOnce();
@@ -150,7 +148,7 @@ describe("MPT2: pickModelViaOverlay 分发", () => {
150
148
  mode: "rpc",
151
149
  ui: { notify: vi.fn(), select: selectMock, custom: vi.fn() },
152
150
  });
153
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
151
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
154
152
  const result = await pickModelViaOverlay(ctx, "auto", models);
155
153
  expect(result).toBe("co/m1");
156
154
  expect(ctx.ui.select).toHaveBeenCalledTimes(2);
@@ -165,13 +163,13 @@ describe("MPT2: pickModelViaOverlay 分发", () => {
165
163
  custom: vi.fn(),
166
164
  },
167
165
  });
168
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
166
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
169
167
  const result = await pickModelViaOverlay(ctx, "auto", models);
170
168
  expect(result).toBeUndefined();
171
169
  });
172
170
 
173
171
  it("TUI 模式:custom factory 被调用,comp done settle 结果", async () => {
174
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
172
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
175
173
  // 模拟 ctx.ui.custom:构造 comp 并模拟 done({kind:'auto'})
176
174
  const customMock = vi.fn(
177
175
  <T,>(factory: (tui: unknown, theme: unknown, kb: unknown, done: (r: T) => void) => unknown) =>
@@ -181,7 +179,7 @@ describe("MPT2: pickModelViaOverlay 分发", () => {
181
179
  );
182
180
  const ctx = makePickerCtx({ mode: "tui", ui: { notify: vi.fn(), select: vi.fn(), custom: customMock } });
183
181
  // 在 factory 内拿到 comp,但这里 mock 直接调 done({kind:'auto'})
184
- // 改写 mock:构造真实 comp 并用 cancel/settle
182
+ // 改写 mock:构造真实 comp 并用 handleInput('\r') 触发 onSelect
185
183
  customMock.mockImplementationOnce(
186
184
  <T,>(factory: (tui: unknown, theme: unknown, kb: unknown, done: (r: T) => void) => unknown) =>
187
185
  new Promise<T>((resolve) => {
@@ -201,7 +199,7 @@ describe("MPT2: pickModelViaOverlay 分发", () => {
201
199
  describe("MPT3: ProviderModelSelectorComponent 构造 + 初始 stage", () => {
202
200
  it("构造:初始 stage='provider',render 非空含 'Select Provider'", () => {
203
201
  const done = vi.fn();
204
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
202
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
205
203
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
206
204
  const lines = comp.render(80);
207
205
  const joined = lines.join("\n");
@@ -212,7 +210,7 @@ describe("MPT3: ProviderModelSelectorComponent 构造 + 初始 stage", () => {
212
210
 
213
211
  it("currentSpec='auto' → 预选 'Auto'(index 0)", () => {
214
212
  const done = vi.fn();
215
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
213
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
216
214
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
217
215
  comp.handleInput("\r"); // Enter 选中预选项(Auto)
218
216
  expect(done).toHaveBeenCalledOnce();
@@ -222,7 +220,7 @@ describe("MPT3: ProviderModelSelectorComponent 构造 + 初始 stage", () => {
222
220
 
223
221
  it("currentSpec='provider/modelId' → 预选该 provider(Enter 直接到 model stage)", () => {
224
222
  const done = vi.fn();
225
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
223
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
226
224
  const comp = new ProviderModelSelectorComponent("co/m1", ["co"], models, done);
227
225
  // 预选 'co'(index 1),Enter 触发 switchToModelStage(不 done)
228
226
  comp.handleInput("\r");
@@ -235,7 +233,7 @@ describe("MPT3: ProviderModelSelectorComponent 构造 + 初始 stage", () => {
235
233
  describe("MPT4: ProviderModelSelectorComponent provider onSelect", () => {
236
234
  it("provider stage 选 Auto → done({kind:'auto'})", () => {
237
235
  const done = vi.fn();
238
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
236
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
239
237
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
240
238
  comp.handleInput("\r"); // Enter 选中预选 Auto
241
239
  expect(done).toHaveBeenCalledWith({ kind: "auto" });
@@ -243,7 +241,7 @@ describe("MPT4: ProviderModelSelectorComponent provider onSelect", () => {
243
241
 
244
242
  it("provider stage 选具体 provider → switchToModelStage(不 done)", () => {
245
243
  const done = vi.fn();
246
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
244
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
247
245
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
248
246
  // 下移到 'co'(index 1),Enter 进入 model stage
249
247
  comp.handleInput("\x1b[B"); // Down arrow
@@ -254,7 +252,7 @@ describe("MPT4: ProviderModelSelectorComponent provider onSelect", () => {
254
252
 
255
253
  it("provider stage Esc → done(undefined)(cancel)", () => {
256
254
  const done = vi.fn();
257
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
255
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
258
256
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
259
257
  comp.handleInput("\x1b"); // Esc
260
258
  expect(done).toHaveBeenCalledWith(undefined);
@@ -264,7 +262,7 @@ describe("MPT4: ProviderModelSelectorComponent provider onSelect", () => {
264
262
  describe("MPT5: ProviderModelSelectorComponent model onSelect", () => {
265
263
  it("model stage 选 model → done({kind:'specific', provider, modelId})", () => {
266
264
  const done = vi.fn();
267
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1), makeEntry("co", "m2", 0.2)] });
265
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1"), makeEntry("co", "m2")] });
268
266
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
269
267
  // 下移到 'co',Enter 进 model stage
270
268
  comp.handleInput("\x1b[B"); // Down
@@ -276,7 +274,7 @@ describe("MPT5: ProviderModelSelectorComponent model onSelect", () => {
276
274
 
277
275
  it("model stage Esc → 回退到 provider stage(不 done)", () => {
278
276
  const done = vi.fn();
279
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
277
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
280
278
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
281
279
  // 进 model stage
282
280
  comp.handleInput("\x1b[B"); // Down
@@ -289,7 +287,7 @@ describe("MPT5: ProviderModelSelectorComponent model onSelect", () => {
289
287
 
290
288
  it("_resolved 守卫:done 后再 handleInput no-op", () => {
291
289
  const done = vi.fn();
292
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
290
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
293
291
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
294
292
  comp.handleInput("\r"); // Auto → done
295
293
  expect(done).toHaveBeenCalledOnce();
@@ -304,7 +302,7 @@ describe("MPT5: ProviderModelSelectorComponent model onSelect", () => {
304
302
  describe("MPT6: WR1 handleInput 委托 SelectList.onSelect(critical)", () => {
305
303
  it("handleInput('\\r') 直接触发 SelectList.onSelect(不绕过键盘委托通路)", () => {
306
304
  const done = vi.fn();
307
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
305
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
308
306
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
309
307
  // 直接调 handleInput('\r'),验证:
310
308
  // 1. Container.handleInput 不存在,组件 override 委托给 SelectList
@@ -317,7 +315,7 @@ describe("MPT6: WR1 handleInput 委托 SelectList.onSelect(critical)", () =>
317
315
 
318
316
  it("handleInput(down + enter) 链式触发 SelectList 导航 + onSelect", () => {
319
317
  const done = vi.fn();
320
- const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
318
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
321
319
  const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
322
320
  // Down 移动到 'co',Enter 选中 → 进 model stage(done 未调)
323
321
  comp.handleInput("\x1b[B"); // Down
@@ -342,3 +340,29 @@ describe("MPT7: DEFAULT_SELECT_THEME(G2/WR2 修正)", () => {
342
340
  expect(result.startsWith("\u25B6")).toBe(true);
343
341
  });
344
342
  });
343
+
344
+ // ──────────────────────── V2b: currentSpec 预选钉值(ext-simplify-18 D4 parseModelRef 采用) ────────────────────────
345
+
346
+ describe("MPT8: V2b currentSpec 预选钉值(parseModelRef 采用后行为锁定)", () => {
347
+ it("合法 ref:provider 与 model 双预选命中(两 stage Enter 落到 currentSpec 对应项)", () => {
348
+ const done = vi.fn();
349
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1"), makeEntry("co", "m2")] });
350
+ const comp = new ProviderModelSelectorComponent("co/m2", ["other", "co"], models, done);
351
+ // provider stage 预选 'co'(index 2:Auto + other + co),Enter → model stage(不 settle)
352
+ comp.handleInput("\r");
353
+ expect(done).not.toHaveBeenCalled();
354
+ // model stage 预选 m2(非列表第一个 m1),Enter → done(specific co/m2)
355
+ comp.handleInput("\r");
356
+ expect(done).toHaveBeenCalledWith({ kind: "specific", provider: "co", modelId: "m2" });
357
+ });
358
+
359
+ it("病态输入 'provider/'(尾空 modelId):parseModelRef 返 null → provider 预选回 Auto", () => {
360
+ const done = vi.fn();
361
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1")] });
362
+ // 行为微变(ext-simplify-18 D4 登记):旧版 provider 预选命中 idx+1,
363
+ // 新版整 ref 无效 → 整体回 Auto(Enter → done({kind:'auto'}))
364
+ const comp = new ProviderModelSelectorComponent("co/", ["co"], models, done);
365
+ comp.handleInput("\r"); // 预选 index 0(Auto),Enter → done({kind:'auto'})
366
+ expect(done).toHaveBeenCalledWith({ kind: "auto" });
367
+ });
368
+ });
@@ -454,6 +454,30 @@ describe("G1: matchNonBashTool(非 bash 工具规则匹配,M5 pattern 对 pa
454
454
  it("空 toolName → ask", () => {
455
455
  expect(matchNonBashTool("", "/x", [])).toEqual({ action: "ask", matchedRule: undefined });
456
456
  });
457
+
458
+ it("E7/T3:pattern 编译走 resolvePattern patternCache(matcher 与 pipeline 复用同一缓存)", async () => {
459
+ // E7(M10)后 matchNonBashTool 的 pattern 双语义分发改调 matcher.resolvePattern,
460
+ // 第二次调用应命中 patternCache(不再重新编译)。可观测信号:wildcardToRegExp
461
+ // 收到该 pattern 的编译次数恰为 1(改前的本地三元无缓存,会是 2)。
462
+ // tool 字段编译不走缓存(每次调用编译一次),与 pattern 计数解耦。
463
+ const wildcardModule = await import("../rules/wildcard.js");
464
+ const spy = vi.spyOn(wildcardModule, "wildcardToRegExp");
465
+ try {
466
+ const rule: Rule = {
467
+ id: "pc-1",
468
+ tool: "read",
469
+ pattern: "/tmp/e7-unique-cache/*",
470
+ action: "deny",
471
+ source: "user",
472
+ };
473
+ expect(matchNonBashTool("read", "/tmp/e7-unique-cache/a", [rule]).action).toBe("deny");
474
+ expect(matchNonBashTool("read", "/tmp/e7-unique-cache/b", [rule]).action).toBe("deny");
475
+ const patternCompiles = spy.mock.calls.filter((call) => call[0] === rule.pattern);
476
+ expect(patternCompiles).toHaveLength(1);
477
+ } finally {
478
+ spy.mockRestore();
479
+ }
480
+ });
457
481
  });
458
482
 
459
483
  // ──────────────────────── runLayer2 ────────────────────────