@gotgenes/pi-permission-system 9.2.0 → 10.0.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.
@@ -13,6 +13,7 @@ import {
13
13
  PERMISSIONS_PROTOCOL_VERSION,
14
14
  PERMISSIONS_RPC_CHECK_CHANNEL,
15
15
  PERMISSIONS_RPC_PROMPT_CHANNEL,
16
+ PERMISSIONS_UI_PROMPT_CHANNEL,
16
17
  } from "#src/permission-events";
17
18
 
18
19
  // ── Helpers ────────────────────────────────────────────────────────────────
@@ -317,6 +318,44 @@ describe("registerPermissionRpcHandlers — permissions:rpc:prompt", () => {
317
318
  }
318
319
  });
319
320
 
321
+ it("emits a UI prompt broadcast before awaiting the UI decision", async () => {
322
+ const bus = createEventBus();
323
+ const ctx = makeCtxWithUi();
324
+ const requestUi = vi
325
+ .fn()
326
+ .mockResolvedValue({ approved: true, state: "approved" as const });
327
+ const deps = makeDeps({
328
+ getRuntimeContext: vi.fn().mockReturnValue(ctx),
329
+ requestPermissionDecisionFromUi: requestUi,
330
+ });
331
+ registerPermissionRpcHandlers(bus, deps);
332
+
333
+ const promptPromise = waitForReply(bus, PERMISSIONS_UI_PROMPT_CHANNEL);
334
+ const replyPromise = waitForReply(
335
+ bus,
336
+ `${PERMISSIONS_RPC_PROMPT_CHANNEL}:reply:req-prompt-broadcast`,
337
+ );
338
+ bus.emit(PERMISSIONS_RPC_PROMPT_CHANNEL, {
339
+ requestId: "req-prompt-broadcast",
340
+ surface: "bash",
341
+ value: "git push",
342
+ message: "Allow git push?",
343
+ agentName: "Worker",
344
+ sessionLabel: "Allow git *",
345
+ });
346
+
347
+ await expect(promptPromise).resolves.toEqual({
348
+ requestId: "req-prompt-broadcast",
349
+ source: "rpc_prompt",
350
+ surface: "bash",
351
+ value: "git push",
352
+ agentName: "Worker",
353
+ message: "Allow git push?",
354
+ forwarding: null,
355
+ });
356
+ await replyPromise;
357
+ });
358
+
320
359
  it("passes the message to requestPermissionDecisionFromUi", async () => {
321
360
  const bus = createEventBus();
322
361
  const ctx = makeCtxWithUi();
@@ -14,15 +14,18 @@ import type {
14
14
  PermissionsPromptRequest,
15
15
  PermissionsReadyEvent,
16
16
  PermissionsRpcReply,
17
+ PermissionUiPromptEvent,
17
18
  } from "#src/permission-events";
18
19
  import {
19
20
  emitDecisionEvent,
20
21
  emitReadyEvent,
22
+ emitUiPromptEvent,
21
23
  PERMISSIONS_DECISION_CHANNEL,
22
24
  PERMISSIONS_PROTOCOL_VERSION,
23
25
  PERMISSIONS_READY_CHANNEL,
24
26
  PERMISSIONS_RPC_CHECK_CHANNEL,
25
27
  PERMISSIONS_RPC_PROMPT_CHANNEL,
28
+ PERMISSIONS_UI_PROMPT_CHANNEL,
26
29
  } from "#src/permission-events";
27
30
 
28
31
  // ── Minimal EventBus stub ──────────────────────────────────────────────────
@@ -43,6 +46,7 @@ describe("constants", () => {
43
46
 
44
47
  it("channel names have the correct values", () => {
45
48
  expect(PERMISSIONS_READY_CHANNEL).toBe("permissions:ready");
49
+ expect(PERMISSIONS_UI_PROMPT_CHANNEL).toBe("permissions:ui_prompt");
46
50
  expect(PERMISSIONS_DECISION_CHANNEL).toBe("permissions:decision");
47
51
  expect(PERMISSIONS_RPC_CHECK_CHANNEL).toBe("permissions:rpc:check");
48
52
  expect(PERMISSIONS_RPC_PROMPT_CHANNEL).toBe("permissions:rpc:prompt");
@@ -52,20 +56,75 @@ describe("constants", () => {
52
56
  // ── emitReadyEvent ─────────────────────────────────────────────────────────
53
57
 
54
58
  describe("emitReadyEvent", () => {
55
- it("emits on the permissions:ready channel with protocol version", () => {
59
+ it("emits an empty payload on the permissions:ready channel", () => {
56
60
  const bus = makeEventBus();
57
61
  emitReadyEvent(bus);
58
62
  expect(bus.emit).toHaveBeenCalledOnce();
59
- expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {
60
- protocolVersion: 1,
61
- });
63
+ expect(bus.emit).toHaveBeenCalledWith("permissions:ready", {});
62
64
  });
63
65
 
64
- it("emitted payload satisfies PermissionsReadyEvent shape", () => {
66
+ it("carries no protocolVersion (version lives in the RPC envelope)", () => {
65
67
  const bus = makeEventBus();
66
68
  emitReadyEvent(bus);
67
69
  const payload = bus.emit.mock.calls[0][1] as PermissionsReadyEvent;
68
- expect(typeof payload.protocolVersion).toBe("number");
70
+ expect(payload).not.toHaveProperty("protocolVersion");
71
+ });
72
+
73
+ it("swallows event bus errors because broadcasts are best-effort", () => {
74
+ const bus = {
75
+ emit: vi.fn(() => {
76
+ throw new Error("listener failed");
77
+ }),
78
+ on: vi.fn().mockReturnValue(() => undefined),
79
+ };
80
+
81
+ expect(() => emitReadyEvent(bus)).not.toThrow();
82
+ });
83
+ });
84
+
85
+ // ── emitUiPromptEvent ──────────────────────────────────────────────────────
86
+
87
+ describe("emitUiPromptEvent", () => {
88
+ function makeUiPromptEvent(
89
+ overrides: Partial<PermissionUiPromptEvent> = {},
90
+ ): PermissionUiPromptEvent {
91
+ return {
92
+ requestId: "req-123",
93
+ source: "tool_call",
94
+ surface: "bash",
95
+ value: "git status",
96
+ agentName: "Explore",
97
+ message: "Allow git status?",
98
+ forwarding: null,
99
+ ...overrides,
100
+ };
101
+ }
102
+
103
+ it("emits on the permissions:ui_prompt channel", () => {
104
+ const bus = makeEventBus();
105
+ emitUiPromptEvent(bus, makeUiPromptEvent());
106
+ expect(bus.emit).toHaveBeenCalledOnce();
107
+ expect(bus.emit.mock.calls[0][0]).toBe("permissions:ui_prompt");
108
+ });
109
+
110
+ it("forwards the full payload unchanged", () => {
111
+ const bus = makeEventBus();
112
+ const event = makeUiPromptEvent({
113
+ forwarding: { requesterAgentName: "Worker", requesterSessionId: "child" },
114
+ });
115
+ emitUiPromptEvent(bus, event);
116
+ expect(bus.emit.mock.calls[0][1]).toEqual(event);
117
+ });
118
+
119
+ it("swallows event bus errors because UI prompt broadcasts are observational", () => {
120
+ const bus = {
121
+ emit: vi.fn(() => {
122
+ throw new Error("listener failed");
123
+ }),
124
+ on: vi.fn().mockReturnValue(() => undefined),
125
+ };
126
+
127
+ expect(() => emitUiPromptEvent(bus, makeUiPromptEvent())).not.toThrow();
69
128
  });
70
129
  });
71
130
 
@@ -143,6 +202,17 @@ describe("emitDecisionEvent", () => {
143
202
  expect(payload.agentName).toBeNull();
144
203
  expect(payload.matchedPattern).toBeNull();
145
204
  });
205
+
206
+ it("swallows event bus errors because broadcasts are best-effort", () => {
207
+ const bus = {
208
+ emit: vi.fn(() => {
209
+ throw new Error("listener failed");
210
+ }),
211
+ on: vi.fn().mockReturnValue(() => undefined),
212
+ };
213
+
214
+ expect(() => emitDecisionEvent(bus, makeDecisionEvent())).not.toThrow();
215
+ });
146
216
  });
147
217
 
148
218
  // ── Type-shape compile-time checks (runtime assertions on literal values) ──
@@ -279,7 +349,7 @@ describe("piPermissionSystemExtension ready event wiring", () => {
279
349
  rmSync(baseDir, { recursive: true, force: true });
280
350
  });
281
351
 
282
- it("emits permissions:ready with protocolVersion at session_start", async () => {
352
+ it("emits permissions:ready at session_start", async () => {
283
353
  const emitSpy = vi.fn();
284
354
  const handlers = new Map<
285
355
  string,
@@ -324,8 +394,6 @@ describe("piPermissionSystemExtension ready event wiring", () => {
324
394
  ([channel]) => channel === PERMISSIONS_READY_CHANNEL,
325
395
  );
326
396
  expect(readyCalls).toHaveLength(1);
327
- expect(readyCalls[0][1]).toEqual({
328
- protocolVersion: PERMISSIONS_PROTOCOL_VERSION,
329
- });
397
+ expect(readyCalls[0][1]).toEqual({});
330
398
  });
331
399
  });
@@ -1,5 +1,14 @@
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
1
5
  import { afterEach, describe, expect, test, vi } from "vitest";
2
6
  import {
7
+ confirmPermission,
8
+ processForwardedPermissionRequests,
9
+ } from "#src/forwarded-permissions/polling";
10
+ import {
11
+ createPermissionForwardingLocation,
3
12
  resolvePermissionForwardingTargetSessionId,
4
13
  SUBAGENT_PARENT_SESSION_ENV_CANDIDATES,
5
14
  SUBAGENT_PARENT_SESSION_ENV_KEY,
@@ -240,3 +249,276 @@ describe("resolvePermissionForwardingTargetSessionId — registry resolution", (
240
249
  ).toBe("parent-from-env");
241
250
  });
242
251
  });
252
+
253
+ describe("processForwardedPermissionRequests", () => {
254
+ test("emits a UI prompt event before showing a forwarded permission dialog", async () => {
255
+ const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
256
+ try {
257
+ const forwardingDir = join(root, "forwarding");
258
+ const location = createPermissionForwardingLocation(
259
+ forwardingDir,
260
+ "parent-session",
261
+ );
262
+ mkdirSync(location.requestsDir, { recursive: true });
263
+ mkdirSync(location.responsesDir, { recursive: true });
264
+ writeFileSync(
265
+ join(location.requestsDir, "req-forwarded.json"),
266
+ JSON.stringify({
267
+ id: "req-forwarded",
268
+ createdAt: Date.now(),
269
+ requesterSessionId: "child-session",
270
+ targetSessionId: "parent-session",
271
+ requesterAgentName: "Explore",
272
+ message: "Allow git push?",
273
+ }),
274
+ "utf-8",
275
+ );
276
+
277
+ const events = {
278
+ emit: vi.fn(),
279
+ on: vi.fn().mockReturnValue(() => undefined),
280
+ };
281
+ const requestPermissionDecisionFromUi = vi
282
+ .fn()
283
+ .mockResolvedValue({ approved: true, state: "approved" as const });
284
+
285
+ await processForwardedPermissionRequests(
286
+ {
287
+ hasUI: true,
288
+ ui: { select: vi.fn(), input: vi.fn() },
289
+ sessionManager: {
290
+ getSessionId: vi.fn().mockReturnValue("parent-session"),
291
+ },
292
+ } as unknown as ExtensionContext,
293
+ {
294
+ forwardingDir,
295
+ subagentSessionsDir: join(root, "subagents"),
296
+ events,
297
+ logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
298
+ writeReviewLog: vi.fn(),
299
+ requestPermissionDecisionFromUi,
300
+ shouldAutoApprove: () => false,
301
+ },
302
+ );
303
+
304
+ expect(events.emit).toHaveBeenCalledWith(
305
+ "permissions:ui_prompt",
306
+ expect.objectContaining({
307
+ requestId: "req-forwarded",
308
+ source: "tool_call",
309
+ surface: null,
310
+ value: null,
311
+ agentName: "Explore",
312
+ message: expect.stringContaining("Allow git push?"),
313
+ forwarding: {
314
+ requesterAgentName: "Explore",
315
+ requesterSessionId: "child-session",
316
+ },
317
+ }),
318
+ );
319
+ expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
320
+ } finally {
321
+ rmSync(root, { recursive: true, force: true });
322
+ }
323
+ });
324
+
325
+ test("emits a non-degraded UI prompt event when the request carries display fields", async () => {
326
+ const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
327
+ try {
328
+ const forwardingDir = join(root, "forwarding");
329
+ const location = createPermissionForwardingLocation(
330
+ forwardingDir,
331
+ "parent-session",
332
+ );
333
+ mkdirSync(location.requestsDir, { recursive: true });
334
+ mkdirSync(location.responsesDir, { recursive: true });
335
+ writeFileSync(
336
+ join(location.requestsDir, "req-forwarded-rich.json"),
337
+ JSON.stringify({
338
+ id: "req-forwarded-rich",
339
+ createdAt: Date.now(),
340
+ requesterSessionId: "child-session",
341
+ targetSessionId: "parent-session",
342
+ requesterAgentName: "Explore",
343
+ message: "Allow git push?",
344
+ source: "tool_call",
345
+ surface: "bash",
346
+ value: "git push",
347
+ }),
348
+ "utf-8",
349
+ );
350
+
351
+ const events = {
352
+ emit: vi.fn(),
353
+ on: vi.fn().mockReturnValue(() => undefined),
354
+ };
355
+ const requestPermissionDecisionFromUi = vi
356
+ .fn()
357
+ .mockResolvedValue({ approved: true, state: "approved" as const });
358
+
359
+ await processForwardedPermissionRequests(
360
+ {
361
+ hasUI: true,
362
+ ui: { select: vi.fn(), input: vi.fn() },
363
+ sessionManager: {
364
+ getSessionId: vi.fn().mockReturnValue("parent-session"),
365
+ },
366
+ } as unknown as ExtensionContext,
367
+ {
368
+ forwardingDir,
369
+ subagentSessionsDir: join(root, "subagents"),
370
+ events,
371
+ logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
372
+ writeReviewLog: vi.fn(),
373
+ requestPermissionDecisionFromUi,
374
+ shouldAutoApprove: () => false,
375
+ },
376
+ );
377
+
378
+ expect(events.emit).toHaveBeenCalledWith(
379
+ "permissions:ui_prompt",
380
+ expect.objectContaining({
381
+ requestId: "req-forwarded-rich",
382
+ source: "tool_call",
383
+ surface: "bash",
384
+ value: "git push",
385
+ agentName: "Explore",
386
+ message: expect.stringContaining("Allow git push?"),
387
+ forwarding: {
388
+ requesterAgentName: "Explore",
389
+ requesterSessionId: "child-session",
390
+ },
391
+ }),
392
+ );
393
+ expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
394
+ } finally {
395
+ rmSync(root, { recursive: true, force: true });
396
+ }
397
+ });
398
+
399
+ test("does not emit a UI prompt event when forwarded permission auto-approves", async () => {
400
+ const root = mkdtempSync(join(tmpdir(), "permission-forwarding-"));
401
+ try {
402
+ const forwardingDir = join(root, "forwarding");
403
+ const location = createPermissionForwardingLocation(
404
+ forwardingDir,
405
+ "parent-session",
406
+ );
407
+ mkdirSync(location.requestsDir, { recursive: true });
408
+ mkdirSync(location.responsesDir, { recursive: true });
409
+ writeFileSync(
410
+ join(location.requestsDir, "req-forwarded-auto.json"),
411
+ JSON.stringify({
412
+ id: "req-forwarded-auto",
413
+ createdAt: Date.now(),
414
+ requesterSessionId: "child-session",
415
+ targetSessionId: "parent-session",
416
+ requesterAgentName: "Explore",
417
+ message: "Allow git push?",
418
+ }),
419
+ "utf-8",
420
+ );
421
+
422
+ const events = {
423
+ emit: vi.fn(),
424
+ on: vi.fn().mockReturnValue(() => undefined),
425
+ };
426
+ const requestPermissionDecisionFromUi = vi.fn();
427
+
428
+ await processForwardedPermissionRequests(
429
+ {
430
+ hasUI: true,
431
+ ui: { select: vi.fn(), input: vi.fn() },
432
+ sessionManager: {
433
+ getSessionId: vi.fn().mockReturnValue("parent-session"),
434
+ },
435
+ } as unknown as ExtensionContext,
436
+ {
437
+ forwardingDir,
438
+ subagentSessionsDir: join(root, "subagents"),
439
+ events,
440
+ logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
441
+ writeReviewLog: vi.fn(),
442
+ requestPermissionDecisionFromUi,
443
+ shouldAutoApprove: () => true,
444
+ },
445
+ );
446
+
447
+ expect(events.emit).not.toHaveBeenCalledWith(
448
+ "permissions:ui_prompt",
449
+ expect.anything(),
450
+ );
451
+ expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
452
+ } finally {
453
+ rmSync(root, { recursive: true, force: true });
454
+ }
455
+ });
456
+ });
457
+
458
+ describe("confirmPermission", () => {
459
+ test("shows the UI dialog but does not emit a UI prompt event (the prompter does)", async () => {
460
+ const events = {
461
+ emit: vi.fn(),
462
+ on: vi.fn().mockReturnValue(() => undefined),
463
+ };
464
+ const requestPermissionDecisionFromUi = vi
465
+ .fn()
466
+ .mockResolvedValue({ approved: true, state: "approved" as const });
467
+
468
+ await confirmPermission(
469
+ {
470
+ hasUI: true,
471
+ ui: { select: vi.fn(), input: vi.fn() },
472
+ } as unknown as ExtensionContext,
473
+ "Allow git push?",
474
+ {
475
+ forwardingDir: "/tmp/forwarding",
476
+ subagentSessionsDir: "/tmp/subagents",
477
+ events,
478
+ logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
479
+ writeReviewLog: vi.fn(),
480
+ requestPermissionDecisionFromUi,
481
+ shouldAutoApprove: () => false,
482
+ },
483
+ );
484
+
485
+ expect(requestPermissionDecisionFromUi).toHaveBeenCalled();
486
+ expect(events.emit).not.toHaveBeenCalledWith(
487
+ "permissions:ui_prompt",
488
+ expect.anything(),
489
+ );
490
+ });
491
+
492
+ test("does not show a dialog or emit when there is no active UI", async () => {
493
+ const events = {
494
+ emit: vi.fn(),
495
+ on: vi.fn().mockReturnValue(() => undefined),
496
+ };
497
+ const requestPermissionDecisionFromUi = vi.fn();
498
+
499
+ await confirmPermission(
500
+ {
501
+ hasUI: false,
502
+ sessionManager: {
503
+ getSessionDir: vi.fn().mockReturnValue(null),
504
+ },
505
+ } as unknown as ExtensionContext,
506
+ "Allow git push?",
507
+ {
508
+ forwardingDir: "/tmp/forwarding",
509
+ subagentSessionsDir: "/tmp/subagents",
510
+ events,
511
+ logger: { writeReviewLog: vi.fn(), writeDebugLog: vi.fn() },
512
+ writeReviewLog: vi.fn(),
513
+ requestPermissionDecisionFromUi,
514
+ shouldAutoApprove: () => false,
515
+ },
516
+ );
517
+
518
+ expect(events.emit).not.toHaveBeenCalledWith(
519
+ "permissions:ui_prompt",
520
+ expect.anything(),
521
+ );
522
+ expect(requestPermissionDecisionFromUi).not.toHaveBeenCalled();
523
+ });
524
+ });
@@ -53,6 +53,7 @@ function makeDeps(
53
53
  writeReviewLog: vi.fn(),
54
54
  subagentSessionsDir: "/sessions/subagents",
55
55
  forwardingDir: "/sessions/permission-forwarding",
56
+ events: { emit: vi.fn(), on: vi.fn().mockReturnValue(() => undefined) },
56
57
  requestPermissionDecisionFromUi: vi
57
58
  .fn()
58
59
  .mockResolvedValue({ approved: true, state: "approved" }),
@@ -71,8 +72,13 @@ describe("PermissionPrompter", () => {
71
72
 
72
73
  describe("yolo-mode auto-approve", () => {
73
74
  it("returns approved without calling confirmPermission when yoloMode is true", async () => {
75
+ const events = {
76
+ emit: vi.fn(),
77
+ on: vi.fn().mockReturnValue(() => undefined),
78
+ };
74
79
  const deps = makeDeps({
75
80
  getConfig: () => ({ ...DEFAULT_EXTENSION_CONFIG, yoloMode: true }),
81
+ events,
76
82
  });
77
83
  const prompter = new PermissionPrompter(deps);
78
84
 
@@ -84,6 +90,10 @@ describe("PermissionPrompter", () => {
84
90
  autoApproved: true,
85
91
  });
86
92
  expect(mockConfirmPermission).not.toHaveBeenCalled();
93
+ expect(events.emit).not.toHaveBeenCalledWith(
94
+ "permissions:ui_prompt",
95
+ expect.anything(),
96
+ );
87
97
  });
88
98
 
89
99
  it("logs permission_request.auto_approved in yolo mode", async () => {
@@ -154,6 +164,90 @@ describe("PermissionPrompter", () => {
154
164
  );
155
165
  });
156
166
 
167
+ it("emits a UI prompt event with normalized surface and value when the session has UI", async () => {
168
+ const events = {
169
+ emit: vi.fn(),
170
+ on: vi.fn().mockReturnValue(() => undefined),
171
+ };
172
+ mockConfirmPermission.mockResolvedValue({
173
+ approved: true,
174
+ state: "approved",
175
+ });
176
+ const deps = makeDeps({ events });
177
+ const prompter = new PermissionPrompter(deps);
178
+
179
+ await prompter.prompt(
180
+ makeCtx(true),
181
+ makeDetails({
182
+ toolName: "bash",
183
+ command: "git push",
184
+ toolInputPreview: "git push",
185
+ }),
186
+ );
187
+
188
+ expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
189
+ requestId: "req-123",
190
+ source: "tool_call",
191
+ surface: "bash",
192
+ value: "git push",
193
+ agentName: "test-agent",
194
+ message: "Allow read?",
195
+ forwarding: null,
196
+ });
197
+ });
198
+
199
+ it("normalizes skill UI prompt events to the skill surface", async () => {
200
+ const events = {
201
+ emit: vi.fn(),
202
+ on: vi.fn().mockReturnValue(() => undefined),
203
+ };
204
+ mockConfirmPermission.mockResolvedValue({
205
+ approved: true,
206
+ state: "approved",
207
+ });
208
+ const deps = makeDeps({ events });
209
+ const prompter = new PermissionPrompter(deps);
210
+
211
+ await prompter.prompt(
212
+ makeCtx(true),
213
+ makeDetails({
214
+ source: "skill_input",
215
+ toolName: undefined,
216
+ skillName: "deploy-helper",
217
+ }),
218
+ );
219
+
220
+ expect(events.emit).toHaveBeenCalledWith("permissions:ui_prompt", {
221
+ requestId: "req-123",
222
+ source: "skill_input",
223
+ surface: "skill",
224
+ value: "deploy-helper",
225
+ agentName: "test-agent",
226
+ message: "Allow read?",
227
+ forwarding: null,
228
+ });
229
+ });
230
+
231
+ it("does not emit a UI prompt event when the session has no UI", async () => {
232
+ const events = {
233
+ emit: vi.fn(),
234
+ on: vi.fn().mockReturnValue(() => undefined),
235
+ };
236
+ mockConfirmPermission.mockResolvedValue({
237
+ approved: true,
238
+ state: "approved",
239
+ });
240
+ const deps = makeDeps({ events });
241
+ const prompter = new PermissionPrompter(deps);
242
+
243
+ await prompter.prompt(makeCtx(false), makeDetails());
244
+
245
+ expect(events.emit).not.toHaveBeenCalledWith(
246
+ "permissions:ui_prompt",
247
+ expect.anything(),
248
+ );
249
+ });
250
+
157
251
  it("logs permission_request.approved when confirmPermission returns approved", async () => {
158
252
  const writeReviewLog = vi.fn();
159
253
  mockConfirmPermission.mockResolvedValue({
@@ -245,6 +339,30 @@ describe("PermissionPrompter", () => {
245
339
  expect.any(String),
246
340
  expect.anything(),
247
341
  { sessionLabel: "Yes, for 'read' tool" },
342
+ { source: "tool_call", surface: "read", value: "read" },
343
+ );
344
+ });
345
+
346
+ it("passes the display fields (source/surface/value) to confirmPermission", async () => {
347
+ mockConfirmPermission.mockResolvedValue({
348
+ approved: true,
349
+ state: "approved",
350
+ });
351
+ const deps = makeDeps();
352
+ const prompter = new PermissionPrompter(deps);
353
+ const details = makeDetails({
354
+ toolName: "bash",
355
+ command: "git push",
356
+ });
357
+
358
+ await prompter.prompt(makeCtx(false), details);
359
+
360
+ expect(mockConfirmPermission).toHaveBeenCalledWith(
361
+ expect.anything(),
362
+ expect.any(String),
363
+ expect.anything(),
364
+ undefined,
365
+ { source: "tool_call", surface: "bash", value: "git push" },
248
366
  );
249
367
  });
250
368
 
@@ -263,6 +381,7 @@ describe("PermissionPrompter", () => {
263
381
  expect.any(String),
264
382
  expect.anything(),
265
383
  undefined,
384
+ { source: "tool_call", surface: "read", value: "read" },
266
385
  );
267
386
  });
268
387
 
@@ -282,6 +401,7 @@ describe("PermissionPrompter", () => {
282
401
  "Allow bash: git status?",
283
402
  expect.anything(),
284
403
  undefined,
404
+ { source: "tool_call", surface: "read", value: "read" },
285
405
  );
286
406
  });
287
407
  });