@zhushanwen/pi-permission 1.4.8 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhushanwen/pi-permission",
3
- "version": "1.4.8",
3
+ "version": "1.5.0",
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.4.1",
53
+ "@zhushanwen/pi-ext-guards": "0.4.2",
54
54
  "@zhushanwen/pi-extension-logger": "0.6.1",
55
- "@zhushanwen/pi-llm-shared": "0.10.0"
55
+ "@zhushanwen/pi-llm-shared": "0.11.0"
56
56
  },
57
57
  "peerDependencies": {
58
58
  "@earendil-works/pi-ai": "^0.84.4",
@@ -12,7 +12,7 @@
12
12
  * 用 PI_CODING_AGENT_DIR 指向临时目录,写入 controlled permission.json,
13
13
  * 让 loadAndWatchConfig 读到指定 mode。mock pi 对象记录 handler 调用。
14
14
  */
15
- import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync } from "node:fs";
15
+ import { existsSync, mkdirSync, rmSync, writeFileSync, readFileSync, statSync } from "node:fs";
16
16
  import { tmpdir } from "node:os";
17
17
  import { join } from "node:path";
18
18
 
@@ -22,6 +22,19 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22
22
  import permissionExtension from "../index.js";
23
23
  import { FOOTER_HANDSHAKE_KEY, REQUEST_RENDER_KEY } from "../footer-provider.js";
24
24
 
25
+ // migrateLegacyConfig 包 spy 计数(透传真实实现,行为零改变):迁移接线用例的
26
+ // 操作级去重断言(双派发 spy = 1)经此观测,文件面断言(mtime/内容不变)同源有效。
27
+ const { spyMigrateLegacyConfig } = vi.hoisted(() => ({
28
+ spyMigrateLegacyConfig: vi.fn(),
29
+ }));
30
+
31
+ vi.mock("@zhushanwen/pi-llm-shared", async (importOriginal) => {
32
+ const actual = await importOriginal<typeof import("@zhushanwen/pi-llm-shared")>();
33
+ spyMigrateLegacyConfig.mockImplementation((agentDir: string, oldRel: string, newRel: string) =>
34
+ actual.migrateLegacyConfig(agentDir, oldRel, newRel));
35
+ return { ...actual, migrateLegacyConfig: spyMigrateLegacyConfig };
36
+ });
37
+
25
38
  // ──────────────────────── mock pi ────────────────────────
26
39
 
27
40
  /** tool_call handler 的最小签名(返回 Promise<block 结果 | undefined>)。 */
@@ -276,24 +289,43 @@ function getPermissionHandler(calls: MockPiCalls): (args: string, ctx: unknown)
276
289
  }
277
290
 
