fix(desktop): keep agent speech live-only

Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
John Tennant
2026-07-29 12:20:53 -04:00
committed by John Tennant
parent 8a25584a78
commit 6186fc2313
9 changed files with 52 additions and 95 deletions
@@ -2,11 +2,11 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import { import {
classifySpeakableAgentText,
createInitialMembershipGate, createInitialMembershipGate,
createLatestStateGate, createLatestStateGate,
createOrderedSpeaker, createOrderedSpeaker,
routeLiveAgentText, routeLiveAgentText,
speakableAgentText,
} from "./ttsLiveMessages.ts"; } from "./ttsLiveMessages.ts";
const agents = new Set(["agent"]); const agents = new Set(["agent"]);
@@ -18,59 +18,48 @@ const base = {
content: "Hello there", content: "Hello there",
tags: [["h", CHANNEL]], tags: [["h", CHANNEL]],
}; };
const speakableText = (event, selfPubkey = "human") =>
classifySpeakableAgentText(event, agents, selfPubkey, CHANNEL).text;
test("speaks only new agent-authored text message events", () => { test("speaks only new agent-authored text message events", () => {
assert.equal(speakableText(base), "Hello there");
assert.equal( assert.equal(
speakableAgentText(base, agents, "human", CHANNEL), speakableText({ ...base, kind: 40002 }),
"Hello there",
);
assert.equal(
speakableAgentText({ ...base, kind: 40002 }, agents, "human", CHANNEL),
"Hello there", "Hello there",
"managed stream-message-v2 replies are spoken", "managed stream-message-v2 replies are spoken",
); );
assert.equal( assert.equal(
speakableAgentText({ ...base, kind: 7 }, agents, "human", CHANNEL), speakableText({ ...base, kind: 7 }),
null, null,
"reactions and other event kinds are excluded", "reactions and other event kinds are excluded",
); );
assert.equal( assert.equal(
speakableAgentText({ ...base, kind: 10 }, agents, "human", CHANNEL), speakableText({ ...base, kind: 10 }),
null, null,
"edits and status events are excluded", "edits and status events are excluded",
); );
assert.equal( assert.equal(
speakableAgentText({ ...base, pubkey: "human" }, agents, "human", CHANNEL), speakableText({ ...base, pubkey: "human" }),
null, null,
"human-authored messages are excluded", "human-authored messages are excluded",
); );
assert.equal( assert.equal(
speakableAgentText({ ...base, content: " " }, agents, "human", CHANNEL), speakableText({ ...base, content: " " }),
null, null,
"empty and non-text content are excluded", "empty and non-text content are excluded",
); );
assert.equal( assert.equal(
speakableAgentText({ ...base, content: "K" }, agents, "human", CHANNEL), speakableText({ ...base, content: "K" }),
"K", "K",
"one-character agent text remains speakable", "one-character agent text remains speakable",
); );
assert.equal( assert.equal(
speakableAgentText( speakableText({ ...base, content: "[System] tool started" }),
{ ...base, content: "[System] tool started" },
agents,
"human",
CHANNEL,
),
null, null,
"legacy system rows are excluded", "legacy system rows are excluded",
); );
assert.equal( assert.equal(
speakableAgentText( speakableText({ ...base, tags: [["h", "another-huddle"]] }),
{ ...base, tags: [["h", "another-huddle"]] },
agents,
"human",
CHANNEL,
),
null, null,
"messages for another huddle are excluded", "messages for another huddle are excluded",
); );
@@ -134,30 +123,19 @@ test("strips attachment markup and skips attachment-only events", () => {
const url = "https://cdn.example/voice.png"; const url = "https://cdn.example/voice.png";
const tags = [...base.tags, ["imeta", `url ${url}`, "m image/png"]]; const tags = [...base.tags, ["imeta", `url ${url}`, "m image/png"]];
assert.equal( assert.equal(
speakableAgentText( speakableText({ ...base, content: `![image](${url})`, tags }),
{ ...base, content: `![image](${url})`, tags },
agents,
"human",
CHANNEL,
),
null, null,
); );
assert.equal( assert.equal(
speakableAgentText( speakableText({
{ ...base, content: `Here is the diagram.\n\n![image](${url})`, tags }, ...base,
agents, content: `Here is the diagram.\n\n![image](${url})`,
"human", tags,
CHANNEL, }),
),
"Here is the diagram.", "Here is the diagram.",
); );
assert.equal( assert.equal(
speakableAgentText( speakableText({ ...base, content: `||\n![image](${url})\n||`, tags }),
{ ...base, content: `||\n![image](${url})\n||`, tags },
agents,
"human",
CHANNEL,
),
null, null,
); );
}); });
@@ -94,16 +94,6 @@ export function routeLiveAgentText(
return enqueue(eligibility.text, routeId); return enqueue(eligibility.text, routeId);
} }
export function speakableAgentText(
event: LiveTtsEvent,
agentPubkeys: ReadonlySet<string>,
selfPubkey: string | null,
channelId: string,
): string | null {
return classifySpeakableAgentText(event, agentPubkeys, selfPubkey, channelId)
.text;
}
/** /**
* Serialize native speak calls so live messages enter the bounded Pocket queue * Serialize native speak calls so live messages enter the bounded Pocket queue
* in thread arrival order even when the bridge resolves calls asynchronously. * in thread arrival order even when the bridge resolves calls asynchronously.
@@ -193,31 +193,27 @@ export function useTtsSubscription(
const seenOrder: string[] = []; const seenOrder: string[] = [];
const MAX_SEEN_EVENTS = 5000; const MAX_SEEN_EVENTS = 5000;
relayClient relayClient
.subscribeLive( .subscribeLive(buildHuddleTtsLiveFilter(ephemeralChannelId), (event) => {
buildHuddleTtsLiveFilter(ephemeralChannelId), if (disposed) return;
(event) => { // Dedup by event ID if a relay repeats live fan-out.
if (disposed) return; if (seenEventIds.has(event.id)) return;
// Dedup by event ID (covers reconnect replay). seenEventIds.add(event.id);
if (seenEventIds.has(event.id)) return; seenOrder.push(event.id);
seenEventIds.add(event.id); if (seenOrder.length > MAX_SEEN_EVENTS) {
seenOrder.push(event.id); const oldest = seenOrder.shift();
if (seenOrder.length > MAX_SEEN_EVENTS) { if (oldest !== undefined) seenEventIds.delete(oldest);
const oldest = seenOrder.shift(); }
if (oldest !== undefined) seenEventIds.delete(oldest);
}
// Preserve arrival order while the initial authoritative membership // Preserve arrival order while the initial authoritative membership
// lookup is pending. A failed lookup clears this buffer fail-closed. // lookup is pending. A failed lookup clears this buffer fail-closed.
const routeId = allocateTtsRouteId(); const routeId = allocateTtsRouteId();
if (!agentsLoaded) { if (!agentsLoaded) {
console.debug( console.debug(
`[huddle] tts stage=eligibility status=deferred reason=membership_unavailable route_id=${routeId}`, `[huddle] tts stage=eligibility status=deferred reason=membership_unavailable route_id=${routeId}`,
); );
} }
initialMembershipGate.push({ event, routeId }); initialMembershipGate.push({ event, routeId });
}, })
{ replayMissedHistory: true },
)
.then((dispose) => { .then((dispose) => {
if (disposed) { if (disposed) {
void dispose(); void dispose();
@@ -163,7 +163,7 @@ export function VoiceSettingsCard() {
<SettingsOptionGroup> <SettingsOptionGroup>
<SettingsOptionRow> <SettingsOptionRow>
<div className="min-w-0"> <div className="min-w-0">
<p className="text-sm font-medium">Voice</p> <p className="text-sm font-medium">Pocket TTS voice</p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Voice files stay private on this device. Voice files stay private on this device.
</p> </p>
@@ -173,6 +173,7 @@ export function VoiceSettingsCard() {
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button <Button
aria-label={`Pocket TTS voice: ${selectedVoice?.displayName ?? "Mary"}`}
className="min-w-32 justify-between" className="min-w-32 justify-between"
data-testid="pocket-voice-selector" data-testid="pocket-voice-selector"
disabled={controlsDisabled} disabled={controlsDisabled}
+1 -7
View File
@@ -430,9 +430,8 @@ export class RelayClient {
async subscribeLive( async subscribeLive(
filter: RelaySubscriptionFilter, filter: RelaySubscriptionFilter,
onEvent: (event: RelayEvent) => void, onEvent: (event: RelayEvent) => void,
options?: { replayMissedHistory?: boolean },
) { ) {
return this.subscribe(filter, onEvent, options); return this.subscribe(filter, onEvent);
} }
async subscribeToChannelMentionEvents( async subscribeToChannelMentionEvents(
@@ -586,7 +585,6 @@ export class RelayClient {
private async subscribe( private async subscribe(
filter: RelaySubscriptionFilter, filter: RelaySubscriptionFilter,
onEvent: (event: RelayEvent) => void, onEvent: (event: RelayEvent) => void,
options?: { replayMissedHistory?: boolean },
) { ) {
await this.ensureConnected(); await this.ensureConnected();
@@ -608,10 +606,6 @@ export class RelayClient {
mode: "live", mode: "live",
filter, filter,
onEvent, onEvent,
replayMissedHistory: options?.replayMissedHistory,
lastSeenCreatedAt: options?.replayMissedHistory
? Math.floor(Date.now() / 1_000)
: undefined,
resolveReady, resolveReady,
}); });
@@ -58,7 +58,6 @@ type LiveSubscription = {
mode: "live"; mode: "live";
filter: RelaySubscriptionFilter; filter: RelaySubscriptionFilter;
onEvent: (event: RelayEvent) => void; onEvent: (event: RelayEvent) => void;
replayMissedHistory?: boolean;
resolveReady?: () => void; resolveReady?: () => void;
lastSeenCreatedAt?: number; lastSeenCreatedAt?: number;
closedRetryAttempt?: number; closedRetryAttempt?: number;
@@ -129,7 +129,7 @@ test("reconnect replay preserves the live-only zero-history contract", () => {
}); });
}); });
test("missed-history replay is explicit for live-only subscriptions", () => { test("live-only subscriptions do not page reconnect history", () => {
const filter = { const filter = {
kinds: [9], kinds: [9],
"#h": ["channel-1"], "#h": ["channel-1"],
@@ -137,7 +137,6 @@ test("missed-history replay is explicit for live-only subscriptions", () => {
}; };
assert.equal(shouldPageReconnectReplay(filter), false); assert.equal(shouldPageReconnectReplay(filter), false);
assert.equal(shouldPageReconnectReplay(filter, true), true);
}); });
test("reconnect replay keeps the stricter existing since window", () => { test("reconnect replay keeps the stricter existing since window", () => {
@@ -69,11 +69,7 @@ export function buildReconnectReplayFilter(
return replayFilter; return replayFilter;
} }
export function shouldPageReconnectReplay( export function shouldPageReconnectReplay(filter: RelaySubscriptionFilter) {
filter: RelaySubscriptionFilter,
replayMissedHistory = false,
) {
if (replayMissedHistory) return true;
return ( return (
filter.limit > 0 && filter.limit > 0 &&
Array.isArray(filter["#h"]) && Array.isArray(filter["#h"]) &&
@@ -180,10 +176,7 @@ export async function replayLiveSubscriptions({
); );
const shouldPageReplay = const shouldPageReplay =
replaySince !== undefined && replaySince !== undefined &&
shouldPageReconnectReplay( shouldPageReconnectReplay(subscription.filter);
subscription.filter,
subscription.replayMissedHistory,
);
return { subId, subscription, replaySince, shouldPageReplay }; return { subId, subscription, replaySince, shouldPageReplay };
}); });
+7
View File
@@ -21,6 +21,10 @@ test.describe("Pocket voice settings", () => {
await expect( await expect(
page.getByText("Agent text to speech", { exact: true }), page.getByText("Agent text to speech", { exact: true }),
).toBeVisible(); ).toBeVisible();
await expect(
page.getByText("Pocket TTS voice", { exact: true }),
).toBeVisible();
await expect(card).not.toContainText("April INT8");
await page.getByTestId("pocket-voice-selector").click(); await page.getByTestId("pocket-voice-selector").click();
await expect(page.getByRole("menuitemradio")).toHaveCount(12); await expect(page.getByRole("menuitemradio")).toHaveCount(12);
@@ -28,6 +32,9 @@ test.describe("Pocket voice settings", () => {
await expect(page.getByTestId("pocket-voice-selector")).toContainText( await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"Eve", "Eve",
); );
await expect(
page.getByRole("button", { name: "Pocket TTS voice: Eve" }),
).toBeVisible();
await page.getByTestId("agent-text-to-speech-toggle").click(); await page.getByTestId("agent-text-to-speech-toggle").click();
await expect( await expect(