pi-smart-voice-notify 0.3.4 → 0.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.4.0] - 2026-04-30
4
+
5
+ ### Added
6
+ - Detect agent turn failures reported at `agent_end` and send error notifications instead of completion alerts.
7
+
8
+ ### Changed
9
+ - Renamed bundled notification sound assets to stable lowercase filenames and migrate legacy bundled sound paths automatically.
10
+ - Updated `@mariozechner/pi-*` peer dependency ranges to `^0.70.6` and synchronized package lock metadata.
11
+
12
+ ## [0.3.5] - 2026-04-27
13
+
14
+ ### Fixed
15
+ - Permission notifications now require an authoritative `pi-permission-system:permission-request` waiting event before alerts or reminders are queued, preventing permission-looking `tool_call` and `tool_result` payloads from producing false permission alerts.
16
+ - Forwarded permission notifications now watch only the active session's scoped request/response directories, require matching `targetSessionId`, and ignore unscoped legacy paths, stale requests, resolved requests, malformed files, and mismatched request filenames.
17
+
3
18
  ## [0.3.4] - 2026-04-25
4
19
 
5
20
  ### Added
package/README.md CHANGED
@@ -19,7 +19,7 @@ Windows-optimized smart notification extension for the Pi coding agent.
19
19
 
20
20
  - **Intelligent event detection**
21
21
  - Task completion (idle)
22
- - Direct permission blocks plus forwarded subagent permission requests
22
+ - Authoritative permission-system wait events plus current-session forwarded subagent permission requests
23
23
  - Questions requiring input (when custom `question` tool is loaded)
24
24
  - Errors
25
25
 
@@ -125,17 +125,17 @@ A starter template is provided in `config/config.example.json`. On startup, the
125
125
  | Option | Type | Default | Description |
126
126
  |--------|------|---------|-------------|
127
127
  | `enableIdleNotification` | boolean | `true` | Notify when agent finishes a task |
128
- | `enablePermissionNotification` | boolean | `true` | Notify on permission blocks |
129
- | `enableForwardedPermissionWatcher` | boolean | `true` | Watch forwarded permission request files and notify when new requests arrive |
128
+ | `enablePermissionNotification` | boolean | `true` | Notify when the permission system reports requests waiting for approval |
129
+ | `enableForwardedPermissionWatcher` | boolean | `true` | Watch current-session forwarded permission request/response files and notify when new requests arrive |
130
130
  | `includeForwardedPermissionAgentName` | boolean | `true` | Include sanitized requester agent name in forwarded permission notification text |
131
- | `watchLegacyForwardedPermissionPath` | boolean | `true` | Also watch the legacy forwarded-permission directory (default: `~/.pi/agent/permission-forwarding/requests`, respects `PI_CODING_AGENT_DIR`) when present |
131
+ | `watchLegacyForwardedPermissionPath` | boolean | `true` | Retained for config compatibility; unscoped legacy forwarded-permission paths are ignored because they cannot prove the current target session |
132
132
  | `enableQuestionNotification` | boolean | `true` | Notify when agent asks a question* |
133
133
  | `enableErrorNotification` | boolean | `true` | Notify on errors |
134
134
  | `suppressIdleAfterError` | boolean | `true` | Skip idle notification if turn had errors |
135
135
 
136
136
  *Question notifications only work when a custom `question` tool is loaded.
137
137
 
138
- Forwarded permission watcher notifications use privacy-safe text and never include raw forwarded `message` content.
138
+ Forwarded permission watcher notifications use privacy-safe text, require the request `targetSessionId` to match the active Pi session, and never include raw forwarded `message` content.
139
139
 
140
140
  ### Reminder Settings
141
141
 
@@ -166,10 +166,10 @@ Forwarded permission watcher notifications use privacy-safe text and never inclu
166
166
 
167
167
  | Option | Type | Default |
168
168
  |--------|------|---------|
