fix(tui): first-run setup preflight + actionable no-provider panel

- tui_gateway: new `setup.status` RPC that reuses CLI's
  `_has_any_provider_configured()`, so the TUI can ask the same question
  the CLI bootstrap asks before launching a session
- useSessionLifecycle: preflight `setup.status` before both `newSession`
  and `resumeById`, and render a clear "Setup Required" panel when no
  provider is configured instead of booting a session that immediately
  fails with `agent init failed`
- createGatewayEventHandler: drop duplicate startup resume logic in
  favor of the preflighted `resumeById`, and special-case the
  no-provider agent-init error as a last-mile fallback to the same
  setup panel
- add regression tests for both paths
This commit is contained in:
Brooklyn Nicholson 2026-04-17 10:58:01 -05:00
parent 5b386ced71
commit 0dd5055d59
9 changed files with 145 additions and 62 deletions

View file

@ -129,6 +129,14 @@ def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
assert server.os.environ["HERMES_INTERACTIVE"] == "1" assert server.os.environ["HERMES_INTERACTIVE"] == "1"
def test_setup_status_reports_provider_config(monkeypatch):
monkeypatch.setattr("hermes_cli.main._has_any_provider_configured", lambda: False)
resp = server.handle_request({"id": "1", "method": "setup.status", "params": {}})
assert resp["result"]["provider_configured"] is False
def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch): def test_config_set_reasoning_updates_live_session_and_agent(tmp_path, monkeypatch):
monkeypatch.setattr(server, "_hermes_home", tmp_path) monkeypatch.setattr(server, "_hermes_home", tmp_path)
agent = types.SimpleNamespace(reasoning_config=None) agent = types.SimpleNamespace(reasoning_config=None)

View file

@ -1878,6 +1878,15 @@ def _(rid, params: dict) -> dict:
return _err(rid, 4002, f"unknown config key: {key}") return _err(rid, 4002, f"unknown config key: {key}")
@method("setup.status")
def _(rid, params: dict) -> dict:
try:
from hermes_cli.main import _has_any_provider_configured
return _ok(rid, {"provider_configured": bool(_has_any_provider_configured())})
except Exception as e:
return _err(rid, 5016, str(e))
# ── Methods: tools & system ────────────────────────────────────────── # ── Methods: tools & system ──────────────────────────────────────────
@method("process.stop") @method("process.stop")

View file

@ -26,6 +26,7 @@ const buildCtx = (appended: Msg[]) =>
colsRef: ref(80), colsRef: ref(80),
newSession: vi.fn(), newSession: vi.fn(),
resetSession: vi.fn(), resetSession: vi.fn(),
resumeById: vi.fn(),
setCatalog: vi.fn() setCatalog: vi.fn()
}, },
system: { system: {
@ -34,6 +35,8 @@ const buildCtx = (appended: Msg[]) =>
}, },
transcript: { transcript: {
appendMessage: (msg: Msg) => appended.push(msg), appendMessage: (msg: Msg) => appended.push(msg),
panel: (title: string, sections: any[]) =>
appended.push({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }),
setHistoryItems: vi.fn() setHistoryItems: vi.fn()
} }
}) as any }) as any
@ -138,4 +141,24 @@ describe('createGatewayEventHandler', () => {
expect(appended[0]?.thinking).toBe(fromServer) expect(appended[0]?.thinking).toBe(fromServer)
expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer)) expect(appended[0]?.thinkingTokens).toBe(estimateTokensRough(fromServer))
}) })
it('shows setup panel for missing provider startup error', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: {
message:
'agent init failed: No LLM provider configured. Run `hermes model` to select a provider, or run `hermes setup` for first-time configuration.'
},
type: 'error'
} as any)
expect(appended).toHaveLength(1)
expect(appended[0]).toMatchObject({
kind: 'panel',
panelData: { title: 'Setup Required' },
role: 'system'
})
})
}) })

View file

