fix(agent): hide Anthropic-only assistant surfaces where the provider cannot run them (#2204) (#2343)

* 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 <noreply@anthropic.com>
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 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 18:54:47 +02:00
committed by GitHub
parent 272d19b287
commit 9cb1d105e3
16 changed files with 220 additions and 30 deletions
+21 -6
View File
@@ -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. */}
<div className="grid gap-4 sm:grid-cols-3">
{/* 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. */}
<div className={cn('grid gap-4', createModes.length === 3 ? 'sm:grid-cols-3' : 'sm:grid-cols-2')}>
{(
[
['mall', () => setShowTemplateDialog(true)],
@@ -307,7 +320,9 @@ export default function BookkeepingPage() {
},
],
] as const
).map(([mode, onClick]) => (
)
.filter(([mode]) => createModes.includes(mode))
.map(([mode, onClick]) => (
<button
key={mode}
type="button"
+5
View File
@@ -1,5 +1,6 @@
import { redirect } from 'next/navigation'
import ChatIntakeStarter from '@/components/agent/ChatIntakeStarter'
import { getAiStatus } from '@/lib/ai'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic'
@@ -18,6 +19,10 @@ export default async function ChatIntakePage() {
])
if (!user) redirect('/login')
if (!companyId) redirect('/onboarding')
// onboarding.intake runs on the tool-loop runtime; without it (#2204) the
// starter would fire an invoke that answers 503. The agent is built by now,
// so the general-help console on /chat is the working next step.
if (!getAiStatus().assistantAvailable) redirect('/chat')
return <ChatIntakeStarter />
}
+9 -1
View File
@@ -1,6 +1,8 @@
import { redirect } from 'next/navigation'
import ChatNewStarter from '@/components/agent/ChatNewStarter'
import { getIntent } from '@/lib/agent/intents/registry'
import { CHAT_INTENT_ID } from '@/lib/agent/ask/persist'
import { getAiStatus } from '@/lib/ai'
import { getDashboardAuthContext, getDashboardCompanyId } from '../../request-context'
export const dynamic = 'force-dynamic'
@@ -28,7 +30,13 @@ export default async function ChatNewPage({ searchParams }: PageProps) {
// Validate against the registry: a bogus ?intent= would otherwise render the
// chat shell and then fail at invoke with a 400, which reads as "Anna is broken"
// rather than "bad link". Fall back to general help instead.
const intent = getIntent(requested) ? requested : 'general.help'
// Specialized intents run on the tool-loop runtime; where the deployment
// cannot run it (#2204) the single-call console takes the same prompt as
// general help instead of a chat that fails at invoke.
const intent =
getIntent(requested) && (requested === CHAT_INTENT_ID || getAiStatus().assistantAvailable)
? requested
: CHAT_INTENT_ID
const prompt = typeof sp.prompt === 'string' ? sp.prompt : ''
return <ChatNewStarter intentId={intent} seedUserMessage={prompt} />
+10
View File
@@ -20,6 +20,7 @@ import { getExtensionNavItems } from '@/lib/extensions/sectors'
import { CompanyProvider, type ByraTeamRef } from '@/contexts/CompanyContext'
import { ReferenceDataSeed } from '@/components/providers/ReferenceDataSeed'
import { getCompanyEntitlements } from '@/lib/entitlements/has-capability'
import { getAiStatus } from '@/lib/ai'
import { getDashboardNavFlags } from '@/lib/dashboard/nav-flags'
import { getBranding } from '@/lib/branding/service'
import { resolveBrandByHost } from '@/lib/branding/resolve'
@@ -77,6 +78,12 @@ export default async function DashboardLayout({
redirect('/login')
}
// Deployment-level: can the in-app assistant (the tool-loop runtime behind
// /api/agent/invoke) run here at all? Env reads only. Handed to every
// CompanyProvider below so the entry points that open that runtime can hide
// instead of leading the user into its 503 (#2204).
const assistantAvailable = getAiStatus().assistantAvailable
// Resolve active company from user_preferences (authoritative). The
// `gnubok-company-id` cookie is intentionally no longer consulted here:
// `getActiveCompanyId` reads from user_preferences, matching what RLS
@@ -196,6 +203,7 @@ export default async function DashboardLayout({
foreignCompanies: [],
isSandbox: false,
capabilities: [],
assistantAvailable,
trialEndsAt: null,
entitlementState: 'none' as const,
trialExpiredAt: null,
@@ -351,6 +359,7 @@ export default async function DashboardLayout({
foreignCompanies: [],
isSandbox: false,
capabilities: [],
assistantAvailable,
trialEndsAt: null,
entitlementState: 'none' as const,
trialExpiredAt: null,
@@ -518,6 +527,7 @@ export default async function DashboardLayout({
foreignCompanies,
isSandbox,
capabilities: entitlements.capabilities,
assistantAvailable,
trialEndsAt: entitlements.trialEndsAt,
entitlementState: entitlements.entitlementState,
trialExpiredAt: entitlements.trialExpiredAt,
+23 -6
View File
@@ -18,7 +18,8 @@ import {
import dynamic from 'next/dynamic'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { useTranslations } from 'next-intl'
import { useAssistantAvailable, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import ApprovalCard from './ApprovalCard'
@@ -251,6 +252,11 @@ export default function AgentChat({
// though it were the new one.
const turnStartRef = useRef(0)
const hasAi = useCapability(CAPABILITY.ai)
// Whether the deployment can run this runtime at all (#2204). Every entry
// point hides itself when it cannot; this is the last line of defense for a
// resumed thread or a deep link: never fire an invoke that answers 503.
const assistantAvailable = useAssistantAvailable()
const tChat = useTranslations('agent_chat')
const [input, setInput] = useState('')
const [streaming, setStreaming] = useState(false)
// Turn boundaries for the status channel are derived from the streaming flag
@@ -312,9 +318,10 @@ export default function AgentChat({
const hasResumeState = !!initialConversationId
if (hasResumeState) return
// Paywall: never auto-fire the first invoke without the ai capability;
// the composer is already replaced by the upgrade note.
if (!hasAi) return
// Paywall / no runtime: never auto-fire the first invoke without the ai
// capability or the tool-loop runtime; the composer is already replaced
// by the upgrade note or the unavailable notice.
if (!hasAi || !assistantAvailable) return
// Seed-message path: render the user's pre-baked starter in the timeline
// and send it as the first turn's user_message (skips intent.capture +
@@ -543,7 +550,7 @@ export default function AgentChat({
}
function handleRegenerate() {
if (!hasAi) return
if (!hasAi || !assistantAvailable) return
// Re-run the last user message and let the agent produce a fresh
// response. UI truncates back to the last user message; DB rows are
// append-only, so the previous assistant turn stays in agent_messages
@@ -611,7 +618,7 @@ export default function AgentChat({
// user turn so the agent re-proposes inline: no synthetic user bubble (we
// don't add a user row, and the turn is persisted hidden).
function handleCorrection(correctionMessage: string) {
if (!hasAi) return
if (!hasAi || !assistantAvailable) return
void startTurn({ conversationId, userMessage: correctionMessage, hidden: true })
}
@@ -865,6 +872,7 @@ export default function AgentChat({
showRegenerate={
!streaming &&
hasAi &&
assistantAvailable &&
i === lastAssistantIdx &&
m.role === 'assistant' &&
m.text.length > 0
@@ -924,6 +932,15 @@ export default function AgentChat({
<div className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
<UpgradeNote>AI-assistenten kräver ett abonnemang.</UpgradeNote>
</div>
) : !assistantAvailable ? (
/* No tool-loop runtime on this deployment (#2204): same swap as the
paywall, so a resumed or deep-linked thread never offers a send
that answers 503. */
<div className="border-t border-border px-5 pt-4 pb-[calc(env(safe-area-inset-bottom,0px)+1rem)]">
<p className="text-sm text-muted-foreground" role="status">
{tChat('assistant_unavailable')}
</p>
</div>
) : (
<form
// padding-bottom = base 1rem + safe-area-inset-bottom on phones so
+6 -2
View File
@@ -7,7 +7,7 @@ import { Loader2, X } from 'lucide-react'
import AgentAvatar from './AgentAvatar'
import { collapsedStatusLabel } from './agent-status'
import { routeToIntent } from '@/lib/agent/intents/route-mapping'
import { useCapability } from '@/contexts/CompanyContext'
import { useAssistantAvailable, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
// Floating trigger sits above the page bottom-right, opens the AgentSheet when
@@ -48,6 +48,7 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
const pathname = usePathname()
const router = useRouter()
const hasAi = useCapability(CAPABILITY.ai)
const assistantAvailable = useAssistantAvailable()
// Read the dismissal AFTER hydration (effect, not state initializer): the
// server always renders the pill, so an initializer that reads
@@ -147,7 +148,10 @@ export default function AgentTrigger({ hidden = false }: { hidden?: boolean }) {
if (!identity.isVerified) return null
const name = identity.displayName?.trim() || 'min assistent'
const dispatch = routeToIntent(pathname)
// Without the tool-loop runtime (OpenAI-compatible or unconfigured AI, #2204)
// every route dispatches to general.help: the single-call console runs on
// any provider, so the pill stays but never opens a chat that would 503.
const dispatch = routeToIntent(pathname, { assistantAvailable })
// AI assistant runs on a paid cloud service. Without the capability, opening
// the sheet would land the user in a chat whose send is dead. Keep the FAB
// visible (it's the conversion surface) but route it to billing instead.
@@ -61,7 +61,7 @@ import { useReceiptHunt } from '@/components/extensions/general/use-receipt-hunt
import { createClient } from '@/lib/supabase/client'
import { fetchWithTimeout } from '@/lib/http/fetch-with-timeout'
import { copyInboxAddress, type AddressCopyState } from '@/components/extensions/general/inbox-address-copy'
import { useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
import { useAssistantAvailable, useCapability, useCompanyOptional } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { useBranding } from '@/lib/branding/brand-context'
import type { WorkspaceComponentProps } from '@/lib/extensions/workspace-registry'
@@ -440,6 +440,9 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
// Its own input: sharing the header's would upload without the purchase.
const purchaseFileInputRef = useRef<HTMLInputElement | null>(null)
const { openAgentSheet, identity } = useAgentSheet()
// Both "Fråga assistenten" doors below open the tool-loop runtime
// (/api/agent/invoke); hide them where the deployment cannot run it (#2204).
const assistantAvailable = useAssistantAvailable()
const [items, setItems] = useState<InboxItem[]>([])
const [isLoading, setIsLoading] = useState(true)
@@ -1869,7 +1872,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
</Button>
{/* Secondary actions: outlined, so they read as buttons */}
<div className="flex items-center gap-2">
{identity.isVerified && (
{identity.isVerified && assistantAvailable && (
<Button
variant="outline"
size="sm"
@@ -2197,7 +2200,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
await Promise.all([fetchItems(), handleSelect(targetId)])
}}
onAskAssistant={
identity.isVerified
identity.isVerified && assistantAvailable
? (transactionId) => {
openAgentSheet({
intentId: 'transaction.categorization',
+26 -4
View File
@@ -23,7 +23,7 @@ import {
type VatDeadlineLine,
} from '@/lib/onboarding/checklist'
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
import { useCapability } from '@/contexts/CompanyContext'
import { useAssistantAvailable, useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { InitialSetupPath, InitialSetupState } from '@/types'
import { useBranding } from '@/lib/branding/brand-context'
@@ -97,6 +97,7 @@ export default function NewUserChecklist({
const showError = useErrorToast()
const { formatDateLong } = useFormat()
const hasAi = useCapability(CAPABILITY.ai)
const assistantAvailable = useAssistantAvailable()
const [state, setState] = useState(initialState)
const [saving, setSaving] = useState<InitialSetupPath | 'dismiss' | 'complete' | null>(null)
// The completion signature: 'verdict' shows the orb check-morph and the
@@ -121,6 +122,21 @@ export default function NewUserChecklist({
const hasSkatteverket = ENABLED_EXTENSION_IDS.has('skatteverket')
const hasInbox = ENABLED_EXTENSION_IDS.has('invoice-inbox')
const hasWhatsApp = ENABLED_EXTENSION_IDS.has('whatsapp-inbox')
// The Claude step is a pitch for running the books with Claude over the MCP
// server. It goes when either half is missing: no mcp-server extension (the
// connector URL would 404) or a deployment whose AI runs on another provider
// (getAiStatus().assistantAvailable false, #2204), where steering every new
// user to claude.ai contradicts the operator's own choice. Settings → API &
// MCP keeps the connector for anyone who wants it anyway.
const hasClaudeStep = assistantAvailable && ENABLED_EXTENSION_IDS.has('mcp-server')
// Which step closes the thread (carries no spine below it).
const lastStep = hasClaudeStep
? 'assistant'
: hasInbox
? 'receipts'
: hasSkatteverket
? 'skv'
: 'bank'
const persist = async (
body: Record<string, unknown>,
@@ -150,10 +166,11 @@ export default function NewUserChecklist({
const step1Done = hasBookkeepingImported || state.path === 'fresh'
const step2Done = hasBankConnected
// Companies built without the skatteverket/inbox extensions skip those steps.
// Companies built without the skatteverket/inbox extensions skip those
// steps; so does the Claude step where it does not render.
const step3Done = !hasSkatteverket || hasSkatteverketConnected
const step4Done = !hasInbox || hasInboxItems
const step5Done = hasMcpKey
const step5Done = !hasClaudeStep || hasMcpKey
useEffect(() => {
// The block retires itself once every step is done; Dölj remains the
@@ -192,7 +209,7 @@ export default function NewUserChecklist({
}
}, [retiring])
const numbers = checklistNumbers({ hasSkatteverket, hasInbox })
const numbers = checklistNumbers({ hasSkatteverket, hasInbox, hasAssistant: hasClaudeStep })
const stepCount = numbers.count
if (state.dismissedAt) return null
@@ -346,6 +363,7 @@ export default function NewUserChecklist({
done={step2Done}
active={activeStep === 2}
title={t('step_bank_title')}
last={lastStep === 'bank'}
action={(variant) => (
<Button
size="sm"
@@ -387,6 +405,7 @@ export default function NewUserChecklist({
done={step3Done}
active={activeStep === 3}
title={t('step_skv_title')}
last={lastStep === 'skv'}
action={(variant) => (
<Button size="sm" variant={variant} asChild>
{/* The authorize endpoint redirects off-site to Skatteverket. */}
@@ -430,6 +449,7 @@ export default function NewUserChecklist({
done={step4Done}
active={activeStep === 4}
title={t('step_receipts_title')}
last={lastStep === 'receipts'}
action={(variant) => (
<Button size="sm" variant={variant} onClick={goReceipts}>
{t('step_receipts_action')}
@@ -445,6 +465,7 @@ export default function NewUserChecklist({
</Step>
)}
{hasClaudeStep && (
<Step
number={numbers.assistant}
done={step5Done}
@@ -504,6 +525,7 @@ export default function NewUserChecklist({
>
{t('step_claude_description')}
</Step>
)}
</ol>
</section>
)
+23
View File
@@ -49,6 +49,16 @@ interface CompanyContextValue {
isSandbox: boolean
/** PAID capability keys the active company currently holds (entitled + enabled). */
capabilities: CapabilityKey[]
/**
* Whether this deployment can run the in-app assistant, i.e. the tool-loop
* runtime behind /api/agent/invoke: getAiStatus().assistantAvailable, false
* without AI credentials or on an OpenAI-compatible endpoint (#2204).
* Deployment-level, not per company. Gates ONLY the surfaces that open
* that runtime; provider-agnostic AI (the single-call ask console,
* categorization, document extraction) never reads it. The route's 503
* stays the real enforcement.
*/
assistantAvailable: boolean
/**
* Trial expiry while the trial is the company's only source of paid access;
* null when paying/comped or after the trial lapsed. Drives the countdown
@@ -111,3 +121,16 @@ export function useCapability(key: CapabilityKey): boolean {
if (!ctx) return true
return ctx.capabilities.includes(key)
}
/**
* Whether the deployment can run the in-app assistant (see
* CompanyContextValue.assistantAvailable). Read it at every entry point that
* opens AgentChat / /api/agent/invoke; the single-call console (general.help)
* runs on any provider and does not need it. Fail-open outside a
* CompanyProvider for the same reason as useCapability: the server answers 503.
*/
export function useAssistantAvailable(): boolean {
const ctx = useContext(CompanyContext)
if (!ctx) return true
return ctx.assistantAvailable
}
@@ -1,6 +1,33 @@
import { describe, it, expect } from 'vitest'
import { routeToIntent, contextRefToTarget } from '../route-mapping'
describe('routeToIntent without the tool-loop runtime', () => {
// getAiStatus().assistantAvailable is false on an OpenAI-compatible or
// unconfigured deployment (#2204): the trigger keeps working on the
// single-call console, so every route collapses onto general.help.
it('dispatches every specialized route to general.help', () => {
for (const route of [
'/invoices/new',
'/invoices/abc-123',
'/supplier-invoices/sup-1',
'/bookkeeping/year-end',
'/kpi',
'/settings/invoicing',
]) {
const out = routeToIntent(route, { assistantAvailable: false })
expect(out.intentId).toBe('general.help')
expect(out.intentArgs).toEqual({ route })
expect(out.contextRef).toBeUndefined()
expect(out.labelSuffix).toBeNull()
}
})
it('leaves the specialized dispatch alone when the option is omitted or true', () => {
expect(routeToIntent('/kpi').intentId).toBe('kpi.explain')
expect(routeToIntent('/kpi', { assistantAvailable: true }).intentId).toBe('kpi.explain')
})
})
describe('routeToIntent', () => {
it('falls back to general.help when pathname is null/undefined/empty', () => {
for (const input of [null, undefined, '']) {
+16 -1
View File
@@ -29,8 +29,23 @@ const GENERAL_HELP = (route: string | null): RouteIntent => ({
labelSuffix: null,
})
export function routeToIntent(pathname: string | null | undefined): RouteIntent {
export interface RouteIntentOptions {
/**
* Whether the deployment can run the tool-loop runtime (/api/agent/invoke,
* getAiStatus().assistantAvailable). Every specialized intent below runs on
* it; general.help runs on the single-call console, which any provider can
* serve. When false, every route dispatches to general.help so the trigger
* keeps working instead of opening a chat that 503s (#2204). Omitted = true.
*/
assistantAvailable?: boolean
}
export function routeToIntent(
pathname: string | null | undefined,
options: RouteIntentOptions = {},
): RouteIntent {
if (!pathname) return GENERAL_HELP(null)
if (options.assistantAvailable === false) return GENERAL_HELP(pathname)
const segments = pathname.split('/').filter(Boolean)
const [first, second] = segments
+10
View File
@@ -199,6 +199,16 @@ describe('getAiStatus', () => {
expect(s.models.extraction).toBe('eu.anthropic.claude-sonnet-5')
})
it('is configured and assistant-capable on a direct Anthropic key', () => {
// Presence is all the config reads (no format check), so the placeholder
// deliberately cannot match a real key's shape and trip secret scanners.
process.env.ANTHROPIC_API_KEY = 'test-not-a-real-key'
const s = getAiStatus()
expect(s.provider).toBe('anthropic')
expect(s.configured).toBe(true)
expect(s.assistantAvailable).toBe(true)
})
it('needs a model id on an OpenAI-compatible endpoint before it counts as configured', () => {
byo('')
const s = getAiStatus()
+21 -4
View File
@@ -45,7 +45,7 @@ describe('vatDeadlineLine', () => {
describe('checklistNumbers', () => {
it('numbers all five steps when both extensions are on', () => {
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: true })).toEqual({
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: true, hasAssistant: true })).toEqual({
count: 5,
skv: 3,
receipts: 4,
@@ -54,7 +54,7 @@ describe('checklistNumbers', () => {
})
it('collapses to four steps without the inbox extension', () => {
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: false })).toEqual({
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: false, hasAssistant: true })).toEqual({
count: 4,
skv: 3,
receipts: 4,
@@ -63,7 +63,7 @@ describe('checklistNumbers', () => {
})
it('collapses to four steps without the skatteverket extension', () => {
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: true })).toEqual({
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: true, hasAssistant: true })).toEqual({
count: 4,
skv: 3,
receipts: 3,
@@ -72,13 +72,30 @@ describe('checklistNumbers', () => {
})
it('collapses to three steps with neither extension', () => {
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: false })).toEqual({
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: false, hasAssistant: true })).toEqual({
count: 3,
skv: 3,
receipts: 3,
assistant: 3,
})
})
// A deployment that cannot run the assistant (#2204) drops the Claude step:
// the title counts one step fewer, the other ordinals stay put.
it('drops the assistant step from the count without moving the other ordinals', () => {
expect(checklistNumbers({ hasSkatteverket: true, hasInbox: true, hasAssistant: false })).toEqual({
count: 4,
skv: 3,
receipts: 4,
assistant: 5,
})
expect(checklistNumbers({ hasSkatteverket: false, hasInbox: false, hasAssistant: false })).toEqual({
count: 2,
skv: 3,
receipts: 3,
assistant: 3,
})
})
})
describe('completionPatchBody', () => {
+11 -3
View File
@@ -33,10 +33,15 @@ export function vatDeadlineLine(input: {
/**
* Display ordinals for the setup checklist steps. Books and bank are always
* present; Skatteverket and the receipts/inbox step render only when their
* extensions are enabled; the assistant step is always last. `count` drives
* extensions are enabled; the assistant (Claude) step renders only where the
* deployment can run the assistant and, when it does, is last. `count` drives
* the "{count} steg så är bokföringen igång" title.
*/
export function checklistNumbers(gates: { hasSkatteverket: boolean; hasInbox: boolean }): {
export function checklistNumbers(gates: {
hasSkatteverket: boolean
hasInbox: boolean
hasAssistant: boolean
}): {
count: number
skv: number
receipts: number
@@ -45,7 +50,10 @@ export function checklistNumbers(gates: { hasSkatteverket: boolean; hasInbox: bo
const skv = 3
const receipts = 3 + (gates.hasSkatteverket ? 1 : 0)
const assistant = receipts + (gates.hasInbox ? 1 : 0)
return { count: assistant, skv, receipts, assistant }
// Without the assistant step the thread ends one step earlier; the other
// ordinals do not move.
const count = gates.hasAssistant ? assistant : assistant - 1
return { count, skv, receipts, assistant }
}
/**
+3
View File
@@ -122,6 +122,9 @@
"cta_primary": "View subscription",
"cta_secondary": "Continue without"
},
"agent_chat": {
"assistant_unavailable": "The assistant cannot run on the AI provider configured for this installation."
},
"agentKnowledge": {
"load_error_title": "Couldn't load the knowledge profile",
"load_error_description": "Something went wrong loading what your agent knows. Try reopening this tab.",
+3
View File
@@ -122,6 +122,9 @@
"cta_primary": "Se abonnemang",
"cta_secondary": "Fortsätt utan"
},
"agent_chat": {
"assistant_unavailable": "Assistenten kan inte köras med den AI-leverantör som är konfigurerad på den här installationen."
},
"agentKnowledge": {
"load_error_title": "Kunde inte läsa in kunskapsprofilen",
"load_error_description": "Något gick fel när det din agent vet skulle läsas in. Försök öppna fliken igen.",