From 9cb1d105e3f2e52e957e7e99a284b2a88f294104 Mon Sep 17 00:00:00 2001 From: Jakob Wennberg Date: Sun, 6 Sep 2026 18:54:47 +0200 Subject: [PATCH] fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them (#2204) (#2343) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them Self-hosted deployments on an OpenAI-compatible provider (or with no AI configured) still showed every entry point into the tool-loop runtime behind /api/agent/invoke, which answers 503 there. The capability lived server-side only (getAiStatus().assistantAvailable); no UI could read it. Hand the flag to the client through CompanyContext (useAssistantAvailable, beside the paid-capability gate) and gate each entry point that opens AgentChat: the bookkeeping page's "Skapa med assistent" and "Med assistenten", the inbox workspace's "Fråga assistenten" doors, /chat/intake and /chat/new?intent=. The floating trigger falls back to general help (the single-call console runs on any provider) instead of hiding, and AgentChat itself never fires an invoke without the runtime, so a resumed thread or a forgotten entry point shows a notice instead of a 503. The Hem checklist's "Anslut till Claude" step renders only where the assistant runs on Claude and the mcp-server extension is on. Provider-agnostic AI (ask console, categorization, extraction) and the server-side 503 are unchanged; on hosted the flag is true and nothing changes. Closes #2204 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_019SaJfqNi4VmsG8FMKq99G6 * test(ai): use a placeholder that cannot match an Anthropic key shape The new direct-Anthropic status test assigned a string in the exact format of a live API key, which trips secret scanners on every run. The config only reads presence, so any non-empty string exercises the path. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5.1 --- app/(dashboard)/bookkeeping/page.tsx | 27 +++++++++++++---- app/(dashboard)/chat/intake/page.tsx | 5 ++++ app/(dashboard)/chat/new/page.tsx | 10 ++++++- app/(dashboard)/layout.tsx | 10 +++++++ components/agent/AgentChat.tsx | 29 ++++++++++++++---- components/agent/AgentTrigger.tsx | 8 +++-- .../general/InvoiceInboxWorkspace.tsx | 9 ++++-- components/onboarding/NewUserChecklist.tsx | 30 ++++++++++++++++--- contexts/CompanyContext.tsx | 23 ++++++++++++++ .../intents/__tests__/route-mapping.test.ts | 27 +++++++++++++++++ lib/agent/intents/route-mapping.ts | 17 ++++++++++- lib/ai/__tests__/config.test.ts | 10 +++++++ lib/onboarding/__tests__/checklist.test.ts | 25 +++++++++++++--- lib/onboarding/checklist.ts | 14 +++++++-- messages/en.json | 3 ++ messages/sv.json | 3 ++ 16 files changed, 220 insertions(+), 30 deletions(-) diff --git a/app/(dashboard)/bookkeeping/page.tsx b/app/(dashboard)/bookkeeping/page.tsx index f92bd111..f6ded70e 100644 --- a/app/(dashboard)/bookkeeping/page.tsx +++ b/app/(dashboard)/bookkeeping/page.tsx @@ -5,6 +5,8 @@ import { useState, useEffect, useMemo } from 'react' import dynamic from 'next/dynamic' import { useRouter, useSearchParams } from 'next/navigation' import { useTranslations } from 'next-intl' +import { cn } from '@/lib/utils' +import { useAssistantAvailable } from '@/contexts/CompanyContext' import JournalEntryList from '@/components/bookkeeping/JournalEntryList' import { StartCard } from '@/components/dashboard/StartCard' import { type FormLine } from '@/components/bookkeeping/JournalEntryForm' @@ -39,6 +41,7 @@ const TemplateBookDialog = dynamic( // SplitButton modes for "Nytt verifikat" (concept scene 9). The last-used // mode persists per user in ui_state.create_mode.bookkeeping. const CREATE_MODES = ['tomt', 'mall', 'assistent'] as const +type CreateMode = (typeof CREATE_MODES)[number] interface NextVoucher { next: number @@ -75,6 +78,16 @@ export default function BookkeepingPage() { const tStart = useTranslations('start_cards') const { openAgentSheet } = useAgentSheet() const { uiState, loaded: uiStateLoaded } = useUiState() + // The 'assistent' mode opens the tool-loop runtime (verifikation.draft via + // /api/agent/invoke), which an OpenAI-compatible or unconfigured deployment + // cannot run (#2204): drop it from the split button and the pristine cards + // rather than offer a door into a 503. One list feeds both surfaces, and a + // persisted last-used 'assistent' falls back to 'tomt' through + // resolveInitialMode's validity check. + const assistantAvailable = useAssistantAvailable() + const createModes: readonly CreateMode[] = assistantAvailable + ? CREATE_MODES + : CREATE_MODES.filter((mode) => mode !== 'assistent') // React to copy_from in URL: switch tab, fetch source entry, then clean URL. // useSearchParams keeps this reactive even when navigation happens within the @@ -234,7 +247,7 @@ export default function BookkeepingPage() { // to the persisted last-used mode. key={uiStateLoaded ? 'loaded' : 'initial'} persistKey="bookkeeping" - initialModeKey={resolveInitialMode(uiState, 'bookkeeping', CREATE_MODES, 'tomt')} + initialModeKey={resolveInitialMode(uiState, 'bookkeeping', createModes, 'tomt')} options={[ { key: 'tomt', @@ -267,7 +280,7 @@ export default function BookkeepingPage() { contextRef: 'verifikation:new', }), }, - ]} + ].filter((option) => (createModes as readonly string[]).includes(option.key))} /> } /> @@ -288,9 +301,9 @@ export default function BookkeepingPage() { primary={{ label: tStart('bookkeeping_primary'), href: '/import?mode=migration' }} secondary={{ label: tStart('bookkeeping_secondary'), href: '/import?mode=sie' }} /> - {/* The split button's three create modes, laid out as cards so the - pristine page shows what the ledger can do instead of a bare table. */} -
+ {/* The split button's create modes, laid out as cards so the pristine + page shows what the ledger can do instead of a bare table. */} +
{( [ ['mall', () => setShowTemplateDialog(true)], @@ -307,7 +320,9 @@ export default function BookkeepingPage() { }, ], ] as const - ).map(([mode, onClick]) => ( + ) + .filter(([mode]) => createModes.includes(mode)) + .map(([mode, onClick]) => ( {/* Secondary actions: outlined, so they read as buttons */}
- {identity.isVerified && ( + {identity.isVerified && assistantAvailable && (