@ -1,7 +1,7 @@
import { STREAM_BATCH_MS } from '../config/timing.js' import { STREAM_BATCH_MS } from '../config/timing.js'
import { introMsg, toTranscriptMessages } from '../domain/messages.js' import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin, SessionResumeResponse } from '../gatewayTypes.js' import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js' import { rpcErrorMessage } from '../lib/rpc.js'
import { formatToolCall } from '../lib/text.js' import { formatToolCall } from '../lib/text.js'
import { fromSkin } from '../theme.js' import { fromSkin } from '../theme.js'
import type { SubagentProgress } from '../types.js' import type { SubagentProgress } from '../types.js'
@ -12,6 +12,7 @@ import { turnController } from './turnController.js'
import { getUiState, patchUiState } from './uiStore.js' import { getUiState, patchUiState } from './uiStore.js'
const ERRLIKE_RE = /\b(error|traceback|exception|failed|spawn)\b/i const ERRLIKE_RE = /\b(error|traceback|exception|failed|spawn)\b/i
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready') const statusFromBusy = () => (getUiState().busy ? 'running…' : 'ready')
@ -46,10 +47,10 @@ const pushTool = pushUnique(8)
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void { export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
const { dequeue, queueEditRef, sendQueued } = ctx.composer const { dequeue, queueEditRef, sendQueued } = ctx.composer
const { gw, rpc } = ctx.gateway const { rpc } = ctx.gateway
const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session
const { bellOnComplete, stdout, sys } = ctx.system const { bellOnComplete, stdout, sys } = ctx.system
const { appendMessage, setHistoryItems } = ctx.transcript const { appendMessage, panel, setHistoryItems } = ctx.transcript
let pendingThinkingStatus = '' let pendingThinkingStatus = ''
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
@ -121,30 +122,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
} }
patchUiState({ status: 'resuming…' }) patchUiState({ status: 'resuming…' })
gw.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID }) resumeById(STARTUP_RESUME_ID)
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r) {
throw new Error('invalid response: session.resume')
}
resetSession()
patchUiState({
info: r.info ?? null,
sid: r.session_id,
status: 'ready',
usage: r.info?.usage ?? getUiState().usage
})
setHistoryItems(
r.info ? [introMsg(r.info), ...toTranscriptMessages(r.messages)] : toTranscriptMessages(r.messages)
)
})
.catch((e: unknown) => {
sys(`resume failed: ${rpcErrorMessage(e)}`)
patchUiState({ status: 'forging session…' })
newSession('started a new session')
})
} }
return (ev: GatewayEvent) => { return (ev: GatewayEvent) => {
@ -438,9 +416,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
case 'error': case 'error':
turnController.recordError() turnController.recordError()
turnController.pushActivity(String(ev.payload?.message || 'unknown error'), 'error')
sys(`error: ${ev.payload?.message}`) {
setStatus('ready') const message = String(ev.payload?.message || 'unknown error')
turnController.pushActivity(message, 'error')
if (NO_PROVIDER_RE.test(message)) {
panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
setStatus('setup required')
return
}
sys(`error: ${message}`)
setStatus('ready')
}
} }
} }
} }

View file

@ -197,6 +197,7 @@ export interface GatewayEventHandlerContext {
colsRef: MutableRefObject<number> colsRef: MutableRefObject<number>
newSession: (msg?: string) => void newSession: (msg?: string) => void
resetSession: () => void resetSession: () => void
resumeById: (id: string) => void
setCatalog: StateSetter<null | SlashCatalog> setCatalog: StateSetter<null | SlashCatalog>
} }
system: { system: {
@ -206,6 +207,7 @@ export interface GatewayEventHandlerContext {
} }
transcript: { transcript: {
appendMessage: (msg: Msg) => void appendMessage: (msg: Msg) => void
panel: (title: string, sections: PanelSection[]) => void
setHistoryItems: StateSetter<Msg[]> setHistoryItems: StateSetter<Msg[]>
} }
} }

View file

