diff --git a/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs b/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs index 3cd95eb3df..8bacceff9c 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,27 +221,51 @@ 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("drops the initial buffer fail-closed when membership lookup fails", () => { +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 with the readiness failure", () => { const delivered = []; const dropped = []; - const gate = createInitialMembershipGate( + 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 b809afee2c..5a5a29afa5 100644 --- a/desktop/src/features/huddle/lib/ttsLiveMessages.ts +++ b/desktop/src/features/huddle/lib/ttsLiveMessages.ts @@ -152,34 +152,49 @@ 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 = () => {}, + drop: ( + event: T, + reason: "membership_unavailable" | "tts_state_unavailable", + ) => void = () => {}, ): { push: (event: T) => void; - succeed: () => void; - fail: () => void; + markMembershipKnown: () => void; + markTtsStateKnown: () => void; + fail: (reason: "membership_unavailable" | "tts_state_unavailable") => 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() { + 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 c77b2cfab7..64fb96efcd 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,11 +114,11 @@ export function useTtsSubscription( ); } }; - const initialMembershipGate = createInitialMembershipGate( + 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}`, ); }, ); @@ -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("membership_unavailable"); } 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,14 @@ export function useTtsSubscription( if (!disposed) applyBootstrap(state); }) .catch((err) => { + if (!ttsStateKnown) + initialReadinessGate.fail("tts_state_unavailable"); console.warn("[huddle] Failed to load TTS state:", err); }); }) .catch((err) => { speakInOrder.setEnabled(false); + initialReadinessGate.fail("tts_state_unavailable"); console.warn("[huddle] Failed to listen for TTS state:", err); }); @@ -204,15 +208,15 @@ 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 readiness check clears this buffer fail-closed. const routeId = allocateTtsRouteId(); if (!agentsLoaded) { console.debug( `[huddle] tts stage=eligibility status=deferred reason=membership_unavailable route_id=${routeId}`, ); } - initialMembershipGate.push({ event, routeId }); + initialReadinessGate.push({ event, routeId }); }) .then((dispose) => { if (disposed) {