@gotgenes/pi-permission-system 10.5.0 → 10.5.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.
@@ -3,33 +3,30 @@
3
3
  * external path only prompt once — the session-approval recorded by the
4
4
  * first call covers the second.
5
5
  *
6
- * These tests use stateful mocks: `recordSessionApproval` records rules,
7
- * and `checkPermission` consults them via `getSessionRuleset`, mirroring
8
- * the real interaction between PermissionSession, SessionRules, and
9
- * PermissionManager.
6
+ * Uses real PermissionSession + PermissionResolver + SessionRules so the
7
+ * stateful approval-tracking path is exercised end-to-end.
10
8
  */
11
9
 
12
10
  import { describe, expect, it, vi } from "vitest";
13
11
 
14
12
  import { GateDecisionReporter } from "#src/decision-reporter";
15
- import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
16
13
  import type { GatePrompter } from "#src/gate-prompter";
17
14
  import { GateRunner } from "#src/handlers/gates/runner";
18
15
  import { SkillInputGatePipeline } from "#src/handlers/gates/skill-input-gate-pipeline";
19
16
  import { ToolCallGatePipeline } from "#src/handlers/gates/tool-call-gate-pipeline";
20
17
  import { PermissionGateHandler } from "#src/handlers/permission-gate-handler";
21
- import type { Rule } from "#src/rule";
22
- import type { SessionApproval } from "#src/session-approval";
23
- import { resolveToolPreviewLimits } from "#src/tool-preview-formatter";
24
- import type { ToolRegistry } from "#src/tool-registry";
25
18
  import type { PermissionCheckResult } from "#src/types";
26
19
  import { wildcardMatch } from "#src/wildcard-matcher";
27
20
 
28
21
  import {
29
- type MockGateHandlerSession,
30
22
  makeCtx,
31
23
  makeEvents,
24
+ makeToolRegistry,
32
25
  } from "#test/helpers/handler-fixtures";
26
+ import {
27
+ makeRealResolver,
28
+ makeRealSession,
29
+ } from "#test/helpers/session-fixtures";
33
30
 
34
31
  // ── SDK stub ───────────────────────────────────────────────────────────────
35
32
  vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
