mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): keep agent speech live-only
Signed-off-by: John Tennant <jtennant@squareup.com>
This commit is contained in:
committed by
John Tennant
parent
8a25584a78
commit
6186fc2313
@@ -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: ``, tags },
|
||||
agents,
|
||||
"human",
|
||||
CHANNEL,
|
||||
),
|
||||
speakableText({ ...base, content: ``, tags }),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
speakableAgentText(
|
||||
{ ...base, content: `Here is the diagram.\n\n`, tags },
|
||||
agents,
|
||||
"human",
|
||||
CHANNEL,
|
||||
),
|
||||
speakableText({
|
||||
...base,
|
||||
content: `Here is the diagram.\n\n`,
|
||||
tags,
|
||||
}),
|
||||
"Here is the diagram.",
|
||||
);
|
||||
assert.equal(
|
||||
speakableAgentText(
|
||||
{ ...base, content: `||\n\n||`, tags },
|
||||
agents,
|
||||
"human",
|
||||
CHANNEL,
|
||||
),
|
||||
speakableText({ ...base, content: `||\n\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}
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user