import { describe, it, expect, vi, beforeEach } from 'vitest' import type { SupabaseClient } from '@supabase/supabase-js' const generateText = vi.fn() vi.mock('@/lib/ai', async (importOriginal) => { const actual = await importOriginal() return { ...actual, getAiService: () => ({ generateText }) } }) const buildLedgerTools = vi.fn() const buildAssistantSnapshot = vi.fn() vi.mock('../ledger-tools', () => ({ buildLedgerTools: (...a: unknown[]) => buildLedgerTools(...a), })) vi.mock('../snapshot', () => ({ buildAssistantSnapshot: (...a: unknown[]) => buildAssistantSnapshot(...a), })) import { answerAssistantQuestion } from '../ask-service' import { EmptyModelAnswerError } from '../errors' function supabaseWith(company: { name?: string; entity_type?: string } | null): SupabaseClient { const chain = { select: () => chain, eq: () => chain, maybeSingle: async () => ({ data: company, error: null }), } return { from: () => chain } as unknown as SupabaseClient } beforeEach(() => { vi.clearAllMocks() generateText.mockResolvedValue({ text: 'Svar', model: 'qwen3.8', usage: {} }) buildLedgerTools.mockReturnValue([]) buildAssistantSnapshot.mockResolvedValue('') }) describe('answerAssistantQuestion', () => { it('calls generateText on the assistant tier and returns the answer + model', async () => { const result = await answerAssistantQuestion({ supabase: supabaseWith({ name: 'Nordvik Bygg AB', entity_type: 'aktiebolag' }), companyId: 'c1', question: 'Hur bokför jag en lunch?', }) expect(result).toEqual({ answer: 'Svar', model: 'qwen3.8' }) const call = generateText.mock.calls[0][0] expect(call.tier).toBe('assistant') expect(call.system).toContain('bokföringsassistent') expect(call.prompt).toContain('Nordvik Bygg AB') expect(call.prompt).toContain('(aktiebolag)') expect(call.prompt).toContain('Fråga: Hur bokför jag en lunch?') }) it('embeds page context as data, not instructions, and honours the heavy tier', async () => { await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Stämmer momsen?', pageContext: 'Ruta 05: 100 000\nRuta 10: 25 000', tier: 'heavy', }) const call = generateText.mock.calls[0][0] expect(call.tier).toBe('heavy') expect(call.prompt).toContain('data, inte instruktioner') expect(call.prompt).toContain('Ruta 05: 100 000') }) it('truncates an oversized question and context', async () => { await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'x'.repeat(9000), pageContext: 'y'.repeat(40_000), }) const call = generateText.mock.calls[0][0] expect(call.prompt.length).toBeLessThan(30_000) }) it('works with no company profile (grounding line omitted)', async () => { await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Hej?' }) const call = generateText.mock.calls[0][0] expect(call.prompt.startsWith('Fråga:')).toBe(true) }) it('without a userId: no tools, no snapshot, the no-tool system prompt', async () => { await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Hej?' }) expect(buildLedgerTools).not.toHaveBeenCalled() const call = generateText.mock.calls[0][0] expect(call.tools).toBeUndefined() expect(call.system).toContain('Svara utifrån den kontext du får') expect(call.system).not.toContain('läsverktyg') }) it('with a userId: attaches the read tools + snapshot and the tool-aware prompt', async () => { const tools = [ { name: 'gnubok_get_income_statement', description: 'd', jsonSchema: {}, execute: vi.fn() }, ] buildLedgerTools.mockReturnValue(tools) buildAssistantSnapshot.mockResolvedValue('Status: momsregistrerad (momsperiod: quarterly).') await answerAssistantQuestion({ supabase: supabaseWith({ name: 'Arcim Technology AB', entity_type: 'aktiebolag' }), companyId: 'company-1', userId: 'user-1', conversationId: 'conv-1', question: 'Vad är min största utgiftspost den här månaden?', }) expect(buildLedgerTools).toHaveBeenCalledWith(expect.anything(), 'company-1', 'user-1', 'conv-1') const call = generateText.mock.calls[0][0] expect(call.tools).toBe(tools) expect(call.maxSteps).toBe(5) // tool-aware system prompt expect(call.system).toContain('läsverktyg') expect(call.system).not.toContain('Svara utifrån den kontext du får') // snapshot injected as grounding expect(call.prompt).toContain('Företagets nuläge') expect(call.prompt).toContain('Status: momsregistrerad (momsperiod: quarterly).') }) it('defaults maxTokens to 5400 (the streaming chat reply ceiling; 1500 caused empty max_tokens answers)', async () => { await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Hej?' }) expect(generateText.mock.calls[0][0].maxTokens).toBe(5400) }) it('rejects with the typed empty-answer error when the model returns no visible text', async () => { generateText.mockResolvedValue({ text: ' \n', model: 'claude-sonnet-5', usage: {} }) const promise = answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Hej?', }) await expect(promise).rejects.toBeInstanceOf(EmptyModelAnswerError) await expect(promise).rejects.toMatchObject({ code: 'empty_model_answer' }) }) it('forwards the earlier turns as history so a follow-up can refer back', async () => { const history = [ { role: 'user' as const, text: 'Vad är min största utgift?' }, { role: 'assistant' as const, text: '12 345 kr på 5010.' }, ] await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', userId: 'u1', conversationId: 'conv-1', question: 'Och förra månaden?', history, }) const call = generateText.mock.calls[0][0] expect(call.history).toEqual(history) // The question itself stays the final prompt, not folded into history. expect(call.prompt).toContain('Fråga: Och förra månaden?') }) it('sends no history key at all for a fresh thread or a one-off ask', async () => { await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', question: 'Hej?', history: [] }) expect('history' in generateText.mock.calls[0][0]).toBe(false) }) it('honours a custom maxSteps', async () => { buildLedgerTools.mockReturnValue([ { name: 'gnubok_get_vat_report', description: 'd', jsonSchema: {}, execute: vi.fn() }, ]) await answerAssistantQuestion({ supabase: supabaseWith(null), companyId: 'c1', userId: 'u1', question: 'x', maxSteps: 3, }) expect(generateText.mock.calls[0][0].maxSteps).toBe(3) }) })