From 8097267a6519540c6d4d24e807b1792016b21ca4 Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 31 Jul 2026 13:18:33 -0400 Subject: [PATCH 1/3] fix(desktop): preserve first huddle speech Signed-off-by: John Tennant --- .../huddle/lib/ttsLiveMessages.test.mjs | 32 ++++++++++++++++--- .../features/huddle/lib/ttsLiveMessages.ts | 30 +++++++++++------ .../features/huddle/lib/useTtsSubscription.ts | 13 +++++--- 3 files changed, 56 insertions(+), 19 deletions(-) diff --git a/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs b/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs index 3cd95eb3df..8952d01e49 100644 --- a/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs +++ b/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { classifySpeakableAgentText, - createInitialMembershipGate, + createInitialTtsReadinessGate, createLatestStateGate, createOrderedSpeaker, routeLiveAgentText, @@ -221,21 +221,43 @@ test("a live TTS state event supersedes a delayed bootstrap result", () => { assert.deepEqual(applied, [false]); }); -test("buffers initial live events until membership resolves in order", () => { +test("buffers initial live events until membership and TTS state resolve", () => { const delivered = []; - const gate = createInitialMembershipGate((event) => delivered.push(event)); + const gate = createInitialTtsReadinessGate((event) => delivered.push(event)); gate.push("first"); gate.push("second"); assert.deepEqual(delivered, []); - gate.succeed(); + gate.markMembershipKnown(); + assert.deepEqual(delivered, []); + gate.markTtsStateKnown(); gate.push("third"); assert.deepEqual(delivered, ["first", "second", "third"]); }); +test("preserves the first agent reply when membership resolves before TTS state", async () => { + const spoken = []; + const speaker = createOrderedSpeaker( + async (text) => spoken.push(text), + assert.fail, + false, + ); + const gate = createInitialTtsReadinessGate((text) => + speaker.enqueue(text, 1), + ); + + gate.push("first agent reply"); + gate.markMembershipKnown(); + speaker.setEnabled(true); + gate.markTtsStateKnown(); + await new Promise((resolve) => setTimeout(resolve, 0)); + + assert.deepEqual(spoken, ["first agent reply"]); +}); + test("drops the initial buffer fail-closed when membership lookup fails", () => { const delivered = []; const dropped = []; - const gate = createInitialMembershipGate( + const gate = createInitialTtsReadinessGate( (event) => delivered.push(event), (event) => dropped.push(event), ); diff --git a/desktop/src/features/huddle/lib/ttsLiveMessages.ts b/desktop/src/features/huddle/lib/ttsLiveMessages.ts index b809afee2c..3913d9a41f 100644 --- a/desktop/src/features/huddle/lib/ttsLiveMessages.ts +++ b/desktop/src/features/huddle/lib/ttsLiveMessages.ts @@ -152,30 +152,42 @@ export function createLatestStateGate(apply: (value: T) => void): { }; } -/** Hold live events until the first authoritative agent-membership lookup. */ -export function createInitialMembershipGate( +/** Hold live events until initial membership and TTS state are both known. */ +export function createInitialTtsReadinessGate( deliver: (event: T) => void, drop: (event: T) => void = () => {}, ): { push: (event: T) => void; - succeed: () => void; + markMembershipKnown: () => void; + markTtsStateKnown: () => void; fail: () => void; } { let settled = false; + let membershipKnown = false; + let ttsStateKnown = false; let pending: T[] = []; + const releaseIfReady = () => { + if (settled || !membershipKnown || !ttsStateKnown) return; + settled = true; + const buffered = pending; + pending = []; + for (const event of buffered) deliver(event); + }; return { push(event) { if (settled) deliver(event); else pending.push(event); }, - succeed() { - if (settled) return; - settled = true; - const buffered = pending; - pending = []; - for (const event of buffered) deliver(event); + markMembershipKnown() { + membershipKnown = true; + releaseIfReady(); + }, + markTtsStateKnown() { + ttsStateKnown = true; + releaseIfReady(); }, fail() { + if (settled) return; settled = true; const dropped = pending; pending = []; diff --git a/desktop/src/features/huddle/lib/useTtsSubscription.ts b/desktop/src/features/huddle/lib/useTtsSubscription.ts index c77b2cfab7..1adf78714d 100644 --- a/desktop/src/features/huddle/lib/useTtsSubscription.ts +++ b/desktop/src/features/huddle/lib/useTtsSubscription.ts @@ -5,7 +5,7 @@ import * as React from "react"; import { buildHuddleTtsLiveFilter } from "@/shared/api/relayChannelFilters"; import { relayClient } from "@/shared/api/relayClient"; import { - createInitialMembershipGate, + createInitialTtsReadinessGate, createLatestStateGate, createOrderedSpeaker, routeLiveAgentText, @@ -114,7 +114,7 @@ export function useTtsSubscription( ); } }; - const initialMembershipGate = createInitialMembershipGate( + const initialReadinessGate = createInitialTtsReadinessGate( deliver, ({ routeId }) => { console.debug( @@ -131,7 +131,7 @@ export function useTtsSubscription( for (const pk of pubkeys) agentPubkeys.add(pk); agentsLoaded = true; if (initial) { - initialMembershipGate.succeed(); + initialReadinessGate.markMembershipKnown(); } } catch (e) { // Fail-closed on ALL failures, including refresh after prior success. @@ -140,7 +140,7 @@ export function useTtsSubscription( agentPubkeys.clear(); agentsLoaded = false; if (initial) { - initialMembershipGate.fail(); + initialReadinessGate.fail(); } console.error("[huddle] Failed to load agent pubkeys:", e); } @@ -159,6 +159,7 @@ export function useTtsSubscription( if (!disposed) { ttsStateKnown = true; speakInOrder.setEnabled(state.tts_enabled); + initialReadinessGate.markTtsStateKnown(); } }, ); @@ -177,11 +178,13 @@ export function useTtsSubscription( if (!disposed) applyBootstrap(state); }) .catch((err) => { + if (!ttsStateKnown) initialReadinessGate.fail(); console.warn("[huddle] Failed to load TTS state:", err); }); }) .catch((err) => { speakInOrder.setEnabled(false); + initialReadinessGate.fail(); console.warn("[huddle] Failed to listen for TTS state:", err); }); @@ -212,7 +215,7 @@ export function useTtsSubscription( `[huddle] tts stage=eligibility status=deferred reason=membership_unavailable route_id=${routeId}`, ); } - initialMembershipGate.push({ event, routeId }); + initialReadinessGate.push({ event, routeId }); }) .then((dispose) => { if (disposed) { From 6246c47a0a4fa9606fb781a0b9065c37b43af08d Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 31 Jul 2026 13:41:33 -0400 Subject: [PATCH 2/3] docs(desktop): describe TTS readiness gate Signed-off-by: John Tennant --- desktop/src/features/huddle/lib/useTtsSubscription.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/desktop/src/features/huddle/lib/useTtsSubscription.ts b/desktop/src/features/huddle/lib/useTtsSubscription.ts index 1adf78714d..c2adba3e55 100644 --- a/desktop/src/features/huddle/lib/useTtsSubscription.ts +++ b/desktop/src/features/huddle/lib/useTtsSubscription.ts @@ -207,8 +207,8 @@ export function useTtsSubscription( if (oldest !== undefined) seenEventIds.delete(oldest); } - // Preserve arrival order while the initial authoritative membership - // lookup is pending. A failed lookup clears this buffer fail-closed. + // Preserve arrival order until initial membership and TTS state are + // both known. A failed lookup clears this buffer fail-closed. const routeId = allocateTtsRouteId(); if (!agentsLoaded) { console.debug( From fab3212b4321d9085fe9f00640ad9bfef9b57ddb Mon Sep 17 00:00:00 2001 From: John Tennant Date: Fri, 31 Jul 2026 13:59:44 -0400 Subject: [PATCH 3/3] fix(desktop): report TTS readiness failures Signed-off-by: John Tennant --- .../features/huddle/lib/ttsLiveMessages.test.mjs | 10 ++++++---- desktop/src/features/huddle/lib/ttsLiveMessages.ts | 11 +++++++---- .../src/features/huddle/lib/useTtsSubscription.ts | 13 +++++++------ 3 files changed, 20 insertions(+), 14 deletions(-) diff --git a/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs b/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs index 8952d01e49..8bacceff9c 100644 --- a/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs +++ b/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs @@ -254,16 +254,18 @@ test("preserves the first agent reply when membership resolves before TTS state" assert.deepEqual(spoken, ["first agent reply"]); }); -test("drops the initial buffer fail-closed when membership lookup fails", () => { +test("drops the initial buffer fail-closed with the readiness failure", () => { const delivered = []; const dropped = []; const gate = createInitialTtsReadinessGate( (event) => delivered.push(event), - (event) => dropped.push(event), + (event, reason) => dropped.push({ event, reason }), ); gate.push("unverified"); - gate.fail(); + gate.fail("tts_state_unavailable"); gate.push("after-failure"); assert.deepEqual(delivered, ["after-failure"]); - assert.deepEqual(dropped, ["unverified"]); + assert.deepEqual(dropped, [ + { event: "unverified", reason: "tts_state_unavailable" }, + ]); }); diff --git a/desktop/src/features/huddle/lib/ttsLiveMessages.ts b/desktop/src/features/huddle/lib/ttsLiveMessages.ts index 3913d9a41f..5a5a29afa5 100644 --- a/desktop/src/features/huddle/lib/ttsLiveMessages.ts +++ b/desktop/src/features/huddle/lib/ttsLiveMessages.ts @@ -155,12 +155,15 @@ export function createLatestStateGate(apply: (value: T) => void): { /** Hold live events until initial membership and TTS state are both known. */ export function createInitialTtsReadinessGate( deliver: (event: T) => void, - drop: (event: T) => void = () => {}, + drop: ( + event: T, + reason: "membership_unavailable" | "tts_state_unavailable", + ) => void = () => {}, ): { push: (event: T) => void; markMembershipKnown: () => void; markTtsStateKnown: () => void; - fail: () => void; + fail: (reason: "membership_unavailable" | "tts_state_unavailable") => void; } { let settled = false; let membershipKnown = false; @@ -186,12 +189,12 @@ export function createInitialTtsReadinessGate( ttsStateKnown = true; releaseIfReady(); }, - fail() { + fail(reason) { if (settled) return; settled = true; const dropped = pending; pending = []; - for (const event of dropped) drop(event); + for (const event of dropped) drop(event, reason); }, }; } diff --git a/desktop/src/features/huddle/lib/useTtsSubscription.ts b/desktop/src/features/huddle/lib/useTtsSubscription.ts index c2adba3e55..64fb96efcd 100644 --- a/desktop/src/features/huddle/lib/useTtsSubscription.ts +++ b/desktop/src/features/huddle/lib/useTtsSubscription.ts @@ -116,9 +116,9 @@ export function useTtsSubscription( }; const initialReadinessGate = createInitialTtsReadinessGate( deliver, - ({ routeId }) => { + ({ routeId }, reason) => { console.debug( - `[huddle] tts stage=eligibility status=rejected reason=membership_unavailable route_id=${routeId}`, + `[huddle] tts stage=eligibility status=rejected reason=${reason} route_id=${routeId}`, ); }, ); @@ -140,7 +140,7 @@ export function useTtsSubscription( agentPubkeys.clear(); agentsLoaded = false; if (initial) { - initialReadinessGate.fail(); + initialReadinessGate.fail("membership_unavailable"); } console.error("[huddle] Failed to load agent pubkeys:", e); } @@ -178,13 +178,14 @@ export function useTtsSubscription( if (!disposed) applyBootstrap(state); }) .catch((err) => { - if (!ttsStateKnown) initialReadinessGate.fail(); + if (!ttsStateKnown) + initialReadinessGate.fail("tts_state_unavailable"); console.warn("[huddle] Failed to load TTS state:", err); }); }) .catch((err) => { speakInOrder.setEnabled(false); - initialReadinessGate.fail(); + initialReadinessGate.fail("tts_state_unavailable"); console.warn("[huddle] Failed to listen for TTS state:", err); }); @@ -208,7 +209,7 @@ export function useTtsSubscription( } // Preserve arrival order until initial membership and TTS state are - // both known. A failed lookup clears this buffer fail-closed. + // both known. A failed readiness check clears this buffer fail-closed. const routeId = allocateTtsRouteId(); if (!agentsLoaded) { console.debug(