From 6da615c77cf8b846348839ca7c801c0d0833f325 Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Wed, 24 Jun 2026 23:19:45 +0700 Subject: [PATCH 1/2] fix(desktop): scope onboarding runtime check to connected provider Let setup.runtime_check accept an optional provider, persist the selected provider/model before the gate, and validate the provider the user just connected instead of a stale config entry such as anthropic. --- apps/desktop/src/lib/runtime-readiness.ts | 17 +++++--- apps/desktop/src/store/onboarding.ts | 52 ++++++++++++----------- tui_gateway/server.py | 3 +- 3 files changed, 41 insertions(+), 31 deletions(-) diff --git a/apps/desktop/src/lib/runtime-readiness.ts b/apps/desktop/src/lib/runtime-readiness.ts index 47f3406eacf..0d6c16b14b3 100644 --- a/apps/desktop/src/lib/runtime-readiness.ts +++ b/apps/desktop/src/lib/runtime-readiness.ts @@ -16,6 +16,7 @@ export interface RuntimeReadinessSignals { export interface RuntimeReadinessOptions { defaultReason?: string + requestedProvider?: string unknownReady?: boolean } @@ -54,21 +55,27 @@ function normalizeMessage(value: null | string | undefined): null | string { async function requestWithFallback( requestGateway: RuntimeReadinessRequester, - method: string + method: string, + params?: Record ): Promise<{ error: null | string; value: null | T }> { try { - return { error: null, value: await requestGateway(method) } + return { error: null, value: await requestGateway(method, params) } } catch (error) { return { error: toErrorMessage(error), value: null } } } export async function fetchRuntimeReadinessSignals( - requestGateway: RuntimeReadinessRequester + requestGateway: RuntimeReadinessRequester, + requestedProvider?: string ): Promise { + const runtimeParams = requestedProvider?.trim() + ? { provider: requestedProvider.trim() } + : undefined + const [setup, runtime] = await Promise.all([ requestWithFallback(requestGateway, 'setup.status'), - requestWithFallback(requestGateway, 'setup.runtime_check') + requestWithFallback(requestGateway, 'setup.runtime_check', runtimeParams) ]) return { @@ -141,7 +148,7 @@ export async function evaluateRuntimeReadiness( requestGateway: RuntimeReadinessRequester, options: RuntimeReadinessOptions = {} ): Promise { - const signals = await fetchRuntimeReadinessSignals(requestGateway) + const signals = await fetchRuntimeReadinessSignals(requestGateway, options.requestedProvider) return interpretRuntimeReadiness(signals, options) } diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 927c0911dda..c8b21878720 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -181,9 +181,13 @@ function clearPoll() { } } -async function checkRuntime(ctx: OnboardingContext): Promise { +async function checkRuntime( + ctx: OnboardingContext, + requestedProvider?: string +): Promise { return evaluateRuntimeReadiness(ctx.requestGateway, { defaultReason: DEFAULT_ONBOARDING_REASON, + requestedProvider, unknownReady: false }) } @@ -307,7 +311,28 @@ async function completeWithModelConfirm( ignoreRuntimeGate = false ) { await ctx.requestGateway('reload.env').catch(() => undefined) - const runtime = await checkRuntime(ctx) + + const defaults = await fetchProviderDefaultModel(preferredSlugs) + + if (defaults) { + // Persist the chosen provider/model before the runtime gate so a stale + // config provider (e.g. anthropic from a prior failed setup) cannot make + // setup.runtime_check validate the wrong backend after a fresh OAuth login. + try { + const res = await setModelAssignment({ + scope: 'main', + provider: defaults.providerSlug, + model: defaults.defaultModel + }) + + notifyGatewayTools(res.gateway_tools) + } catch { + // Persistence failed — still run the scoped runtime check below and + // show the confirm card so the user can pick something explicitly. + } + } + + const runtime = await checkRuntime(ctx, preferredSlugs[0]) if (!runtime.ready && !ignoreRuntimeGate) { onFail(runtime.reason) @@ -315,8 +340,6 @@ async function completeWithModelConfirm( return } - const defaults = await fetchProviderDefaultModel(preferredSlugs) - if (!defaults) { // Couldn't get a sensible default — proceed without confirm step. notifyReady(providerLabel) @@ -326,27 +349,6 @@ async function completeWithModelConfirm( return } - // Persist the default model BEFORE showing the confirm card so that: - // (1) "current default: X" shown in the UI is what's actually written - // to config — no lying. - // (2) If the user clicks "Start chatting" without changing anything, - // no extra write is needed. - // (3) If they bail out (e.g., refresh the page), they still end up - // with a working config, not an empty-model fallback. - try { - const res = await setModelAssignment({ - scope: 'main', - provider: defaults.providerSlug, - model: defaults.defaultModel - }) - - notifyGatewayTools(res.gateway_tools) - } catch { - // Persistence failed — still show the confirm card so the user can - // pick something explicitly. The backend will pick its own default - // at chat time if we end up never persisting. - } - setFlow({ status: 'confirming_model', providerSlug: defaults.providerSlug, diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 61a100b98c8..642bfa029df 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -7799,7 +7799,8 @@ def _(rid, params: dict) -> dict: from hermes_cli.auth import has_usable_secret from hermes_cli.main import _has_any_provider_configured - runtime = resolve_runtime_provider(requested=None) + requested = str(params.get("provider") or "").strip() or None + runtime = resolve_runtime_provider(requested=requested) provider_configured = bool(_has_any_provider_configured()) provider = runtime.get("provider") or "provider" source = str(runtime.get("source") or "") From d8fe1c0b4195a8743cd8121a05d9f147c4e60c6d Mon Sep 17 00:00:00 2001 From: infinitycrew39 Date: Wed, 24 Jun 2026 23:19:51 +0700 Subject: [PATCH 2/2] test(desktop): cover scoped onboarding runtime readiness checks Assert setup.runtime_check honors provider params and that Nous OAuth onboarding persists model config before validating the connected provider. --- .../desktop/src/lib/runtime-readiness.test.ts | 50 ++++++++++++++++++- apps/desktop/src/store/onboarding.test.ts | 12 ++++- tests/test_tui_gateway_server.py | 33 ++++++++++++ 3 files changed, 93 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/runtime-readiness.test.ts b/apps/desktop/src/lib/runtime-readiness.test.ts index 54a25828c7e..83a1d2a2bdd 100644 --- a/apps/desktop/src/lib/runtime-readiness.test.ts +++ b/apps/desktop/src/lib/runtime-readiness.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { interpretRuntimeReadiness } from './runtime-readiness' +import { evaluateRuntimeReadiness, fetchRuntimeReadinessSignals, interpretRuntimeReadiness } from './runtime-readiness' describe('interpretRuntimeReadiness', () => { it('prefers runtime_check when both signals exist', () => { @@ -63,3 +63,51 @@ describe('interpretRuntimeReadiness', () => { expect(result.reason).toBe('setup.runtime_check timeout') }) }) + +describe('fetchRuntimeReadinessSignals', () => { + it('scopes setup.runtime_check to the requested provider', async () => { + const calls: Array<{ method: string; params?: Record }> = [] + const requestGateway = async (method: string, params?: Record) => { + calls.push({ method, params }) + + if (method === 'setup.status') { + return { provider_configured: true } as T + } + + if (method === 'setup.runtime_check') { + return { ok: true } as T + } + + throw new Error(`unexpected method: ${method}`) + } + + await fetchRuntimeReadinessSignals(requestGateway, 'nous') + + expect(calls).toEqual([ + { method: 'setup.status' }, + { method: 'setup.runtime_check', params: { provider: 'nous' } } + ]) + }) +}) + +describe('evaluateRuntimeReadiness', () => { + it('forwards requestedProvider to setup.runtime_check', async () => { + const requestGateway = async (method: string, params?: Record) => { + if (method === 'setup.status') { + return { provider_configured: true } as T + } + + if (method === 'setup.runtime_check') { + expect(params).toEqual({ provider: 'nous' }) + + return { ok: true } as T + } + + throw new Error(`unexpected method: ${method}`) + } + + const result = await evaluateRuntimeReadiness(requestGateway, { requestedProvider: 'nous' }) + + expect(result.ready).toBe(true) + }) +}) diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 7173e89f572..10c3159f021 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -194,7 +194,7 @@ describe('OAuth onboarding', () => { throw new Error(`unexpected api path: ${path}`) }) - const requestGateway: OnboardingContext['requestGateway'] = async method => { + const requestGateway: OnboardingContext['requestGateway'] = async (method, params) => { if (method === 'reload.env') { return {} as never } @@ -204,6 +204,8 @@ describe('OAuth onboarding', () => { } if (method === 'setup.runtime_check') { + expect(params).toEqual({ provider: 'nous' }) + return { ok: true } as never } @@ -241,6 +243,14 @@ describe('OAuth onboarding', () => { } expect(calls.some(c => c.path === '/api/model/set')).toBe(true) + + const optionsIndex = calls.findIndex(c => c.path === '/api/model/options') + const recommendedIndex = calls.findIndex(c => c.path.startsWith('/api/model/recommended-default')) + const setIndex = calls.findIndex(c => c.path === '/api/model/set') + + expect(optionsIndex).toBeGreaterThanOrEqual(0) + expect(recommendedIndex).toBeGreaterThan(optionsIndex) + expect(setIndex).toBeGreaterThan(recommendedIndex) }) }) diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 8d7dce2bd79..e88128a3c97 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2784,6 +2784,39 @@ def test_setup_runtime_check_rejects_implicit_bedrock_when_unconfigured(monkeypa assert resp["result"]["provider"] == "bedrock" +def test_setup_runtime_check_honors_requested_provider(monkeypatch): + """Onboarding must be able to validate the provider the user just connected.""" + monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: True) + + def fake_resolve(requested=None, **kwargs): + if requested == "nous": + return { + "provider": "nous", + "api_key": "invoke-jwt", + "source": "portal", + } + return { + "provider": "anthropic", + "api_key": "", + "source": "config", + } + + monkeypatch.setattr( + "hermes_cli.runtime_provider.resolve_runtime_provider", + fake_resolve, + ) + + scoped = server.handle_request( + {"id": "1", "method": "setup.runtime_check", "params": {"provider": "nous"}} + ) + assert scoped["result"]["ok"] is True + assert scoped["result"]["provider"] == "nous" + + default = server.handle_request({"id": "1", "method": "setup.runtime_check", "params": {}}) + assert default["result"]["ok"] is False + assert default["result"]["provider"] == "anthropic" + + def test_complete_slash_drops_removed_provider_alias(): # `/provider` was folded into a single `/model` command, so autocomplete # must no longer offer the dead alias...