diff --git a/apps/desktop/electron/event-dedupe.test.ts b/apps/desktop/electron/event-dedupe.test.ts index 3c0f905587..c27096796b 100644 --- a/apps/desktop/electron/event-dedupe.test.ts +++ b/apps/desktop/electron/event-dedupe.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { createEventDeduper } from './event-dedupe' +import { createAmbientClaimArbiter, createEventDeduper } from './event-dedupe' test('collapses the same key inside the window (two windows, one event)', () => { const isDup = createEventDeduper(1000) @@ -35,3 +35,20 @@ test('prunes stale keys so the map cannot grow unbounded', () => { assert.equal(isDup(`turnDone:s${i}`, i * 2000), false) } }) + +// #99717: the hidden app window under an open HUD claims the same reply late. +test('a spoken reply stays claimed long after the tick window, a beep does not', () => { + const owns = createAmbientClaimArbiter(1000) + + assert.equal(owns('speak:m1', 0), true, 'HUD renderer claims the reply') + assert.equal(owns('speak:m1', 5_000), false, 'app window claiming 5 s later stays quiet') + assert.equal(owns('sound:turnDone:s1', 0), true) + assert.equal(owns('sound:turnDone:s1', 5_000), true, 'beep keys still re-fire after the tick window') +}) + +test('a spoken reply can be claimed again once the speech TTL elapses', () => { + const owns = createAmbientClaimArbiter(1000, 60_000) + + assert.equal(owns('speak:m1', 0), true) + assert.equal(owns('speak:m1', 60_000), true) +}) diff --git a/apps/desktop/electron/event-dedupe.ts b/apps/desktop/electron/event-dedupe.ts index a2d12a8f2c..c8f553ed02 100644 --- a/apps/desktop/electron/event-dedupe.ts +++ b/apps/desktop/electron/event-dedupe.ts @@ -30,3 +30,21 @@ export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) { return false } } + +// A `speak:` cue is seconds of audio keyed by a durable backend +// message id, not an instant beep: the peer's claim can arrive well past the +// 1 s window (the app window hidden under an open HUD is throttled by Chromium +// and its transcript subscription fires late), and the reply was read twice +// (#99717). One reply is one claim for as long as a reply can plausibly play. +export const SPEECH_CLAIM_TTL_MS = 10 * 60_000 + +// Cross-window arbiter for every ambient cue: `speak:*` keys hold for the +// speech TTL, everything else keeps the tick-sized window. +export function createAmbientClaimArbiter(intervalMs = DEDUPE_INTERVAL_MS, speechTtlMs = SPEECH_CLAIM_TTL_MS) { + const cues = createEventDeduper(intervalMs) + const speech = createEventDeduper(speechTtlMs) + + return function owns(key: string, now = Date.now()): boolean { + return !(key.startsWith('speak:') ? speech(key, now) : cues(key, now)) + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 4fb88c2d4d..d0c2e18513 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -169,7 +169,7 @@ import { } from './desktop-uninstall' import { describeDevCdpDecision, resolveDevCdpPort } from './dev-cdp' import { installEmbedReferer } from './embed-referer' -import { createEventDeduper } from './event-dedupe' +import { createAmbientClaimArbiter } from './event-dedupe' import { buildTerminalScript, resolveTerminalLaunch, @@ -16799,9 +16799,10 @@ ipcMain.handle('hermes:api', async (_event, request) => { return handleHermesApiRequest(request).finally(releaseProfileDeletion) }) -// Main serializes cross-window ambient claims. -const claimedAmbientCue = createEventDeduper() -ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? ''))) +// Main serializes cross-window ambient claims (see event-dedupe.ts for why a +// spoken reply holds its claim far longer than a beep). +const ownsAmbientCue = createAmbientClaimArbiter() +ipcMain.handle('hermes:ambient:claim', (_event, key) => ownsAmbientCue(String(key ?? ''))) registerNativeNotifications({ getMainWindow: () => mainWindow, focusWindow })