Files
hermes-agent/apps/desktop/src/lib/spoken-reply.test.ts
T
brooklyn! 63565fa26b fix(desktop): keep auto-speak silent across the stream-id rewrite
Supersedes #75649, #86637, #87672, #88642.

Fixes #86601
Fixes #87652
Fixes #87823

Co-authored-by: Charmmy <lilShawtty@qq.com>
Co-authored-by: chelsealong <chelsealong@126.com>
Co-authored-by: Olympusbuildz <Olympus.roots@outlook.com>
Co-authored-by: Ricardo Mendes <ricardo.mendes@maiolabs.ai>
2026-08-19 02:56:55 -05:00

93 lines
3.3 KiB
TypeScript

import { afterEach, describe, expect, it } from 'vitest'
import {
absorbSpokenReplyRewrite,
assistantReplyOrdinal,
clearSpokenRepliesForTests,
isLiveTailReplyId,
markAssistantIdSpoken,
resolveSpokenReply,
spokenReplyOf
} from './spoken-reply'
const assistant = (id: string) => ({ id, role: 'assistant' as const })
const user = (id: string) => ({ id, role: 'user' as const })
const hidden = (id: string) => ({ hidden: true, id, role: 'assistant' as const })
afterEach(() => {
clearSpokenRepliesForTests()
})
describe('isLiveTailReplyId', () => {
it('matches renderer stream and inflight ids only', () => {
expect(isLiveTailReplyId('assistant-stream-s1')).toBe(true)
expect(isLiveTailReplyId('inflight-assistant-9')).toBe(true)
expect(isLiveTailReplyId('42')).toBe(false)
expect(isLiveTailReplyId('1770-3-assistant')).toBe(false)
})
})
describe('assistantReplyOrdinal', () => {
it('counts visible assistant bubbles and skips hidden ones', () => {
const messages = [user('u1'), assistant('a1'), hidden('skip'), assistant('a2')]
expect(assistantReplyOrdinal(messages, 'a1')).toBe(0)
expect(assistantReplyOrdinal(messages, 'a2')).toBe(1)
expect(assistantReplyOrdinal(messages, 'missing')).toBe(-1)
})
})
describe('absorbSpokenReplyRewrite', () => {
it('stays silent when the live-tail id is rewritten at the same ordinal', () => {
const spoken = { id: 'assistant-stream-s', ordinal: 1 }
const after = [user('u1'), assistant('a0'), assistant('42')]
expect(absorbSpokenReplyRewrite(spoken, after)).toEqual({ id: '42', ordinal: 1 })
})
it('does not treat a later same-slot-looking turn as the rewrite when ordinal moved', () => {
const spoken = { id: 'assistant-stream-s', ordinal: 0 }
const after = [user('u1'), assistant('durable-1'), user('u2'), assistant('assistant-stream-next')]
expect(absorbSpokenReplyRewrite(spoken, after)).toEqual(spoken)
})
it('does not migrate a durable id that simply vanished', () => {
const spoken = { id: 'durable-old', ordinal: 0 }
const after = [assistant('durable-new')]
expect(absorbSpokenReplyRewrite(spoken, after)).toEqual(spoken)
})
it('keeps the anchor when the spoken id is still in the list', () => {
const spoken = { id: 'assistant-stream-s', ordinal: 0 }
const messages = [assistant('assistant-stream-s')]
expect(absorbSpokenReplyRewrite(spoken, messages)).toBe(spoken)
})
})
describe('resolveSpokenReply', () => {
it('migrates per session and does not leak across sessions', () => {
const before = [assistant('assistant-stream-s')]
markAssistantIdSpoken('session-a', before, 'assistant-stream-s')
const after = [assistant('42')]
expect(resolveSpokenReply('session-a', after)?.id).toBe('42')
expect(spokenReplyOf('session-b')).toBeNull()
expect(resolveSpokenReply('session-b', after)).toBeNull()
})
it('lets a second turn at the next ordinal stay unspoken', () => {
markAssistantIdSpoken('s', [assistant('assistant-stream-1')], 'assistant-stream-1')
resolveSpokenReply('s', [assistant('durable-1')])
const nextTurn = [assistant('durable-1'), assistant('assistant-stream-2')]
const spoken = resolveSpokenReply('s', nextTurn)
expect(spoken?.id).toBe('durable-1')
expect(assistantReplyOrdinal(nextTurn, 'assistant-stream-2')).toBe(1)
expect(spoken?.ordinal).toBe(0)
})
})