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
This commit is contained in:
Claude committed 2026-09-05 08:22:48 +00:00
1 parent 5991410685
commit c2aef4cbc7
12 files changed
+256 -16

No files matched your search

+57
View File
@@ -0,0 +1,57 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HermesClient } from '../src/services/hermes/client';
import { httpFetch, httpStream } from '../src/lib/transport';
import { DEFAULT_SETTINGS } from '../src/state/settings';
import type { HermesStreamEvent } from '../src/services/hermes/types';
vi.mock('../src/lib/transport', async (original) => ({ ...await original<typeof import('../src/lib/transport')>(), httpFetch: vi.fn(), httpStream: vi.fn() }));
const config = { ...DEFAULT_SETTINGS.hermes, url: 'https://example.test' };
afterEach(() => vi.restoreAllMocks());
/** Run started, streamed to completion over SSE (which never carries the session id), status read afterwards. */
function mockRun(sessionId: string) {
const bodies: Array<{ url: string; body: unknown }> = [];
vi.mocked(httpFetch).mockImplementation(async (req) => {
bodies.push({ url: req.url, body: req.body ? JSON.parse(req.body as string) : undefined });
if (req.url.endsWith('/v1/runs')) return { ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ run_id: 'run_1', status: 'started' }) };
return { ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ run_id: 'run_1', status: 'completed', session_id: sessionId, output: 'Salut.' }) };
});
vi.mocked(httpStream).mockImplementation(async (_req, handlers) => {
// Chunks arrive on a later tick, after the client has accepted the stream (as over IPC).
const done = new Promise<void>((resolve) => setTimeout(() => {
handlers.onChunk('event: message.delta\ndata: {"run_id":"run_1","delta":"Salut."}\n\n');
handlers.onChunk('event: run.completed\ndata: {"run_id":"run_1","status":"completed","output":"Salut."}\n\n');
resolve();
}, 0));
return { start: { id: 'stream-1', ok: true, status: 200, statusText: '', headers: {} }, done, abort: () => undefined };
});
return bodies;
}
describe('Hermes runs session continuity', () => {
it('adopts the session Hermes attached to the run so the next message continues it', async () => {
const bodies = mockRun('api_abc');
const events: HermesStreamEvent[] = [];
const client = new HermesClient(config);
const reply = await client.send({ text: 'Bonjour', sessionId: 'eveflow-local', history: [], onEvent: (e) => events.push(e) }, 'runs').result;
expect(reply).toBe('Salut.');
expect(events.map((e) => e.kind)).toEqual(['run.started', 'delta', 'completed', 'session']);
expect(events[3]).toEqual({ kind: 'session', sessionId: 'api_abc' });
expect(bodies.map((b) => b.url)).toEqual(['https://example.test/v1/runs', 'https://example.test/v1/runs/run_1']);
expect(bodies[0].body).toEqual(expect.objectContaining({ input: 'Bonjour', session_id: 'eveflow-local' }));
const again = await client.send({ text: 'Et ensuite ?', sessionId: 'api_abc', history: [], onEvent: () => undefined }, 'runs').result;
expect(again).toBe('Salut.');
expect(bodies[2].body).toEqual(expect.objectContaining({ session_id: 'api_abc' }));
});
it('does not fail the reply when the run status is unavailable', async () => {
mockRun('api_abc');
vi.mocked(httpFetch).mockImplementation(async (req) =>
req.url.endsWith('/v1/runs')
? { ok: true, status: 200, statusText: '', headers: {}, text: JSON.stringify({ run_id: 'run_1', status: 'started' }) }
: { ok: false, status: 500, statusText: '', headers: {}, text: 'boom' });
const events: HermesStreamEvent[] = [];
const reply = await new HermesClient(config).send({ text: 'Bonjour', sessionId: 'x', history: [], onEvent: (e) => events.push(e) }, 'runs').result;
expect(reply).toBe('Salut.');
expect(events.some((e) => e.kind === 'session')).toBe(false);
});
});
+59
View File
@@ -0,0 +1,59 @@
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());
});
});
+19 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { chunkForSpeech, cleanForSpeech, extractSentences, isTranscriptNoise, preprocessMedia } from '../src/lib/text';
import { chunkForSpeech, cleanForSpeech, closingQuestion, extractSentences, isTranscriptNoise, preprocessMedia, spokenDigest } from '../src/lib/text';
describe('text', () => {
it('cleans markdown for speech', () => {
@@ -40,3 +40,21 @@ describe('isTranscriptNoise', () => {
expect(isTranscriptNoise('Oui')).toBe(false);
});
});
describe('spokenDigest', () => {
const reply = 'Voici la réponse. Elle contient plusieurs phrases. Une troisième pour la forme. Et une quatrième.\n\n```js\nconsole.log(1)\n```\n\nTu veux que je continue ?';
it('speaks the first sentences and keeps the closing question', () => {
const digest = spokenDigest(reply, 2);
expect(digest.truncated).toBe(true);
expect(digest.text).toBe('Voici la réponse. Elle contient plusieurs phrases. Tu veux que je continue ?');
});
it('leaves short replies untouched', () => {
const digest = spokenDigest('Bonjour Michael. Tout va bien.', 4);
expect(digest).toEqual({ text: 'Bonjour Michael. Tout va bien.', truncated: false });
});
it('ignores code blocks when counting and only reports a real question', () => {
expect(spokenDigest('Une phrase.\n\n```\ncode\n```\n', 1).truncated).toBe(false);
expect(closingQuestion(reply)).toBe('Tu veux que je continue ?');
expect(closingQuestion('Aucune question ici.')).toBeNull();
});
});