mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
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:
parent
5b386ced71
commit
0dd5055d59
9 changed files with 145 additions and 62 deletions
|
|
@ -129,6 +129,14 @@ def test_enable_gateway_prompts_sets_gateway_env(monkeypatch):
|
|||
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):
|
||||
monkeypatch.setattr(server, "_hermes_home", tmp_path)
|
||||
agent = types.SimpleNamespace(reasoning_config=None)
|
||||
|
|
|
|||
|
|
@ -1878,6 +1878,15 @@ def _(rid, params: dict) -> dict:
|
|||
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 ──────────────────────────────────────────
|
||||
|
||||
@method("process.stop")
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const buildCtx = (appended: Msg[]) =>
|
|||
colsRef: ref(80),
|
||||
newSession: vi.fn(),
|
||||
resetSession: vi.fn(),
|
||||
resumeById: vi.fn(),
|
||||
setCatalog: vi.fn()
|
||||
},
|
||||
system: {
|
||||
|
|
@ -34,6 +35,8 @@ const buildCtx = (appended: Msg[]) =>
|
|||
},
|
||||
transcript: {
|
||||
appendMessage: (msg: Msg) => appended.push(msg),
|
||||
panel: (title: string, sections: any[]) =>
|
||||
appended.push({ kind: 'panel', panelData: { sections, title }, role: 'system', text: '' }),
|
||||
setHistoryItems: vi.fn()
|
||||
}
|
||||
}) as any
|
||||
|
|
@ -138,4 +141,24 @@ describe('createGatewayEventHandler', () => {
|
|||
expect(appended[0]?.thinking).toBe(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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { STREAM_BATCH_MS } from '../config/timing.js'
|
||||
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
|
||||
import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin, SessionResumeResponse } from '../gatewayTypes.js'
|
||||
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import type { CommandsCatalogResponse, GatewayEvent, GatewaySkin } from '../gatewayTypes.js'
|
||||
import { rpcErrorMessage } from '../lib/rpc.js'
|
||||
import { formatToolCall } from '../lib/text.js'
|
||||
import { fromSkin } from '../theme.js'
|
||||
import type { SubagentProgress } from '../types.js'
|
||||
|
|
@ -12,6 +12,7 @@ import { turnController } from './turnController.js'
|
|||
import { getUiState, patchUiState } from './uiStore.js'
|
||||
|
||||
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')
|
||||
|
||||
|
|
@ -46,10 +47,10 @@ const pushTool = pushUnique(8)
|
|||
|
||||
export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: GatewayEvent) => void {
|
||||
const { dequeue, queueEditRef, sendQueued } = ctx.composer
|
||||
const { gw, rpc } = ctx.gateway
|
||||
const { STARTUP_RESUME_ID, colsRef, newSession, resetSession, setCatalog } = ctx.session
|
||||
const { rpc } = ctx.gateway
|
||||
const { STARTUP_RESUME_ID, newSession, resumeById, setCatalog } = ctx.session
|
||||
const { bellOnComplete, stdout, sys } = ctx.system
|
||||
const { appendMessage, setHistoryItems } = ctx.transcript
|
||||
const { appendMessage, panel, setHistoryItems } = ctx.transcript
|
||||
|
||||
let pendingThinkingStatus = ''
|
||||
let thinkingStatusTimer: null | ReturnType<typeof setTimeout> = null
|
||||
|
|
@ -121,30 +122,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
}
|
||||
|
||||
patchUiState({ status: 'resuming…' })
|
||||
gw.request<SessionResumeResponse>('session.resume', { cols: colsRef.current, session_id: 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')
|
||||
})
|
||||
resumeById(STARTUP_RESUME_ID)
|
||||
}
|
||||
|
||||
return (ev: GatewayEvent) => {
|
||||
|
|
@ -438,9 +416,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
|
|||
|
||||
case 'error':
|
||||
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')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -197,6 +197,7 @@ export interface GatewayEventHandlerContext {
|
|||
colsRef: MutableRefObject<number>
|
||||
newSession: (msg?: string) => void
|
||||
resetSession: () => void
|
||||
resumeById: (id: string) => void
|
||||
setCatalog: StateSetter<null | SlashCatalog>
|
||||
}
|
||||
system: {
|
||||
|
|
@ -206,6 +207,7 @@ export interface GatewayEventHandlerContext {
|
|||
}
|
||||
transcript: {
|
||||
appendMessage: (msg: Msg) => void
|
||||
panel: (title: string, sections: PanelSection[]) => void
|
||||
setHistoryItems: StateSetter<Msg[]>
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
|
|
@ -270,6 +270,7 @@ export function useMainApp(gw: GatewayClient) {
|
|||
colsRef,
|
||||
composerActions,
|
||||
gw,
|
||||
panel,
|
||||
rpc,
|
||||
setHistoryItems,
|
||||
setLastUserMsg,
|
||||
|
|
@ -413,10 +414,11 @@ export function useMainApp(gw: GatewayClient) {
|
|||
colsRef,
|
||||
newSession: session.newSession,
|
||||
resetSession: session.resetSession,
|
||||
resumeById: session.resumeById,
|
||||
setCatalog
|
||||
},
|
||||
system: { bellOnComplete, stdout, sys },
|
||||
transcript: { appendMessage, setHistoryItems }
|
||||
transcript: { appendMessage, panel, setHistoryItems }
|
||||
}),
|
||||
[
|
||||
appendMessage,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
import { useCallback } from 'react'
|
||||
|
||||
import { buildSetupRequiredSections, SETUP_REQUIRED_TITLE } from '../content/setup.js'
|
||||
import { introMsg, toTranscriptMessages } from '../domain/messages.js'
|
||||
import { ZERO } from '../domain/usage.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 type { Msg, SessionInfo, Usage } from '../types.js'
|
||||
|
||||
|
|
@ -33,6 +39,7 @@ export interface UseSessionLifecycleOptions {
|
|||
colsRef: { current: number }
|
||||
composerActions: ComposerActions
|
||||
gw: GatewayClient
|
||||
panel: (title: string, sections: import('../types.js').PanelSection[]) => void
|
||||
rpc: GatewayRpc
|
||||
setHistoryItems: StateSetter<Msg[]>
|
||||
setLastUserMsg: StateSetter<string>
|
||||
|
|
@ -48,6 +55,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
colsRef,
|
||||
composerActions,
|
||||
gw,
|
||||
panel,
|
||||
rpc,
|
||||
setHistoryItems,
|
||||
setLastUserMsg,
|
||||
|
|
@ -94,6 +102,15 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
|
||||
const newSession = useCallback(
|
||||
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)
|
||||
|
||||
const r = await rpc<SessionCreateResponse>('session.create', { cols: colsRef.current })
|
||||
|
|
@ -126,7 +143,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
sys(msg)
|
||||
}
|
||||
},
|
||||
[closeSession, colsRef, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
|
||||
[closeSession, colsRef, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
|
||||
)
|
||||
|
||||
const resumeById = useCallback(
|
||||
|
|
@ -134,38 +151,47 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
|
|||
patchOverlayState({ picker: false })
|
||||
patchUiState({ status: 'resuming…' })
|
||||
|
||||
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)
|
||||
rpc<SetupStatusResponse>('setup.status', {}).then(setup => {
|
||||
if (setup?.provider_configured === false) {
|
||||
panel(SETUP_REQUIRED_TITLE, buildSetupRequiredSections())
|
||||
patchUiState({ status: 'setup required' })
|
||||
|
||||
if (!r) {
|
||||
sys('error: invalid response: session.resume')
|
||||
return
|
||||
}
|
||||
|
||||
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()
|
||||
setSessionStartedAt(Date.now())
|
||||
if (!r) {
|
||||
sys('error: invalid response: session.resume')
|
||||
|
||||
const resumed = toTranscriptMessages(r.messages)
|
||||
return patchUiState({ status: 'ready' })
|
||||
}
|
||||
|
||||
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
|
||||
patchUiState({
|
||||
info: r.info ?? null,
|
||||
sid: r.session_id,
|
||||
status: 'ready',
|
||||
usage: usageFrom(r.info ?? null)
|
||||
resetSession()
|
||||
setSessionStartedAt(Date.now())
|
||||
|
||||
const resumed = toTranscriptMessages(r.messages)
|
||||
|
||||
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) => {
|
||||
sys(`error: ${e.message}`)
|
||||
patchUiState({ status: 'ready' })
|
||||
})
|
||||
)
|
||||
.catch((e: Error) => {
|
||||
sys(`error: ${e.message}`)
|
||||
patchUiState({ status: 'ready' })
|
||||
})
|
||||
)
|
||||
})
|
||||
},
|
||||
[closeSession, colsRef, gw, resetSession, setHistoryItems, setSessionStartedAt, sys]
|
||||
[closeSession, colsRef, gw, panel, resetSession, rpc, setHistoryItems, setSessionStartedAt, sys]
|
||||
)
|
||||
|
||||
const guardBusySessionSwitch = useCallback(
|
||||
|
|
|
|||
18
ui-tui/src/content/setup.ts
Normal file
18
ui-tui/src/content/setup.ts
Normal 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'
|
||||
}
|
||||
]
|
||||
|
|
@ -80,6 +80,10 @@ export interface ConfigSetResponse {
|
|||
warning?: string
|
||||
}
|
||||
|
||||
export interface SetupStatusResponse {
|
||||
provider_configured?: boolean
|
||||
}
|
||||
|
||||
// ── Session lifecycle ────────────────────────────────────────────────
|
||||
|
||||
export interface SessionCreateResponse {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue