@gotgenes/pi-permission-system 10.7.2 → 10.9.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/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [10.9.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.8.0...pi-permission-system-v10.9.0) (2026-06-10)
9
+
10
+
11
+ ### Features
12
+
13
+ * add ToolInputFormatterRegistrar write-side interface ([#366](https://github.com/gotgenes/pi-packages/issues/366)) ([e000eb0](https://github.com/gotgenes/pi-packages/commit/e000eb02a507c06e241b5a35cac5334e06dca1e2))
14
+
15
+ ## [10.8.0](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.2...pi-permission-system-v10.8.0) (2026-06-10)
16
+
17
+
18
+ ### Features
19
+
20
+ * add CacheKeyGate for agent-start cache keys ([#365](https://github.com/gotgenes/pi-packages/issues/365)) ([e99285c](https://github.com/gotgenes/pi-packages/commit/e99285c50fef3f6fd8ea7dac00080eeb9957adaa))
21
+
22
+
23
+ ### Documentation
24
+
25
+ * mark Phase 5 Step 4 complete ([#365](https://github.com/gotgenes/pi-packages/issues/365)) ([4bd0e30](https://github.com/gotgenes/pi-packages/commit/4bd0e30fb4cb03d6cff76242f75955e9698c7d0d))
26
+
8
27
  ## [10.7.2](https://github.com/gotgenes/pi-packages/compare/pi-permission-system-v10.7.1...pi-permission-system-v10.7.2) (2026-06-10)
9
28
 
10
29
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gotgenes/pi-permission-system",
3
- "version": "10.7.2",
3
+ "version": "10.9.0",
4
4
  "description": "Permission enforcement extension for the Pi coding agent.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -35,10 +35,3 @@ export function createBeforeAgentStartPromptStateKey(
35
35
  normalizePrompt(input.systemPrompt),
36
36
  ]);
37
37
  }
38
-
39
- export function shouldApplyCachedAgentStartState(
40
- previousKey: string | null,
41
- nextKey: string,
42
- ): boolean {
43
- return previousKey !== nextKey;
44
- }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Owns a previous cache key and conditionally runs an effect when the key changes.
3
+ *
4
+ * Encapsulates the prev !== next comparison that previously lived in three places:
5
+ * the session's inline `!==`, the handler's ask-then-tell orchestration, and the
6
+ * (test-only-alive) `shouldApplyCachedAgentStartState` free function.
7
+ *
8
+ * Semantics:
9
+ * - On a changed key: runs `effect`, commits `nextKey`, returns the effect's value.
10
+ * - On an unchanged key: skips `effect`, returns `undefined`.
11
+ * - `reset()` re-arms the gate (used by session lifecycle: `resetForNewSession`,
12
+ * `shutdown`, `reload`).
13
+ *
14
+ * Commit ordering is run-then-commit: the key is saved only after `effect` returns.
15
+ * If `effect` throws, the key stays uncommitted and the next call retries.
16
+ */
17
+ export class CacheKeyGate {
18
+ private previousKey: string | null = null;
19
+
20
+ runIfChanged<T>(nextKey: string, effect: () => T): T | undefined {
21
+ if (this.previousKey === nextKey) {
22
+ return undefined;
23
+ }
24
+ const result = effect();
25
+ this.previousKey = nextKey;
26
+ return result;
27
+ }
28
+
29
+ reset(): void {
30
+ this.previousKey = null;
31
+ }
32
+ }
@@ -74,10 +74,9 @@ export class AgentPrepHandler {
74
74
  }
75
75
 
76
76
  const activeToolsCacheKey = createActiveToolsCacheKey(allowedTools);
77
- if (this.session.shouldUpdateActiveTools(activeToolsCacheKey)) {
77
+ this.session.activeToolsGate.runIfChanged(activeToolsCacheKey, () => {
78
78
  this.toolRegistry.setActive(allowedTools);
79
- this.session.commitActiveToolsCacheKey(activeToolsCacheKey);
80
- }
79
+ });
81
80
 
82
81
  const promptStateCacheKey = createBeforeAgentStartPromptStateKey({
83
82
  agentName,
@@ -89,28 +88,25 @@ export class AgentPrepHandler {
89
88
  allowedToolNames: allowedTools,
90
89
  });
91
90
 
92
- if (!this.session.shouldUpdatePromptState(promptStateCacheKey)) {
93
- return {};
94
- }
95
-
96
- this.session.commitPromptStateCacheKey(promptStateCacheKey);
97
-
98
- const toolPromptResult = sanitizeAvailableToolsSection(
99
- event.systemPrompt,
100
- allowedTools,
91
+ const promptResult = this.session.promptStateGate.runIfChanged(
92
+ promptStateCacheKey,
93
+ () => {
94
+ const toolPromptResult = sanitizeAvailableToolsSection(
95
+ event.systemPrompt,
96
+ allowedTools,
97
+ );
98
+ const skillPromptResult = resolveSkillPromptEntries(
99
+ toolPromptResult.prompt,
100
+ this.resolver,
101
+ agentName,
102
+ ctx.cwd,
103
+ );
104
+ this.session.setActiveSkillEntries(skillPromptResult.entries);
105
+ return skillPromptResult.prompt !== event.systemPrompt
106
+ ? { systemPrompt: skillPromptResult.prompt }
107
+ : {};
108
+ },
101
109
  );
102
- const skillPromptResult = resolveSkillPromptEntries(
103
- toolPromptResult.prompt,
104
- this.resolver,
105
- agentName,
106
- ctx.cwd,
107
- );
108
- this.session.setActiveSkillEntries(skillPromptResult.entries);
109
-
110
- if (skillPromptResult.prompt !== event.systemPrompt) {
111
- return { systemPrompt: skillPromptResult.prompt };
112
- }
113
-
114
- return {};
110
+ return promptResult ?? {};
115
111
  }
116
112
  }
@@ -1,5 +1,5 @@
1
1
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
-
2
+ import { CacheKeyGate } from "#src/cache-key-gate";
3
3
  import {
4
4
  getActiveAgentName,
5
5
  getActiveAgentNameFromSystemPrompt,
@@ -38,8 +38,8 @@ export class PermissionSession implements ToolCallGateInputs {
38
38
  private context: ExtensionContext | null = null;
39
39
  private skillEntries: SkillPromptEntry[] = [];
40
40
  private knownAgentName: string | null = null;
41
- private toolsCacheKey: string | null = null;
42
- private promptCacheKey: string | null = null;
41
+ readonly activeToolsGate = new CacheKeyGate();
42
+ readonly promptStateGate = new CacheKeyGate();
43
43
 
44
44
  constructor(
45
45
  private readonly paths: ExtensionPaths,
@@ -89,8 +89,8 @@ export class PermissionSession implements ToolCallGateInputs {
89
89
  resetForNewSession(ctx: ExtensionContext): void {
90
90
  this.permissionManager.configureForCwd(ctx.cwd);
91
91
  this.skillEntries = [];
92
- this.toolsCacheKey = null;
93
- this.promptCacheKey = null;
92
+ this.activeToolsGate.reset();
93
+ this.promptStateGate.reset();
94
94
  this.activate(ctx);
95
95
  }
96
96
 
@@ -101,8 +101,8 @@ export class PermissionSession implements ToolCallGateInputs {
101
101
  shutdown(): void {
102
102
  this.sessionRules.clear();
103
103
  this.skillEntries = [];
104
- this.toolsCacheKey = null;
105
- this.promptCacheKey = null;
104
+ this.activeToolsGate.reset();
105
+ this.promptStateGate.reset();
106
106
  this.deactivate();
107
107
  }
108
108
 
@@ -113,26 +113,8 @@ export class PermissionSession implements ToolCallGateInputs {
113
113
  reload(): void {
114
114
  this.permissionManager.configureForCwd(this.context?.cwd);
115
115
  this.skillEntries = [];
116
- this.toolsCacheKey = null;
117
- this.promptCacheKey = null;
118
- }
119
-
120
- // ── Agent-start caching ────────────────────────────────────────────────
121
-
122
- shouldUpdateActiveTools(cacheKey: string): boolean {
123
- return this.toolsCacheKey !== cacheKey;
124
- }
125
-
126
- commitActiveToolsCacheKey(cacheKey: string): void {
127
- this.toolsCacheKey = cacheKey;
128
- }
129
-
130
- shouldUpdatePromptState(cacheKey: string): boolean {
131
- return this.promptCacheKey !== cacheKey;
132
- }
133
-
134
- commitPromptStateCacheKey(cacheKey: string): void {
135
- this.promptCacheKey = cacheKey;
116
+ this.activeToolsGate.reset();
117
+ this.promptStateGate.reset();
136
118
  }
137
119
 
138
120
  // ── Skill entries ──────────────────────────────────────────────────────
@@ -1,10 +1,10 @@
1
1
  import { buildInputForSurface } from "./input-normalizer";
2
- import type { PermissionManager } from "./permission-manager";
2
+ import type { ScopedPermissionManager } from "./permission-manager";
3
3
  import type { PermissionsService } from "./service";
4
4
  import type { SessionRules } from "./session-rules";
5
5
  import type {
6
6
  ToolInputFormatter,
7
- ToolInputFormatterRegistry,
7
+ ToolInputFormatterRegistrar,
8
8
  } from "./tool-input-formatter-registry";
9
9
 
10
10
  /**
@@ -16,9 +16,9 @@ import type {
16
16
  */
17
17
  export class LocalPermissionsService implements PermissionsService {
18
18
  constructor(
19
- private readonly permissionManager: PermissionManager,
20
- private readonly sessionRules: SessionRules,
21
- private readonly formatterRegistry: ToolInputFormatterRegistry,
19
+ private readonly permissionManager: ScopedPermissionManager,
20
+ private readonly sessionRules: Pick<SessionRules, "getRuleset">,
21
+ private readonly formatterRegistry: ToolInputFormatterRegistrar,
22
22
  ) {}
23
23
 
24
24
  checkPermission(
@@ -19,6 +19,14 @@ export interface ToolInputFormatterLookup {
19
19
  get(toolName: string): ToolInputFormatter | undefined;
20
20
  }
21
21
 
22
+ /**
23
+ * Registration side of the formatter registry (ISP — exposes only the
24
+ * write surface, mirroring the read-only {@link ToolInputFormatterLookup}).
25
+ */
26
+ export interface ToolInputFormatterRegistrar {
27
+ register(toolName: string, formatter: ToolInputFormatter): () => void;
28
+ }
29
+
22
30
  /**
23
31
  * Persistent registry mapping tool names to custom preview formatters.
24
32
  *
@@ -26,7 +34,9 @@ export interface ToolInputFormatterLookup {
26
34
  * per-tool-call `ToolPreviewFormatter` construction cycle.
27
35
  * Exposed to sibling extensions via `PermissionsService.registerToolInputFormatter`.
28
36
  */
29
- export class ToolInputFormatterRegistry implements ToolInputFormatterLookup {
37
+ export class ToolInputFormatterRegistry
38
+ implements ToolInputFormatterLookup, ToolInputFormatterRegistrar
39
+ {
30
40
  private readonly formatters = new Map<string, ToolInputFormatter>();
31
41
 
32
42
  /**
@@ -1,33 +1,8 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { expect, test } from "vitest";
3
- import {
4
- createActiveToolsCacheKey,
5
- createBeforeAgentStartPromptStateKey,
6
- shouldApplyCachedAgentStartState,
7
- } from "#src/before-agent-start-cache";
3
+ import { createBeforeAgentStartPromptStateKey } from "#src/before-agent-start-cache";
8
4
  import { createManager } from "#test/helpers/manager-harness";
9
5
 
10
- test("Before-agent-start cache dedupes unchanged active-tool exposure and prompt state", () => {
11
- const allowedTools = ["read", "mcp"];
12
- const activeToolsKey = createActiveToolsCacheKey(allowedTools);
13
- const promptStateKey = createBeforeAgentStartPromptStateKey({
14
- agentName: "code",
15
- cwd: "C:/workspace/project",
16
- permissionStamp: "permissions-v1",
17
- systemPrompt: "Available tools:\n- read\n- mcp",
18
- allowedToolNames: allowedTools,
19
- });
20
-
21
- expect(shouldApplyCachedAgentStartState(null, activeToolsKey)).toBe(true);
22
- expect(shouldApplyCachedAgentStartState(activeToolsKey, activeToolsKey)).toBe(
23
- false,
24
- );
25
- expect(shouldApplyCachedAgentStartState(null, promptStateKey)).toBe(true);
26
- expect(shouldApplyCachedAgentStartState(promptStateKey, promptStateKey)).toBe(
27
- false,
28
- );
29
- });
30
-
31
6
  test("Before-agent-start prompt cache invalidates on permission changes while runtime enforcement stays authoritative", () => {
32
7
  const { manager, globalConfigPath, cleanup } = createManager({
33
8
  permission: { "*": "allow", write: "deny" },
@@ -43,9 +18,6 @@ test("Before-agent-start prompt cache invalidates on permission changes while ru
43
18
  allowedToolNames: ["read"],
44
19
  });
45
20
 
46
- expect(shouldApplyCachedAgentStartState(baselineKey, baselineKey)).toBe(
47
- false,
48
- );
49
21
  expect(manager.checkPermission("write", {}, undefined).state).toBe("deny");
50
22
 
51
23
  const updatedConfig = `${JSON.stringify(
@@ -79,9 +51,7 @@ test("Before-agent-start prompt cache invalidates on permission changes while ru
79
51
  allowedToolNames: ["read", "write"],
80
52
  });
81
53
 
82
- expect(shouldApplyCachedAgentStartState(baselineKey, invalidatedKey)).toBe(
83
- true,
84
- );
54
+ expect(invalidatedKey).not.toBe(baselineKey);
85
55
  expect(manager.checkPermission("write", {}, undefined).state).toBe("allow");
86
56
  } finally {
87
57
  cleanup();
@@ -0,0 +1,85 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+
3
+ import { CacheKeyGate } from "#src/cache-key-gate";
4
+
5
+ describe("CacheKeyGate", () => {
6
+ describe("runIfChanged", () => {
7
+ it("runs the effect and returns its value when the key is new (null previous)", () => {
8
+ const gate = new CacheKeyGate();
9
+ const effect = vi.fn(() => "result");
10
+
11
+ const result = gate.runIfChanged("key-a", effect);
12
+
13
+ expect(effect).toHaveBeenCalledOnce();
14
+ expect(result).toBe("result");
15
+ });
16
+
17
+ it("commits the key so a second call with the same key skips the effect", () => {
18
+ const gate = new CacheKeyGate();
19
+ const effect = vi.fn(() => "result");
20
+
21
+ gate.runIfChanged("key-a", effect);
22
+ const result = gate.runIfChanged("key-a", effect);
23
+
24
+ expect(effect).toHaveBeenCalledOnce();
25
+ expect(result).toBeUndefined();
26
+ });
27
+
28
+ it("runs the effect when the key changes", () => {
29
+ const gate = new CacheKeyGate();
30
+ const effect = vi.fn((n: number) => n);
31
+
32
+ gate.runIfChanged("key-a", () => effect(1));
33
+ const result = gate.runIfChanged("key-b", () => effect(2));
34
+
35
+ expect(effect).toHaveBeenCalledTimes(2);
36
+ expect(result).toBe(2);
37
+ });
38
+
39
+ it("returns undefined when the key is unchanged", () => {
40
+ const gate = new CacheKeyGate();
41
+ gate.runIfChanged("key-a", vi.fn());
42
+
43
+ const result = gate.runIfChanged("key-a", vi.fn());
44
+
45
+ expect(result).toBeUndefined();
46
+ });
47
+
48
+ it("does not commit the key if the effect throws", () => {
49
+ const gate = new CacheKeyGate();
50
+ const throwing = vi.fn(() => {
51
+ throw new Error("oops");
52
+ });
53
+ const fallback = vi.fn(() => "ok");
54
+
55
+ expect(() => gate.runIfChanged("key-a", throwing)).toThrow("oops");
56
+
57
+ // Same key should run again since the first call threw
58
+ gate.runIfChanged("key-a", fallback);
59
+ expect(fallback).toHaveBeenCalledOnce();
60
+ });
61
+ });
62
+
63
+ describe("reset", () => {
64
+ it("re-arms the gate so the same key runs again on the next call", () => {
65
+ const gate = new CacheKeyGate();
66
+ const effect = vi.fn(() => "ok");
67
+
68
+ gate.runIfChanged("key-a", effect);
69
+ gate.reset();
70
+ gate.runIfChanged("key-a", effect);
71
+
72
+ expect(effect).toHaveBeenCalledTimes(2);
73
+ });
74
+
75
+ it("is idempotent when called on a fresh gate", () => {
76
+ const gate = new CacheKeyGate();
77
+ gate.reset();
78
+ const effect = vi.fn(() => "ok");
79
+
80
+ gate.runIfChanged("key-a", effect);
81
+
82
+ expect(effect).toHaveBeenCalledOnce();
83
+ });
84
+ });
85
+ });
@@ -143,42 +143,24 @@ describe("AgentPrepHandler.handle", () => {
143
143
  expect(toolRegistry.setActive).toHaveBeenCalledWith(["read", "write"]);
144
144
  });
145
145
 
146
- it("commits active-tools cache key after applying", async () => {
147
- const { handler, session } = makeSetup({
146
+ it("calls setActive once across repeated calls with the same allowed tools", async () => {
147
+ const { handler, toolRegistry } = makeSetup({
148
148
  toolRegistry: {
149
149
  getAll: vi.fn().mockReturnValue([{ name: "read" }]),
150
150
  },
151
151
  });
152
- const spy = vi.spyOn(session, "commitActiveToolsCacheKey");
153
152
  await handler.handle(makeEvent(), makeCtx());
154
- expect(spy).toHaveBeenCalled();
155
- });
156
-
157
- it("skips setActive when cache key is unchanged", async () => {
158
- const { handler, session, toolRegistry } = makeSetup({
159
- toolRegistry: {
160
- getAll: vi.fn().mockReturnValue([{ name: "read" }]),
161
- },
162
- });
163
- vi.spyOn(session, "shouldUpdateActiveTools").mockReturnValue(false);
164
153
  await handler.handle(makeEvent(), makeCtx());
165
- expect(toolRegistry.setActive).not.toHaveBeenCalled();
154
+ expect(toolRegistry.setActive).toHaveBeenCalledOnce();
166
155
  });
167
156
 
168
- it("returns empty object when prompt cache is unchanged", async () => {
169
- const { handler, session } = makeSetup();
170
- vi.spyOn(session, "shouldUpdatePromptState").mockReturnValue(false);
157
+ it("returns empty object on repeated calls with unchanged inputs", async () => {
158
+ const { handler } = makeSetup();
159
+ await handler.handle(makeEvent(), makeCtx());
171
160
  const result = await handler.handle(makeEvent(), makeCtx());
172
161
  expect(result).toEqual({});
173
162
  });
174
163
 
175
- it("commits prompt-state cache key and processes prompt when cache is new", async () => {
176
- const { handler, session } = makeSetup();
177
- const spy = vi.spyOn(session, "commitPromptStateCacheKey");
178
- await handler.handle(makeEvent(), makeCtx());
179
- expect(spy).toHaveBeenCalled();
180
- });
181
-
182
164
  it("stores resolved skill entries on the session", async () => {
183
165
  const { handler, session } = makeSetup();
184
166
  const spy = vi.spyOn(session, "setActiveSkillEntries");
@@ -105,16 +105,19 @@ describe("PermissionSession", () => {
105
105
 
106
106
  it("clears cache keys", () => {
107
107
  const { session } = createSession();
108
- session.commitActiveToolsCacheKey("key-1");
109
- session.commitPromptStateCacheKey("key-2");
110
- expect(session.shouldUpdateActiveTools("key-1")).toBe(false);
111
- expect(session.shouldUpdatePromptState("key-2")).toBe(false);
108
+ // Prime both gates with a key
109
+ session.activeToolsGate.runIfChanged("key-1", () => {});
110
+ session.promptStateGate.runIfChanged("key-2", () => {});
112
111
 
113
112
  session.resetForNewSession(makeCtx());
114
113
 
115
- // After reset, same keys should be treated as new
116
- expect(session.shouldUpdateActiveTools("key-1")).toBe(true);
117
- expect(session.shouldUpdatePromptState("key-2")).toBe(true);
114
+ // After reset, the same keys should run the effect again
115
+ const toolsEffect = vi.fn();
116
+ const promptEffect = vi.fn();
117
+ session.activeToolsGate.runIfChanged("key-1", toolsEffect);
118
+ session.promptStateGate.runIfChanged("key-2", promptEffect);
119
+ expect(toolsEffect).toHaveBeenCalledOnce();
120
+ expect(promptEffect).toHaveBeenCalledOnce();
118
121
  });
119
122
 
120
123
  it("clears skill entries", () => {
@@ -162,13 +165,19 @@ describe("PermissionSession", () => {
162
165
 
163
166
  it("clears cache keys", () => {
164
167
  const { session } = createSession();
165
- session.commitActiveToolsCacheKey("k1");
166
- session.commitPromptStateCacheKey("k2");
168
+ // Prime both gates with a key
169
+ session.activeToolsGate.runIfChanged("k1", () => {});
170
+ session.promptStateGate.runIfChanged("k2", () => {});
167
171
 
168
172
  session.shutdown();
169
173
 
170
- expect(session.shouldUpdateActiveTools("k1")).toBe(true);
171
- expect(session.shouldUpdatePromptState("k2")).toBe(true);
174
+ // After shutdown, the same keys should run the effect again
175
+ const toolsEffect = vi.fn();
176
+ const promptEffect = vi.fn();
177
+ session.activeToolsGate.runIfChanged("k1", toolsEffect);
178
+ session.promptStateGate.runIfChanged("k2", promptEffect);
179
+ expect(toolsEffect).toHaveBeenCalledOnce();
180
+ expect(promptEffect).toHaveBeenCalledOnce();
172
181
  });
173
182
 
174
183
  it("clears skill entries", () => {
@@ -190,36 +199,6 @@ describe("PermissionSession", () => {
190
199
  });
191
200
  });
192
201
 
193
- describe("cache key methods", () => {
194
- it("shouldUpdateActiveTools returns true for new key", () => {
195
- const { session } = createSession();
196
- expect(session.shouldUpdateActiveTools("key-1")).toBe(true);
197
- });
198
-
199
- it("shouldUpdateActiveTools returns false for committed key", () => {
200
- const { session } = createSession();
201
- session.commitActiveToolsCacheKey("key-1");
202
- expect(session.shouldUpdateActiveTools("key-1")).toBe(false);
203
- });
204
-
205
- it("shouldUpdateActiveTools returns true for different key", () => {
206
- const { session } = createSession();
207
- session.commitActiveToolsCacheKey("key-1");
208
- expect(session.shouldUpdateActiveTools("key-2")).toBe(true);
209
- });
210
-
211
- it("shouldUpdatePromptState returns true for new key", () => {
212
- const { session } = createSession();
213
- expect(session.shouldUpdatePromptState("key-1")).toBe(true);
214
- });
215
-
216
- it("shouldUpdatePromptState returns false for committed key", () => {
217
- const { session } = createSession();
218
- session.commitPromptStateCacheKey("key-1");
219
- expect(session.shouldUpdatePromptState("key-1")).toBe(false);
220
- });
221
- });
222
-
223
202
  describe("skill entries", () => {
224
203
  it("get/set skill entries", () => {
225
204
  const { session } = createSession();
@@ -356,14 +335,20 @@ describe("PermissionSession", () => {
356
335
 
357
336
  it("clears caches and skill entries", () => {
358
337
  const { session } = createSession();
359
- session.commitActiveToolsCacheKey("k1");
360
- session.commitPromptStateCacheKey("k2");
338
+ // Prime both gates with a key
339
+ session.activeToolsGate.runIfChanged("k1", () => {});
340
+ session.promptStateGate.runIfChanged("k2", () => {});
361
341
  session.setActiveSkillEntries([makeSkillEntry("s")]);
362
342
 
363
343
  session.reload();
364
344
 
365
- expect(session.shouldUpdateActiveTools("k1")).toBe(true);
366
- expect(session.shouldUpdatePromptState("k2")).toBe(true);
345
+ // After reload, the same keys should run the effect again
346
+ const toolsEffect = vi.fn();
347
+ const promptEffect = vi.fn();
348
+ session.activeToolsGate.runIfChanged("k1", toolsEffect);
349
+ session.promptStateGate.runIfChanged("k2", promptEffect);
350
+ expect(toolsEffect).toHaveBeenCalledOnce();
351
+ expect(promptEffect).toHaveBeenCalledOnce();
367
352
  expect(session.getActiveSkillEntries()).toEqual([]);
368
353
  });
369
354
  });
@@ -1,14 +1,15 @@
1
1
  import { beforeEach, describe, expect, it, vi } from "vitest";
2
- import type { PermissionManager } from "#src/permission-manager";
2
+ import type { ScopedPermissionManager } from "#src/permission-manager";
3
3
  import { LocalPermissionsService } from "#src/permissions-service";
4
4
  import type { Ruleset } from "#src/rule";
5
5
  import type { SessionRules } from "#src/session-rules";
6
6
  import type {
7
7
  ToolInputFormatter,
8
- ToolInputFormatterRegistry,
8
+ ToolInputFormatterRegistrar,
9
9
  } from "#src/tool-input-formatter-registry";
10
10
 
11
11
  import { makeCheckResult } from "#test/helpers/handler-fixtures";
12
+ import { makeFakePermissionManager } from "#test/helpers/session-fixtures";
12
13
 
13
14
  // ── input-normalizer stub ──────────────────────────────────────────────────
14
15
 
@@ -22,38 +23,29 @@ vi.mock("#src/input-normalizer", () => ({
22
23
 
23
24
  // ── helpers ────────────────────────────────────────────────────────────────
24
25
 
25
- function makePermissionManager(): PermissionManager {
26
- return {
27
- checkPermission: vi
28
- .fn<PermissionManager["checkPermission"]>()
29
- .mockReturnValue(makeCheckResult()),
30
- getToolPermission: vi
31
- .fn<PermissionManager["getToolPermission"]>()
32
- .mockReturnValue("allow"),
33
- } as unknown as PermissionManager;
34
- }
35
-
36
- function makeSessionRules(rules: Ruleset = []): SessionRules {
26
+ function makeSessionRules(
27
+ rules: Ruleset = [],
28
+ ): Pick<SessionRules, "getRuleset"> {
37
29
  return {
38
30
  getRuleset: vi.fn<SessionRules["getRuleset"]>().mockReturnValue(rules),
39
- } as unknown as SessionRules;
31
+ };
40
32
  }
41
33
 
42
- function makeFormatterRegistry(): ToolInputFormatterRegistry {
34
+ function makeFormatterRegistry(): ToolInputFormatterRegistrar {
43
35
  return {
44
36
  register: vi
45
- .fn<ToolInputFormatterRegistry["register"]>()
37
+ .fn<ToolInputFormatterRegistrar["register"]>()
46
38
  .mockReturnValue(vi.fn()),
47
- } as unknown as ToolInputFormatterRegistry;
39
+ };
48
40
  }
49
41
 
50
42
  function makeService(overrides?: {
51
- permissionManager?: PermissionManager;
52
- sessionRules?: SessionRules;
53
- formatterRegistry?: ToolInputFormatterRegistry;
43
+ permissionManager?: ScopedPermissionManager;
44
+ sessionRules?: Pick<SessionRules, "getRuleset">;
45
+ formatterRegistry?: ToolInputFormatterRegistrar;
54
46
  }) {
55
47
  const permissionManager =
56
- overrides?.permissionManager ?? makePermissionManager();
48
+ overrides?.permissionManager ?? makeFakePermissionManager();
57
49
  const sessionRules = overrides?.sessionRules ?? makeSessionRules();
58
50
  const formatterRegistry =
59
51
  overrides?.formatterRegistry ?? makeFormatterRegistry();