@gotgenes/pi-permission-system 10.3.0 → 10.4.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.
Files changed (35) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/package.json +1 -1
  3. package/src/config-modal.ts +10 -8
  4. package/src/config-store.ts +13 -34
  5. package/src/forwarded-permissions/io.ts +16 -22
  6. package/src/forwarded-permissions/permission-forwarder.ts +16 -19
  7. package/src/gate-prompter.ts +1 -3
  8. package/src/handlers/gates/runner.ts +1 -1
  9. package/src/index.ts +68 -51
  10. package/src/permission-event-rpc.ts +19 -15
  11. package/src/permission-prompter.ts +4 -3
  12. package/src/permission-session.ts +10 -67
  13. package/src/permissions-service.ts +3 -5
  14. package/src/prompting-gateway.ts +104 -0
  15. package/src/session-logger.ts +63 -12
  16. package/test/composition-root.test.ts +85 -1
  17. package/test/config-modal.test.ts +13 -7
  18. package/test/config-store.test.ts +23 -49
  19. package/test/forwarded-permissions/io.test.ts +23 -26
  20. package/test/handlers/external-directory-integration.test.ts +45 -32
  21. package/test/handlers/external-directory-session-dedup.test.ts +36 -46
  22. package/test/handlers/gates/runner.test.ts +10 -16
  23. package/test/handlers/input-events.test.ts +19 -4
  24. package/test/handlers/input.test.ts +29 -13
  25. package/test/handlers/tool-call-events.test.ts +23 -5
  26. package/test/helpers/gate-fixtures.ts +6 -6
  27. package/test/helpers/handler-fixtures.ts +24 -39
  28. package/test/permission-event-rpc.test.ts +30 -28
  29. package/test/permission-forwarder.test.ts +6 -5
  30. package/test/permission-prompter.test.ts +28 -28
  31. package/test/permission-session.test.ts +40 -112
  32. package/test/prompting-gateway.test.ts +230 -0
  33. package/test/session-logger.test.ts +151 -64
  34. package/src/runtime.ts +0 -147
  35. package/test/runtime.test.ts +0 -303
@@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
5
5
  import { afterEach, describe, expect, test, vi } from "vitest";
6
-
6
+ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
7
7
  import {
8
8
  PermissionForwarder,
9
9
  type PermissionForwarderDeps,
@@ -18,12 +18,11 @@ function makeDeps(
18
18
  return {
19
19
  forwardingDir: "/tmp/forwarding",
20
20
  subagentSessionsDir: "/tmp/subagents",
21
- logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
22
- writeReviewLog: vi.fn(),
21
+ logger: { review: vi.fn(), debug: vi.fn() },
23
22
  requestPermissionDecisionFromUi: vi
24
23
  .fn()
25
24
  .mockResolvedValue({ approved: true, state: "approved" as const }),
26
- shouldAutoApprove: () => false,
25
+ config: { current: () => ({ ...DEFAULT_EXTENSION_CONFIG }) },
27
26
  ...overrides,
28
27
  };
29
28
  }
@@ -271,7 +270,9 @@ describe("processInbox", () => {
271
270
  forwardingDir,
272
271
  events,
273
272
  requestPermissionDecisionFromUi,
274
- shouldAutoApprove: () => true,
273
+ config: {
274
+ current: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
275
+ },
275
276
  }),
276
277
  );
277
278
 
