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 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: ``, tags }),
|
||||||
{ ...base, content: ``, tags },
|
|
||||||
agents,
|
|
||||||
"human",
|
|
||||||
CHANNEL,
|
|
||||||
),
|
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
speakableAgentText(
|
speakableText({
|
||||||
{ ...base, content: `Here is the diagram.\n\n`, tags },
|
...base,
|
||||||
agents,
|
content: `Here is the diagram.\n\n`,
|
||||||
"human",
|
tags,
|
||||||
CHANNEL,
|
}),
|
||||||
),
|
|
||||||
"Here is the diagram.",
|
"Here is the diagram.",
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
speakableAgentText(
|
speakableText({ ...base, content: `||\n\n||`, tags }),
|
||||||
{ ...base, content: `||\n\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}
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user