/** * Integration tests for conversation persistence and context resume (M1-008). * * Verifies the full flow end-to-end using in-memory mocks: * 1. User messages are persisted when sent via ChatGateway. * 2. Assistant responses are persisted with metadata on agent:end. * 3. Conversation history is loaded and injected into context on session resume. * 4. The search endpoint returns matching messages. */ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { describe, expect, it, vi, beforeEach } from 'vitest'; import type { ConversationHistoryMessage } from '../agent/agent.service.js'; import { ConversationsController } from '../conversations/conversations.controller.js'; import type { Message } from '@mosaicstack/brain'; // --------------------------------------------------------------------------- // Shared test data // --------------------------------------------------------------------------- const USER_ID = 'user-test-001'; const CONV_ID = 'conv-test-001'; function makeConversation(overrides?: Record) { return { id: CONV_ID, userId: USER_ID, title: null, projectId: null, archived: false, createdAt: new Date('2026-01-01T00:00:00Z'), updatedAt: new Date('2026-01-01T00:00:00Z'), ...overrides, }; } function makeMessage( role: 'user' | 'assistant' | 'system', content: string, overrides?: Record, ) { return { id: `msg-${role}-${Math.random().toString(36).slice(2)}`, conversationId: CONV_ID, role, content, metadata: null, createdAt: new Date('2026-01-01T00:01:00Z'), ...overrides, }; } // --------------------------------------------------------------------------- // Helper: build a mock ConversationsRepo // --------------------------------------------------------------------------- function createMockBrain(options?: { conversation?: ReturnType | undefined; messages?: ReturnType[]; searchResults?: Array<{ messageId: string; conversationId: string; conversationTitle: string | null; role: 'user' | 'assistant' | 'system'; content: string; createdAt: Date; }>; }) { const conversation = options?.conversation; const messages = options?.messages ?? []; const searchResults = options?.searchResults ?? []; return { conversations: { findAll: vi.fn().mockResolvedValue(conversation ? [conversation] : []), findById: vi.fn().mockResolvedValue(conversation), create: vi.fn().mockResolvedValue(conversation ?? makeConversation()), update: vi.fn().mockResolvedValue(conversation), remove: vi.fn().mockResolvedValue(true), findMessages: vi.fn().mockResolvedValue(messages), addMessage: vi.fn().mockImplementation((data: unknown) => { const d = data as { conversationId: string; role: 'user' | 'assistant' | 'system'; content: string; metadata?: Record; }; return Promise.resolve(makeMessage(d.role, d.content, { metadata: d.metadata ?? null })); }), searchMessages: vi.fn().mockResolvedValue(searchResults), }, }; } // --------------------------------------------------------------------------- // 1. ConversationsRepo: addMessage persists user message // --------------------------------------------------------------------------- describe('ConversationsRepo.addMessage — user message persistence', () => { it('persists a user message and returns the saved record', async () => { const brain = createMockBrain({ conversation: makeConversation() }); const result = await brain.conversations.addMessage( { conversationId: CONV_ID, role: 'user', content: 'Hello, agent!', metadata: { timestamp: '2026-01-01T00:01:00.000Z' }, }, USER_ID, ); expect(brain.conversations.addMessage).toHaveBeenCalledOnce(); expect(result).toBeDefined(); expect(result!.role).toBe('user'); expect(result!.content).toBe('Hello, agent!'); expect(result!.conversationId).toBe(CONV_ID); }); it('returns undefined when conversation does not belong to the user', async () => { // Simulate the repo enforcement: ownership mismatch returns undefined const brain = createMockBrain({ conversation: undefined }); brain.conversations.addMessage = vi.fn().mockResolvedValue(undefined); const result = await brain.conversations.addMessage( { conversationId: CONV_ID, role: 'user', content: 'Hello' }, 'other-user', ); expect(result).toBeUndefined(); }); }); // --------------------------------------------------------------------------- // 2. ConversationsRepo.addMessage — assistant response with metadata // --------------------------------------------------------------------------- describe('ConversationsRepo.addMessage — assistant response metadata', () => { it('persists assistant message with model, provider, tokens and toolCalls metadata', async () => { const assistantMetadata = { timestamp: '2026-01-01T00:02:00.000Z', model: 'claude-3-5-sonnet-20241022', provider: 'anthropic', toolCalls: [ { toolCallId: 'tc-001', toolName: 'read_file', args: { path: '/foo/bar.ts' }, isError: false, }, ], tokenUsage: { input: 1000, output: 250, cacheRead: 0, cacheWrite: 0, total: 1250, }, }; const brain = createMockBrain({ conversation: makeConversation() }); const result = await brain.conversations.addMessage( { conversationId: CONV_ID, role: 'assistant', content: 'Here is the file content you requested.', metadata: assistantMetadata, }, USER_ID, ); expect(result).toBeDefined(); expect(result!.role).toBe('assistant'); expect(result!.content).toBe('Here is the file content you requested.'); expect(result!.metadata).toMatchObject({ model: 'claude-3-5-sonnet-20241022', provider: 'anthropic', tokenUsage: { input: 1000, output: 250, total: 1250 }, }); expect((result!.metadata as Record)['toolCalls']).toHaveLength(1); expect( ( (result!.metadata as Record)['toolCalls'] as Array> )[0]!['toolName'], ).toBe('read_file'); }); }); // --------------------------------------------------------------------------- // 3. ChatGateway.loadConversationHistory — session resume loads history // --------------------------------------------------------------------------- describe('Conversation resume — history loading', () => { it('maps DB messages to ConversationHistoryMessage shape', () => { // Simulate what ChatGateway.loadConversationHistory does: // convert DB Message rows to ConversationHistoryMessage for context injection. const dbMessages = [ makeMessage('user', 'What is the capital of France?', { createdAt: new Date('2026-01-01T00:01:00Z'), }), makeMessage('assistant', 'The capital of France is Paris.', { createdAt: new Date('2026-01-01T00:01:05Z'), }), makeMessage('user', 'And Germany?', { createdAt: new Date('2026-01-01T00:02:00Z') }), makeMessage('assistant', 'The capital of Germany is Berlin.', { createdAt: new Date('2026-01-01T00:02:05Z'), }), ]; // Replicate the mapping logic from ChatGateway const history: ConversationHistoryMessage[] = dbMessages.map((msg) => ({ role: msg.role as 'user' | 'assistant' | 'system', content: msg.content, createdAt: msg.createdAt, })); expect(history).toHaveLength(4); expect(history[0]).toEqual({ role: 'user', content: 'What is the capital of France?', createdAt: new Date('2026-01-01T00:01:00Z'), }); expect(history[1]).toEqual({ role: 'assistant', content: 'The capital of France is Paris.', createdAt: new Date('2026-01-01T00:01:05Z'), }); expect(history[2]!.role).toBe('user'); expect(history[3]!.role).toBe('assistant'); }); it('returns empty array when conversation has no messages', async () => { const brain = createMockBrain({ conversation: makeConversation(), messages: [] }); const messages = await brain.conversations.findMessages(CONV_ID, USER_ID); expect(messages).toHaveLength(0); // Gateway produces empty history → no context injection const history: ConversationHistoryMessage[] = (messages as Message[]).map((msg) => ({ role: msg.role as 'user' | 'assistant' | 'system', content: msg.content, createdAt: msg.createdAt, })); expect(history).toHaveLength(0); }); it('returns empty array when conversation does not belong to the user', async () => { const brain = createMockBrain({ conversation: undefined }); brain.conversations.findMessages = vi.fn().mockResolvedValue([]); const messages = await brain.conversations.findMessages(CONV_ID, 'other-user'); expect(messages).toHaveLength(0); }); it('preserves message order (ascending by createdAt)', async () => { const ordered = [ makeMessage('user', 'First', { createdAt: new Date('2026-01-01T00:01:00Z') }), makeMessage('assistant', 'Second', { createdAt: new Date('2026-01-01T00:01:05Z') }), makeMessage('user', 'Third', { createdAt: new Date('2026-01-01T00:02:00Z') }), ]; const brain = createMockBrain({ conversation: makeConversation(), messages: ordered }); const messages = await brain.conversations.findMessages(CONV_ID, USER_ID); expect(messages[0]!.content).toBe('First'); expect(messages[1]!.content).toBe('Second'); expect(messages[2]!.content).toBe('Third'); }); }); // --------------------------------------------------------------------------- // 4. AgentService.buildHistoryPromptSection — context injection format // --------------------------------------------------------------------------- describe('AgentService — buildHistoryPromptSection (context injection)', () => { /** * Replicate the private method logic to test it in isolation. * The real method lives in AgentService but is private; we mirror the * exact logic here so the test is independent of the service's constructor. */ function buildHistoryPromptSection( history: ConversationHistoryMessage[], contextWindow: number, _sessionId: string, ): string { const TOKEN_BUDGET = Math.floor(contextWindow * 0.8); const HISTORY_HEADER = '## Conversation History (resumed session)\n\n'; const formatMessage = (msg: ConversationHistoryMessage): string => { const roleLabel = msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : 'System'; return `**${roleLabel}:** ${msg.content}`; }; const estimateTokens = (text: string) => Math.ceil(text.length / 4); const formatted = history.map((msg) => formatMessage(msg)); const fullHistory = formatted.join('\n\n'); const fullTokens = estimateTokens(HISTORY_HEADER + fullHistory); if (fullTokens <= TOKEN_BUDGET) { return HISTORY_HEADER + fullHistory; } // History exceeds budget — summarize oldest messages, keep recent verbatim const SUMMARY_RESERVE = Math.floor(TOKEN_BUDGET * 0.2); const verbatimBudget = TOKEN_BUDGET - SUMMARY_RESERVE; let verbatimTokens = 0; let verbatimCutIndex = history.length; for (let i = history.length - 1; i >= 0; i--) { const t = estimateTokens(formatted[i]!); if (verbatimTokens + t > verbatimBudget) break; verbatimTokens += t; verbatimCutIndex = i; } const summarizedMessages = history.slice(0, verbatimCutIndex); const verbatimMessages = history.slice(verbatimCutIndex); let summaryText = ''; if (summarizedMessages.length > 0) { const topics = summarizedMessages .filter((m) => m.role === 'user') .map((m) => m.content.slice(0, 120).replace(/\n/g, ' ')) .join('; '); summaryText = `**Previous conversation summary** (${summarizedMessages.length} messages omitted for brevity):\n` + `Topics discussed: ${topics || '(no user messages in summarized portion)'}`; } const verbatimSection = verbatimMessages.map((m) => formatMessage(m)).join('\n\n'); const parts: string[] = [HISTORY_HEADER]; if (summaryText) parts.push(summaryText); if (verbatimSection) parts.push(verbatimSection); return parts.join('\n\n'); } it('includes header and all messages when history fits within context budget', () => { const history: ConversationHistoryMessage[] = [ { role: 'user', content: 'Hello', createdAt: new Date() }, { role: 'assistant', content: 'Hi there!', createdAt: new Date() }, ]; const result = buildHistoryPromptSection(history, 8192, 'session-1'); expect(result).toContain('## Conversation History (resumed session)'); expect(result).toContain('**User:** Hello'); expect(result).toContain('**Assistant:** Hi there!'); }); it('labels roles correctly (user, assistant, system)', () => { const history: ConversationHistoryMessage[] = [ { role: 'system', content: 'You are helpful.', createdAt: new Date() }, { role: 'user', content: 'Ping', createdAt: new Date() }, { role: 'assistant', content: 'Pong', createdAt: new Date() }, ]; const result = buildHistoryPromptSection(history, 8192, 'session-2'); expect(result).toContain('**System:** You are helpful.'); expect(result).toContain('**User:** Ping'); expect(result).toContain('**Assistant:** Pong'); }); it('summarizes old messages when history exceeds 80% of context window', () => { // Create enough messages to exceed a tiny context window budget const longContent = 'A'.repeat(200); const history: ConversationHistoryMessage[] = Array.from({ length: 20 }, (_, i) => ({ role: (i % 2 === 0 ? 'user' : 'assistant') as 'user' | 'assistant', content: `${longContent} message ${i}`, createdAt: new Date(), })); // Use a small context window so history definitely exceeds 80% const result = buildHistoryPromptSection(history, 512, 'session-3'); // Should contain the summary prefix expect(result).toContain('messages omitted for brevity'); expect(result).toContain('Topics discussed:'); }); it('returns only header for empty history', () => { const result = buildHistoryPromptSection([], 8192, 'session-4'); // With empty history, the full history join is '' and the section is just the header expect(result).toContain('## Conversation History (resumed session)'); }); }); // --------------------------------------------------------------------------- // 5. ConversationsController.search — GET /api/conversations/search // --------------------------------------------------------------------------- describe('ConversationsController — search endpoint', () => { let brain: ReturnType; let controller: ConversationsController; beforeEach(() => { const searchResults = [ { messageId: 'msg-001', conversationId: CONV_ID, conversationTitle: 'Test Chat', role: 'user' as const, content: 'What is the capital of France?', createdAt: new Date('2026-01-01T00:01:00Z'), }, { messageId: 'msg-002', conversationId: CONV_ID, conversationTitle: 'Test Chat', role: 'assistant' as const, content: 'The capital of France is Paris.', createdAt: new Date('2026-01-01T00:01:05Z'), }, ]; brain = createMockBrain({ searchResults }); controller = new ConversationsController(brain as never); }); it('returns matching messages for a valid search query', async () => { const results = await controller.search({ q: 'France' }, { id: USER_ID }); expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'France', 20, 0); expect(results).toHaveLength(2); expect(results[0]).toMatchObject({ messageId: 'msg-001', role: 'user', content: 'What is the capital of France?', }); expect(results[1]).toMatchObject({ messageId: 'msg-002', role: 'assistant', content: 'The capital of France is Paris.', }); }); it('uses custom limit and offset when provided', async () => { await controller.search({ q: 'Paris', limit: 5, offset: 10 }, { id: USER_ID }); expect(brain.conversations.searchMessages).toHaveBeenCalledWith(USER_ID, 'Paris', 5, 10); }); it('throws BadRequestException when query is empty', async () => { await expect(controller.search({ q: '' }, { id: USER_ID })).rejects.toBeInstanceOf( BadRequestException, ); await expect(controller.search({ q: ' ' }, { id: USER_ID })).rejects.toBeInstanceOf( BadRequestException, ); }); it('trims whitespace from query before passing to repo', async () => { await controller.search({ q: ' Berlin ' }, { id: USER_ID }); expect(brain.conversations.searchMessages).toHaveBeenCalledWith( USER_ID, 'Berlin', expect.any(Number), expect.any(Number), ); }); it('returns empty array when no messages match', async () => { brain.conversations.searchMessages = vi.fn().mockResolvedValue([]); const results = await controller.search({ q: 'xyzzy-no-match' }, { id: USER_ID }); expect(results).toHaveLength(0); }); }); // --------------------------------------------------------------------------- // 6. ConversationsController — messages CRUD // --------------------------------------------------------------------------- describe('ConversationsController — message CRUD', () => { it('listMessages returns 404 when conversation is not owned by user', async () => { const brain = createMockBrain({ conversation: undefined }); const controller = new ConversationsController(brain as never); await expect(controller.listMessages(CONV_ID, { id: USER_ID })).rejects.toBeInstanceOf( NotFoundException, ); }); it('listMessages returns the messages for an owned conversation', async () => { const msgs = [makeMessage('user', 'Test message'), makeMessage('assistant', 'Test reply')]; const brain = createMockBrain({ conversation: makeConversation(), messages: msgs }); const controller = new ConversationsController(brain as never); const result = await controller.listMessages(CONV_ID, { id: USER_ID }); expect(result).toHaveLength(2); expect(result[0]!.role).toBe('user'); expect(result[1]!.role).toBe('assistant'); }); it('addMessage returns the persisted message', async () => { const brain = createMockBrain({ conversation: makeConversation() }); const controller = new ConversationsController(brain as never); const result = await controller.addMessage( CONV_ID, { role: 'user', content: 'Persisted content' }, { id: USER_ID }, ); expect(result).toBeDefined(); expect(result.role).toBe('user'); expect(result.content).toBe('Persisted content'); }); }); // --------------------------------------------------------------------------- // 7. End-to-end persistence flow simulation // --------------------------------------------------------------------------- describe('End-to-end persistence flow', () => { it('simulates a full conversation: persist user message → persist assistant response → resume with history', async () => { // ── Step 1: Conversation is created ──────────────────────────────────── const brain = createMockBrain({ conversation: makeConversation() }); await brain.conversations.create({ id: CONV_ID, userId: USER_ID }); expect(brain.conversations.create).toHaveBeenCalledOnce(); // ── Step 2: User message is persisted ────────────────────────────────── const userMsg = await brain.conversations.addMessage( { conversationId: CONV_ID, role: 'user', content: 'Explain monads in simple terms.', metadata: { timestamp: '2026-01-01T00:01:00.000Z' }, }, USER_ID, ); expect(userMsg).toBeDefined(); expect(userMsg!.role).toBe('user'); // ── Step 3: Assistant response is persisted with metadata ─────────────── const assistantMeta = { timestamp: '2026-01-01T00:01:10.000Z', model: 'claude-3-5-sonnet-20241022', provider: 'anthropic', toolCalls: [], tokenUsage: { input: 500, output: 120, cacheRead: 0, cacheWrite: 0, total: 620 }, }; const assistantMsg = await brain.conversations.addMessage( { conversationId: CONV_ID, role: 'assistant', content: 'A monad is a design pattern that wraps values in a context...', metadata: assistantMeta, }, USER_ID, ); expect(assistantMsg).toBeDefined(); expect(assistantMsg!.role).toBe('assistant'); // ── Step 4: On session resume, history is loaded ──────────────────────── const storedMessages = [ makeMessage('user', 'Explain monads in simple terms.', { createdAt: new Date('2026-01-01T00:01:00Z'), metadata: { timestamp: '2026-01-01T00:01:00.000Z' }, }), makeMessage('assistant', 'A monad is a design pattern that wraps values in a context...', { createdAt: new Date('2026-01-01T00:01:10Z'), metadata: assistantMeta, }), ]; brain.conversations.findMessages = vi.fn().mockResolvedValue(storedMessages); const dbMessages = await brain.conversations.findMessages(CONV_ID, USER_ID); expect(dbMessages).toHaveLength(2); // ── Step 5: History is mapped for context injection ───────────────────── const history: ConversationHistoryMessage[] = (dbMessages as Message[]).map((msg) => ({ role: msg.role as 'user' | 'assistant' | 'system', content: msg.content, createdAt: msg.createdAt, })); expect(history[0]).toMatchObject({ role: 'user', content: 'Explain monads in simple terms.', }); expect(history[1]).toMatchObject({ role: 'assistant', content: 'A monad is a design pattern that wraps values in a context...', }); // ── Step 6: History roles are valid for injection ─────────────────────── for (const msg of history) { expect(['user', 'assistant', 'system']).toContain(msg.role); expect(typeof msg.content).toBe('string'); expect(msg.createdAt).toBeInstanceOf(Date); } }); });