@zhushanwen/pi-permission 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/README.md +455 -0
  2. package/index.ts +2 -0
  3. package/package.json +52 -0
  4. package/src/__tests__/approval.test.ts +456 -0
  5. package/src/__tests__/commands.test.ts +487 -0
  6. package/src/__tests__/config.test.ts +229 -0
  7. package/src/__tests__/e2e-modes.test.ts +253 -0
  8. package/src/__tests__/extension.test.ts +170 -0
  9. package/src/__tests__/index-integration.test.ts +282 -0
  10. package/src/__tests__/model-picker.test.ts +371 -0
  11. package/src/__tests__/pipeline.test.ts +976 -0
  12. package/src/__tests__/production.test.ts +102 -0
  13. package/src/__tests__/rule-editor-component.test.ts +475 -0
  14. package/src/__tests__/rule-editor.test.ts +252 -0
  15. package/src/__tests__/rule-templates.test.ts +264 -0
  16. package/src/__tests__/statusline.test.ts +221 -0
  17. package/src/__tests__/types.test.ts +59 -0
  18. package/src/approval.ts +352 -0
  19. package/src/ast/__tests__/analyzer.test.ts +262 -0
  20. package/src/ast/__tests__/loader.test.ts +136 -0
  21. package/src/ast/analyzer.ts +326 -0
  22. package/src/ast/index.ts +7 -0
  23. package/src/ast/loader.ts +113 -0
  24. package/src/classifier/__tests__/classifier.test.ts +285 -0
  25. package/src/classifier/__tests__/json-parser.test.ts +118 -0
  26. package/src/classifier/__tests__/model-resolver.test.ts +173 -0
  27. package/src/classifier/__tests__/prompt.test.ts +59 -0
  28. package/src/classifier/classifier.ts +242 -0
  29. package/src/classifier/index.ts +28 -0
  30. package/src/classifier/json-parser.ts +127 -0
  31. package/src/classifier/model-resolver.ts +295 -0
  32. package/src/classifier/prompt.ts +52 -0
  33. package/src/commands.ts +234 -0
  34. package/src/config.ts +216 -0
  35. package/src/index.ts +291 -0
  36. package/src/model-picker.ts +398 -0
  37. package/src/pipeline.ts +541 -0
  38. package/src/production.ts +102 -0
  39. package/src/rule-editor-component.ts +883 -0
  40. package/src/rule-editor.ts +262 -0
  41. package/src/rule-templates.ts +332 -0
  42. package/src/rules/__tests__/builtins.test.ts +614 -0
  43. package/src/rules/__tests__/index.test.ts +26 -0
  44. package/src/rules/__tests__/matcher.test.ts +378 -0
  45. package/src/rules/__tests__/wildcard.test.ts +84 -0
  46. package/src/rules/builtins.ts +680 -0
  47. package/src/rules/index.ts +27 -0
  48. package/src/rules/matcher.ts +160 -0
  49. package/src/rules/wildcard.ts +43 -0
  50. package/src/statusline.ts +226 -0
  51. package/src/types.ts +249 -0
