feat(desktop): five Capabilities-tab UX fixes from live testing — hints, vision link, web split, key deep-links (#67482)

* fix(desktop): stop contradicting the Ready pill with the one-time-install hint

When a provider's server-computed status is 'ready' (post_setup install
verifiably satisfied, e.g. cua-driver on PATH), the PostSetupRunner row
still said 'This backend needs a one-time install (…)'. Swap the copy for
a muted installed-confirmation one-liner and keep the Run setup button for
repair re-runs. Gated purely on the provider status prop so it composes
with the server-driven resting state work in the sibling lane.

* feat(tools): surface the web search/extract capability split in the Capabilities UI

The runtime has dispatched web_search and web_extract to independently
configurable backends for a long time (web.search_backend /
web.extract_backend overrides with web.backend as the shared fallback),
but the Capabilities tab still presented one monolithic 'Web Search &
Extract' choice that only wrote web.backend.

Backend:
- GET /api/tools/toolsets/web/config now returns active_search_backend /
  active_extract_backend resolved via the REAL runtime getters
  (tools.web_tools._get_search_backend/_get_extract_backend), plus each
  provider row's web_backend key and supported capabilities (from the
  registry's supports_search/supports_extract flags).
- PUT /api/tools/toolsets/web/provider accepts an optional capability
  ('search'|'extract') that writes web.<capability>_backend without
  touching web.backend; validates the provider actually supports the
  requested capability (ddgs/brave-free are search-only). Omitted →
  unchanged legacy apply_provider_selection path.
- New tools_config.web_provider_capabilities() helper reads the plugin
  registry's capability flags.

Frontend: 'Search: <backend>' / 'Extract: <backend>' pills above the web
provider matrix, per-row 'Search backend'/'Extract backend' assignment
pills, and 'Use for Search'/'Use for Extract' actions gated on each
backend's declared capabilities.

Tests: endpoint tests assert the runtime getters resolve to the written
backend (searxng for search, firecrawl for extract) after the endpoint
write; vitest covers badges, capability-gated buttons, and non-web
toolsets staying untouched.

* feat(desktop): deep-link Capabilities key rows to Settings → API Keys

Set env-var rows in the toolset config panel now offer 'Manage in API
Keys' in the row actions menu — an internal route change to
/settings?tab=keys&key=<ENV_KEY>. KeysSettings consumes the ?key= param
via the shared useDeepLinkHighlight hook (same mechanism as the command
palette's ?field= config deep links and ?session= archived-session
links): scrolls the credential card into view, flashes it, and expands
it. Applies generically to every env-var row, and only when the key is
set (unset keys are managed inline via Set). i18n in en/zh/zh-hant/ja.

* feat(desktop): point the vision Capabilities detail at Settings → Models

The vision toolset has no TOOL_CATEGORIES provider matrix — its
provider/model resolution runs through the auxiliary model config
(agent/auxiliary_client.py), so the Capabilities detail pane looked
empty with no hint of where the model choice lives.

Add a short explainer + an internal deep link
(/settings?tab=config:model&aux=vision) rendered only for
toolset.name === 'vision'. ModelSettings consumes the ?aux= param via
the shared useDeepLinkHighlight hook and scrolls/flashes the matching
auxiliary task row (rows now carry aux-task-<key> anchor ids). No
external URLs. i18n in en/zh/zh-hant/ja.

* test(desktop): use type-alias imports for the react-router mock (lint)

* chore: drop accidentally committed node_modules symlinks

* chore: drop remaining committed node_modules symlinks (apps/desktop, apps/shared)
This commit is contained in:
Teknium 2026-07-19 05:45:01 -07:00 • committed by GitHub
parent 3fc006ebe1
commit 2ae0d67f63
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 716 additions and 33 deletions

View file

