fix(desktop): preserve first huddle speech (#3962)

## 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 <jtennant@squareup.com>
This commit is contained in:
John Matthew Tennant
2026-07-31 22:16:56 -04:00
committed by GitHub
parent eb049ddf81
commit 45314fc504
3 changed files with 74 additions and 31 deletions
@@ -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" },
]);
});
@@ -152,34 +152,49 @@ export function createLatestStateGate<T>(apply: (value: T) => void): {
};
}
/** Hold live events until the first authoritative agent-membership lookup. */
export function createInitialMembershipGate<T>(
/** Hold live events until initial membership and TTS state are both known. */
export function createInitialTtsReadinessGate<T>(
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);
},
};
}
@@ -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) {