278
291
  describe("W8 /permission rule 命令集成", () => {
279
- it("headless(json)→ notify 降级提示,不改 config", async () => {
292
+ it("headless(json)→ rule 参数走 rule 路径:notify 降级提示,config 字节不变", async () => {
280
293
  writeConfig("yolo");
294
+ const configBefore = readFileSync(CONFIG_PATH, "utf-8");
281
295
  const { pi, calls } = createMockPi();
282
296
  permissionExtension(pi);
283
297
  const handler = getPermissionHandler(calls);
284
- const ctx = makeCtx("json");
298
+ // notify spy 注入 ctx(commands.test.ts 同款 vi.fn 形态):headless 降级提示的可观测面
299
+ const notify = vi.fn();
300
+ const ctx = {
301
+ mode: "json",
302
+ cwd: "/tmp",
303
+ ui: {
304
+ notify,
305
+ select(): Promise<string | undefined> {
306
+ return Promise.resolve(undefined);
307
+ },
308
+ custom(): Promise<unknown> {
309
+ return Promise.resolve(undefined);
310
+ },
311
+ },
312
+ signal: undefined,
313
+ };
285
314
  // 不应抛错
286
- await handler("rule", ctx);
287
- });
288
-
289
- it("permission handler 分流:rule 参数走 rule 路径(headless 降级)", async () => {
290
- writeConfig("yolo");
291
- const { pi, calls } = createMockPi();
292
- permissionExtension(pi);
293
- const handler = getPermissionHandler(calls);
294
- const ctx = makeCtx("json");
295
- // rule 参数应走 headless 降级(notify)
296
- await expect(handler("rule", ctx)).resolves.not.toThrow();
315
+ await expect(handler("rule", ctx)).resolves.toBeUndefined();
316
+
317
+ // 分流断言:warning 文案只产自 rule 编辑器的 headless 分支(editRulesViaOverlay),
318
+ // 同步路径(mode 切换/status)只发 info——命中即证明「rule 参数走了 rule 路径」。
319
+ // 第二次 notify = 编辑器返回 undefined 后的 no-changes 提示。
320
+ expect(notify).toHaveBeenCalledTimes(2);
321
+ expect(notify).toHaveBeenNthCalledWith(
322
+ 1,
323
+ expect.stringContaining("not available in headless mode"),
324
+ "warning",
325
+ );
326
+ expect(notify).toHaveBeenNthCalledWith(2, "[pi-permission] No changes applied.", "info");
327
+ // 降级路径不得改 config(编辑器未打开、save 未触发):字节级不变
328
+ expect(readFileSync(CONFIG_PATH, "utf-8")).toBe(configBefore);
297
329
  });
298
330
  });
299
331
 
@@ -377,6 +409,12 @@ describe("footer line 注册", () => {
377
409
  // 迁移经 oncePerProcess 守卫(u-audit-fix 收编原内联 once flag),守卫的模块级 Map
378
410
  // 跨用例残留:用 vi.resetModules() + 动态 import 取新鲜模块实例,否则前面用例已触发过
379
411
  // session_start,迁移分支永不执行(静默不测)。
412
+ // 末两条为守卫双派发实测(u-audit-fix 探针):
413
+ // a) 操作级去重:双派发下 spy migrateLegacyConfig 调用 = 1(warn 在 migrateLegacyConfig
414
+ // 调用点内部,调用 = 1 构造性蕴含 warn ≤ 1,不另设断言);
415
+ // b) 文件面:第二次派发后重建的旧文件原样保留、新路径 mtime/内容严格不变;
416
+ // c) 豁免项防误伤:footer 注册为纯内存初始化(registry.register 同 id 覆盖),不在
417
+ // 包装范围——双派发仍每次执行(pending push ×2)。
380
418
  describe("session_start 配置路径迁移接线", () => {
381
419
  const LEGACY_PATH = join(AGENT_DIR, "permission-config.json");
382
420
 
@@ -459,4 +497,59 @@ describe("session_start 配置路径迁移接线", () => {
459
497
  expect(() => handler({}, makeCtx("json"))).not.toThrow();
460
498
  expect(existsSync(CONFIG_PATH)).toBe(false); // 迁移不凭空造新配置
461
499
  });
500
+
501
+ // ── oncePerProcess 守卫双派发实测(u-audit-fix 探针)──
502
+ it("双派发下 migrateLegacyConfig 仅执行一次:spy = 1,文件面首次迁移到位、第二次 mtime/内容不变", async () => {
503
+ // spy 是文件级单例(跨 resetModules 保留计数),前面用例已触发过迁移 → 先清零
504
+ spyMigrateLegacyConfig.mockClear();
505
+ // 预置旧路径文件(新路径不存在 → 走 renameSync 迁移分支)
506
+ rmSync(CONFIG_PATH, { force: true });
507
+ writeLegacyConfig("strict");
508
+
509
+ const extension = await freshExtension();
510
+ const { pi, calls } = createMockPi();
511
+ extension(pi);
512
+ const handler = getSessionStartHandler(calls);
513
+
514
+ // 第一次派发:迁移发生(旧文件消失 + 新路径出现,内容来自旧文件)
515
+ handler({}, makeCtx("json"));
516
+ expect(existsSync(LEGACY_PATH)).toBe(false);
517
+ expect(existsSync(CONFIG_PATH)).toBe(true);
518
+ expect(JSON.parse(readFileSync(CONFIG_PATH, "utf-8")).mode).toBe("strict");
519
+ const mtimeAfterFirst = statSync(CONFIG_PATH).mtimeMs;
520
+ const contentAfterFirst = readFileSync(CONFIG_PATH, "utf-8");
521
+
522
+ // 第二次派发(factory 二调/handler 累积形态:同一 handler 引用再调):
523
+ // 重建旧路径文件模拟外部残留——守卫已消费 key,不得再次触碰任何文件。
524
+ writeLegacyConfig("yolo");
525
+ handler({}, makeCtx("json"));
526
+
527
+ // 探针 a:操作级去重(spy 透传真实实现,文件面与计数同源)
528
+ expect(spyMigrateLegacyConfig).toHaveBeenCalledTimes(1);
529
+ // 探针 b:第二次后重建的旧文件原样保留、新路径 mtime/内容严格不变
530
+ expect(existsSync(LEGACY_PATH)).toBe(true);
531
+ expect(statSync(CONFIG_PATH).mtimeMs).toBe(mtimeAfterFirst);
532
+ expect(readFileSync(CONFIG_PATH, "utf-8")).toBe(contentAfterFirst);
533
+ });
534
+
535
+ it("豁免项防误伤:footer 注册不包装,双派发仍每次执行(pending push ×2)", async () => {
536
+ writeLegacyConfig("strict");
537
+
538
+ const extension = await freshExtension();
539
+ const { pi, calls } = createMockPi();
540
+ extension(pi);
541
+ const handler = getSessionStartHandler(calls);
542
+
543
+ handler({}, makeCtx("json"));
544
+ handler({}, makeCtx("json"));
545
+
546
+ // 纯内存初始化(registry.register 同 id 覆盖 / pending push)保持每 session_start
547
+ // 执行——清单「粒度边界」明令不包装,挂进程级 flag 会杀 session 级语义。
548
+ const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY) as {
549
+ pending: Array<{ id: string; renderer: unknown }>;
550
+ } | undefined;
551
+ expect(slot).toBeDefined();
552
+ expect(slot!.pending.length).toBe(2);
553
+ expect(slot!.pending[0]!.id).toBe("pi-permission");
554
+ });
462
555
  });
@@ -282,4 +282,27 @@ describe("CT5: callLLM opts 构造(model/messages/timeoutMs/signal)", () =>
282
282
  const opts = getLastOpts();
283
283
  expect(opts?.signal).toBe(ac.signal);
284
284
  });
285
+
286
+ it("配置 thinkingLevel 不被模型支持 → warn 留痕 + reasoning 不传(不静默降级)", async () => {
287
+ // FIXED_MODEL_OBJ reasoning:false → supportedLevels = ["off"],配置 "high" 判定落空;
288
+ // changeset pi-permission-thinking-level-datadriven 承诺 warn 留痕(同族 rename-session 对齐)
289
+ const onLog = vi.fn();
290
+ const { spy, getLastOpts } = capturingCallLLM(okResult('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}'));
291
+ const classifier = createClassifier(makeDeps({ callLLM: spy, onLog }));
292
+ const r = await classifier.classifyRisk(CTX, { ...CONFIG, thinkingLevel: "high" });
293
+ expect(r.outcome).toBe("allow");
294
+ expect(onLog).toHaveBeenCalledWith(
295
+ expect.stringContaining('configured thinkingLevel "high" is not supported by test-co/test-model; calling without a thinking level'),
296
+ );
297
+ expect(getLastOpts()?.reasoning).toBeUndefined();
298
+ });
299
+
300
+ it("配置 thinkingLevel 被模型支持 → 命中透传,无 warn", async () => {
301
+ const onLog = vi.fn();
302
+ const { spy, getLastOpts } = capturingCallLLM(okResult('{"outcome":"allow","risk_level":"low","reasoning":"x","confidence":0.9}'));
303
+ const classifier = createClassifier(makeDeps({ callLLM: spy, onLog }));
304
+ await classifier.classifyRisk(CTX, { ...CONFIG, thinkingLevel: "off" });
305
+ expect(onLog).not.toHaveBeenCalledWith(expect.stringContaining("not supported"));
306
+ expect(getLastOpts()?.reasoning).toBe("off");
307
+ });
285
308
  });