@ -12,7 +12,7 @@ import {
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ExternalLink, Eye, EyeOff, Trash2 } from '@/lib/icons'
import { ExternalLink, Eye, EyeOff, KeyRound, Trash2 } from '@/lib/icons'
import { cn } from '@/lib/utils'
interface EnvVarActionsMenuProps extends Pick<
@ -27,6 +27,10 @@ interface EnvVarActionsMenuProps extends Pick<
label: string
onClear?: () => void
onEdit: () => void
/** Internal navigation to Settings → API Keys with this key highlighted.
* Rendered only when provided AND the key is set (an unset key is managed
* right here via Set). */
onManageKeys?: () => void
onReveal?: () => void
showReveal?: boolean
}
@ -41,6 +45,7 @@ export function EnvVarActionsMenu({
label,
onClear,
onEdit,
onManageKeys,
onReveal,
showReveal = true,
sideOffset = 6
@ -49,6 +54,7 @@ export function EnvVarActionsMenu({
const copy = t.settings.envActions
const hasClear = isSet && onClear
const hasReveal = isSet && showReveal && onReveal
const hasManageKeys = isSet && onManageKeys
const hasDocs = Boolean(docsUrl?.trim())
return (
@ -90,6 +96,18 @@ export function EnvVarActionsMenu({
<span>{isSet ? copy.replace : copy.set}</span>
</DropdownMenuItem>
{hasManageKeys && (
<DropdownMenuItem
onSelect={() => {
triggerHaptic('selection')
onManageKeys()
}}
>
<KeyRound className="size-3.5" />
<span>{copy.manageInKeys}</span>
</DropdownMenuItem>
)}
{hasClear && (
<>
<DropdownMenuSeparator />

View file

@ -7,6 +7,7 @@ import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './
import { useEnvCredentials } from './env-credentials'
import { asText } from './helpers'
import { LoadingState, SettingsContent } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
// Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx).
export const KEYS_VIEWS = ['tools', 'settings'] as const
@ -36,6 +37,16 @@ export function KeysSettings({ view }: KeysSettingsProps) {
setOpenKey(null)
}, [view])
// Deep link from Capabilities env-var rows (?tab=keys&key=<ENV_KEY>): scroll
// the credential card into view, flash it, and expand it. Same mechanism the
// command palette uses for config fields / archived sessions.
useDeepLinkHighlight({
elementId: key => `credential-key-${key}`,
onResolve: key => setOpenKey(key),
param: 'key',
ready: key => Boolean(vars && key in vars)
})
const groups = useMemo(() => {
if (!vars) {
return []
@ -66,17 +77,18 @@ export function KeysSettings({ view }: KeysSettingsProps) {
const label = credentialRowLabel(key, info)
return (
<CredentialKeyCard
expanded={openKey === key}
info={info}
key={key}
label={label}
onExpand={() => setOpenKey(key)}
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
placeholder={credentialPlaceholder(key, info, label)}
rowProps={rowProps}
varKey={key}
/>
<div className="scroll-mt-6 rounded-[6px]" id={`credential-key-${key}`} key={key}>
<CredentialKeyCard
expanded={openKey === key}
info={info}
label={label}
onExpand={() => setOpenKey(key)}
onToggle={() => setOpenKey(prev => (prev === key ? null : key))}
placeholder={credentialPlaceholder(key, info, label)}
rowProps={rowProps}
varKey={key}
/>
</div>
)
})}
</div>

View file

@ -1,5 +1,6 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Radix Select calls scrollIntoView on its items when the content opens; jsdom
@ -88,9 +89,13 @@ async function renderModelSettings() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return render(
<QueryClientProvider client={client}>
<ModelSettings />
</QueryClientProvider>
// The aux-task deep-link highlight reads useSearchParams, so the page
// needs a router context in tests (the app provides HashRouter at root).
<MemoryRouter>
<QueryClientProvider client={client}>
<ModelSettings />
</QueryClientProvider>
</MemoryRouter>
)
}

View file

@ -35,6 +35,7 @@ import { useOnProfileSwitch } from '../hooks/use-on-profile-switch'
import { CONTROL_TEXT } from './constants'
import { getNested, setNested } from './helpers'
import { ListRow, Pill, SectionHeading } from './primitives'
import { useDeepLinkHighlight } from './use-deep-link-highlight'
// Skeleton mirror of the Model settings DOM so the page keeps its shape while
// the provider/model catalog loads, instead of collapsing to a centered
@ -215,6 +216,14 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const [apiKeyDraft, setApiKeyDraft] = useState('')
const [activating, setActivating] = useState(false)
// Deep link from the vision Capabilities detail (?tab=config:model&aux=vision):
// scroll the auxiliary task row into view and flash it once the list loads.
useDeepLinkHighlight({
elementId: task => `aux-task-${task}`,
param: 'aux',
ready: task => AUX_TASKS.some(meta => meta.key === task)
})
// Every profile-scoped async here captures this and bails before writing back,
// so a request in flight when the user switches profiles can't paint profile
// A's models/providers into profile B (or fire onMainModelChanged for A).
@ -877,7 +886,8 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
const isEditing = editingAuxTask === meta.key
return (
<ListRow
<div className="scroll-mt-6 rounded-lg" id={`aux-task-${meta.key}`} key={meta.key}>
<ListRow
action={
!isEditing && (
<div className="flex shrink-0 items-center gap-1.5">
@ -951,7 +961,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
{isAuto ? m.autoUseMain : `${current.provider} · ${current.model || m.providerDefault}`}
</span>
}
key={meta.key}
title={
<span className="flex items-baseline gap-2">
{copy.label}
@ -959,6 +968,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) {
</span>
}
/>
</div>
)
})}
</div>

View file

@ -1,8 +1,22 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'
import type { ReactElement } from 'react'
import { MemoryRouter } from 'react-router-dom'
import type * as ReactRouterDom from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ToolsetConfig } from '@/types/hermes'
// EnvVarField navigates to Settings → Keys via useNavigate, so every render
// needs a router context. The navigate spy asserts the deep-link target.
const navigateSpy = vi.fn()
vi.mock('react-router-dom', async importOriginal => ({
...(await importOriginal<typeof ReactRouterDom>()),
useNavigate: () => navigateSpy
}))
const render = (ui: ReactElement) => rtlRender(ui, { wrapper: MemoryRouter })
const getToolsetConfig = vi.fn()
const getToolsetModels = vi.fn()
const selectToolsetModel = vi.fn()
@ -19,7 +33,8 @@ vi.mock('@/hermes', () => ({
getToolsetConfig: (name: string) => getToolsetConfig(name),
getToolsetModels: (name: string, provider?: string) => getToolsetModels(name, provider),
selectToolsetModel: (name: string, model: string, provider?: string) => selectToolsetModel(name, model, provider),
selectToolsetProvider: (name: string, provider: string) => selectToolsetProvider(name, provider),
selectToolsetProvider: (name: string, provider: string, capability?: string) =>
capability === undefined ? selectToolsetProvider(name, provider) : selectToolsetProvider(name, provider, capability),
setEnvVar: (key: string, value: string) => setEnvVar(key, value),
deleteEnvVar: (key: string) => deleteEnvVar(key),
revealEnvVar: (key: string) => revealEnvVar(key),
@ -363,6 +378,40 @@ describe('ToolsetConfigPanel', () => {
})
})
it('swaps the install hint for the installed one-liner when the provider is ready', async () => {
// Server says the post_setup install is already satisfied (status ready) —
// the "needs a one-time install" copy would contradict the Ready pill.
getToolsetConfig.mockResolvedValue(
config({
name: 'browser',
active_provider: 'Camofox',
providers: [
{
name: 'Camofox',
badge: 'local',
tag: 'Stealth local browser',
env_vars: [],
post_setup: 'camofox',
requires_nous_auth: false,
is_active: true,
status: 'ready'
}
]
})
)
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="browser" />)
// Installed confirmation replaces the contradictory install prompt…
expect(await screen.findByText(/Installed\. Re-run setup only if something is broken\./)).toBeTruthy()
expect(screen.queryByText(/needs a one-time install/)).toBeNull()
// …but a repair affordance stays available (c9's resting state renders
// the low-key Re-run setup button instead of the primary CTA).
expect(screen.getByRole('button', { name: /Re-run setup/ })).toBeTruthy()
})
describe('readiness pills', () => {
it('renders the server status instead of assuming keyless rows are Ready', async () => {
// The false-Ready bug: a logged-out Nous Subscription row and a
@ -740,4 +789,143 @@ describe('ToolsetConfigPanel', () => {
expect(startOAuthLogin).not.toHaveBeenCalled()
})
})
describe('API key deep link', () => {
it('offers "Manage in API Keys" on a set key and navigates to Settings → Keys', async () => {
getToolsetConfig.mockResolvedValue(
config({
active_provider: 'ElevenLabs',
providers: [
{
name: 'ElevenLabs',
badge: 'paid',
tag: 'Most natural voices',
env_vars: [
{
key: 'ELEVENLABS_API_KEY',
prompt: 'ElevenLabs API key',
url: 'https://x',
default: null,
is_set: true
}
],
post_setup: null,
requires_nous_auth: false,
is_active: true,
status: 'ready'
}
]
})
)
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
fireEvent.click(await screen.findByRole('menuitem', { name: 'Manage in API Keys' }))
await waitFor(() =>
expect(navigateSpy).toHaveBeenCalledWith('/settings?tab=keys&key=ELEVENLABS_API_KEY')
)
})
it('hides "Manage in API Keys" while the key is unset', async () => {
// Default config(): ElevenLabs key is not set. An unset key is managed
// right here via Set — no point bouncing the user to another page.
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
// Expand the keyed provider so its env row renders.
fireEvent.click(await screen.findByRole('button', { name: /ElevenLabs/ }))
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
await screen.findByRole('menuitem', { name: 'Set' })
expect(screen.queryByRole('menuitem', { name: 'Manage in API Keys' })).toBeNull()
})
})
describe('web capability split', () => {
function webConfig(overrides: Partial<ToolsetConfig> = {}): ToolsetConfig {
return {
name: 'web',
has_category: true,
active_provider: 'SearXNG',
active_search_backend: 'searxng',
active_extract_backend: 'firecrawl',
providers: [
{
name: 'SearXNG',
badge: 'free · self-hosted',
tag: 'Free metasearch',
env_vars: [],
post_setup: null,
requires_nous_auth: false,
is_active: true,
status: 'ready',
web_backend: 'searxng',
capabilities: ['search']
},
{
name: 'Firecrawl',
badge: 'paid',
tag: 'Full search + extract',
env_vars: [],
post_setup: null,
requires_nous_auth: false,
is_active: false,
status: 'ready',
web_backend: 'firecrawl',
capabilities: ['search', 'extract']
}
],
...overrides
}
}
it('shows the resolved per-capability backends as badges', async () => {
getToolsetConfig.mockResolvedValue(webConfig())
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="web" />)
expect(await screen.findByText('Search: searxng')).toBeTruthy()
expect(screen.getByText('Extract: firecrawl')).toBeTruthy()
// The row backing each capability gets an assignment pill.
expect(screen.getByText('Search backend')).toBeTruthy()
expect(screen.getByText('Extract backend')).toBeTruthy()
})
it('hides "Use for Extract" on a search-only provider and wires capability selection', async () => {
getToolsetConfig.mockResolvedValue(webConfig())
selectToolsetProvider.mockResolvedValue({ ok: true, name: 'web', provider: 'SearXNG', capability: 'search' })
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="web" />)
// Active/expanded provider is search-only SearXNG.
await screen.findByText('Search: searxng')
expect(await screen.findByRole('button', { name: 'Use for Search' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Use for Extract' })).toBeNull()
// Expand Firecrawl (search + extract) and assign it as the search backend.
fireEvent.click(screen.getByRole('button', { name: /Firecrawl/ }))
const useForSearch = await screen.findByRole('button', { name: 'Use for Search' })
fireEvent.click(useForSearch)
await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('web', 'Firecrawl', 'search'))
// Badge tracks the local write without a refetch.
await waitFor(() => expect(screen.getByText('Search: firecrawl')).toBeTruthy())
})
it('does not render capability chrome for non-web toolsets', async () => {
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
await screen.findByText('Microsoft Edge TTS')
expect(screen.queryByText(/^Search: /)).toBeNull()
expect(screen.queryByRole('button', { name: 'Use for Search' })).toBeNull()
})
})
})

View file

@ -1,5 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { SETTINGS_ROUTE } from '@/app/routes'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import {
@ -83,11 +85,16 @@ interface EnvVarFieldProps {
function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
const { t } = useI18n()
const copy = t.settings.toolsets
const navigate = useNavigate()
const [editing, setEditing] = useState(false)
const [value, setValue] = useState('')
const [revealed, setRevealed] = useState<string | null>(null)
const [busy, setBusy] = useState(false)
// Internal route change to Settings → API Keys (tools sub-view) with the
// deep-link param keys-settings consumes to scroll + flash this key's card.
const openInKeys = () => navigate(`${SETTINGS_ROUTE}?tab=keys&key=${encodeURIComponent(envVar.key)}`)
async function handleSave() {
if (!value) {
return
@ -166,6 +173,7 @@ function EnvVarField({ envVar, isSet, onSaved, onCleared }: EnvVarFieldProps) {
label={envVar.key}
onClear={() => void handleClear()}
onEdit={() => setEditing(true)}
onManageKeys={openInKeys}
onReveal={() => void handleReveal()}
>
<EnvVarActionsTrigger label={envVar.key} onClick={event => event.stopPropagation()} />
@ -642,6 +650,36 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
onConfiguredChange?.()
}
async function handleSelectCapability(provider: ToolProvider, capability: 'search' | 'extract') {
setSelecting(provider.name)
try {
await selectToolsetProvider(toolset, provider.name, capability)
// Mirror the backend write locally so the Search:/Extract: badges track
// the new per-capability backend without a refetch.
setCfg(current =>
current
? {
...current,
...(capability === 'search'
? { active_search_backend: provider.web_backend ?? provider.name }
: { active_extract_backend: provider.web_backend ?? provider.name })
}
: current
)
notify({
kind: 'success',
title: copy.selectedTitle,
message: copy.webCapabilitySelectedMessage(provider.name, capability)
})
onConfiguredChange?.()
} catch (err) {
notifyError(err, copy.failedSelectCapability(provider.name))
} finally {
setSelecting(null)
}
}
if (loading) {
// Inline row, not a full block loader — a big centered spinner is what
// caused the Skills/Tools tab-switch layout jump; this reads as "more
@ -667,9 +705,21 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
return (
<div className="grid gap-2">
{toolset === 'web' && cfg.active_search_backend !== undefined && (
// The runtime dispatches web_search and web_extract independently
// (web.search_backend / web.extract_backend) — show which backend
// each capability resolves to right now.
<div className="flex flex-wrap items-center gap-2 px-1">
<Pill>{copy.webSearchActive(cfg.active_search_backend || copy.webCapabilityUnset)}</Pill>
<Pill>{copy.webExtractActive(cfg.active_extract_backend || copy.webCapabilityUnset)}</Pill>
</div>
)}
{providers.map(provider => {
const isActive = activeProvider === provider.name
const status = providerStatus(provider, envState)
const webCaps = toolset === 'web' ? (provider.capabilities ?? []) : []
const isSearchBackend = Boolean(provider.web_backend && cfg.active_search_backend === provider.web_backend)
const isExtractBackend = Boolean(provider.web_backend && cfg.active_extract_backend === provider.web_backend)
return (
<div className="overflow-hidden rounded-xl bg-background/60" key={provider.name}>
@ -693,6 +743,8 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
)}
{status === 'needs_auth' && <Pill tone="warn">{copy.needsSignIn}</Pill>}
{status === 'needs_setup' && <Pill tone="warn">{copy.needsSetup}</Pill>}
{isSearchBackend && <Pill tone="primary">{copy.webUsedForSearch}</Pill>}
{isExtractBackend && <Pill tone="primary">{copy.webUsedForExtract}</Pill>}
</span>
{selecting === provider.name && <Loader2 className="size-3.5 shrink-0 animate-spin" />}
</button>
@ -700,6 +752,34 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
{isActive && (
<div className="grid gap-2 bg-muted/20 p-3">
{provider.tag && <p className="text-[0.72rem] text-muted-foreground">{provider.tag}</p>}
{webCaps.length > 0 && (
// Per-capability assignment: writes web.search_backend /
// web.extract_backend without touching the shared
// web.backend key. Hidden for capabilities the backend
// can't serve (e.g. ddgs is search-only).
<div className="flex flex-wrap items-center gap-1.5">
{webCaps.includes('search') && (
<Button
disabled={selecting !== null || isSearchBackend}
onClick={() => void handleSelectCapability(provider, 'search')}
size="xs"
variant="text"
>
{copy.webUseForSearch}
</Button>
)}
{webCaps.includes('extract') && (
<Button
disabled={selecting !== null || isExtractBackend}
onClick={() => void handleSelectCapability(provider, 'extract')}
size="xs"
variant="text"
>
{copy.webUseForExtract}
</Button>
)}
</div>
)}
{provider.requires_nous_auth && (
<p className="text-[0.72rem] text-muted-foreground">{copy.nousIncluded}</p>
)}

View file

@ -2,6 +2,7 @@
import { QueryClientProvider } from '@tanstack/react-query'
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import type * as ReactRouterDom from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesApi from '@/hermes'
@ -35,6 +36,15 @@ vi.mock('@/store/notifications', () => ({
notifyError: vi.fn()
}))
// The vision detail navigates to Settings → Models via useNavigate; spy on it
// so the deep-link target is assertable.
const navigateSpy = vi.fn()
vi.mock('react-router-dom', async importOriginal => ({
...(await importOriginal<typeof ReactRouterDom>()),
useNavigate: () => navigateSpy
}))
function toolset(overrides: Record<string, unknown> = {}) {
return {
name: 'web',
@ -115,4 +125,32 @@ describe('SkillsView toolset management', () => {
await screen.findByRole('switch', { name: 'Toggle Web Search toolset' })
await waitFor(() => expect(getToolsetConfig).toHaveBeenCalledWith('web'))
})
it('shows a vision explainer that deep-links to Settings → Models', async () => {
// Vision has no TOOL_CATEGORIES provider matrix — its model lives in the
// auxiliary model config, so the detail pane must point there instead of
// rendering an empty panel.
getToolsets.mockResolvedValue([
toolset({
name: 'vision',
label: 'Vision / Image Analysis',
description: 'vision_analyze',
tools: ['vision_analyze']
})
])
getToolsetConfig.mockResolvedValue({ has_category: false, active_provider: null, providers: [] })
await renderSkills()
expect(await screen.findByText(/auxiliary model configuration/)).toBeTruthy()
const link = screen.getByRole('button', { name: /Choose vision model in Settings/ })
await act(async () => {
fireEvent.click(link)
})
// Internal route change into the Models section with the aux slot target —
// consumed by ModelSettings' deep-link highlight. Never an external URL.
await waitFor(() => expect(navigateSpy).toHaveBeenCalledWith('/settings?tab=config:model&aux=vision'))
})
})

View file

@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react'
import { useQuery } from '@tanstack/react-query'
import type * as React from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { ArchiveSkillConfirmDialog } from '@/app/learning/archive-skill-confirm-dialog'
import { CodeEditor } from '@/components/chat/code-editor'
@ -46,6 +47,7 @@ import {
} from '../master-detail'
import { PanelEmpty, PanelPill } from '../overlays/panel'
import { PageSearchShell } from '../page-search-shell'
import { SETTINGS_ROUTE } from '../routes'
import { ComputerUsePanel } from '../settings/computer-use-panel'
import { asText, includesQuery, prettyName, toolNames, toolsetDisplayLabel } from '../settings/helpers'
import { TerminalBackendPanel } from '../settings/terminal-backend-panel'
@ -760,6 +762,7 @@ function ToolsetDetail({
onConfiguredChange: () => void
}) {
const { t } = useI18n()
const navigate = useNavigate()
const tools = toolNames(toolset)
const label = toolsetDisplayLabel(toolset)
@ -783,6 +786,26 @@ function ToolsetDetail({
))}
</div>
)}
{toolset.name === 'vision' && (
// Vision has no provider matrix — model resolution runs through the
// auxiliary model config. Point at the actual home (Settings → Models,
// aux "vision" row) via an internal deep link instead of leaving the
// detail pane empty.
<div className="grid gap-1.5">
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{t.skills.visionModelHint}
</p>
<div>
<Button
onClick={() => navigate(`${SETTINGS_ROUTE}?tab=config:model&aux=vision`)}
size="xs"
variant="textStrong"
>
{t.skills.visionModelLink}
</Button>
</div>
</div>
)}
{toolset.name === 'computer_use' && <ComputerUsePanel onConfiguredChange={onConfiguredChange} />}
{toolset.name === 'terminal' && <TerminalBackendPanel onConfiguredChange={onConfiguredChange} />}
<ToolsetConfigPanel key={toolset.name} onConfiguredChange={onConfiguredChange} toolset={toolset.name} />

View file

@ -869,6 +869,8 @@ export interface SelectToolsetProviderResponse {
ok: boolean
name: string
provider: string
/** Present when the selection was scoped to one web capability. */
capability?: string
/** Present (true) when a managed Nous row was selected but the Portal
* entitlement is missing — the row won't activate until the user signs
* in to Nous Portal. */
@ -877,12 +879,16 @@ export interface SelectToolsetProviderResponse {
feature?: string
}
export function selectToolsetProvider(name: string, provider: string): Promise<SelectToolsetProviderResponse> {
export function selectToolsetProvider(
name: string,
provider: string,
capability?: 'search' | 'extract'
): Promise<SelectToolsetProviderResponse> {
return window.hermesDesktop.api<SelectToolsetProviderResponse>({
...profileScoped(),
path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`,
method: 'PUT',
body: { provider }
body: capability ? { provider, capability } : { provider }
})
}

View file

@ -537,6 +537,7 @@ export const en: Translations = {
envActions: {
actionsFor: label => `Actions for ${label}`,
credentialActions: 'Credential actions',
manageInKeys: 'Manage in API Keys',
docs: 'Docs',
hideValue: 'Hide value',
revealValue: 'Reveal value',
@ -832,7 +833,7 @@ export const en: Translations = {
noApiKeyRequired: 'No API key required.',
postSetupHint: step =>
`This backend needs a one-time install (${step}). Runs on this machine — may take a few minutes.`,
postSetupInstalledHint: 'This backend is installed on this machine.',
postSetupInstalledHint: 'Installed. Re-run setup only if something is broken.',
postSetupRun: 'Run setup',
postSetupRerun: 'Re-run setup',
postSetupInstalled: 'Installed',
@ -843,6 +844,15 @@ export const en: Translations = {
postSetupErrorTitle: 'Setup finished with errors',
postSetupErrorMessage: step => `Check the ${step} log.`,
postSetupFailed: step => `Failed to run ${step} setup`,
webSearchActive: backend => `Search: ${backend}`,
webExtractActive: backend => `Extract: ${backend}`,
webCapabilityUnset: 'not set',
webUseForSearch: 'Use for Search',
webUseForExtract: 'Use for Extract',
webUsedForSearch: 'Search backend',
webUsedForExtract: 'Extract backend',
webCapabilitySelectedMessage: (provider, capability) => `${provider} now handles web ${capability}.`,
failedSelectCapability: provider => `Failed to set ${provider}`,
loadingModels: 'Loading model catalog...',
modelSectionTitle: 'Model',
modelCount: count => `${count} model${count === 1 ? '' : 's'}`,
@ -886,6 +896,9 @@ export const en: Translations = {
noDescription: 'No description.',
configured: 'Configured',
needsKeys: 'Needs keys',
visionModelHint:
'Vision uses your auxiliary model configuration — the image-capable model is picked there, not per-provider here.',
visionModelLink: 'Choose vision model in Settings → Models',
toolsetsEnabled: (enabled, total) => `${enabled}/${total} toolsets enabled`,
configureToolset: label => `Configure ${label}`,
toggleToolset: label => `Toggle ${label} toolset`,

View file

@ -635,6 +635,7 @@ export const ja = defineLocale({
envActions: {
actionsFor: label => `${label} のアクション`,
credentialActions: '認証情報のアクション',
manageInKeys: 'API キーで管理',
docs: 'ドキュメント',
hideValue: '値を非表示',
revealValue: '値を表示',
@ -872,7 +873,7 @@ export const ja = defineLocale({
noApiKeyRequired: 'API キーは不要です。',
postSetupHint: step =>
`このバックエンドは一度だけインストールが必要です (${step})。このマシン上で実行され、数分かかる場合があります。`,
postSetupInstalledHint: 'このバックエンドはこのマシンにインストール済みです。',
postSetupInstalledHint: 'インストール済みです。問題がある場合のみセットアップを再実行してください。',
postSetupRun: 'セットアップを実行',
postSetupRerun: 'セットアップを再実行',
postSetupInstalled: 'インストール済み',
@ -883,6 +884,16 @@ export const ja = defineLocale({
postSetupErrorTitle: 'セットアップはエラーで終了しました',
postSetupErrorMessage: step => `${step} のログを確認してください。`,
postSetupFailed: step => `${step} のセットアップの実行に失敗しました`,
webSearchActive: backend => `検索: ${backend}`,
webExtractActive: backend => `抽出: ${backend}`,
webCapabilityUnset: '未設定',
webUseForSearch: '検索に使用',
webUseForExtract: '抽出に使用',
webUsedForSearch: '検索バックエンド',
webUsedForExtract: '抽出バックエンド',
webCapabilitySelectedMessage: (provider, capability) =>
`${provider} がウェブ${capability === 'search' ? '検索' : '抽出'}を担当します。`,
failedSelectCapability: provider => `${provider} の設定に失敗しました`,
terminalBackend: {
sectionTitle: '実行バックエンド',
loading: '実行バックエンドを確認中…',
@ -916,6 +927,9 @@ export const ja = defineLocale({
noDescription: '説明はありません。',
configured: '設定済み',
needsKeys: 'キーが必要',
visionModelHint:
'ビジョンは補助モデル設定を使用します。画像対応モデルはそこで選択され、ここでプロバイダーごとに選ぶものではありません。',
visionModelLink: '設定 → モデル でビジョンモデルを選択',
toolsetsEnabled: (enabled, total) => `${enabled}/${total} ツールセットが有効`,
configureToolset: label => `${label} を設定`,
toggleToolset: label => `${label} ツールセットを切り替え`,

View file

@ -448,6 +448,7 @@ export interface Translations {
envActions: {
actionsFor: (label: string) => string
credentialActions: string
manageInKeys: string
docs: string
hideValue: string
revealValue: string
@ -731,6 +732,15 @@ export interface Translations {
postSetupErrorTitle: string
postSetupErrorMessage: (step: string) => string
postSetupFailed: (step: string) => string
webSearchActive: (backend: string) => string
webExtractActive: (backend: string) => string
webCapabilityUnset: string
webUseForSearch: string
webUseForExtract: string
webUsedForSearch: string
webUsedForExtract: string
webCapabilitySelectedMessage: (provider: string, capability: string) => string
failedSelectCapability: (provider: string) => string
loadingModels: string
modelSectionTitle: string
modelCount: (count: number) => string
@ -774,6 +784,8 @@ export interface Translations {
noDescription: string
configured: string
needsKeys: string
visionModelHint: string
visionModelLink: string
toolsetsEnabled: (enabled: number, total: number) => string
configureToolset: (label: string) => string
toggleToolset: (label: string) => string

View file

@ -623,6 +623,7 @@ export const zhHant = defineLocale({
envActions: {
actionsFor: label => `${label} 的動作`,
credentialActions: '憑證動作',
manageInKeys: '在 API 金鑰中管理',
docs: '文件',
hideValue: '隱藏值',
revealValue: '顯示值',
@ -843,7 +844,7 @@ export const zhHant = defineLocale({
nousAuthFailed: 'Nous Portal 登入未完成',
noApiKeyRequired: '不需要 API 金鑰。',
postSetupHint: step => `此後端需要一次性安裝 (${step})。將在此機器上執行,可能需要幾分鐘。`,
postSetupInstalledHint: '此後端已在此機器上安裝。',
postSetupInstalledHint: '已安裝。僅在出現問題時才需要重新執行安裝。',
postSetupRun: '執行設定',
postSetupRerun: '重新執行設定',
postSetupInstalled: '已安裝',
@ -854,6 +855,16 @@ export const zhHant = defineLocale({
postSetupErrorTitle: '設定完成但有錯誤',
postSetupErrorMessage: step => `請檢查 ${step} 日誌。`,
postSetupFailed: step => `執行 ${step} 設定失敗`,
webSearchActive: backend => `搜尋:${backend}`,
webExtractActive: backend => `擷取:${backend}`,
webCapabilityUnset: '未設定',
webUseForSearch: '用於搜尋',
webUseForExtract: '用於擷取',
webUsedForSearch: '搜尋後端',
webUsedForExtract: '擷取後端',
webCapabilitySelectedMessage: (provider, capability) =>
`${provider} 現在負責網頁${capability === 'search' ? '搜尋' : '擷取'}。`,
failedSelectCapability: provider => `無法設定 ${provider}`,
terminalBackend: {
sectionTitle: '執行後端',
loading: '正在檢查執行後端…',
@ -887,6 +898,8 @@ export const zhHant = defineLocale({
noDescription: '無可用描述。',
configured: '已設定',
needsKeys: '需要金鑰',
visionModelHint: '視覺功能使用你的輔助模型設定——支援影像的模型在那裡選擇,而不是在此處按供應商選擇。',
visionModelLink: '在 設定 → 模型 中選擇視覺模型',
toolsetsEnabled: (enabled, total) => `已啟用 ${enabled}/${total} 個工具集`,
configureToolset: label => `設定 ${label}`,
toggleToolset: label => `切換 ${label} 工具集`,

View file

@ -734,6 +734,7 @@ export const zh: Translations = {
envActions: {
actionsFor: label => `${label} 的操作`,
credentialActions: '凭据操作',
manageInKeys: '在 API 密钥中管理',
docs: '文档',
hideValue: '隐藏值',
revealValue: '显示值',
@ -1021,7 +1022,7 @@ export const zh: Translations = {
nousAuthFailed: 'Nous Portal 登录未完成',
noApiKeyRequired: '不需要 API 密钥。',
postSetupHint: step => `此后端需要一次性安装 (${step})。将在此机器上执行,可能需要几分钟。`,
postSetupInstalledHint: '此后端已在此机器上安装。',
postSetupInstalledHint: '已安装。仅在出现问题时才需要重新运行安装。',
postSetupRun: '运行设置',
postSetupRerun: '重新运行设置',
postSetupInstalled: '已安装',
@ -1032,6 +1033,16 @@ export const zh: Translations = {
postSetupErrorTitle: '设置完成但有错误',
postSetupErrorMessage: step => `请检查 ${step} 日志。`,
postSetupFailed: step => `运行 ${step} 设置失败`,
webSearchActive: backend => `搜索:${backend}`,
webExtractActive: backend => `提取:${backend}`,
webCapabilityUnset: '未设置',
webUseForSearch: '用于搜索',
webUseForExtract: '用于提取',
webUsedForSearch: '搜索后端',
webUsedForExtract: '提取后端',
webCapabilitySelectedMessage: (provider, capability) =>
`${provider} 现在负责网页${capability === 'search' ? '搜索' : '提取'}。`,
failedSelectCapability: provider => `无法设置 ${provider}`,
loadingModels: '正在加载模型目录…',
modelSectionTitle: '模型',
modelCount: count => `${count} 个模型`,
@ -1075,6 +1086,8 @@ export const zh: Translations = {
noDescription: '暂无描述。',
configured: '已配置',
needsKeys: '需要密钥',
visionModelHint: '视觉功能使用你的辅助模型配置——支持图像的模型在那里选择,而不是在此处按提供商选择。',
visionModelLink: '在 设置 → 模型 中选择视觉模型',
toolsetsEnabled: (enabled, total) => `已启用 ${enabled}/${total} 个工具集`,
configureToolset: label => `配置 ${label}`,
toggleToolset: label => `切换 ${label} 工具集`,

View file

@ -714,14 +714,29 @@ export interface ToolProvider {
/** Honest readiness computed server-side (keys ∧ Nous entitlement ∧
* post-setup install state). Optional for older backends. */
status?: ToolProviderStatus
/** Web toolset only: the backend key written to web.*backend config
* (e.g. 'searxng'). Absent on other toolsets and older backends. */
web_backend?: string
/** Web toolset only: capabilities this backend can serve. Search-only
* providers (ddgs, brave-free) report ['search']. */
capabilities?: WebCapability[]
}
/** A web toolset capability — the runtime dispatches web_search and
* web_extract to independently configurable backends. */
export type WebCapability = 'search' | 'extract'
export interface ToolsetConfig {
name: string
has_category: boolean
providers: ToolProvider[]
/** Name of the currently active provider, or null if none is configured. */
active_provider: string | null
/** Web toolset only: backend the web_search tool resolves to right now
* (web.search_backend → web.backend → credential auto-detect). */
active_search_backend?: string | null
/** Web toolset only: backend the web_extract tool resolves to right now. */
active_extract_backend?: string | null
}
/** Health status of a terminal execution backend row.

View file

@ -2417,6 +2417,33 @@ def _plugin_web_search_providers() -> list[dict]:
return rows
def web_provider_capabilities(backend: str) -> list:
"""Return the capabilities (``search`` / ``extract``) a web backend supports.
Consults the plugin registry's provider instance (``supports_search`` /
``supports_extract``) so the Capabilities GUI can offer per-capability
selection (``web.search_backend`` / ``web.extract_backend``) only where it
makes sense — e.g. ddgs and brave-free are search-only. Falls back to both
capabilities when the backend isn't registered (hardcoded setup-flow rows
like the managed Firecrawl entries resolve before plugin discovery in some
test contexts, and firecrawl itself supports both).
"""
try:
from agent.web_search_registry import get_provider
provider = get_provider(backend)
if provider is not None:
caps = []
if provider.supports_search():
caps.append("search")
if provider.supports_extract():
caps.append("extract")
return caps
except Exception:
pass
return ["search", "extract"]
# Mirror of _plugin_web_search_providers for cloud browser backends. After
# PR #25214, Browserbase / Browser Use / Firecrawl live as plugins under
# plugins/browser/<vendor>/; this helper is the sole source of provider rows

View file

@ -14670,6 +14670,7 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
_is_provider_active,
_visible_providers,
provider_readiness_status,
web_provider_capabilities,
)
from hermes_cli.config import get_env_value
from hermes_cli.nous_subscription import get_nous_subscription_features
@ -14683,6 +14684,8 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
cat = TOOL_CATEGORIES.get(name)
providers = []
active_provider = None
active_search_backend = None
active_extract_backend = None
if cat:
# Fetch portal/entitlement state once for the whole matrix — the
# per-provider readiness computation below reuses it instead of
@ -14706,7 +14709,7 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
is_active = _is_provider_active(prov, config, force_fresh=True)
if is_active and active_provider is None:
active_provider = prov["name"]
providers.append({
row = {
"name": prov["name"],
"badge": prov.get("badge", ""),
"tag": prov.get("tag", ""),
@ -14721,17 +14724,45 @@ async def get_toolset_config(name: str, profile: Optional[str] = None):
"status": provider_readiness_status(
prov, config, features=features, is_active=is_active
),
})
return {
}
if name == "web" and prov.get("web_backend"):
# The runtime split web into two capabilities long ago
# (web.search_backend / web.extract_backend); surface each
# row's backend key and which capabilities it can serve so
# the GUI can offer per-capability selection.
row["web_backend"] = prov["web_backend"]
row["capabilities"] = web_provider_capabilities(prov["web_backend"])
providers.append(row)
if name == "web":
# Resolve the per-capability active backends exactly the way the
# web_search / web_extract dispatchers do (per-capability key →
# shared web.backend → credential auto-detect), so the GUI badges
# reflect what a tool call would actually hit right now.
try:
from tools.web_tools import _get_extract_backend, _get_search_backend
active_search_backend = _get_search_backend()
active_extract_backend = _get_extract_backend()
except Exception:
active_search_backend = None
active_extract_backend = None
payload = {
"name": name,
"has_category": cat is not None,
"providers": providers,
"active_provider": active_provider,
}
if name == "web":
payload["active_search_backend"] = active_search_backend
payload["active_extract_backend"] = active_extract_backend
return payload
class ToolsetProviderSelect(BaseModel):
provider: str
# Web-only capability scope: 'search' | 'extract'. Omitted → whole-provider
# selection through the legacy apply_provider_selection path (web.backend).
capability: Optional[str] = None
profile: Optional[str] = None
@ -14917,6 +14948,15 @@ async def select_toolset_provider(
API keys and post-setup flows are handled by separate endpoints. Returns
400 for unknown toolset or provider names.
For the ``web`` toolset only, an optional ``capability`` ('search' |
'extract') scopes the selection to ``web.search_backend`` /
``web.extract_backend`` — the same per-capability overrides the runtime
dispatchers (``tools.web_tools._get_search_backend`` /
``_get_extract_backend``) resolve first. The provider must actually
support the requested capability (a search-only backend can't be the
extract backend). Omitting ``capability`` keeps the legacy whole-provider
behavior (writes ``web.backend``).
Managed Nous rows (``managed_nous_feature``) additionally report the
Portal entitlement state: the CLI flow gates these selections on
``ensure_nous_portal_access`` (inline login), but the GUI has no inline
@ -14930,6 +14970,7 @@ async def select_toolset_provider(
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
apply_provider_selection,
web_provider_capabilities,
_get_effective_configurable_toolsets,
_visible_providers,
)
@ -14942,15 +14983,58 @@ async def select_toolset_provider(
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
if body.capability is not None:
if name != "web":
raise HTTPException(
status_code=400,
detail="capability selection is only supported for the web toolset",
)
if body.capability not in ("search", "extract"):
raise HTTPException(
status_code=400,
detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')",
)
with _profile_scope(body.profile or profile):
config = load_config()
try:
apply_provider_selection(name, body.provider, config)
except KeyError as exc:
raise HTTPException(status_code=400, detail=str(exc).strip('"'))
if body.capability is not None:
# Per-capability path: resolve the picker row to its backend key
# and write web.<capability>_backend. Does NOT touch web.backend,
# so the other capability keeps resolving through the shared
# fallback chain.
cat = TOOL_CATEGORIES.get(name)
providers = _visible_providers(cat, config, force_fresh=True) if cat else []
prov = next((p for p in providers if p.get("name") == body.provider), None)
if prov is None:
raise HTTPException(
status_code=400,
detail=f"Unknown provider {body.provider!r} for toolset {name!r}",
)
backend = prov.get("web_backend")
if not backend:
raise HTTPException(
status_code=400,
detail=f"Provider {body.provider!r} has no web backend key",
)
if body.capability not in web_provider_capabilities(backend):
raise HTTPException(
status_code=400,
detail=f"{body.provider} does not support {body.capability}",
)
web_cfg = config.setdefault("web", {})
if not isinstance(web_cfg, dict):
web_cfg = {}
config["web"] = web_cfg
web_cfg[f"{body.capability}_backend"] = backend
else:
try:
apply_provider_selection(name, body.provider, config)
except KeyError as exc:
raise HTTPException(status_code=400, detail=str(exc).strip('"'))
save_config(config)
response: Dict[str, Any] = {"ok": True, "name": name, "provider": body.provider}
if body.capability is not None:
response["capability"] = body.capability
# Entitlement check for managed Nous rows — mirrors the gate the CLI
# applies via ensure_nous_portal_access at selection time.

View file

@ -5635,6 +5635,118 @@ class TestNewEndpoints:
)
assert resp.status_code == 400
# -- Web capability split (search vs extract backends) ------------------
def test_web_config_reports_per_capability_backends(self):
"""GET web/config carries the resolved search/extract backends.
The runtime resolves web_search and web_extract independently
(web.search_backend / web.extract_backend → web.backend → auto-detect);
the config payload must surface both so the GUI can show which backend
each capability actually hits.
"""
resp = self.client.get("/api/tools/toolsets/web/config")
assert resp.status_code == 200
data = resp.json()
assert "active_search_backend" in data
assert "active_extract_backend" in data
# Provider rows carry their backend key + supported capabilities so
# the GUI can hide "Use for Extract" on search-only rows.
rows_with_backend = [p for p in data["providers"] if p.get("web_backend")]
assert rows_with_backend, "expected at least one provider with a web backend key"
for prov in rows_with_backend:
assert isinstance(prov["capabilities"], list)
assert set(prov["capabilities"]) <= {"search", "extract"}
assert prov["capabilities"], "a web provider must support at least one capability"
def test_web_capability_fields_only_on_web_toolset(self):
resp = self.client.get("/api/tools/toolsets/tts/config")
assert resp.status_code == 200
data = resp.json()
assert "active_search_backend" not in data
assert "active_extract_backend" not in data
def test_select_web_search_backend_matches_runtime_resolution(self, monkeypatch):
"""PUT provider with capability=search writes web.search_backend and the
runtime search dispatcher resolves to it — while extract is untouched."""
# Make SearXNG available so both the endpoint gate and the runtime
# availability check agree it's usable.
monkeypatch.setenv("SEARXNG_URL", "http://localhost:8888")
# Give extract an explicit shared backend so the assertion isn't
# hostage to whatever creds exist on the machine running the tests.
monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
base = self.client.put(
"/api/tools/toolsets/web/provider",
json={"provider": "Firecrawl Self-Hosted"},
)
assert base.status_code == 200
resp = self.client.put(
"/api/tools/toolsets/web/provider",
json={"provider": "SearXNG", "capability": "search"},
)
assert resp.status_code == 200
body = resp.json()
assert body["ok"] is True
assert body["capability"] == "search"
from hermes_cli.config import load_config
cfg = load_config()
assert cfg["web"]["search_backend"] == "searxng"
# The shared backend selected first must be preserved for extract.
assert cfg["web"]["backend"] == "firecrawl"
# The REAL runtime resolution — not a parallel reimplementation.
from tools.web_tools import _get_extract_backend, _get_search_backend
assert _get_search_backend() == "searxng"
assert _get_extract_backend() == "firecrawl"
# And the config endpoint reports the same split.
data = self.client.get("/api/tools/toolsets/web/config").json()
assert data["active_search_backend"] == "searxng"
assert data["active_extract_backend"] == "firecrawl"
def test_select_web_extract_backend_writes_extract_key(self, monkeypatch):
monkeypatch.setenv("FIRECRAWL_API_URL", "http://localhost:3002")
resp = self.client.put(
"/api/tools/toolsets/web/provider",
json={"provider": "Firecrawl Self-Hosted", "capability": "extract"},
)
assert resp.status_code == 200
from hermes_cli.config import load_config
cfg = load_config()
assert cfg["web"]["extract_backend"] == "firecrawl"
# Whole-provider/search keys untouched by a capability-scoped write
# (the default config seeds them as empty strings).
assert not cfg["web"].get("search_backend")
from tools.web_tools import _get_extract_backend
assert _get_extract_backend() == "firecrawl"
def test_select_web_capability_rejects_unsupported_capability(self):
"""A search-only provider (ddgs) can't be set as the extract backend."""
resp = self.client.put(
"/api/tools/toolsets/web/provider",
json={"provider": "DuckDuckGo (ddgs)", "capability": "extract"},
)
assert resp.status_code == 400
assert "does not support extract" in resp.json()["detail"]
def test_select_web_capability_rejects_bad_values(self):
resp = self.client.put(
"/api/tools/toolsets/web/provider",
json={"provider": "Firecrawl Self-Hosted", "capability": "browse"},
)
assert resp.status_code == 400
# capability is a web-only concept.
resp = self.client.put(
"/api/tools/toolsets/tts/provider",
json={"provider": "Microsoft Edge TTS", "capability": "search"},
)
assert resp.status_code == 400
# -- Terminal execution backend picker ---------------------------------
def test_get_terminal_backends_shape_and_local_ready(self, monkeypatch):