ff4425d10e
The /chat assistant (audit Option A / rip) shipped in #1759 reading only the company name + entity type, so it answered "jag har ingen bokföringsdata" to every figures question ("vad är min största utgiftspost?"). It now behaves like an MCP client: it answers over a bounded, READ-only tool loop across the same MCP read tools the old streaming assistant had, plus an always-on company snapshot as the backstop. Provider-agnostic by construction, so it still runs on a local model: - lib/ai generateText gains optional `tools` + `maxSteps`. The OpenAI-compatible service forwards them to the Vercel AI SDK (stopWhen: stepCountIs), which runs the loop; the Anthropic-family service hand-rolls a small loop against messages.create. Kept on the raw Anthropic SDK: no new deps, and the no-tools path is byte-identical, so hosted extraction/composer/etc. are unchanged. - lib/agent/ask/ledger-tools.ts: the read slice of general.help's whitelist (income statement, VAT, ledgers, query_journal, reskontror, lists…) from agentToolRegistry, dispatched with the agent_chat actor run-turn uses. Write/ staging + memory-write tools are excluded; readOnlyHint/destructiveHint are re-checked. Empty in a core-only build → snapshot-only, graceful. - lib/agent/ask/snapshot.ts: a compact company_settings + deadlines block so a model that can't/won't call tools still answers status questions. Never carries figures (those come from the live tools). - ask-service attaches tools + snapshot when a userId is present and uses a tool-aware system prompt; the route calls ensureInitialized() so the registry is populated and threads userId/conversationId through. Works on Bedrock and on any local model with function-calling (Qwen). Tests: the anthropic hand-rolled loop (tool call → result → answer, is_error handling, step-budget forced answer), openai tool forwarding, the read-only adapter filter, the snapshot format, and the ask-service wiring. 457 agent+ai tests green, lint/guards clean. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
81 lines
2.9 KiB
TypeScript
81 lines
2.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
import type { SupabaseClient } from '@supabase/supabase-js'
|
|
|
|
const getDeadlines = vi.fn()
|
|
vi.mock('@/lib/deadlines/status-engine', () => ({
|
|
getDeadlinesNeedingAttention: (...a: unknown[]) => getDeadlines(...a),
|
|
}))
|
|
|
|
import { buildAssistantSnapshot } from '../snapshot'
|
|
|
|
function supabaseWith(settings: Record<string, unknown> | null): SupabaseClient {
|
|
const chain = {
|
|
select: () => chain,
|
|
eq: () => chain,
|
|
maybeSingle: async () => ({ data: settings, error: null }),
|
|
}
|
|
return { from: () => chain } as unknown as SupabaseClient
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks()
|
|
getDeadlines.mockResolvedValue({ overdue: [], actionNeeded: [] })
|
|
})
|
|
|
|
describe('buildAssistantSnapshot', () => {
|
|
it('summarises the company status line', async () => {
|
|
const snap = await buildAssistantSnapshot(
|
|
supabaseWith({
|
|
vat_registered: true,
|
|
moms_period: 'quarterly',
|
|
accounting_method: 'accrual',
|
|
pays_salaries: true,
|
|
}),
|
|
'c1',
|
|
)
|
|
expect(snap).toContain('momsregistrerad (momsperiod: quarterly)')
|
|
expect(snap).toContain('bokföringsmetod: fakturametod')
|
|
expect(snap).toContain('betalar löner')
|
|
})
|
|
|
|
it('handles a non-VAT, cash-method company', async () => {
|
|
const snap = await buildAssistantSnapshot(
|
|
supabaseWith({ vat_registered: false, accounting_method: 'cash', pays_salaries: false }),
|
|
'c1',
|
|
)
|
|
expect(snap).toContain('ej momsregistrerad')
|
|
expect(snap).toContain('bokföringsmetod: kontantmetod')
|
|
expect(snap).toContain('betalar inte löner')
|
|
})
|
|
|
|
it('lists deadlines that need attention (overdue first, capped)', async () => {
|
|
getDeadlines.mockResolvedValue({
|
|
overdue: [{ id: '1', title: 'Momsdeklaration', due_date: '2026-08-12', tax_deadline_type: 'vat' }],
|
|
actionNeeded: [{ id: '2', title: 'Arbetsgivardeklaration', due_date: '2026-08-17', tax_deadline_type: 'employer' }],
|
|
})
|
|
const snap = await buildAssistantSnapshot(supabaseWith(null), 'c1')
|
|
expect(snap).toContain('Deadlines som behöver åtgärd:')
|
|
expect(snap).toContain('Momsdeklaration (2026-08-12)')
|
|
expect(snap).toContain('Arbetsgivardeklaration (2026-08-17)')
|
|
})
|
|
|
|
it('is best-effort: a failing settings query still yields the deadlines line', async () => {
|
|
const throwing = {
|
|
from: () => ({
|
|
select: () => ({ eq: () => ({ maybeSingle: async () => { throw new Error('db down') } }) }),
|
|
}),
|
|
} as unknown as SupabaseClient
|
|
getDeadlines.mockResolvedValue({
|
|
overdue: [],
|
|
actionNeeded: [{ id: '2', title: 'Moms', due_date: '2026-09-12', tax_deadline_type: 'vat' }],
|
|
})
|
|
const snap = await buildAssistantSnapshot(throwing, 'c1')
|
|
expect(snap).toContain('Moms (2026-09-12)')
|
|
})
|
|
|
|
it('returns an empty string when there is nothing to say', async () => {
|
|
const snap = await buildAssistantSnapshot(supabaseWith(null), 'c1')
|
|
expect(snap).toBe('')
|
|
})
|
|
})
|