fix(mcp): flow-order signals in tool results: ask the bank, import history first (#1955)

E2E #5: the agent skipped the bank question (generic picker link) and
went straight to connect cards for a company whose fiscal year started
13 months ago (history import should come first). The skill says both,
but the agent follows tool results more reliably than skill prose, so
the rules now live in the results themselves:

- gnubok_lookup_company still_to_ask gains two flow questions: which
  bank (for the bank= deep link) and whether the bookkeeping lived in a
  previous system (SIE import BEFORE bank).
- gnubok_create_company's confirmed result computes days of history in
  the fiscal period: past 90 days it emits history_note (PSD2 cannot
  reach it; run gnubok_sie_preflight first) and reorders the remaining-
  setup message to (1) SIE import, (2) bank, (3) Skatteverket.
- gnubok_connect_bank called without bank= now opens its instructions
  with a nudge to ask for the bank and re-call for the direct consent
  link.

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:
Jakob Wennberg
2026-08-26 16:35:12 +02:00
committed by GitHub
parent cd9fb5b717
commit 6f0f4d1eea
4 changed files with 77 additions and 4 deletions
@@ -74,6 +74,18 @@ describe('onboarding connect-link tools', () => {
expect(result.connect_url).toBe('https://app.example.test/import?mode=psd2')
})
it('bank: nudges the agent to ask for the bank when none was passed, and not when one was', async () => {
const bare = (await bankTool.execute({}, COMPANY_ID, 'user-1', {
from: listClient([]).from,
} as never)) as Record<string, unknown>
expect(bare.instructions).toContain('BETTER LINK AVAILABLE')
const named = (await bankTool.execute({ bank: 'Swedbank' }, COMPANY_ID, 'user-1', {
from: listClient([]).from,
} as never)) as Record<string, unknown>
expect(named.instructions).not.toContain('BETTER LINK AVAILABLE')
})
it('bank: reports an active connection', async () => {
const { from } = listClient([
{ id: 'c1', bank_name: 'Swedbank', status: 'active', created_at: '2026-08-01T00:00:00Z' },
@@ -118,6 +118,35 @@ describe('gnubok_create_company', () => {
expect((result.next as Record<string, unknown>).tool).toBe('gnubok_load_skill')
})
it('orders history import before the bank when the fiscal period started over 90 days ago', async () => {
// The default calendar-year setup starts 1 January: from February on the
// period has >90 days of history the bank cannot deliver, so the created
// result must point at SIE-first ordering. Frozen mid-year so the test
// does not flip in January.
vi.useFakeTimers()
vi.setSystemTime(new Date(`${new Date().getFullYear()}-08-15T12:00:00Z`))
try {
const supabase = supabaseWithTeam(TEAM_ID)
mocks.createCompanyCore.mockImplementation(
async (_client: unknown, _input: unknown, createRow: () => Promise<{ data: unknown; error: unknown }>) => {
const { data } = await createRow()
return { companyId: data as string }
}
)
const result = (await tool.execute(
{ ...setup, confirm: true },
'',
'user-1',
supabase as never
)) as Record<string, unknown>
expect(result.history_note).toContain('gnubok_sie_preflight')
expect(result.message).toContain('IN ORDER')
} finally {
vi.useRealTimers()
}
})
it('refuses a VAT-registered company without a moms period before touching the database', async () => {
const supabase = supabaseWithTeam(TEAM_ID)
await expect(
@@ -86,6 +86,10 @@ describe('gnubok_lookup_company', () => {
// accounting_method defaults by form in create_company (flagged in its
// preview) and is deliberately not a question here.
expect(ask.some((q) => q.startsWith('accounting_method'))).toBe(false)
// The two flow questions (E2E #5): bank for the direct consent link,
// previous system for the import-before-bank ordering.
expect(ask.some((q) => q.startsWith('bank ('))).toBe(true)
expect(ask.some((q) => q.startsWith('previous_system'))).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)
+32 -4
View File
@@ -3211,6 +3211,16 @@ export const tools: McpTool[] = [
// accounting_method is deliberately NOT in this list: gnubok_create_company
// defaults it by form (AB accrual, EF cash) and flags the default in the
// preview, where the user confirms or overrides it in the same "ja".
//
// Two flow questions the agent must ask in the SAME round (E2E #5: the
// agent skipped both and the user landed on a generic bank picker with
// no history import):
stillToAsk.push(
"bank (vilken bank har företaget? pass it to gnubok_connect_bank as bank so the connect link opens that bank's consent directly)"
)
stillToAsk.push(
'previous_system (har bokföringen legat i ett annat system? if yes: SIE import comes FIRST, before any bank connect: PSD2 history rarely covers the fiscal year)'
)
const startMonth = parseStartMonthDay(lookup.fiscalYear?.startMonthDay)
// No closed fiscal period in the registry = no annual report filed yet
@@ -3382,15 +3392,30 @@ export const tools: McpTool[] = [
throw Object.assign(new Error(result.error), { code })
}
// A fiscal period that started well before today means bookkeeping
// already happened somewhere: history import comes BEFORE the bank
// (PSD2 reaches ~90 days back; the rest only arrives via SIE).
const periodStartMs = Date.parse(plan.fiscalPeriod.startDate)
const daysOfHistory = Number.isFinite(periodStartMs)
? Math.floor((Date.now() - periodStartMs) / 86_400_000)
: 0
const historyFirst = daysOfHistory > 90
return {
created: true,
company_id: result.companyId,
...preview,
trial: 'A 30-day trial with every paid capability (bank sync, Skatteverket, AI, e-mail) is active from now.',
message:
'Company created and ready for bookkeeping. This connection uses it automatically from the next call. Remaining setup: bank connection, Skatteverket connection, and the first transactions.',
...(historyFirst
? {
history_note: `The fiscal period started ${daysOfHistory} days ago but bank PSD2 history reaches ~90 days: ask which system the bookkeeping lived in and run the SIE import (gnubok_sie_preflight) BEFORE connecting the bank.`,
}
: {}),
message: historyFirst
? 'Company created; this connection uses it automatically from the next call. Remaining setup IN ORDER: (1) import existing bookkeeping via SIE (see history_note), (2) bank connection (pass bank=<name>), (3) Skatteverket.'
: 'Company created and ready for bookkeeping. This connection uses it automatically from the next call. Remaining setup: bank connection (pass bank=<name> for a direct consent link), Skatteverket connection, and the first transactions.',
next: {
description: 'Load the onboarding skill for the remaining setup steps (bank, Skatteverket, first transactions).',
description: 'Load the onboarding skill for the remaining setup steps (history import, bank, Skatteverket, first transactions).',
tool: 'gnubok_load_skill',
args: { slug: 'onboarding' },
},
@@ -3458,7 +3483,10 @@ export const tools: McpTool[] = [
instructions:
active.length > 0
? 'At least one bank is connected and syncing. To add another bank, give the user the connect_url.'
: 'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there. With bank passed, the link starts that bank\'s consent directly; otherwise they pick the bank first. They approve with BankID (consent up to 180 days), then CONFIRM WHICH ACCOUNTS to sync in the dialog that opens; the first transactions arrive within a minute of that save. Banks cap PSD2 history (often ~90 days): older history comes via SIE import, not the bank. When the user is back, call this tool again to verify status=active, then continue straight to gnubok_list_uncategorized_transactions without asking.',
: (requestedBank
? ''
: 'BETTER LINK AVAILABLE: if you know (or can ask) which bank the company uses, call this tool again with bank=<name>; the link then opens that bank\'s consent directly instead of a picker. ') +
'On claude.ai/Claude Desktop a connect card with an open-in-browser button is rendered with this result; on other clients give the user the connect_url as a link. They must be logged in to Accounted there. They approve with BankID (consent up to 180 days), then CONFIRM WHICH ACCOUNTS to sync in the dialog that opens; the first transactions arrive within a minute of that save. Banks cap PSD2 history (often ~90 days): older history comes via SIE import, not the bank. When the user is back, call this tool again to verify status=active, then continue straight to gnubok_list_uncategorized_transactions without asking.',
}
},
},