@@ -0,0 +1,282 @@
1
+ /**
2
+ * index-integration.test.ts — W5 tool_call handler 集成测试。
3
+ *
4
+ * 测扩展工厂注册的 tool_call handler 行为:
5
+ * - yolo 快速路径 → return undefined(放行)
6
+ * - strict 模式 → block(headless deny)
7
+ * - mode 切换(config 文件变化后 handler 用新 mode)
8
+ * - G5 并发串行化(多次 tool_call 顺序处理)
9
+ * - fail-closed(异常 → block)
10
+ * - session_start 重载 config
11
+ *
12
+ * 用 PI_CODING_AGENT_DIR 指向临时目录,写入 controlled permission-config.json,
13
+ * 让 loadAndWatchConfig 读到指定 mode。mock pi 对象记录 handler 调用。
14
+ */
15
+ import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
21
+
22
+ import permissionExtension from "../index.js";
23
+
24
+ // ──────────────────────── mock pi ────────────────────────
25
+
26
+ /** tool_call handler 的最小签名(返回 Promise<block 结果 | undefined>)。 */
27
+ type ToolCallHandler = (event: unknown, ctx: unknown) => Promise<ToolCallResult | undefined>;
28
+ /** tool_call handler 的返回值(Pi ToolCallEventResult 子集)。 */
29
+ interface ToolCallResult {
30
+ block?: boolean;
31
+ reason?: string;
32
+ }
33
+
34
+ interface MockPiCalls {
35
+ registerCommandCalls: Array<{ name: string; options: unknown }>;
36
+ eventHandlers: Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>>;
37
+ }
38
+
39
+ /**
40
+ * 构造 mock pi。返回对象满足 ExtensionAPI 的 on/registerCommand 子集,
41
+ * 用 `as Pick<ExtensionAPI, "on" | "registerCommand">` 单点断言(taste/no-unsafe-cast 允许 Pick 断言)。
42
+ */
43
+ function createMockPi(): { pi: Pick<ExtensionAPI, "on" | "registerCommand">; calls: MockPiCalls } {
44
+ const registerCommandCalls: MockPiCalls["registerCommandCalls"] = [];
45
+ const eventHandlers: MockPiCalls["eventHandlers"] = new Map();
46
+ const pi = {
47
+ registerCommand(name: string, options: unknown): void {
48
+ registerCommandCalls.push({ name, options });
49
+ },
50
+ on(event: string, handler: (event?: unknown, ctx?: unknown) => unknown): void {
51
+ if (!eventHandlers.has(event)) eventHandlers.set(event, []);
52
+ eventHandlers.get(event)!.push(handler);
53
+ },
54
+ };
55
+ return { pi: pi as Pick<ExtensionAPI, "on" | "registerCommand">, calls: { registerCommandCalls, eventHandlers } };
56
+ }
57
+
58
+ /** 提取 tool_call handler(已注册的单一 handler)。 */
59
+ function getToolCallHandler(calls: MockPiCalls): ToolCallHandler {
60
+ const handlers = calls.eventHandlers.get("tool_call");
61
+ if (!handlers || handlers.length === 0) {
62
+ throw new Error("tool_call handler not registered");
63
+ }
64
+ return handlers[0] as ToolCallHandler;
65
+ }
66
+
67
+ /** 提取 session_start handler。 */
68
+ function getSessionStartHandler(calls: MockPiCalls): (event: unknown, ctx: unknown) => unknown {
69
+ const handlers = calls.eventHandlers.get("session_start");
70
+ if (!handlers || handlers.length === 0) {
71
+ throw new Error("session_start handler not registered");
72
+ }
73
+ return handlers[0];
74
+ }
75
+
76
+ /** 构造 mock ctx(含 mode/ui/signal)。 */
77
+ function makeCtx(mode: "tui" | "rpc" | "json" | "print" = "json"): unknown {
78
+ return {
79
+ mode,
80
+ cwd: "/tmp",
81
+ ui: {
82
+ notify(): void {
83
+ /* noop */
84
+ },
85
+ select(): Promise<string | undefined> {
86
+ return Promise.resolve(undefined);
87
+ },
88
+ custom(): Promise<unknown> {
89
+ return Promise.resolve(undefined);
90
+ },
91
+ },
92
+ signal: undefined,
93
+ };
94
+ }
95
+
96
+ /** bash tool_call event 构造器。 */
97
+ function bashEvent(command: string): { toolName: string; input: { command: string } } {
98
+ return { toolName: "bash", input: { command } };
99
+ }
100
+
101
+ // ──────────────────────── 临时 config 目录 ────────────────────────
102
+
103
+ const TMP_ROOT = join(tmpdir(), "pi-perm-test-" + process.pid);
104
+ const AGENT_DIR = join(TMP_ROOT, "agent");
105
+ const CONFIG_PATH = join(AGENT_DIR, "permission-config.json");
106
+
107
+ function writeConfig(mode: string, enabled = true): void {
108
+ mkdirSync(AGENT_DIR, { recursive: true });
109
+ const config = {
110
+ mode,
111
+ enabled,
112
+ classifier: { enabled: true, model: "auto", timeout: 5, autoApproveLowRisk: true, autoDenyHighRisk: true },
113
+ userRules: [],
114
+ };
115
+ writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
116
+ }
117
+
118
+ beforeEach(() => {
119
+ process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
120
+ mkdirSync(AGENT_DIR, { recursive: true });
121
+ // 默认 yolo config(大多数测试需要可预测起点)
122
+ writeConfig("yolo");
123
+ });
124
+
125
+ afterEach(() => {
126
+ delete process.env.PI_CODING_AGENT_DIR;
127
+ if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true });
128
+ });
129
+
130
+ // ──────────────────────── 测试 ────────────────────────
131
+
132
+ describe("W5 tool_call handler 集成", () => {
133
+ it("工厂注册 session_start + tool_call + /permission command", () => {
134
+ const { pi, calls } = createMockPi();
135
+ expect(() => permissionExtension(pi)).not.toThrow();
136
+ expect(calls.eventHandlers.has("session_start")).toBe(true);
137
+ expect(calls.eventHandlers.has("tool_call")).toBe(true);
138
+ const permCmd = calls.registerCommandCalls.find((c) => c.name === "permission");
139
+ expect(permCmd).toBeDefined();
140
+ });
141
+
142
+ it("yolo 快速路径 → return undefined(放行,不跑管道)", async () => {
143
+ writeConfig("yolo");
144
+ const { pi, calls } = createMockPi();
145
+ permissionExtension(pi);
146
+ const handler = getToolCallHandler(calls);
147
+ const result = await handler(bashEvent("rm -rf /"), makeCtx());
148
+ expect(result).toBeUndefined();
149
+ });
150
+
151
+ it("enabled=false → 等同 yolo 放行", async () => {
152
+ writeConfig("strict", false);
153
+ const { pi, calls } = createMockPi();
154
+ permissionExtension(pi);
155
+ const handler = getToolCallHandler(calls);
156
+ const result = await handler(bashEvent("ls"), makeCtx());
157
+ expect(result).toBeUndefined();
158
+ });
159
+
160
+ it("strict + headless → block(fail-closed deny)", async () => {
161
+ writeConfig("strict");
162
+ const { pi, calls } = createMockPi();
163
+ permissionExtension(pi);
164
+ const handler = getToolCallHandler(calls);
165
+ const result = await handler(bashEvent("ls"), makeCtx("json"));
166
+ expect(result).toBeDefined();
167
+ expect(result?.block).toBe(true);
168
+ expect(result?.reason).toContain("headless");
169
+ });
170
+
171
+ it("approve + 非安全命令 + headless → block", async () => {
172
+ writeConfig("approve");
173
+ const { pi, calls } = createMockPi();
174
+ permissionExtension(pi);
175
+ const handler = getToolCallHandler(calls);
176
+ // curl 不在白名单 → ask → approve 模式 → 人工审批 → headless deny
177
+ const result = await handler(bashEvent("curl http://example.com"), makeCtx("json"));
178
+ expect(result?.block).toBe(true);
179
+ });
180
+
181
+ it("approve + 安全命令(ls)→ 放行", async () => {
182
+ writeConfig("approve");
183
+ const { pi, calls } = createMockPi();
184
+ permissionExtension(pi);
185
+ const handler = getToolCallHandler(calls);
186
+ // ls 在白名单 → allow → 放行
187
+ const result = await handler(bashEvent("ls"), makeCtx("json"));
188
+ expect(result).toBeUndefined();
189
+ });
190
+
191
+ it("缺 toolName → fail-closed block", async () => {
192
+ writeConfig("approve");
193
+ const { pi, calls } = createMockPi();
194
+ permissionExtension(pi);
195
+ const handler = getToolCallHandler(calls);
196
+ const result = await handler({ input: { command: "ls" } }, makeCtx());
197
+ expect(result?.block).toBe(true);
198
+ expect(result?.reason).toContain("missing toolName");
199
+ });
200
+
201
+ it("G5 并发串行:多个 tool_call 顺序处理(不重叠)", async () => {
202
+ writeConfig("strict");
203
+ const { pi, calls } = createMockPi();
204
+ permissionExtension(pi);
205
+ const handler = getToolCallHandler(calls);
206
+ // 并发发起 3 个 tool_call(不 await),它们应串行
207
+ const ctx = makeCtx();
208
+ const p1 = handler(bashEvent("ls"), ctx);
209
+ const p2 = handler(bashEvent("pwd"), ctx);
210
+ const p3 = handler(bashEvent("whoami"), ctx);
211
+ // Promise.all 这里是故意的:3 个调用是同一串行链上的任务,断言全部 block。
212
+ // 非独立降级场景才用 allSettled;此处任一 reject 即测试失败(符合预期)。
213
+ const results = await Promise.all([p1, p2, p3]);
214
+ expect(results.length).toBe(3);
215
+ for (const r of results) {
216
+ expect(r?.block).toBe(true);
217
+ }
218
+ });
219
+
220
+ it("session_start handler 重载 config(mode 切换生效)", async () => {
221
+ writeConfig("yolo");
222
+ const { pi, calls } = createMockPi();
223
+ permissionExtension(pi);
224
+ const toolHandler = getToolCallHandler(calls);
225
+ const sessionHandler = getSessionStartHandler(calls);
226
+
227
+ // 初始 yolo → 放行
228
+ const r1 = await toolHandler(bashEvent("ls"), makeCtx());
229
+ expect(r1).toBeUndefined();
230
+
231
+ // 切换到 strict,触发 session_start 重载
232
+ writeConfig("strict");
233
+ getSessionStartHandler(calls);
234
+ sessionHandler({}, makeCtx());
235
+
236
+ // 现在 strict → block
237
+ const r2 = await toolHandler(bashEvent("ls"), makeCtx());
238
+ expect(r2?.block).toBe(true);
239
+ });
240
+
241
+ it("异常路径 → fail-closed block(绝不放行)", async () => {
242
+ writeConfig("approve");
243
+ const { pi, calls } = createMockPi();
244
+ permissionExtension(pi);
245
+ const handler = getToolCallHandler(calls);
246
+ // input=null → 容错为 {},bash 无 command → 走管道,approve + 空命令 → 人工审批 → headless deny
247
+ const result = await handler({ toolName: "bash", input: null }, makeCtx());
248
+ expect(result).toBeDefined();
249
+ expect(result?.block).toBe(true);
250
+ });
251
+ });
252
+
253
+ // ──────────────────────── W8 /permission rule 命令集成 ────────────────────────
254
+
255
+ /** 提取 /permission command handler。 */
256
+ function getPermissionHandler(calls: MockPiCalls): (args: string, ctx: unknown) => Promise<void> {
257
+ const cmd = calls.registerCommandCalls.find((c) => c.name === "permission");
258
+ if (!cmd) throw new Error("permission command not registered");
259
+ return (cmd.options as { handler: (args: string, ctx: unknown) => Promise<void> }).handler;
260
+ }
261
+
262
+ describe("W8 /permission rule 命令集成", () => {
263
+ it("headless(json)→ notify 降级提示,不改 config", async () => {
264
+ writeConfig("yolo");
265
+ const { pi, calls } = createMockPi();
266
+ permissionExtension(pi);
267
+ const handler = getPermissionHandler(calls);
268
+ const ctx = makeCtx("json");
269
+ // 不应抛错
270
+ await handler("rule", ctx);
271
+ });
272
+
273
+ it("permission handler 分流:rule 参数走 rule 路径(headless 降级)", async () => {
274
+ writeConfig("yolo");
275
+ const { pi, calls } = createMockPi();
276
+ permissionExtension(pi);
277
+ const handler = getPermissionHandler(calls);
278
+ const ctx = makeCtx("json");
279
+ // rule 参数应走 headless 降级(notify)
280
+ await expect(handler("rule", ctx)).resolves.not.toThrow();
281
+ });
282
+ });
@@ -0,0 +1,371 @@
1
+ /**
2
+ * model-picker.test.ts — W7 T5:model picker 单元测试。
3
+ *
4
+ * 覆盖:
5
+ * - listAvailableModels(5):空文件 / 无 apiKey 过滤 / 多 provider 排序 / cost 升序 / 无 model 的 provider 不进 Map
6
+ * - pickModelViaOverlay(3):TUI mock custom / RPC mock select / headless 降级
7
+ * - ProviderModelSelectorComponent(5,含 WR1 handleInput 锁定):
8
+ * 构造 / 初始 stage / provider onSelect / switchToModelStage / model onSelect
9
+ * + 直接调 comp.handleInput('\r') 验证 SelectList.onSelect 触发(WR1)
10
+ *
11
+ * 用真实 pi-tui SelectList(不 mock),验证键盘委托集成通路(WR1 critical)。
12
+ */
13
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
14
+ import { tmpdir } from "node:os";
15
+ import { join } from "node:path";
16
+
17
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
18
+
19
+ import type { ResolvedModelEntry } from "../classifier/model-resolver.js";
20
+ import { listAvailableModels } from "../classifier/model-resolver.js";
21
+ import {
22
+ DEFAULT_SELECT_THEME,
23
+ type ModelPickerContext,
24
+ pickModelViaOverlay,
25
+ ProviderModelSelectorComponent,
26
+ type SelectionResult,
27
+ } from "../model-picker.js";
28
+
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
+ }
46
+
47
+ /** 构造一条 ResolvedModelEntry(测试 helper)。 */
48
+ function makeEntry(provider: string, id: string, inputCost: number, hasApiKey = true): ResolvedModelEntry {
49
+ return {
50
+ provider,
51
+ id,
52
+ name: id,
53
+ api: "openai-completions",
54
+ cost: { input: inputCost, output: 0, cacheRead: 0, cacheWrite: 0 },
55
+ hasApiKey,
56
+ };
57
+ }
58
+
59
+ /** 构造 models Map(用于 component / pickModelViaOverlay 测试,避免读盘)。 */
60
+ function makeModelsMap(providers: Record<string, ResolvedModelEntry[]>): Map<string, ResolvedModelEntry[]> {
61
+ return new Map(Object.entries(providers));
62
+ }
63
+
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
+ });
72
+
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 } }] },
90
+ },
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
+ },
109
+ },
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"]);
114
+ });
115
+
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 } }] },
121
+ },
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);
126
+ });
127
+ });
128
+
129
+ // ──────────────────────── pickModelViaOverlay(3 cases) ────────────────────────
130
+
131
+ function makePickerCtx(overrides: Partial<ModelPickerContext> = {}): ModelPickerContext {
132
+ const base: ModelPickerContext = {
133
+ mode: "tui",
134
+ ui: {
135
+ notify: vi.fn(),
136
+ select: vi.fn(() => Promise.resolve(undefined)),
137
+ custom: vi.fn(() => Promise.resolve(undefined)),
138
+ },
139
+ };
140
+ return { mode: overrides.mode ?? base.mode, ui: { ...base.ui, ...overrides.ui } };
141
+ }
142
+
143
+ describe("MPT2: pickModelViaOverlay 分发", () => {
144
+ it("headless(json)→ 返回 undefined(降级)", async () => {
145
+ const ctx = makePickerCtx({ mode: "json" });
146
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
147
+ const result = await pickModelViaOverlay(ctx, "auto", models);
148
+ expect(result).toBeUndefined();
149
+ });
150
+
151
+ it("空 models Map → 返回 undefined(降级,无论 mode)", async () => {
152
+ const ctx = makePickerCtx({ mode: "tui" });
153
+ const result = await pickModelViaOverlay(ctx, "auto", new Map());
154
+ expect(result).toBeUndefined();
155
+ });
156
+
157
+ it("RPC 模式:第一次选 Auto → 返回 'auto'", async () => {
158
+ const ctx = makePickerCtx({
159
+ mode: "rpc",
160
+ ui: {
161
+ notify: vi.fn(),
162
+ select: vi.fn(() => Promise.resolve("Auto")),
163
+ custom: vi.fn(),
164
+ },
165
+ });
166
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
167
+ const result = await pickModelViaOverlay(ctx, "auto", models);
168
+ expect(result).toBe("auto");
169
+ expect(ctx.ui.select).toHaveBeenCalledOnce();
170
+ });
171
+
172
+ it("RPC 模式:选 provider 后选 model → 返回 'provider/model'", async () => {
173
+ const selectMock = vi.fn()
174
+ .mockResolvedValueOnce("co") // provider
175
+ .mockResolvedValueOnce("m1"); // model
176
+ const ctx = makePickerCtx({
177
+ mode: "rpc",
178
+ ui: { notify: vi.fn(), select: selectMock, custom: vi.fn() },
179
+ });
180
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
181
+ const result = await pickModelViaOverlay(ctx, "auto", models);
182
+ expect(result).toBe("co/m1");
183
+ expect(ctx.ui.select).toHaveBeenCalledTimes(2);
184
+ });
185
+
186
+ it("RPC 模式:第一次 select undefined(cancel)→ 返回 undefined", async () => {
187
+ const ctx = makePickerCtx({
188
+ mode: "rpc",
189
+ ui: {
190
+ notify: vi.fn(),
191
+ select: vi.fn(() => Promise.resolve(undefined)),
192
+ custom: vi.fn(),
193
+ },
194
+ });
195
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
196
+ const result = await pickModelViaOverlay(ctx, "auto", models);
197
+ expect(result).toBeUndefined();
198
+ });
199
+
200
+ it("TUI 模式:custom factory 被调用,comp done settle 结果", async () => {
201
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
202
+ // 模拟 ctx.ui.custom:构造 comp 并模拟 done({kind:'auto'})
203
+ const customMock = vi.fn(
204
+ <T,>(factory: (tui: unknown, theme: unknown, kb: unknown, done: (r: T) => void) => unknown) =>
205
+ new Promise<T>((resolve) => {
206
+ factory({}, {}, {}, (r: T) => resolve(r));
207
+ }),
208
+ );
209
+ const ctx = makePickerCtx({ mode: "tui", ui: { notify: vi.fn(), select: vi.fn(), custom: customMock } });
210
+ // 在 factory 内拿到 comp,但这里 mock 直接调 done({kind:'auto'})
211
+ // 改写 mock:构造真实 comp 并用 cancel/settle
212
+ customMock.mockImplementationOnce(
213
+ <T,>(factory: (tui: unknown, theme: unknown, kb: unknown, done: (r: T) => void) => unknown) =>
214
+ new Promise<T>((resolve) => {
215
+ const comp = factory({}, {}, {}, (r: T) => resolve(r)) as ProviderModelSelectorComponent;
216
+ // 触发 provider stage 的 'Auto' 选中(index 0)
217
+ comp.handleInput("\r"); // Enter → onSelect(Auto) → done({kind:'auto'})
218
+ }),
219
+ );
220
+ const result = await pickModelViaOverlay(ctx, "auto", models);
221
+ expect(result).toBe("auto");
222
+ expect(customMock).toHaveBeenCalledOnce();
223
+ });
224
+ });
225
+
226
+ // ──────────────────────── ProviderModelSelectorComponent(5 cases + WR1) ────────────────────────
227
+
228
+ describe("MPT3: ProviderModelSelectorComponent 构造 + 初始 stage", () => {
229
+ it("构造:初始 stage='provider',render 非空含 'Select Provider'", () => {
230
+ const done = vi.fn();
231
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
232
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
233
+ const lines = comp.render(80);
234
+ const joined = lines.join("\n");
235
+ expect(joined).toContain("Select Provider");
236
+ expect(joined).toContain("Auto");
237
+ expect(done).not.toHaveBeenCalled();
238
+ });
239
+
240
+ it("currentSpec='auto' → 预选 'Auto'(index 0)", () => {
241
+ const done = vi.fn();
242
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
243
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
244
+ comp.handleInput("\r"); // Enter 选中预选项(Auto)
245
+ expect(done).toHaveBeenCalledOnce();
246
+ const result = done.mock.calls[0]![0] as SelectionResult | undefined;
247
+ expect(result).toEqual({ kind: "auto" });
248
+ });
249
+
250
+ it("currentSpec='provider/modelId' → 预选该 provider(Enter 直接到 model stage)", () => {
251
+ const done = vi.fn();
252
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
253
+ const comp = new ProviderModelSelectorComponent("co/m1", ["co"], models, done);
254
+ // 预选 'co'(index 1),Enter 触发 switchToModelStage(不 done)
255
+ comp.handleInput("\r");
256
+ expect(done).not.toHaveBeenCalled(); // 进入 model stage,未 settle
257
+ const lines = comp.render(80);
258
+ expect(lines.join("\n")).toContain("Select Model");
259
+ });
260
+ });
261
+
262
+ describe("MPT4: ProviderModelSelectorComponent provider onSelect", () => {
263
+ it("provider stage 选 Auto → done({kind:'auto'})", () => {
264
+ const done = vi.fn();
265
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
266
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
267
+ comp.handleInput("\r"); // Enter 选中预选 Auto
268
+ expect(done).toHaveBeenCalledWith({ kind: "auto" });
269
+ });
270
+
271
+ it("provider stage 选具体 provider → switchToModelStage(不 done)", () => {
272
+ const done = vi.fn();
273
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
274
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
275
+ // 下移到 'co'(index 1),Enter 进入 model stage
276
+ comp.handleInput("\x1b[B"); // Down arrow
277
+ comp.handleInput("\r"); // Enter
278
+ expect(done).not.toHaveBeenCalled();
279
+ expect(comp.render(80).join("\n")).toContain("Select Model");
280
+ });
281
+
282
+ it("provider stage Esc → done(undefined)(cancel)", () => {
283
+ const done = vi.fn();
284
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
285
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
286
+ comp.handleInput("\x1b"); // Esc
287
+ expect(done).toHaveBeenCalledWith(undefined);
288
+ });
289
+ });
290
+
291
+ describe("MPT5: ProviderModelSelectorComponent model onSelect", () => {
292
+ it("model stage 选 model → done({kind:'specific', provider, modelId})", () => {
293
+ const done = vi.fn();
294
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1), makeEntry("co", "m2", 0.2)] });
295
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
296
+ // 下移到 'co',Enter 进 model stage
297
+ comp.handleInput("\x1b[B"); // Down
298
+ comp.handleInput("\r"); // Enter → model stage
299
+ // model stage 预选 m1(cost 升序第一个)
300
+ comp.handleInput("\r"); // Enter 选 m1
301
+ expect(done).toHaveBeenCalledWith({ kind: "specific", provider: "co", modelId: "m1" });
302
+ });
303
+
304
+ it("model stage Esc → 回退到 provider stage(不 done)", () => {
305
+ const done = vi.fn();
306
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
307
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
308
+ // 进 model stage
309
+ comp.handleInput("\x1b[B"); // Down
310
+ comp.handleInput("\r"); // Enter → model stage
311
+ // Esc 回退
312
+ comp.handleInput("\x1b"); // Esc
313
+ expect(done).not.toHaveBeenCalled();
314
+ expect(comp.render(80).join("\n")).toContain("Select Provider"); // 回到 provider stage
315
+ });
316
+
317
+ it("_resolved 守卫:done 后再 handleInput no-op", () => {
318
+ const done = vi.fn();
319
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
320
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
321
+ comp.handleInput("\r"); // Auto → done
322
+ expect(done).toHaveBeenCalledOnce();
323
+ // 二次输入 no-op
324
+ comp.handleInput("\r");
325
+ expect(done).toHaveBeenCalledOnce();
326
+ });
327
+ });
328
+
329
+ // ──────────────────────── WR1: handleInput 键盘委托锁定(critical) ────────────────────────
330
+
331
+ describe("MPT6: WR1 handleInput 委托 SelectList.onSelect(critical)", () => {
332
+ it("handleInput('\\r') 直接触发 SelectList.onSelect(不绕过键盘委托通路)", () => {
333
+ const done = vi.fn();
334
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
335
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
336
+ // 直接调 handleInput('\r'),验证:
337
+ // 1. Container.handleInput 不存在,组件 override 委托给 SelectList
338
+ // 2. SelectList.handleInput('\r') 触发 onSelect → comp.settle({kind:'auto'})
339
+ comp.handleInput("\r");
340
+ expect(done).toHaveBeenCalledOnce();
341
+ const result = done.mock.calls[0]![0] as SelectionResult | undefined;
342
+ expect(result).toEqual({ kind: "auto" });
343
+ });
344
+
345
+ it("handleInput(down + enter) 链式触发 SelectList 导航 + onSelect", () => {
346
+ const done = vi.fn();
347
+ const models = makeModelsMap({ "co": [makeEntry("co", "m1", 0.1)] });
348
+ const comp = new ProviderModelSelectorComponent("auto", ["co"], models, done);
349
+ // Down 移动到 'co',Enter 选中 → 进 model stage(done 未调)
350
+ comp.handleInput("\x1b[B"); // Down
351
+ comp.handleInput("\r"); // Enter
352
+ expect(done).not.toHaveBeenCalled(); // 进入 model stage
353
+ // model stage Enter 选中 m1
354
+ comp.handleInput("\r");
355
+ expect(done).toHaveBeenCalledWith({ kind: "specific", provider: "co", modelId: "m1" });
356
+ });
357
+ });
358
+
359
+ // ──────────────────────── DEFAULT_SELECT_THEME(G2 修正验证) ────────────────────────
360
+
361
+ describe("MPT7: DEFAULT_SELECT_THEME(G2/WR2 修正)", () => {
362
+ it("selectedPrefix 在文本前加 '▶ '", () => {
363
+ expect(DEFAULT_SELECT_THEME.selectedPrefix("text")).toBe("\u25B6 text");
364
+ });
365
+
366
+ it("selectedPrefix 非 identity(有视觉区分)", () => {
367
+ const result = DEFAULT_SELECT_THEME.selectedPrefix("foo");
368
+ expect(result).not.toBe("foo");
369
+ expect(result.startsWith("\u25B6")).toBe(true);
370
+ });
371
+ });