@ -1,4 +1,4 @@
import { type ScrollBoxHandle, useApp, useHasSelection, useSelection, useStdout } from '@hermes/ink' import { useApp, useHasSelection, useSelection, useStdout, type ScrollBoxHandle } from '@hermes/ink'
import { useStore } from '@nanostores/react' import { useStore } from '@nanostores/react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
@ -270,6 +270,7 @@ export function useMainApp(gw: GatewayClient) {
colsRef, colsRef,
composerActions, composerActions,
gw, gw,
panel,
rpc, rpc,
setHistoryItems, setHistoryItems,
setLastUserMsg, setLastUserMsg,
@ -413,10 +414,11 @@ export function useMainApp(gw: GatewayClient) {
colsRef, colsRef,
newSession: session.newSession, newSession: session.newSession,
resetSession: session.resetSession, resetSession: session.resetSession,
resumeById: session.resumeById,
setCatalog setCatalog
}, },
system: { bellOnComplete, stdout, sys }, system: { bellOnComplete, stdout, sys },
transcript: { appendMessage, setHistoryItems } transcript: { appendMessage, panel, setHistoryItems }
}), }),
[ [
appendMessage, appendMessage,

View file

@ -1,9 +1,15 @@
import { useCallback } from 'react' import { useCallback } from 'react'
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
import { introMsg, toTranscriptMessages } from '../domain/messages.js' import { introMsg, toTranscriptMessages } from '../domain/messages.js'
import { ZERO } from '../domain/usage.js' import { ZERO } from '../domain/usage.js'
import { type GatewayClient } from '../gatewayClient.js' import { type GatewayClient } from '../gatewayClient.js'
import type { SessionCloseResponse, SessionCreateResponse, SessionResumeResponse } from '../gatewayTypes.js' import type {
SessionCloseResponse,
SessionCreateResponse,
SessionResumeResponse,
SetupStatusResponse
} from '../gatewayTypes.js'
import { asRpcResult } from '../lib/rpc.js' import { asRpcResult } from '../lib/rpc.js'
import type { Msg, SessionInfo, Usage } from '../types.js' import type { Msg, SessionInfo, Usage } from '../types.js'
@ -33,6 +39,7 @@ export interface UseSessionLifecycleOptions {
colsRef: { current: number } colsRef: { current: number }
composerActions: ComposerActions composerActions: ComposerActions
gw: GatewayClient gw: GatewayClient
panel: (title: string, sections: import('../types.js').PanelSection[]) => void
rpc: GatewayRpc rpc: GatewayRpc
setHistoryItems: StateSetter<Msg[]> setHistoryItems: StateSetter<Msg[]>
setLastUserMsg: StateSetter<string> setLastUserMsg: StateSetter<string>
@ -48,6 +55,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
colsRef, colsRef,
composerActions, composerActions,
gw, gw,
panel,
rpc, rpc,
setHistoryItems, setHistoryItems,
setLastUserMsg, setLastUserMsg,
@ -94,6 +102,15 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
const newSession = useCallback( const newSession = useCallback(
async (msg?: string) => { async (msg?: string) => {
const setup = await rpc<SetupStatusResponse>('setup.status', {})
if (setup?.provider_configured === false) {
panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
patchUiState({ status: 'setup required' })
return
}
await closeSession(getUiState().sid) await closeSession(getUiState().sid)
const r = await rpc<SessionCreateResponse>('session.create', { cols: colsRef.current }) const r = await rpc<SessionCreateResponse>('session.create', { cols: colsRef.current })
@ -126,7 +143,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
sys(msg) sys(msg)
} }
}, },
[closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys] [closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
) )
const resumeById = useCallback( const resumeById = useCallback(
@ -134,38 +151,47 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
patchOverlayState({ picker: false }) patchOverlayState({ picker: false })
patchUiState({ status: 'resuming…' }) patchUiState({ status: 'resuming…' })
closeSession(getUiState().sid === id ? null : getUiState().sid).then(() => rpc<SetupStatusResponse>('setup.status', {}).then(setup => {
gw if (setup?.provider_configured === false) {
.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id }) panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
.then(raw => { patchUiState({ status: 'setup required' })
const r = asRpcResult<SessionResumeResponse>(raw)
if (!r) { return
sys('error: invalid response: session.resume') }
return patchUiState({ status: 'ready' }) closeSession(getUiState().sid === id ? null : getUiState().sid).then(() =>
} gw
.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: id })
.then(raw => {
const r = asRpcResult<SessionResumeResponse>(raw)
resetSession() if (!r) {
setSessionStartedAt(Date.now()) sys('error: invalid response: session.resume')
const resumed = toTranscriptMessages(r.messages) return patchUiState({ status: 'ready' })
}
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed) resetSession()
patchUiState({ setSessionStartedAt(Date.now())
info: r.info ?? null,
sid: r.session_id, const resumed = toTranscriptMessages(r.messages)
status: 'ready',
usage: usageFrom(r.info ?? null) setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
patchUiState({
info: r.info ?? null,
sid: r.session_id,
status: 'ready',
usage: usageFrom(r.info ?? null)
})
}) })
}) .catch((e: Error) => {
.catch((e: Error) => { sys(`error: ${e.message}`)
sys(`error: ${e.message}`) patchUiState({ status: 'ready' })
patchUiState({ status: 'ready' }) })
}) )
) })
}, },
[closeSession, colsRef, gw, resetSession, setHistoryItems, setSessionStartedAt, sys] [closeSession, colsRef, gw, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
) )
const guardBusySessionSwitch = useCallback( const guardBusySessionSwitch = useCallback(

View file

@ -0,0 +1,18 @@
import type { PanelSection } from '../types.js'
export const SETUP_REQUIRED_TITLE = 'Setup Required'
export const buildSetupRequiredSections = (): PanelSection[] => [
{
text: 'Hermes needs a model provider before the TUI can start a session.'
},
{
rows: [
['1.', 'Exit with Ctrl+C'],
['2.', 'Run `hermes model` to choose a provider + model'],
['3.', 'Or run `hermes setup` for full first-time setup'],
['4.', 'Re-open `hermes --tui` when setup is done']
],
title: 'Next Steps'
}
]

View file

@ -80,6 +80,10 @@ export interface ConfigSetResponse {
warning?: string warning?: string
} }
export interface SetupStatusResponse {
provider_configured?: boolean
}
// ── Session lifecycle ──────────────────────────────────────────────── // ── Session lifecycle ────────────────────────────────────────────────
export interface SessionCreateResponse { export interface SessionCreateResponse {