@@ -17,6 +17,7 @@
17
17
  * - fail-closed:timeout / 抛错 / 解析失败 / 无可用模型 → 一律 ask
18
18
  */
19
19
 
20
+ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
20
21
  import type { Api, Message, Model } from "@earendil-works/pi-ai";
21
22
  import { toErrorMessage } from "@zhushanwen/pi-ext-guards";
22
23
  import type { CallLLMOptions, CallLLMResult } from "@zhushanwen/pi-llm-shared";
@@ -133,15 +134,25 @@ function buildCallOptions(
133
134
  config: ClassifierConfig,
134
135
  timeoutMs: number | undefined,
135
136
  signal: AbortSignal | undefined,
137
+ onLog?: (msg: string) => void,
136
138
  ): CallLLMOptions {
139
+ // 档位按「该模型自己的 supportedLevels」判定(pi-ai 数据驱动,本层不自持词表);
140
+ // 不支持则按「不传档位」处理,不静默换成别的档(clamp),并 warn 留痕
141
+ // (changeset pi-permission-thinking-level-datadriven 的承诺,与 rename-session 同族对齐)。
142
+ // llm-shared 会把 "off" 映射为不传。
143
+ const reasoning = getSupportedThinkingLevels(model).find((l) => l === config.thinkingLevel);
144
+ if (config.thinkingLevel !== undefined && config.thinkingLevel !== "" && reasoning === undefined) {
145
+ onLog?.(
146
+ `[pi-permission] classifier: configured thinkingLevel "${config.thinkingLevel}" is not supported by ${model.provider}/${model.id}; calling without a thinking level`,
147
+ );
148
+ }
137
149
  return {
138
150
  model,
139
151
  systemPrompt: CLASSIFIER_SYSTEM_PROMPT,
140
152
  messages: buildMessages(ctx),
141
153
  ...(timeoutMs !== undefined ? { timeoutMs } : {}),
142
154
  ...(signal !== undefined ? { signal } : {}),
143
- // thinkingLevel 直接透传(含 "off");llm-shared 内部会把 "off" 映射为不传 reasoning(provider 默认)
144
- reasoning: config.thinkingLevel,
155
+ reasoning,
145
156
  };
146
157
  }
