rohitsar567 Claude Opus 4.7 (1M context) commited on
Commit
52e5881
·
1 Parent(s): 1344492

fix(closure+hydration+voice+admin): KI-248..KI-251 — V1-V4 bundle from Playwright smoke

Browse files

V1 — recommendation closure ("I lost my train of thought"):
ROOT CAUSE was a coercer/null-guard collision, NOT MAX_ITERATIONS or prompt
length. Z6 prompt told Gemini → save_profile_field(health_conditions, "none"),
_coerce_health_conditions stripped "none" → [], save_profile_field's KI-091
null-overwrite guard rejected [] as normalized_empty, profile stayed
incomplete forever, retrieve looped, MAX_ITERATIONS=5 exhausted, defensive
"lost my train of thought" fired.
Three-angle fix:
- (D primary) brain_tools._coerce_health_conditions: pure-negation tokens
("none", "no", "healthy", "n/a") now collapse to sentinel ["none"]
(non-empty list) instead of []. Mixed input ("diabetes, none") still
drops the negation noise. Downstream consumers verified safe.
- (C) brain_tools.retrieve_policies profile_incomplete return now includes
action_required="ask_user_for" + field=<first_missing> + exact_question
so Gemini relays the question verbatim instead of looping.
- (A) single_brain MAX_ITERATIONS 5→8 (headroom for chained save+retrieve)
+ new _synthesise_fallback(profile) helper replaces the generic "lost
my train of thought" with a profile-aware question for the first missing
slot, or a confirmation prompt if all 7 captured.

V2 — turn_idx admin column showing "—":
Backend wiring was correct end-to-end (session.turn_idx increments,
record_policy_event persists turn_idx in JSON). The break was at the API
boundary: GET /api/admin/recommendation-history projection dict in
admin.py omitted turn_idx entirely. Plus latent field-name mismatch:
frontend reads e.conversation_turn, backend wrote turn_idx. Fix: one line
added to the projection — "conversation_turn": entry.get("turn_idx") —
bridges both at once. Pre-Y2 events keep showing "—" (correct, no
stamping at write time); new events render the turn number.

V3 — React #418 hydration mismatch on home page:
useStreamingVoice.ts:316 isSupported defaulted false during SSR (no window
to read SpeechRecognition constructor) but true on Chrome/Safari CSR.
Different boolean drove a different pill element (<span> "Mic blocked" vs
<button> "Voice off") between SSR and CSR. Fix in page.tsx: mounted flag
(false on SSR + first CSR render, true after useEffect) gates
micPermissionDenied. SSR HTML now contains the same "Voice off" pill that
CSR renders, eliminating mismatch.

V4 — voice "green pill, zero audio" silent failure:
When getUserMedia fails (permission denied, no mic, OverconstrainedError,
etc.) the pill still said "Voice on" with zero capture — matched the
documented [[feedback_voice_silent_failure_modes]] pattern.
Fix: useStreamingVoice ensureAudioCapture catch block now emits
onVoiceError("mic_permission_denied"), flips wantRunningRef=false, halts
SR auto-restart. start() awaits ensureAudioCapture and skips safeStart on
denial. page.tsx onVoiceError handler also calls setVoicePermDenied(true)
+ setVoiceEnabled(false) to revert the pill; 4th banner case renders red
"Microphone access denied — click the lock icon and allow, then reload".
DOMException mapping: NotAllowedError / SecurityError / NotFoundError /
OverconstrainedError / NotReadableError / AbortError / unknown all
collapse to mic_permission_denied (same remediation).

Verification: py_compile clean (admin/brain_tools/single_brain), tsc
clean, next build succeeds. Playwright headless smoke with shimmed
NotAllowedError confirmed banner renders within 8s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

backend/brain_tools.py CHANGED
@@ -207,15 +207,51 @@ async def retrieve_policies(
207
  if getattr(profile, slot, None) in (None, "", [])
208
  ]
