const http = require('http'); const { buildApp } = require('../src/server'); const { MemorySessionStore } = require('../src/sessions'); const { createEngine } = require('../src/engine'); function fakeEngine() { const sessions = new Map(); const api = { sessions, aborted: false, startSession: async (input) => { if (input.bookId === 'draft') { const error = new Error('Book draft is not ready'); error.statusCode = 409; throw error; } const session = { sessionId: 'session-1', bookId: String(input.bookId), mode: 'HYBRID_AGENTIC', channel: input.channel, reason: 'default_hybrid', bookTitle: 'River', vocabularyName: null, openingLine: 'River. Ask me anything about it.', history: [], }; sessions.set(session.sessionId, session); return session; }, answer: async function* answer(input) { const session = sessions.get(input.sessionId); if (input.userText === 'hang') { yield { type: 'text_delta', text: 'Hello' }; await new Promise((resolve) => { if (input.signal.aborted) { api.aborted = true; return resolve(); } input.signal.addEventListener('abort', () => { api.aborted = true; resolve(); }); }); return; } yield { type: 'text_delta', text: 'Hello' }; yield { type: 'final', text: 'Hello', citations: [], usage: {}, interrupted: false }; if (session) { session.history.push({ role: 'user', text: input.userText }); session.history.push({ role: 'assistant', text: 'Hello', interrupted: false }); } }, markInterrupted: async (sessionId, spokenText) => { const session = sessions.get(sessionId); const turn = session.history.filter((item) => item.role === 'assistant').pop(); turn.text = spokenText; turn.interrupted = true; return { ok: true }; }, endSession: async (sessionId) => { sessions.get(sessionId).endedAt = '2026-01-01T00:00:00.000Z'; return { ok: true, usage: {} }; }, }; return api; } function auth(token = 'secret') { return { authorization: `Bearer ${token}` }; } describe('internal API', () => { test('missing token is rejected and a valid token reaches startSession', async () => { const app = buildApp({ token: 'secret', previousToken: 'old-secret', engine: fakeEngine() }); const denied = await app.inject({ method: 'POST', url: '/internal/sessions', payload: { bookId: '3', channel: 'chat' }, }); expect(denied.statusCode).toBe(401); const health = await app.inject({ method: 'GET', url: '/health' }); expect(health.statusCode).toBe(200); const ready = await app.inject({ method: 'GET', url: '/ready' }); expect(ready.statusCode).toBe(200); const created = await app.inject({ method: 'POST', url: '/internal/sessions', headers: auth('old-secret'), payload: { bookId: '3', userId: 7, channel: 'chat' }, }); expect(created.statusCode).toBe(201); expect(created.json()).toMatchObject({ sessionId: 'session-1', bookTitle: 'River', openingLine: expect.stringContaining('River') }); await app.close(); }); test('rejects a book that is not ready', async () => { const app = buildApp({ token: 'secret', engine: fakeEngine() }); const response = await app.inject({ method: 'POST', url: '/internal/sessions', headers: auth(), payload: { bookId: 'draft', channel: 'chat' }, }); expect(response.statusCode).toBe(409); expect(response.json().error).toMatch(/not ready/); await app.close(); }); test('frames SSE, heartbeats are comments, and a second message is rejected', async () => { const engine = fakeEngine(); const app = buildApp({ token: 'secret', engine }); await app.listen({ host: '127.0.0.1', port: 0 }); const address = app.server.address(); const body = JSON.stringify({ text: 'hang' }); const first = http.request({ host: '127.0.0.1', port: address.port, method: 'POST', path: '/internal/sessions/session-1/messages', headers: { ...auth(), 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }, }); const received = await new Promise((resolve, reject) => { first.on('error', reject); first.on('response', (response) => { let data = ''; response.on('data', (chunk) => { data += chunk.toString(); if (data.includes('text_delta')) resolve({ response, data, first }); }); }); first.end(body); }); expect(received.response.headers['content-type']).toMatch(/text\/event-stream/); expect(received.data).toContain('data: {"type":"text_delta","text":"Hello"}'); const conflict = await app.inject({ method: 'POST', url: '/internal/sessions/session-1/messages', headers: auth(), payload: { text: 'again' }, }); expect(conflict.statusCode).toBe(409); first.destroy(); await new Promise((resolve) => setTimeout(resolve, 80)); expect(engine.aborted).toBe(true); await app.close(); }); test('replaces the last assistant turn when interrupted', async () => { const engine = fakeEngine(); await engine.startSession({ bookId: '3', channel: 'voice' }); const events = []; for await (const event of engine.answer({ sessionId: 'session-1', userText: 'Hi' })) events.push(event); expect(events.map((event) => event.type)).toEqual(['text_delta', 'final']); const app = buildApp({ token: 'secret', engine }); const response = await app.inject({ method: 'POST', url: '/internal/sessions/session-1/interrupted', headers: auth(), payload: { spokenText: 'Hel' }, }); expect(response.statusCode).toBe(200); const turn = engine.sessions.get('session-1').history.find((item) => item.role === 'assistant'); expect(turn).toMatchObject({ text: 'Hel', interrupted: true }); const ended = await app.inject({ method: 'POST', url: '/internal/sessions/session-1/end', headers: auth(), }); expect(ended.json().ok).toBe(true); await app.close(); }); }); describe('opening line persistence through the engine', () => { test('markInterrupted keeps only the spoken text', async () => { const { memoryStorage, chatConfig } = require('./fixture'); const sessions = new MemorySessionStore(); const engine = createEngine({ config: chatConfig(), storage: memoryStorage('3'), books: { async get() { return { bookId: 3, tokenCount: 10, status: 'completed', title: 'River' }; } }, sessions, bedrock: null, opensearch: null, }); const session = await sessions.create({ bookId: '3', mode: 'HYBRID_AGENTIC', channel: 'voice', history: [ { role: 'user', text: 'Hi' }, { role: 'assistant', text: 'Hello there', interrupted: false }, ], }); await engine.markInterrupted(session.sessionId, 'Hello'); const saved = await sessions.get(session.sessionId); expect(saved.history[1]).toMatchObject({ text: 'Hello', interrupted: true }); const ended = await engine.endSession(session.sessionId); expect(ended.ok).toBe(true); expect((await sessions.get(session.sessionId)).endedAt).toBeTruthy(); }); });