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
co-authored by Claude Fable 5.1 Jakob Wennberg
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,