@gotgenes/pi-permission-system 3.5.0 → 3.7.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.
@@ -0,0 +1,271 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ import {
5
+ extractSkillNameFromInput,
6
+ handleInput,
7
+ } from "../../src/handlers/input";
8
+ import type { HandlerDeps } from "../../src/handlers/types";
9
+ import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
10
+
11
+ // ── helpers ────────────────────────────────────────────────────────────────
12
+
13
+ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
14
+ return {
15
+ cwd: "/test/project",
16
+ hasUI: true,
17
+ ui: {
18
+ setStatus: vi.fn(),
19
+ notify: vi.fn(),
20
+ select: vi.fn(),
21
+ input: vi.fn(),
22
+ },
23
+ sessionManager: {
24
+ getEntries: vi.fn().mockReturnValue([]),
25
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
26
+ addEntry: vi.fn(),
27
+ },
28
+ ...overrides,
29
+ } as unknown as ExtensionContext;
30
+ }
31
+
32
+ function makeInputEvent(text: string) {
33
+ return { text };
34
+ }
35
+
36
+ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
37
+ return {
38
+ getPermissionManager: vi.fn().mockReturnValue({
39
+ checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
40
+ getConfigIssues: vi.fn().mockReturnValue([]),
41
+ }),
42
+ setPermissionManager: vi.fn(),
43
+ getRuntimeContext: vi.fn().mockReturnValue(null),
44
+ setRuntimeContext: vi.fn(),
45
+ getActiveSkillEntries: vi.fn().mockReturnValue([] as SkillPromptEntry[]),
46
+ setActiveSkillEntries: vi.fn(),
47
+ getLastKnownActiveAgentName: vi.fn().mockReturnValue(null),
48
+ setLastKnownActiveAgentName: vi.fn(),
49
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
50
+ setLastActiveToolsCacheKey: vi.fn(),
51
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
52
+ setLastPromptStateCacheKey: vi.fn(),
53
+ sessionApprovalCache: {
54
+ approve: vi.fn(),
55
+ has: vi.fn(),
56
+ findMatchingPrefix: vi.fn(),
57
+ clear: vi.fn(),
58
+ } as unknown as HandlerDeps["sessionApprovalCache"],
59
+ createPermissionManagerForCwd: vi.fn(),
60
+ refreshExtensionConfig: vi.fn(),
61
+ notifyWarning: vi.fn(),
62
+ logResolvedConfigPaths: vi.fn(),
63
+ resolveAgentName: vi.fn().mockReturnValue(null),
64
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
65
+ promptPermission: vi
66
+ .fn()
67
+ .mockResolvedValue({ approved: true, state: "approved" }),
68
+ createPermissionRequestId: vi.fn().mockReturnValue("req-id"),
69
+ startForwardedPermissionPolling: vi.fn(),
70
+ stopForwardedPermissionPolling: vi.fn(),
71
+ writeReviewLog: vi.fn(),
72
+ writeDebugLog: vi.fn(),
73
+ getAllTools: vi.fn().mockReturnValue([]),
74
+ setActiveTools: vi.fn(),
75
+ ...overrides,
76
+ };
77
+ }
78
+
79
+ // ── extractSkillNameFromInput ──────────────────────────────────────────────
80
+
81
+ describe("extractSkillNameFromInput", () => {
82
+ it("returns null for plain text", () => {
83
+ expect(extractSkillNameFromInput("hello world")).toBeNull();
84
+ });
85
+
86
+ it("returns null for empty string", () => {
87
+ expect(extractSkillNameFromInput("")).toBeNull();
88
+ });
89
+
90
+ it("returns null for bare /skill: with no name", () => {
91
+ expect(extractSkillNameFromInput("/skill:")).toBeNull();
92
+ });
93
+
94
+ it("extracts skill name from /skill:<name>", () => {
95
+ expect(extractSkillNameFromInput("/skill:librarian")).toBe("librarian");
96
+ });
97
+
98
+ it("extracts skill name stopping at whitespace", () => {
99
+ expect(extractSkillNameFromInput("/skill:librarian some extra")).toBe(
100
+ "librarian",
101
+ );
102
+ });
103
+
104
+ it("trims leading whitespace before the prefix", () => {
105
+ expect(extractSkillNameFromInput(" /skill:my-skill")).toBe("my-skill");
106
+ });
107
+
108
+ it("returns null when the skill name after trimming is empty", () => {
109
+ expect(extractSkillNameFromInput("/skill: ")).toBeNull();
110
+ });
111
+ });
112
+
113
+ // ── handleInput ───────────────────────────────────────────────────────────
114
+
115
+ describe("handleInput", () => {
116
+ it("sets runtime context", async () => {
117
+ const ctx = makeCtx();
118
+ const deps = makeDeps();
119
+ await handleInput(deps, makeInputEvent("hello"), ctx);
120
+ expect(deps.setRuntimeContext).toHaveBeenCalledWith(ctx);
121
+ });
122
+
123
+ it("starts forwarded permission polling", async () => {
124
+ const ctx = makeCtx();
125
+ const deps = makeDeps();
126
+ await handleInput(deps, makeInputEvent("hello"), ctx);
127
+ expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
128
+ });
129
+
130
+ it("returns continue for non-skill input", async () => {
131
+ const deps = makeDeps();
132
+ const result = await handleInput(
133
+ deps,
134
+ makeInputEvent("just a message"),
135
+ makeCtx(),
136
+ );
137
+ expect(result).toEqual({ action: "continue" });
138
+ });
139
+
140
+ it("does not check permissions for non-skill input", async () => {
141
+ const deps = makeDeps();
142
+ await handleInput(deps, makeInputEvent("just a message"), makeCtx());
143
+ expect(deps.getPermissionManager().checkPermission).not.toHaveBeenCalled();
144
+ });
145
+
146
+ it("returns continue when skill is allowed", async () => {
147
+ const deps = makeDeps({
148
+ getPermissionManager: vi.fn().mockReturnValue({
149
+ checkPermission: vi.fn().mockReturnValue({ state: "allow" }),
150
+ }),
151
+ });
152
+ const result = await handleInput(
153
+ deps,
154
+ makeInputEvent("/skill:librarian"),
155
+ makeCtx(),
156
+ );
157
+ expect(result).toEqual({ action: "continue" });
158
+ });
159
+
160
+ it("returns handled when skill is denied", async () => {
161
+ const deps = makeDeps({
162
+ getPermissionManager: vi.fn().mockReturnValue({
163
+ checkPermission: vi.fn().mockReturnValue({ state: "deny" }),
164
+ }),
165
+ });
166
+ const result = await handleInput(
167
+ deps,
168
+ makeInputEvent("/skill:librarian"),
169
+ makeCtx(),
170
+ );
171
+ expect(result).toEqual({ action: "handled" });
172
+ });
173
+
174
+ it("shows a warning notification when skill is denied and UI is available", async () => {
175
+ const ctx = makeCtx({ hasUI: true });
176
+ const deps = makeDeps({
177
+ getPermissionManager: vi.fn().mockReturnValue({
178
+ checkPermission: vi.fn().mockReturnValue({ state: "deny" }),
179
+ }),
180
+ });
181
+ await handleInput(deps, makeInputEvent("/skill:librarian"), ctx);
182
+ expect(ctx.ui.notify).toHaveBeenCalledWith(
183
+ expect.stringContaining("librarian"),
184
+ "warning",
185
+ );
186
+ });
187
+
188
+ it("does not show a warning notification when skill is denied and UI is absent", async () => {
189
+ const ctx = makeCtx({ hasUI: false });
190
+ const deps = makeDeps({
191
+ getPermissionManager: vi.fn().mockReturnValue({
192
+ checkPermission: vi.fn().mockReturnValue({ state: "deny" }),
193
+ }),
194
+ });
195
+ await handleInput(deps, makeInputEvent("/skill:librarian"), ctx);
196
+ expect(ctx.ui.notify).not.toHaveBeenCalled();
197
+ });
198
+
199
+ it("returns handled when skill requires approval but no UI is available", async () => {
200
+ const deps = makeDeps({
201
+ getPermissionManager: vi.fn().mockReturnValue({
202
+ checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
203
+ }),
204
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
205
+ });
206
+ const result = await handleInput(
207
+ deps,
208
+ makeInputEvent("/skill:librarian"),
209
+ makeCtx(),
210
+ );
211
+ expect(result).toEqual({ action: "handled" });
212
+ });
213
+
214
+ it("prompts and returns continue when skill ask is approved", async () => {
215
+ const deps = makeDeps({
216
+ getPermissionManager: vi.fn().mockReturnValue({
217
+ checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
218
+ }),
219
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
220
+ promptPermission: vi
221
+ .fn()
222
+ .mockResolvedValue({ approved: true, state: "approved" }),
223
+ });
224
+ const result = await handleInput(
225
+ deps,
226
+ makeInputEvent("/skill:librarian"),
227
+ makeCtx(),
228
+ );
229
+ expect(result).toEqual({ action: "continue" });
230
+ expect(deps.promptPermission).toHaveBeenCalledOnce();
231
+ });
232
+
233
+ it("returns handled when skill ask is denied by user", async () => {
234
+ const deps = makeDeps({
235
+ getPermissionManager: vi.fn().mockReturnValue({
236
+ checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
237
+ }),
238
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
239
+ promptPermission: vi
240
+ .fn()
241
+ .mockResolvedValue({ approved: false, state: "denied" }),
242
+ });
243
+ const result = await handleInput(
244
+ deps,
245
+ makeInputEvent("/skill:librarian"),
246
+ makeCtx(),
247
+ );
248
+ expect(result).toEqual({ action: "handled" });
249
+ });
250
+
251
+ it("passes agentName in the prompt permission request", async () => {
252
+ const deps = makeDeps({
253
+ getPermissionManager: vi.fn().mockReturnValue({
254
+ checkPermission: vi.fn().mockReturnValue({ state: "ask" }),
255
+ }),
256
+ resolveAgentName: vi.fn().mockReturnValue("code-agent"),
257
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
258
+ promptPermission: vi
259
+ .fn()
260
+ .mockResolvedValue({ approved: true, state: "approved" }),
261
+ });
262
+ await handleInput(deps, makeInputEvent("/skill:librarian"), makeCtx());
263
+ expect(deps.promptPermission).toHaveBeenCalledWith(
264
+ expect.anything(),
265
+ expect.objectContaining({
266
+ agentName: "code-agent",
267
+ skillName: "librarian",
268
+ }),
269
+ );
270
+ });
271
+ });
@@ -0,0 +1,331 @@
1
+ import type { ExtensionContext } from "@mariozechner/pi-coding-agent";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+ import {
4
+ handleResourcesDiscover,
5
+ handleSessionShutdown,
6
+ handleSessionStart,
7
+ } from "../../src/handlers/lifecycle";
8
+ import type { HandlerDeps } from "../../src/handlers/types";
9
+ import type { PermissionManager } from "../../src/permission-manager";
10
+ import type { SessionApprovalCache } from "../../src/session-approval-cache";
11
+ import type { SkillPromptEntry } from "../../src/skill-prompt-sanitizer";
12
+
13
+ // ── active-agent stub ──────────────────────────────────────────────────────
14
+ const { mockGetActiveAgentName } = vi.hoisted(() => ({
15
+ mockGetActiveAgentName: vi.fn<(ctx: ExtensionContext) => string | null>(),
16
+ }));
17
+
18
+ vi.mock("../../src/active-agent", () => ({
19
+ getActiveAgentName: mockGetActiveAgentName,
20
+ getActiveAgentNameFromSystemPrompt: vi.fn().mockReturnValue(null),
21
+ }));
22
+
23
+ // ── PERMISSION_SYSTEM_STATUS_KEY stub ──────────────────────────────────────
24
+ // status.ts is re-exported through the handler; the key value doesn't matter
25
+ // for these tests.
26
+ vi.mock("../../src/status", () => ({
27
+ PERMISSION_SYSTEM_STATUS_KEY: "permission-system",
28
+ syncPermissionSystemStatus: vi.fn(),
29
+ getPermissionSystemStatus: vi.fn(),
30
+ }));
31
+
32
+ // ── helpers ────────────────────────────────────────────────────────────────
33
+
34
+ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
35
+ return {
36
+ cwd: "/test/project",
37
+ hasUI: true,
38
+ ui: {
39
+ setStatus: vi.fn(),
40
+ notify: vi.fn(),
41
+ select: vi.fn(),
42
+ input: vi.fn(),
43
+ },
44
+ sessionManager: {
45
+ getEntries: vi.fn().mockReturnValue([]),
46
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
47
+ addEntry: vi.fn(),
48
+ },
49
+ ...overrides,
50
+ } as unknown as ExtensionContext;
51
+ }
52
+
53
+ function makePermissionManager(
54
+ issues: string[] = [],
55
+ ): Pick<PermissionManager, "getConfigIssues"> {
56
+ return {
57
+ getConfigIssues: vi.fn().mockReturnValue(issues),
58
+ };
59
+ }
60
+
61
+ function makeSessionApprovalCache(): SessionApprovalCache {
62
+ return {
63
+ approve: vi.fn(),
64
+ has: vi.fn().mockReturnValue(false),
65
+ findMatchingPrefix: vi.fn().mockReturnValue(null),
66
+ clear: vi.fn(),
67
+ } as unknown as SessionApprovalCache;
68
+ }
69
+
70
+ function makeDeps(overrides: Partial<HandlerDeps> = {}): HandlerDeps {
71
+ const pm = makePermissionManager();
72
+ return {
73
+ getPermissionManager: vi.fn().mockReturnValue(pm),
74
+ setPermissionManager: vi.fn(),
75
+ getRuntimeContext: vi.fn().mockReturnValue(null),
76
+ setRuntimeContext: vi.fn(),
77
+ getActiveSkillEntries: vi.fn().mockReturnValue([] as SkillPromptEntry[]),
78
+ setActiveSkillEntries: vi.fn(),
79
+ getLastKnownActiveAgentName: vi.fn().mockReturnValue(null),
80
+ setLastKnownActiveAgentName: vi.fn(),
81
+ getLastActiveToolsCacheKey: vi.fn().mockReturnValue(null),
82
+ setLastActiveToolsCacheKey: vi.fn(),
83
+ getLastPromptStateCacheKey: vi.fn().mockReturnValue(null),
84
+ setLastPromptStateCacheKey: vi.fn(),
85
+ sessionApprovalCache: makeSessionApprovalCache(),
86
+ createPermissionManagerForCwd: vi
87
+ .fn()
88
+ .mockReturnValue(makePermissionManager()),
89
+ refreshExtensionConfig: vi.fn(),
90
+ notifyWarning: vi.fn(),
91
+ logResolvedConfigPaths: vi.fn(),
92
+ resolveAgentName: vi.fn().mockReturnValue(null),
93
+ canRequestPermissionConfirmation: vi.fn().mockReturnValue(false),
94
+ promptPermission: vi
95
+ .fn()
96
+ .mockResolvedValue({ approved: true, state: "approved" }),
97
+ createPermissionRequestId: vi.fn().mockReturnValue("test-id"),
98
+ startForwardedPermissionPolling: vi.fn(),
99
+ stopForwardedPermissionPolling: vi.fn(),
100
+ writeReviewLog: vi.fn(),
101
+ writeDebugLog: vi.fn(),
102
+ getAllTools: vi.fn().mockReturnValue([]),
103
+ setActiveTools: vi.fn(),
104
+ ...overrides,
105
+ };
106
+ }
107
+
108
+ // ── handleSessionStart ─────────────────────────────────────────────────────
109
+
110
+ describe("handleSessionStart", () => {
111
+ beforeEach(() => {
112
+ mockGetActiveAgentName.mockReset();
113
+ mockGetActiveAgentName.mockReturnValue(null);
114
+ });
115
+
116
+ it("sets the runtime context", async () => {
117
+ const ctx = makeCtx();
118
+ const deps = makeDeps();
119
+ await handleSessionStart(deps, { reason: "startup" }, ctx);
120
+ expect(deps.setRuntimeContext).toHaveBeenCalledWith(ctx);
121
+ });
122
+
123
+ it("refreshes extension config with ctx", async () => {
124
+ const ctx = makeCtx();
125
+ const deps = makeDeps();
126
+ await handleSessionStart(deps, { reason: "startup" }, ctx);
127
+ expect(deps.refreshExtensionConfig).toHaveBeenCalledWith(ctx);
128
+ });
129
+
130
+ it("creates a new permission manager for ctx.cwd and stores it", async () => {
131
+ const ctx = makeCtx({ cwd: "/my/project" });
132
+ const newPm = makePermissionManager();
133
+ const deps = makeDeps({
134
+ createPermissionManagerForCwd: vi.fn().mockReturnValue(newPm),
135
+ });
136
+ await handleSessionStart(deps, { reason: "startup" }, ctx);
137
+ expect(deps.createPermissionManagerForCwd).toHaveBeenCalledWith(
138
+ "/my/project",
139
+ );
140
+ expect(deps.setPermissionManager).toHaveBeenCalledWith(newPm);
141
+ });
142
+
143
+ it("clears the before_agent_start cache", async () => {
144
+ const ctx = makeCtx();
145
+ const deps = makeDeps();
146
+ await handleSessionStart(deps, { reason: "startup" }, ctx);
147
+ expect(deps.setActiveSkillEntries).toHaveBeenCalledWith([]);
148
+ expect(deps.setLastActiveToolsCacheKey).toHaveBeenCalledWith(null);
149
+ expect(deps.setLastPromptStateCacheKey).toHaveBeenCalledWith(null);
150
+ });
151
+
152
+ it("sets lastKnownActiveAgentName from getActiveAgentName", async () => {
153
+ mockGetActiveAgentName.mockReturnValue("my-agent");
154
+ const ctx = makeCtx();
155
+ const deps = makeDeps();
156
+ await handleSessionStart(deps, { reason: "startup" }, ctx);
157
+ expect(deps.setLastKnownActiveAgentName).toHaveBeenCalledWith("my-agent");
158
+ });
159
+
160
+ it("sets lastKnownActiveAgentName to null when no agent is active", async () => {
161
+ mockGetActiveAgentName.mockReturnValue(null);
162
+ const ctx = makeCtx();
163
+ const deps = makeDeps();
164
+ await handleSessionStart(deps, { reason: "startup" }, ctx);
165
+ expect(deps.setLastKnownActiveAgentName).toHaveBeenCalledWith(null);
166
+ });
167
+
168
+ it("starts forwarded permission polling", async () => {
169
+ const ctx = makeCtx();
170
+ const deps = makeDeps();
171
+ await handleSessionStart(deps, { reason: "startup" }, ctx);
172
+ expect(deps.startForwardedPermissionPolling).toHaveBeenCalledWith(ctx);
173
+ });
174
+
175
+ it("logs resolved config paths", async () => {
176
+ const ctx = makeCtx();
177
+ const deps = makeDeps();
178
+ await handleSessionStart(deps, { reason: "startup" }, ctx);
179
+ expect(deps.logResolvedConfigPaths).toHaveBeenCalledOnce();
180
+ });
181
+
182
+ it("notifies each policy issue", async () => {
183
+ const pm = makePermissionManager(["issue A", "issue B"]);
184
+ const deps = makeDeps({
185
+ getPermissionManager: vi.fn().mockReturnValue(pm),
186
+ });
187
+ await handleSessionStart(deps, { reason: "startup" }, makeCtx());
188
+ expect(deps.notifyWarning).toHaveBeenCalledWith("issue A");
189
+ expect(deps.notifyWarning).toHaveBeenCalledWith("issue B");
190
+ });
191
+
192
+ it("does not call notifyWarning when there are no policy issues", async () => {
193
+ const deps = makeDeps();
194
+ await handleSessionStart(deps, { reason: "startup" }, makeCtx());
195
+ expect(deps.notifyWarning).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it("writes lifecycle.reload debug log when reason is reload", async () => {
199
+ const ctx = makeCtx({ cwd: "/proj" });
200
+ const deps = makeDeps();
201
+ await handleSessionStart(deps, { reason: "reload" }, ctx);
202
+ expect(deps.writeDebugLog).toHaveBeenCalledWith("lifecycle.reload", {
203
+ triggeredBy: "session_start",
204
+ reason: "reload",
205
+ cwd: "/proj",
206
+ });
207
+ });
208
+
209
+ it("does not write lifecycle.reload debug log for non-reload reasons", async () => {
210
+ const deps = makeDeps();
211
+ await handleSessionStart(deps, { reason: "startup" }, makeCtx());
212
+ expect(deps.writeDebugLog).not.toHaveBeenCalled();
213
+ });
214
+ });
215
+
216
+ // ── handleResourcesDiscover ────────────────────────────────────────────────
217
+
218
+ describe("handleResourcesDiscover", () => {
219
+ it("does nothing when reason is not reload", async () => {
220
+ const deps = makeDeps();
221
+ await handleResourcesDiscover(deps, { reason: "startup" });
222
+ expect(deps.setPermissionManager).not.toHaveBeenCalled();
223
+ expect(deps.writeDebugLog).not.toHaveBeenCalled();
224
+ });
225
+
226
+ it("creates and stores a new PM using runtimeContext.cwd on reload", async () => {
227
+ const ctx = makeCtx({ cwd: "/runtime/cwd" });
228
+ const newPm = makePermissionManager();
229
+ const deps = makeDeps({
230
+ getRuntimeContext: vi.fn().mockReturnValue(ctx),
231
+ createPermissionManagerForCwd: vi.fn().mockReturnValue(newPm),
232
+ });
233
+ await handleResourcesDiscover(deps, { reason: "reload" });
234
+ expect(deps.createPermissionManagerForCwd).toHaveBeenCalledWith(
235
+ "/runtime/cwd",
236
+ );
237
+ expect(deps.setPermissionManager).toHaveBeenCalledWith(newPm);
238
+ });
239
+
240
+ it("uses undefined cwd when runtimeContext is null on reload", async () => {
241
+ const deps = makeDeps({
242
+ getRuntimeContext: vi.fn().mockReturnValue(null),
243
+ });
244
+ await handleResourcesDiscover(deps, { reason: "reload" });
245
+ expect(deps.createPermissionManagerForCwd).toHaveBeenCalledWith(undefined);
246
+ });
247
+
248
+ it("clears the before_agent_start cache on reload", async () => {
249
+ const deps = makeDeps({ getRuntimeContext: vi.fn().mockReturnValue(null) });
250
+ await handleResourcesDiscover(deps, { reason: "reload" });
251
+ expect(deps.setActiveSkillEntries).toHaveBeenCalledWith([]);
252
+ expect(deps.setLastActiveToolsCacheKey).toHaveBeenCalledWith(null);
253
+ expect(deps.setLastPromptStateCacheKey).toHaveBeenCalledWith(null);
254
+ });
255
+
256
+ it("writes lifecycle.reload debug log on reload", async () => {
257
+ const ctx = makeCtx({ cwd: "/proj" });
258
+ const deps = makeDeps({ getRuntimeContext: vi.fn().mockReturnValue(ctx) });
259
+ await handleResourcesDiscover(deps, { reason: "reload" });
260
+ expect(deps.writeDebugLog).toHaveBeenCalledWith("lifecycle.reload", {
261
+ triggeredBy: "resources_discover",
262
+ reason: "reload",
263
+ cwd: "/proj",
264
+ });
265
+ });
266
+
267
+ it("logs cwd as null when runtimeContext is null on reload", async () => {
268
+ const deps = makeDeps({
269
+ getRuntimeContext: vi.fn().mockReturnValue(null),
270
+ });
271
+ await handleResourcesDiscover(deps, { reason: "reload" });
272
+ expect(deps.writeDebugLog).toHaveBeenCalledWith("lifecycle.reload", {
273
+ triggeredBy: "resources_discover",
274
+ reason: "reload",
275
+ cwd: null,
276
+ });
277
+ });
278
+ });
279
+
280
+ // ── handleSessionShutdown ──────────────────────────────────────────────────
281
+
282
+ describe("handleSessionShutdown", () => {
283
+ it("clears the UI status when a runtime context is present", async () => {
284
+ const ctx = makeCtx();
285
+ const deps = makeDeps({
286
+ getRuntimeContext: vi.fn().mockReturnValue(ctx),
287
+ });
288
+ await handleSessionShutdown(deps);
289
+ expect(ctx.ui.setStatus).toHaveBeenCalledWith(
290
+ "permission-system",
291
+ undefined,
292
+ );
293
+ });
294
+
295
+ it("does not throw when runtime context is null", async () => {
296
+ const deps = makeDeps({ getRuntimeContext: vi.fn().mockReturnValue(null) });
297
+ await expect(handleSessionShutdown(deps)).resolves.not.toThrow();
298
+ });
299
+
300
+ it("sets runtime context to null", async () => {
301
+ const deps = makeDeps();
302
+ await handleSessionShutdown(deps);
303
+ expect(deps.setRuntimeContext).toHaveBeenCalledWith(null);
304
+ });
305
+
306
+ it("clears the before_agent_start cache", async () => {
307
+ const deps = makeDeps();
308
+ await handleSessionShutdown(deps);
309
+ expect(deps.setActiveSkillEntries).toHaveBeenCalledWith([]);
310
+ expect(deps.setLastActiveToolsCacheKey).toHaveBeenCalledWith(null);
311
+ expect(deps.setLastPromptStateCacheKey).toHaveBeenCalledWith(null);
312
+ });
313
+
314
+ it("clears the session approval cache", async () => {
315
+ const deps = makeDeps();
316
+ await handleSessionShutdown(deps);
317
+ expect(deps.sessionApprovalCache.clear).toHaveBeenCalledOnce();
318
+ });
319
+
320
+ it("stops forwarded permission polling", async () => {
321
+ const deps = makeDeps();
322
+ await handleSessionShutdown(deps);
323
+ expect(deps.stopForwardedPermissionPolling).toHaveBeenCalledOnce();
324
+ });
325
+
326
+ it("does not reset lastKnownActiveAgentName", async () => {
327
+ const deps = makeDeps();
328
+ await handleSessionShutdown(deps);
329
+ expect(deps.setLastKnownActiveAgentName).not.toHaveBeenCalled();
330
+ });
331
+ });