Files
EveFlow/tests/speechDigest.test.ts
Claude c2aef4cbc7 fix: keep Hermes session across runs and speak long replies as a digest (2.5.3)
Over the runs transport Hermes never reports the session it attached to a
run in the SSE stream, only in GET /v1/runs/{id}. EveFlow only read it in the
polling fallback, so the locally generated id was sent again and again and
each message opened a fresh Hermes session, losing the conversation context.
The client now adopts the run's session id after every run.

Long answers are now spoken as a digest: the first sentences (configurable,
4 by default) plus the closing question, followed by a short notice that the
full text is on screen. The default instructions ask Hermes to open long
replies with the essentials and to rely on the ongoing conversation.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ln4KL1feHtnV4nee4sZFhM
2026-09-05 08:22:48 +00:00

60 lines
2.5 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { DEFAULT_SETTINGS, useSettings } from '../src/state/settings';
const enqueue = vi.fn((text: string) => /[\p{L}\p{N}]/u.test(text.replace(/```[\s\S]*?```/g, '')));
const speak = vi.fn();
const stop = vi.fn();
vi.mock('../src/services/voice/tts', () => ({
TtsEngine: class {
enqueue = enqueue;
speak = speak;
stop = stop;
onState() { return () => undefined; }
updateConfig() { /* noop */ }
get isActive() { return false; }
}
}));
const { speech, DIGEST_NOTICE } = await import('../src/services/voice/speech');
function stream(text: string, size = 7): void {
for (let i = 0; i < text.length; i += size) speech.pushStream(text.slice(i, i + size));
}
const spoken = () => enqueue.mock.calls.map((c) => c[0]);
describe('spoken digest of streamed replies', () => {
beforeEach(() => {
enqueue.mockClear();
speak.mockClear();
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, speech: { ...DEFAULT_SETTINGS.speech, summarizeReplies: true, replySentences: 2 } } });
});
afterEach(() => speech.stop());
it('stops after the configured sentences, then adds the closing question and a notice', () => {
const text = 'Première phrase du rapport. Deuxième phrase utile. Troisième phrase de détail. Quatrième phrase encore. Tu veux la suite ?';
stream(text);
speech.endStream(text);
expect(spoken()).toEqual(['Première phrase du rapport.', 'Deuxième phrase utile.', 'Tu veux la suite ?', DIGEST_NOTICE]);
});
it('speaks short replies in full without any notice', () => {
const text = 'Une phrase courte. Une seconde phrase';
stream(text);
speech.endStream(text);
expect(spoken()).toEqual(['Une phrase courte.', 'Une seconde phrase']);
});
it('speaks everything when the digest is disabled', () => {
useSettings.setState({ settings: { ...DEFAULT_SETTINGS, speech: { ...DEFAULT_SETTINGS.speech, summarizeReplies: false } } });
const text = 'Première phrase du rapport. Deuxième phrase utile. Troisième phrase de détail. Quatrième phrase encore.';
stream(text);
speech.endStream(text);
expect(spoken()).toHaveLength(4);
});
it('digests a reply delivered in one piece', () => {
speech.sayReply('Première phrase du rapport. Deuxième phrase utile. Troisième phrase de détail. On continue ?');
expect(speak).toHaveBeenCalledWith(`Première phrase du rapport. Deuxième phrase utile. On continue ? ${DIGEST_NOTICE}`, expect.anything());
});
});