@zhushanwen/pi-permission 1.0.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -42
- package/package.json +11 -6
- package/skills/permission-ext-config/SKILL.md +151 -0
- package/src/__tests__/commands.test.ts +10 -3
- package/src/__tests__/config.test.ts +101 -33
- package/src/__tests__/extension.test.ts +22 -2
- package/src/__tests__/index-integration.test.ts +143 -66
- package/src/__tests__/model-picker.test.ts +56 -83
- package/src/__tests__/production.test.ts +188 -23
- package/src/classifier/__tests__/classifier.test.ts +109 -118
- package/src/classifier/__tests__/model-resolver.test.ts +118 -142
- package/src/classifier/classifier.ts +76 -117
- package/src/classifier/index.ts +2 -8
- package/src/classifier/model-resolver.ts +50 -239
- package/src/commands.ts +5 -5
- package/src/config.ts +79 -120
- package/src/footer-provider.ts +20 -18
- package/src/index.ts +107 -100
- package/src/model-picker.ts +12 -6
- package/src/production.ts +49 -57
- package/src/rule-editor.ts +1 -1
- package/src/statusline-palette.ts +4 -5
- package/src/types.ts +11 -2
|
@@ -2,13 +2,33 @@
|
|
|
2
2
|
* WT8/WT9: 扩展入口注册 + 占位 tool_call 测试
|
|
3
3
|
*
|
|
4
4
|
* 用 mock pi 对象记录调用,不依赖真实 Pi 运行时。
|
|
5
|
+
* PI_CODING_AGENT_DIR 隔离到临时目录:permissionExtension 工厂初始化时会
|
|
6
|
+
* loadAndWatchConfig → ensureConfigFile 创建默认配置,不隔离会污染真实 agentDir。
|
|
5
7
|
*/
|
|
6
|
-
import {
|
|
8
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
9
|
+
import { tmpdir } from "node:os";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
7
12
|
|
|
8
13
|
// Mock Pi SDK 模块(extension.ts import 的 @earendil-works/pi-coding-agent 类型在 node_modules 有完整定义,
|
|
9
14
|
// 这里 import 工厂函数,用 mock pi 调用它)
|
|
10
15
|
import permissionExtension from "../index.js";
|
|
11
16
|
|
|
17
|
+
let tempAgentDir: string;
|
|
18
|
+
let originalAgentDirEnv: string | undefined;
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
tempAgentDir = mkdtempSync(join(tmpdir(), "pi-perm-ext-test-"));
|
|
22
|
+
originalAgentDirEnv = process.env.PI_CODING_AGENT_DIR;
|
|
23
|
+
process.env.PI_CODING_AGENT_DIR = tempAgentDir;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
if (originalAgentDirEnv === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
28
|
+
else process.env.PI_CODING_AGENT_DIR = originalAgentDirEnv;
|
|
29
|
+
rmSync(tempAgentDir, { recursive: true, force: true });
|
|
30
|
+
});
|
|
31
|
+
|
|
12
32
|
/** 最小 mock:只记录 registerCommand 和 on 调用 */
|
|
13
33
|
interface MockPi {
|
|
14
34
|
registerCommand: (name: string, options: unknown) => void;
|
|
@@ -143,7 +163,7 @@ describe("WT9: tool_call handler(W5 三层管道接入)", () => {
|
|
|
143
163
|
ctx: unknown,
|
|
144
164
|
) => Promise<unknown>;
|
|
145
165
|
|
|
146
|
-
// 注:handler 实际读取磁盘上的 permission-config.json(无法从测试注入 config)。
|
|
166
|
+
// 注:handler 实际读取磁盘上的 config/permission-ext-config.json(无法从测试注入 config)。
|
|
147
167
|
// 默认 user config 含 allow 规则 `python *`(user-1),故 `python script.py` 命中 allow → resolve undefined。
|
|
148
168
|
// 此前测试断言「yolo 放行任意命令(含 git push --force)」依赖磁盘 config.mode==='yolo',
|
|
149
169
|
// 但 config 已改为 auto 且 bd-005 危险规则即使 yolo 之外的模式会拦截 force push。
|
|
@@ -9,18 +9,18 @@
|
|
|
9
9
|
* - fail-closed(异常 → block)
|
|
10
10
|
* - session_start 重载 config
|
|
11
11
|
*
|
|
12
|
-
* 用 PI_CODING_AGENT_DIR 指向临时目录,写入 controlled permission
|
|
12
|
+
* 用 PI_CODING_AGENT_DIR 指向临时目录,写入 controlled permission.json,
|
|
13
13
|
* 让 loadAndWatchConfig 读到指定 mode。mock pi 对象记录 handler 调用。
|
|
14
14
|
*/
|
|
15
|
-
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { join } from "node:path";
|
|
18
18
|
|
|
19
19
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
-
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
20
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
21
21
|
|
|
22
|
-
import { FOOTER_HANDSHAKE_KEY, REQUEST_RENDER_KEY } from "../footer-provider.js";
|
|
23
22
|
import permissionExtension from "../index.js";
|
|
23
|
+
import { FOOTER_HANDSHAKE_KEY, REQUEST_RENDER_KEY } from "../footer-provider.js";
|
|
24
24
|
|
|
25
25
|
// ──────────────────────── mock pi ────────────────────────
|
|
26
26
|
|
|
@@ -112,10 +112,11 @@ function bashEvent(command: string): { toolName: string; input: { command: strin
|
|
|
112
112
|
|
|
113
113
|
const TMP_ROOT = join(tmpdir(), "pi-perm-test-" + process.pid);
|
|
114
114
|
const AGENT_DIR = join(TMP_ROOT, "agent");
|
|
115
|
-
const CONFIG_PATH = join(AGENT_DIR, "permission-config.json");
|
|
115
|
+
const CONFIG_PATH = join(AGENT_DIR, "config", "permission-ext-config.json");
|
|
116
116
|
|
|
117
117
|
function writeConfig(mode: string, enabled = true): void {
|
|
118
118
|
mkdirSync(AGENT_DIR, { recursive: true });
|
|
119
|
+
mkdirSync(join(AGENT_DIR, "config"), { recursive: true });
|
|
119
120
|
const config = {
|
|
120
121
|
mode,
|
|
121
122
|
enabled,
|
|
@@ -126,6 +127,9 @@ function writeConfig(mode: string, enabled = true): void {
|
|
|
126
127
|
}
|
|
127
128
|
|
|
128
129
|
beforeEach(() => {
|
|
130
|
+
// 清理 footer 握手 slot(防 footer 测试跨用例污染)
|
|
131
|
+
Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
132
|
+
Reflect.deleteProperty(globalThis, REQUEST_RENDER_KEY);
|
|
129
133
|
process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
|
|
130
134
|
mkdirSync(AGENT_DIR, { recursive: true });
|
|
131
135
|
// 默认 yolo config(大多数测试需要可预测起点)
|
|
@@ -134,10 +138,9 @@ beforeEach(() => {
|
|
|
134
138
|
|
|
135
139
|
afterEach(() => {
|
|
136
140
|
delete process.env.PI_CODING_AGENT_DIR;
|
|
137
|
-
if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
138
|
-
// 清理 footer 握手 slot(session_start/session_tree handler 会写 globalThis)
|
|
139
141
|
Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
|
|
140
142
|
Reflect.deleteProperty(globalThis, REQUEST_RENDER_KEY);
|
|
143
|
+
if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true });
|
|
141
144
|
});
|
|
142
145
|
|
|
143
146
|
// ──────────────────────── 测试 ────────────────────────
|
|
@@ -294,91 +297,165 @@ describe("W8 /permission rule 命令集成", () => {
|
|
|
294
297
|
});
|
|
295
298
|
});
|
|
296
299
|
|
|
297
|
-
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
// ──────────────────────── footer line 注册(consumer 端握手 + render 读时刷新)────────────────────────
|
|
298
303
|
|
|
299
304
|
describe("footer line 注册", () => {
|
|
300
|
-
it("session_start:ctx.ui 无 theme →
|
|
301
|
-
writeConfig("yolo");
|
|
305
|
+
it("session_start:ctx.ui 无 theme → 仍注册 renderer(不依赖 ctx.ui),render(null theme) 返回 null", () => {
|
|
302
306
|
const { pi, calls } = createMockPi();
|
|
303
307
|
permissionExtension(pi);
|
|
304
308
|
const sessionHandler = getSessionStartHandler(calls);
|
|
305
|
-
|
|
306
|
-
// ctx.ui 完全无 theme 字段(headless/mock 形状)。
|
|
307
|
-
// registerFooterLineFor 的 duck typing 守卫应命中,返回 () => {} 而不调
|
|
308
|
-
// registerPermissionFooterLine(故 globalThis 不应出现握手 slot)。
|
|
309
309
|
const ctxNoTheme = { mode: "json", cwd: "/tmp", ui: {}, signal: undefined };
|
|
310
310
|
expect(() => sessionHandler({}, ctxNoTheme)).not.toThrow();
|
|
311
|
-
|
|
311
|
+
// 不再依赖 ctx.ui.theme(pi 的 ExtensionUIContext 无此字段):无条件注册
|
|
312
|
+
const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY) as {
|
|
313
|
+
pending: Array<{ id: string; renderer: unknown }>;
|
|
314
|
+
} | undefined;
|
|
315
|
+
expect(slot).toBeDefined();
|
|
316
|
+
expect(slot!.pending.length).toBe(1);
|
|
317
|
+
const renderer = slot!.pending[0]!.renderer as {
|
|
318
|
+
render: (ctx: unknown, theme: unknown) => string | null;
|
|
319
|
+
};
|
|
320
|
+
// headless:theme 无效(undefined)→ null(statusline 跳过)
|
|
321
|
+
expect(renderer.render(undefined, undefined)).toBeNull();
|
|
312
322
|
});
|
|
313
323
|
|
|
314
|
-
it("
|
|
315
|
-
writeConfig("yolo");
|
|
324
|
+
it("render:theme 无 fg 函数 → 返回 null(不抛)", () => {
|
|
316
325
|
const { pi, calls } = createMockPi();
|
|
317
326
|
permissionExtension(pi);
|
|
318
327
|
const sessionHandler = getSessionStartHandler(calls);
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
signal: undefined,
|
|
328
|
+
sessionHandler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
|
|
329
|
+
const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY) as {
|
|
330
|
+
pending: Array<{ id: string; renderer: unknown }>;
|
|
331
|
+
} | undefined;
|
|
332
|
+
const renderer = slot!.pending[0]!.renderer as {
|
|
333
|
+
render: (ctx: unknown, theme: unknown) => string | null;
|
|
326
334
|
};
|
|
327
|
-
expect((
|
|
328
|
-
// 未注册 renderer → 不应触碰 globalThis 握手 slot
|
|
329
|
-
expect(Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY)).toBeUndefined();
|
|
335
|
+
expect(renderer.render(undefined, { fg: "not-a-fn" })).toBeNull();
|
|
330
336
|
});
|
|
331
337
|
|
|
332
|
-
it("
|
|
338
|
+
it("session_start:注册 renderer,render(ctx, theme) 用传入 theme 构造 palette + 读时刷新", () => {
|
|
333
339
|
writeConfig("yolo");
|
|
334
340
|
const { pi, calls } = createMockPi();
|
|
335
341
|
permissionExtension(pi);
|
|
336
|
-
const
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
mode: "json",
|
|
341
|
-
cwd: "/tmp",
|
|
342
|
-
ui: {
|
|
343
|
-
theme: {
|
|
344
|
-
fg: (_token: string, text: string): string => text,
|
|
345
|
-
},
|
|
346
|
-
},
|
|
347
|
-
signal: undefined,
|
|
348
|
-
};
|
|
349
|
-
|
|
350
|
-
// 第一次:注册 renderer(无旧 renderer,dispose 是 noop)。
|
|
351
|
-
expect(() => sessionTreeHandler({}, ctxWithTheme)).not.toThrow();
|
|
352
|
-
expect(Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY)).toBeDefined();
|
|
353
|
-
|
|
354
|
-
// 切换 config(模拟分支切换后 permission-config.json 变化)。
|
|
355
|
-
writeConfig("strict");
|
|
356
|
-
|
|
357
|
-
// 第二次:dispose 上次的 renderer → refreshConfig 读到 strict → 重新注册。
|
|
358
|
-
// disposeFooterLine 是闭包私有变量,这里间接验证:多次触发不抛、不破坏 slot 结构。
|
|
359
|
-
expect(() => sessionTreeHandler({}, ctxWithTheme)).not.toThrow();
|
|
360
|
-
// slot 仍存在且结构合规(version=1,pending 是数组)
|
|
342
|
+
const sessionHandler = getSessionStartHandler(calls);
|
|
343
|
+
// 注册不依赖 ctx.ui.theme;theme 由 statusline 在 render 时传入
|
|
344
|
+
sessionHandler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
|
|
345
|
+
// consumer 端:owner(pi-statusline) 未加载 → registry 未就绪 → renderer 进 pending
|
|
361
346
|
const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY) as {
|
|
362
347
|
version: number;
|
|
363
|
-
pending: unknown
|
|
364
|
-
};
|
|
348
|
+
pending: Array<{ id: string; renderer: unknown }>;
|
|
349
|
+
} | undefined;
|
|
365
350
|
expect(slot).toBeDefined();
|
|
366
|
-
expect(slot
|
|
367
|
-
expect(
|
|
368
|
-
|
|
369
|
-
|
|
351
|
+
expect(slot!.version).toBe(1);
|
|
352
|
+
expect(slot!.pending.length).toBe(1);
|
|
353
|
+
expect(slot!.pending[0]!.id).toBe("pi-permission");
|
|
354
|
+
const renderer = slot!.pending[0]!.renderer as {
|
|
355
|
+
render: (ctx: unknown, theme: unknown) => string | null;
|
|
356
|
+
};
|
|
357
|
+
// statusline render hook 传入的 theme 参数(pi Theme 对象,fg(token, text))
|
|
358
|
+
const theme = { fg: (_t: string, text: string): string => text };
|
|
359
|
+
// render 用传入 theme 构造 palette + 读最新 config(yolo)→ 含 YOLO label
|
|
360
|
+
expect(renderer.render(undefined, theme)).toContain("YOLO");
|
|
361
|
+
// 读时刷新:改 config mode 后再次 render → 反映 Strict(验证 render 内 loadAndWatchConfig)
|
|
362
|
+
writeConfig("strict");
|
|
363
|
+
const out2 = renderer.render(undefined, theme);
|
|
364
|
+
expect(out2).toContain("Strict");
|
|
365
|
+
expect(out2).not.toContain("YOLO");
|
|
370
366
|
});
|
|
371
367
|
|
|
372
|
-
it("session_tree
|
|
373
|
-
writeConfig("yolo");
|
|
368
|
+
it("session_tree:触发不抛异常(只 requestRender,不改注册状态)", () => {
|
|
374
369
|
const { pi, calls } = createMockPi();
|
|
375
370
|
permissionExtension(pi);
|
|
376
371
|
const sessionTreeHandler = getSessionTreeHandler(calls);
|
|
372
|
+
expect(() => sessionTreeHandler({}, undefined)).not.toThrow();
|
|
373
|
+
});
|
|
374
|
+
});
|
|
377
375
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
376
|
+
// ──────────────────────── session_start 配置路径迁移接线(index.ts migrateLegacyConfig 调用)────────────────────────
|
|
377
|
+
// 模块级 once flag(configMigrationChecked)跨用例残留:用 vi.resetModules() + 动态 import
|
|
378
|
+
// 取新鲜模块实例,否则前面用例已触发过 session_start,迁移分支永不执行(静默不测)。
|
|
379
|
+
describe("session_start 配置路径迁移接线", () => {
|
|
380
|
+
const LEGACY_PATH = join(AGENT_DIR, "permission-config.json");
|
|
381
|
+
|
|
382
|
+
/** 重置模块 + 动态 import,返回新鲜 permissionExtension 工厂。 */
|
|
383
|
+
async function freshExtension(): Promise<(pi: Pick<ExtensionAPI, "on" | "registerCommand">) => void> {
|
|
384
|
+
vi.resetModules();
|
|
385
|
+
const mod = await import("../index.js");
|
|
386
|
+
return mod.default as (pi: Pick<ExtensionAPI, "on" | "registerCommand">) => void;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** 写旧路径配置文件(permission-config.json)。 */
|
|
390
|
+
function writeLegacyConfig(mode: string): void {
|
|
391
|
+
mkdirSync(AGENT_DIR, { recursive: true });
|
|
392
|
+
writeFileSync(
|
|
393
|
+
LEGACY_PATH,
|
|
394
|
+
JSON.stringify({ mode, enabled: true, classifier: { enabled: true }, userRules: [] }, null, 2) + "\n",
|
|
395
|
+
{ mode: 0o600 },
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
it("旧路径存在 + 新路径不存在 → session_start 触发 renameSync 迁移(内容保留 + 旧文件消失)", async () => {
|
|
400
|
+
// 前置:beforeEach 已写新路径 yolo 配置,迁移场景要求新路径不存在
|
|
401
|
+
rmSync(CONFIG_PATH, { force: true });
|
|
402
|
+
writeLegacyConfig("strict");
|
|
403
|
+
|
|
404
|
+
const extension = await freshExtension();
|
|
405
|
+
const { pi, calls } = createMockPi();
|
|
406
|
+
extension(pi);
|
|
407
|
+
const handler = getSessionStartHandler(calls);
|
|
408
|
+
handler({}, makeCtx("json"));
|
|
409
|
+
|
|
410
|
+
// 新路径存在且内容来自旧文件(renameSync 保留内容),旧文件消失
|
|
411
|
+
expect(existsSync(CONFIG_PATH)).toBe(true);
|
|
412
|
+
const migrated = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
413
|
+
expect(migrated.mode).toBe("strict");
|
|
414
|
+
expect(existsSync(LEGACY_PATH)).toBe(false);
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it("once flag:同进程第二次 session_start 不重复迁移(重建旧文件不动)", async () => {
|
|
418
|
+
rmSync(CONFIG_PATH, { force: true });
|
|
419
|
+
writeLegacyConfig("auto");
|
|
420
|
+
|
|
421
|
+
const extension = await freshExtension();
|
|
422
|
+
const { pi, calls } = createMockPi();
|
|
423
|
+
extension(pi);
|
|
424
|
+
const handler = getSessionStartHandler(calls);
|
|
425
|
+
handler({}, makeCtx("json"));
|
|
426
|
+
expect(existsSync(LEGACY_PATH)).toBe(false); // 首次已迁移
|
|
427
|
+
|
|
428
|
+
// 重建旧文件(模拟外部残留),再次触发 session_start → once flag 跳过迁移
|
|
429
|
+
writeLegacyConfig("yolo");
|
|
430
|
+
handler({}, makeCtx("json"));
|
|
431
|
+
expect(existsSync(LEGACY_PATH)).toBe(true); // 未被再次消费
|
|
432
|
+
expect(existsSync(CONFIG_PATH)).toBe(true);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("旧路径存在 + 新路径已存在 → 删除旧文件(残留副本清理),新配置内容不变", async () => {
|
|
436
|
+
// beforeEach 已写新路径 yolo;再放旧文件 → session_start 删旧文件,新内容保持 yolo
|
|
437
|
+
writeLegacyConfig("strict");
|
|
438
|
+
|
|
439
|
+
const extension = await freshExtension();
|
|
440
|
+
const { pi, calls } = createMockPi();
|
|
441
|
+
extension(pi);
|
|
442
|
+
const handler = getSessionStartHandler(calls);
|
|
443
|
+
handler({}, makeCtx("json"));
|
|
444
|
+
|
|
445
|
+
expect(existsSync(LEGACY_PATH)).toBe(false);
|
|
446
|
+
const current = JSON.parse(readFileSync(CONFIG_PATH, "utf-8"));
|
|
447
|
+
expect(current.mode).toBe("yolo");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("旧路径不存在(全新安装)→ noop,不创建新路径之外的东西", async () => {
|
|
451
|
+
rmSync(CONFIG_PATH, { force: true });
|
|
452
|
+
expect(existsSync(LEGACY_PATH)).toBe(false);
|
|
453
|
+
|
|
454
|
+
const extension = await freshExtension();
|
|
455
|
+
const { pi, calls } = createMockPi();
|
|
456
|
+
extension(pi);
|
|
457
|
+
const handler = getSessionStartHandler(calls);
|
|
458
|
+
expect(() => handler({}, makeCtx("json"))).not.toThrow();
|
|
459
|
+
expect(existsSync(CONFIG_PATH)).toBe(false); // 迁移不凭空造新配置
|
|
383
460
|
});
|
|
384
461
|
});
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* model-picker.test.ts — W7 T5:model picker 单元测试。
|
|
3
3
|
*
|
|
4
4
|
* 覆盖:
|
|
5
|
-
* - listAvailableModels(
|
|
5
|
+
* - listAvailableModels(2):E2 后走 mock ctx.modelRegistry(详细覆盖在 model-resolver.test.ts)
|
|
6
6
|
* - pickModelViaOverlay(3):TUI mock custom / RPC mock select / headless 降级
|
|
7
7
|
* - ProviderModelSelectorComponent(5,含 WR1 handleInput 锁定):
|
|
8
8
|
* 构造 / 初始 stage / provider onSelect / switchToModelStage / model onSelect
|
|
@@ -10,12 +10,10 @@
|
|
|
10
10
|
*
|
|
11
11
|
* 用真实 pi-tui SelectList(不 mock),验证键盘委托集成通路(WR1 critical)。
|
|
12
12
|
*/
|
|
13
|
-
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
-
import { tmpdir } from "node:os";
|
|
15
|
-
import { join } from "node:path";
|
|
16
|
-
|
|
17
13
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
18
14
|
|
|
15
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
16
|
+
|
|
19
17
|
import type { ResolvedModelEntry } from "../classifier/model-resolver.js";
|
|
20
18
|
import { listAvailableModels } from "../classifier/model-resolver.js";
|
|
21
19
|
import {
|
|
@@ -26,103 +24,70 @@ import {
|
|
|
26
24
|
type SelectionResult,
|
|
27
25
|
} from "../model-picker.js";
|
|
28
26
|
|
|
29
|
-
// ────────────────────────
|
|
30
|
-
|
|
31
|
-
let tempDir: string;
|
|
32
|
-
let modelsJsonPath: string;
|
|
33
|
-
|
|
34
|
-
beforeEach(() => {
|
|
35
|
-
tempDir = mkdtempSync(join(tmpdir(), "pi-perm-mp-"));
|
|
36
|
-
modelsJsonPath = join(tempDir, "models.json");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
afterEach(() => {
|
|
40
|
-
rmSync(tempDir, { recursive: true, force: true });
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
function writeModels(data: unknown): void {
|
|
44
|
-
writeFileSync(modelsJsonPath, JSON.stringify(data), "utf-8");
|
|
45
|
-
}
|
|
27
|
+
// ──────────────────────── mock fixtures(listAvailableModels) ────────────────────────
|
|
46
28
|
|
|
47
29
|
/** 构造一条 ResolvedModelEntry(测试 helper)。 */
|
|
48
|
-
function makeEntry(provider: string, id: string, inputCost: number
|
|
30
|
+
function makeEntry(provider: string, id: string, inputCost: number): ResolvedModelEntry {
|
|
49
31
|
return {
|
|
50
32
|
provider,
|
|
51
33
|
id,
|
|
52
34
|
name: id,
|
|
53
35
|
api: "openai-completions",
|
|
54
36
|
cost: { input: inputCost, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
55
|
-
hasApiKey,
|
|
56
37
|
};
|
|
57
38
|
}
|
|
58
39
|
|
|
59
|
-
/** 构造
|
|
40
|
+
/** 构造 mock Model(modelRegistry.getAll 用)。 */
|
|
41
|
+
function makeMockModel(provider: string, id: string): Model<Api> {
|
|
42
|
+
return {
|
|
43
|
+
id,
|
|
44
|
+
name: id,
|
|
45
|
+
api: "openai-completions" as Api,
|
|
46
|
+
provider,
|
|
47
|
+
baseUrl: "",
|
|
48
|
+
reasoning: false,
|
|
49
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
50
|
+
} as Model<Api>;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** 构造 models Map(用于 component / pickModelViaOverlay 测试,避免真实数据源)。 */
|
|
60
54
|
function makeModelsMap(providers: Record<string, ResolvedModelEntry[]>): Map<string, ResolvedModelEntry[]> {
|
|
61
55
|
return new Map(Object.entries(providers));
|
|
62
56
|
}
|
|
63
57
|
|
|
64
|
-
// ──────────────────────── listAvailableModels(
|
|
65
|
-
|
|
66
|
-
describe("MPT1: listAvailableModels", () => {
|
|
67
|
-
it("文件缺失 → 空 Map(不 throw)", () => {
|
|
68
|
-
expect(existsSync(modelsJsonPath)).toBe(false);
|
|
69
|
-
const map = listAvailableModels(undefined, modelsJsonPath);
|
|
70
|
-
expect(map.size).toBe(0);
|
|
71
|
-
});
|
|
58
|
+
// ──────────────────────── listAvailableModels(E2 数据源接入) ────────────────────────
|
|
72
59
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const map = listAvailableModels(undefined, modelsJsonPath);
|
|
81
|
-
expect(map.has("auth-co")).toBe(true);
|
|
82
|
-
expect(map.has("noauth-co")).toBe(false); // 无 apiKey 过滤
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it("多 provider 按 provider 名字母序排序(Map 插入序)", () => {
|
|
86
|
-
writeModels({
|
|
87
|
-
providers: {
|
|
88
|
-
"zebra-co": { apiKey: "k1", api: "openai-completions", models: [{ id: "m1", cost: { input: 0.1, output: 0, cacheRead: 0, cacheWrite: 0 } }] },
|
|
89
|
-
"alpha-co": { apiKey: "k2", api: "openai-completions", models: [{ id: "m2", cost: { input: 0.2, output: 0, cacheRead: 0, cacheWrite: 0 } }] },
|
|
60
|
+
describe("MPT1: listAvailableModels(E2 走 ctx.modelRegistry)", () => {
|
|
61
|
+
it("mock ctx.modelRegistry:hasConfiguredAuth 过滤后按 provider 分组(OAuth provider 可见)", () => {
|
|
62
|
+
const ctx: ModelPickerContext = {
|
|
63
|
+
mode: "rpc",
|
|
64
|
+
modelRegistry: {
|
|
65
|
+
getAll: () => [makeMockModel("auth-co", "m1"), makeMockModel("noauth-co", "m2")],
|
|
66
|
+
hasConfiguredAuth: (m) => m.provider === "auth-co",
|
|
90
67
|
},
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
it("provider 内 model 按 cost.input 升序 + id 字母序 tiebreaker", () => {
|
|
98
|
-
writeModels({
|
|
99
|
-
providers: {
|
|
100
|
-
"co": {
|
|
101
|
-
apiKey: "k1",
|
|
102
|
-
api: "openai-completions",
|
|
103
|
-
models: [
|
|
104
|
-
{ id: "expensive", cost: { input: 1.0, output: 0, cacheRead: 0, cacheWrite: 0 } },
|
|
105
|
-
{ id: "cheap-b", cost: { input: 0.1, output: 0, cacheRead: 0, cacheWrite: 0 } },
|
|
106
|
-
{ id: "cheap-a", cost: { input: 0.1, output: 0, cacheRead: 0, cacheWrite: 0 } },
|
|
107
|
-
],
|
|
108
|
-
},
|
|
68
|
+
ui: {
|
|
69
|
+
notify: vi.fn(),
|
|
70
|
+
select: vi.fn(() => Promise.resolve(undefined)),
|
|
71
|
+
custom: vi.fn(() => Promise.resolve(undefined)),
|
|
109
72
|
},
|
|
110
|
-
}
|
|
111
|
-
const map = listAvailableModels(
|
|
112
|
-
|
|
113
|
-
expect(
|
|
73
|
+
};
|
|
74
|
+
const map = listAvailableModels(ctx);
|
|
75
|
+
expect(map.has("auth-co")).toBe(true);
|
|
76
|
+
expect(map.has("noauth-co")).toBe(false);
|
|
114
77
|
});
|
|
115
78
|
|
|
116
|
-
it("
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
79
|
+
it("registry 无可选模型 → 空 Map(picker 降级提示)", () => {
|
|
80
|
+
const ctx: ModelPickerContext = {
|
|
81
|
+
mode: "rpc",
|
|
82
|
+
modelRegistry: { getAll: () => [], hasConfiguredAuth: () => false },
|
|
83
|
+
ui: {
|
|
84
|
+
notify: vi.fn(),
|
|
85
|
+
select: vi.fn(() => Promise.resolve(undefined)),
|
|
86
|
+
custom: vi.fn(() => Promise.resolve(undefined)),
|
|
121
87
|
},
|
|
122
|
-
}
|
|
123
|
-
const map = listAvailableModels(
|
|
124
|
-
expect(map.
|
|
125
|
-
expect(map.has("has-co")).toBe(true);
|
|
88
|
+
};
|
|
89
|
+
const map = listAvailableModels(ctx);
|
|
90
|
+
expect(map.size).toBe(0);
|
|
126
91
|
});
|
|
127
92
|
});
|
|
128
93
|
|
|
@@ -131,13 +96,21 @@ describe("MPT1: listAvailableModels", () => {
|
|
|
131
96
|
function makePickerCtx(overrides: Partial<ModelPickerContext> = {}): ModelPickerContext {
|
|
132
97
|
const base: ModelPickerContext = {
|
|
133
98
|
mode: "tui",
|
|
99
|
+
modelRegistry: {
|
|
100
|
+
getAll: vi.fn(() => []),
|
|
101
|
+
hasConfiguredAuth: vi.fn(() => false),
|
|
102
|
+
},
|
|
134
103
|
ui: {
|
|
135
104
|
notify: vi.fn(),
|
|
136
105
|
select: vi.fn(() => Promise.resolve(undefined)),
|
|
137
106
|
custom: vi.fn(() => Promise.resolve(undefined)),
|
|
138
107
|
},
|
|
139
108
|
};
|
|
140
|
-
return {
|
|
109
|
+
return {
|
|
110
|
+
mode: overrides.mode ?? base.mode,
|
|
111
|
+
modelRegistry: overrides.modelRegistry ?? base.modelRegistry,
|
|
112
|
+
ui: { ...base.ui, ...overrides.ui },
|
|
113
|
+
};
|
|
141
114
|
}
|
|
142
115
|
|
|
143
116
|
describe("MPT2: pickModelViaOverlay 分发", () => {
|
|
@@ -296,7 +269,7 @@ describe("MPT5: ProviderModelSelectorComponent model onSelect", () => {
|
|
|
296
269
|
// 下移到 'co',Enter 进 model stage
|
|
297
270
|
comp.handleInput("\x1b[B"); // Down
|
|
298
271
|
comp.handleInput("\r"); // Enter → model stage
|
|
299
|
-
// model stage 预选 m1(
|
|
272
|
+
// model stage 预选 m1(provider + id 字典序第一个)
|
|
300
273
|
comp.handleInput("\r"); // Enter 选 m1
|
|
301
274
|
expect(done).toHaveBeenCalledWith({ kind: "specific", provider: "co", modelId: "m1" });
|
|
302
275
|
});
|