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"
|
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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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'
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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[]>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
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
|
warning?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SetupStatusResponse {
|
||||||
|
provider_configured?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
// ── Session lifecycle ────────────────────────────────────────────────
|
// ── Session lifecycle ────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface SessionCreateResponse {
|
export interface SessionCreateResponse {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue