diff --git a/apps/desktop/src/app/settings/combobox-input.tsx b/apps/desktop/src/app/settings/combobox-input.tsx new file mode 100644 index 000000000000..f8dcd7933817 --- /dev/null +++ b/apps/desktop/src/app/settings/combobox-input.tsx @@ -0,0 +1,112 @@ +import { useRef, useState } from 'react' + +import { Codicon } from '@/components/ui/codicon' +import { Command, CommandGroup, CommandItem, CommandList } from '@/components/ui/command' +import { Input } from '@/components/ui/input' +import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover' +import { cn } from '@/lib/utils' + +/** + * Free-input combobox for open-world fields (voice/model names): a plain + * Input the user can type anything into, plus a dropdown listing ALL known + * options. + * + * Replaces the old `` + `` rendering for + * FREE_INPUT_KEYS: native datalists filter by the field's current value, so a + * field already holding a valid option (e.g. `gpt-4o-mini-tts`) suggested + * only that one entry — users couldn't discover the other models/voices at + * all (and on some platforms datalists barely render). Suggestions filter by + * substring while typing, but an exact-match value shows the full list so an + * already-configured field still exposes every alternative. + */ +export function ComboboxInput({ + value, + onChange, + options, + optionLabels, + placeholder, + className +}: { + value: string + onChange: (value: string) => void + options: string[] + optionLabels?: Record + placeholder?: string + className?: string +}) { + const [open, setOpen] = useState(false) + const inputRef = useRef(null) + + const query = value.trim().toLowerCase() + const isExact = options.some(option => option.toLowerCase() === query) + + const visible = + query && !isExact ? options.filter(option => option.toLowerCase().includes(query)) : options + + return ( + + +
+ { + onChange(e.target.value) + + if (!open) { + setOpen(true) + } + }} + onFocus={() => setOpen(true)} + onKeyDown={e => { + if (e.key === 'Escape' || e.key === 'Enter' || e.key === 'Tab') { + setOpen(false) + } + }} + placeholder={placeholder} + ref={inputRef} + value={value} + /> + +
+
+ e.preventDefault()} + > + + + {visible.length > 0 && ( + + {visible.map(option => ( + { + onChange(option) + setOpen(false) + }} + value={option} + > + + {optionLabels?.[option] ?? option} + + ))} + + )} + + + +
+ ) +} diff --git a/apps/desktop/src/app/settings/config-field.tsx b/apps/desktop/src/app/settings/config-field.tsx index 7461b4b0c08c..21e46787226c 100644 --- a/apps/desktop/src/app/settings/config-field.tsx +++ b/apps/desktop/src/app/settings/config-field.tsx @@ -9,6 +9,7 @@ import { prettyName } from '@/lib/text' import { cn } from '@/lib/utils' import type { ConfigFieldSchema } from '@/types/hermes' +import { ComboboxInput } from './combobox-input' import { CONTROL_TEXT, EMPTY_SELECT_VALUE, FIELD_DESCRIPTIONS, FIELD_LABELS, FREE_INPUT_KEYS } from './constants' import { FallbackModelsField } from './fallback-models-field' import { fieldCopyForSchemaKey } from './field-copy' @@ -113,27 +114,19 @@ export function ConfigField({ // Voice/model name fields are open-world (custom voice IDs, cloned voices, // brand-new model names) — render a free-input combobox where the known - // options are datalist suggestions instead of a closed Select gate. + // options are dropdown suggestions instead of a closed Select gate. The old + // native filtered by the current value, so a field already set + // to a valid option showed only that single suggestion. if (selectOptions && FREE_INPUT_KEYS.has(schemaKey)) { - const datalistId = `config-field-options-${schemaKey.replace(/\./g, '-')}` - return row( - <> - onChange(e.target.value)} - placeholder={c.notSet} - value={String(value ?? '')} - /> - - {selectOptions - .filter(option => option !== '') - .map(option => ( - - + o !== '')} + placeholder={c.notSet} + value={String(value ?? '')} + /> ) } diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index b0e03175bc0c..51f9ab9c2dc6 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -263,8 +263,10 @@ export const ENUM_OPTIONS: Record = { // Speech-to-text backends — kept in sync with the stt block in // hermes_cli/config.py (local/groq/openai/mistral/elevenlabs). 'stt.provider': ['local', 'groq', 'openai', 'mistral', 'xai', 'elevenlabs'], - // gpt-4o-mini-tts voice set (the tts-1 era stopped at shimmer). Free-input - // field — the list is suggestions, not a gate (see FREE_INPUT_KEYS). + // OpenAI TTS voices — the union across models (per the OpenAI TTS API + // docs). Model-specific narrowing happens in enumOptionsFor(): + // tts-1 / tts-1-hd support 9 voices; gpt-4o-mini-tts supports all 13. + // Free-input field — the list is suggestions, not a gate (FREE_INPUT_KEYS). 'tts.openai.voice': [ 'alloy', 'ash', diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 860c89cb31f0..cd0a455b3cc7 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -209,6 +209,26 @@ describe('settings helpers', () => { expect(opts).toEqual(['local', 'docker', 'singularity', 'modal', 'daytona', 'ssh']) }) + it('narrows OpenAI TTS voice suggestions to what the selected model supports', () => { + // gpt-4o-mini-tts (and unset/unknown models): full 13-voice set. + const full = enumOptionsFor('tts.openai.voice', 'alloy', { tts: { openai: { model: 'gpt-4o-mini-tts' } } }) + expect(full).toContain('marin') + expect(full).toContain('cedar') + expect(full).toContain('ballad') + expect(full).toContain('verse') + expect(full).toHaveLength(13) + + // tts-1 / tts-1-hd: the 9-voice set — no ballad/verse/marin/cedar. + for (const model of ['tts-1', 'tts-1-hd']) { + const narrowed = enumOptionsFor('tts.openai.voice', 'alloy', { tts: { openai: { model } } }) + expect(narrowed).toEqual(['alloy', 'ash', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer']) + } + + // A hand-typed custom voice still stays selectable on tts-1. + const custom = enumOptionsFor('tts.openai.voice', 'my-cloned-voice', { tts: { openai: { model: 'tts-1' } } }) + expect(custom).toContain('my-cloned-voice') + }) + it('appends a hand-typed value not in the known list so it stays selected', () => { const opts = enumOptionsFor('tts.provider', 'my-custom-command-tts', config) expect(opts).toContain('my-custom-command-tts') diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 13be2c6f0bf7..e3f0fe14e2a2 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -259,6 +259,12 @@ function commandProviderNames(config: HermesConfigRecord, section: 'tts' | 'stt' return [...names] } +// Voice sets per OpenAI speech model, per the OpenAI TTS API docs: tts-1 and +// tts-1-hd support 9 voices; gpt-4o-mini-tts supports those plus ballad, +// verse, marin, and cedar (13 total). Unknown/future models get the full +// union (the field is free-input anyway — this only narrows suggestions). +const OPENAI_TTS1_VOICES = new Set(['alloy', 'ash', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer']) + export function enumOptionsFor( key: string, value: unknown, @@ -280,6 +286,16 @@ export function enumOptionsFor( } } + // Narrow OpenAI voice suggestions to what the selected model actually + // accepts — offering `marin` against tts-1 would 400 at the API. + if (!dynamicOptions && opts && key === 'tts.openai.voice') { + const model = asText(getNested(config, 'tts.openai.model')) + + if (model === 'tts-1' || model === 'tts-1-hd') { + opts = opts.filter(voice => OPENAI_TTS1_VOICES.has(voice)) + } + } + if (!opts) { return undefined } 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 6fd5d359087b..fb057d63879d 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx @@ -111,6 +111,16 @@ beforeEach(() => { Element.prototype.scrollIntoView = vi.fn() Element.prototype.hasPointerCapture = vi.fn(() => false) Element.prototype.releasePointerCapture = vi.fn() + // cmdk (used by the free-input voice/model combobox) needs ResizeObserver, + // which jsdom doesn't ship (mirrors searchable-select.test.tsx). + vi.stubGlobal( + 'ResizeObserver', + class { + observe() {} + unobserve() {} + disconnect() {} + } + ) getToolsetConfig.mockResolvedValue(config()) getToolsetModels.mockResolvedValue({ @@ -201,12 +211,17 @@ describe('ToolsetConfigPanel', () => { expect(getToolsetConfig).toHaveBeenCalledWith('tts') }) - it('selects a provider when clicked', async () => { + it('expands a provider on row click and activates it via the explicit button', async () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() + // Row click only expands — browsing details must not rewrite config. const elevenlabs = await screen.findByRole('button', { name: /ElevenLabs/ }) fireEvent.click(elevenlabs) + expect(selectToolsetProvider).not.toHaveBeenCalled() + + // The explicit activation button is what persists the backend choice. + fireEvent.click(await screen.findByRole('button', { name: /Use this backend/ })) await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'ElevenLabs')) }) @@ -223,17 +238,22 @@ describe('ToolsetConfigPanel', () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() - const edge = await screen.findByRole('button', { name: /Microsoft Edge TTS/ }) - const elevenlabs = screen.getByRole('button', { name: /ElevenLabs/ }) - fireEvent.click(edge) + // Edge auto-expands (first configured provider); activate it explicitly. + await screen.findByRole('button', { name: /Microsoft Edge TTS/ }) + const useBackend = await screen.findByRole('button', { name: /Use this backend/ }) + fireEvent.click(useBackend) await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'Microsoft Edge TTS')) - expect(elevenlabs.hasAttribute('disabled')).toBe(true) - fireEvent.click(elevenlabs) + // While the first selection is pending, the activation CTA is disabled — + // a second click must not fire another PUT. + expect(useBackend.hasAttribute('disabled')).toBe(true) + fireEvent.click(useBackend) expect(selectToolsetProvider).toHaveBeenCalledTimes(1) resolveSelection({ name: 'tts', ok: true, provider: 'Microsoft Edge TTS' }) - await waitFor(() => expect(elevenlabs.hasAttribute('disabled')).toBe(false)) + // Edge is now the active backend — its expanded panel shows the active + // hint instead of the activation button. + await waitFor(() => expect(screen.getByText('This is your active backend')).toBeTruthy()) }) it('shows a backend model catalog for image_gen and persists a pick', async () => { @@ -564,10 +584,12 @@ describe('ToolsetConfigPanel', () => { render() await screen.findByText('Microsoft Edge TTS') - // Exactly one Ready pill — the genuinely keyless Edge TTS row. - expect(screen.getAllByText('Ready')).toHaveLength(1) + // Edge is the active backend — its row pill reads Active (which + // subsumes Ready); the other rows keep their warn pills. + expect(screen.getAllByText('Active')).toHaveLength(1) + expect(screen.queryByText('Ready')).toBeNull() expect(screen.getByText('Needs sign-in')).toBeTruthy() - expect(screen.getByText('Needs setup')).toBeTruthy() + expect(screen.getByText('Setup required')).toBeTruthy() }) it('shows no Ready pill for a keyed provider the server marks needs_keys', async () => { @@ -603,7 +625,7 @@ describe('ToolsetConfigPanel', () => { expect(screen.queryByText('Ready')).toBeNull() // Missing keys are signalled by the env-var fields, not a warn pill. expect(screen.queryByText('Needs sign-in')).toBeNull() - expect(screen.queryByText('Needs setup')).toBeNull() + expect(screen.queryByText('Setup required')).toBeNull() }) it('falls back to the env-var heuristic when the backend sends no status', async () => { @@ -617,13 +639,13 @@ describe('ToolsetConfigPanel', () => { // Default config(): keyless Edge TTS (ready) + unset ElevenLabs (not). expect(screen.getAllByText('Ready')).toHaveLength(1) expect(screen.queryByText('Needs sign-in')).toBeNull() - expect(screen.queryByText('Needs setup')).toBeNull() + expect(screen.queryByText('Setup required')).toBeNull() }) it('flips a needs_keys provider to Ready locally after its key is saved', async () => { getToolsetConfig.mockResolvedValue( config({ - active_provider: 'ElevenLabs', + active_provider: null, providers: [ { name: 'ElevenLabs', @@ -640,7 +662,7 @@ describe('ToolsetConfigPanel', () => { ], post_setup: null, requires_nous_auth: false, - is_active: true, + is_active: false, status: 'needs_keys' } ] @@ -806,7 +828,9 @@ describe('ToolsetConfigPanel', () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() - fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ })) + // The single Nous row auto-expands; activate via the explicit button. + await screen.findByRole('button', { name: /Nous Subscription/ }) + fireEvent.click(await screen.findByRole('button', { name: /Use this backend/ })) await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('browser', 'Nous Subscription (Browser Use cloud)') @@ -849,7 +873,8 @@ describe('ToolsetConfigPanel', () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() - fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ })) + await screen.findByRole('button', { name: /Nous Subscription/ }) + fireEvent.click(await screen.findByRole('button', { name: /Use this backend/ })) // Grab the sign-in action off the warning notification and invoke it — // this is the affordance the toast renders as a button. @@ -891,7 +916,8 @@ describe('ToolsetConfigPanel', () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() - fireEvent.click(await screen.findByRole('button', { name: /Nous Subscription/ })) + await screen.findByRole('button', { name: /Nous Subscription/ }) + fireEvent.click(await screen.findByRole('button', { name: /Use this backend/ })) await waitFor(() => expect(notify).toHaveBeenCalledWith(expect.objectContaining({ kind: 'success' }))) expect(startOAuthLogin).not.toHaveBeenCalled() @@ -947,7 +973,7 @@ describe('ToolsetConfigPanel', () => { // provider initializer, and user intent must win that race. const elevenLabs = await screen.findByRole('button', { name: /ElevenLabs/ }) fireEvent.click(elevenLabs) - await waitFor(() => expect(elevenLabs.getAttribute('aria-pressed')).toBe('true')) + await waitFor(() => expect(elevenLabs.getAttribute('aria-expanded')).toBe('true')) const trigger = await screen.findByRole('button', { name: /^Actions$/ }) fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 1e824c2cd2c5..d0c92957c6df 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -492,7 +492,9 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi const [cfg, setCfg] = useState(null) const [loading, setLoading] = useState(true) const [selecting, setSelecting] = useState(null) - const [activeProvider, setActiveProvider] = useState(null) + // Which provider row is EXPANDED in the panel (purely presentational — + // distinct from the backend-active provider in cfg.active_provider). + const [expandedProvider, setExpandedProvider] = useState(null) // Live per-key set/unset state, seeded from the endpoint then patched locally. const [envState, setEnvState] = useState>({}) // Default-provider selection and a user click race just after config arrives: @@ -545,7 +547,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi // the user had already selected another (e.g. DuckDuckGo). // eslint-disable-next-line no-restricted-syntax -- one-shot provider-choice claim flag, not an atom mirror useEffect(() => { - if (providerChoiceClaimedRef.current || activeProvider || providers.length === 0) { + if (providerChoiceClaimedRef.current || expandedProvider || providers.length === 0) { return } @@ -556,11 +558,11 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi providers[0] // Claim before enqueueing the state update. Effects can run with a stale - // activeProvider closure after a user click, so state alone is too late to - // protect that choice. + // expandedProvider closure after a user click, so state alone is too late + // to protect that choice. providerChoiceClaimedRef.current = true - setActiveProvider(selected.name) - }, [activeProvider, providers, envState, cfg]) + setExpandedProvider(selected.name) + }, [expandedProvider, providers, envState, cfg]) async function handleSelect(provider: ToolProvider) { if (selecting !== null) { @@ -568,7 +570,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi } providerChoiceClaimedRef.current = true - setActiveProvider(provider.name) + setExpandedProvider(provider.name) setSelecting(provider.name) try { @@ -735,7 +737,8 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi )} {providers.map(provider => { - const isActive = activeProvider === provider.name + const isExpanded = expandedProvider === provider.name + const isBackendActive = provider.is_active || cfg?.active_provider === 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) @@ -744,19 +747,30 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi return (
- {isActive && ( + {isExpanded && (
{provider.tag &&

{provider.tag}

} + {(toolset !== 'web' || webCaps.length === 0) && ( + // Explicit activation — the old row-click-selects UX gave no + // signal about which backend was actually in use and made + // reading a row's details indistinguishable from choosing it. +
+ {isBackendActive ? ( + + + {copy.activeBackendHint} + + ) : ( + + )} +
+ )} {webCaps.length > 0 && ( // Per-capability assignment: writes web.search_backend / // web.extract_backend without touching the shared diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 87f4e84914e3..29fa06c439fe 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -910,7 +910,10 @@ export const en: Translations = { noProviders: 'No providers are available for this toolset right now.', ready: 'Ready', needsSignIn: 'Needs sign-in', - needsSetup: 'Needs setup', + needsSetup: 'Setup required', + activeBackend: 'Active', + activeBackendHint: 'This is your active backend', + useBackend: 'Use this backend', nousIncluded: 'Included with a Nous subscription — sign in to Nous Portal to activate.', nousAuthNeededTitle: 'Sign in to Nous Portal', nousAuthNeededMessage: provider => `${provider} is saved but won't activate until you sign in to Nous Portal.`, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 88ba5809cca1..22a11179e084 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -947,6 +947,9 @@ export const ja = defineLocale({ ready: '準備完了', needsSignIn: 'サインインが必要', needsSetup: 'セットアップが必要', + activeBackend: '使用中', + activeBackendHint: 'これが現在アクティブなバックエンドです', + useBackend: 'このバックエンドを使う', nousIncluded: 'Nous サブスクリプションに含まれています。有効にするには Nous Portal にサインインしてください。', nousAuthNeededTitle: 'Nous Portal にサインイン', nousAuthNeededMessage: provider => diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index cbcf9bd1579c..9b986fd90a92 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -786,6 +786,9 @@ export interface Translations { ready: string needsSignIn: string needsSetup: string + activeBackend: string + activeBackendHint: string + useBackend: string nousIncluded: string nousAuthNeededTitle: string nousAuthNeededMessage: (provider: string) => string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index b7ff39699d8c..9f1c2474e40a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -916,6 +916,9 @@ export const zhHant = defineLocale({ ready: '就緒', needsSignIn: '需要登入', needsSetup: '需要安裝', + activeBackend: '目前後端', + activeBackendHint: '這是你目前使用的後端', + useBackend: '使用此後端', nousIncluded: '包含在 Nous 訂閱中;登入 Nous Portal 即可啟用。', nousAuthNeededTitle: '登入 Nous Portal', nousAuthNeededMessage: provider => `已儲存 ${provider},但在登入 Nous Portal 之前不會啟用。`, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 71b55234df61..7a881a763197 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -1111,6 +1111,9 @@ export const zh: Translations = { ready: '就绪', needsSignIn: '需要登录', needsSetup: '需要安装', + activeBackend: '当前后端', + activeBackendHint: '这是你当前使用的后端', + useBackend: '使用此后端', nousIncluded: '包含在 Nous 订阅中;登录 Nous Portal 即可激活。', nousAuthNeededTitle: '登录 Nous Portal', nousAuthNeededMessage: provider => `已保存 ${provider},但在登录 Nous Portal 之前不会激活。`, diff --git a/hermes_cli/nous_subscription.py b/hermes_cli/nous_subscription.py index f2775e3bbe83..bfba9b2dd49f 100644 --- a/hermes_cli/nous_subscription.py +++ b/hermes_cli/nous_subscription.py @@ -159,17 +159,35 @@ def _toolset_enabled(config: Dict[str, object], toolset_key: str) -> bool: def _has_agent_browser() -> bool: import shutil - from hermes_constants import agent_browser_runnable + from hermes_constants import agent_browser_runnable, with_hermes_node_path # Validate the resolved binary actually runs — a dangling global symlink # (issue #48521) is reported by ``which`` but fails at exec. Fall through to # the local node_modules copy, which the validator also checks. if agent_browser_runnable(shutil.which("agent-browser")): return True - local_bin = ( - Path(__file__).parent.parent / "node_modules" / ".bin" / "agent-browser" - ) - return agent_browser_runnable(str(local_bin)) if local_bin.exists() else False + + # Hermes-managed Node dirs (Windows installer / POSIX $HERMES_HOME/node) + # are prepended to PATH at runtime but usually absent from the *probe* + # process's PATH — the same rung `_find_agent_browser` searches. Without + # it a successful install keeps reporting "needs setup" on Windows. + managed_path = with_hermes_node_path().get("PATH", "") + if managed_path: + managed_hit = shutil.which("agent-browser", path=managed_path) + if managed_hit and agent_browser_runnable(managed_hit): + return True + + # Local node_modules/.bin: resolve via PATHEXT-aware ``shutil.which`` so + # Windows picks the executable ``.cmd`` shim. Probing the extensionless + # POSIX shim directly fails exec (WinError 193) even right after a + # successful ``npm install`` — the bug that pinned every browser row on + # "Setup required" in the desktop GUI. + local_bin_dir = Path(__file__).parent.parent / "node_modules" / ".bin" + if local_bin_dir.is_dir(): + local_which = shutil.which("agent-browser", path=str(local_bin_dir)) + if local_which and agent_browser_runnable(local_which): + return True + return False def _local_browser_runnable() -> bool: diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 1d3f6899fa9a..cdc0243e0bd6 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -540,7 +540,12 @@ TOOL_CATEGORIES = { "requires_nous_auth": True, "managed_nous_feature": "browser", "override_env_vars": ["BROWSER_USE_API_KEY"], - "post_setup": "agent_browser", + # Cloud hook: installs the agent-browser CLI only. Browser Use + # hosts its own Chromium, so the local-Chromium install (and + # the local-Chromium readiness gate) must not apply here — + # with "agent_browser" this row read "needs setup" forever on + # machines without a local Chromium build. + "post_setup": "browserbase", }, { "name": "Camofox", @@ -2872,8 +2877,19 @@ def _agent_browser_installed() -> bool: Lightpanda engine, which needs no Chromium). Mirrors the hook so "Run setup" flips to an installed state only when re-running it would be a no-op.""" + import sys + from hermes_cli.nous_subscription import _local_browser_runnable + # The install hook runs in a spawned ``hermes tools post-setup`` process, + # but this probe runs in the long-lived web-server/CLI process, whose + # browser_tool module may have cached a stale "Chromium missing" result + # from before the install. Drop the cache (when the module is loaded) so + # the readiness pill flips to Ready right after a successful setup run. + bt = sys.modules.get("tools.browser_tool") + if bt is not None: + bt._cached_chromium_installed = None + return _local_browser_runnable() diff --git a/plugins/browser/browser_use/provider.py b/plugins/browser/browser_use/provider.py index 46a220333446..8310862cf7a7 100644 --- a/plugins/browser/browser_use/provider.py +++ b/plugins/browser/browser_use/provider.py @@ -313,5 +313,7 @@ class BrowserUseBrowserProvider(BrowserProvider): "url": "https://browser-use.com", }, ], - "post_setup": "agent_browser", + # Cloud-scoped hook: installs the agent-browser CLI only (no + # local Chromium — Browser Use hosts the browser). + "post_setup": "browserbase", } diff --git a/plugins/browser/browserbase/provider.py b/plugins/browser/browserbase/provider.py index 41ceb9e83d15..c828ae29b2da 100644 --- a/plugins/browser/browserbase/provider.py +++ b/plugins/browser/browserbase/provider.py @@ -293,5 +293,7 @@ class BrowserbaseBrowserProvider(BrowserProvider): "prompt": "Browserbase project ID", }, ], - "post_setup": "agent_browser", + # Cloud-scoped hook: installs the agent-browser CLI only (no + # local Chromium — Browserbase hosts the browser). + "post_setup": "browserbase", } diff --git a/plugins/browser/firecrawl/provider.py b/plugins/browser/firecrawl/provider.py index 50f813f60185..892cd6c1dc8f 100644 --- a/plugins/browser/firecrawl/provider.py +++ b/plugins/browser/firecrawl/provider.py @@ -167,5 +167,7 @@ class FirecrawlBrowserProvider(BrowserProvider): "url": "https://firecrawl.dev", }, ], - "post_setup": "agent_browser", + # Cloud-scoped hook: installs the agent-browser CLI only (no + # local Chromium — Firecrawl hosts the browser). + "post_setup": "browserbase", } diff --git a/tests/hermes_cli/test_nous_subscription.py b/tests/hermes_cli/test_nous_subscription.py index 9faf25965e0e..064c3fa66cbc 100644 --- a/tests/hermes_cli/test_nous_subscription.py +++ b/tests/hermes_cli/test_nous_subscription.py @@ -854,3 +854,37 @@ def test_apply_gateway_defaults_sets_stt_use_gateway(monkeypatch): assert "stt" in changed assert config["stt"]["provider"] == "openai" assert config["stt"]["use_gateway"] is True + + +def test_has_agent_browser_resolves_via_hermes_managed_node_path(monkeypatch, tmp_path): + """The managed-Node rung: a runnable agent-browser under the Hermes Node + dir must count even when it's absent from the probe process's PATH (the + Windows installer shape — install succeeded, GUI still said needs setup).""" + import shutil as _shutil + + managed_dir = tmp_path / "node" + managed_dir.mkdir() + managed_bin = managed_dir / "agent-browser" + managed_bin.write_text("#!/bin/sh\nexit 0\n") + managed_bin.chmod(0o755) + + monkeypatch.setattr(_shutil, "which", lambda cmd, path=None: str(managed_bin) if path else None) + monkeypatch.setattr( + "hermes_constants.with_hermes_node_path", lambda: {"PATH": str(managed_dir)} + ) + monkeypatch.setattr( + "hermes_constants.agent_browser_runnable", + lambda p: bool(p) and str(p) == str(managed_bin), + ) + + assert ns._has_agent_browser() is True + + +def test_has_agent_browser_false_when_nothing_runnable(monkeypatch): + import shutil as _shutil + + monkeypatch.setattr(_shutil, "which", lambda cmd, path=None: None) + monkeypatch.setattr("hermes_constants.with_hermes_node_path", lambda: {"PATH": ""}) + monkeypatch.setattr("hermes_constants.agent_browser_runnable", lambda p: False) + + assert ns._has_agent_browser() is False diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 0b3e84251185..5d298000734e 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -1200,7 +1200,7 @@ def test_computer_use_needs_configuration_when_cua_driver_post_setup_pending(): def test_computer_use_skips_configuration_when_cua_driver_already_installed(): """Installed post_setup dependencies should keep returning-user toggles no-op.""" - def fake_which(name: str): + def fake_which(name: str, path=None): return "/usr/local/bin/cua-driver" if name == "cua-driver" else None with patch("shutil.which", side_effect=fake_which): @@ -1209,7 +1209,7 @@ def test_computer_use_skips_configuration_when_cua_driver_already_installed(): def test_computer_use_respects_custom_cua_driver_command(): """The setup gate should match runtime's HERMES_CUA_DRIVER_CMD override.""" - def fake_which(name: str): + def fake_which(name: str, path=None): return "/opt/bin/custom-cua" if name == "custom-cua" else None with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \ @@ -1219,7 +1219,7 @@ def test_computer_use_respects_custom_cua_driver_command(): def test_computer_use_blank_custom_driver_command_falls_back_to_default(): """Blank overrides should not make the setup gate look for an empty command.""" - def fake_which(name: str): + def fake_which(name: str, path=None): return "/usr/local/bin/cua-driver" if name == "cua-driver" else None with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": " "}), \ @@ -1229,7 +1229,7 @@ def test_computer_use_blank_custom_driver_command_falls_back_to_default(): def test_computer_use_post_setup_respects_custom_driver_command_when_installed(): """post_setup already-installed checks should version-probe the resolved override.""" - def fake_which(name: str): + def fake_which(name: str, path=None): return "/opt/bin/custom-cua" if name == "custom-cua" else None with patch.dict("os.environ", {"HERMES_CUA_DRIVER_CMD": "custom-cua"}), \ @@ -1250,7 +1250,7 @@ def test_computer_use_post_setup_missing_override_does_not_accept_default_binary """A default cua-driver binary must not satisfy a missing runtime override.""" seen = [] - def fake_which(name: str): + def fake_which(name: str, path=None): seen.append(name) if name == "cua-driver": return "/usr/local/bin/cua-driver" diff --git a/tests/plugins/browser/test_browser_provider_plugins.py b/tests/plugins/browser/test_browser_provider_plugins.py index 986a1d635bfe..c05f92b3b92a 100644 --- a/tests/plugins/browser/test_browser_provider_plugins.py +++ b/tests/plugins/browser/test_browser_provider_plugins.py @@ -115,9 +115,11 @@ class TestBundledPluginsRegister: assert isinstance(schema, dict) assert "name" in schema assert "env_vars" in schema - # Every cloud-browser plugin needs the agent-browser post-setup hook - # so the picker auto-installs the CLI on selection. - assert schema.get("post_setup") == "agent_browser" + # Every cloud-browser plugin needs the cloud-scoped post-setup hook + # ("browserbase" = agent-browser CLI only, no local Chromium install) + # so the picker auto-installs the CLI on selection without gating + # readiness on a local Chromium build the cloud never uses. + assert schema.get("post_setup") == "browserbase" @pytest.mark.parametrize( "plugin_name", @@ -359,13 +361,14 @@ class TestPickerIntegration: assert names == ["browser-use", "browserbase", "firecrawl"] def test_picker_rows_carry_post_setup_hook(self) -> None: - """Every browser plugin row has post_setup='agent_browser' so - selecting it triggers the agent-browser CLI install.""" + """Every browser plugin row has the cloud-scoped post_setup hook + ('browserbase': agent-browser CLI only) so selecting it installs the + CLI without requiring a local Chromium the cloud never uses.""" _ensure_plugins_loaded() from hermes_cli.tools_config import _plugin_browser_providers for row in _plugin_browser_providers(): - assert row.get("post_setup") == "agent_browser", ( + assert row.get("post_setup") == "browserbase", ( f"plugin row {row['browser_provider']!r} missing post_setup hook" )