3edbf0a2e3
Three user-reported failures in the assistant panel, one root cause each: 1. "The chat asks what I'm referring to" when continuing a thread. The single-call console (general.help, AskConsole -> /api/agent/ask) was stateless since the 08-20 model-agnostic cutover: conversationId was only the tool actor id, so every turn was answered blind, reload or not. The provider-agnostic GenerateTextRequest gains an optional `history` (real message turns before the prompt, in both the Anthropic-family and the OpenAI-compatible adapter; absent/empty leaves the request byte-identical to the single-turn call). The route loads the thread's earlier turns server-side (loadChatHistory: text only, hidden and tool rows dropped, alternation repaired, newest 16 rows / 10k chars) before writing the new question, and hands them to the model. 2. A full page reload (the deploy prompt's "Ladda om") closed the docked panel and dropped the thread from view. The panel now remembers its open thread per tab in sessionStorage (lib/agent-panel/session-restore) and the provider reopens it on mount; the sheet loads it exactly like a pick from "Tidigare konversationer". Close and "Ny konversation" forget it; a thread that no longer opens is dropped instead of retried on every reload. 3. "Can't type any more" once the update banner shows. DeployReloadPrompt's full-width wrapper sits at z-[60] after the panel in DOM order and swallowed clicks on the panel's composer; only the card takes input now. Claude-Session: https://claude.ai/code/session_01VjoXN3xdNZrHZeYA6qMi3g Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
174 lines
6.8 KiB
TypeScript
174 lines
6.8 KiB
TypeScript
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<typeof import('@/lib/ai')>()
|
|
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)
|
|
})
|
|
})
|