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 {
classifySpeakableAgentText,
createInitialMembershipGate,
createLatestStateGate,
createOrderedSpeaker,
routeLiveAgentText,
speakableAgentText,
} from "./ttsLiveMessages.ts";
const agents = new Set(["agent"]);
@@ -18,59 +18,48 @@ const base = {
content: "Hello there",
tags: [["h", CHANNEL]],
};
const speakableText = (event, selfPubkey = "human") =>
classifySpeakableAgentText(event, agents, selfPubkey, CHANNEL).text;
test("speaks only new agent-authored text message events", () => {
assert.equal(speakableText(base), "Hello there");
assert.equal(
speakableAgentText(base, agents, "human", CHANNEL),
"Hello there",
);
assert.equal(
speakableAgentText({ ...base, kind: 40002 }, agents, "human", CHANNEL),
speakableText({ ...base, kind: 40002 }),
"Hello there",
"managed stream-message-v2 replies are spoken",
);
assert.equal(
speakableAgentText({ ...base, kind: 7 }, agents, "human", CHANNEL),
speakableText({ ...base, kind: 7 }),
null,
"reactions and other event kinds are excluded",
);
assert.equal(
speakableAgentText({ ...base, kind: 10 }, agents, "human", CHANNEL),
speakableText({ ...base, kind: 10 }),
null,
"edits and status events are excluded",
);
assert.equal(
speakableAgentText({ ...base, pubkey: "human" }, agents, "human", CHANNEL),
speakableText({ ...base, pubkey: "human" }),
null,
"human-authored messages are excluded",
);
assert.equal(
speakableAgentText({ ...base, content: " " }, agents, "human", CHANNEL),
speakableText({ ...base, content: " " }),
null,
"empty and non-text content are excluded",
);
assert.equal(
speakableAgentText({ ...base, content: "K" }, agents, "human", CHANNEL),
speakableText({ ...base, content: "K" }),
"K",
"one-character agent text remains speakable",
);
assert.equal(
speakableAgentText(
{ ...base, content: "[System] tool started" },
agents,
"human",
CHANNEL,
),
speakableText({ ...base, content: "[System] tool started" }),
null,
"legacy system rows are excluded",
);
assert.equal(
speakableAgentText(
{ ...base, tags: [["h", "another-huddle"]] },
agents,
"human",
CHANNEL,
),
speakableText({ ...base, tags: [["h", "another-huddle"]] }),
null,
"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 tags = [...base.tags, ["imeta", `url ${url}`, "m image/png"]];
assert.equal(
speakableAgentText(
{ ...base, content: `![image](${url})`, tags },
agents,
"human",
CHANNEL,
),
speakableText({ ...base, content: `![image](${url})`, tags }),
null,
);
assert.equal(
speakableAgentText(
{ ...base, content: `Here is the diagram.\n\n![image](${url})`, tags },
agents,
"human",
CHANNEL,
),
speakableText({
...base,
content: `Here is the diagram.\n\n![image](${url})`,
tags,
}),
"Here is the diagram.",
);
assert.equal(
speakableAgentText(
{ ...base, content: `||\n![image](${url})\n||`, tags },
agents,
"human",
CHANNEL,
),
speakableText({ ...base, content: `||\n![image](${url})\n||`, tags }),
null,
);
});
@@ -94,16 +94,6 @@ export function routeLiveAgentText(
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
* in thread arrival order even when the bridge resolves calls asynchronously.
@@ -193,31 +193,27 @@ export function useTtsSubscription(
const seenOrder: string[] = [];
const MAX_SEEN_EVENTS = 5000;
relayClient
.subscribeLive(
buildHuddleTtsLiveFilter(ephemeralChannelId),
(event) => {
if (disposed) return;
// Dedup by event ID (covers reconnect replay).
if (seenEventIds.has(event.id)) return;
seenEventIds.add(event.id);
seenOrder.push(event.id);
if (seenOrder.length > MAX_SEEN_EVENTS) {
const oldest = seenOrder.shift();
if (oldest !== undefined) seenEventIds.delete(oldest);
}
.subscribeLive(buildHuddleTtsLiveFilter(ephemeralChannelId), (event) => {
if (disposed) return;
// Dedup by event ID if a relay repeats live fan-out.
if (seenEventIds.has(event.id)) return;
seenEventIds.add(event.id);
seenOrder.push(event.id);
if (seenOrder.length > MAX_SEEN_EVENTS) {
const oldest = seenOrder.shift();
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.
const routeId = allocateTtsRouteId();
if (!agentsLoaded) {
console.debug(
`[huddle] tts stage=eligibility status=deferred reason=membership_unavailable route_id=${routeId}`,
);
}
initialMembershipGate.push({ event, routeId });
},
{ replayMissedHistory: true },
)
// Preserve arrival order while the initial authoritative membership
// lookup is pending. A failed lookup 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 });
})
.then((dispose) => {
if (disposed) {
void dispose();
@@ -163,7 +163,7 @@ export function VoiceSettingsCard() {
<SettingsOptionGroup>
<SettingsOptionRow>
<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">
Voice files stay private on this device.
</p>
@@ -173,6 +173,7 @@ export function VoiceSettingsCard() {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label={`Pocket TTS voice: ${selectedVoice?.displayName ?? "Mary"}`}
className="min-w-32 justify-between"
data-testid="pocket-voice-selector"
disabled={controlsDisabled}
+1 -7
View File
@@ -430,9 +430,8 @@ export class RelayClient {
async subscribeLive(
filter: RelaySubscriptionFilter,
onEvent: (event: RelayEvent) => void,
options?: { replayMissedHistory?: boolean },
) {
return this.subscribe(filter, onEvent, options);
return this.subscribe(filter, onEvent);
}
async subscribeToChannelMentionEvents(
@@ -586,7 +585,6 @@ export class RelayClient {
private async subscribe(
filter: RelaySubscriptionFilter,
onEvent: (event: RelayEvent) => void,
options?: { replayMissedHistory?: boolean },
) {
await this.ensureConnected();
@@ -608,10 +606,6 @@ export class RelayClient {
mode: "live",
filter,
onEvent,
replayMissedHistory: options?.replayMissedHistory,
lastSeenCreatedAt: options?.replayMissedHistory
? Math.floor(Date.now() / 1_000)
: undefined,
resolveReady,
});
@@ -58,7 +58,6 @@ type LiveSubscription = {
mode: "live";
filter: RelaySubscriptionFilter;
onEvent: (event: RelayEvent) => void;
replayMissedHistory?: boolean;
resolveReady?: () => void;
lastSeenCreatedAt?: 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 = {
kinds: [9],
"#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, true), true);
});
test("reconnect replay keeps the stricter existing since window", () => {
@@ -69,11 +69,7 @@ export function buildReconnectReplayFilter(
return replayFilter;
}
export function shouldPageReconnectReplay(
filter: RelaySubscriptionFilter,
replayMissedHistory = false,
) {
if (replayMissedHistory) return true;
export function shouldPageReconnectReplay(filter: RelaySubscriptionFilter) {
return (
filter.limit > 0 &&
Array.isArray(filter["#h"]) &&
@@ -180,10 +176,7 @@ export async function replayLiveSubscriptions({
);
const shouldPageReplay =
replaySince !== undefined &&
shouldPageReconnectReplay(
subscription.filter,
subscription.replayMissedHistory,
);
shouldPageReconnectReplay(subscription.filter);
return { subId, subscription, replaySince, shouldPageReplay };
});
+7
View File
@@ -21,6 +21,10 @@ test.describe("Pocket voice settings", () => {
await expect(
page.getByText("Agent text to speech", { exact: true }),
).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 expect(page.getByRole("menuitemradio")).toHaveCount(12);
@@ -28,6 +32,9 @@ test.describe("Pocket voice settings", () => {
await expect(page.getByTestId("pocket-voice-selector")).toContainText(
"Eve",
);
await expect(
page.getByRole("button", { name: "Pocket TTS voice: Eve" }),
).toBeVisible();
await page.getByTestId("agent-text-to-speech-toggle").click();
await expect(