147
158
 
@@ -223,7 +234,7 @@ export function createClassifier(deps: ClassifierDeps): {
223
234
 
224
235
  // 2. 构造 CallLLMOptions
225
236
  const timeoutMs = config.timeout > 0 ? config.timeout * MILLIS_PER_SECOND : undefined;
226
- const callPromise = callLLM(buildCallOptions(model, ctx, config, timeoutMs, signal));
237
+ const callPromise = callLLM(buildCallOptions(model, ctx, config, timeoutMs, signal, onLog));
227
238
 
228
239
  // 3. 等待 callLLM + 外层超时/中止兜底(provider 不支持 timeoutMs/signal 时防永挂)
229
240
  let settled: SettledRace;
package/src/config.ts CHANGED
@@ -22,7 +22,7 @@ import { isRecord, toErrorMessage } from "@zhushanwen/pi-ext-guards";
22
22
  import {
23
23
  clearConfigCache,
24
24
  getConfigPath as getLlmConfigPath,
25
- isThinkingLevel,
25
+ normalizeThinkingLevel,
26
26
  loadConfig,
27
27
  saveConfig as saveLlmConfig,
28
28
  } from "@zhushanwen/pi-llm-shared";
@@ -59,10 +59,8 @@ function normalizeClassifierConfig(raw: unknown): ClassifierConfig {
59
59
  if (record.model !== undefined && !(typeof record.model === "string" && record.model.length > 0)) {
60
60
  logger.warn("Ignoring invalid classifier.model (expected string 'auto' or 'provider/model-id'), using default auto");
61
61
  }
62
- // thinkingLevel 校验:合法值为 'off'|'minimal'|'low'|'medium'|'high'|'xhigh'|'max'
63
- const thinkingLevel = isThinkingLevel(record.thinkingLevel)
64
- ? record.thinkingLevel
65
- : DEFAULT_CLASSIFIER_CONFIG.thinkingLevel;
62
+ // thinkingLevel:非空字符串即采信(合法档位由写入侧 UI 与 pi 判定;本层不再自持词表)
63
+ const thinkingLevel = normalizeThinkingLevel(record.thinkingLevel) ?? DEFAULT_CLASSIFIER_CONFIG.thinkingLevel;
66
64
  return {
67
65
  enabled: record.enabled !== false,
68
66
  model: typeof record.model === "string" && record.model.length > 0 ? record.model : DEFAULT_CLASSIFIER_CONFIG.model,
package/src/types.ts CHANGED
@@ -5,7 +5,6 @@
5
5
  * 对应 slice plan 的 DM1-DM5 数据模型。
6
6
  */
7
7
 
8
- import type { ModelThinkingLevel } from "@earendil-works/pi-ai";
9
8
 
10
9
  // ──────────────────────── DM1: PermissionMode ────────────────────────
11
10
 
@@ -108,11 +107,11 @@ export interface ClassifierConfig {
108
107
  /** 高风险是否强制 deny(不问人:high+allow 时直接拒绝) */
109
108
  autoDenyHighRisk: boolean;
110
109
  /**
111
- * 标题生成 LLM 的 thinking 级别(pi 的 ModelThinkingLevel,THINKING_ORDER SSOT)。
110
+ * 标题生成 LLM 的 thinking 级别(字符串透传;合法档位按模型 supportedLevels 在调用点判定)。
112
111
  * 默认 "off":不传 pi-ai reasoning(provider 默认行为);
113
112
  * "minimal"~"max" 透传给 SimpleStreamOptions.reasoning(provider 不支持时静默忽略)。
114
113
  */
115
- thinkingLevel: ModelThinkingLevel;
114
+ thinkingLevel: string;
116
115
  }
117
116
 
118
117
  // ──────────────────────── DM5: PermissionConfig ────────────────────────
@@ -1,146 +0,0 @@
1
- /**
2
- * session-start-once-guard.test.ts — u-audit-fix 探针实测(permission 侧)。
3
- *
4
- * 判定「必须接入」:migrateLegacyConfig 写 agentDir 全局配置文件,属跨 session 副作用,
5
- * 已由 oncePerProcess 守卫收编原内联 once flag。本文件按清单探针验证点双派发实测:
6
- * a) 双派发(同一 factory 闭包直接调 handler 两次)下 spy migrateLegacyConfig 调用 = 1;
7
- * b) 文件面:tmp agentDir 预置旧路径文件 → 第一次 handler 后旧文件消失 + 新路径出现,
8
- * 第二次 handler 后新路径 mtime/内容不变(守卫拦截 = 不触碰);
9
- * c) migrated warn 仅一条:构造性蕴含于 a——warn 在 migrateLegacyConfig 调用点内部
10
- * (llm-shared migrate.ts "migrated" 分支),调用 = 1 ⟹ warn ≤ 1,不另设断言。
11
- * 反向断言(豁免项防误伤):footer 注册为纯内存初始化(registry.register 同 id 覆盖),
12
- * 不在包装范围——双派发仍每次执行(pending push ×2)。
13
- */
14
- import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
15
- import { tmpdir } from "node:os";
16
- import { join } from "node:path";
17
-
18
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
19
- import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
20
-
21
- import { FOOTER_HANDSHAKE_KEY } from "../footer-provider.js";
22
-
23
- // ── mock:migrateLegacyConfig 包 spy 计数(透传真实实现,文件面断言保留) ──
24
-
25
- const { spyMigrateLegacyConfig } = vi.hoisted(() => ({
26
- spyMigrateLegacyConfig: vi.fn(),
27
- }));
28
-
29
- vi.mock("@zhushanwen/pi-llm-shared", async (importOriginal) => {
30
- const actual = await importOriginal<typeof import("@zhushanwen/pi-llm-shared")>();
31
- spyMigrateLegacyConfig.mockImplementation((agentDir: string, oldRel: string, newRel: string) =>
32
- actual.migrateLegacyConfig(agentDir, oldRel, newRel));
33
- return { ...actual, migrateLegacyConfig: spyMigrateLegacyConfig };
34
- });
35
-
36
- // ── mock pi(对齐 index-integration.test.ts 自包含 helper 形态) ──
37
-
38
- function createMockPi(): { pi: Pick<ExtensionAPI, "on" | "registerCommand">; calls: { eventHandlers: Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>> } } {
39
- const eventHandlers = new Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>>();
40
- const pi = {
41
- registerCommand(): void {
42
- /* noop */
43
- },
44
- on(event: string, handler: (event?: unknown, ctx?: unknown) => unknown): void {
45
- if (!eventHandlers.has(event)) eventHandlers.set(event, []);
46
- eventHandlers.get(event)!.push(handler);
47
- },
48
- };
49
- return { pi: pi as Pick<ExtensionAPI, "on" | "registerCommand">, calls: { eventHandlers } };
50
- }
51
-
52
- function getSessionStartHandler(calls: { eventHandlers: Map<string, Array<(event?: unknown, ctx?: unknown) => unknown>> }): (event: unknown, ctx: unknown) => unknown {
53
- const handlers = calls.eventHandlers.get("session_start");
54
- if (!handlers || handlers.length === 0) {
55
- throw new Error("session_start handler not registered");
56
- }
57
- return handlers[0]!;
58
- }
59
-
60
- // ── tmp agentDir(getAgentDir 经 PI_CODING_AGENT_DIR 推导,对齐 index-integration.test.ts) ──
61
-
62
- const TMP_ROOT = join(tmpdir(), "pi-perm-once-guard-" + process.pid);
63
- const AGENT_DIR = join(TMP_ROOT, "agent");
64
- const LEGACY_PATH = join(AGENT_DIR, "permission-config.json");
65
- const CONFIG_PATH = join(AGENT_DIR, "config", "permission-ext-config.json");
66
-
67
- function writeLegacyConfig(mode: string): void {
68
- mkdirSync(AGENT_DIR, { recursive: true });
69
- writeFileSync(
70
- LEGACY_PATH,
71
- JSON.stringify({ mode, enabled: true, classifier: { enabled: true }, userRules: [] }, null, 2) + "\n",
72
- { mode: 0o600 },
73
- );
74
- }
75
-
76
- /** oncePerProcess 的 Map 是模块级状态:每个用例 resetModules 取新鲜模块实例(对齐迁移接线测试先例)。 */
77
- async function freshExtension(): Promise<(pi: Pick<ExtensionAPI, "on" | "registerCommand">) => void> {
78
- vi.resetModules();
79
- const mod = await import("../index.js");
80
- return mod.default as (pi: Pick<ExtensionAPI, "on" | "registerCommand">) => void;
81
- }
82
-
83
- beforeEach(() => {
84
- Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
85
- process.env.PI_CODING_AGENT_DIR = AGENT_DIR;
86
- mkdirSync(AGENT_DIR, { recursive: true });
87
- });
88
-
89
- afterEach(() => {
90
- delete process.env.PI_CODING_AGENT_DIR;
91
- Reflect.deleteProperty(globalThis, FOOTER_HANDSHAKE_KEY);
92
- if (existsSync(TMP_ROOT)) rmSync(TMP_ROOT, { recursive: true, force: true, maxRetries: 5, retryDelay: 20 });
93
- });
94
-
95
- describe("session_start 双派发幂等守卫(oncePerProcess,u-audit-fix)", () => {
96
- it("双派发下 migrateLegacyConfig 仅执行一次:spy = 1,文件面首次迁移到位、第二次 mtime/内容不变", async () => {
97
- // 预置旧路径文件(新路径不存在 → 走 renameSync 迁移分支)
98
- writeLegacyConfig("strict");
99
-
100
- const extension = await freshExtension();
101
- const { pi, calls } = createMockPi();
102
- extension(pi);
103
- const handler = getSessionStartHandler(calls);
104
-
105
- // 第一次派发:迁移发生(旧文件消失 + 新路径出现,内容来自旧文件)
106
- handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
107
- expect(existsSync(LEGACY_PATH)).toBe(false);
108
- expect(existsSync(CONFIG_PATH)).toBe(true);
109
- expect(JSON.parse(readFileSync(CONFIG_PATH, "utf-8")).mode).toBe("strict");
110
- const mtimeAfterFirst = statSync(CONFIG_PATH).mtimeMs;
111
- const contentAfterFirst = readFileSync(CONFIG_PATH, "utf-8");
112
-
113
- // 第二次派发(factory 二调/handler 累积形态:同一 handler 引用再调):
114
- // 重建旧路径文件模拟外部残留——守卫已消费 key,不得再次触碰任何文件。
115
- writeLegacyConfig("yolo");
116
- handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
117
-
118
- // 探针 a:操作级去重(spy 透传真实实现,文件面与计数同源)
119
- expect(spyMigrateLegacyConfig).toHaveBeenCalledTimes(1);
120
- // 探针 b:第二次后重建的旧文件原样保留、新路径 mtime/内容严格不变
121
- expect(existsSync(LEGACY_PATH)).toBe(true);
122
- expect(statSync(CONFIG_PATH).mtimeMs).toBe(mtimeAfterFirst);
123
- expect(readFileSync(CONFIG_PATH, "utf-8")).toBe(contentAfterFirst);
124
- });
125
-
126
- it("豁免项防误伤:footer 注册不包装,双派发仍每次执行(pending push ×2)", async () => {
127
- writeLegacyConfig("strict");
128
-
129
- const extension = await freshExtension();
130
- const { pi, calls } = createMockPi();
131
- extension(pi);
132
- const handler = getSessionStartHandler(calls);
133
-
134
- handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
135
- handler({}, { mode: "json", cwd: "/tmp", ui: {}, signal: undefined });
136
-
137
- // 纯内存初始化(registry.register 同 id 覆盖 / pending push)保持每 session_start
138
- // 执行——清单「粒度边界」明令不包装,挂进程级 flag 会杀 session 级语义。
139
- const slot = Reflect.get(globalThis, FOOTER_HANDSHAKE_KEY) as {
140
- pending: Array<{ id: string; renderer: unknown }>;
141
- } | undefined;
142
- expect(slot).toBeDefined();
143
- expect(slot!.pending.length).toBe(2);
144
- expect(slot!.pending[0]!.id).toBe("pi-permission");
145
- });
146
- });