@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 +4 -4
- package/src/__tests__/approval.test.ts +48 -1
- package/src/__tests__/commands.test.ts +59 -68
- package/src/__tests__/model-picker.test.ts +46 -22
- package/src/__tests__/pipeline.test.ts +24 -0
- package/src/approval.ts +68 -31
- package/src/ast/analyzer.ts +2 -1
- package/src/ast/loader.ts +2 -1
- package/src/classifier/classifier.ts +3 -2
- package/src/classifier/index.ts +4 -16
- package/src/classifier/model-resolver.ts +3 -16
- package/src/commands.ts +15 -34
- package/src/config.ts +7 -24
- package/src/index.ts +48 -67
- package/src/model-picker.ts +19 -38
- package/src/pipeline.ts +27 -44
- package/src/rule-editor.ts +8 -22
- package/src/rules/__tests__/index.test.ts +5 -14
- package/src/rules/__tests__/matcher.test.ts +16 -23
- package/src/rules/index.ts +7 -24
- package/src/rules/matcher.ts +29 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/pi-permission",
|
|
3
|
-
"version": "1.4.
|
|
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.
|
|
54
|
-
"@zhushanwen/pi-
|
|
55
|
-
"@zhushanwen/pi-
|
|
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
|
|
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
|
-
|
|
17
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
257
|
-
await handlePermissionModelCommand(ctx, makeConfig(),
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
292
|
-
await handlePermissionModelCommand(ctx, makeConfig(),
|
|
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
|
-
|
|
309
|
-
await handlePermissionModelCommand(ctx, makeConfig(),
|
|
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
|
-
|
|
324
|
-
await handlePermissionModelCommand(ctx, makeConfig(),
|
|
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
|
|
331
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
409
|
-
|
|
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
|
-
|
|
422
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
-
|
|
455
|
-
|
|
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
|
-
|
|
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(),
|
|
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
|
|
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
|
-
|
|
488
|
-
await handlePermissionRuleCommand(ctx, makeConfig({ userRules: existingRules }), counter,
|
|
489
|
-
expect(
|
|
490
|
-
const [_callCtx, callRules, callCounter] =
|
|
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
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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 并用
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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"
|
|
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 ────────────────────────
|