169
- | `idleSoundFile` | string | `"assets/Soft-high-tech-notification-sound-effect.mp3"` |
170
- | `permissionSoundFile` | string | `"assets/Machine-alert-beep-sound-effect.mp3"` |
171
- | `questionSoundFile` | string | `"assets/Machine-alert-beep-sound-effect.mp3"` |
172
- | `errorSoundFile` | string | `"assets/Machine-alert-beep-sound-effect.mp3"` |
169
+ | `idleSoundFile` | string | `"assets/soft-notification.mp3"` |
170
+ | `permissionSoundFile` | string | `"assets/attention-alert.mp3"` |
171
+ | `questionSoundFile` | string | `"assets/attention-alert.mp3"` |
172
+ | `errorSoundFile` | string | `"assets/attention-alert.mp3"` |
173
173
 
174
174
  Paths can be absolute or relative to the extension directory.
175
175
 
@@ -94,10 +94,10 @@
94
94
  "openaiTtsSpeed": 1,
95
95
 
96
96
  "_comment_sounds": "Direct sound file fallbacks.",
97
- "idleSoundFile": "assets/Soft-high-tech-notification-sound-effect.mp3",
98
- "permissionSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
99
- "questionSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
100
- "errorSoundFile": "assets/Machine-alert-beep-sound-effect.mp3",
97
+ "idleSoundFile": "assets/soft-notification.mp3",
98
+ "permissionSoundFile": "assets/attention-alert.mp3",
99
+ "questionSoundFile": "assets/attention-alert.mp3",
100
+ "errorSoundFile": "assets/attention-alert.mp3",
101
101
 
102
102
  "_comment_sound_theme": "Sound theme settings.",
103
103
  "themePath": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-smart-voice-notify",
3
- "version": "0.3.4",
3
+ "version": "0.4.0",
4
4
  "description": "Windows-optimized smart voice, sound, and desktop notifications for Pi coding agent.",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -19,7 +19,7 @@
19
19
  "scripts": {
20
20
  "build": "npx --yes -p typescript@5.7.3 -p @types/node@20.17.57 tsc -p tsconfig.json --noEmit",
21
21
  "lint": "npm run build",
22
- "test": "node --experimental-strip-types --test src/abortable-command.test.ts src/reminder-playback.test.ts src/index.test.ts",
22
+ "test": "node --experimental-strip-types --test src/abortable-command.test.ts src/reminder-playback.test.ts src/permission-forwarding-watcher.test.ts src/index.test.ts",
23
23
  "check": "npm run build && npm run test"
24
24
  },
25
25
  "keywords": [
@@ -61,8 +61,8 @@
61
61
  ]
62
62
  },
63
63
  "peerDependencies": {
64
- "@mariozechner/pi-coding-agent": "^0.70.2",
65
- "@mariozechner/pi-tui": "^0.70.2"
64
+ "@mariozechner/pi-coding-agent": "^0.70.6",
65
+ "@mariozechner/pi-tui": "^0.70.6"
66
66
  },