@@ -41,177 +38,105 @@ vi.mock("@earendil-works/pi-coding-agent", async (importOriginal) => {
41
38
  // ── helpers ────────────────────────────────────────────────────────────────
42
39
 
43
40
  /**
44
- * Build a PermissionSession mock with stateful session-rule tracking.
41
+ * Build a fully wired PermissionGateHandler for external-directory dedup
42
+ * tests.
45
43
  *
46
- * `checkPermission` returns "ask" for `external_directory` unless a
47
- * matching session rule exists (via `recordSessionApproval`), in which case
48
- * it returns "allow" with `source: "session"`. All other surfaces return
49
- * "allow" by default.
44
+ * `permissionManager.checkPermission` is configured so that:
45
+ * - `external_directory` surface returns "ask" on first call
46
+ * - On subsequent calls it checks the shared `sessionRules` store; if a
47
+ * matching rule was recorded by the runner, it returns "allow" with
48
+ * `source: "session"`.
49
+ * - All other surfaces return "allow".
50
50
  */
51
- function makeStatefulSession(
52
- overrides: Partial<MockGateHandlerSession> = {},
53
- ): MockGateHandlerSession {
54
- const sessionRules: Rule[] = [];
55
-
56
- const checkPermission = vi
57
- .fn<MockGateHandlerSession["checkPermission"]>()
58
- .mockImplementation(
59
- (
60
- surface: string,
61
- input: unknown,
62
- _agentName?: string,
63
- rules?: Rule[],
64
- ): PermissionCheckResult => {
65
- // Merge stored session rules with any passed-in rules
66
- const allRules = [...sessionRules, ...(rules ?? [])];
67
-
68
- if (surface === "external_directory") {
69
- const record = (input ?? {}) as Record<string, unknown>;
70
- const pathValue =
71
- typeof record.path === "string" ? record.path : null;
72
-
73
- if (pathValue && allRules.length > 0) {
74
- const match = allRules.findLast(
75
- (r) =>
76
- r.surface === "external_directory" &&
77
- wildcardMatch(r.pattern, pathValue),
78
- );
79
- if (match) {
80
- return {
81
- state: "allow",
82
- toolName: surface,
83
- source: "session",
84
- origin: "session",
85
- matchedPattern: match.pattern,
86
- };
87
- }
51
+ function makeDeduplicatingHandler(prompter?: GatePrompter): {
52
+ handler: PermissionGateHandler;
53
+ prompter: GatePrompter;
54
+ } {
55
+ const { session, permissionManager, sessionRules, logger } =
56
+ makeRealSession();
57
+ const { resolver } = makeRealResolver(permissionManager, sessionRules);
58
+
59
+ // Configure checkPermission to simulate config-level "ask" for external_directory
60
+ // but return "allow/session" when a session rule has been recorded.
61
+ vi.mocked(permissionManager.checkPermission).mockImplementation(
62
+ (surface, input, _agentName, rules): PermissionCheckResult => {
63
+ if (surface === "external_directory") {
64
+ const record = (input ?? {}) as Record<string, unknown>;
65
+ const pathValue = typeof record.path === "string" ? record.path : null;
66
+
67
+ if (pathValue && rules && rules.length > 0) {
68
+ const match = rules.findLast(
69
+ (r) =>
70
+ r.surface === "external_directory" &&
71
+ wildcardMatch(r.pattern, pathValue),
72
+ );
73
+ if (match) {
74
+ return {
75
+ state: "allow",
76
+ toolName: surface,
77
+ source: "session",
78
+ origin: "session",
79
+ matchedPattern: match.pattern,
80
+ };
88
81
  }
89
-
90
- // No session match → config-level "ask"
91
- return {
92
- state: "ask",
93
- toolName: surface,
94
- source: "special",
95
- origin: "global",
96
- };
97
82
  }
98
83
 
99
- // All other surfaces: allow
100
84
  return {
101
- state: "allow",
85
+ state: "ask",
102
86
  toolName: surface,
103
- source: "tool",
104
- origin: "builtin",
87
+ source: "special",
88
+ origin: "global",
105
89
  };
106
- },
107
- );
108
-
109
- const recordSessionApproval = vi
110
- .fn<MockGateHandlerSession["recordSessionApproval"]>()
111
- .mockImplementation((approval: SessionApproval) => {
112
- for (const pattern of approval.patterns) {
113
- sessionRules.push({
114
- surface: approval.surface,
115
- pattern,
116
- action: "allow",
117
- layer: "session",
118
- origin: "session",
119
- });
120
90
  }
121
- });
122
91
 
123
- const getSessionRuleset = vi
124
- .fn<MockGateHandlerSession["getSessionRuleset"]>()
125
- .mockImplementation(() => [...sessionRules]);
126
-
127
- const session: MockGateHandlerSession = {
128
- logger: overrides.logger ?? {
129
- debug: vi.fn(),
130
- review: vi.fn(),
131
- warn: vi.fn(),
92
+ return {
93
+ state: "allow",
94
+ toolName: surface,
95
+ source: "tool",
96
+ origin: "builtin",
97
+ };
132
98
  },
133
- activate: overrides.activate ?? vi.fn<MockGateHandlerSession["activate"]>(),
134
- resolveAgentName:
135
- overrides.resolveAgentName ??
136
- vi.fn<MockGateHandlerSession["resolveAgentName"]>().mockReturnValue(null),
137
- checkPermission: overrides.checkPermission ?? checkPermission,
138
- getSessionRuleset: overrides.getSessionRuleset ?? getSessionRuleset,
139
- recordSessionApproval:
140
- overrides.recordSessionApproval ?? recordSessionApproval,
141
- getActiveSkillEntries:
142
- overrides.getActiveSkillEntries ??
143
- vi
144
- .fn<MockGateHandlerSession["getActiveSkillEntries"]>()
145
- .mockReturnValue([]),
146
- getInfrastructureReadDirs:
147
- overrides.getInfrastructureReadDirs ??
148
- vi
149
- .fn<MockGateHandlerSession["getInfrastructureReadDirs"]>()
150
- .mockReturnValue([]),
151
- getToolPreviewLimits:
152
- overrides.getToolPreviewLimits ??
153
- vi
154
- .fn<MockGateHandlerSession["getToolPreviewLimits"]>()
155
- .mockReturnValue(resolveToolPreviewLimits(DEFAULT_EXTENSION_CONFIG)),
156
- };
157
- return session;
158
- }
99
+ );
159
100
 
160
- function makeHandlerForSession(
161
- session: MockGateHandlerSession,
162
- prompter?: GatePrompter,
163
- ): { handler: PermissionGateHandler; prompter: GatePrompter } {
164
101
  const events = makeEvents();
165
- const reporter = new GateDecisionReporter(session.logger, events);
102
+ const reporter = new GateDecisionReporter(logger, events);
166
103
  const resolvedPrompter: GatePrompter = prompter ?? {
167
104
  canConfirm: vi.fn().mockReturnValue(true),
168
105
  prompt: vi
169
106
  .fn<GatePrompter["prompt"]>()
170
107
  .mockResolvedValue({ approved: true, state: "approved_for_session" }),
171
108
  };
172
- // Resolver delegates to session's checkPermission + getSessionRuleset so
173
- // stateful approval tracking steers resolve automatically.
174
- const resolver = {
175
- resolve: (surface: string, input: unknown, agentName?: string) =>
176
- session.checkPermission(
177
- surface,
178
- input,
179
- agentName,
180
- session.getSessionRuleset(),
181
- ),
182
- };
183
- const runner = new GateRunner(resolver, session, resolvedPrompter, reporter);
109
+ const runner = new GateRunner(
110
+ resolver,
111
+ sessionRules,
112
+ resolvedPrompter,
113
+ reporter,
114
+ );
184
115
  const handler = new PermissionGateHandler(
185
116
  session,
186
- makeToolRegistry(),
117
+ makeToolRegistry({
118
+ getAll: vi
119
+ .fn()
120
+ .mockReturnValue([
121
+ { name: "read" },
122
+ { name: "write" },
123
+ { name: "edit" },
124
+ { name: "bash" },
125
+ ]),
126
+ }),
187
127
  new ToolCallGatePipeline(resolver, session),
188
- new SkillInputGatePipeline(session),
128
+ new SkillInputGatePipeline(resolver),
189
129
  runner,
190
130
  );
191
131
  return { handler, prompter: resolvedPrompter };
192
132
  }
193
133
 
194
- function makeToolRegistry(): ToolRegistry {
195
- return {
196
- getAll: vi
197
- .fn()
198
- .mockReturnValue([
199
- { name: "read" },
200
- { name: "write" },
201
- { name: "edit" },
202
- { name: "bash" },
203
- ]),
204
- setActive: vi.fn(),
205
- };
206
- }
207
-
208
134
  // ── tests ──────────────────────────────────────────────────────────────────
209
135
 
210
136
  describe("external-directory session dedup", () => {
211
137
  describe("path-bearing tools (read, write, edit)", () => {
212
138
  it("does not re-prompt for the same external path after session approval", async () => {
213
- const session = makeStatefulSession();
214
- const { handler, prompter } = makeHandlerForSession(session);
139
+ const { handler, prompter } = makeDeduplicatingHandler();
215
140
  const ctx = makeCtx();
216
141
  const externalPath = "/outside/project/data.txt";
217
142
 
@@ -239,8 +164,7 @@ describe("external-directory session dedup", () => {
239
164
  });
240
165
 
241
166
  it("does not re-prompt for a different file in the same external directory", async () => {
242
- const session = makeStatefulSession();
243
- const { handler, prompter } = makeHandlerForSession(session);
167
+ const { handler, prompter } = makeDeduplicatingHandler();
244
168
  const ctx = makeCtx();
245
169
 
246
170
  // First call — prompt for /outside/project/a.txt
@@ -265,8 +189,7 @@ describe("external-directory session dedup", () => {
265
189
  });
266
190
 
267
191
  it("does prompt for a file in a different external directory", async () => {
268
- const session = makeStatefulSession();
269
- const { handler, prompter } = makeHandlerForSession(session);
192
+ const { handler, prompter } = makeDeduplicatingHandler();
270
193
  const ctx = makeCtx();
271
194
 
272
195
  // First call — /outside/alpha/file.txt
@@ -291,14 +214,13 @@ describe("external-directory session dedup", () => {
291
214
  });
292
215
 
293
216
  it("re-prompts when user approved once (not for session)", async () => {
294
- const session = makeStatefulSession();
295
217
  const approveOnce: GatePrompter = {
296
218
  canConfirm: vi.fn().mockReturnValue(true),
297
219
  prompt: vi
298
220
  .fn<GatePrompter["prompt"]>()
299
221
  .mockResolvedValue({ approved: true, state: "approved" }),
300
222
  };
301
- const { handler, prompter } = makeHandlerForSession(session, approveOnce);
223
+ const { handler, prompter } = makeDeduplicatingHandler(approveOnce);
302
224
  const ctx = makeCtx();
303
225
  const externalPath = "/outside/project/data.txt";
304
226
 
@@ -326,8 +248,7 @@ describe("external-directory session dedup", () => {
326
248
 
327
249
  describe("bash commands with external paths", () => {
328
250
  it("does not re-prompt for a bash command referencing the same external path after session approval", async () => {
329
- const session = makeStatefulSession();
330
- const { handler, prompter } = makeHandlerForSession(session);
251
+ const { handler, prompter } = makeDeduplicatingHandler();
331
252
  const ctx = makeCtx();
332
253
 
333
254
  // First call — bash referencing /tmp/out.txt
@@ -354,8 +275,7 @@ describe("external-directory session dedup", () => {
354
275
  });
355
276
 
356
277
  it("does not re-prompt for read after bash already approved the same directory", async () => {
357
- const session = makeStatefulSession();
358
- const { handler, prompter } = makeHandlerForSession(session);
278
+ const { handler, prompter } = makeDeduplicatingHandler();
359
279
  const ctx = makeCtx();
360
280
 
361
281
  // First call — bash writes to /tmp/out.txt
@@ -49,9 +49,10 @@ describe("extractSkillNameFromInput", () => {
49
49
  describe("handleInput", () => {
50
50
  it("activates session with ctx", async () => {
51
51
  const ctx = makeCtx();
52
- const { handler, session } = makeHandler();
52
+ const { handler, forwarding } = makeHandler();
53
53
  await handler.handleInput(makeInputEvent("hello"), ctx);
54
- expect(session.activate).toHaveBeenCalledWith(ctx);
54
+ // session.activate(ctx) calls forwarding.start(ctx) on the real session
55
+ expect(forwarding.start).toHaveBeenCalledWith(ctx);
55
56
  });
56
57
 
57
58
  it("returns continue for non-skill input", async () => {
@@ -64,9 +65,9 @@ describe("handleInput", () => {
64
65
  });
65
66
 
66
67
  it("does not check permissions for non-skill input", async () => {
67
- const { handler, session } = makeHandler();
68
+ const { handler, permissionManager } = makeHandler();
68
69
  await handler.handleInput(makeInputEvent("just a message"), makeCtx());
69
- expect(session.checkPermission).not.toHaveBeenCalled();
70
+ expect(permissionManager.checkPermission).not.toHaveBeenCalled();
70
71
  });
71
72
 
72
73
  it("returns continue when skill is allowed", async () => {
@@ -2,9 +2,12 @@ import { describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import { SessionLifecycleHandler } from "#src/handlers/lifecycle";
4
4
  import type { ServiceLifecycle } from "#src/service-lifecycle";
5
- import type { SessionLifecycleSession } from "#src/session-lifecycle-session";
6
5
 
7
6
  import { makeCtx } from "#test/helpers/handler-fixtures";
7
+ import {
8
+ makeRealResolver,
9
+ makeRealSession,
10
+ } from "#test/helpers/session-fixtures";
8
11
 
9
12
  // ── status stub ────────────────────────────────────────────────────────────
10
13
  vi.mock("../../src/status", () => ({
@@ -15,55 +18,40 @@ vi.mock("../../src/status", () => ({
15
18
 
16
19
  // ── helpers ────────────────────────────────────────────────────────────────
17
20
 
18
- function makeSession(
19
- overrides: Partial<SessionLifecycleSession> = {},
20
- ): SessionLifecycleSession {
21
- return {
22
- logger: overrides.logger ?? {
23
- debug: vi.fn<SessionLifecycleSession["logger"]["debug"]>(),
24
- review: vi.fn<SessionLifecycleSession["logger"]["review"]>(),
25
- warn: vi.fn<SessionLifecycleSession["logger"]["warn"]>(),
26
- },
27
- refreshConfig:
28
- overrides.refreshConfig ??
29
- vi.fn<SessionLifecycleSession["refreshConfig"]>(),
30
- resetForNewSession:
31
- overrides.resetForNewSession ??
32
- vi.fn<SessionLifecycleSession["resetForNewSession"]>(),
33
- logResolvedConfigPaths:
34
- overrides.logResolvedConfigPaths ??
35
- vi.fn<SessionLifecycleSession["logResolvedConfigPaths"]>(),
36
- resolveAgentName:
37
- overrides.resolveAgentName ??
38
- vi
39
- .fn<SessionLifecycleSession["resolveAgentName"]>()
40
- .mockReturnValue(null),
41
- getConfigIssues:
42
- overrides.getConfigIssues ??
43
- vi.fn<SessionLifecycleSession["getConfigIssues"]>().mockReturnValue([]),
44
- reload: overrides.reload ?? vi.fn<SessionLifecycleSession["reload"]>(),
45
- getRuntimeContext:
46
- overrides.getRuntimeContext ??
47
- vi
48
- .fn<SessionLifecycleSession["getRuntimeContext"]>()
49
- .mockReturnValue(null),
50
- shutdown:
51
- overrides.shutdown ?? vi.fn<SessionLifecycleSession["shutdown"]>(),
52
- };
53
- }
54
-
55
- function makeHandler(overrides?: Partial<SessionLifecycleSession>): {
56
- handler: SessionLifecycleHandler;
57
- session: SessionLifecycleSession;
58
- serviceLifecycle: ServiceLifecycle;
59
- } {
60
- const session = makeSession(overrides);
21
+ function makeSetup(opts?: { configIssues?: string[] }) {
22
+ const {
23
+ session,
24
+ permissionManager,
25
+ sessionRules,
26
+ logger,
27
+ forwarding,
28
+ configStore,
29
+ } = makeRealSession();
30
+ const { resolver } = makeRealResolver(permissionManager, sessionRules);
31
+ if (opts?.configIssues) {
32
+ vi.mocked(permissionManager.getConfigIssues).mockReturnValue(
33
+ opts.configIssues,
34
+ );
35
+ }
61
36
  const serviceLifecycle: ServiceLifecycle = {
62
37
  activate: vi.fn<ServiceLifecycle["activate"]>(),
63
38
  teardown: vi.fn<ServiceLifecycle["teardown"]>(),
64
39
  };
65
- const handler = new SessionLifecycleHandler(session, serviceLifecycle);
66
- return { handler, session, serviceLifecycle };
40
+ const handler = new SessionLifecycleHandler(
41
+ session,
42
+ resolver,
43
+ serviceLifecycle,
44
+ );
45
+ return {
46
+ handler,
47
+ session,
48
+ resolver,
49
+ permissionManager,
50
+ logger,
51
+ forwarding,
52
+ configStore,
53
+ serviceLifecycle,
54
+ };
67
55
  }
68
56
 
69
57
  // ── handleSessionStart ─────────────────────────────────────────────────────
@@ -71,51 +59,53 @@ function makeHandler(overrides?: Partial<SessionLifecycleSession>): {
71
59
  describe("handleSessionStart", () => {
72
60
  it("refreshes config with ctx", async () => {
73
61
  const ctx = makeCtx();
74
- const { handler, session } = makeHandler();
62
+ const { handler, configStore } = makeSetup();
75
63
  await handler.handleSessionStart({ reason: "startup" }, ctx);
76
- expect(session.refreshConfig).toHaveBeenCalledWith(ctx);
64
+ expect(configStore.refresh).toHaveBeenCalledWith(ctx);
77
65
  });
78
66
 
79
67
  it("calls resetForNewSession with ctx", async () => {
80
68
  const ctx = makeCtx();
81
- const { handler, session } = makeHandler();
69
+ const { handler, session } = makeSetup();
70
+ const spy = vi.spyOn(session, "resetForNewSession");
82
71
  await handler.handleSessionStart({ reason: "startup" }, ctx);
83
- expect(session.resetForNewSession).toHaveBeenCalledWith(ctx);
72
+ expect(spy).toHaveBeenCalledWith(ctx);
84
73
  });
85
74
 
86
75
  it("logs resolved config paths", async () => {
87
- const { handler, session } = makeHandler();
76
+ const { handler, configStore } = makeSetup();
88
77
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
89
- expect(session.logResolvedConfigPaths).toHaveBeenCalledOnce();
78
+ expect(configStore.logResolvedPaths).toHaveBeenCalledOnce();
90
79
  });
91
80
 
92
81
  it("resolves agent name from ctx", async () => {
93
82
  const ctx = makeCtx();
94
- const { handler, session } = makeHandler();
83
+ const { handler, session } = makeSetup();
84
+ const spy = vi.spyOn(session, "resolveAgentName");
95
85
  await handler.handleSessionStart({ reason: "startup" }, ctx);
96
- expect(session.resolveAgentName).toHaveBeenCalledWith(ctx);
86
+ expect(spy).toHaveBeenCalledWith(ctx);
97
87
  });
98
88
 
99
89
  it("notifies each policy issue", async () => {
100
- const { handler, session } = makeHandler({
101
- getConfigIssues: vi.fn().mockReturnValue(["issue A", "issue B"]),
90
+ const { handler, logger } = makeSetup({
91
+ configIssues: ["issue A", "issue B"],
102
92
  });
103
93
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
104
- expect(session.logger.warn).toHaveBeenCalledWith("issue A");
105
- expect(session.logger.warn).toHaveBeenCalledWith("issue B");
94
+ expect(logger.warn).toHaveBeenCalledWith("issue A");
95
+ expect(logger.warn).toHaveBeenCalledWith("issue B");
106
96
  });
107
97
 
108
98
  it("does not warn when there are no policy issues", async () => {
109
- const { handler, session } = makeHandler();
99
+ const { handler, logger } = makeSetup();
110
100
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
111
- expect(session.logger.warn).not.toHaveBeenCalled();
101
+ expect(logger.warn).not.toHaveBeenCalled();
112
102
  });
113
103
 
114
104
  it("writes lifecycle.reload debug log when reason is reload", async () => {
115
105
  const ctx = makeCtx({ cwd: "/proj" });
116
- const { handler, session } = makeHandler();
106
+ const { handler, logger } = makeSetup();
117
107
  await handler.handleSessionStart({ reason: "reload" }, ctx);
118
- expect(session.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
108
+ expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
119
109
  triggeredBy: "session_start",
120
110
  reason: "reload",
121
111
  cwd: "/proj",
@@ -123,23 +113,26 @@ describe("handleSessionStart", () => {
123
113
  });
124
114
 
125
115
  it("does not write lifecycle.reload debug log for non-reload reasons", async () => {
126
- const { handler, session } = makeHandler();
116
+ const { handler, logger } = makeSetup();
127
117
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
128
- expect(session.logger.debug).not.toHaveBeenCalled();
118
+ expect(logger.debug).not.toHaveBeenCalled();
129
119
  });
130
120
 
131
121
  it("activates the service for the session with ctx", async () => {
132
122
  const ctx = makeCtx();
133
- const { handler, serviceLifecycle } = makeHandler();
123
+ const { handler, serviceLifecycle } = makeSetup();
134
124
  await handler.handleSessionStart({ reason: "startup" }, ctx);
135
125
  expect(serviceLifecycle.activate).toHaveBeenCalledWith(ctx);
136
126
  });
137
127
 
138
128
  it("calls refreshConfig before resetForNewSession", async () => {
139
129
  const callOrder: string[] = [];
140
- const { handler } = makeHandler({
141
- refreshConfig: vi.fn(() => callOrder.push("refreshConfig")),
142
- resetForNewSession: vi.fn(() => callOrder.push("resetForNewSession")),
130
+ const { handler, session, configStore } = makeSetup();
131
+ vi.spyOn(configStore, "refresh").mockImplementation(() => {
132
+ callOrder.push("refreshConfig");
133
+ });
134
+ vi.spyOn(session, "resetForNewSession").mockImplementation(() => {
135
+ callOrder.push("resetForNewSession");
143
136
  });
144
137
  await handler.handleSessionStart({ reason: "startup" }, makeCtx());
145
138
  expect(callOrder).toEqual(["refreshConfig", "resetForNewSession"]);
@@ -150,24 +143,25 @@ describe("handleSessionStart", () => {
150
143
 
151
144
  describe("handleResourcesDiscover", () => {
152
145
  it("does nothing when reason is not reload", async () => {
153
- const { handler, session } = makeHandler();
146
+ const { handler, session } = makeSetup();
147
+ const spy = vi.spyOn(session, "reload");
154
148
  await handler.handleResourcesDiscover({ reason: "startup" });
155
- expect(session.reload).not.toHaveBeenCalled();
149
+ expect(spy).not.toHaveBeenCalled();
156
150
  });
157
151
 
158
152
  it("calls reload on the session on reload", async () => {
159
- const { handler, session } = makeHandler();
153
+ const { handler, session } = makeSetup();
154
+ const spy = vi.spyOn(session, "reload");
160
155
  await handler.handleResourcesDiscover({ reason: "reload" });
161
- expect(session.reload).toHaveBeenCalledOnce();
156
+ expect(spy).toHaveBeenCalledOnce();
162
157
  });
163
158
 
164
159
  it("writes lifecycle.reload debug log on reload", async () => {
165
160
  const ctx = makeCtx({ cwd: "/proj" });
166
- const { handler, session } = makeHandler({
167
- getRuntimeContext: vi.fn().mockReturnValue(ctx),
168
- });
161
+ const { handler, session, logger } = makeSetup();
162
+ session.activate(ctx);
169
163
  await handler.handleResourcesDiscover({ reason: "reload" });
170
- expect(session.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
164
+ expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
171
165
  triggeredBy: "resources_discover",
172
166
  reason: "reload",
173
167
  cwd: "/proj",
@@ -175,9 +169,9 @@ describe("handleResourcesDiscover", () => {
175
169
  });
176
170
 
177
171
  it("logs cwd as null when runtimeContext is null on reload", async () => {
178
- const { handler, session } = makeHandler();
172
+ const { handler, logger } = makeSetup();
179
173
  await handler.handleResourcesDiscover({ reason: "reload" });
180
- expect(session.logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
174
+ expect(logger.debug).toHaveBeenCalledWith("lifecycle.reload", {
181
175
  triggeredBy: "resources_discover",
182
176
  reason: "reload",
183
177
  cwd: null,
@@ -190,9 +184,8 @@ describe("handleResourcesDiscover", () => {
190
184
  describe("handleSessionShutdown", () => {
191
185
  it("clears UI status when runtime context is present", async () => {
192
186
  const ctx = makeCtx();
193
- const { handler } = makeHandler({
194
- getRuntimeContext: vi.fn().mockReturnValue(ctx),
195
- });
187
+ const { handler, session } = makeSetup();
188
+ session.activate(ctx);
196
189
  await handler.handleSessionShutdown();
197
190
  expect(ctx.ui.setStatus).toHaveBeenCalledWith(
198
191
  "permission-system",
@@ -201,18 +194,19 @@ describe("handleSessionShutdown", () => {
201
194
  });
202
195
 
203
196
  it("does not throw when runtime context is null", async () => {
204
- const { handler } = makeHandler();
197
+ const { handler } = makeSetup();
205
198
  await expect(handler.handleSessionShutdown()).resolves.not.toThrow();
206
199
  });
207
200
 
208
201
  it("calls shutdown on the session", async () => {
209
- const { handler, session } = makeHandler();
202
+ const { handler, session } = makeSetup();
203
+ const spy = vi.spyOn(session, "shutdown");
210
204
  await handler.handleSessionShutdown();
211
- expect(session.shutdown).toHaveBeenCalledOnce();
205
+ expect(spy).toHaveBeenCalledOnce();
212
206
  });
213
207
 
214
208
  it("calls serviceLifecycle.teardown", async () => {
215
- const { handler, serviceLifecycle } = makeHandler();
209
+ const { handler, serviceLifecycle } = makeSetup();
216
210
  await handler.handleSessionShutdown();
217
211
  expect(serviceLifecycle.teardown).toHaveBeenCalledOnce();
218
212
  });
@@ -49,9 +49,10 @@ describe("getEventInput", () => {
49
49
  describe("handleToolCall", () => {
50
50
  it("activates session with ctx", async () => {
51
51
  const ctx = makeCtx();
52
- const { handler, session } = makeHandler();
52
+ const { handler, forwarding } = makeHandler();
53
53
  await handler.handleToolCall(makeToolCallEvent("read"), ctx);
54
- expect(session.activate).toHaveBeenCalledWith(ctx);
54
+ // session.activate(ctx) calls forwarding.start(ctx) on the real session
55
+ expect(forwarding.start).toHaveBeenCalledWith(ctx);
55
56
  });
56
57
 
57
58
  it("blocks when tool name cannot be resolved", async () => {