diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream.test.ts new file mode 100644 index 0000000000000..d2e378944c1bd --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest' + +import { isStickySafeProvider } from './use-message-stream' + +describe('isStickySafeProvider', () => { + it('rejects the bare billing-class "custom" that backend pushes for every named providers:/custom_providers: entry', () => { + expect(isStickySafeProvider('custom')).toBe(false) + }) + + it('rejects "custom:" slugs recovered by _runtime_model_config → canonical_custom_identity', () => { + expect(isStickySafeProvider('custom:tokenrouter')).toBe(false) + // The literal value Bruce observed in the broken desktop after a Telegram + // /model + picker round-trip: + expect(isStickySafeProvider('custom:minimax m3')).toBe(false) + expect(isStickySafeProvider('custom: MiniMax M3 Max')).toBe(false) + }) + + it('rejects the rejected shapes regardless of case or surrounding whitespace', () => { + expect(isStickySafeProvider('Custom')).toBe(false) + expect(isStickySafeProvider('CUSTOM')).toBe(false) + expect(isStickySafeProvider(' custom ')).toBe(false) + expect(isStickySafeProvider('Custom:Tokenrouter')).toBe(false) + }) + + it('accepts a real, user-facing provider name (the named custom_providers entry)', () => { + expect(isStickySafeProvider('tokenrouter')).toBe(true) + }) + + it('accepts a built-in provider slug', () => { + expect(isStickySafeProvider('openrouter')).toBe(true) + expect(isStickySafeProvider('anthropic')).toBe(true) + expect(isStickySafeProvider('gemini')).toBe(true) + expect(isStickySafeProvider('xai-oauth')).toBe(true) + }) + + it('rejects empty / whitespace / null / undefined payloads', () => { + expect(isStickySafeProvider('')).toBe(false) + expect(isStickySafeProvider(' ')).toBe(false) + expect(isStickySafeProvider(null)).toBe(false) + expect(isStickySafeProvider(undefined)).toBe(false) + }) +}) \ No newline at end of file diff --git a/apps/desktop/src/app/session/hooks/use-message-stream.ts b/apps/desktop/src/app/session/hooks/use-message-stream.ts index 909c142479678..e59f038659ddd 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream.ts @@ -56,6 +56,40 @@ import type { RpcEvent } from '@/types/hermes' import type { ClientSessionState } from '../../types' +/** + * Decide whether a `session.info` payload's `provider` field is a real, + * user-facing provider name (worth persisting into the sticky + * `$currentProvider` atom) versus an internal billing-class identity that + * would brick the next `session.create` round-trip if written verbatim. + * + * Backend writes the **resolved billing class** into `agent.provider` for + * every named `providers:` / `custom_providers:` entry: + * - the bare string `"custom"` — for entries resolved via + * `_resolve_named_custom_runtime` (`hermes_cli/runtime_provider.py`) + * - the `"custom:"` slug — for entries recovered via + * `_runtime_model_config` → `canonical_custom_identity` + * (`tui_gateway/server.py`) + * Neither is a routable user-facing slug: + * - `resolve_runtime_provider("custom")` → falls through to + * OpenRouter default URL with no api_key, then to the Gemini + * fallback endpoint. + * - `resolve_runtime_provider("custom:")` → looks up + * `_get_named_custom_provider("custom:")`, finds nothing (the + * `custom_providers:` map keys are the bare names, not `custom:`), + * same fallback path. + * Either value written to `localStorage` makes the next `session.create` + * ship an unresolvable `provider` and surface as the Desktop 400 + * `unexpected model name format` regression until the user manually + * re-picks a model from the picker. Reject both shapes here. + */ +export function isStickySafeProvider(provider: string | null | undefined): boolean { + if (typeof provider !== 'string') return false + const trimmed = provider.trim() + if (!trimmed) return false + const lower = trimmed.toLowerCase() + return lower !== 'custom' && !lower.startsWith('custom:') +} + interface MessageStreamOptions { activeSessionIdRef: MutableRefObject hydrateFromStoredSession: ( @@ -741,7 +775,22 @@ export function useMessageStream({ } if (providerChanged) { - setCurrentProvider(payload!.provider || '') + // backend pushes `agent.provider` (the resolved billing class) on + // every `session.info` event. For any named ``providers:`` / + // ``custom_providers:`` entry that value is the literal string + // ``"custom"`` — NOT a routable provider identity (see + // ``hermes_cli/runtime_provider.py:_resolve_named_custom_runtime`` + // and ``tui_gateway/server.py:_session_info``). Writing it into + // the sticky ``$currentProvider`` atom bricks every subsequent + // ``session.create`` round-trip until the user manually re-picks + // a model from the picker (Desktop 400 "unexpected model name + // format" bug — `?custom` is not a built-in model and gets + // routed to the Gemini fallback endpoint). Keep the existing + // sticky state until a real provider name arrives. + const incomingProvider = (payload!.provider || '').trim() + if (isStickySafeProvider(incomingProvider)) { + setCurrentProvider(incomingProvider) + } } if (typeof payload?.cwd === 'string') {