fix(ui-tui): surface RPC errors and guard invalid gateway responses

This commit is contained in:
Brooklyn Nicholson 2026-04-13 14:17:52 -05:00
parent 0642b6cc53
commit cac1b1b724
4 changed files with 328 additions and 55 deletions

View file

@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
describe('asRpcResult', () => {
it('keeps plain object payloads', () => {
expect(asRpcResult({ ok: true, value: 'x' })).toEqual({ ok: true, value: 'x' })
})
it('rejects missing or non-object payloads', () => {
expect(asRpcResult(undefined)).toBeNull()
expect(asRpcResult(null)).toBeNull()
expect(asRpcResult('oops')).toBeNull()
expect(asRpcResult(['bad'])).toBeNull()
})
})
describe('rpcErrorMessage', () => {
it('prefers Error messages', () => {
expect(rpcErrorMessage(new Error('boom'))).toBe('boom')
})
it('falls back for unknown errors', () => {
expect(rpcErrorMessage('broken')).toBe('broken')
expect(rpcErrorMessage({ code: 500 })).toBe('request failed')
})
})

View file

@ -20,6 +20,7 @@ import { useCompletion } from './hooks/useCompletion.js'
import { useInputHistory } from './hooks/useInputHistory.js'
import { useQueue } from './hooks/useQueue.js'
import { writeOsc52Clipboard } from './lib/osc52.js'
import { asRpcResult, rpcErrorMessage } from './lib/rpc.js'
import {
buildToolTrailLine,
compactPreview,
@ -515,10 +516,21 @@ export function App({ gw }: { gw: GatewayClient }) {
}, [])
const rpc = useCallback(
(method: string, params: Record<string, unknown> = {}) =>
gw.request(method, params).catch((e: Error) => {
sys(`error: ${e.message}`)
}),
async (method: string, params: Record<string, unknown> = {}) => {
try {
const result = asRpcResult(await gw.request(method, params))
if (result) {
return result
}
sys(`error: invalid response: ${method}`)
} catch (e) {
sys(`error: ${rpcErrorMessage(e)}`)
}
return null
},
[gw, sys]
)
@ -579,7 +591,13 @@ export function App({ gw }: { gw: GatewayClient }) {
if (configMtimeRef.current && next && next !== configMtimeRef.current) {
configMtimeRef.current = next
rpc('reload.mcp', { session_id: sid }).then(() => pushActivity('MCP reloaded after config change'))
rpc('reload.mcp', { session_id: sid }).then(r => {
if (!r) {
return
}
pushActivity('MCP reloaded after config change')
})
rpc('config.get', { key: 'full' }).then((cfg: any) => {
setBellOnComplete(!!cfg?.config?.display?.bell_on_complete)
})
@ -675,7 +693,16 @@ export function App({ gw }: { gw: GatewayClient }) {
setPicker(false)
setStatus('resuming…')
gw.request('session.resume', { cols: colsRef.current, session_id: id })
.then((r: any) => {
.then((raw: any) => {
const r = asRpcResult(raw)
if (!r) {
sys('error: invalid response: session.resume')
setStatus('ready')
return
}
resetSession()
setSid(r.session_id)
setSessionStartedAt(Date.now())
@ -892,7 +919,15 @@ export function App({ gw }: { gw: GatewayClient }) {
setStatus('running…')
gw.request('shell.exec', { command: cmd })
.then((r: any) => {
.then((raw: any) => {
const r = asRpcResult(raw)
if (!r) {
sys('error: invalid response: shell.exec')
return
}
const out = [r.stdout, r.stderr].filter(Boolean).join('\n').trim()
if (out) {
@ -944,7 +979,11 @@ export function App({ gw }: { gw: GatewayClient }) {
matches.map(m =>
gw
.request('shell.exec', { command: m[1]! })
.then((r: any) => [r.stdout, r.stderr].filter(Boolean).join('\n').trim())
.then((raw: any) => {
const r = asRpcResult(raw)
return [r?.stdout, r?.stderr].filter(Boolean).join('\n').trim()
})
.catch(() => '(error)')
)
).then(results => {
@ -1252,6 +1291,10 @@ export function App({ gw }: { gw: GatewayClient }) {
setVoiceProcessing(true)
rpc('voice.record', { action: 'stop' })
.then((r: any) => {
if (!r) {
return
}
const transcript = String(r?.text || '').trim()
if (transcript) {
@ -1267,7 +1310,11 @@ export function App({ gw }: { gw: GatewayClient }) {
})
} else {
rpc('voice.record', { action: 'start' })
.then(() => {
.then(r => {
if (!r) {
return
}
setVoiceRecording(true)
setStatus('recording…')
})
@ -1315,7 +1362,13 @@ export function App({ gw }: { gw: GatewayClient }) {
if (STARTUP_RESUME_ID) {
setStatus('resuming…')
gw.request('session.resume', { cols: colsRef.current, session_id: STARTUP_RESUME_ID })
.then((r: any) => {
.then((raw: any) => {
const r = asRpcResult(raw)
if (!r) {
throw new Error('invalid response: session.resume')
}
resetSession()
setSid(r.session_id)
setInfo(r.info ?? null)
@ -1329,9 +1382,10 @@ export function App({ gw }: { gw: GatewayClient }) {
setHistoryItems(r.info ? [introMsg(r.info), ...resumed] : resumed)
setStatus('ready')
})
.catch(() => {
.catch((e: unknown) => {
sys(`resume failed: ${rpcErrorMessage(e)}`)
setStatus('forging session…')
newSession('resume failed, started a new session')
newSession('started a new session')
})
} else {
setStatus('forging session…')
@ -1823,6 +1877,10 @@ export function App({ gw }: { gw: GatewayClient }) {
}
rpc('session.undo', { session_id: sid }).then((r: any) => {
if (!r) {
return
}
if (r.removed > 0) {
setMessages(prev => {
const q = [...prev]
@ -1879,6 +1937,10 @@ export function App({ gw }: { gw: GatewayClient }) {
}
rpc('prompt.background', { session_id: sid, text: arg }).then((r: any) => {
if (!r?.task_id) {
return
}
setBgTasks(prev => new Set(prev).add(r.task_id))
sys(`bg ${r.task_id} started`)
})
@ -1892,7 +1954,11 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
}
rpc('prompt.btw', { session_id: sid, text: arg }).then(() => {
rpc('prompt.btw', { session_id: sid, text: arg }).then(r => {
if (!r) {
return
}
setBgTasks(prev => new Set(prev).add('btw:x'))
sys('btw running…')
})
@ -1901,7 +1967,11 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'model':
if (!arg) {
rpc('config.get', { key: 'provider' }).then((r: any) =>
rpc('config.get', { key: 'provider' }).then((r: any) => {
if (!r) {
return
}
panel('Model', [
{
rows: [
@ -1910,10 +1980,14 @@ export function App({ gw }: { gw: GatewayClient }) {
]
}
])
)
})
} else {
rpc('config.set', { session_id: sid, key: 'model', value: arg.replace('--global', '').trim() }).then(
(r: any) => {
if (!r?.value) {
return
}
sys(`model → ${r.value}`)
setInfo(prev => (prev ? { ...prev, model: r.value } : prev))
}
@ -1940,62 +2014,100 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'provider':
gw.request('slash.exec', { command: 'provider', session_id: sid })
.then((r: any) => page(r?.output || '(no output)', 'Provider'))
.catch(() => sys('provider command failed'))
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
case 'skin':
if (arg) {
rpc('config.set', { key: 'skin', value: arg }).then((r: any) => sys(`skin → ${r.value}`))
rpc('config.set', { key: 'skin', value: arg }).then((r: any) => {
if (!r?.value) {
return
}
sys(`skin → ${r.value}`)
})
} else {
rpc('config.get', { key: 'skin' }).then((r: any) => sys(`skin: ${r.value || 'default'}`))
rpc('config.get', { key: 'skin' }).then((r: any) => {
if (!r) {
return
}
sys(`skin: ${r.value || 'default'}`)
})
}
return true
case 'yolo':
rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) =>
rpc('config.set', { session_id: sid, key: 'yolo' }).then((r: any) => {
if (!r) {
return
}
sys(`yolo ${r.value === '1' ? 'on' : 'off'}`)
)
})
return true
case 'reasoning':
rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) =>
rpc('config.set', { session_id: sid, key: 'reasoning', value: arg || 'medium' }).then((r: any) => {
if (!r?.value) {
return
}
sys(`reasoning: ${r.value}`)
)
})
return true
case 'verbose':
rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) =>
rpc('config.set', { session_id: sid, key: 'verbose', value: arg || 'cycle' }).then((r: any) => {
if (!r?.value) {
return
}
sys(`verbose: ${r.value}`)
)
})
return true
case 'personality':
if (arg) {
rpc('config.set', { key: 'personality', value: arg }).then((r: any) =>
rpc('config.set', { key: 'personality', value: arg }).then((r: any) => {
if (!r) {
return
}
sys(`personality: ${r.value || 'default'}`)
)
})
} else {
gw.request('slash.exec', { command: 'personality', session_id: sid })
.then((r: any) => panel('Personality', [{ text: r?.output || '(no output)' }]))
.catch(() => sys('personality command failed'))
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
}
return true
case 'compress':
rpc('session.compress', { session_id: sid }).then((r: any) =>
rpc('session.compress', { session_id: sid }).then((r: any) => {
if (!r) {
return
}
sys(`compressed${r.usage?.total ? ' · ' + fmtK(r.usage.total) + ' tok' : ''}`)
)
})
return true
case 'stop':
rpc('process.stop', {}).then((r: any) => sys(`killed ${r.killed ?? 0} process(es)`))
rpc('process.stop', {}).then((r: any) => {
if (!r) {
return
}
sys(`killed ${r.killed ?? 0} process(es)`)
})
return true
@ -2017,14 +2129,24 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'reload-mcp':
case 'reload_mcp':
rpc('reload.mcp', { session_id: sid }).then(() => sys('MCP reloaded'))
rpc('reload.mcp', { session_id: sid }).then(r => {
if (!r) {
return
}
sys('MCP reloaded')
})
return true
case 'title':
rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) =>
rpc('session.title', { session_id: sid, ...(arg ? { title: arg } : {}) }).then((r: any) => {
if (!r) {
return
}
sys(`title: ${r.title || '(none)'}`)
)
})
return true
@ -2075,18 +2197,34 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
case 'save':
rpc('session.save', { session_id: sid }).then((r: any) => sys(`saved: ${r.file}`))
rpc('session.save', { session_id: sid }).then((r: any) => {
if (!r?.file) {
return
}
sys(`saved: ${r.file}`)
})
return true
case 'history':
rpc('session.history', { session_id: sid }).then((r: any) => sys(`${r.count} messages`))
rpc('session.history', { session_id: sid }).then((r: any) => {
if (typeof r?.count !== 'number') {
return
}
sys(`${r.count} messages`)
})
return true
case 'profile':
rpc('config.get', { key: 'profile' }).then((r: any) => {
const text = r.display || r.home
if (!r) {
return
}
const text = r.display || r.home || '(unknown profile)'
const lines = text.split('\n').filter(Boolean)
if (lines.length <= 2) {
@ -2111,7 +2249,11 @@ export function App({ gw }: { gw: GatewayClient }) {
return true
case 'insights':
rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) =>
rpc('insights.get', { days: parseInt(arg) || 30 }).then((r: any) => {
if (!r) {
return
}
panel('Insights', [
{
rows: [
@ -2121,7 +2263,7 @@ export function App({ gw }: { gw: GatewayClient }) {
]
}
])
)
})
return true
case 'rollback': {
@ -2129,6 +2271,10 @@ export function App({ gw }: { gw: GatewayClient }) {
if (!sub || sub === 'list') {
rpc('rollback.list', { session_id: sid }).then((r: any) => {
if (!r) {
return
}
if (!r.checkpoints?.length) {
return sys('no checkpoints')
}
@ -2151,7 +2297,13 @@ export function App({ gw }: { gw: GatewayClient }) {
session_id: sid,
hash,
...(sub === 'diff' || !filePath ? {} : { file_path: filePath })
}).then((r: any) => sys(r.rendered || r.diff || r.message || 'done'))
}).then((r: any) => {
if (!r) {
return
}
sys(r.rendered || r.diff || r.message || 'done')
})
}
return true
@ -2159,15 +2311,23 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'browser': {
const [act, ...bArgs] = (arg || 'status').split(/\s+/)
rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) =>
rpc('browser.manage', { action: act, ...(bArgs[0] ? { url: bArgs[0] } : {}) }).then((r: any) => {
if (!r) {
return
}
sys(r.connected ? `browser: ${r.url}` : 'browser: disconnected')
)
})
return true
}
case 'plugins':
rpc('plugins.list', {}).then((r: any) => {
if (!r) {
return
}
if (!r.plugins?.length) {
return sys('no plugins')
}
@ -2185,6 +2345,10 @@ export function App({ gw }: { gw: GatewayClient }) {
if (!sub || sub === 'list') {
rpc('skills.manage', { action: 'list' }).then((r: any) => {
if (!r) {
return
}
const sk = r.skills as Record<string, string[]> | undefined
if (!sk || !Object.keys(sk).length) {
@ -2206,6 +2370,10 @@ export function App({ gw }: { gw: GatewayClient }) {
if (sub === 'browse') {
const pg = parseInt(sArgs[0] ?? '1', 10) || 1
rpc('skills.manage', { action: 'browse', page: pg }).then((r: any) => {
if (!r) {
return
}
if (!r.items?.length) {
return sys('no skills found in the hub')
}
@ -2238,7 +2406,7 @@ export function App({ gw }: { gw: GatewayClient }) {
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || '/skills: no output'))
.catch(() => sys(`skills: ${sub} failed`))
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
}
@ -2248,6 +2416,10 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'tasks':
rpc('agents.list', {})
.then((r: any) => {
if (!r) {
return
}
const procs = r.processes ?? []
const running = procs.filter((p: any) => p.status === 'running')
const finished = procs.filter((p: any) => p.status !== 'running')
@ -2273,7 +2445,7 @@ export function App({ gw }: { gw: GatewayClient }) {
panel('Agents', sections)
})
.catch(() => sys('agents command failed'))
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
@ -2281,6 +2453,10 @@ export function App({ gw }: { gw: GatewayClient }) {
if (!arg || arg === 'list') {
rpc('cron.manage', { action: 'list' })
.then((r: any) => {
if (!r) {
return
}
const jobs = r.jobs ?? []
if (!jobs.length) {
@ -2296,11 +2472,11 @@ export function App({ gw }: { gw: GatewayClient }) {
}
])
})
.catch(() => sys('cron command failed'))
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
} else {
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || '(no output)'))
.catch(() => sys('cron command failed'))
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
}
return true
@ -2308,6 +2484,10 @@ export function App({ gw }: { gw: GatewayClient }) {
case 'config':
rpc('config.show', {})
.then((r: any) => {
if (!r) {
return
}
panel(
'Config',
(r.sections ?? []).map((s: any) => ({
@ -2316,13 +2496,17 @@ export function App({ gw }: { gw: GatewayClient }) {
}))
)
})
.catch(() => sys('config command failed'))
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
case 'tools':
rpc('tools.list', { session_id: sid })
.then((r: any) => {
if (!r) {
return
}
if (!r.toolsets?.length) {
return sys('no tools')
}
@ -2335,13 +2519,17 @@ export function App({ gw }: { gw: GatewayClient }) {
}))
)
})
.catch(() => sys('tools command failed'))
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
case 'toolsets':
rpc('toolsets.list', { session_id: sid })
.then((r: any) => {
if (!r) {
return
}
if (!r.toolsets?.length) {
return sys('no toolsets')
}
@ -2358,16 +2546,24 @@ export function App({ gw }: { gw: GatewayClient }) {
}
])
})
.catch(() => sys('toolsets command failed'))
.catch((e: unknown) => sys(`error: ${rpcErrorMessage(e)}`))
return true
default:
gw.request('slash.exec', { command: cmd.slice(1), session_id: sid })
.then((r: any) => sys(r?.output || `/${name}: no output`))
.catch(() => {
.catch((e: unknown) => {
gw.request('command.dispatch', { name: name ?? '', arg, session_id: sid })
.then((d: any) => {
.then((raw: any) => {
const d = asRpcResult(raw)
if (!d?.type) {
sys(`error: ${rpcErrorMessage(e)}`)
return
}
if (d.type === 'exec') {
sys(d.output || '(no output)')
} else if (d.type === 'alias') {
@ -2376,10 +2572,15 @@ export function App({ gw }: { gw: GatewayClient }) {
sys(d.output || '(no output)')
} else if (d.type === 'skill') {
sys(`⚡ loading skill: ${d.name}`)
send(d.message)
if (typeof d.message === 'string' && d.message.trim()) {
send(d.message)
} else {
sys(`/${name}: skill payload missing message`)
}
}
})
.catch(() => sys(`unknown command: /${name}`))
.catch(() => sys(`error: ${rpcErrorMessage(e)}`))
})
return true

View file

@ -2,6 +2,7 @@ import { Box, Text, useInput } from '@hermes/ink'
import { useEffect, useState } from 'react'
import type { GatewayClient } from '../gatewayClient.js'
import { asRpcResult, rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
interface SessionItem {
@ -41,16 +42,30 @@ export function SessionPicker({
t: Theme
}) {
const [items, setItems] = useState<SessionItem[]>([])
const [err, setErr] = useState('')
const [sel, setSel] = useState(0)
const [loading, setLoading] = useState(true)
useEffect(() => {
gw.request('session.list', { limit: 20 })
.then((r: any) => {
setItems(r.sessions ?? [])
.then((raw: any) => {
const r = asRpcResult(raw)
if (!r) {
setErr('invalid response: session.list')
setLoading(false)
return
}
setItems((r?.sessions ?? []) as SessionItem[])
setErr('')
setLoading(false)
})
.catch((e: unknown) => {
setErr(rpcErrorMessage(e))
setLoading(false)
})
.catch(() => setLoading(false))
}, [gw])
useInput((ch, key) => {
@ -81,6 +96,15 @@ export function SessionPicker({
return <Text color={t.color.dim}>loading sessions</Text>
}
if (err) {
return (
<Box flexDirection="column">
<Text color={t.color.label}>error: {err}</Text>
<Text color={t.color.dim}>Esc to cancel</Text>
</Box>
)
}
if (!items.length) {
return (
<Box flexDirection="column">

21
ui-tui/src/lib/rpc.ts Normal file
View file

@ -0,0 +1,21 @@
export type RpcResult = Record<string, any>
export const asRpcResult = (value: unknown): RpcResult | null => {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
return value as RpcResult
}
export const rpcErrorMessage = (err: unknown) => {
if (err instanceof Error && err.message) {
return err.message
}
if (typeof err === 'string' && err.trim()) {
return err
}
return 'request failed'
}