209
  if missing:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  return {
211
  "chunks": [],
212
  "count": 0,
213
  "error": "profile_incomplete",
214
  "missing_slots": missing,
 
 
 
 
 
 
215
  "instruction": (
216
  f"Profile is incomplete — missing: {', '.join(missing)}. "
217
- "Do NOT make a recommendation. Ask the user for the "
218
- "missing slot(s) before calling retrieve_policies again."
 
 
219
  ),
220
  }
221
 
@@ -490,7 +526,21 @@ def _coerce_existing_cover(value: Any) -> Optional[int]:
490
 
491
 
492
  def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
493
- """Always return list[str] lowercase, stripped, empties dropped."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
494
  if value is None:
495
  return None
496
  if isinstance(value, str):
@@ -501,9 +551,15 @@ def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
501
  else:
502
  items = [str(value).strip()]
503
  cleaned = [t.lower() for t in items if t]
504
- # "none" / "no" empty list (user explicitly said no conditions).
505
- cleaned = [t for t in cleaned if t not in {"none", "no", "n/a", "na", "nil"}]
506
- return cleaned
 
 
 
 
 
 
507
 
508
 
509
  __all__ = [
 
207
  if getattr(profile, slot, None) in (None, "", [])
208
  ]
209
  if missing:
210
+ # KI-Z6-NONE follow-up (2026-05-15): make the response
211
+ # extremely directive so Gemini doesn't burn an iteration
212
+ # re-trying retrieve_policies on the same incomplete profile.
213
+ # Provide an exact_question string the model can literally
214
+ # relay to the user for the first missing slot.
215
+ _SLOT_QUESTIONS = {
216
+ "name": "What's your name?",
217
+ "age": "How old are you?",
218
+ "dependents": (
219
+ "Who would you like the cover to include — just you, "
220
+ "or spouse / kids / parents?"
221
+ ),
222
+ "location_tier": "Which city do you live in?",
223
+ "income_band": (
224
+ "Roughly what's your annual household income — "
225
+ "under 10 lakh, 10-25 lakh, or above 25 lakh?"
226
+ ),
227
+ "primary_goal": (
228
+ "Is this your first health policy, an upgrade, for "
229
+ "tax planning, or to find a cheaper option?"
230
+ ),
231
+ "health_conditions": (
232
+ "Do you or your family have any pre-existing health "
233
+ "conditions like diabetes, BP, or thyroid? If none, "
234
+ "just say no."
235
+ ),
236
+ }
237
+ first = missing[0]
238
  return {
239
  "chunks": [],
240
  "count": 0,
241
  "error": "profile_incomplete",
242
  "missing_slots": missing,
243
+ "action_required": "ask_user_for",
244
+ "field": first,
245
+ "exact_question": _SLOT_QUESTIONS.get(
246
+ first,
247
+ f"Could you share your {first.replace('_', ' ')}?",
248
+ ),
249
  "instruction": (
250
  f"Profile is incomplete — missing: {', '.join(missing)}. "
251
+ "Do NOT call retrieve_policies again this turn. Do NOT "
252
+ "retry save_profile_field for the same field. Emit a "
253
+ "TEXT reply that asks the user the `exact_question` "
254
+ "above verbatim."
255
  ),
256
  }
257
 
 
526
 
527
 
528
  def _coerce_health_conditions(value: Any) -> Optional[list[str]]:
529
+ """Always return list[str] lowercase, stripped, empties dropped.
530
+
531
+ KI-Z6-NONE (2026-05-15): "none" / "no" / "n/a" — used to be stripped to
532
+ `[]`, but downstream `save_profile_field` then hits the KI-091 null-
533
+ overwrite guard (`normalized in (None, "", [])`) and refuses to persist
534
+ the slot. Result: profile.health_conditions stays empty forever,
535
+ `_profile_complete` returns False, retrieve_policies returns
536
+ profile_incomplete, the brain loops, MAX_ITERATIONS exhausts, the bot
537
+ emits "Sorry — I lost my train of thought" (W1 Turn 3 live blocker).
538
+
539
+ Fix: keep the explicit-negation sentinel `["none"]` so:
540
+ • the slot is non-empty → _profile_complete=True → retrieve fires
541
+ • downstream consumers can still detect "no PED" via the literal
542
+ token `"none"` in the list (callers already lowercase-compare).
543
+ """
544
  if value is None:
545
  return None
546
  if isinstance(value, str):
 
551
  else:
552
  items = [str(value).strip()]
553
  cleaned = [t.lower() for t in items if t]
554
+ # Explicit-negation tokens collapse to the canonical sentinel
555
+ # `["none"]` rather than `[]` so the slot is captured, not blanked.
556
+ _NEGATION = {"none", "no", "n/a", "na", "nil", "nothing", "healthy"}
557
+ if cleaned and all(t in _NEGATION for t in cleaned):
558
+ return ["none"]
559
+ # Mixed input ("diabetes, none") — drop the negation noise, keep real
560
+ # conditions.
561
+ real = [t for t in cleaned if t not in _NEGATION]
562
+ return real
563
 
564
 
565
  __all__ = [
backend/single_brain.py CHANGED
@@ -54,7 +54,13 @@ PER_CALL_TIMEOUT_SEC = 25.0
54
 
55
  # Max iterations of the tool-call loop. Prevents runaway tool-call cycles
56
  # where the LLM keeps calling save_profile_field on the same value.
57
- MAX_ITERATIONS = 5
 
 
 
 
 
 
58
 
59
  # Transient-error retry policy (2026-05-15 / KI-singlebrain-503).
60
  # Live HF Space logs (rohitsar567/InsuranceBot, 2026-05-15 08:15Z) show
@@ -457,6 +463,61 @@ def _detect_language(user_text: str) -> str:
457
  return "en"
458
 
459
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
460
  def _classify_intent(user_text: str, tool_calls_made: list[str]) -> str:
461
  """Best-effort intent label for logging only. Single-brain doesn't
