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