@@ -50,7 +50,7 @@ function makeDeps(
50
50
  ): PermissionPrompterDeps {
51
51
  return {
52
52
  config: makeConfigReader(),
53
- writeReviewLog: vi.fn(),
53
+ logger: { review: vi.fn() },
54
54
  events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
55
55
  forwarder: { requestApproval: mockRequestApproval },
56
56
  ...overrides,
@@ -97,32 +97,32 @@ describe("PermissionPrompter", () => {
97
97
  });
98
98
 
99
99
  it("logs permission_request.auto_approved in yolo mode", async () => {
100
- const writeReviewLog = vi.fn();
100
+ const logger = { review: vi.fn() };
101
101
  const deps = makeDeps({
102
102
  config: makeConfigReader({ yoloMode: true }),
103
- writeReviewLog,
103
+ logger,
104
104
  });
105
105
  const prompter = new PermissionPrompter(deps);
106
106
 
107
107
  await prompter.prompt(makeCtx(false), makeDetails());
108
108
 
109
- expect(writeReviewLog).toHaveBeenCalledWith(
109
+ expect(logger.review).toHaveBeenCalledWith(
110
110
  "permission_request.auto_approved",
111
111
  expect.objectContaining({ requestId: "req-123" }),
112
112
  );
113
113
  });
114
114
 
115
115
  it("does not log permission_request.waiting in yolo mode", async () => {
116
- const writeReviewLog = vi.fn();
116
+ const logger = { review: vi.fn() };
117
117
  const deps = makeDeps({
118
118
  config: makeConfigReader({ yoloMode: true }),
119
- writeReviewLog,
119
+ logger,
120
120
  });
121
121
  const prompter = new PermissionPrompter(deps);
122
122
 
123
123
  await prompter.prompt(makeCtx(false), makeDetails());
124
124
 
125
- expect(writeReviewLog).not.toHaveBeenCalledWith(
125
+ expect(logger.review).not.toHaveBeenCalledWith(
126
126
  "permission_request.waiting",
127
127
  expect.anything(),
128
128
  );
@@ -144,18 +144,18 @@ describe("PermissionPrompter", () => {
144
144
 
145
145
  describe("non-yolo path (UI present)", () => {
146
146
  it("logs permission_request.waiting before calling confirmPermission", async () => {
147
- const writeReviewLog = vi.fn();
147
+ const logger = { review: vi.fn() };
148
148
  const approved: PermissionPromptDecision = {
149
149
  approved: true,
150
150
  state: "approved",
151
151
  };
152
152
  mockRequestApproval.mockResolvedValue(approved);
153
- const deps = makeDeps({ writeReviewLog });
153
+ const deps = makeDeps({ logger });
154
154
  const prompter = new PermissionPrompter(deps);
155
155
 
156
156
  await prompter.prompt(makeCtx(true), makeDetails());
157
157
 
158
- const calls = writeReviewLog.mock.calls.map((c) => c[0] as string);
158
+ const calls = logger.review.mock.calls.map((c) => c[0] as string);
159
159
  expect(
160
160
  calls.indexOf("permission_request.waiting"),
161
161
  ).toBeGreaterThanOrEqual(0);
@@ -249,17 +249,17 @@ describe("PermissionPrompter", () => {
249
249
  });
250
250
 
251
251
  it("logs permission_request.approved when confirmPermission returns approved", async () => {
252
- const writeReviewLog = vi.fn();
252
+ const logger = { review: vi.fn() };
253
253
  mockRequestApproval.mockResolvedValue({
254
254
  approved: true,
255
255
  state: "approved",
256
256
  });
257
- const deps = makeDeps({ writeReviewLog });
257
+ const deps = makeDeps({ logger });
258
258
  const prompter = new PermissionPrompter(deps);
259
259
 
260
260
  await prompter.prompt(makeCtx(true), makeDetails());
261
261
 
262
- expect(writeReviewLog).toHaveBeenCalledWith(
262
+ expect(logger.review).toHaveBeenCalledWith(
263
263
  "permission_request.approved",
264
264
  expect.objectContaining({
265
265
  requestId: "req-123",
@@ -269,17 +269,17 @@ describe("PermissionPrompter", () => {
269
269
  });
270
270
 
271
271
  it("logs permission_request.denied when confirmPermission returns denied", async () => {
272
- const writeReviewLog = vi.fn();
272
+ const logger = { review: vi.fn() };
273
273
  mockRequestApproval.mockResolvedValue({
274
274
  approved: false,
275
275
  state: "denied",
276
276
  });
277
- const deps = makeDeps({ writeReviewLog });
277
+ const deps = makeDeps({ logger });
278
278
  const prompter = new PermissionPrompter(deps);
279
279
 
280
280
  await prompter.prompt(makeCtx(true), makeDetails());
281
281
 
282
- expect(writeReviewLog).toHaveBeenCalledWith(
282
+ expect(logger.review).toHaveBeenCalledWith(
283
283
  "permission_request.denied",
284
284
  expect.objectContaining({
285
285
  requestId: "req-123",
@@ -289,18 +289,18 @@ describe("PermissionPrompter", () => {
289
289
  });
290
290
 
291
291
  it("logs permission_request.denied with denialReason when present", async () => {
292
- const writeReviewLog = vi.fn();
292
+ const logger = { review: vi.fn() };
293
293
  mockRequestApproval.mockResolvedValue({
294
294
  approved: false,
295
295
  state: "denied_with_reason",
296
296
  denialReason: "too sensitive",
297
297
  });
298
- const deps = makeDeps({ writeReviewLog });
298
+ const deps = makeDeps({ logger });
299
299
  const prompter = new PermissionPrompter(deps);
300
300
 
301
301
  await prompter.prompt(makeCtx(true), makeDetails());
302
302
 
303
- expect(writeReviewLog).toHaveBeenCalledWith(
303
+ expect(logger.review).toHaveBeenCalledWith(
304
304
  "permission_request.denied",
305
305
  expect.objectContaining({
306
306
  denialReason: "too sensitive",
@@ -406,12 +406,12 @@ describe("PermissionPrompter", () => {
406
406
 
407
407
  describe("review log fields", () => {
408
408
  it("includes all standard fields in the waiting log entry", async () => {
409
- const writeReviewLog = vi.fn();
409
+ const logger = { review: vi.fn() };
410
410
  mockRequestApproval.mockResolvedValue({
411
411
  approved: true,
412
412
  state: "approved",
413
413
  });
414
- const deps = makeDeps({ writeReviewLog });
414
+ const deps = makeDeps({ logger });
415
415
  const prompter = new PermissionPrompter(deps);
416
416
  const details = makeDetails({
417
417
  toolCallId: "tc-1",
@@ -424,7 +424,7 @@ describe("PermissionPrompter", () => {
424
424
 
425
425
  await prompter.prompt(makeCtx(true), details);
426
426
 
427
- expect(writeReviewLog).toHaveBeenCalledWith(
427
+ expect(logger.review).toHaveBeenCalledWith(
428
428
  "permission_request.waiting",
429
429
  expect.objectContaining({
430
430
  requestId: "req-123",
@@ -443,17 +443,17 @@ describe("PermissionPrompter", () => {
443
443
  });
444
444
 
445
445
  it("uses null for optional fields not present in details", async () => {
446
- const writeReviewLog = vi.fn();
446
+ const logger = { review: vi.fn() };
447
447
  mockRequestApproval.mockResolvedValue({
448
448
  approved: true,
449
449
  state: "approved",
450
450
  });
451
- const deps = makeDeps({ writeReviewLog });
451
+ const deps = makeDeps({ logger });
452
452
  const prompter = new PermissionPrompter(deps);
453
453
 
454
454
  await prompter.prompt(makeCtx(true), makeDetails());
455
455
 
456
- expect(writeReviewLog).toHaveBeenCalledWith(
456
+ expect(logger.review).toHaveBeenCalledWith(
457
457
  "permission_request.waiting",
458
458
  expect.objectContaining({
459
459
  toolCallId: null,
@@ -499,17 +499,17 @@ describe("PermissionPrompter", () => {
499
499
  });
500
500
 
501
501
  it("logs the outcome when confirmPermission resolves via forwarding", async () => {
502
- const writeReviewLog = vi.fn();
502
+ const logger = { review: vi.fn() };
503
503
  mockRequestApproval.mockResolvedValue({
504
504
  approved: true,
505
505
  state: "approved",
506
506
  });
507
- const deps = makeDeps({ writeReviewLog });
507
+ const deps = makeDeps({ logger });
508
508
  const prompter = new PermissionPrompter(deps);
509
509
 
510
510
  await prompter.prompt(makeCtx(false), makeDetails());
511
511
 
512
- expect(writeReviewLog).toHaveBeenCalledWith(
512
+ expect(logger.review).toHaveBeenCalledWith(
513
513
  "permission_request.approved",
514
514
  expect.objectContaining({ requestId: "req-123" }),
515
515
  );
@@ -22,13 +22,12 @@ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
22
22
  import type { ExtensionPaths } from "#src/extension-paths";
23
23
  import type { ForwardingController } from "#src/forwarding-manager";
24
24
  import type { ScopedPermissionManager } from "#src/permission-manager";
25
- import {
26
- PermissionSession,
27
- type PermissionSessionRuntimeDeps,
28
- } from "#src/permission-session";
25
+ import { PermissionSession } from "#src/permission-session";
26
+ import type { PromptingGatewayLifecycle } from "#src/prompting-gateway";
29
27
  import type { Ruleset } from "#src/rule";
30
28
  import { SessionApproval } from "#src/session-approval";
31
29
  import type { SessionLogger } from "#src/session-logger";
30
+ import { SessionRules } from "#src/session-rules";
32
31
  import type { SkillPromptEntry } from "#src/skill-prompt-sanitizer";
33
32
  import type { PermissionCheckResult, PermissionState } from "#src/types";
34
33
  import { makeCtx } from "#test/helpers/handler-fixtures";
@@ -82,12 +81,10 @@ function makeConfigStore(
82
81
  };
83
82
  }
84
83
 
85
- function makeRuntimeDeps(): PermissionSessionRuntimeDeps {
84
+ function makeGateway(): PromptingGatewayLifecycle {
86
85
  return {
87
- canRequestPermissionConfirmation: vi.fn().mockReturnValue(true),
88
- promptPermission: vi
89
- .fn()
90
- .mockResolvedValue({ approved: true, state: "approved" }),
86
+ activate: vi.fn<PromptingGatewayLifecycle["activate"]>(),
87
+ deactivate: vi.fn<PromptingGatewayLifecycle["deactivate"]>(),
91
88
  };
92
89
  }
93
90
 
@@ -129,32 +126,44 @@ function createSession(overrides?: {
129
126
  logger?: SessionLogger;
130
127
  forwarding?: ForwardingController;
131
128
  permissionManager?: ScopedPermissionManager;
129
+ sessionRules?: SessionRules;
132
130
  configStore?: SessionConfigStore;
133
- runtimeDeps?: PermissionSessionRuntimeDeps;
131
+ gateway?: PromptingGatewayLifecycle;
134
132
  }): {
135
133
  session: PermissionSession;
136
134
  paths: ExtensionPaths;
137
135
  logger: SessionLogger;
138
136
  forwarding: ForwardingController;
137
+ sessionRules: SessionRules;
139
138
  configStore: SessionConfigStore;
140
- runtimeDeps: PermissionSessionRuntimeDeps;
139
+ gateway: PromptingGatewayLifecycle;
141
140
  } {
142
141
  const paths = makePaths(overrides?.paths);
143
142
  const logger = overrides?.logger ?? makeLogger();
144
143
  const forwarding = overrides?.forwarding ?? makeForwarding();
145
144
  const permissionManager =
146
145
  overrides?.permissionManager ?? makePermissionManager();
146
+ const sessionRules = overrides?.sessionRules ?? new SessionRules();
147
147
  const configStore = overrides?.configStore ?? makeConfigStore();
148
- const runtimeDeps = overrides?.runtimeDeps ?? makeRuntimeDeps();
148
+ const gateway = overrides?.gateway ?? makeGateway();
149
149
  const session = new PermissionSession(
150
150
  paths,
151
151
  logger,
152
152
  forwarding,
153
153
  permissionManager,
154
+ sessionRules,
154
155
  configStore,
155
- runtimeDeps,
156
+ gateway,
156
157
  );
157
- return { session, paths, logger, forwarding, configStore, runtimeDeps };
158
+ return {
159
+ session,
160
+ paths,
161
+ logger,
162
+ forwarding,
163
+ sessionRules,
164
+ configStore,
165
+ gateway,
166
+ };
158
167
  }
159
168
 
160
169
  // ── Tests ──────────────────────────────────────────────────────────────────
@@ -316,6 +325,23 @@ describe("PermissionSession", () => {
316
325
 
317
326
  expect(forwarding.stop).toHaveBeenCalled();
318
327
  });
328
+
329
+ it("forwards activate to the gateway", () => {
330
+ const { session, gateway } = createSession();
331
+ const ctx = makeCtx();
332
+
333
+ session.activate(ctx);
334
+
335
+ expect(gateway.activate).toHaveBeenCalledWith(ctx);
336
+ });
337
+
338
+ it("forwards deactivate to the gateway", () => {
339
+ const { session, gateway } = createSession();
340
+ session.activate(makeCtx());
341
+ session.deactivate();
342
+
343
+ expect(gateway.deactivate).toHaveBeenCalled();
344
+ });
319
345
  });
320
346
 
321
347
  describe("resetForNewSession", () => {
@@ -614,102 +640,4 @@ describe("PermissionSession", () => {
614
640
  expect(session.getRuntimeContext()).toBeNull();
615
641
  });
616
642
  });
617
-
618
- describe("canConfirm", () => {
619
- it("returns true when context is active and canPrompt returns true", () => {
620
- const { session } = createSession();
621
- session.activate(makeCtx());
622
- expect(session.canConfirm()).toBe(true);
623
- });
624
-
625
- it("returns false when no context is active", () => {
626
- const { session } = createSession();
627
- expect(session.canConfirm()).toBe(false);
628
- });
629
-
630
- it("returns false when canPrompt returns false", () => {
631
- const runtimeDeps = makeRuntimeDeps();
632
- (
633
- runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
634
- ).mockReturnValue(false);
635
- const { session } = createSession({ runtimeDeps });
636
- session.activate(makeCtx());
637
- expect(session.canConfirm()).toBe(false);
638
- });
639
- });
640
-
641
- describe("promptPermission", () => {
642
- it("delegates to prompt with stored context", async () => {
643
- const { session, runtimeDeps } = createSession();
644
- const ctx = makeCtx();
645
- session.activate(ctx);
646
- const details = {
647
- requestId: "req-1",
648
- source: "tool_call" as const,
649
- agentName: null,
650
- message: "Allow?",
651
- };
652
-
653
- const result = await session.promptPermission(details);
654
-
655
- expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
656
- expect(result).toEqual({ approved: true, state: "approved" });
657
- });
658
-
659
- it("throws when no context is active", async () => {
660
- const { session } = createSession();
661
- const details = {
662
- requestId: "req-1",
663
- source: "tool_call" as const,
664
- agentName: null,
665
- message: "Allow?",
666
- };
667
-
668
- await expect(session.promptPermission(details)).rejects.toThrow(
669
- "promptPermission called before the session was activated",
670
- );
671
- });
672
- });
673
-
674
- describe("canPrompt", () => {
675
- it("delegates to runtimeDeps.canRequestPermissionConfirmation", () => {
676
- const { session, runtimeDeps } = createSession();
677
- const ctx = makeCtx();
678
-
679
- const result = session.canPrompt(ctx);
680
-
681
- expect(runtimeDeps.canRequestPermissionConfirmation).toHaveBeenCalledWith(
682
- ctx,
683
- );
684
- expect(result).toBe(true);
685
- });
686
-
687
- it("returns false when runtimeDeps says no", () => {
688
- const runtimeDeps = makeRuntimeDeps();
689
- (
690
- runtimeDeps.canRequestPermissionConfirmation as ReturnType<typeof vi.fn>
691
- ).mockReturnValue(false);
692
- const { session } = createSession({ runtimeDeps });
693
-
694
- expect(session.canPrompt(makeCtx())).toBe(false);
695
- });
696
- });
697
-
698
- describe("prompt", () => {
699
- it("delegates to runtimeDeps.promptPermission", async () => {
700
- const { session, runtimeDeps } = createSession();
701
- const ctx = makeCtx();
702
- const details = {
703
- requestId: "req-1",
704
- source: "tool_call" as const,
705
- agentName: null,
706
- message: "Allow?",
707
- };
708
-
709
- const result = await session.prompt(ctx, details);
710
-
711
- expect(runtimeDeps.promptPermission).toHaveBeenCalledWith(ctx, details);
712
- expect(result).toEqual({ approved: true, state: "approved" });
713
- });
714
- });
715
643
  });
@@ -0,0 +1,230 @@
1
+ /**
2
+ * Unit tests for PromptingGateway.
3
+ *
4
+ * The gateway owns the stored ExtensionContext and is the sole implementation
5
+ * of the GatePrompter role. These tests exercise canConfirm() across all
6
+ * policy permutations and verify the prompt/reject contract for promptPermission().
7
+ */
8
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
9
+ import { afterEach, describe, expect, it, vi } from "vitest";
10
+
11
+ import type { ConfigReader } from "#src/config-store";
12
+ import { DEFAULT_EXTENSION_CONFIG } from "#src/extension-config";
13
+ import type { PermissionPromptDecision } from "#src/permission-dialog";
14
+ import type {
15
+ PermissionPrompterApi,
16
+ PromptPermissionDetails,
17
+ } from "#src/permission-prompter";
18
+ import {
19
+ PromptingGateway,
20
+ type PromptingGatewayDeps,
21
+ } from "#src/prompting-gateway";
22
+
23
+ // ── Test helpers ──────────────────────────────────────────────────────────
24
+
25
+ function makeCtx(overrides: Partial<ExtensionContext> = {}): ExtensionContext {
26
+ return {
27
+ cwd: "/test/project",
28
+ hasUI: true,
29
+ ui: {
30
+ setStatus: vi.fn(),
31
+ notify: vi.fn(),
32
+ select: vi.fn(),
33
+ input: vi.fn(),
34
+ },
35
+ sessionManager: {
36
+ getEntries: vi.fn().mockReturnValue([]),
37
+ getSessionDir: vi.fn().mockReturnValue("/sessions/test"),
38
+ getSessionId: vi.fn().mockReturnValue(null),
39
+ addEntry: vi.fn(),
40
+ },
41
+ ...overrides,
42
+ } as unknown as ExtensionContext;
43
+ }
44
+
45
+ function makeConfigReader(
46
+ overrides: Partial<typeof DEFAULT_EXTENSION_CONFIG> = {},
47
+ ): ConfigReader {
48
+ return {
49
+ current: vi
50
+ .fn<() => typeof DEFAULT_EXTENSION_CONFIG>()
51
+ .mockReturnValue({ ...DEFAULT_EXTENSION_CONFIG, ...overrides }),
52
+ };
53
+ }
54
+
55
+ function makePrompterApi(): PermissionPrompterApi & {
56
+ prompt: ReturnType<typeof vi.fn>;
57
+ } {
58
+ return {
59
+ prompt: vi
60
+ .fn<PermissionPrompterApi["prompt"]>()
61
+ .mockResolvedValue({ approved: true, state: "approved" }),
62
+ };
63
+ }
64
+
65
+ function makeDetails(): PromptPermissionDetails {
66
+ return {
67
+ requestId: "req-1",
68
+ source: "tool_call",
69
+ agentName: null,
70
+ message: "Allow this?",
71
+ };
72
+ }
73
+
74
+ function makeDeps(
75
+ overrides: Partial<PromptingGatewayDeps> = {},
76
+ ): PromptingGatewayDeps {
77
+ return {
78
+ config: overrides.config ?? makeConfigReader(),
79
+ subagentSessionsDir:
80
+ overrides.subagentSessionsDir ?? "/test/agent/subagent-sessions",
81
+ registry: overrides.registry,
82
+ prompter: overrides.prompter ?? makePrompterApi(),
83
+ };
84
+ }
85
+
86
+ // ── Tests ─────────────────────────────────────────────────────────────────
87
+
88
+ describe("PromptingGateway", () => {
89
+ describe("canConfirm", () => {
90
+ it("returns false before activate", () => {
91
+ const gateway = new PromptingGateway(makeDeps());
92
+ expect(gateway.canConfirm()).toBe(false);
93
+ });
94
+
95
+ it("returns true after activate when context has UI", () => {
96
+ const gateway = new PromptingGateway(makeDeps());
97
+ gateway.activate(makeCtx({ hasUI: true }));
98
+ expect(gateway.canConfirm()).toBe(true);
99
+ });
100
+
101
+ it("returns false when context has no UI, is not a subagent, and yolo mode is off", () => {
102
+ const gateway = new PromptingGateway(
103
+ makeDeps({ config: makeConfigReader({ yoloMode: false }) }),
104
+ );
105
+ gateway.activate(makeCtx({ hasUI: false }));
106
+ expect(gateway.canConfirm()).toBe(false);
107
+ });
108
+
109
+ it("returns true when yolo mode is enabled (no UI, not subagent)", () => {
110
+ const gateway = new PromptingGateway(
111
+ makeDeps({ config: makeConfigReader({ yoloMode: true }) }),
112
+ );
113
+ gateway.activate(makeCtx({ hasUI: false }));
114
+ expect(gateway.canConfirm()).toBe(true);
115
+ });
116
+
117
+ it("returns true when running as a subagent (env hint)", () => {
118
+ vi.stubEnv("PI_IS_SUBAGENT", "1");
119
+ const gateway = new PromptingGateway(
120
+ makeDeps({ config: makeConfigReader({ yoloMode: false }) }),
121
+ );
122
+ gateway.activate(makeCtx({ hasUI: false }));
123
+ expect(gateway.canConfirm()).toBe(true);
124
+ vi.unstubAllEnvs();
125
+ });
126
+
127
+ it("returns false after deactivate", () => {
128
+ const gateway = new PromptingGateway(makeDeps());
129
+ gateway.activate(makeCtx({ hasUI: true }));
130
+ gateway.deactivate();
131
+ expect(gateway.canConfirm()).toBe(false);
132
+ });
133
+
134
+ it("returns true after re-activate following deactivate", () => {
135
+ const gateway = new PromptingGateway(makeDeps());
136
+ gateway.activate(makeCtx({ hasUI: true }));
137
+ gateway.deactivate();
138
+ gateway.activate(makeCtx({ hasUI: true }));
139
+ expect(gateway.canConfirm()).toBe(true);
140
+ });
141
+ });
142
+
143
+ describe("prompt", () => {
144
+ it("rejects before activate", async () => {
145
+ const gateway = new PromptingGateway(makeDeps());
146
+ await expect(gateway.prompt(makeDetails())).rejects.toThrow(
147
+ "prompt called before the session was activated",
148
+ );
149
+ });
150
+
151
+ it("delegates to deps.prompter.prompt with the stored context", async () => {
152
+ const prompter = makePrompterApi();
153
+ const gateway = new PromptingGateway(makeDeps({ prompter }));
154
+ const ctx = makeCtx();
155
+ gateway.activate(ctx);
156
+ const details = makeDetails();
157
+
158
+ const result = await gateway.prompt(details);
159
+
160
+ expect(prompter.prompt).toHaveBeenCalledWith(ctx, details);
161
+ expect(result).toEqual({ approved: true, state: "approved" });
162
+ });
163
+
164
+ it("uses the most recently activated context", async () => {
165
+ const prompter = makePrompterApi();
166
+ const gateway = new PromptingGateway(makeDeps({ prompter }));
167
+ const firstCtx = makeCtx({ cwd: "/first" });
168
+ const secondCtx = makeCtx({ cwd: "/second" });
169
+
170
+ gateway.activate(firstCtx);
171
+ gateway.activate(secondCtx);
172
+
173
+ await gateway.prompt(makeDetails());
174
+
175
+ expect(prompter.prompt).toHaveBeenCalledWith(
176
+ secondCtx,
177
+ expect.anything(),
178
+ );
179
+ });
180
+
181
+ it("rejects after deactivate", async () => {
182
+ const gateway = new PromptingGateway(makeDeps());
183
+ gateway.activate(makeCtx());
184
+ gateway.deactivate();
185
+ await expect(gateway.prompt(makeDetails())).rejects.toThrow(
186
+ "prompt called before the session was activated",
187
+ );
188
+ });
189
+
190
+ it("returns the prompter decision", async () => {
191
+ const decision: PermissionPromptDecision = {
192
+ approved: false,
193
+ state: "denied",
194
+ denialReason: "user declined",
195
+ };
196
+ const prompter = makePrompterApi();
197
+ prompter.prompt.mockResolvedValue(decision);
198
+ const gateway = new PromptingGateway(makeDeps({ prompter }));
199
+ gateway.activate(makeCtx());
200
+
201
+ const result = await gateway.prompt(makeDetails());
202
+
203
+ expect(result).toEqual(decision);
204
+ });
205
+ });
206
+
207
+ describe("lifecycle", () => {
208
+ afterEach(() => {
209
+ vi.unstubAllEnvs();
210
+ });
211
+
212
+ it("activate then deactivate clears the stored context", () => {
213
+ const gateway = new PromptingGateway(makeDeps());
214
+ gateway.activate(makeCtx());
215
+ gateway.deactivate();
216
+ expect(gateway.canConfirm()).toBe(false);
217
+ });
218
+
219
+ it("multiple activate calls update the stored context", () => {
220
+ const prompter = makePrompterApi();
221
+ const gateway = new PromptingGateway(makeDeps({ prompter }));
222
+ const ctx2 = makeCtx({ cwd: "/new" });
223
+ gateway.activate(makeCtx({ cwd: "/old" }));
224
+ gateway.activate(ctx2);
225
+
226
+ // canConfirm still works (context set)
227
+ expect(gateway.canConfirm()).toBe(true);
228
+ });
229
+ });
230
+ });