462
  route on intent — but the legacy `TurnResult.intent` field is logged
@@ -933,16 +994,10 @@ async def handle_turn(
933
  "single_brain hit MAX_ITERATIONS=%d (tool_calls=%s)",
934
  MAX_ITERATIONS, tool_calls_made,
935
  )
936
- last_text = (
937
- last_text
938
- or "Let me pause for a second — could you tell me a bit more about "
939
- "what you're looking for, so I can give you a clean recommendation?"
940
- )
941
 
942
  # Build TurnResult.
943
- reply_text = last_text or (
944
- "Sorry — I lost my train of thought there. Could you say that again?"
945
- )
946
 
947
  # Bug C secondary defense — log a WARNING if the reply name-drops an
948
  # insurer/product brand even though no retrieve_policies result was
 
54
 
55
  # Max iterations of the tool-call loop. Prevents runaway tool-call cycles
56
  # where the LLM keeps calling save_profile_field on the same value.
57
+ # KI-Z6-NONE (2026-05-15): bumped 5 → 8 after W1 Turn 3 live blocker.
58
+ # The Z6 "no medical issues" path used to need: save(health=none) +
59
+ # retrieve → profile_incomplete → save(health=none) + retrieve → loop
60
+ # exhaust. Coercer fix in brain_tools resolves the primary cause; the
61
+ # extra headroom protects against the next variant where Gemini chains
62
+ # 3-4 saves + 2 retrieves on a long pre-recommendation user turn.
63
+ MAX_ITERATIONS = 8
64
 
65
  # Transient-error retry policy (2026-05-15 / KI-singlebrain-503).
66
  # Live HF Space logs (rohitsar567/InsuranceBot, 2026-05-15 08:15Z) show
 
463
  return "en"
464
 
465
 
