diff --git a/apps/desktop/src/app/settings/env-var-actions-menu.tsx b/apps/desktop/src/app/settings/env-var-actions-menu.tsx index db454cb5637..cd1ced813ee 100644 --- a/apps/desktop/src/app/settings/env-var-actions-menu.tsx +++ b/apps/desktop/src/app/settings/env-var-actions-menu.tsx @@ -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({ {isSet ? copy.replace : copy.set} + {hasManageKeys && ( + { + triggerHaptic('selection') + onManageKeys() + }} + > + + {copy.manageInKeys} + + )} + {hasClear && ( <> diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index 3f69c0166b2..15f33ca7b60 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -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=): 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 ( - setOpenKey(key)} - onToggle={() => setOpenKey(prev => (prev === key ? null : key))} - placeholder={credentialPlaceholder(key, info, label)} - rowProps={rowProps} - varKey={key} - /> +
+ setOpenKey(key)} + onToggle={() => setOpenKey(prev => (prev === key ? null : key))} + placeholder={credentialPlaceholder(key, info, label)} + rowProps={rowProps} + varKey={key} + /> +
) })} diff --git a/apps/desktop/src/app/settings/model-settings.test.tsx b/apps/desktop/src/app/settings/model-settings.test.tsx index 4ba6fd512ca..985c6a018b6 100644 --- a/apps/desktop/src/app/settings/model-settings.test.tsx +++ b/apps/desktop/src/app/settings/model-settings.test.tsx @@ -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( - - - + // The aux-task deep-link highlight reads useSearchParams, so the page + // needs a router context in tests (the app provides HashRouter at root). + + + + + ) } diff --git a/apps/desktop/src/app/settings/model-settings.tsx b/apps/desktop/src/app/settings/model-settings.tsx index 0b188605ce6..8b18b65b5fc 100644 --- a/apps/desktop/src/app/settings/model-settings.tsx +++ b/apps/desktop/src/app/settings/model-settings.tsx @@ -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 ( - + @@ -951,7 +961,6 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { {isAuto ? m.autoUseMain : `${current.provider} · ${current.model || m.providerDefault}`} } - key={meta.key} title={ {copy.label} @@ -959,6 +968,7 @@ export function ModelSettings({ onMainModelChanged }: ModelSettingsProps) { } /> + ) })} diff --git a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx index 693dc86f2e3..c63e516541e 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx @@ -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()), + 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() + + // 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() + + 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() + + // 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 { + 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() + + 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() + + // 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() + + await screen.findByText('Microsoft Edge TTS') + expect(screen.queryByText(/^Search: /)).toBeNull() + expect(screen.queryByRole('button', { name: 'Use for Search' })).toBeNull() + }) + }) }) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 4ca544926ba..a35dcdea223 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -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(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()} > 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 (
+ {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. +
+ {copy.webSearchActive(cfg.active_search_backend || copy.webCapabilityUnset)} + {copy.webExtractActive(cfg.active_extract_backend || copy.webCapabilityUnset)} +
+ )} {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 (
@@ -693,6 +743,8 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi )} {status === 'needs_auth' && {copy.needsSignIn}} {status === 'needs_setup' && {copy.needsSetup}} + {isSearchBackend && {copy.webUsedForSearch}} + {isExtractBackend && {copy.webUsedForExtract}} {selecting === provider.name && } @@ -700,6 +752,34 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi {isActive && (
{provider.tag &&

{provider.tag}

} + {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). +
+ {webCaps.includes('search') && ( + + )} + {webCaps.includes('extract') && ( + + )} +
+ )} {provider.requires_nous_auth && (

{copy.nousIncluded}

)} diff --git a/apps/desktop/src/app/skills/index.test.tsx b/apps/desktop/src/app/skills/index.test.tsx index f4057a4f2b8..0c05bec75ad 100644 --- a/apps/desktop/src/app/skills/index.test.tsx +++ b/apps/desktop/src/app/skills/index.test.tsx @@ -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()), + useNavigate: () => navigateSpy +})) + function toolset(overrides: Record = {}) { 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')) + }) }) diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index ddaf9e6c898..6349e39a00a 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -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({ ))}
)} + {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. +
+

+ {t.skills.visionModelHint} +

+
+ +
+
+ )} {toolset.name === 'computer_use' && } {toolset.name === 'terminal' && } diff --git a/apps/desktop/src/hermes.ts b/apps/desktop/src/hermes.ts index 9f8de233598..f86843cd3b5 100644 --- a/apps/desktop/src/hermes.ts +++ b/apps/desktop/src/hermes.ts @@ -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 { +export function selectToolsetProvider( + name: string, + provider: string, + capability?: 'search' | 'extract' +): Promise { return window.hermesDesktop.api({ ...profileScoped(), path: `/api/tools/toolsets/${encodeURIComponent(name)}/provider`, method: 'PUT', - body: { provider } + body: capability ? { provider, capability } : { provider } }) } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 6adeee5cde2..7765c0f7468 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 235ae45970b..fcc27776201 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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} ツールセットを切り替え`, diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 11cb6a534da..40f224f2b24 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 625073d8589..4c543feb986 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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} 工具集`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index dce07395e44..07c01b7395e 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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} 工具集`, diff --git a/apps/desktop/src/types/hermes.ts b/apps/desktop/src/types/hermes.ts index 0bfa3edc358..dc2a71f1dff 100644 --- a/apps/desktop/src/types/hermes.ts +++ b/apps/desktop/src/types/hermes.ts @@ -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. diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 5f8d305b5d2..9c82d5a1725 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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//; this helper is the sole source of provider rows diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index ba8c8ec7c8c..1d4abc7f896 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -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._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. diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 204c1424d3b..5353ef0f4ba 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -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):