From 45314fc504113aec7c54ae6520cfe5e1562aae40 Mon Sep 17 00:00:00 2001 From: John Matthew Tennant Date: Fri, 31 Jul 2026 22:16:56 -0400 Subject: [PATCH] fix(desktop): preserve first huddle speech (#3962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context On the first huddle after launching Buzz Desktop, a live agent reply can arrive after agent membership is known but before the initial TTS-enabled state has loaded. The subscription previously released buffered messages at the membership boundary, so that first reply was evaluated while speech was still disabled and was silently skipped. Later replies worked, and later huddles usually worked because the state was already warm. ## Summary Hold initial live agent replies until both authoritative agent membership and the initial TTS state are known. This preserves the first eligible reply after a cold app launch without changing live-only routing, ordering, or fail-closed behavior. ## Changes - Replace the membership-only startup gate with a two-signal readiness gate for membership and TTS state. - Release buffered live messages in arrival order only after both signals resolve. - Drop buffered messages if either initial lookup fails. - Add a deterministic regression for the observed ordering: membership resolves first, TTS enables second, and the first reply is spoken. ## Related issue None found. ## Testing Manual validation in the daily-driver build confirmed that the first agent reply is spoken in the first huddle after a fresh app launch. The regression scenario was also run against both revisions: ```text main: FAIL — actual spoken replies: []; expected: ["first agent reply"] PR: PASS — 10 passed, 0 failed ``` ## Screenshots N/A, nonvisual speech behavior. ## Reviewer-reproducible examples 1. Quit Buzz Desktop completely. 2. Reopen it with Pocket TTS enabled. 3. Start the first huddle of the session with a running agent. 4. Send a prompt that produces a spoken agent reply immediately after the huddle starts. 5. Confirm the first reply is spoken, not only the second reply. 6. Stop the huddle, start another one, and confirm subsequent huddles retain the same behavior. For a deterministic red/green check, run the same membership-before-TTS-state ordering from `desktop/`. On `main`: ```bash node --import ./test-loader.mjs --experimental-strip-types --input-type=module -e ' import assert from "node:assert/strict"; import { createInitialMembershipGate, createOrderedSpeaker } from "./src/features/huddle/lib/ttsLiveMessages.ts"; const spoken = []; const speaker = createOrderedSpeaker(async text => spoken.push(text), error => { throw error; }, false); const gate = createInitialMembershipGate(text => speaker.enqueue(text, 1)); gate.push("first agent reply"); gate.succeed(); speaker.setEnabled(true); await new Promise(resolve => setTimeout(resolve, 0)); console.log("spoken:", JSON.stringify(spoken)); assert.deepEqual(spoken, ["first agent reply"]); ' ``` Observed failure: ```text spoken: [] AssertionError: Expected values to be strictly deep-equal ``` On this PR branch: ```bash node --import ./test-loader.mjs --experimental-strip-types --input-type=module -e ' import assert from "node:assert/strict"; import { createInitialTtsReadinessGate, createOrderedSpeaker } from "./src/features/huddle/lib/ttsLiveMessages.ts"; const spoken = []; const speaker = createOrderedSpeaker(async text => spoken.push(text), error => { throw error; }, 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)); console.log("spoken:", JSON.stringify(spoken)); assert.deepEqual(spoken, ["first agent reply"]); ' ``` Observed output: ```text spoken: ["first agent reply"] ``` --------- Signed-off-by: John Tennant --- .../huddle/lib/ttsLiveMessages.test.mjs | 42 +++++++++++++++---- .../features/huddle/lib/ttsLiveMessages.ts | 41 ++++++++++++------ .../features/huddle/lib/useTtsSubscription.ts | 22 ++++++---- 3 files changed, 74 insertions(+), 31 deletions(-) diff --git a/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs b/desktop/src/features/huddle/lib/ttsLiveMessages.test.mjs index 3cd95eb3d..8bacceff9 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 b809afee2..5a5a29afa 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(); }, - fail() { + markTtsStateKnown() { + ttsStateKnown = true; + releaseIfReady(); + }, + 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 c77b2cfab..64fb96efc 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) {