feat(mcp): org-number-first onboarding: gnubok_lookup_company prefills the company from the registry (#1940)
The onboarding flow now mirrors the web wizard: ask for the organisationsnummer first, look the company up in the public registry (one TIC Lens call through the extracted extensions/general/tic/lib/lookup.ts, shared with the /lookup HTTP route), and present the facts for confirmation instead of interrogating the user. The new gnubok_lookup_company tool (companies:read, company-independent, default catalog) returns the registry facts, a prefilled suggested_create_company_input, and a still_to_ask list that encodes the same fact-vs-question rules as lib/onboarding-journey/reducer.ts: F-skatt is a fact both ways, VAT is a fact only when positively registered (ML 17 kap 24 paragraf), moms period and accounting method are always asked, an enskild firma's verksamhetsnamn is the user's choice, and a known fiscal year becomes a confirm question. Registry outages degrade to the full question list instead of failing onboarding. The onboarding skill and the plugin's /accounted:setup command are updated to the orgnr-first flow (plugin 1.2.0). tools/list ceiling bumped 61.2K to 61.5K with the reason documented in the bench. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
00e7ac92ae
commit
8ddc77fdfd
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* gnubok_lookup_company: the org-number-first onboarding entry point. Tests
|
||||
* the fact-vs-question split mirrored from lib/onboarding-journey/reducer.ts:
|
||||
* registry facts are presented for confirmation, VAT is a fact only when
|
||||
* positively registered, moms period and accounting method are always asked.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TOOL_SCOPE_MAP } from '@/lib/auth/api-keys'
|
||||
import { TICAPIError } from '@/extensions/general/tic/lib/tic-types'
|
||||
import type { CompanyLookupResult } from '@/lib/company-lookup/types'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
lookupCompanyByOrgNumber: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/general/tic/lib/lookup', () => ({
|
||||
lookupCompanyByOrgNumber: (...args: unknown[]) => mocks.lookupCompanyByOrgNumber(...args),
|
||||
}))
|
||||
|
||||
import { tools } from '../server'
|
||||
import { isCompanyDependentTool } from '../company-routing'
|
||||
|
||||
const tool = tools.find((t) => t.name === 'gnubok_lookup_company')!
|
||||
|
||||
function found(overrides: Partial<CompanyLookupResult> = {}): CompanyLookupResult {
|
||||
return {
|
||||
companyName: 'Testbolaget AB',
|
||||
isCeased: false,
|
||||
address: { street: 'Storgatan 1', postalCode: '111 22', city: 'Stockholm' },
|
||||
registration: { fTax: true, vat: true },
|
||||
bankAccounts: [],
|
||||
email: null,
|
||||
phone: null,
|
||||
sniCodes: [{ code: '62010', name: 'Dataprogrammering' }],
|
||||
fiscalYear: { startMonthDay: '01-01', endMonthDay: '12-31' },
|
||||
legalEntityType: 'AB',
|
||||
registrationDate: Date.UTC(2018, 2, 1),
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function run(orgNumber: string) {
|
||||
return (await tool.execute({ org_number: orgNumber }, '', 'user-1', {} as never)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
}
|
||||
|
||||
describe('gnubok_lookup_company', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('is a companies:read, company-independent read tool', () => {
|
||||
expect(tool).toBeDefined()
|
||||
expect(TOOL_SCOPE_MAP.gnubok_lookup_company).toBe('companies:read')
|
||||
expect(isCompanyDependentTool('gnubok_lookup_company')).toBe(false)
|
||||
expect(tool.annotations.readOnlyHint).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects a malformed organisationsnummer without spending a registry call', async () => {
|
||||
await expect(run('12345')).rejects.toMatchObject({ code: 'VALIDATION_ERROR' })
|
||||
expect(mocks.lookupCompanyByOrgNumber).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('treats a VAT-registered AB as facts: prefill everything, ask only period and method', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(found())
|
||||
const result = await run('556000-0001')
|
||||
|
||||
expect(result.status).toBe('found')
|
||||
expect(result.warnings).toEqual([])
|
||||
expect(result.suggested_create_company_input).toMatchObject({
|
||||
name: 'Testbolaget AB',
|
||||
entity_type: 'aktiebolag',
|
||||
org_number: '5560000001',
|
||||
f_skatt: true,
|
||||
vat_registered: true,
|
||||
address_line1: 'Storgatan 1',
|
||||
postal_code: '111 22',
|
||||
city: 'Stockholm',
|
||||
fiscal_year_start_month: 1,
|
||||
})
|
||||
|
||||
const ask = result.still_to_ask as string[]
|
||||
expect(ask.some((q) => q.startsWith('moms_period ('))).toBe(true)
|
||||
expect(ask.some((q) => q.startsWith('accounting_method'))).toBe(true)
|
||||
// Registry facts are confirmed, never re-asked.
|
||||
expect(ask.some((q) => q.startsWith('entity_type'))).toBe(false)
|
||||
expect(ask.some((q) => q.startsWith('vat_registered'))).toBe(false)
|
||||
expect(ask.some((q) => q.startsWith('name'))).toBe(false)
|
||||
// The known fiscal year becomes a confirmation, not an open question.
|
||||
expect(ask.some((q) => q.includes('stämmer detta?'))).toBe(true)
|
||||
})
|
||||
|
||||
it('never silently defaults VAT: absence of registration is a question, not a fact', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
||||
found({ registration: { fTax: false, vat: false } })
|
||||
)
|
||||
const result = await run('5560000001')
|
||||
|
||||
const suggested = result.suggested_create_company_input as Record<string, unknown>
|
||||
expect('vat_registered' in suggested).toBe(false)
|
||||
// f_skatt=false IS a fact (the registry answered), unlike vat=false.
|
||||
expect(suggested.f_skatt).toBe(false)
|
||||
|
||||
const ask = result.still_to_ask as string[]
|
||||
expect(ask.some((q) => q.startsWith('vat_registered'))).toBe(true)
|
||||
})
|
||||
|
||||
it('lets the user pick the enskild firma verksamhetsnamn instead of assuming the registered name', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
||||
found({ legalEntityType: 'EF', companyName: 'Anna Andersson' })
|
||||
)
|
||||
const result = await run('5560000001')
|
||||
|
||||
const ask = result.still_to_ask as string[]
|
||||
expect(ask.some((q) => q.startsWith('name'))).toBe(true)
|
||||
// The registered name still arrives as the suggestion.
|
||||
expect((result.suggested_create_company_input as Record<string, unknown>).name).toBe(
|
||||
'Anna Andersson'
|
||||
)
|
||||
})
|
||||
|
||||
it('flags an unsupported legal form and asks for the entity type', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(found({ legalEntityType: 'HB' }))
|
||||
const result = await run('5560000001')
|
||||
|
||||
expect((result.warnings as string[]).some((w) => w.includes('not supported'))).toBe(true)
|
||||
const suggested = result.suggested_create_company_input as Record<string, unknown>
|
||||
expect('entity_type' in suggested && suggested.entity_type !== undefined).toBe(false)
|
||||
expect((result.still_to_ask as string[]).some((q) => q.startsWith('entity_type'))).toBe(true)
|
||||
})
|
||||
|
||||
it('warns about a ceased company but lets the flow continue', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(found({ isCeased: true }))
|
||||
const result = await run('5560000001')
|
||||
|
||||
expect(result.status).toBe('found')
|
||||
expect((result.warnings as string[]).some((w) => w.includes('CEASED'))).toBe(true)
|
||||
})
|
||||
|
||||
it('suggests a first fiscal year for a recently registered company with no closed period', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(
|
||||
found({ fiscalYear: null, registrationDate: Date.now() - 60 * 24 * 60 * 60 * 1000 })
|
||||
)
|
||||
const result = await run('5560000001')
|
||||
|
||||
const ask = result.still_to_ask as string[]
|
||||
expect(ask.some((q) => q.includes('first fiscal year') || q.includes('first_fiscal_year'))).toBe(
|
||||
true
|
||||
)
|
||||
const suggested = result.suggested_create_company_input as Record<string, unknown>
|
||||
expect('fiscal_year_start_month' in suggested).toBe(false)
|
||||
})
|
||||
|
||||
it('returns not_found with the full question list when no company matches', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockResolvedValue(null)
|
||||
const result = await run('5560000001')
|
||||
|
||||
expect(result.status).toBe('not_found')
|
||||
expect(result.suggested_create_company_input).toBeNull()
|
||||
expect((result.still_to_ask as string[]).length).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
|
||||
it('degrades to unavailable on a TIC error instead of failing the onboarding', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockRejectedValue(
|
||||
new TICAPIError('not configured', undefined, 'NOT_CONFIGURED')
|
||||
)
|
||||
const result = await run('5560000001')
|
||||
|
||||
expect(result.status).toBe('unavailable')
|
||||
expect((result.warnings as string[])[0]).toContain('NOT_CONFIGURED')
|
||||
expect((result.still_to_ask as string[]).length).toBeGreaterThanOrEqual(6)
|
||||
})
|
||||
|
||||
it('rethrows non-TIC errors', async () => {
|
||||
mocks.lookupCompanyByOrgNumber.mockRejectedValue(new Error('boom'))
|
||||
await expect(run('5560000001')).rejects.toThrow('boom')
|
||||
})
|
||||
})
|
||||
@@ -221,9 +221,14 @@ describe('tools/list payload size guard', () => {
|
||||
// tools/list, so catalogVisibility 'search' means discover-only there;
|
||||
// the onboarding flow dead-ended on client-side tool-not-found when
|
||||
// the skill pointed at them (SilverPark session, 2026-08-26).
|
||||
// * 61.2K to 61.5K with gnubok_lookup_company (org-number-first
|
||||
// onboarding): default-catalog for the same reason as the connect
|
||||
// tools; the onboarding skill's first instruction is to call it, and
|
||||
// a search-only tool is uncallable on Claude.ai. Descriptions were
|
||||
// trimmed first; the tool costs ~265 tokens against ~0 headroom.
|
||||
// Long-term answer to growth is leaning harder on gnubok_search_tools: if this
|
||||
// fires again, prefer trimming descriptions or making a tool opt-in via search
|
||||
// before bumping further.
|
||||
expect(approxTokens).toBeLessThan(61_200)
|
||||
expect(approxTokens).toBeLessThan(61_500)
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user