208 lines
7.3 KiB
JavaScript
208 lines
7.3 KiB
JavaScript
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();
|
|
});
|
|
});
|