466
+ _FALLBACK_SLOT_QUESTIONS = {
467
+ "name": "What's your name?",
468
+ "age": "How old are you?",
469
+ "dependents": (
470
+ "Who would you like the cover to include — just you, "
471
+ "or spouse / kids / parents?"
472
+ ),
473
+ "location_tier": "Which city do you live in?",
474
+ "income_band": (
475
+ "Roughly what's your annual household income — under 10 lakh, "
476
+ "10-25 lakh, or above 25 lakh?"
477
+ ),
478
+ "primary_goal": (
479
+ "Is this your first health policy, an upgrade, for tax planning, "
480
+ "or to find a cheaper option?"
481
+ ),
482
+ "health_conditions": (
483
+ "Do you or your family have any pre-existing health conditions "
484
+ "like diabetes, BP, or thyroid? If none, just say no."
485
+ ),
486
+ }
487
+
488
+ _FALLBACK_REQUIRED_SLOTS = (
489
+ "name", "age", "dependents", "location_tier",
490
+ "income_band", "primary_goal", "health_conditions",
491
+ )
492
+
493
+
494
+ def _synthesise_fallback(profile) -> str:
495
+ """KI-Z6-NONE (2026-05-15): replace the legacy 'I lost my train of
496
+ thought' reply with a useful next-question synthesised from the
497
+ profile snapshot. If a slot is still missing, ask for the first
498
+ missing one verbatim. If everything's captured, ask for a recap
499
+ confirmation. Never empty-string — always returns user-visible text.
500
+ """
501
+ try:
502
+ for slot in _FALLBACK_REQUIRED_SLOTS:
503
+ v = getattr(profile, slot, None)
504
+ if v in (None, "", []):
505
+ return _FALLBACK_SLOT_QUESTIONS.get(
506
+ slot,
507
+ f"Could you share your {slot.replace('_', ' ')}?",
508
+ )
509
+ # All slots present — ask the user to confirm before recommending.
510
+ return (
511
+ "Let me confirm what I have before pulling up options — "
512
+ "does this look right, or anything to update?"
513
+ )
514
+ except Exception: # noqa: BLE001 — never fail the fallback
515
+ return (
516
+ "Could you tell me a bit more about what you're looking for "
517
+ "so I can pull up the right options?"
518
+ )
519
+
520
+
521
  def _classify_intent(user_text: str, tool_calls_made: list[str]) -> str:
522
  """Best-effort intent label for logging only. Single-brain doesn't
523
  route on intent — but the legacy `TurnResult.intent` field is logged
 
994
  "single_brain hit MAX_ITERATIONS=%d (tool_calls=%s)",
995
  MAX_ITERATIONS, tool_calls_made,
996
  )
997
+ last_text = last_text or _synthesise_fallback(session.profile)
 
 
 
 
998
 
999
  # Build TurnResult.
1000
+ reply_text = last_text or _synthesise_fallback(session.profile)
 
 
1001
 
1002
  # Bug C secondary defense — log a WARNING if the reply name-drops an
1003
  # insurer/product brand even though no retrieve_policies result was
frontend/src/app/page.tsx CHANGED
@@ -220,6 +220,16 @@ export default function Page() {
220
  const [voiceEnabled, setVoiceEnabled] = useState(false);
221
  const [voiceListening, setVoiceListening] = useState(false);
222
  const [voicePermDenied, setVoicePermDenied] = useState(false);
 
 
 
 
 
 
 
 
 
 
223
  // KI-223 (2026-05-15) — V1.1 / V1.2. Structured voice-error banner state.
224
  // Populated by useStreamingVoice's onVoiceError callback when the hook hits
225
  // a recoverable failure mode that the user can act on (tap to unlock audio,
@@ -288,11 +298,21 @@ export default function Page() {
288
  },
289
  onListening: setVoiceListening,
290
  // KI-223 (2026-05-15) — V1.1 / V1.2. Surface recoverable voice failures
291
- // as a top-right banner. The hook emits one of three error strings; the
292
  // banner state stamps a fresh `ts` so the auto-dismiss timer restarts on
293
  // every new emission (useful when the same error fires twice in a row).
 
 
 
 
 
 
294
  onVoiceError: (error) => {
295
  setVoiceErrorBanner({ type: error, ts: Date.now() });
 
 
 
 
296
  },
297
  });
298
 
@@ -334,7 +354,13 @@ export default function Page() {
334
  const live = {
335
  live: voiceEnabled,
336
  recording: voiceListening,
337
- micPermissionDenied: voicePermDenied || !streamingVoice.isSupported,
 
 
 
 
 
 
338
  setLive: setVoiceEnabled,
339
  };
340
 
@@ -1066,6 +1092,17 @@ export default function Page() {
1066
  {voiceErrorBanner.type === "worklet_failed" && (
1067
  <span>Audio capture failed — please reload the page.</span>
1068
  )}
 
 
 
 
 
 
 
 
 
 
 
1069
  </div>
1070
  <button
1071
  type="button"
 
220
  const [voiceEnabled, setVoiceEnabled] = useState(false);
221
  const [voiceListening, setVoiceListening] = useState(false);
222
  const [voicePermDenied, setVoicePermDenied] = useState(false);
223
+ // Hydration guard — `streamingVoice.isSupported` resolves via `typeof
224
+ // window` inside `useStreamingVoice`, so it's `false` on the SSR pass and
225
+ // (typically) `true` on the client. Rendering JSX that branches on it
226
+ // before hydration completes triggers React error #418 (text content
227
+ // mismatch / hydration failure). We pin the SSR + first-client-render
228
+ // output to the same shape, then flip on a post-mount effect.
229
+ const [mounted, setMounted] = useState(false);
230
+ useEffect(() => {
231
+ setMounted(true);
232
+ }, []);
233
  // KI-223 (2026-05-15) — V1.1 / V1.2. Structured voice-error banner state.
234
  // Populated by useStreamingVoice's onVoiceError callback when the hook hits
235
  // a recoverable failure mode that the user can act on (tap to unlock audio,
 
298
  },
299
  onListening: setVoiceListening,
300
  // KI-223 (2026-05-15) — V1.1 / V1.2. Surface recoverable voice failures
301
+ // as a top-right banner. The hook emits one of four error strings; the
302
  // banner state stamps a fresh `ts` so the auto-dismiss timer restarts on
303
  // every new emission (useful when the same error fires twice in a row).
304
+ // W1 (2026-05-15) — added "mic_permission_denied". When the hook reports
305
+ // a getUserMedia DOMException (NotAllowedError / NotFoundError / etc.)
306
+ // we also revert the pill back to grey + set the legacy permDenied flag
307
+ // so the "🔇 Mic blocked" branch fires. Without these flips the pill
308
+ // stayed green over a dead mic ("green pill, zero audio") until the user
309
+ // manually toggled off — the exact silent-failure mode this fix targets.
310
  onVoiceError: (error) => {
311
  setVoiceErrorBanner({ type: error, ts: Date.now() });
312
+ if (error === "mic_permission_denied") {
313
+ setVoicePermDenied(true);
314
+ setVoiceEnabled(false);
315
+ }
316
  },
317
  });
318
 
 
354
  const live = {
355
  live: voiceEnabled,
356
  recording: voiceListening,
357
+ // Hydration-safe: until the post-mount effect runs, force this to false
358
+ // so the SSR pass and the first client render emit identical JSX (the
359
+ // Voice toggle pill, not the "Mic blocked" badge). After mount we trust
360
+ // the hook's `isSupported` flag (which reads `window.SpeechRecognition`).
361
+ micPermissionDenied: mounted
362
+ ? (voicePermDenied || !streamingVoice.isSupported)
363
+ : false,
364
  setLive: setVoiceEnabled,
365
  };
366
 
 
1092
  {voiceErrorBanner.type === "worklet_failed" && (
1093
  <span>Audio capture failed — please reload the page.</span>
1094
  )}
1095
+ {/* W1 (2026-05-15) — silent getUserMedia denial. The default
1096
+ non-yellow branch above already renders a red background;
1097
+ we just supply the human-readable copy here. Triggered when
1098
+ the user (or browser policy / OS / another app holding the
1099
+ mic) rejects the permission prompt. */}
1100
+ {voiceErrorBanner.type === "mic_permission_denied" && (
1101
+ <span>
1102
+ Microphone access denied. Click the lock icon next to the URL
1103
+ bar and allow microphone, then reload.
1104
+ </span>
1105
+ )}
1106
  </div>
1107
  <button
1108
  type="button"
frontend/src/lib/useStreamingVoice.ts CHANGED
@@ -46,9 +46,19 @@ import {
46
  retryPostTranscribe,
47
  scaleSpeechZcrBand,
48
  AdaptiveNoiseFloor,
49
- type VoiceError,
50
  } from "./voice_resilience";
51
 
 
 
 
 
 
 
 
 
 
 
52
  // KI-189 (2026-05-15) — live-speak barge-in tuning constants.
53
  // The MediaRecorder mic stream IS echo-cancelled by the browser (KI-185
54
  // `getUserMedia` AEC constraints), so the bot's TTS bleed lands at a
@@ -453,8 +463,47 @@ export function useStreamingVoice(
453
  console.debug("[useStreamingVoice] MediaRecorder started", { mime: recorderMimeRef.current });
454
  return true;
455
  } catch (err) {
456
- console.debug("[useStreamingVoice] MediaRecorder init failed falling back to Web Speech only", err);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
457
  recorderActiveRef.current = false;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  return false;
459
  }
460
  }, [pickRecorderMime]);
@@ -837,10 +886,27 @@ export function useStreamingVoice(
837
  }
838
  finalsRef.current = [];
839
  finalsConsumedRef.current = 0;
840
- // Kick off audio capture in parallel with recognition. If it fails we
841
- // degrade to Web Speech-only onend handles the fallback path.
842
- void ensureAudioCapture();
843
- safeStart();
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
844
  }, [isSupported, buildRecognition, safeStart, ensureAudioCapture]);
845
 
846
  const stop = useCallback(() => {
 
46
  retryPostTranscribe,
47
  scaleSpeechZcrBand,
48
  AdaptiveNoiseFloor,
49
+ type VoiceError as VoiceErrorBase,
50
  } from "./voice_resilience";
51
 
52
+ // W1 (2026-05-15) — additive 4th voice-error code. Surfaces a silent
53
+ // `getUserMedia` permission/denial failure (NotAllowedError /
54
+ // NotFoundError / SecurityError / generic DOMException) so page.tsx can
55
+ // render an actionable banner and revert the "Voice on" pill. Kept as a
56
+ // local widening of the base `VoiceError` union from voice_resilience.ts
57
+ // (which we don't touch per scope) — callers see the same
58
+ // `onVoiceError(err: VoiceError) => void` shape, just with one more legal
59
+ // string value.
60
+ export type VoiceError = VoiceErrorBase | "mic_permission_denied";
61
+
62
  // KI-189 (2026-05-15) — live-speak barge-in tuning constants.
63
  // The MediaRecorder mic stream IS echo-cancelled by the browser (KI-185
64
  // `getUserMedia` AEC constraints), so the bot's TTS bleed lands at a
 
463
  console.debug("[useStreamingVoice] MediaRecorder started", { mime: recorderMimeRef.current });
464
  return true;
465
  } catch (err) {
466
+ // W1 (2026-05-15)DOMException name VoiceError mapping.
467
+ // NotAllowedError / SecurityError → user denied or browser-blocked
468
+ // NotFoundError / OverconstrainedError → no usable input device
469
+ // NotReadableError / AbortError → OS-level mic owned by another app
470
+ // anything else (incl. plain Error) → treat as denial so the UI still
471
+ // surfaces an actionable banner
472
+ // ALL of these map to "mic_permission_denied" because the user-visible
473
+ // remediation is the same: open site permissions, allow mic, reload.
474
+ // Returning `false` alone was insufficient — `start()` calls this via
475
+ // `void ensureAudioCapture()` and never sees the rejection, so the pill
476
+ // stayed at "Voice on" with zero mic. Emitting onVoiceError + flipping
477
+ // wantRunningRef false + onListening(false) is the recovery contract.
478
+ const name = (err as { name?: string } | null)?.name ?? "Error";
479
+ console.debug(
480
+ "[useStreamingVoice] getUserMedia / MediaRecorder init failed",
481
+ { name, err },
482
+ );
483
  recorderActiveRef.current = false;
484
+ // `getUserMedia` rejection happens BEFORE we assign mediaStreamRef /
485
+ // mediaRecorderRef, so there's nothing to tear down here. The
486
+ // `wantRunningRef = false` + `onListening(false)` below is enough to
487
+ // halt the SR auto-restart loop. The parent's `enabled = false` flip
488
+ // (driven by the banner code) will run stop() which idempotently
489
+ // re-runs full cleanup.
490
+ // Surface to the page-level banner. Cast through the local widened
491
+ // VoiceError union (W1) so TS accepts the new string code.
492
+ try {
493
+ onVoiceErrorRef.current("mic_permission_denied" as VoiceError);
494
+ } catch {
495
+ /* never let a user-supplied callback crash the hook */
496
+ }
497
+ // Stop the recognition restart loop and reset listening state so the
498
+ // pill doesn't stay green over a dead mic. The parent (page.tsx) is
499
+ // expected to also flip `enabled` back to false on the banner code,
500
+ // which calls our `stop()` and idempotently cleans up.
501
+ wantRunningRef.current = false;
502
+ try {
503
+ onListeningRef.current(false);
504
+ } catch {
505
+ /* ignore */
506
+ }
507
  return false;
508
  }
509
  }, [pickRecorderMime]);
 
886
  }
887
  finalsRef.current = [];
888
  finalsConsumedRef.current = 0;
889
+ // W1 (2026-05-15) gate the SR start on a successful `getUserMedia`.
890
+ // Previously this was `void ensureAudioCapture(); safeStart();` which
891
+ // raced the two in parallel: on a Chromium / iOS Safari permission
892
+ // denial, the recognition started, the pill flipped to "Voice on —
893
+ // just speak", but the mic was dead (zero audio, no banner, no log).
894
+ // By awaiting the capture result and skipping safeStart() on a hard
895
+ // denial, the pill-flip (driven by page.tsx's
896
+ // `onVoiceError("mic_permission_denied")` handler) lands BEFORE
897
+ // recognition kicks off. The ensureAudioCapture catch already sets
898
+ // wantRunningRef=false and emits onVoiceError on its way out.
899
+ void (async () => {
900
+ const ok = await ensureAudioCapture();
901
+ // Hard denial path: capture failed AND ensureAudioCapture reset
902
+ // wantRunningRef. Skip recognition.start — page.tsx will flip
903
+ // `enabled` to false on the banner code, which triggers stop().
904
+ if (!ok && !wantRunningRef.current) return;
905
+ // Soft-degraded path: capture failed but wantRunning is still true
906
+ // (e.g. MediaRecorder mime mismatch on a niche browser). Fall back
907
+ // to Web-Speech-only — onend's restart loop handles the fallback.
908
+ safeStart();
909
+ })();
910
  }, [isSupported, buildRecognition, safeStart, ensureAudioCapture]);
911
 
912
  const stop = useCallback(() => {