67
67
  "dependencies": {
68
68
  "node-notifier": "^10.0.1"
@@ -70,7 +70,7 @@ const DEFAULT_TEMPLATES: Record<CoreAIEventType, string[]> = {
70
70
  permission: [
71
71
  "Permission needed for {projectName}. Please approve in the terminal.",
72
72
  "I need your approval to continue {projectName}.",
73
- "Action blocked. Please confirm the pending permission request.",
73
+ "Permission approval is pending. Please confirm the request.",
74
74
  ],
75
75
  question: [
76
76
  "I need your input for {projectName}. Please check the terminal.",
@@ -45,7 +45,7 @@ const DEFAULT_WEBHOOK_EVENTS: NotificationType[] = [...NOTIFICATION_TYPE_VALUES]
45
45
 
46
46
  export const INLINE_NOTIFY_TEXT: Record<NotificationType, string> = {
47
47
  idle: "✅ Agent finished its current task.",
48
- permission: "⚠️ Action blocked by permission policy.",
48
+ permission: "⚠️ Permission approval is pending.",
49
49
  question: "❓ Agent needs your input.",
50
50
  error: "❌ Agent encountered an error.",
51
51
  };
@@ -108,15 +108,6 @@ export const MESSAGE_LIBRARY: Record<NotificationType, MessageSet> = {
108
108
  },
109
109
  };
110
110
 
111
- export const PERMISSION_HINTS = [
112
- "permission",
113
- "not permitted",
114
- "requires approval",
115
- "approval",
116
- "user denied",
117
- "blocked by",
118
- ];
119
-
120
111
  export const QUESTION_HINTS = ["question", "need your input", "please answer", "requires your input"];
121
112
 
122
113
  export const DEFAULT_CONFIG: VoiceNotifyConfig = {
@@ -183,10 +174,10 @@ export const DEFAULT_CONFIG: VoiceNotifyConfig = {
183
174
  openaiTtsFormat: "mp3",
184
175
  openaiTtsSpeed: 1,
185
176
 
186
- idleSoundFile: "assets/Soft-high-tech-notification-sound-effect.mp3",
187
- permissionSoundFile: "assets/Machine-alert-beep-sound-effect.mp3",
188
- questionSoundFile: "assets/Machine-alert-beep-sound-effect.mp3",
189
- errorSoundFile: "assets/Machine-alert-beep-sound-effect.mp3",
177
+ idleSoundFile: "assets/soft-notification.mp3",
178
+ permissionSoundFile: "assets/attention-alert.mp3",
179
+ questionSoundFile: "assets/attention-alert.mp3",
180
+ errorSoundFile: "assets/attention-alert.mp3",
190
181
  themePath: "",
191
182
  themeName: "default",
192
183
  themesRootPath: "",
@@ -353,6 +344,34 @@ function stringOrDefault(value: unknown, fallback: string): string {
353
344
  return fallback;
354
345
  }
355
346
 
347
+ const LEGACY_BUNDLED_SOUND_FILES: Record<string, string> = {
348
+ "assets/machine-alert-beep-sound-effect.mp3": "assets/attention-alert.mp3",
349
+ "assets/soft-high-tech-notification-sound-effect.mp3": "assets/soft-notification.mp3",
350
+ };
351
+
352
+ function normalizeSoundFileLookup(value: string): string {
353
+ return value.replaceAll("\\", "/").replace(/^\.\//, "").toLowerCase();
354
+ }
355
+
356
+ function soundFileExists(value: string): boolean {
357
+ return existsSync(isAbsolute(value) ? value : join(CONFIG_DIR, value));
358
+ }
359
+
360
+ function normalizeSoundFile(value: unknown, fallback: string): string {
361
+ const selected = stringOrDefault(value, fallback);
362
+ const lookup = normalizeSoundFileLookup(selected);
363
+ const migrated = LEGACY_BUNDLED_SOUND_FILES[lookup];
364
+ if (migrated) {
365
+ return migrated;
366
+ }
367
+
368
+ if (lookup.startsWith("assets/") && !soundFileExists(selected) && soundFileExists(fallback)) {
369
+ return fallback;
370
+ }
371
+
372
+ return selected;
373
+ }
374
+
356
375
  function stringOrEmpty(value: unknown): string {
357
376
  if (typeof value === "string") {
358
377
  return value.trim();
@@ -755,13 +774,13 @@ export function normalizeConfig(raw: unknown): VoiceNotifyConfig {
755
774
  openaiTtsFormat: stringOrDefault(record.openaiTtsFormat, DEFAULT_CONFIG.openaiTtsFormat),
756
775
  openaiTtsSpeed: clampNumber(record.openaiTtsSpeed, DEFAULT_CONFIG.openaiTtsSpeed, 0.25, 4),
757
776
 
758
- idleSoundFile: stringOrDefault(record.idleSoundFile ?? record.idleSound, DEFAULT_CONFIG.idleSoundFile),
759
- permissionSoundFile: stringOrDefault(
777
+ idleSoundFile: normalizeSoundFile(record.idleSoundFile ?? record.idleSound, DEFAULT_CONFIG.idleSoundFile),
778
+ permissionSoundFile: normalizeSoundFile(
760
779
  record.permissionSoundFile ?? record.permissionSound,
761
780
  DEFAULT_CONFIG.permissionSoundFile,
762
781
  ),
763
- questionSoundFile: stringOrDefault(record.questionSoundFile ?? record.questionSound, DEFAULT_CONFIG.questionSoundFile),
764
- errorSoundFile: stringOrDefault(record.errorSoundFile ?? record.errorSound, DEFAULT_CONFIG.errorSoundFile),
782
+ questionSoundFile: normalizeSoundFile(record.questionSoundFile ?? record.questionSound, DEFAULT_CONFIG.questionSoundFile),
783
+ errorSoundFile: normalizeSoundFile(record.errorSoundFile ?? record.errorSound, DEFAULT_CONFIG.errorSoundFile),
765
784
  themePath: stringOrEmpty(record.themePath ?? record.soundThemeDir ?? record.themeDirectory),
766
785
  themeName: stringOrDefault(record.themeName, DEFAULT_CONFIG.themeName),
767
786
  themesRootPath: stringOrEmpty(record.themesRootPath ?? record.themesRootDirectory),
@@ -998,7 +1017,7 @@ export function isWindows(): boolean {
998
1017
 
999
1018
  export function resolveSoundFile(config: VoiceNotifyConfig, type: NotificationType): string | null {
1000
1019
  const field = SOUND_FILE_FIELD[type];
1001
- const value = config[field];
1020
+ const value = normalizeSoundFile(config[field], DEFAULT_CONFIG[field]);
1002
1021
  if (!value.trim()) {
1003
1022
  return null;
1004
1023
  }
package/src/index.test.ts CHANGED
@@ -61,6 +61,7 @@ class FakeEventBus {
61
61
 
62
62
  class FakePi {
63
63
  private readonly handlers = new Map<string, EventHandler[]>();
64
+ private tools: Array<{ name: string }> = [];
64
65
 
65
66
  public readonly events = new FakeEventBus();
66
67
 
@@ -73,8 +74,12 @@ class FakePi {
73
74
  public registerCommand(): void {
74
75
  }
75
76
 
77
+ public setAvailableTools(tools: Array<{ name: string }>): void {
78
+ this.tools = [...tools];
79
+ }
80
+
76
81
  public getAllTools(): Array<{ name: string }> {
77
- return [];
82
+ return [...this.tools];
78
83
  }
79
84
 
80
85
  public sendMessage(): void {
@@ -397,7 +402,13 @@ test("skipWhenFocused=false still notifies even when the terminal is focused", a
397
402
 
398
403
  await pi.emit("session_start", {}, ctx);
399
404
  await flushAsyncWork();
400
- await pi.emit("tool_call", permissionEvent("call-focus-disabled"), ctx);
405
+ pi.events.emit(
406
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
407
+ permissionSystemEvent("waiting", "permission-focus-disabled", {
408
+ toolCallId: "call-focus-disabled",
409
+ toolName: "write_file",
410
+ }),
411
+ );
401
412
  await flushAsyncWork();
402
413
  await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
403
414
 
@@ -425,7 +436,13 @@ test("skipWhenFocused=true suppresses focused notifications and uses config focu
425
436
 
426
437
  await pi.emit("session_start", {}, ctx);
427
438
  await flushAsyncWork();
428
- await pi.emit("tool_call", permissionEvent("call-focus-enabled"), ctx);
439
+ pi.events.emit(
440
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
441
+ permissionSystemEvent("waiting", "permission-focus-enabled", {
442
+ toolCallId: "call-focus-enabled",
443
+ toolName: "write_file",
444
+ }),
445
+ );
429
446
  await flushAsyncWork();
430
447
  await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
431
448
 
@@ -525,7 +542,13 @@ test("permission reminders use the permission-specific reminder interval", async
525
542
 
526
543
  await pi.emit("session_start", {}, ctx);
527
544
  await flushAsyncWork();
528
- await pi.emit("tool_call", permissionEvent("call-permission-reminder-delay"), ctx);
545
+ pi.events.emit(
546
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
547
+ permissionSystemEvent("waiting", "permission-reminder-delay", {
548
+ toolCallId: "call-permission-reminder-delay",
549
+ toolName: "write_file",
550
+ }),
551
+ );
529
552
  await flushAsyncWork();
530
553
  await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
531
554
 
@@ -539,6 +562,47 @@ test("permission reminders use the permission-specific reminder interval", async
539
562
  assert.equal(countReminderCalls(ttsCalls), 1);
540
563
  });
541
564
 
565
+ test("blocked tool_call events do not trigger permission notifications without permission-system waiting state", async (t) => {
566
+ disableFocusDetection(t);
567
+ useMockClock(t);
568
+
569
+ const { ctx, pi, ttsCalls } = createHarness();
570
+
571
+ await pi.emit("session_start", {}, ctx);
572
+ await flushAsyncWork();
573
+ await pi.emit("tool_call", permissionEvent("call-no-authoritative-wait"), ctx);
574
+ await flushAsyncWork();
575
+ await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
576
+
577
+ assert.equal(immediateNotificationCalls(ttsCalls).length, 0);
578
+ assert.equal(countReminderCalls(ttsCalls), 0);
579
+ });
580
+
581
+ test("permission-looking tool_result errors do not trigger permission notifications without permission-system waiting state", async (t) => {
582
+ disableFocusDetection(t);
583
+ useMockClock(t);
584
+
585
+ const { ctx, pi, ttsCalls } = createHarness();
586
+
587
+ await pi.emit("session_start", {}, ctx);
588
+ await flushAsyncWork();
589
+ await pi.emit(
590
+ "tool_result",
591
+ {
592
+ toolCallId: "result-no-authoritative-wait",
593
+ toolName: "permission_guard",
594
+ isError: true,
595
+ content: [{ type: "text", text: "Requires approval from the user before continuing." }],
596
+ },
597
+ ctx,
598
+ );
599
+ await flushAsyncWork();
600
+ await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
601
+
602
+ assert.equal(immediateNotificationCalls(ttsCalls).length, 0);
603
+ assert.equal(countReminderCalls(ttsCalls), 0);
604
+ });
605
+
542
606
  test("permission-system waiting events trigger a permission notification and cancel on resolution", async (t) => {
543
607
  disableFocusDetection(t);
544
608
  useMockClock(t);
@@ -605,9 +669,21 @@ test("tool_execution_start only cancels the resolved permission reminder flow",
605
669
 
606
670
  await pi.emit("session_start", {}, ctx);
607
671
  await flushAsyncWork();
608
- await pi.emit("tool_call", permissionEvent("call-a"), ctx);
672
+ pi.events.emit(
673
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
674
+ permissionSystemEvent("waiting", "permission-call-a", {
675
+ toolCallId: "call-a",
676
+ toolName: "write_file",
677
+ }),
678
+ );
609
679
  await flushAsyncWork();
610
- await pi.emit("tool_call", permissionEvent("call-b"), ctx);
680
+ pi.events.emit(
681
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
682
+ permissionSystemEvent("waiting", "permission-call-b", {
683
+ toolCallId: "call-b",
684
+ toolName: "write_file",
685
+ }),
686
+ );
611
687
  await flushAsyncWork();
612
688
 
613
689
  assert.equal(countReminderCalls(ttsCalls), 0);
@@ -640,9 +716,21 @@ test("tool_result resolution keeps another permission reminder active while drop
640
716
 
641
717
  await pi.emit("session_start", {}, ctx);
642
718
  await flushAsyncWork();
643
- await pi.emit("tool_call", permissionEvent("call-a"), ctx);
719
+ pi.events.emit(
720
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
721
+ permissionSystemEvent("waiting", "permission-result-call-a", {
722
+ toolCallId: "call-a",
723
+ toolName: "write_file",
724
+ }),
725
+ );
644
726
  await flushAsyncWork();
645
- await pi.emit("tool_call", permissionEvent("call-b"), ctx);
727
+ pi.events.emit(
728
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
729
+ permissionSystemEvent("waiting", "permission-result-call-b", {
730
+ toolCallId: "call-b",
731
+ toolName: "write_file",
732
+ }),
733
+ );
646
734
  await flushAsyncWork();
647
735
  await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
648
736
  await tickAndFlush(1_000);
@@ -662,7 +750,7 @@ test("tool_result resolution keeps another permission reminder active while drop
662
750
  assert.equal(countReminderCalls(ttsCalls), 1);
663
751
  });
664
752
 
665
- test("tool_result with the same toolCallId still classifies after a permission-blocked tool_call", async (t) => {
753
+ test("tool_result with the same toolCallId still classifies after a permission-system waiting event", async (t) => {
666
754
  disableFocusDetection(t);
667
755
  useMockClock(t);
668
756
 
@@ -672,7 +760,13 @@ test("tool_result with the same toolCallId still classifies after a permission-b
672
760
 
673
761
  await pi.emit("session_start", {}, ctx);
674
762
  await flushAsyncWork();
675
- await pi.emit("tool_call", permissionEvent("call-shared"), ctx);
763
+ pi.events.emit(
764
+ PERMISSION_SYSTEM_EVENT_CHANNEL,
765
+ permissionSystemEvent("waiting", "permission-shared", {
766
+ toolCallId: "call-shared",
767
+ toolName: "write_file",
768
+ }),
769
+ );
676
770
  await flushAsyncWork();
677
771
  await tickAndFlush(PERMISSION_BATCH_WINDOW_MS);
678
772
  assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
@@ -692,6 +786,79 @@ test("tool_result with the same toolCallId still classifies after a permission-b
692
786
  assert.equal(immediateNotificationCalls(ttsCalls).length, 2);
693
787
  });
694
788
 
789
+ test("agent_end triggers an idle notification when idle notifications are enabled", async (t) => {
790
+ disableFocusDetection(t);
791
+ useMockClock(t);
792
+
793
+ const { ctx, pi, ttsCalls } = createHarness({
794
+ enableIdleNotification: true,
795
+ reminderEnabled: false,
796
+ });
797
+
798
+ await pi.emit("session_start", {}, ctx);
799
+ await flushAsyncWork();
800
+ await pi.emit("agent_end", {}, ctx);
801
+ await flushAsyncWork();
802
+
803
+ assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
804
+ assert.equal(countReminderCalls(ttsCalls), 0);
805
+ });
806
+
807
+ test("question-classified tool_result triggers a question notification when the question tool is available", async (t) => {
808
+ disableFocusDetection(t);
809
+ useMockClock(t);
810
+
811
+ const { ctx, pi, ttsCalls } = createHarness({
812
+ enableQuestionNotification: true,
813
+ reminderEnabled: false,
814
+ });
815
+ pi.setAvailableTools([{ name: "question" }]);
816
+
817
+ await pi.emit("session_start", {}, ctx);
818
+ await flushAsyncWork();
819
+ await pi.emit(
820
+ "tool_result",
821
+ {
822
+ toolCallId: "call-question-available",
823
+ toolName: "custom_tool",
824
+ isError: false,
825
+ content: [{ type: "text", text: "This request requires your input before continuing." }],
826
+ },
827
+ ctx,
828
+ );
829
+ await flushAsyncWork();
830
+
831
+ assert.equal(immediateNotificationCalls(ttsCalls).length, 1);
832
+ assert.equal(countReminderCalls(ttsCalls), 0);
833
+ });
834
+
835
+ test("question-classified tool_result does not notify when the question tool is unavailable", async (t) => {
836
+ disableFocusDetection(t);
837
+ useMockClock(t);
838
+
839
+ const { ctx, pi, ttsCalls } = createHarness({
840
+ enableQuestionNotification: true,
841
+ reminderEnabled: false,
842
+ });
843
+
844
+ await pi.emit("session_start", {}, ctx);
845
+ await flushAsyncWork();
846
+ await pi.emit(
847
+ "tool_result",
848
+ {
849
+ toolCallId: "call-question-unavailable",
850
+ toolName: "custom_tool",
851
+ isError: false,
852
+ content: [{ type: "text", text: "This request requires your input before continuing." }],
853
+ },
854
+ ctx,
855
+ );
856
+ await flushAsyncWork();
857
+
858
+ assert.equal(immediateNotificationCalls(ttsCalls).length, 0);
859
+ assert.equal(countReminderCalls(ttsCalls), 0);
860
+ });
861
+
695
862
  test("forwarded permission resolution cancels a queued reminder before it fires", async (t) => {
696
863
  disableFocusDetection(t);
697
864
  useMockClock(t);