@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.
@@ -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 { describe, expect, it } from "vitest";
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-config.json,
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
- // ──────────────────────── footer line 注册(duck typing + session_tree)────────────────────────
300
+
301
+
302
+ // ──────────────────────── footer line 注册(consumer 端握手 + render 读时刷新)────────────────────────
298
303
 
299
304
  describe("footer line 注册", () => {
300
- it("session_start:ctx.ui 无 theme → registerFooterLineFor 返回 noop dispose,不抛异常", () => {
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
- expect(Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY)).toBeUndefined();
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("session_start:ctx.ui.theme 存在但 fg 非函数 → noop dispose,不抛异常", () => {
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
- // theme 存在但 fg 缺失/非函数 → duck typing 失败 → noop dispose。
321
- const ctxBadFg = {
322
- mode: "json",
323
- cwd: "/tmp",
324
- ui: { theme: { fg: "not-a-function" } },
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(() => sessionHandler({}, ctxBadFg)).not.toThrow();
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("session_tree:触发两次 handler 不抛异常(dispose 旧 renderer → refreshConfig → 重注册)", () => {
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 sessionTreeHandler = getSessionTreeHandler(calls);
337
-
338
- // ctx.ui.theme 为合法 fg 函数 → 真实注册 renderer(写 globalThis slot)。
339
- const ctxWithTheme = {
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.version).toBe(1);
367
- expect(Array.isArray(slot.pending)).toBe(true);
368
- // 两次注册 + 一次 dispose(第二次触发前)→ pending 应至多 1 项(旧 renderer 已 dispose 移除)
369
- expect(slot.pending.length).toBeLessThanOrEqual(1);
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:ctx.ui 无 theme 时多次触发仍安全(noop dispose 重复调用)", () => {
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
- // 无 theme → 两次都是 noop dispose,不应抛、不应写 globalThis。
379
- const ctxNoTheme = { mode: "json", cwd: "/tmp", ui: {}, signal: undefined };
380
- expect(() => sessionTreeHandler({}, ctxNoTheme)).not.toThrow();
381
- expect(() => sessionTreeHandler({}, ctxNoTheme)).not.toThrow();
382
- expect(Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY)).toBeUndefined();
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):空文件 / 无 apiKey 过滤 / 多 provider 排序 / cost 升序 / 无 model 的 provider 不进 Map
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
- // ──────────────────────── 临时文件 fixtures(listAvailableModels) ────────────────────────
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, hasApiKey = true): ResolvedModelEntry {
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
- /** 构造 models Map(用于 component / pickModelViaOverlay 测试,避免读盘)。 */
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(5 cases) ────────────────────────
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
- it("无 apiKey 的 provider 被过滤(不进 Map)", () => {
74
- writeModels({
75
- providers: {
76
- "auth-co": { apiKey: "k1", api: "openai-completions", models: [{ id: "m1", cost: { input: 0.1, output: 0, cacheRead: 0, cacheWrite: 0 } }] },
77
- "noauth-co": { api: "openai-completions", models: [{ id: "m2", cost: { input: 0.2, output: 0, cacheRead: 0, cacheWrite: 0 } }] },
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
- const map = listAvailableModels(undefined, modelsJsonPath);
93
- const providers = [...map.keys()];
94
- expect(providers).toEqual(["alpha-co", "zebra-co"]); // 字母序
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(undefined, modelsJsonPath);
112
- const models = map.get("co")!;
113
- expect(models.map((m) => m.id)).toEqual(["cheap-a", "cheap-b", "expensive"]);
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("provider 有 apiKey 但无 model → 不进 Map(无 model 项被过滤)", () => {
117
- writeModels({
118
- providers: {
119
- "empty-co": { apiKey: "k1", api: "openai-completions", models: [] },
120
- "has-co": { apiKey: "k2", api: "openai-completions", models: [{ id: "m1", cost: { input: 0.1, output: 0, cacheRead: 0, cacheWrite: 0 } }] },
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(undefined, modelsJsonPath);
124
- expect(map.has("empty-co")).toBe(false); // 无 model 不进 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 { mode: overrides.mode ?? base.mode, ui: { ...base.ui, ...overrides.ui } };
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(cost 升序第一个)
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
  });