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

@ -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')
}
}
}
}