fix(ux): plain-language, actionable user-facing messages (desktop-tui)

Squashed integration of the user-facing message audit for this surface set.
Full per-finding receipts: /tmp/ux-audit/lanes/*-receipt.md (campaign artifacts).
This commit is contained in:
teknium1
2026-09-14 23:01:25 -07:00
parent 45ab3ad57f
commit 66878996dd
82 changed files with 3593 additions and 325 deletions
@@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { bootstrapStageLabel, describeBootstrapFailure, missingInstallPartMessage } from './bootstrap-failure-copy'
test('known stage names get everyday labels; unknown ones are humanized', () => {
assert.equal(bootstrapStageLabel('venv'), 'Python environment')
assert.equal(bootstrapStageLabel('system-packages'), 'System packages')
assert.equal(bootstrapStageLabel('some-new_stage'), 'Some new stage')
assert.equal(bootstrapStageLabel(null), null)
})
test('lead sentence is plain and actionable; raw error is confined to the Details line', () => {
const raw = "install.ps1 exited 1: spawn ENOENT (stage 'venv')"
const message = describeBootstrapFailure('venv', raw)
const [lead, ...rest] = message.split('\n')
assert.match(lead, /'Python environment' step/)
assert.match(lead, /Reload and retry/)
assert.match(lead, /open the logs/)
assert.doesNotMatch(lead, /bootstrap|stage|venv|ENOENT|exited|desktop\.log/)
assert.equal(rest.join('\n'), `Details: ${raw}`)
})
test('missing stage and missing error still produce a complete message', () => {
const message = describeBootstrapFailure(null, undefined)
assert.match(message, /^Setting up Hermes stopped before it could finish\./)
assert.match(message, /\nDetails: unknown error$/)
})
test('missing-install-part copy names Repair install and keeps the path in Details', () => {
const message = missingInstallPartMessage('Python environment missing at /home/me/.hermes/venv')
const [lead, details] = message.split('Details: ')
assert.match(lead, /Repair install/)
assert.doesNotMatch(lead, /venv|install\.ps1|\//)
assert.equal(details, 'Python environment missing at /home/me/.hermes/venv')
})
@@ -0,0 +1,88 @@
/**
* User-facing copy for a failed first-run install (bootstrap).
*
* The install runner reports the manifest stage name that failed (see
* electron/bootstrap-runner.ts and the stage manifests in scripts/install.ps1 /
* scripts/install.sh) plus the raw error text. This module turns that into an
* Error.message the install overlay can show verbatim: a plain lead sentence
* naming the step in everyday words and what to do next, with the raw error on
* a trailing "Details:" line.
*
* Pure module: no Electron imports, unit-tested next to it.
*/
/** Manifest stage name -> everyday label. Unknown names fall back to humanizeStageName. */
export const BOOTSTRAP_STAGE_LABELS: ReadonlyMap<string, string> = new Map([
// scripts/install.ps1 manifest
['uv', 'Package installer'],
['git', 'Git'],
['node', 'Node.js'],
['system-packages', 'System packages'],
['repository', 'Hermes source code'],
['python', 'Python runtime'],
['venv', 'Python environment'],
['dependencies', 'Python packages'],
['node-deps', 'Browser tool packages'],
['desktop', 'Desktop app build'],
['platform-sdks', 'Platform tools'],
['configure', 'Settings'],
['config-templates', 'Settings templates'],
['path', 'Hermes command'],
['gateway', 'Hermes service'],
['bootstrap-marker', 'Finishing touches'],
// scripts/install.sh manifest (names that differ from the Windows one)
['prerequisites', 'System prerequisites'],
['python-deps', 'Python packages'],
['config', 'Settings'],
['setup', 'Settings'],
['complete', 'Finishing touches']
])
/** `system-packages` -> `System packages`. */
export function humanizeStageName(stage: string): string {
const words = stage.replace(/[-_]+/g, ' ').trim()
return words ? words.charAt(0).toUpperCase() + words.slice(1) : ''
}
export function bootstrapStageLabel(stage: string | null | undefined): string | null {
if (!stage) {
return null
}
return BOOTSTRAP_STAGE_LABELS.get(stage) ?? humanizeStageName(stage)
}
const BOOTSTRAP_FAILURE_REMEDY =
'Common causes: no internet connection, antivirus blocking the installer, or another copy of Hermes running. ' +
'Close other Hermes windows and choose Reload and retry; if it fails again, open the logs and send them to support.'
/**
* Build the Error.message for a failed bootstrap. First line is the plain
* explanation; the raw error follows on its own "Details:" line.
*/
export function describeBootstrapFailure(failedStage: string | null | undefined, rawError: unknown): string {
const label = bootstrapStageLabel(failedStage)
const lead = label
? `Setting up Hermes stopped during the '${label}' step.`
: 'Setting up Hermes stopped before it could finish.'
const details = typeof rawError === 'string' && rawError.trim() ? rawError.trim() : 'unknown error'
return `${lead} ${BOOTSTRAP_FAILURE_REMEDY}\nDetails: ${details}`
}
/**
* Error.message for an installed Hermes with a piece missing (source tree,
* Python environment). The renderer's install overlay offers the Repair install
* button ('hermes:bootstrap:repair'), so the copy points there. `whatIsMissing`
* names the missing part and its path, e.g. "Python environment missing at /x".
*/
export function missingInstallPartMessage(whatIsMissing: string): string {
return (
"Part of Hermes' installation is missing (it may have been deleted or quarantined by antivirus). " +
'Choose Repair install below to put it back — your chats and settings are not affected. ' +
`Details: ${whatIsMissing}`
)
}
+63 -25
View File
@@ -74,6 +74,7 @@ import {
shouldLatchHostKeyChangedFailure,
shouldLatchRemoteReauthFailure
} from './backend-start-failure'
import { describeBootstrapFailure, missingInstallPartMessage } from './bootstrap-failure-copy'
import {
detectRemoteDisplay,
isWindowsBinaryPathInWsl,
@@ -397,6 +398,7 @@ import { readLiveUpdateMarker, updateHandoffConflict, writeUpdateMarker } from '
import { isOfficialSshRemote, OFFICIAL_REPO_HTTPS_URL } from './update-remote'
import {
collectRelaunchArgs,
describeUpdaterHandoffFailure,
observeUpdaterHandoff,
resolvePosixScriptHandoff,
resolveStagedUpdaterBinary,
@@ -2474,10 +2476,31 @@ async function waitForUpdateToFinish() {
rememberLog(`[updates] detached update finished OK (branch ${result.branch})`)
} else if (result) {
rememberLog(`[updates] detached update FAILED (exit ${result.exitCode}): ${result.message}`)
dialog.showErrorBox(
'Hermes update did not finish',
`${result.message}\n\nDetails: ${path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')}`
)
const handoffLogPath = path.join(HERMES_HOME, 'logs', 'desktop-update-handoff.log')
// Async so boot is not blocked behind the dialog; the response handlers
// reuse the menu's open-updates path (queued until the renderer is ready)
// and the same reveal primitive as 'hermes:logs:reveal'.
void dialog
.showMessageBox({
type: 'error',
title: 'Hermes update',
message: "Hermes couldn't finish updating",
detail:
"You're still on the previous version and can keep using it. Try the update again, or open the update log to report the problem.\n\n" +
`Details: ${result.message}`,
buttons: ['Try again', 'Open log', 'Close'],
defaultId: 0,
cancelId: 2,
noLink: true
})
.then(({ response }) => {
if (response === 0) {
sendOpenUpdatesRequested()
} else if (response === 1) {
shell.showItemInFolder(handoffLogPath)
}
})
}
} catch (err) {
rememberLog(`[updates] could not read hand-off result: ${err.message}`)
@@ -3136,7 +3159,9 @@ async function checkUpdates({ force = false }: { force?: boolean } = {}) {
return {
supported: false,
reason: 'not-a-git-checkout',
message: `${updateRoot} isn't a git checkout — desktop self-update only runs against a source install.`,
message:
"This copy of Hermes can't update itself from inside the app. Download the latest version from the Hermes website, " +
`or reinstall Hermes to enable in-app updates. Details: ${updateRoot} has no version-control metadata.`,
hermesRoot: updateRoot,
branch
}
@@ -4261,7 +4286,7 @@ async function applyUpdates(opts: { stopSafeBlockers?: boolean } = {}) {
const handoffOutcome = await observeUpdaterHandoff(child, UPDATE_HANDOFF_DWELL_MS)
if (!handoffOutcome.ok) {
const message = `Update failed to start: ${handoffOutcome.message}. Hermes will keep running — try again, or run \`hermes update\` from a terminal.`
const message = describeUpdaterHandoffFailure(handoffOutcome)
rememberLog(`[updates] hand-off not viable, aborting quit: ${handoffOutcome.message}`)
emitUpdateProgress({ stage: 'error', message, percent: null })
@@ -4614,7 +4639,7 @@ async function applyUpdatesPosixHandoff(opts: any) {
const handoffOutcome = await observeUpdaterHandoff(child, UPDATE_HANDOFF_DWELL_MS)
if (!handoffOutcome.ok) {
const message = `Update failed to start: ${handoffOutcome.message}. Hermes will keep running — try again, or run \`hermes update\` from a terminal.`
const message = describeUpdaterHandoffFailure(handoffOutcome)
rememberLog(`[updates] posix hand-off not viable, aborting quit: ${handoffOutcome.message}`)
emitUpdateProgress({ stage: 'error', message, percent: null })
@@ -5248,10 +5273,10 @@ async function runEnsureRuntime(backend: any, assertStillOwned: () => void): Pro
}
if (!bootstrapResult.ok) {
// Plain lead sentence + trailing "Details:" line; the install overlay
// shows this verbatim and offers Reload and retry / Open logs itself.
const bootstrapError = new Error(
`Hermes bootstrap failed${bootstrapResult.failedStage ? ` at stage '${bootstrapResult.failedStage}'` : ''}: ` +
`${bootstrapResult.error || 'unknown error'}. ` +
`Check ${path.join(HERMES_HOME, 'logs', 'desktop.log')} for the full transcript.`
describeBootstrapFailure(bootstrapResult.failedStage, bootstrapResult.error)
) as any
bootstrapError.isBootstrapFailure = true
@@ -5277,10 +5302,7 @@ async function runEnsureRuntime(backend: any, assertStillOwned: () => void): Pro
// (install.ps1 owns those concerns now and the bootstrap-complete marker
// attests they ran successfully).
if (!isHermesSourceRoot(ACTIVE_HERMES_ROOT)) {
throw new Error(
`Hermes install at ${ACTIVE_HERMES_ROOT} is missing or incomplete. ` +
'Reinstall via the desktop installer or scripts/install.ps1.'
)
throw new Error(missingInstallPartMessage(`Hermes source files are missing or incomplete at ${ACTIVE_HERMES_ROOT}`))
}
// On Windows, preflight Git Bash. Hermes' terminal tool calls bash.exe
@@ -5291,10 +5313,8 @@ async function runEnsureRuntime(backend: any, assertStillOwned: () => void): Pro
// here via an external `hermes` on PATH, this check still helps.
if (IS_WINDOWS && !findGitBash()) {
throw new Error(
'Git for Windows is required for Hermes on Windows (provides Git Bash, ' +
"which the agent's terminal tool uses). Install it from " +
'https://git-scm.com/download/win or run `winget install -e --id Git.Git`, ' +
'then relaunch Hermes.'
"Hermes needs a helper called Git for Windows, which isn't installed. " +
'Choose Repair install to add it automatically, or install it yourself from git-scm.com and reopen Hermes.'
)
}
@@ -5308,9 +5328,7 @@ async function runEnsureRuntime(backend: any, assertStillOwned: () => void): Pro
// plus an importable hermes_cli before it hands back the active runtime.
// If we hit this, the user (or a deleted venv) broke the invariant; tell
// them to re-run the install.
throw new Error(
`Hermes venv missing at ${VENV_ROOT}. Re-run the desktop installer or ` + '`scripts/install.ps1` to rebuild it.'
)
throw new Error(missingInstallPartMessage(`Python environment missing at ${VENV_ROOT}`))
}
backend.command = getVenvPython(VENV_ROOT)
@@ -6835,15 +6853,23 @@ async function showPluginCompatNoticeOnce() {
rememberLog(`[plugins] compat notice shown (${notice.key})`)
try {
await dialog.showMessageBox(mainWindow, {
// 'OK' is the default and cancel so a stray Enter/Escape never navigates;
// 'Open Plugins' rides the existing deep-link channel (hermes://open/…),
// which the renderer already maps to its hash router.
const { response } = await dialog.showMessageBox(mainWindow, {
type: 'warning',
title: notice.title,
message: notice.message,
detail: notice.detail,
buttons: ['OK'],
defaultId: 0,
buttons: ['Open Plugins', 'OK'],
defaultId: 1,
cancelId: 1,
noLink: true
})
if (response === 0) {
handleDeepLink(`${HERMES_PROTOCOL}://open/skills?tab=plugins`)
}
} finally {
try {
recordPluginCompatDismissed(app.getPath('userData'), notice.key)
@@ -6854,7 +6880,12 @@ async function showPluginCompatNoticeOnce() {
}
function sendOpenUpdatesRequested() {
if (!mainWindow || mainWindow.isDestroyed()) {
// The renderer mounts its open-updates listener in the same effect pass that
// signals deep-link readiness. Before that (e.g. a boot-time dialog answered
// before the window is up) queue the request; 'hermes:deep-link-ready' flushes it.
if (!_rendererReadyForDeepLink || !mainWindow || mainWindow.isDestroyed()) {
_pendingOpenUpdates = true
return
}
@@ -17913,6 +17944,8 @@ const HERMES_PROTOCOL = DEV_SERVER ? 'hermes-dev' : 'hermes'
const DEEPLINK_SCHEMES = DEV_SERVER ? ['hermes-dev', 'hermes'] : ['hermes']
let _pendingDeepLink = null
let _rendererReadyForDeepLink = false
// Set by sendOpenUpdatesRequested() when the renderer cannot hear it yet.
let _pendingOpenUpdates = false
function _extractDeepLink(argv) {
if (!Array.isArray(argv)) {
@@ -17978,6 +18011,11 @@ function handleDeepLink(url) {
ipcMain.handle('hermes:deep-link-ready', () => {
_rendererReadyForDeepLink = true
if (_pendingOpenUpdates) {
_pendingOpenUpdates = false
sendOpenUpdatesRequested()
}
if (_pendingDeepLink) {
const queued = _pendingDeepLink
_pendingDeepLink = null
@@ -43,19 +43,22 @@ test('no report file → no notice', () => {
assert.equal(pendingNotice(tmp(), tmp()), null)
})
test('report → one notice naming plugins, date and the CLI command', () => {
test('report → one notice naming the plugins and the date, with no config keys or CLI commands', () => {
const home = tmp()
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(REPORT))
const n = pendingNotice(home, tmp())
assert.ok(n)
assert.equal(n.title, 'Plugins need an update')
assert.match(n.message, /2 plugins import module paths that stop working on 2026-09-14/)
assert.equal(n.title, 'Some plugins need an update')
assert.match(n.message, /stop working on 2026-09-14: alpha, beta\./)
assert.match(
n.detail,
/• alpha — 1 import \(e\.g\. tools\.web_tools\.prefers_gateway → tools\.tool_backend_helpers\.prefers_gateway\)/
)
assert.match(n.detail, /• beta — 2 imports/)
assert.match(n.detail, /hermes plugins compat/)
for (const text of [n.title, n.message, n.detail]) {
assert.doesNotMatch(text, /config\.yaml|hermes plugins compat|allow_deprecated_imports|module path/)
}
})
test('dismissal is remembered for the same report and forgotten for a different one', () => {
@@ -84,8 +87,9 @@ test('dismissal is remembered for the same report and forgotten for a different
fs.writeFileSync(path.join(home, REPORT_FILE), JSON.stringify(disabled))
const third = pendingNotice(home, userData)
assert.ok(third)
assert.equal(third.title, 'Some plugins were not loaded')
assert.match(third.detail, /allow_deprecated_imports/)
assert.equal(third.title, 'Some plugins were turned off')
assert.match(third.message, /were turned off: alpha, beta\. Hermes works normally without them\./)
assert.doesNotMatch(third.detail, /config\.yaml|hermes plugins compat|allow_deprecated_imports/)
assert.notEqual(reportKey(disabled as any), reportKey(REPORT as any))
})
+15 -10
View File
@@ -114,6 +114,7 @@ export function pendingNotice(hermesHome: string, userData: string): PendingNoti
}
const names = Object.keys(report.plugins).sort()
const nameList = names.join(', ')
const list = names
.map(n => {
@@ -124,15 +125,19 @@ export function pendingNotice(hermesHome: string, userData: string): PendingNoti
})
.join('\n')
const title = report.in_effect ? 'Some plugins were not loaded' : 'Plugins need an update'
// User copy: what happened, what it means for them, what to do. Config keys
// and CLI commands stay out; the per-plugin list is for the plugin author.
const copy = report.in_effect
? {
title: 'Some plugins were turned off',
message: `These plugins were built for an older Hermes and were turned off: ${nameList}. Hermes works normally without them.`,
detail: `Look for an updated version of each plugin or ask its author.\n\n${list}`
}
: {
title: 'Some plugins need an update',
message: `These plugins were built for an older Hermes and will stop working on ${report.removal_date}: ${nameList}.`,
detail: `Look for an updated version or ask the plugin's author before then.\n\n${list}`
}
const message = report.in_effect
? `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that were removed on ${report.removal_date} and ${names.length === 1 ? 'was' : 'were'} not loaded.`
: `${names.length} plugin${names.length === 1 ? '' : 's'} import${names.length === 1 ? 's' : ''} module paths that stop working on ${report.removal_date}.`
const detail = report.in_effect
? `${list}\n\nUpdate the plugin(s), or force-load them with plugins.allow_deprecated_imports: true in config.yaml (they will still break once the compatibility layer is removed).\n\nFull list: hermes plugins compat`
: `${list}\n\nCheck for plugin updates or notify the author before ${report.removal_date}. After that date these plugins are not loaded.\n\nFull list: hermes plugins compat`
return { key, title, message, detail }
return { key, ...copy }
}
@@ -6,6 +6,7 @@ import { test } from 'vitest'
import {
collectRelaunchArgs,
describeUpdaterHandoffFailure,
MARKER_SELF_ADOPT_EPOCH_MS,
observeUpdaterHandoff,
resolvePosixScriptHandoff,
@@ -393,6 +394,20 @@ test('observeUpdaterHandoff reports a non-zero early exit', async () => {
assert.equal(outcome.code, 127)
})
test('describeUpdaterHandoffFailure leads with plain copy and confines the raw outcome to Details', () => {
for (const raw of ['updater exited 127 before the settle window elapsed', 'updater spawn failed: ENOENT']) {
const text = describeUpdaterHandoffFailure({ message: raw })
const [lead, details] = text.split('\n\nDetails: ')
assert.match(lead, /Hermes keeps running/)
assert.match(lead, /Try again/)
assert.doesNotMatch(lead, /exited|spawn|ENOENT|settle window|hermes update|\d/)
assert.equal(details, raw)
}
assert.doesNotMatch(describeUpdaterHandoffFailure({}), /Details:/)
})
test('observeUpdaterHandoff reports a signal death inside the window', async () => {
const child = new FakeChild()
const timer = manualTimer()
+14
View File
@@ -357,6 +357,20 @@ export interface ObserveUpdaterHandoffDeps {
clearTimeoutFn?: (timer: unknown) => void
}
/**
* User-facing copy for a hand-off that did not take (spawn error or early exit).
* The lead sentence is plain: nothing changed and Hermes keeps running. The raw
* outcome message (exit code / signal / spawn error) stays on a trailing
* "Details:" line for logs and support.
*/
export function describeUpdaterHandoffFailure(outcome: Pick<UpdaterHandoffOutcome, 'message'>): string {
const lead =
"The updater couldn't start, so nothing was changed and Hermes keeps running as before. " +
'Try again; if it keeps failing, open the logs and send them to support.'
return outcome.message ? `${lead}\n\nDetails: ${outcome.message}` : lead
}
/**
* Watch a just-spawned detached updater for the duration of the quit dwell
* and report whether the hand-off actually became viable (#66753).
+44
View File
@@ -38,6 +38,7 @@ import { SendDiagnosticsHost } from '@/components/send-diagnostics-dialog'
import { TipHost } from '@/components/tips'
import { emitGatewayEvent } from '@/contrib/events'
import { getLatestSessionMessages } from '@/hermes'
import { translateNow } from '@/i18n'
import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
@@ -49,6 +50,7 @@ import { requestVoiceConversationStart } from '@/store/composer'
import { $activeConnectionId } from '@/store/connections'
import { $cronReviewRequest, setCronFocusJobId } from '@/store/cron'
import { requestGatewayForProfile } from '@/store/gateway'
import { reconnectGateway } from '@/store/gateway-reconnect'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { $poolLimitsSettingsRequest } from '@/store/pool-limits'
@@ -64,6 +66,7 @@ import {
refreshActiveProfile
} from '@/store/profile'
import { $newProjectSessionRequest, $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects'
import { $backendRestartRequest, $routeRequest } from '@/store/recovery-requests'
import {
$activeSessionId,
$connection,
@@ -197,6 +200,8 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// intent counter here; the ref skips the initial mount value.
const billingSettingsSeenRef = useRef(0)
const poolLimitsSettingsSeenRef = useRef(0)
const routeRequestSeenRef = useRef(0)
const backendRestartSeenRef = useRef(0)
const cronReviewSeenRef = useRef(0)
const activeTranscriptSignatureRef = useRef(new Map<string, string>())
const activeTranscriptRequestSequenceRef = useRef(0)
@@ -208,9 +213,48 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const activeSessionId = useStore($activeSessionId)
const billingSettingsRequest = useStore($billingSettingsRequest)
const poolLimitsSettingsRequest = useStore($poolLimitsSettingsRequest)
const routeRequest = useStore($routeRequest)
const backendRestartRequest = useStore($backendRestartRequest)
const cronReviewRequest = useStore($cronReviewRequest)
const currentCwd = useStore($currentCwd)
// Generic in-app route intents raised by toast recovery buttons (Open Keys,
// Open Gateways, Maintenance …) fired from stores with no router context.
// eslint-disable-next-line no-restricted-syntax -- one-shot request-seen sentinel, not an atom mirror
useEffect(() => {
if (!routeRequest || routeRequest.seq === routeRequestSeenRef.current) {
return
}
routeRequestSeenRef.current = routeRequest.seq
navigate(routeRequest.path)
}, [navigate, routeRequest])
// "Restart Hermes" from a toast: recycle the local backend the user is
// looking at (same IPC the Models page uses), then let the boot hook re-dial.
// A remote/cloud connection has no local process to recycle — there the
// only meaningful "restart" is re-dialing the connection.
// eslint-disable-next-line no-restricted-syntax -- one-shot request-seen sentinel, not an atom mirror
useEffect(() => {
if (backendRestartRequest === backendRestartSeenRef.current) {
return
}
backendRestartSeenRef.current = backendRestartRequest
if (backendRestartRequest > 0) {
if ($connection.get()?.mode === 'remote') {
void reconnectGateway().catch(err => notifyError(err, translateNow('notifications.errors.restartHermesFailed')))
return
}
void window.hermesDesktop?.recycleBackend?.(normalizeProfileKey($activeGatewayProfile.get())).catch(err =>
notifyError(err, translateNow('notifications.errors.restartHermesFailed'))
)
}
}, [backendRestartRequest])
// eslint-disable-next-line no-restricted-syntax -- one-shot request-seen sentinel, not an atom mirror
useEffect(() => {
if (billingSettingsRequest === billingSettingsSeenRef.current) {
@@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest'
import {
cronEditorUpdates,
jobIsScriptOnly,
lastErrorSummary,
parseCronDeliveryTargets,
toggleCronDeliveryTarget,
validateCronEditor
@@ -59,6 +60,29 @@ describe('cron delivery targets', () => {
})
})
describe('lastErrorSummary', () => {
it('strips the exception wrapper and markers, keeping only the first sentence', () => {
const raw =
"RuntimeError: Cron job 'x' has no model configured (job.model=None, HERMES_MODEL=''). Set a per-job model via `hermes cron edit x --model <name>`."
expect(lastErrorSummary(raw)).toBe("Cron job 'x' has no model configured (job.model=None, HERMES_MODEL='').")
expect(lastErrorSummary('[blocked_config:silent] ⚠️ ValueError: bad schedule\nDetails follow')).toBe('bad schedule')
})
it('caps very long single sentences with an ellipsis', () => {
const summary = lastErrorSummary(`HTTPStatusError: ${'x'.repeat(400)}`)
expect(summary.length).toBeLessThanOrEqual(200)
expect(summary.endsWith('…')).toBe(true)
})
it('returns an empty string for missing input', () => {
expect(lastErrorSummary(null)).toBe('')
expect(lastErrorSummary(undefined)).toBe('')
expect(lastErrorSummary(' ')).toBe('')
})
})
describe('cronEditorUpdates', () => {
it('omits prompt when saving a script-only job with an empty prompt', () => {
expect(
@@ -68,6 +68,30 @@ export function toggleCronDeliveryTarget(value: string, target: string, checked:
return targets.filter(candidate => candidate !== target).join(',')
}
// The scheduler stores `last_error` as the raw exception text, e.g.
// "RuntimeError: Cron job 'x' has no model configured (job.model=None, …). Set a
// per-job model via `hermes cron edit …`". Users need the first plain sentence,
// not the Python wrapper; the full text stays reachable via a hover title.
const ERROR_PREFIX_RE = /^(?:[A-Za-z_][\w.]*(?:Error|Exception)|Exception):\s*/
const ERROR_MARKER_RE = /^\[[a-z_]+(?::[a-z_]+)?\]\s*/
const ERROR_EMOJI_RE = /^(?:\u26A0\uFE0F?|\uD83D\uDED1|\u274C|\u{1F6AB})\s*/u
const ERROR_SUMMARY_MAX = 200
export function lastErrorSummary(lastError: string | null | undefined): string {
let text = (lastError ?? '').trim()
// Wrappers can nest (marker, then emoji, then exception class); peel until stable.
for (let previous = ''; previous !== text; ) {
previous = text
text = text.replace(ERROR_MARKER_RE, '').replace(ERROR_EMOJI_RE, '').replace(ERROR_PREFIX_RE, '').trimStart()
}
const sentenceEnd = text.search(/\. |\n/)
const sentence = (sentenceEnd === -1 ? text : text.slice(0, sentenceEnd + 1)).trim()
return sentence.length > ERROR_SUMMARY_MAX ? `${sentence.slice(0, ERROR_SUMMARY_MAX - 1).trimEnd()}…` : sentence
}
/** Build the API update payload, preserving an empty prompt on script-only jobs. */
export function cronEditorUpdates(values: CronEditorSaveValues, options: { scriptOnlyJob: boolean }): CronJobUpdates {
const updates: CronJobUpdates = {
+22 -12
View File
@@ -78,6 +78,7 @@ import { mutateAndRefreshCronJobs, refreshCronJobs, triggerAndRefreshCronJobs }
import {
cronEditorUpdates,
jobIsScriptOnly,
lastErrorSummary,
parseCronDeliveryTargets,
toggleCronDeliveryTarget,
validateCronEditor
@@ -701,6 +702,7 @@ export function CronView({ onClose, onOpenSession, setStatusbarItemGroup: _setSt
busy={busyJobTokens.has(selectedJob.id) || triggeringJobKeys.has(`${profile}:${selectedJob.id}`)}
c={c}
job={selectedJob}
onEdit={() => setEditor({ mode: 'edit', job: selectedJob })}
onOpenSession={onOpenSession}
onPauseResume={() => void handlePauseResume(selectedJob)}
onTrigger={() => void handleTrigger(selectedJob)}
@@ -777,21 +779,17 @@ function CronJobListRow({
)
}
function CronJobDetail({
busy,
c,
job,
onOpenSession,
onPauseResume,
onTrigger
}: {
interface CronJobDetailProps {
busy: boolean
c: Translations['cron']
job: CronJob
onEdit: () => void
onOpenSession?: (sessionId: string) => void
onPauseResume: () => void
onTrigger: () => void
}) {
}
function CronJobDetail({ busy, c, job, onEdit, onOpenSession, onPauseResume, onTrigger }: CronJobDetailProps) {
const state = jobState(job)
const isPaused = state === 'paused'
const deliver = jobDeliver(job)
@@ -827,9 +825,21 @@ function CronJobDetail({
/>
{job.last_error ? (
<div className="flex items-start gap-1.5 rounded bg-destructive/10 p-2 text-[0.7rem] text-destructive">
<AlertTriangle className="mt-px size-3 shrink-0" />
<span className="min-w-0 break-words">{job.last_error}</span>
<div className="space-y-1.5 rounded bg-destructive/10 p-2 text-[0.7rem] text-destructive">
<div className="flex items-start gap-1.5">
<AlertTriangle className="mt-px size-3 shrink-0" />
<span className="min-w-0 break-words" title={job.last_error}>
{c.lastRunFailed} {lastErrorSummary(job.last_error)}
</span>
</div>
<div className="flex items-center gap-0.5 pl-4">
<PanelAction disabled={busy} icon="edit" onClick={onEdit}>
{c.editJob}
</PanelAction>
<PanelAction disabled={busy} icon="zap" onClick={onTrigger}>
{c.runAgain}
</PanelAction>
</div>
</div>
) : null}
</header>
@@ -27,8 +27,9 @@ import {
endGatewaySwitch,
recoverActiveSourceAfterFailedGatewaySwitch
} from '@/store/gateway-switch'
import { notifyError } from '@/store/notifications'
import { $notifications, clearNotifications, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, $profiles, ensureGatewayProfile } from '@/store/profile'
import { $backendRestartRequest } from '@/store/recovery-requests'
import {
$activeSessionId,
$awaitingResponse,
@@ -43,6 +44,7 @@ import {
setSelectedStoredSessionId
} from '@/store/session'
import { $sessionTiles, $workingSessionIds, clearAllSessionStates, publishSessionState } from '@/store/session-states'
import { warnIfTerminalBackendUnavailable } from '@/store/terminal-backend-warning'
import { deferred } from '../../../test/deferred'
@@ -54,6 +56,10 @@ vi.mock(import('@/store/notifications'), async importOriginal => ({
notifyError: vi.fn()
}))
vi.mock(import('@/store/terminal-backend-warning'), () => ({
warnIfTerminalBackendUnavailable: vi.fn(async () => false)
}))
// End-to-end-ish repro of the "remote VPS → stuck on CONNECTING, no Settings"
// bug that drives the REAL useGatewayBoot hook + REAL HermesGateway through a
// fake WebSocket we fully control. No Docker / no real port: from the desktop's
@@ -68,6 +74,7 @@ vi.mock(import('@/store/notifications'), async importOriginal => ({
type Listener = (ev: unknown) => void
let connectionApplied: null | (() => void) = null
let powerResume: null | (() => void) = null
let backendExit: null | ((payload?: unknown) => void) = null
describe('primaryRuntimeConnectionId', () => {
it('uses the registry identity when the primary connection has one', () => {
@@ -224,7 +231,13 @@ function fakeDesktop() {
emitBootProgress(payload: Record<string, unknown>) {
bootProgressHandler?.(payload)
},
onBackendExit: vi.fn(() => () => undefined),
onBackendExit: vi.fn(callback => {
backendExit = callback
return () => {
backendExit = null
}
}),
onConnectionApplied: vi.fn(callback => {
connectionApplied = callback
@@ -293,7 +306,10 @@ beforeEach(() => {
FakeWebSocket.pingMode = 'pong'
connectionApplied = null
powerResume = null
backendExit = null
clearNotifications()
vi.mocked(notifyError).mockReset()
vi.mocked(warnIfTerminalBackendUnavailable).mockClear()
;(globalThis as { WebSocket: unknown }).WebSocket = FakeWebSocket
;(window as { hermesDesktop?: unknown }).hermesDesktop = fakeDesktop()
$gatewayState.set('idle')
@@ -1712,6 +1728,46 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
expect($desktopBoot.get().phase).toBe('renderer.ready')
})
it('a cold boot warns about a Docker/SSH terminal that is not ready, not only a connection switch', async () => {
render(<Harness />)
await flushAsync()
expect($desktopBoot.get().phase).toBe('renderer.ready')
expect(warnIfTerminalBackendUnavailable).toHaveBeenCalledTimes(1)
})
it('a backend exit while the boot overlay is up fails the overlay and does not add a dead-button toast', async () => {
// reconnectGateway() is a no-op before boot completes, so a "Restart
// Hermes" toast here would do nothing when clicked; the overlay's own
// Retry is the recovery.
const desktop = fakeDesktop()
desktop.getConnection = vi.fn(() => new Promise<never>(() => undefined))
;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop
render(<Harness />)
await flushAsync()
expect($desktopBoot.get().visible).toBe(true)
act(() => backendExit?.({ code: 1 }))
expect($desktopBoot.get().error).toBeTruthy()
expect($notifications.get()).toHaveLength(0)
})
it('a backend exit after boot toasts a Restart that raises the shell restart intent', async () => {
render(<Harness />)
await flushAsync()
expect($desktopBoot.get().visible).toBe(false)
const before = $backendRestartRequest.get()
act(() => backendExit?.({ code: 1 }))
const toast = $notifications.get().find(entry => entry.kind === 'error')
expect(toast?.action).toBeTruthy()
toast?.action?.onClick()
expect($backendRestartRequest.get()).toBe(before + 1)
})
it('seeds the configured default project dir pre-connect — no route-resume race (#71873)', async () => {
// The reporter's scenario: a configured default project dir must be applied
// at boot regardless of route-resume timing. The seed now runs BEFORE the
@@ -47,7 +47,7 @@ import {
setPrimaryGatewayConnection,
touchSecondaryGateways
} from '@/store/gateway'
import { registerGatewayReconnect } from '@/store/gateway-reconnect'
import { reconnectGateway, registerGatewayReconnect } from '@/store/gateway-reconnect'
import {
$gatewaySwitching,
beginGatewaySwitch,
@@ -56,7 +56,7 @@ import {
registerGatewaySwitchLifecycle
} from '@/store/gateway-switch'
import { watchLocalRuntimeJobs } from '@/store/local-runtime-jobs'
import { notify, notifyError } from '@/store/notifications'
import { notify, notifyError, RECOVERY_ACTIONS } from '@/store/notifications'
import { loadPoolLimits } from '@/store/pool-limits'
import {
$activeGatewayProfile,
@@ -64,6 +64,7 @@ import {
refreshActiveProfile,
touchActiveGatewayBackend
} from '@/store/profile'
import { requestBackendRestart } from '@/store/recovery-requests'
import {
$activeSessionId,
$connection,
@@ -91,6 +92,7 @@ import {
recordSessionEventScope,
resetTileRuntimeBindings
} from '@/store/session-states'
import { warnIfTerminalBackendUnavailable } from '@/store/terminal-backend-warning'
import { windowProfileOverride } from '@/store/windows'
import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor'
@@ -436,7 +438,19 @@ export function useGatewayBoot({
if (isActivePrimary()) {
reauthNotified = true
notifyError(err, translateNow('boot.errors.gatewaySignInRequired'))
// Plain "signed out" copy; the raw ticket/HTTP text stays under
// Details. The boot overlay carries the sign-in flow itself, so
// the button hands off to it (desktop-14).
notify({
kind: 'error',
title: translateNow('boot.errors.gatewaySignInRequired'),
message: translateNow('boot.errors.gatewaySignInRequiredDetail'),
detail: primaryReauthError,
action: {
label: translateNow('boot.errors.signInAgain'),
onClick: () => failDesktopBoot(primaryReauthError ?? '')
}
})
}
}
} finally {
@@ -455,7 +469,12 @@ export function useGatewayBoot({
kind: 'warning',
title: translateNow('boot.errors.gatewayConnectionLost'),
message: translateNow('boot.errors.gatewayConnectionLostDetail'),
durationMs: 0
durationMs: 0,
action: {
label: translateNow('boot.errors.reconnectNow'),
onClick: () => void reconnectGateway().catch(() => undefined)
},
secondaryAction: RECOVERY_ACTIONS.openGateways()
})
}
@@ -710,6 +729,9 @@ export function useGatewayBoot({
// that were running before a reload — the backend registry is the
// authority; this just resumes following it.
watchLocalRuntimeJobs()
// A Docker/SSH terminal backend that fails its probe means shell
// commands silently cannot run — say so once, with a way out.
void warnIfTerminalBackendUnavailable()
} catch (err) {
const mayPublishFailure =
!cancelled && (switchToken === null ? !$gatewaySwitching.get() : isCurrentGatewaySwitch(switchToken))
@@ -1068,15 +1090,32 @@ export function useGatewayBoot({
return
}
// While the boot overlay is up it already shows the failure with its own
// Retry, and the reconnect handler below is a no-op before boot completes
// — a toast whose button does nothing would only mislead. Fail the
// overlay and stop there.
if ($desktopBoot.get().running || $desktopBoot.get().visible) {
failDesktopBoot(translateNow('boot.errors.backgroundExitedDuringStartup'))
return
}
// Post-boot: the shell's restart intent recycles a local service via main
// (or re-dials a remote one) and does not depend on this hook's
// reconnect gate, unlike reconnectGateway().
notify({
kind: 'error',
title: translateNow('boot.errors.backendStopped'),
message: translateNow('boot.errors.backgroundExited'),
durationMs: 0
durationMs: 0,
action: {
label: translateNow('boot.errors.restartHermes'),
onClick: requestBackendRestart
},
secondaryAction: {
label: translateNow('boot.errors.openLogs'),
onClick: () => void desktop.revealLogs?.().catch(() => undefined)
}
})
})
@@ -1190,6 +1229,10 @@ export function useGatewayBoot({
completeDesktopBoot()
bootCompleted = true
bootRetryAttempt = 0
// A Docker/SSH terminal backend that fails its probe means shell
// commands silently cannot run — say so once, with a way out. Cold
// launch is the common path, so it must warn too, not only softSwitch.
void warnIfTerminalBackendUnavailable()
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : String(err)
@@ -1253,6 +1296,12 @@ export function useGatewayBoot({
}
await callbacksRef.current.refreshSessions().catch(() => undefined)
if (cancelled) {
return
}
void warnIfTerminalBackendUnavailable()
}
if (adoptedFromHmr) {
+7 -1
View File
@@ -419,7 +419,13 @@ export function MessagingView({ setStatusbarItemGroup: _setStatusbarItemGroup, .
if (!ok) {
setRestartNeeded(true)
notifyError(new Error(m.restartFailedManual), m.restartFailedManual)
notify({
kind: 'error',
title: m.restartFailedManual,
message: m.restartFailedManualDetail,
action: { label: m.restartAgain, onClick: () => void runGatewayRestart() },
secondaryAction: { label: m.openLogs, onClick: () => void window.hermesDesktop?.revealLogs?.().catch(() => undefined) }
})
}
void refreshPlatforms(true)
@@ -71,7 +71,12 @@ describe('useMessageStream agent-init error surfacing (#63078)', () => {
expect(state.awaitingResponse).toBe(false)
// A global toast also fired (turn-ending errors are easy to miss inline).
expect($notifications.get().some(n => n.kind === 'error' && n.message?.includes('was not sent'))).toBe(true)
// The server already sends plain, actionable copy for an agent-init
// failure, so it IS the toast message — not demoted to a detail line
// under a generic gloss.
const toast = $notifications.get().find(n => n.kind === 'error' && n.message.includes('was not sent'))
expect(toast).toBeDefined()
expect(toast!.detail).toBeUndefined()
})
it('renders the pre-ready cancel error event (#65567 server emit) visibly', () => {
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $notifications, clearNotifications } from '@/store/notifications'
import { $approvalRequests, clearApprovalRequest, setApprovalRequest } from '@/store/prompts'
import { $routeRequest } from '@/store/recovery-requests'
import { handleInputRequestEvent } from './input-requests'
import type { GatewayEventContext } from './types'
// When the server withdraws an unanswered approval on timeout, the Run/Reject
// bar disappears and the tool row then shows a model-facing "BLOCKED …" result.
// The transcript must gain a human system line that says what happened and
// where the wait is configured; a cancel for any other reason stays silent.
function context(reason: string, updateSessionState: ReturnType<typeof vi.fn>): GatewayEventContext {
const payload = { id: 'srv-1', method: 'approval', reason }
return {
deps: { flushQueuedDeltas: vi.fn(), updateSessionState } as unknown as GatewayEventContext['deps'],
event: { payload, session_id: 's1', type: 'request.cancel' },
explicitSid: 's1',
fromActiveSource: () => true,
isActiveEvent: true,
occurredAt: 1_700_000_000,
payload: payload as GatewayEventContext['payload'],
scheduleConfigRefresh: vi.fn(),
sessionId: 's1'
}
}
function parkApproval() {
setApprovalRequest({
sessionId: 's1',
command: 'rm -rf build',
description: '',
requestId: 'req-1',
serverRequestId: 'srv-1'
})
}
describe('approval request.cancel', () => {
afterEach(() => {
clearApprovalRequest('s1')
clearNotifications()
})
it('timeout: tears the bar down and appends a plain system line with a Safety settings action', () => {
parkApproval()
const updateSessionState = vi.fn((_: string, updater: (s: { messages: unknown[] }) => unknown) =>
updater({ messages: [] })
)
expect(handleInputRequestEvent(context('timeout', updateSessionState))).toBe(true)
expect($approvalRequests.get()['s1']).toBeUndefined()
const next = updateSessionState.mock.results[0]?.value as { messages: { role: string; parts: { text: string }[] }[] }
expect(next.messages).toHaveLength(1)
expect(next.messages[0].role).toBe('system')
expect(next.messages[0].parts[0].text).toMatch(/timed out/i)
expect(next.messages[0].parts[0].text).toMatch(/Settings → Safety/)
expect(next.messages[0].parts[0].text).not.toMatch(/BLOCKED|Do NOT/)
const toast = $notifications.get()[0]
expect(toast?.action?.label).toBe('Open Safety settings')
toast?.action?.onClick()
expect($routeRequest.get()?.path).toBe('/settings?tab=config:safety')
})
it('answered elsewhere: tears the bar down silently', () => {
parkApproval()
const updateSessionState = vi.fn()
expect(handleInputRequestEvent(context('answered', updateSessionState))).toBe(true)
expect($approvalRequests.get()['s1']).toBeUndefined()
expect(updateSessionState).not.toHaveBeenCalled()
expect($notifications.get()).toHaveLength(0)
})
})
@@ -3,10 +3,11 @@ import type { ConnectionRequestPayload, ConnectionUpdatePayload, GatewayEvent }
import { pendingClarifyToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-clarify'
import { connectionRequestToolPayload } from '@/app/session/hooks/use-session-actions/restore-pending-connection'
import { translateNow } from '@/i18n'
import { settlePendingClarifyToolCall } from '@/lib/chat-messages'
import { settlePendingClarifyToolCall, textPart } from '@/lib/chat-messages'
import { $clarifyRequests, clearClarifyRequest } from '@/store/clarify'
import { normalizeConnectionRequest, setConnectionRequest, updateConnectionRequest } from '@/store/connection-request'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import {
$approvalRequests,
$secretRequests,
@@ -21,10 +22,14 @@ import {
clearVaultSaveLoginRequest,
clearVaultUnlockRequest
} from '@/store/prompts'
import { requestRoute } from '@/store/recovery-requests'
import { forgetServerRequest } from '@/store/server-requests'
import type { GatewayEventContext } from './types'
/** Settings → Safety, where `approvals.timeout` lives (settings/constants.ts). */
const SAFETY_SETTINGS_ROUTE = '/settings?tab=config:safety'
type ConnectionRequestEvent = GatewayEvent<'connection.request'> & { payload: ConnectionRequestPayload }
type ConnectionUpdateEvent = GatewayEvent<'connection.update'> & { payload: ConnectionUpdatePayload }
@@ -118,6 +123,30 @@ export function handleInputRequestEvent(ctx: GatewayEventContext): boolean {
if ($approvalRequests.get()[key]?.serverRequestId === id) {
clearApprovalRequest(sessionId, $approvalRequests.get()[key]?.requestId)
// The Run/Reject bar vanishing is the only thing the user would otherwise
// see; the tool row then shows a model-facing "BLOCKED" result. Say what
// happened in human terms and point at the setting that controls the wait.
if (payload?.reason === 'timeout' && sessionId) {
const line = translateNow('assistant.approval.timedOutSystemLine')
deps.flushQueuedDeltas(sessionId)
deps.updateSessionState(sessionId, state => ({
...state,
messages: [
...state.messages,
{ id: `approval-timeout-${id}`, role: 'system', parts: [textPart(line, occurredAt)], timestamp: occurredAt }
]
}))
notify({
kind: 'warning',
message: line,
action: {
label: translateNow('assistant.approval.openSafetySettings'),
onClick: () => requestRoute(SAFETY_SETTINGS_ROUTE)
}
})
}
} else if ($sudoRequests.get()[key]?.requestId === id) {
clearSudoRequest(sessionId, id)
} else if ($secretRequests.get()[key]?.requestId === id) {
@@ -0,0 +1,92 @@
// The gateway `error` event (tui_gateway/prompt_turn.py) carries only a
// message — no error_surface. The dispatcher must still classify the two
// refusals it can mean so the card and toast read like a classified turn:
// plain words up front, raw text demoted to the detail line, and Retry gone
// where retrying reproduces the failure.
import { afterEach, describe, expect, it, vi } from 'vitest'
import { $notifications } from '@/store/notifications'
import { handleStatusEvent } from './status'
import type { GatewayEventContext } from './types'
vi.mock('@/store/native-notifications', () => ({ dispatchNativeNotification: vi.fn() }))
vi.mock('@/store/onboarding', () => ({ requestDesktopOnboarding: vi.fn() }))
const OWNED_REFUSAL =
'Session 20260909_095312_6b93f5 already has a live owner (tui, pid 32977, lease age 22m). ' +
'Attach through a compatible owner, or close the session in its owning surface before resuming here.'
function errorContext(message: string) {
const failAssistantMessage = vi.fn()
const payload = { message } as GatewayEventContext['payload']
const ctx: GatewayEventContext = {
deps: {
compactedTurnRef: { current: new Set<string>() },
failAssistantMessage,
flushQueuedDeltas: vi.fn(),
hydrateFromStoredSession: vi.fn(),
queryClient: { invalidateQueries: vi.fn() },
sessionStateByRuntimeIdRef: { current: new Map() },
updateSessionState: vi.fn()
} as unknown as GatewayEventContext['deps'],
event: { payload, session_id: 'sess-1', type: 'error' },
explicitSid: 'sess-1',
fromActiveSource: () => true,
isActiveEvent: false,
occurredAt: 1_700_000_100,
payload,
scheduleConfigRefresh: vi.fn(),
sessionId: 'sess-1'
}
return { ctx, failAssistantMessage }
}
afterEach(() => {
$notifications.set([])
})
describe('gateway `error` event → error card + toast', () => {
it('stamps SESSION_NOT_OWNED on a live-owner refusal so the card drops Retry', () => {
const { ctx, failAssistantMessage } = errorContext(OWNED_REFUSAL)
expect(handleStatusEvent(ctx)).toBe(true)
expect(failAssistantMessage).toHaveBeenCalledWith(
'sess-1',
OWNED_REFUSAL,
1_700_000_100,
expect.objectContaining({ code: 'SESSION_NOT_OWNED', retryable: false })
)
})
it('toasts the plain explanation, not the lease jargon, and keeps the raw text as detail', () => {
const { ctx } = errorContext(OWNED_REFUSAL)
handleStatusEvent(ctx)
const toast = $notifications.get()[0]
expect(toast.title).toBe("Hermes couldn't finish the reply")
expect(toast.message).toMatch(/open in another Hermes window or terminal/)
expect(toast.message).not.toMatch(/lease|pid|live owner/i)
expect(toast.detail).toBe(OWNED_REFUSAL)
expect(toast.action).toBeUndefined()
})
it('keeps the server\'s own plain copy as the toast message when no code was recovered', () => {
// tui_gateway/user_messages.py already writes actionable sentences for
// pre-turn failures; the generic "couldn't finish" gloss must not bury them.
const serverCopy = 'Hermes could not start the assistant for this chat. Check your model settings and try again.'
const { ctx, failAssistantMessage } = errorContext(serverCopy)
handleStatusEvent(ctx)
expect(failAssistantMessage).toHaveBeenCalledWith('sess-1', serverCopy, 1_700_000_100, null)
const toast = $notifications.get()[0]
expect(toast.message).toBe(serverCopy)
expect(toast.detail).toBeUndefined()
})
})
@@ -1,6 +1,10 @@
import { translateNow } from '@/i18n'
import { isSessionNotOwnedError } from '@/app/session/hooks/use-prompt-actions/utils'
import { translateNow, TRANSLATIONS } from '@/i18n'
import { getRuntimeI18nLocale } from '@/i18n/runtime'
import { textPart } from '@/lib/chat-messages'
import { coerceGatewayText } from '@/lib/chat-runtime'
import type { ErrorSurface } from '@/lib/error-surface'
import { errorCardText } from '@/lib/error-surface-copy'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
import { clearClarifyRequest } from '@/store/clarify'
@@ -170,6 +174,27 @@ export function handleStatusEvent(ctx: GatewayEventContext): boolean {
const errorMessage = payload?.message || 'Hermes reported an error'
const looksLikeProviderSetup = isProviderSetupErrorMessage(errorMessage)
// The gateway's `error` event carries no error_surface (prompt_turn.py
// emits it for pre-turn refusals). Recover the two codes it CAN mean from
// the text so the card and toast get the same plain copy + button gating
// as a classified turn: a live-owner refusal (SESSION_NOT_OWNED, #106217)
// is deterministic — Retry hits the same wall, only a new chat helps —
// and disk-full is a machine problem, not a provider one.
const surface: ErrorSurface | null = isSessionNotOwnedError(new Error(errorMessage))
? { code: 'SESSION_NOT_OWNED', layer: 'gateway', retryable: false }
: isDiskFullErrorMessage(errorMessage)
? { code: 'disk_full', layer: 'disk', retryable: false }
: null
// When a code was recovered, the glossed card sentence explains it better
// than the raw refusal. When none was, the server's own text IS the plain
// copy (tui_gateway/user_messages.py writes actionable sentences for
// pre-turn failures — agent init, resume, cancelled-before-ready), and
// burying it under a generic "couldn't finish" gloss would hide the one
// instruction the user needs.
const card = surface ? errorCardText(TRANSLATIONS[getRuntimeI18nLocale()].assistant.thread, surface) : null
const toastMessage = card ? `${card.title}. ${card.body}` : errorMessage
// A turn that errors out has also ended — drop any open blocking prompt
// for this session so an approval/sudo/secret overlay can't linger past
// the failed turn (same intent as the message.complete clear).
@@ -187,7 +212,7 @@ export function handleStatusEvent(ctx: GatewayEventContext): boolean {
}
dispatchNativeNotification({
body: errorMessage,
body: toastMessage,
kind: 'turnError',
sessionId,
title: translateNow('notifications.native.turnErrorTitle')
@@ -195,24 +220,30 @@ export function handleStatusEvent(ctx: GatewayEventContext): boolean {
if (looksLikeProviderSetup) {
requestDesktopOnboarding(errorMessage)
} else if (isDiskFullErrorMessage(errorMessage)) {
} else if (surface?.code === 'disk_full') {
notifyError(new Error(errorMessage), translateNow('notifications.errors.diskFull'))
} else {
// Toast globally, not just when the failing thread is focused: a
// turn-ending error (e.g. out of funds) blocks every thread, so the
// inline error alone is too easy to miss. The stable id collapses the
// same error from multiple blocked threads into one toast.
// same error from multiple blocked threads into one toast. For a
// recovered code the message is the card's glossed sentence with the raw
// gateway text as the dimmed detail; otherwise the server copy is the
// message and there is no separate detail to repeat.
// No Retry action: assistant-ui's reload is per-thread, and for the
// codes recovered above a retry would fail identically anyway.
notify({
detail: surface ? errorMessage : undefined,
id: `gateway-error:${errorMessage}`,
kind: 'error',
title: 'Hermes error',
message: errorMessage
message: toastMessage,
title: translateNow('assistant.thread.errorToastTitle')
})
}
if (sessionId) {
flushQueuedDeltas(sessionId)
failAssistantMessage(sessionId, errorMessage, occurredAt)
failAssistantMessage(sessionId, errorMessage, occurredAt, surface)
}
if (isActiveEvent) {
@@ -3,6 +3,7 @@ import type { QueryClient } from '@tanstack/react-query'
import type { MutableRefObject } from 'react'
import type { GatewayEventPayload } from '@/lib/chat-messages'
import type { ErrorSurface } from '@/lib/error-surface'
import type { ClientSessionState } from '../../../../types'
@@ -21,7 +22,12 @@ export interface GatewayEventDeps {
failure?: { error: string; partial: boolean },
occurredAt?: number
) => void
failAssistantMessage: (sessionId: string, errorMessage: string, occurredAt?: number) => void
failAssistantMessage: (
sessionId: string,
errorMessage: string,
occurredAt?: number,
surface?: ErrorSurface | null
) => void
flushQueuedDeltas: (sessionId?: string) => void
finalizeInterimAssistantMessage: (sessionId: string, text: string, occurredAt?: number) => void
hydrateFromStoredSession: (
@@ -803,12 +803,16 @@ export function useMessageStream({
)
const failAssistantMessage = useCallback(
(sessionId: string, errorMessage: string, occurredAt = Date.now() / 1000) => {
(sessionId: string, errorMessage: string, occurredAt = Date.now() / 1000, surface?: ErrorSurface | null) => {
updateSessionState(sessionId, state => {
const streamId = state.streamId ?? `assistant-error-${Date.now()}`
const groupId = state.pendingBranchGroup ?? undefined
const prev = state.messages
const error = errorMessage.trim() || 'Hermes reported an error'
// The `error` event carries no descriptor; the dispatcher may recover
// one from the text (SESSION_NOT_OWNED, disk_full) so the card gates
// its buttons like a classified turn.
const errorSurface = surface ? { errorSurface: surface } : {}
const durationS = state.turnStartedAt
? Math.max(1, Math.round((Date.now() - state.turnStartedAt) / 1000))
@@ -821,6 +825,7 @@ export function useMessageStream({
...message,
completedAt: occurredAt,
error,
...errorSurface,
parts: completeOpenTimelineParts(message.parts, occurredAt),
pending: false,
...(durationS !== undefined ? { durationS } : {})
@@ -836,6 +841,7 @@ export function useMessageStream({
timestamp: occurredAt,
completedAt: occurredAt,
error,
...errorSurface,
pending: false,
branchGroupId: groupId,
...(durationS !== undefined ? { durationS } : {})
@@ -1924,7 +1924,7 @@ describe('usePromptActions desktop slash pickers', () => {
expect(calls).toContainEqual({
method: 'handoff.fail',
params: {
error: expect.stringContaining('Timed out'),
error: expect.stringContaining("couldn't reach your messaging connection"),
session_id: RUNTIME_SESSION_ID
}
})
@@ -39,6 +39,7 @@ import {
} from '@/store/session'
import { $sessionStates, isSessionRemote } from '@/store/session-states'
import { clearSessionSubagents } from '@/store/subagents'
import { runGatewayRestart } from '@/store/system-actions'
import { clearSessionTodos } from '@/store/todos'
import { setSessionDraftingTool } from '@/store/tool-drafting'
@@ -579,6 +580,14 @@ export function usePromptActions({
return markCompleted()
}
// The messaging service can be started from the app — offer it here
// instead of asking a desktop user a CLI question (desktop-17).
notify({
kind: 'error',
message: copy.handoff.timedOut,
action: { label: copy.handoff.startMessaging, onClick: () => void runGatewayRestart() }
})
return { error: copy.handoff.timedOut, ok: false }
},
[activeSessionIdRef, appendSessionTextMessage, copy, requestGateway]
@@ -223,7 +223,7 @@ export function PluginInstallModal() {
notify({
kind: 'warning',
message: m.missingEnv(result.missingEnv.join(', ')),
message: m.missingEnv(result.pluginName ?? request.repo, result.missingEnv.join(', ')),
// Deep-link straight to the credential card instead of leaving
// the user to hunt through Settings → Tools & Keys by hand.
action: {
@@ -34,6 +34,7 @@ import type {
} from '@/types/hermes'
import { EnvVarActionsMenu, EnvVarActionsTrigger, EnvVarContextMenu } from './env-var-actions-menu'
import { prettyName } from './helpers'
import { Pill } from './primitives'
import { VoiceProviderFields } from './voice-provider-fields'
@@ -329,7 +330,16 @@ function PostSetupRunner({ toolset, postSetupKey, installed = false, onComplete,
title: copy.postSetupCompleteTitle,
message: copy.postSetupCompleteMessage(postSetupKey)
}
: { kind: 'error', title: copy.postSetupErrorTitle, message: copy.postSetupErrorMessage(postSetupKey) }
: {
kind: 'error',
title: copy.postSetupErrorTitle,
message: copy.postSetupErrorMessage(prettyName(postSetupKey)),
action: {
label: copy.postSetupOpenLogs,
onClick: () => void window.hermesDesktop?.revealLogs?.().catch(() => undefined)
},
secondaryAction: { label: copy.postSetupRunAgain, onClick: () => void run() }
}
)
onComplete?.()
}
@@ -646,7 +656,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange, profile }: Too
const start = await startOAuthLogin('nous', profile)
if (start.flow !== 'device_code') {
notifyError(new Error(`unexpected flow: ${start.flow}`), copy.nousAuthFailed)
notifyNousAuthFailed(`unexpected flow: ${start.flow}`)
return
}
@@ -682,18 +692,30 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange, profile }: Too
}
if (polled.status !== 'pending') {
notifyError(new Error(polled.error_message || `Sign-in ${polled.status}`), copy.nousAuthFailed)
notifyNousAuthFailed(polled.error_message || `Sign-in ${polled.status}`)
return
}
}
} catch (err) {
if (mountedRef.current) {
notifyError(err, copy.nousAuthFailed)
notifyNousAuthFailed(err instanceof Error ? err.message : String(err))
}
}
}
// Plain failure copy with the raw poll status under Details and a one-click
// retry of the same sign-in flow (desktop-26).
function notifyNousAuthFailed(detail: string) {
notify({
kind: 'error',
title: copy.nousAuthFailed,
message: copy.nousAuthFailedMessage,
detail,
action: { label: copy.nousAuthTryAgain, onClick: () => void signInToNousPortal() }
})
}
function patchEnv(key: string, isSet: boolean) {
setEnvState(c => ({ ...c, [key]: isSet }))
onConfiguredChange?.()
@@ -7,7 +7,7 @@ import { useI18n } from '@/i18n'
import { Loader2 } from '@/lib/icons'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $hubActions, installHubSkill, UPDATE_ALL_KEY, updateHubSkills } from '@/store/hub-actions'
import { $hubActions, installHubSkill, notifyHubActionFailed, UPDATE_ALL_KEY, updateHubSkills } from '@/store/hub-actions'
import { notify, notifyError } from '@/store/notifications'
import { $paneHeightOverride, setPaneHeightOverride } from '@/store/panes'
@@ -149,7 +149,7 @@ export const EmbeddedHubPicker = memo(function EmbeddedHubPicker({
}
notify({ kind: 'success', title: h.installStarted(label), message: h.actionLog })
void installHubSkill(target, profile).catch(err => notifyError(err, h.actionFailed))
void installHubSkill(target, profile).catch(err => notifyHubActionFailed(err, h.actionFailed, label, profile))
}
window.addEventListener('message', onMessage)
+4 -2
View File
@@ -37,7 +37,7 @@ import { normalize } from '@/lib/text'
import { useStoreSelector } from '@/lib/use-session-slice'
import { cn } from '@/lib/utils'
import { $gateway, activeGatewayConnectionId } from '@/store/gateway'
import { $hubActions, installHubSkill, OFFICIAL_SKILLS_KEY } from '@/store/hub-actions'
import { $hubActions, installHubSkill, notifyHubActionFailed, OFFICIAL_SKILLS_KEY } from '@/store/hub-actions'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import type { OfficialSkillInfo, SkillInfo, ToolsetInfo } from '@/types/hermes'
@@ -548,7 +548,9 @@ export function SkillsView({
// catalog section into the installed section with the normal toggle.
function handleInstallOfficial(skill: OfficialSkillInfo) {
notify({ kind: 'success', title: t.skills.hub.installStarted(skill.name), message: t.skills.hub.actionLog })
void installHubSkill(skill.identifier, scopeProfile).catch(err => notifyError(err, t.skills.hub.actionFailed))
void installHubSkill(skill.identifier, scopeProfile).catch(err =>
notifyHubActionFailed(err, t.skills.hub.actionFailed, skill.name, scopeProfile)
)
}
async function handleToggleToolset(toolset: ToolsetInfo, enabled: boolean) {
+37 -4
View File
@@ -17,9 +17,11 @@ import { Progress } from '@/components/ui/progress'
import type { DesktopUpdateBlocker, DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global'
import { useI18n } from '@/i18n'
import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog'
import { openExternalLink } from '@/lib/external-link'
import { AlertCircle, Check, Copy, Terminal } from '@/lib/icons'
import { resolveUpdateCopy, type UpdateTarget } from '@/lib/update-copy'
import { cn } from '@/lib/utils'
import { requestRoute } from '@/store/recovery-requests'
import {
$backendUpdateApply,
$backendUpdateChecking,
@@ -38,6 +40,18 @@ import {
type UpdateApplyState
} from '@/store/updates'
import { SETTINGS_ROUTE } from './routes'
/** Same installer page Settings → About links to. */
const INSTALLER_URL = 'https://hermes-agent.nousresearch.com/'
/** Main puts the raw cause after "Details:" — show it as the dimmed line. */
function splitDetails(text: string): [string, string | null] {
const marker = text.search(/\s*Details:\s*/)
return marker < 0 ? [text, null] : [text.slice(0, marker).trim(), text.slice(marker).replace(/^\s*Details:\s*/, '').trim()]
}
function totalItems(groups: readonly CommitGroup[]) {
return groups.reduce((sum, g) => sum + g.items.length, 0)
}
@@ -197,9 +211,21 @@ function IdleView({
}
if (!status.supported) {
// A copy without version-control metadata can't self-update; the website
// carries the current installer (same URL as Settings → About).
const [lead, detail] = splitDetails(status.message ?? u.unsupportedMessage)
return (
<CenteredStatus
body={status.message ?? u.unsupportedMessage}
action={
status.reason === 'not-a-git-checkout' ? (
<Button onClick={() => openExternalLink(INSTALLER_URL)} size="sm">
{u.openDownloadPage}
</Button>
) : undefined
}
body={lead}
detail={detail ?? undefined}
icon={<AlertCircle className="size-6 text-muted-foreground" />}
title={u.notAvailableTitle}
/>
@@ -210,9 +236,16 @@ function IdleView({
return (
<CenteredStatus
action={
<Button disabled={checking} onClick={onRetryCheck} size="sm">
{u.tryAgain}
</Button>
<div className="flex flex-wrap justify-center gap-2">
<Button disabled={checking} onClick={onRetryCheck} size="sm">
{u.tryAgain}
</Button>
{target === 'backend' && (
<Button onClick={() => requestRoute(`${SETTINGS_ROUTE}?tab=gateway`)} size="sm" variant="outline">
{u.connectionSettings}
</Button>
)}
</div>
}
body={u.connectionRetry}
detail={status.message}
@@ -38,6 +38,7 @@ import {
warnDroppedChoices
} from '@/store/clarify'
import { $gateway } from '@/store/gateway'
import { reconnectAction } from '@/store/gateway-reconnect'
import { notifyError } from '@/store/notifications'
import { forgetServerRequest, respondToServerRequest } from '@/store/server-requests'
import { requestForOwnedSession } from '@/store/session-states'
@@ -469,7 +470,7 @@ function ClarifyToolSinglePending({
}
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed, { action: reconnectAction() })
return
}
@@ -1016,7 +1017,7 @@ function ClarifyToolBatchPending({ onAnswered, request }: { onAnswered: () => vo
const confirmAll = useCallback(async () => {
if (!request || !gateway) {
notifyError(new Error(request ? copy.gatewayDisconnected : copy.notReady), copy.sendFailed)
notifyError(new Error(request ? copy.gatewayDisconnected : copy.notReady), copy.sendFailed, request ? { action: reconnectAction() } : {})
return
}
@@ -28,6 +28,7 @@ import {
sessionConnectionRequest
} from '@/store/connection-request'
import { $gateway } from '@/store/gateway'
import { reconnectAction } from '@/store/gateway-reconnect'
import { notifyError } from '@/store/notifications'
import { invalidateMcpSuggestionIndex } from '@/store/suggestion-providers/mcp'
@@ -206,7 +207,7 @@ function McpSetupRow({ action, copy, request, single, target }: McpSetupRowProps
const respond = async (outcome: ConnectionTargetOutcome) => {
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed, { action: reconnectAction() })
return
}
@@ -5,9 +5,11 @@
// AssistantMessage's action bar hide the button entirely when no handler is
// supplied, matching how onDismissError/onRestoreToMessage already behave.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { cleanup, render, screen } from '@testing-library/react'
import { cleanup, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, useLocation } from 'react-router'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { en } from '@/i18n/en'
import { $displayTimestamps } from '@/store/display-timestamps'
import { stubThreadEnvironment } from '../test-utils'
@@ -134,6 +136,31 @@ function oauthExpiredMessage(): ThreadMessage {
} as unknown as ThreadMessage
}
/** A failed turn carrying an arbitrary error_surface descriptor. */
function failedMessage(errorSurface: Record<string, unknown>, error = 'HTTP 400: raw provider body'): ThreadMessage {
return {
id: 'assistant-error-3',
role: 'assistant',
content: [],
status: { type: 'incomplete', reason: 'error', error },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { errorSurface }
}
} as unknown as ThreadMessage
}
/** Renders the router's current URL so a test can assert where a deep link went. */
function LocationProbe() {
const location = useLocation()
return <span data-testid="location">{`${location.pathname}${location.search}`}</span>
}
function Harness({
assistant = assistantMessage(),
onBranchInNewChat
@@ -183,6 +210,123 @@ describe('ownership refusal recovery (#106217)', () => {
screen.getByRole('button', { name: 'Start new session' }).click()
expect(requestFreshSession).toHaveBeenCalledTimes(1)
})
it('explains the refusal in plain words and demotes the lease text to details', async () => {
render(<Harness assistant={ownershipRefusalMessage()} />)
expect(await screen.findByText(/open in another Hermes window or terminal/)).toBeTruthy()
// The raw refusal ("live owner", "pid", "lease") is kept only inside the
// collapsed Details disclosure, never as the headline.
const raw = screen.getByText(/already has a live owner/)
expect(raw.closest('details')).not.toBeNull()
})
})
describe('code-keyed error card copy and actions', () => {
it('hides Retry and offers Edit message for a safety refusal', async () => {
render(
<Harness
assistant={failedMessage({
code: 'content_policy_blocked',
layer: 'provider',
provider: 'openai',
retryable: false
})}
/>
)
expect(await screen.findByText('The AI service declined this request')).toBeTruthy()
// The user bubble itself is also labelled "Edit message"; assert on the
// card's own action button.
const editActions = screen
.getAllByRole('button', { name: 'Edit message' })
.filter(button => button.classList.contains('aui-error-action'))
expect(editActions).toHaveLength(1)
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})
it('offers Choose a model and no Retry when the model is not available', async () => {
render(
<Harness
assistant={failedMessage({ code: 'model_not_found', layer: 'provider', provider: 'openai', retryable: false })}
/>
)
expect(await screen.findByRole('button', { name: 'Choose a model' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
// The raw HTTP body is not the lead sentence.
expect(screen.getByText(en.assistant.thread.errorCodes.model_not_found.title as string)).toBeTruthy()
expect(screen.getByText(/HTTP 400/).closest('details')).not.toBeNull()
})
it('offers Compress conversation and Start new session for a context overflow', async () => {
render(<Harness assistant={failedMessage({ code: 'context_overflow', layer: 'provider', retryable: true })} />)
expect(await screen.findByText('This conversation is too long')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Compress conversation' })).toBeTruthy()
expect(screen.getByRole('button', { name: 'Start new session' })).toBeTruthy()
expect(screen.queryByRole('button', { name: 'Retry' })).toBeNull()
})
it('names the provider and keeps Retry for a rate limit', async () => {
render(
<Harness
assistant={failedMessage(
{ code: 'rate_limit', layer: 'provider', provider: 'openai', retryable: true },
'HTTP 429: {"error":{"message":"Rate limit reached"}}'
)}
/>
)
expect(await screen.findByText('The AI service is busy')).toBeTruthy()
expect(screen.getByText(/openai is limiting requests right now/)).toBeTruthy()
expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
})
it('falls back to the generic headline when no descriptor was sent (older backend)', async () => {
const legacy = {
...failedMessage({}),
metadata: { unstable_state: null, unstable_annotations: [], unstable_data: [], steps: [], custom: {} }
} as unknown as ThreadMessage
render(<Harness assistant={legacy} />)
expect(await screen.findByText("Hermes couldn't finish this reply")).toBeTruthy()
expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
})
})
describe('rejected API key recovery', () => {
it('names the key as the problem and deep-links Settings → Keys to that env var', async () => {
render(
<MemoryRouter>
<LocationProbe />
<Harness
assistant={failedMessage(
{
apiKeyEnv: 'OPENAI_API_KEY',
authKind: 'api_key',
code: 'auth',
layer: 'auth',
provider: 'openai',
providerLabel: 'OpenAI',
retryable: false
},
'HTTP 401: {"error":{"message":"Incorrect API key provided: sk-…"}}'
)}
/>
</MemoryRouter>
)
expect(await screen.findByText('OpenAI rejected your API key')).toBeTruthy()
// Fixing the key changes the outcome, so Retry stays as the follow-up click.
expect(screen.getByRole('button', { name: 'Retry' })).toBeTruthy()
screen.getByRole('button', { name: 'Update API key' }).click()
await waitFor(() => expect(screen.getByTestId('location').textContent).toMatch(/\?tab=keys&key=OPENAI_API_KEY$/))
})
})
describe('expired OAuth grant recovery', () => {
@@ -4,7 +4,8 @@ import {
ErrorPrimitive,
MessagePrimitive,
useAuiState,
useMessageRuntime
useMessageRuntime,
useThreadRuntime
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, type ReactNode, useCallback, useMemo, useState } from 'react'
@@ -30,7 +31,8 @@ import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { useI18n } from '@/i18n'
import { type ErrorSurface, formatErrorDiagnostics, isOAuthReauthSurface } from '@/lib/error-surface'
import { errorRecoveryPlan, type ErrorSurface, formatErrorDiagnostics, isOAuthReauthSurface } from '@/lib/error-surface'
import { errorCardText } from '@/lib/error-surface-copy'
import { triggerHaptic } from '@/lib/haptics'
import {
AudioLines,
@@ -52,7 +54,9 @@ import { notifyError } from '@/store/notifications'
import { startManualProviderOAuth } from '@/store/onboarding'
import { $activeGatewayProfile, normalizeProfileKey, requestFreshSession } from '@/store/profile'
import { requestSendDiagnostics } from '@/store/send-diagnostics'
import { $connection, $currentModel } from '@/store/session'
import { $connection, $currentModel, setModelPickerOpen } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import { notifyThreadEditOpen } from '@/store/thread-scroll'
import { $voicePlayback } from '@/store/voice-playback'
// Stable empty identity for the settled-parts selector — a fresh [] per render
@@ -246,8 +250,7 @@ const AssistantMessageBody: FC<AssistantMessageProps & { collapsedNotice?: null
>
<div className="flex items-start gap-1.5">
<div className="min-w-0 flex-1">
<ErrorLayerLabel />
<ErrorPrimitive.Message className="min-w-0" />
<ErrorCardHeadline />
</div>
{onDismissError && (
<TooltipIconButton
@@ -460,23 +463,37 @@ const StreamingMarker: FC = () => {
//
// The gateway stamps failed turns with a structured {layer, code, retryable}
// descriptor (metadata.custom.errorSurface — see agent/error_surface.py).
// These leaves render the layer label + recovery actions. Older backends
// never send the descriptor: the label falls back to a generic title and the
// action row still offers Retry / Open Logs / Copy error details, so nothing
// regresses on version skew.
// These leaves render a plain-language headline + recovery actions, both
// resolved from ONE table keyed on the failure code (lib/error-surface.ts,
// i18n `assistant.thread.errorCodes`); the raw provider/gateway text moves to
// a collapsed "Details" line. Older backends never send the descriptor: the
// headline falls back to a generic title and the action row still offers
// Retry / Open logs / Copy error details, so nothing regresses on version skew.
const ErrorLayerLabel: FC = () => {
const useErrorSurface = () => useAuiState(s => s.message.metadata?.custom?.errorSurface as ErrorSurface | undefined)
const useErrorText = () =>
useAuiState(s => {
const status = s.message.status as { error?: unknown; type?: string } | undefined
return status?.type === 'incomplete' && typeof status.error === 'string' ? status.error : ''
})
const ErrorCardHeadline: FC = () => {
const { t } = useI18n()
const surface = useAuiState(s => s.message.metadata?.custom?.errorSurface as ErrorSurface | undefined)
const labels = t.assistant.thread.errorLayers
const label = (surface && labels[surface.layer]) || labels.generic
const surface = useErrorSurface()
const errorText = useErrorText()
const { body, title } = errorCardText(t.assistant.thread, surface)
return (
<>
<div className="font-medium">{label}</div>
{isOAuthReauthSurface(surface) && (
<div>{t.assistant.thread.errorOauthExpired(surface.providerLabel || surface.provider)}</div>
<div className="font-medium">{title}</div>
<div>{body}</div>
{errorText && (
<details className="mt-0.5 min-w-0 text-[0.72rem] opacity-70">
<summary className="cursor-pointer select-none">{t.assistant.thread.errorDetails}</summary>
<div className="wrap-anywhere mt-0.5 whitespace-pre-wrap font-mono">{errorText}</div>
</details>
)}
</>
)
@@ -484,12 +501,100 @@ const ErrorLayerLabel: FC = () => {
// Isolated because useNavigate() THROWS outside a <Router> (bare test
// harnesses, embedded panes render threads router-free). The parent gates
// this child's mount on useInRouterContext(), which is safe anywhere.
const SwitchProviderAction: FC<{ label: string }> = ({ label }) => {
// these children's mount on useInRouterContext(), which is safe anywhere.
const SettingsLinkAction: FC<{ icon?: ReactNode; label: string; to: string }> = ({ icon, label, to }) => {
const navigate = useNavigate()
return (
<button className="aui-error-action" onClick={() => navigate(`${SETTINGS_ROUTE}?tab=config:model`)} type="button">
<button className="aui-error-action" onClick={() => navigate(to)} type="button">
{icon}
{label}
</button>
)
}
// Settings → Keys deep link for a rejected API key: `?tab=keys` plus
// `&key=<ENV>` when the descriptor names the env var (keys-settings.tsx
// scrolls to and expands that row). Older backends omit `api_key_env`; the
// tab alone is still the right place.
const updateApiKeyRoute = (surface: ErrorSurface | undefined) => {
const params = new URLSearchParams({ tab: 'keys' })
if (surface?.apiKeyEnv) {
params.set('key', surface.apiKeyEnv)
}
return `${SETTINGS_ROUTE}?${params.toString()}`
}
// "Edit message" for a safety refusal: opens the preceding user message in
// the edit composer, the same runtime call the bubble's own click performs
// (user-message.tsx ActionBarPrimitive.Edit). Retry would reproduce the
// refusal; changing the words is the only way forward.
const EditPreviousMessageAction: FC<{ label: string }> = ({ label }) => {
const threadRuntime = useThreadRuntime()
const previousUserMessageId = useAuiState(s => {
const messages = s.thread.messages
const index = messages.findIndex(message => message.id === s.message.id)
for (let i = index - 1; i >= 0; i--) {
if (messages[i].role === 'user') {
return messages[i].id
}
}
return null
})
const beginEdit = useCallback(() => {
if (!previousUserMessageId) {
return
}
triggerHaptic('selection')
notifyThreadEditOpen()
threadRuntime.getMessageById(previousUserMessageId).composer.beginEdit()
}, [previousUserMessageId, threadRuntime])
if (!previousUserMessageId) {
return null
}
return (
<button className="aui-error-action" onClick={beginEdit} type="button">
{label}
</button>
)
}
// "Compress conversation" for a context overflow: runs /compress against the
// failed turn's OWN session through the app's slash pipeline (the same
// session.compress RPC path the typed command takes — slash.ts `compress`),
// so it inherits the stale-runtime recovery, transcript replacement and
// progress notice instead of re-implementing them here.
const CompressConversationAction: FC<{ label: string }> = ({ label }) => {
const { t } = useI18n()
const view = useSessionView()
const sessionId = useStore(view.$runtimeId)
const compress = useCallback(() => {
const delegate = sessionTileDelegate()
if (!sessionId || !delegate) {
notifyError(new Error('slash delegate unavailable'), t.assistant.thread.errorCompressFailed)
return
}
triggerHaptic('submit')
void delegate.executeSlash('/compress', sessionId).catch(error => {
notifyError(error, t.assistant.thread.errorCompressFailed)
})
}, [sessionId, t.assistant.thread.errorCompressFailed])
return (
<button className="aui-error-action" onClick={compress} type="button">
{label}
</button>
)
@@ -498,16 +603,11 @@ const SwitchProviderAction: FC<{ label: string }> = ({ label }) => {
const ErrorRecoveryActions: FC = () => {
const { t } = useI18n()
const copy = t.assistant.thread
const surface = useAuiState(s => s.message.metadata?.custom?.errorSurface as ErrorSurface | undefined)
const errorText = useAuiState(s => {
const status = s.message.status as { error?: unknown; type?: string } | undefined
return status?.type === 'incomplete' && typeof status.error === 'string' ? status.error : ''
})
const surface = useErrorSurface()
const errorText = useErrorText()
// useNavigate() would throw here when no Router is above us; the deep-link
// child mounts only when one is (see SwitchProviderAction).
// children mount only when one is (see SettingsLinkAction).
const inRouter = useInRouterContext()
const model = useStore($currentModel)
const connection = useStore($connection)
@@ -519,58 +619,58 @@ const ErrorRecoveryActions: FC = () => {
// runtime's logs.
const remoteConnection = connection?.mode === 'remote'
// One table decides which buttons this failure gets (lib/error-surface.ts).
const plan = errorRecoveryPlan(surface)
// An expired/revoked OAuth grant (HTTP 401 on nous / openai-codex / ...):
// the one-click fix is re-running that provider's sign-in, which the
// onboarding overlay already owns end to end (device code → poll →
// reload.env → model confirm). Scoped to the gateway profile the failed
// session runs on, so a Bot profile's grant is renewed, not the primary's.
const oauthReauth = isOAuthReauthSurface(surface)
const gatewayProfile = useStore($activeGatewayProfile)
const signInAgain = useCallback(() => {
if (!oauthReauth) {
if (!isOAuthReauthSurface(surface)) {
return
}
triggerHaptic('submit')
const key = normalizeProfileKey(gatewayProfile)
startManualProviderOAuth(surface.provider, key === 'default' ? undefined : key)
}, [gatewayProfile, oauthReauth, surface])
}, [gatewayProfile, surface])
// Retry = assistant-ui reload (same wiring as the footer's refresh action):
// re-runs the failed turn's prompt in place. Suppressed when the classifier
// says the failure is deterministic (retrying reproduces it) — except for an
// OAuth rejection, where signing in again changes the outcome and Retry is
// the natural second click.
const retryable = !surface || surface.retryable || oauthReauth
// Another surface holds this session's lease (#106217): Retry would hit the
// same refusal, so the way out is a fresh session on this surface.
const ownershipRefusal = surface?.code === 'SESSION_NOT_OWNED'
// Switch Provider deep-links Settings → Models for the layers where the fix
// is provider/endpoint/auth config, not a retry.
const showSwitchProvider = surface != null && ['auth', 'billing', 'endpoint', 'provider'].includes(surface.layer)
const openLogs = useCallback(async () => {
// Reveal a local folder through Electron; `logsRoot` is the profile's
// HERMES_HOME/logs, and its parent is the Hermes data folder itself (what
// the user needs to see to free space after a disk-full failure).
const openLocalDir = useCallback(async (resolve: (logsRoot: string) => string, failedMessage: string) => {
try {
const root = await window.hermesDesktop?.logsRoot?.()
if (!root) {
notifyError(new Error('logs root unavailable'), copy.errorOpenLogsFailed)
notifyError(new Error('logs root unavailable'), failedMessage)
return
}
const result = await window.hermesDesktop?.openDir?.(root)
const result = await window.hermesDesktop?.openDir?.(resolve(root))
if (result && !result.ok) {
notifyError(new Error(result.error || 'open failed'), copy.errorOpenLogsFailed)
notifyError(new Error(result.error || 'open failed'), failedMessage)
}
} catch (error) {
notifyError(error, copy.errorOpenLogsFailed)
notifyError(error, failedMessage)
}
}, [copy.errorOpenLogsFailed])
}, [])
const openLogs = useCallback(
() => openLocalDir(root => root, copy.errorOpenLogsFailed),
[copy.errorOpenLogsFailed, openLocalDir]
)
const openHermesFolder = useCallback(
() => openLocalDir(root => root.replace(/[\\/]+logs[\\/]*$/, ''), copy.errorOpenHermesFolderFailed),
[copy.errorOpenHermesFolderFailed, openLocalDir]
)
const diagnosticsText = useCallback(
() =>
@@ -587,20 +687,46 @@ const ErrorRecoveryActions: FC = () => {
requestFreshSession()
}, [])
const chooseModel = useCallback(() => {
triggerHaptic('selection')
setModelPickerOpen(true)
}, [])
const localFolders = Boolean(window.hermesDesktop?.logsRoot)
return (
<div className="flex flex-wrap items-center gap-1.5">
{ownershipRefusal && (
{plan.editMessage && <EditPreviousMessageAction label={copy.editMessage} />}
{plan.compress && <CompressConversationAction label={copy.errorCompressConversation} />}
{plan.chooseModel && (
<button className="aui-error-action" onClick={chooseModel} type="button">
{copy.errorChooseModel}
</button>
)}
{plan.startNewSession && (
<button className="aui-error-action" onClick={startNewSession} type="button">
{copy.errorStartNewSession}
</button>
)}
{oauthReauth && (
{plan.signInAgain && isOAuthReauthSurface(surface) && (
<button className="aui-error-action" onClick={signInAgain} type="button">
<KeyRound className="size-3" />
{copy.errorSignInAgain(surface.providerLabel || surface.provider)}
</button>
)}
{retryable && (
{plan.updateApiKey && inRouter && (
<SettingsLinkAction
icon={<KeyRound className="size-3" />}
label={copy.errorUpdateApiKey}
to={updateApiKeyRoute(surface)}
/>
)}
{plan.openHermesFolder && localFolders && (
<button className="aui-error-action" onClick={() => void openHermesFolder()} type="button">
{copy.errorOpenHermesFolder}
</button>
)}
{plan.retry && (
<ActionBarPrimitive.Reload asChild>
<button className="aui-error-action" onClick={() => triggerHaptic('submit')} type="button">
<RefreshCwIcon className="size-3" />
@@ -608,8 +734,10 @@ const ErrorRecoveryActions: FC = () => {
</button>
</ActionBarPrimitive.Reload>
)}
{showSwitchProvider && inRouter && <SwitchProviderAction label={copy.errorSwitchProvider} />}
{window.hermesDesktop?.logsRoot && (
{plan.switchProvider && inRouter && (
<SettingsLinkAction label={copy.errorSwitchProvider} to={`${SETTINGS_ROUTE}?tab=config:model`} />
)}
{localFolders && (
<button className="aui-error-action" onClick={() => void openLogs()} type="button">
{remoteConnection ? copy.errorOpenDesktopLogs : copy.errorOpenLogs}
</button>
@@ -20,6 +20,7 @@ import { AlertCircle, ChevronDown } from '@/lib/icons'
import { isSubmitEnter } from '@/lib/ime'
import { cn } from '@/lib/utils'
import { $gateway } from '@/store/gateway'
import { reconnectAction } from '@/store/gateway-reconnect'
import { notifyError } from '@/store/notifications'
import { answerApproval } from '@/store/prompts'
import {
@@ -137,7 +138,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest; surface: 'floating' | 'inline'
}
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed)
notifyError(new Error(copy.gatewayDisconnected), copy.sendFailed, { action: reconnectAction() })
return
}
@@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest'
import { classifyLocalBootFailure, localBootFailureCopy } from './boot-failure-cause'
const CAUSES = {
diskFull: 'disk full copy',
exitedEarly: 'exited early copy',
installMissing: 'install missing copy',
permission: 'permission copy',
portInUse: 'port copy',
timedOut: 'timed out copy'
}
// The red box on the boot-failure overlay must lead with ONE plain sentence;
// exit codes, millisecond values and Python tracebacks belong under Details.
describe('local boot failure classification', () => {
it('classifies the raw main-process failures into plain causes', () => {
expect(
classifyLocalBootFailure(
'Hermes backend exited before it became ready (1).\nRecent backend output:\nTraceback (most recent call last):\n File "x.py"'
)
).toBe('exitedEarly')
expect(classifyLocalBootFailure('Timed out connecting to Hermes backend after 45000ms')).toBe('timedOut')
expect(classifyLocalBootFailure("EACCES: permission denied, open '/home/x/.hermes/state.db'")).toBe('permission')
expect(classifyLocalBootFailure('OSError: [Errno 28] No space left on device')).toBe('diskFull')
expect(classifyLocalBootFailure('listen EADDRINUSE: address already in use 127.0.0.1:9191')).toBe('portInUse')
expect(classifyLocalBootFailure(null)).toBeNull()
})
it('keeps the raw output out of the headline and behind details', () => {
const raw = 'Hermes backend exited before it became ready (1).\nRecent backend output:\nTraceback (most recent call last):'
const copy = localBootFailureCopy(raw, CAUSES)
expect(copy.headline).toBe(CAUSES.exitedEarly)
expect(copy.headline).not.toMatch(/\(1\)|Traceback|ms\b/)
expect(copy.rawDetail).toBe(raw)
})
it('falls back to the first raw line for an unknown failure, never a dump', () => {
const copy = localBootFailureCopy('Something odd happened\nline 2\nline 3', CAUSES)
expect(copy.headline).toBe('Something odd happened')
expect(copy.rawDetail).toContain('line 3')
})
})
@@ -0,0 +1,61 @@
/**
* Plain-language cause for a LOCAL backend boot failure. The main process
* reports what it saw ("exited before it became ready (1)", "Timed out
* connecting to Hermes backend after 45000ms", an EACCES, a Python traceback
* tail…). None of that tells a non-developer what went wrong, so the overlay
* leads with one classified sentence and keeps the raw text behind
* "Show recent logs". Pure, table-driven, unit-tested without React.
*
* SSH and remote-reauth failures have their own classifiers
* (`sshFailureMessage`, `isRemoteReauthFailure`) and are not handled here.
*/
export type LocalBootCause = 'diskFull' | 'exitedEarly' | 'installMissing' | 'permission' | 'portInUse' | 'timedOut'
// Order matters: the more specific causes (disk, permission, port, missing
// install) are checked before the generic exit/timeout shapes that usually
// accompany them in the same output.
const CAUSE_PATTERNS: readonly [LocalBootCause, RegExp][] = [
['diskFull', /no space left on device|database or disk is full|\bENOSPC\b|disk full/i],
['permission', /permission denied|\bEACCES\b|\bEPERM\b|read-only file system|\bEROFS\b|operation not permitted/i],
['portInUse', /address already in use|\bEADDRINUSE\b|port .* (?:is )?(?:already )?in use/i],
['installMissing', /installation is missing|is missing or incomplete|venv missing|no module named|modulenotfounderror/i],
['timedOut', /timed out|timeout/i],
['exitedEarly', /exited before|exited \(|process exited|exited with|traceback \(most recent call last\)/i]
]
export function classifyLocalBootFailure(error: string | null | undefined): LocalBootCause | null {
const text = String(error || '')
if (!text) {
return null
}
return CAUSE_PATTERNS.find(([, pattern]) => pattern.test(text))?.[0] ?? null
}
export interface LocalBootFailureCopy {
/** The one classified sentence shown in the red box. */
headline: string
/** Raw error text worth keeping for the collapsed details — null when the
* raw text is short enough that the headline already carries it. */
rawDetail: string | null
}
/** Copy for the overlay's red box: a classified cause when one is known,
* otherwise the raw error's FIRST line (never a traceback dump). */
export function localBootFailureCopy(
error: string | null | undefined,
causes: Record<LocalBootCause, string>
): LocalBootFailureCopy {
const raw = String(error || '').trim()
const cause = classifyLocalBootFailure(raw)
if (cause) {
return { headline: causes[cause], rawDetail: raw || null }
}
const firstLine = raw.split('\n')[0]?.trim() ?? ''
return { headline: firstLine, rawDetail: firstLine === raw ? null : raw }
}
@@ -13,6 +13,7 @@ import { $desktopBoot } from '@/store/boot'
import { notify, notifyError } from '@/store/notifications'
import { $desktopOnboarding } from '@/store/onboarding'
import { type LocalBootFailureCopy, localBootFailureCopy } from './boot-failure-cause'
import type { RemoteReauth } from './boot-failure-reauth'
import {
deriveProviderShape,
@@ -229,6 +230,13 @@ export function BootFailureOverlay() {
const openLogs = () => void window.hermesDesktop?.revealLogs().catch(() => undefined)
const copy = t.boot.failure
// SSH failures keep their own gloss; every other local failure is classified
// into one plain sentence, raw output collapsed underneath (desktop-05).
const failureCopy: LocalBootFailureCopy =
connectionConfig?.mode === 'ssh'
? { headline: sshFailureMessage(connectionConfig, boot.error, t.settings.gateway), rawDetail: null }
: localBootFailureCopy(boot.error, t.boot.causes)
const label = signInLabel(remoteReauth, {
identityProvider: copy.identityProvider,
remoteGateway: copy.signInToRemoteGateway,
@@ -391,7 +399,18 @@ export function BootFailureOverlay() {
<div className="grid gap-4 p-5 pt-0">
<div className="rounded-2xl border border-destructive/30 bg-destructive/10 px-4 py-3 text-xs text-destructive">
{sshFailureMessage(connectionConfig, boot.error, t.settings.gateway)}
{failureCopy.headline}
{failureCopy.rawDetail ? (
<details className="mt-2 text-muted-foreground">
<summary className="cursor-pointer select-none font-medium">{copy.details}</summary>
<pre
className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem] leading-relaxed"
data-selectable-text="true"
>
{failureCopy.rawDetail}
</pre>
</details>
) : null}
</div>
<div className="grid gap-2">
@@ -161,6 +161,21 @@ function errorMessage(err: unknown): string {
return err instanceof Error ? err.message : String(err || 'Unknown error')
}
/** Split "lead sentence\nDetails: raw" into [lead, raw]; no marker → [text, null]. */
export function splitFailureDetails(text: string | null): [string, string | null] {
const value = (text ?? '').trim()
const marker = value.search(/\n?\s*Details:\s*/)
if (marker < 0) {
return [value, null]
}
const lead = value.slice(0, marker).trim()
const detail = value.slice(marker).replace(/^\s*Details:\s*/, '').trim()
return [lead || value, detail || null]
}
const EMPTY_STATE: DesktopBootstrapState = {
active: false,
manifest: null,
@@ -538,6 +553,9 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
const totalCount = stages.length
const failed = Boolean(state.error)
// Main writes a plain lead sentence and keeps the raw installer error after
// "Details:" (electron/bootstrap-failure-copy.ts); show them as two lines.
const [failureLead, failureDetail] = splitFailureDetails(state.error)
// Count the running stage as half-done so the bar advances *during* a long
// stage instead of sitting frozen at the last completed step while its logs
// stream (e.g. "0 of 2" pinned at 0% for the whole first stage).
@@ -593,7 +611,12 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<ErrorIcon className="mt-0.5 shrink-0" size="1rem" />
<div className="min-w-0">
<div className="font-medium text-destructive">{copy.error}</div>
<p className="mt-0.5 whitespace-pre-wrap break-words text-foreground/90">{state.error}</p>
<p className="mt-0.5 whitespace-pre-wrap break-words text-foreground/90">{failureLead}</p>
{failureDetail ? (
<p className="mt-1 whitespace-pre-wrap break-words font-mono text-xs text-muted-foreground">
{failureDetail}
</p>
) : null}
</div>
</div>
)}
@@ -673,6 +696,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP
<code className="font-mono text-(--ui-text-secondary)">%LOCALAPPDATA%\hermes\logs\</code>
</span>
<div className="flex gap-2">
<Button
onClick={() => void window.hermesDesktop?.revealLogs?.().catch(() => undefined)}
size="sm"
variant="ghost"
>
{copy.openLogs}
</Button>
<Button
onClick={async () => {
const text = state.log
+17 -1
View File
@@ -3,6 +3,7 @@ import { Component, type ErrorInfo, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { ErrorState } from '@/components/ui/error-state'
import { useI18n } from '@/i18n'
import { requestSendDiagnostics } from '@/store/send-diagnostics'
export interface ErrorBoundaryFallbackProps {
error: Error
@@ -151,7 +152,19 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
>
<ErrorState
className="w-full max-w-[28rem]"
description={error.message || t.errors.boundaryDesc}
description={
<>
{t.errors.boundaryDesc}
{error.message ? (
<details className="mt-2 text-left text-xs text-muted-foreground">
<summary className="cursor-pointer select-none text-center">{t.errors.boundaryDetails}</summary>
<pre className="mt-1 max-h-32 overflow-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem]">
{error.message}
</pre>
</details>
) : null}
</>
}
title={t.errors.boundaryTitle}
>
<Button className="font-semibold" onClick={reset} size="lg">
@@ -163,6 +176,9 @@ function RootErrorFallback({ error, reset }: ErrorBoundaryFallbackProps) {
<Button onClick={() => void window.hermesDesktop?.revealLogs()?.catch(() => undefined)} variant="text">
{t.errors.openLogs}
</Button>
<Button onClick={() => requestSendDiagnostics(error.stack || error.message)} variant="text">
{t.errors.sendDiagnostics}
</Button>
</ErrorState>
</div>
)
@@ -259,7 +259,15 @@ export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) {
{probeStatus === 'error' ? (
<div className="flex items-start gap-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{probe?.error || copy.probeError}</span>
<div className="min-w-0">
<span>{copy.probeError}</span>
{probe?.error ? (
<details className="mt-1 text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">{copy.probeErrorDetails}</summary>
<pre className="mt-1 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem]">{probe.error}</pre>
</details>
) : null}
</div>
</div>
) : null}
@@ -65,7 +65,7 @@ describe('toast titles', () => {
</I18nProvider>
)
expect(screen.getByText(/All local profile backend slots are busy/)).toBeTruthy()
expect(screen.getByText(/Too many bots are running at once/)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Open Advanced Settings' }))
@@ -19,6 +19,8 @@ import {
recheckExternalSignin,
setOnboardingCode,
setOnboardingModel,
startManualOnboarding,
startProviderOAuth,
submitOnboardingCode
} from '@/store/onboarding'
@@ -56,16 +58,33 @@ export function FlowPanel({
}
if (flow.status === 'error') {
// Recovery in the order a stuck user needs it: retry the same provider
// (when we know which one failed), fall back to a pasted API key, or go
// back to the provider list. Raw error text stays behind Details.
const failedProvider = flow.provider
return (
<div className="grid gap-3">
<div className="flex items-center gap-1.5 text-sm text-destructive">
<ErrorIcon className="shrink-0" size="0.875rem" />
<span>{flow.message || t.onboarding.signInFailed}</span>
</div>
<div className="flex justify-end">
<Button onClick={cancelOnboardingFlow} variant="outline">
{flow.detail ? (
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer select-none">{t.onboarding.errorDetails}</summary>
<pre className="mt-1 whitespace-pre-wrap wrap-break-word font-mono text-[0.6875rem]">{flow.detail}</pre>
</details>
) : null}
<div className="flex flex-wrap justify-end gap-2">
<Button onClick={cancelOnboardingFlow} variant="text">
{t.onboarding.pickDifferentProvider}
</Button>
<Button onClick={() => startManualOnboarding(null, ctx.profile)} variant="outline">
{t.onboarding.useApiKeyInstead}
</Button>
{failedProvider ? (
<Button onClick={() => void startProviderOAuth(failedProvider, ctx)}>{t.onboarding.tryAgain}</Button>
) : null}
</div>
</div>
)
@@ -21,6 +21,7 @@ import { isMissingPendingPromptRequest } from '@/lib/gateway-rpc'
import { triggerHaptic } from '@/lib/haptics'
import { KeyRound, Loader2, Lock, ShieldLock } from '@/lib/icons'
import { $gateway } from '@/store/gateway'
import { reconnectAction } from '@/store/gateway-reconnect'
import { notifyError } from '@/store/notifications'
import {
clearSecretRequest,
@@ -70,7 +71,7 @@ function SudoDialog({ sessionId }: { sessionId: string | null }) {
}
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.sudoSendFailed)
notifyError(new Error(copy.gatewayDisconnected), copy.sudoSendFailed, { action: reconnectAction() })
return
}
@@ -187,7 +188,7 @@ function SecretDialog({ sessionId }: { sessionId: string | null }) {
}
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.secretSendFailed)
notifyError(new Error(copy.gatewayDisconnected), copy.secretSendFailed, { action: reconnectAction() })
return
}
@@ -288,7 +289,7 @@ function VaultUnlockDialog({ sessionId }: { sessionId: string | null }) {
}
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.vaultUnlockSendFailed)
notifyError(new Error(copy.gatewayDisconnected), copy.vaultUnlockSendFailed, { action: reconnectAction() })
return
}
@@ -385,7 +386,7 @@ function VaultSaveLoginDialog({ sessionId }: { sessionId: string | null }) {
}
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.vaultSaveSendFailed)
notifyError(new Error(copy.gatewayDisconnected), copy.vaultSaveSendFailed, { action: reconnectAction() })
return
}
@@ -497,7 +498,7 @@ function VaultCodeDialog({ sessionId }: { sessionId: string | null }) {
}
if (!gateway) {
notifyError(new Error(copy.gatewayDisconnected), copy.vaultCodeSendFailed)
notifyError(new Error(copy.gatewayDisconnected), copy.vaultCodeSendFailed, { action: reconnectAction() })
return
}
-1
View File
@@ -200,7 +200,6 @@ export const ar = defineLocale({
methodNotAllowed: 'رفضت خلفية سطح المكتب هذا الطلب (405 Method Not Allowed). جرب إعادة تشغيل Hermes Desktop.',
microphonePermission: 'تم رفض إذن الميكروفون.',
openaiRejectedApiKey: 'رفض OpenAI مفتاح API.',
openaiRejectedApiKeyWithStatus: status => `رفض OpenAI مفتاح API (${status} invalid_api_key).`,
openaiTtsNeedsKey: 'يتطلب OpenAI TTS المفتاح VOICE_TOOLS_OPENAI_KEY أو OPENAI_API_KEY.',
codeSkewRestartRequired: 'بعد التحديث ما زال هذا الخلفية يشغّل كودا قديما. أعد تشغيله لتحميل الكود الجديد.'
},
+239 -52
View File
@@ -143,20 +143,37 @@ export const en: Translations = {
startingHermesDesktop: 'Starting Hermes Desktop…'
},
errors: {
backgroundExited: 'Hermes background process exited.',
backgroundExitedDuringStartup: 'Hermes background process exited during startup.',
backendStopped: 'Backend stopped',
desktopBootFailed: 'Desktop boot failed',
gatewayConnectionLost: 'Lost connection to the gateway',
backgroundExited: 'The service that runs your chats closed unexpectedly. Restart it to keep going — your chats and settings are safe.',
backgroundExitedDuringStartup: 'Hermes stopped right after it started.',
backendStopped: 'Hermes stopped working in the background',
restartHermes: 'Restart Hermes',
openLogs: 'Open logs',
desktopBootFailed: "Hermes couldn't start",
gatewayConnectionLost: 'Hermes lost its connection',
gatewayConnectionLostDetail:
'Still retrying in the background. You can keep reading and drafting — open Gateway settings if this persists.',
gatewaySignInRequired: 'Gateway sign-in required',
ipcBridgeUnavailable: 'Desktop IPC bridge is unavailable.'
'Still trying to reconnect. You can keep reading and drafting. If this keeps up, reconnect now or check your connection settings.',
reconnectNow: 'Reconnect now',
connectionSettings: 'Connection settings',
gatewaySignInRequired: 'Your remote Hermes signed you out',
gatewaySignInRequiredDetail: 'Sign in again to reconnect. Your chats and settings are safe.',
signInAgain: 'Sign in again',
ipcBridgeUnavailable: "Hermes Desktop couldn't talk to its own background layer. Restart the app."
},
// Plain causes for a local backend boot failure (`classifyBootFailure`);
// the raw output stays behind "Show recent logs".
causes: {
exitedEarly: "Hermes' background service stopped right after starting.",
timedOut: "Hermes' background service didn't answer in time.",
permission: "Hermes couldn't write to its data folder (permission problem).",
diskFull: 'The disk is full, so Hermes could not start.',
portInUse: 'Another program is using the network port Hermes needs.',
installMissing: "Part of Hermes' installation is missing. Choose Repair install to put it back."
},
failure: {
title: "Hermes couldn't start",
description:
"The background gateway didn't come up. Try one of the recovery steps below. Nothing here deletes your chats or settings.",
"Hermes' background service didn't come up. Try one of the recovery steps below. Nothing here deletes your chats or settings.",
details: 'Details',
remoteTitle: 'Remote gateway sign-in required',
remoteDescription:
'Your remote gateway session has expired. Sign in again to reconnect. Nothing here deletes your chats or settings.',
@@ -222,17 +239,24 @@ export const en: Translations = {
disableFailed: name => `Could not disable ${name} MCP.`
},
errors: {
elevenLabsNeedsKey: 'ElevenLabs STT needs ELEVENLABS_API_KEY.',
elevenLabsRejectedKey: 'ElevenLabs rejected the API key (401).',
elevenLabsNeedsKey: 'Voice input needs an ElevenLabs key. Add one in Settings → Keys.',
elevenLabsRejectedKey: "ElevenLabs didn't accept your API key. Update it in Settings → Keys, then try again.",
diskFull: 'Disk full — free some space, then try again.',
gatewayAuthFailed: 'Gateway authentication failed — check your API_SERVER_KEY.',
methodNotAllowed:
'The desktop backend rejected that request (405 Method Not Allowed). Try restarting Hermes Desktop.',
storageFailure: "Hermes couldn't save to its data folder. Open Maintenance to check and repair it.",
gatewayAuthFailed:
'This Hermes no longer accepts your saved sign-in. Open Gateways and sign in again (or paste a new access token), then retry.',
methodNotAllowed: "Hermes' background service is out of step with the app, probably after an update. Restart it to fix this.",
microphonePermission: 'Microphone permission was denied.',
openaiRejectedApiKey: 'OpenAI rejected the API key.',
openaiRejectedApiKeyWithStatus: status => `OpenAI rejected the API key (${status} invalid_api_key).`,
openaiTtsNeedsKey: 'OpenAI TTS needs VOICE_TOOLS_OPENAI_KEY or OPENAI_API_KEY.',
codeSkewRestartRequired: 'This backend is running old code after an update. Restart it to load the new code.'
openaiRejectedApiKey: "OpenAI didn't accept your API key. Update it in Settings → Keys, then try again.",
openaiTtsNeedsKey: 'Voice needs an OpenAI key. Add one in Settings → Keys.',
codeSkewRestartRequired: 'Hermes was updated but is still running the old version. Restart it to finish the update.',
restartHermesFailed: "Couldn't restart Hermes"
},
actions: {
restartHermes: 'Restart Hermes',
openKeys: 'Open Keys',
openGateways: 'Open Gateways',
openMaintenance: 'Open Maintenance'
},
voice: {
configureSpeechToText: 'Configure speech-to-text to use voice mode.',
@@ -547,7 +571,7 @@ export const en: Translations = {
desktopSuccess: name => `Desktop plugin ${name} installed`,
agentFailed: 'Agent plugin install failed',
desktopFailed: 'Desktop plugin install failed',
missingEnv: vars => `Missing env vars: ${vars}. Add them in Settings → Keys.`
missingEnv: (name, vars) => `${name} is installed but needs a key before it can work: ${vars}. Add it now, or the plugin's tools will fail.`
}
},
vault: {
@@ -727,7 +751,7 @@ export const en: Translations = {
terminalFontReset: 'Use default',
chatFontTitle: 'Chat Font',
chatFontDesc:
'Choose an installed font for chat and the rest of the app. Handy for readability faces such as OpenDyslexic; leave blank to use the theme\'s font.',
"Choose an installed font for chat and the rest of the app. Handy for readability faces such as OpenDyslexic; leave blank to use the theme's font.",
chatFontPlaceholder: 'OpenDyslexic or a CSS font stack',
chatFontPreview: 'Preview',
chatFontSample: 'The quick brown fox jumps over the lazy dog. 0123456789',
@@ -1046,14 +1070,14 @@ export const en: Translations = {
gateway: {
loading: 'Loading gateway settings...',
unavailableTitle: 'Gateway settings unavailable',
unavailableDesc: 'The desktop IPC bridge does not expose gateway settings.',
unavailableDesc: 'Connection settings can only be changed from the Hermes Desktop app on the computer running it.',
title: 'Gateway Connection',
envOverride: 'env override',
intro:
'Local by default. Use remote when this app should drive a Hermes backend elsewhere. Gateway connections are machine-level; profiles are discovered from the gateways you connect.',
envOverrideTitle: 'Environment variables are controlling this desktop session.',
envOverrideTitle: 'This connection was fixed by the way Hermes was launched.',
envOverrideDesc:
'Unset HERMES_DESKTOP_REMOTE_URL and HERMES_DESKTOP_REMOTE_TOKEN to use the saved setting below.',
'A startup setting outside the app chose this connection, so the options below are read-only. Restart Hermes without that setting — or ask whoever set it up — to change it here.',
modeTitle: 'Connection mode',
localTitle: 'Local gateway',
localDesc: 'Start a private Hermes backend on localhost. This is the default and works offline.',
@@ -1099,7 +1123,7 @@ export const en: Translations = {
remoteUrlTitle: 'Remote URL',
remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.',
probing: 'Checking how this gateway authenticates…',
probeError: 'Could not reach this gateway yet. Check the URL — the auth method will appear once it responds.',
probeError: "Hermes can't reach that address. Check the URL and that the other computer is running Hermes — sign-in options appear once it answers.",
signedIn: 'Signed in',
signIn: 'Sign in',
signOut: 'Sign out',
@@ -1540,13 +1564,15 @@ export const en: Translations = {
activeBackend: 'Active',
activeBackendHint: 'This is your active backend',
useBackend: 'Use this backend',
nousIncluded: 'Included with a Nous subscription — sign in to Nous Portal to activate.',
nousAuthNeededTitle: 'Sign in to Nous Portal',
nousAuthNeededMessage: provider => `${provider} is saved but won't activate until you sign in to Nous Portal.`,
nousIncluded: 'Included with a Nous subscription — sign in with your Nous account to activate.',
nousAuthNeededTitle: 'Sign in with your Nous account',
nousAuthNeededMessage: provider => `${provider} is saved but will only work once you sign in with your Nous account.`,
nousAuthSignIn: 'Sign in',
nousAuthDoneTitle: 'Nous Portal connected',
nousAuthDoneTitle: 'Nous account connected',
nousAuthDoneMessage: 'Your subscription backends are now active.',
nousAuthFailed: 'Nous Portal sign-in did not complete',
nousAuthFailed: 'Nous sign-in did not complete',
nousAuthFailedMessage: 'Try again.',
nousAuthTryAgain: 'Try again',
noApiKeyRequired: 'No API key required.',
postSetupHint: step =>
`This backend needs a one-time install (${step}). Runs on this machine — may take a few minutes.`,
@@ -1559,7 +1585,9 @@ export const en: Translations = {
postSetupCompleteTitle: 'Setup complete',
postSetupCompleteMessage: step => `${step} installed.`,
postSetupErrorTitle: 'Setup finished with errors',
postSetupErrorMessage: step => `Check the ${step} log.`,
postSetupErrorMessage: step => `Setting up ${step} did not finish. Open the logs to see why, then run setup again.`,
postSetupOpenLogs: 'Open logs',
postSetupRunAgain: 'Run again',
postSetupFailed: step => `Failed to run ${step} setup`,
webSearchActive: backend => `Search: ${backend}`,
webExtractActive: backend => `Extract: ${backend}`,
@@ -1590,7 +1618,13 @@ export const en: Translations = {
selectedTitle: 'Backend selected',
selectedMessage: backend => `Terminal commands now run via ${backend}. Applies to new sessions.`,
failedSelect: backend => `Failed to select ${backend}`,
needsSetupHint: 'You can select this backend now — commands will fail until setup is complete.'
needsSetupHint: 'You can select this option now — commands will fail until setup is complete.',
unavailableTitle: 'Terminal commands are unavailable',
unavailableMessage: backend =>
`Hermes can't run shell commands right now: ${backend} isn't ready. Switch to Local, or finish setting up ${backend} and try again.`,
openBackendSettings: 'Open terminal settings',
useLocal: 'Use Local',
switchedToLocal: 'Terminal commands now run locally. Applies to new sessions.'
},
browserRealProfile: {
label: 'Use My Real Browser Profile',
@@ -1760,6 +1794,11 @@ export const en: Translations = {
uninstallStarted: name => `Uninstalling ${name}...`,
updateStarted: 'Updating installed skills...',
actionFailed: 'Skill action failed',
installBlockedTitle: name => `Couldn't install ${name}`,
installBlockedMessage: (findings, unverified) =>
`The security scan flagged ${findings > 0 ? `${findings} item${findings === 1 ? '' : 's'}` : 'risky patterns'} to review${unverified ? ' and the skill comes from an unverified source' : ''}. Read the scan before deciding whether to trust the author.`,
viewScan: 'View scan',
openLog: 'Open log',
actionLog: 'Action log',
alreadyInstalled: (name: string) => `"${name}" is already installed`,
pickerTitle: 'Skills Hub',
@@ -2109,7 +2148,10 @@ export const en: Translations = {
restartNeeded: 'Saved. Restart the messaging gateway so the new settings take effect.',
restartNow: 'Restart now',
restarting: 'Restarting…',
restartFailedManual: 'Gateway restart failed — restart it manually and check the gateway logs.',
restartFailedManual: "Hermes couldn't restart to apply your messaging settings",
restartFailedManualDetail: 'Try Restart again; if it still fails, open the logs and send diagnostics.',
restartAgain: 'Restart again',
openLogs: 'Open logs',
telegramQr: {
title: 'Choose how to connect your Telegram bot',
subtitle: 'Both options connect a bot you control and save its credentials only to this Hermes installation.',
@@ -2446,9 +2488,12 @@ export const en: Translations = {
running: 'running',
paused: 'paused',
disabled: 'disabled',
error: 'error',
error: 'last run failed',
completed: 'completed'
},
lastRunFailed: 'Last run failed:',
editJob: 'Edit job',
runAgain: 'Run again',
deliveryLabels: {
local: 'This desktop',
telegram: 'Telegram',
@@ -3161,7 +3206,10 @@ export const en: Translations = {
tryAgain: 'Try again',
notAvailableTitle: 'Update not available',
unsupportedMessage: 'This version of Hermes can’t update itself from inside the app.',
connectionRetry: 'Check your connection and try again.',
connectionRetry:
"Hermes couldn't reach the update server. Check your internet connection and try again. If you use a remote Hermes, make sure it is online.",
connectionSettings: 'Connection settings',
openDownloadPage: 'Open download page',
latestBody: 'You’re running the latest version.',
latestBodyBackend: 'The backend is running the latest version.',
allSetTitle: 'You’re all set',
@@ -3267,7 +3315,8 @@ export const en: Translations = {
remoteUrlDesc: 'Use the base URL of the Hermes gateway, including https:// when remote.',
remoteUrlPlaceholder: 'https://gateway.example.com/hermes',
probing: 'Detecting gateway authentication...',
probeError: 'Could not reach that Hermes gateway.',
probeError: "Hermes can't reach that address. Check the URL and that the other computer is running Hermes — sign-in options appear once it answers.",
probeErrorDetails: 'Details',
identityProvider: 'your identity provider',
authTitle: 'Authentication',
authNeedsOauth: provider => `Sign in with ${provider} before testing this gateway.`,
@@ -3290,7 +3339,7 @@ export const en: Translations = {
settingUpTitle: 'Setting up Hermes Agent',
finishingTitle: 'Finishing up',
failedDesc:
'One of the install steps failed. On Windows, this can happen if another Hermes CLI or desktop instance is running. Stop any running Hermes instances, then retry. Check the details below or the desktop log for the full transcript.',
'One of the setup steps did not finish. This can happen when another copy of Hermes is running, the internet connection dropped, or antivirus blocked the installer. Close other Hermes windows, then choose Reload and retry. If it fails again, open the logs and send them to support.',
activeDesc:
'This is a one-time setup. The Hermes installer is downloading dependencies and configuring your machine. Subsequent launches will skip this step.',
progress: (completed, total) => `${completed} of ${total} steps complete`,
@@ -3306,7 +3355,8 @@ export const en: Translations = {
transcriptSaved: 'Full transcript saved to',
copiedOutput: 'Copied!',
copyOutput: 'Copy output',
reloadRetry: 'Reload and retry'
reloadRetry: 'Reload and retry',
openLogs: 'Open logs'
},
onboarding: {
@@ -3362,7 +3412,12 @@ export const en: Translations = {
connectedPicking: provider => `${provider} connected. Picking a default model...`,
signInFailed: 'Sign-in failed. Try again.',
signInExpired:
'Sign-in expired waiting for authorization. This usually means the sign-in page stalled in the opened tab (server-side issue) — finish signing in there, then try again. If it keeps failing, use an API key or the CLI fallback instead.',
'The sign-in page timed out before you finished. Try again and complete the browser step within a few minutes, or use an API key instead.',
signInDidNotFinish: provider =>
`Sign-in with ${provider} did not finish. Check your internet connection and try again, or pick a different provider.`,
tryAgain: 'Try again',
useApiKeyInstead: 'Use an API key',
errorDetails: 'Details',
pickDifferentProvider: 'Pick a different provider',
signInWith: provider => `Sign in with ${provider}`,
openedBrowser: provider => `We opened ${provider} in your browser.`,
@@ -3860,19 +3915,142 @@ export const en: Translations = {
react: 'React',
dismissError: 'Dismiss error',
errorLayers: {
auth: 'Authentication error',
auth: 'Sign-in problem',
billing: 'Out of credits',
disk: 'Disk full',
endpoint: 'Custom endpoint error',
gateway: 'Gateway error',
generic: 'Turn failed',
provider: 'Provider error',
runtime: 'Local runtime error',
streaming: 'Streaming connection error'
endpoint: "Can't reach your model server",
gateway: 'Hermes hit a problem',
generic: "Hermes couldn't finish this reply",
provider: 'The AI service returned an error',
runtime: 'Hermes hit a problem',
streaming: 'The reply was cut off'
},
errorLayerBodies: {
auth: 'The AI service rejected your sign-in. Check the credentials for this provider, then send your message again.',
billing: 'Your account has no credits left for this provider. Top up or switch provider, then send again.',
disk: 'Your disk is full, so Hermes could not save this conversation. Free some space, then retry.',
endpoint: "Hermes can't reach your custom model server. Check that it is running, then send your message again.",
gateway: 'Hermes hit an internal problem starting this reply. Send your message again; if it keeps happening, send diagnostics.',
generic: 'Something went wrong while Hermes was replying. Retry, or copy the details if it keeps happening.',
provider: 'The AI service could not complete this request. Retry in a moment or switch provider.',
runtime: 'Hermes hit an internal problem starting this reply. Send your message again; if it keeps happening, send diagnostics.',
streaming: 'The connection dropped before the reply finished. Retry to send it again.'
},
errorCodes: {
auth: {
title: provider => `${provider} rejected your sign-in`,
body: provider => `The credentials saved for ${provider} were not accepted. Fix them in Settings or switch provider, then send your message again.`
},
auth_permanent: {
title: provider => `${provider} rejected your sign-in`,
body: provider =>
`The credentials saved for ${provider} are invalid or were revoked. Update them or switch provider, then send your message again.`
},
billing: {
title: 'Out of credits',
body: provider => `Your ${provider} account has no credits left. Top up or switch provider, then send again.`
},
rate_limit: {
title: 'The AI service is busy',
body: provider => `${provider} is limiting requests right now. Wait a minute, then retry.`
},
upstream_rate_limit: {
title: 'The AI service is busy',
body: provider => `${provider} is limiting requests right now. Wait a minute, then retry.`
},
overloaded: {
title: 'The AI service is overloaded',
body: provider => `${provider} is having problems right now. Retry in a moment or switch provider.`
},
server_error: {
title: 'The AI service had a problem',
body: provider => `${provider} returned a server error. Retry in a moment or switch provider.`
},
timeout: {
title: 'The reply timed out',
body: provider => `${provider} did not answer in time. Retry to send it again.`
},
stream_drop: {
title: 'The reply was cut off',
body: 'The connection dropped before the reply finished. Retry to send it again.'
},
ssl_cert_verification: {
title: 'Secure connection failed',
body: provider =>
`Hermes could not verify the secure connection to ${provider}. Check your network or proxy settings, or switch provider, then send your message again.`
},
context_overflow: {
title: 'This conversation is too long',
body: 'The conversation no longer fits the model. Compress it or start a new chat, then send again.'
},
payload_too_large: {
title: 'This message is too large',
body: 'The request was too big for the model. Compress the conversation or start a new chat, then send again.'
},
model_not_found: {
title: 'This model is not available',
body: provider => `${provider} does not offer this model on your account. Choose another model, then send your message again.`
},
provider_policy_blocked: {
title: 'This model is blocked by your account settings',
body: provider =>
`${provider} would not route this request under your account's data or privacy settings. Choose another model or switch provider.`
},
content_policy_blocked: {
title: 'The AI service declined this request',
body: provider => `${provider} would not answer this message. Edit it and send again.`
},
format_error: {
title: 'The AI service rejected the request',
body: provider =>
`${provider} did not accept how this request was built. Switch provider or send diagnostics so we can look into it.`
},
truncated: {
title: 'The reply was cut short',
body: 'The model stopped before finishing. Retry to get a complete reply.'
},
invalid_response: {
title: 'The AI service sent an unreadable reply',
body: provider => `${provider} returned something Hermes could not read. Retry in a moment.`
},
empty_response: {
title: 'The AI service sent an empty reply',
body: provider => `${provider} returned nothing for this message. Retry in a moment.`
},
loop_error: {
title: 'Hermes got stuck in a loop',
body: 'The reply kept repeating the same steps, so Hermes stopped it. Retry, or start a new chat if it happens again.'
},
SESSION_NOT_OWNED: {
title: 'This chat is open somewhere else',
body: 'This chat is currently open in another Hermes window or terminal. Close it there and send your message again, or start a new chat here.'
},
disk_full: {
title: 'Disk full',
body: 'Your disk is full, so Hermes could not save this conversation. Free some space, then retry.'
}
},
errorAuthKinds: {
api_key: {
title: provider => `${provider} rejected your API key`,
body: provider => `The key saved for ${provider} is invalid or was revoked. Update it, then retry.`
},
oauth: {
title: provider => `Your ${provider} sign-in expired`
}
},
errorDetails: 'Details',
errorGenericProvider: 'The AI service',
errorToastTitle: "Hermes couldn't finish the reply",
errorRetry: 'Retry',
errorStartNewSession: 'Start new session',
errorSwitchProvider: 'Switch provider',
errorChooseModel: 'Choose a model',
errorCompressConversation: 'Compress conversation',
errorCompressFailed: 'Could not compress the conversation',
errorOpenHermesFolder: 'Open Hermes folder',
errorOpenHermesFolderFailed: 'Could not open the Hermes folder',
errorUpdateApiKey: 'Update API key',
errorSignInAgain: provider => `Sign in to ${provider} again`,
errorOauthExpired: provider =>
`Your ${provider} sign-in has expired or was revoked. Sign in again to keep chatting.`,
@@ -3904,8 +4082,13 @@ export const en: Translations = {
attachingFile: 'Attaching…'
},
approval: {
gatewayDisconnected: 'Hermes gateway is not connected',
sendFailed: 'Could not send approval response',
gatewayDisconnected:
'Hermes is offline right now. The command is still waiting for your answer (until the approval timeout). Reconnect, then send it again.',
sendFailed: 'Could not send your answer',
reconnect: 'Reconnect',
timedOutSystemLine:
'Approval timed out — the command was not run. Ask Hermes to try again, or raise the limit in Settings → Safety → Approval timeout.',
openSafetySettings: 'Open Safety settings',
run: 'Run',
command: 'Command',
moreOptions: 'More approval options',
@@ -3920,7 +4103,7 @@ export const en: Translations = {
},
clarify: {
notReady: 'Clarify request is not ready yet',
gatewayDisconnected: 'Hermes gateway is not connected',
gatewayDisconnected: 'Hermes is offline right now. Reconnect, then send it again.',
sendFailed: 'Could not send clarify response',
loadingQuestion: 'Loading question…',
other: 'Other (type your answer)',
@@ -3951,7 +4134,7 @@ export const en: Translations = {
envRequired: 'Fill in the required credentials first',
sendFailed: 'Could not send MCP setup response',
reloadFailed: 'Server saved, but reloading MCP tools failed — they load next session',
gatewayDisconnected: 'Hermes gateway is not connected'
gatewayDisconnected: 'Hermes is offline right now. Reconnect, then send it again.'
},
tool: {
copyCode: 'Copy code',
@@ -4056,7 +4239,8 @@ export const en: Translations = {
},
prompts: {
gatewayDisconnected: 'Hermes gateway is not connected',
gatewayDisconnected: 'Hermes is offline right now. Reconnect, then send it again.',
reconnect: 'Reconnect',
sudoSendFailed: 'Could not send sudo password',
secretSendFailed: 'Could not send secret',
sudoTitle: 'Administrator password',
@@ -4129,7 +4313,7 @@ export const en: Translations = {
resumeStrandedBody:
'The connection to this session failed and automatic retries gave up. Check that the gateway is running, then try again.',
poolSlotTimeoutBody:
'All local profile backend slots are busy. Increase Warm Bot Backends in Settings → Advanced, or retry after an idle backend is evicted.',
"Too many bots are running at once for this computer's limit. Raise the limit in Settings → Advanced, or wait for one to finish and retry.",
poolSlotTimeoutOpenSettings: 'Open Advanced Settings',
resumeRetry: 'Retry',
nothingToBranch: 'Nothing to branch',
@@ -4173,7 +4357,8 @@ export const en: Translations = {
success: platform => `Handed off to ${platform}. Resume here anytime.`,
systemNote: platform => `↻ Handed off to ${platform} — resume here anytime.`,
failed: error => `Handoff failed: ${error}`,
timedOut: 'Timed out waiting for the gateway. Is `hermes gateway` running?'
timedOut: "Hermes couldn't reach your messaging connection. Start it from Settings → Messaging, then try the handoff again.",
startMessaging: 'Start messaging'
}
},
@@ -4233,6 +4418,8 @@ export const en: Translations = {
genericFailure: 'Something went wrong',
boundaryTitle: 'Something broke in the interface',
boundaryDesc: 'The view hit an unexpected error. Your chats and settings are safe.',
boundaryDetails: 'Details',
sendDiagnostics: 'Send diagnostics',
reloadWindow: 'Reload window',
openLogs: 'Open logs'
},
-1
View File
@@ -197,7 +197,6 @@ export const ja = defineLocale({
'デスクトップバックエンドがそのリクエストを拒否しました (405 Method Not Allowed)。Hermes Desktop を再起動してください。',
microphonePermission: 'マイクのアクセス許可が拒否されました。',
openaiRejectedApiKey: 'OpenAI が API キーを拒否しました。',
openaiRejectedApiKeyWithStatus: status => `OpenAI が API キーを拒否しました (${status} invalid_api_key)。`,
openaiTtsNeedsKey: 'OpenAI TTS には VOICE_TOOLS_OPENAI_KEY または OPENAI_API_KEY が必要です。',
codeSkewRestartRequired:
'アップデート後、このバックエンドは古いコードのままです。再起動して新しいコードを読み込んでください。'
+1 -2
View File
@@ -208,7 +208,6 @@ export const ru = defineLocale({
'Бэкенд приложения отклонил запрос (405 Method Not Allowed). Попробуйте перезапустить Hermes Desktop.',
microphonePermission: 'Доступ к микрофону запрещён.',
openaiRejectedApiKey: 'OpenAI отклонил API-ключ.',
openaiRejectedApiKeyWithStatus: status => `OpenAI отклонил API-ключ (${status} invalid_api_key).`,
openaiTtsNeedsKey: 'Для TTS OpenAI нужен VOICE_TOOLS_OPENAI_KEY или OPENAI_API_KEY.'
},
voice: {
@@ -476,7 +475,7 @@ export const ru = defineLocale({
desktopSuccess: name => `Плагин приложения ${name} установлен`,
agentFailed: 'Не удалось установить плагин агента',
desktopFailed: 'Не удалось установить плагин приложения',
missingEnv: vars => `Не хватает переменных окружения: ${vars}. Добавьте их в Настройки → Ключи.`
missingEnv: (_name, vars) => `Не хватает переменных окружения: ${vars}. Добавьте их в Настройки → Ключи.`
}
},
notifications: {
+98 -2
View File
@@ -5,10 +5,18 @@
// partial locales should use `defineLocale()` so missing desktop-only strings
// fall back to English while new keys remain type-checked.
import type { ErrorCodeKey } from '@/lib/error-surface'
import type { TipId } from '@/lib/tips/catalog'
export type Locale = 'en' | 'zh' | 'zh-hant' | 'ja' | 'ar' | 'ru'
/** One error-card entry: a short title and one plain sentence. Either may
* take the failing provider's display name (falls back to "the AI service"). */
export interface ErrorCardCopy {
title: string | ((provider: string) => string)
body: string | ((provider: string) => string)
}
export type ToolTitleKey =
| 'browser_click'
| 'browser_fill'
@@ -192,15 +200,30 @@ export interface Translations {
backgroundExited: string
backgroundExitedDuringStartup: string
backendStopped: string
restartHermes: string
openLogs: string
desktopBootFailed: string
gatewayConnectionLost: string
gatewayConnectionLostDetail: string
reconnectNow: string
connectionSettings: string
gatewaySignInRequired: string
gatewaySignInRequiredDetail: string
signInAgain: string
ipcBridgeUnavailable: string
}
causes: {
exitedEarly: string
timedOut: string
permission: string
diskFull: string
portInUse: string
installMissing: string
}
failure: {
title: string
description: string
details: string
remoteTitle: string
remoteDescription: string
retry: string
@@ -264,13 +287,20 @@ export interface Translations {
elevenLabsNeedsKey: string
elevenLabsRejectedKey: string
diskFull: string
storageFailure: string
gatewayAuthFailed: string
methodNotAllowed: string
microphonePermission: string
openaiRejectedApiKey: string
openaiRejectedApiKeyWithStatus: (status: string) => string
openaiTtsNeedsKey: string
codeSkewRestartRequired: string
restartHermesFailed: string
}
actions: {
restartHermes: string
openKeys: string
openGateways: string
openMaintenance: string
}
voice: {
configureSpeechToText: string
@@ -482,7 +512,7 @@ export interface Translations {
desktopSuccess: (name: string) => string
agentFailed: string
desktopFailed: string
missingEnv: (vars: string) => string
missingEnv: (name: string, vars: string) => string
}
}
vault: {
@@ -1355,6 +1385,8 @@ export interface Translations {
nousAuthDoneTitle: string
nousAuthDoneMessage: string
nousAuthFailed: string
nousAuthFailedMessage: string
nousAuthTryAgain: string
noApiKeyRequired: string
postSetupHint: (step: string) => string
postSetupInstalledHint: string
@@ -1367,6 +1399,8 @@ export interface Translations {
postSetupCompleteMessage: (step: string) => string
postSetupErrorTitle: string
postSetupErrorMessage: (step: string) => string
postSetupOpenLogs: string
postSetupRunAgain: string
postSetupFailed: (step: string) => string
webSearchActive: (backend: string) => string
webExtractActive: (backend: string) => string
@@ -1398,6 +1432,11 @@ export interface Translations {
selectedMessage: (backend: string) => string
failedSelect: (backend: string) => string
needsSetupHint: string
unavailableTitle: string
unavailableMessage: (backend: string) => string
openBackendSettings: string
useLocal: string
switchedToLocal: string
}
browserRealProfile: {
label: string
@@ -1550,6 +1589,10 @@ export interface Translations {
uninstallStarted: (name: string) => string
updateStarted: string
actionFailed: string
installBlockedTitle: (name: string) => string
installBlockedMessage: (findings: number, unverified: boolean) => string
viewScan: string
openLog: string
actionLog: string
alreadyInstalled: (name: string) => string
pickerTitle: string
@@ -1872,6 +1915,9 @@ export interface Translations {
restartNow: string
restarting: string
restartFailedManual: string
restartFailedManualDetail: string
restartAgain: string
openLogs: string
telegramQr: {
title: string
subtitle: string
@@ -2109,6 +2155,9 @@ export interface Translations {
search: string
loading: string
states: Record<string, string>
lastRunFailed: string
editJob: string
runAgain: string
deliveryLabels: Record<string, string>
scheduleLabels: Record<string, string>
scheduleHints: Record<string, string>
@@ -2718,6 +2767,8 @@ export interface Translations {
notAvailableTitle: string
unsupportedMessage: string
connectionRetry: string
connectionSettings: string
openDownloadPage: string
latestBody: string
latestBodyBackend: string
allSetTitle: string
@@ -2816,6 +2867,7 @@ export interface Translations {
remoteUrlPlaceholder: string
probing: string
probeError: string
probeErrorDetails: string
identityProvider: string
authTitle: string
authNeedsOauth: (provider: string) => string
@@ -2853,6 +2905,7 @@ export interface Translations {
copiedOutput: string
copyOutput: string
reloadRetry: string
openLogs: string
}
onboarding: {
@@ -2888,6 +2941,10 @@ export interface Translations {
connectedPicking: (provider: string) => string
signInFailed: string
signInExpired: string
signInDidNotFinish: (provider: string) => string
tryAgain: string
useApiKeyInstead: string
errorDetails: string
pickDifferentProvider: string
signInWith: (provider: string) => string
openedBrowser: (provider: string) => string
@@ -3393,10 +3450,42 @@ export interface Translations {
runtime: string
streaming: string
}
/** One plain sentence per layer — what happened and what to do — shown
* when the failure code has no dedicated entry in `errorCodes`. */
errorLayerBodies: {
auth: string
billing: string
disk: string
endpoint: string
gateway: string
generic: string
provider: string
runtime: string
streaming: string
}
/** Per failure code (agent/error_classifier.py FailoverReason values plus
* the gateway's site codes): a title and one plain sentence saying what
* happened and what to do. Function entries take the provider label. */
errorCodes: Record<ErrorCodeKey, ErrorCardCopy>
/** Auth layer, keyed on how the provider is credentialed. The OAuth
* body is `errorOauthExpired` (already translated per locale). */
errorAuthKinds: { api_key: ErrorCardCopy; oauth: Pick<ErrorCardCopy, 'title'> }
/** Collapsed "Details" line holding the raw provider/gateway text. */
errorDetails: string
/** Stands in for the provider name when the descriptor carries none. */
errorGenericProvider: string
/** Global toast title for a mid-turn gateway `error` event. */
errorToastTitle: string
errorRetry: string
/** Escape hatch when Retry would only reproduce SESSION_NOT_OWNED (#106217). */
errorStartNewSession: string
errorSwitchProvider: string
errorChooseModel: string
errorCompressConversation: string
errorCompressFailed: string
errorOpenHermesFolder: string
errorOpenHermesFolderFailed: string
errorUpdateApiKey: string
/** One-click recovery for an expired/revoked OAuth grant: re-runs that
* provider's sign-in flow (auth layer, authKind 'oauth'). */
errorSignInAgain: (provider: string) => string
@@ -3432,6 +3521,9 @@ export interface Translations {
approval: {
gatewayDisconnected: string
sendFailed: string
reconnect: string
timedOutSystemLine: string
openSafetySettings: string
run: string
command: string
moreOptions: string
@@ -3547,6 +3639,7 @@ export interface Translations {
prompts: {
gatewayDisconnected: string
reconnect: string
sudoSendFailed: string
secretSendFailed: string
sudoTitle: string
@@ -3656,6 +3749,7 @@ export interface Translations {
systemNote: (platform: string) => string
failed: (error: string) => string
timedOut: string
startMessaging: string
}
}
@@ -3674,6 +3768,8 @@ export interface Translations {
genericFailure: string
boundaryTitle: string
boundaryDesc: string
boundaryDetails: string
sendDiagnostics: string
reloadWindow: string
openLogs: string
}
-1
View File
@@ -191,7 +191,6 @@ export const zhHant = defineLocale({
methodNotAllowed: '桌面後端拒絕了該請求 (405 Method Not Allowed)。請嘗試重新啟動 Hermes Desktop。',
microphonePermission: '麥克風權限已被拒絕。',
openaiRejectedApiKey: 'OpenAI 拒絕了該 API 金鑰。',
openaiRejectedApiKeyWithStatus: status => `OpenAI 拒絕了該 API 金鑰 (${status} invalid_api_key)。`,
openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。',
codeSkewRestartRequired: '更新後此後端仍在執行舊程式碼。請重新啟動以載入新程式碼。'
},
+1 -2
View File
@@ -219,7 +219,6 @@ export const zh = defineLocale({
methodNotAllowed: '桌面后端拒绝了该请求 (405 Method Not Allowed)。请尝试重启 Hermes Desktop。',
microphonePermission: '麦克风权限已被拒绝。',
openaiRejectedApiKey: 'OpenAI 拒绝了该 API key。',
openaiRejectedApiKeyWithStatus: status => `OpenAI 拒绝了该 API key (${status} invalid_api_key)。`,
openaiTtsNeedsKey: 'OpenAI TTS 需要 VOICE_TOOLS_OPENAI_KEY 或 OPENAI_API_KEY。',
codeSkewRestartRequired: '更新后此后端仍在运行旧代码。请重启以加载新代码。'
},
@@ -598,7 +597,7 @@ export const zh = defineLocale({
desktopSuccess: name => `桌面插件 ${name} 已安装`,
agentFailed: '智能体插件安装失败',
desktopFailed: '桌面插件安装失败',
missingEnv: vars => `缺少环境变量:${vars}。请在设置 → 密钥中添加。`
missingEnv: (_name, vars) => `缺少环境变量:${vars}。请在设置 → 密钥中添加。`
}
},
notifications: {
@@ -0,0 +1,52 @@
// Resolves the error card's title/body for a failed turn from the i18n tables
// (`assistant.thread.errorCodes` / `errorAuthKinds` / `errorLayers`), so the
// inline card and the global gateway-error toast read the same words for the
// same failure. Sibling of error-surface.ts because i18n/types.ts imports the
// code list from there — importing Translations back would be a cycle.
import type { ErrorCardCopy, Translations } from '@/i18n/types'
import { errorCardKey, type ErrorSurface } from './error-surface'
export interface ErrorCardText {
title: string
body: string
}
const render = (value: ErrorCardCopy['title'], provider: string) =>
typeof value === 'function' ? value(provider) : value
/** The failing provider's display name for copy — the descriptor's label,
* else its id, else the generic "the AI service". */
export function errorProviderName(thread: Translations['assistant']['thread'], surface?: ErrorSurface | null): string {
return surface?.providerLabel || surface?.provider || thread.errorGenericProvider
}
export function errorCardText(
thread: Translations['assistant']['thread'],
surface: ErrorSurface | null | undefined
): ErrorCardText {
const provider = errorProviderName(thread, surface)
// A credential rejection is worded by HOW the provider is credentialed
// (key vs sign-in), which the code alone (`auth`) cannot tell.
if (surface?.layer === 'auth' && surface.authKind === 'oauth') {
return { body: thread.errorOauthExpired(provider), title: render(thread.errorAuthKinds.oauth.title, provider) }
}
if (surface?.layer === 'auth' && surface.authKind === 'api_key') {
const copy = thread.errorAuthKinds.api_key
return { body: render(copy.body, provider), title: render(copy.title, provider) }
}
const key = errorCardKey(surface)
if ('code' in key) {
const copy = thread.errorCodes[key.code]
return { body: render(copy.body, provider), title: render(copy.title, provider) }
}
return { body: thread.errorLayerBodies[key.layer], title: thread.errorLayers[key.layer] }
}
+55 -1
View File
@@ -1,6 +1,9 @@
import { describe, expect, it } from 'vitest'
import { formatErrorDiagnostics, parseErrorSurface } from './error-surface'
import { en } from '@/i18n/en'
import { ERROR_CODE_KEYS, errorRecoveryPlan, type ErrorSurface, formatErrorDiagnostics, parseErrorSurface } from './error-surface'
import { errorCardText } from './error-surface-copy'
describe('parseErrorSurface', () => {
it('accepts a valid descriptor', () => {
@@ -84,3 +87,54 @@ describe('formatErrorDiagnostics', () => {
expect(text.split('\n').every(line => line.trim().length > 0)).toBe(true)
})
})
// The card body and the buttons under it must agree: a body that says "retry"
// while the plan hides the Retry button leaves the user with an instruction
// they cannot follow. Walks every code the backend can send (plus the layer
// fallbacks) with the non-retryable verdict the classifier stamps for it.
describe('error copy never names a hidden Retry', () => {
const thread = en.assistant.thread
const RETRY_WORDS = /\bretry\b|\btry again\b/i
// Verdicts the classifier stamps as deterministic (agent/error_surface.py
// `_NON_RETRYABLE_REASONS`); everything else arrives retryable.
const NON_RETRYABLE = new Set([
'auth',
'auth_permanent',
'billing',
'content_policy_blocked',
'provider_policy_blocked',
'model_not_found',
'format_error',
'ssl_cert_verification',
'context_overflow',
'interpreter_shutdown'
])
const surfaces: ErrorSurface[] = [
...ERROR_CODE_KEYS.map(code => ({ code, layer: 'provider' as const, retryable: !NON_RETRYABLE.has(code) })),
{ code: 'auth', layer: 'auth', retryable: false },
{ code: 'auth_permanent', layer: 'auth', retryable: false },
{ code: 'ssl_cert_verification', layer: 'endpoint', retryable: false },
{ code: 'interpreter_shutdown', layer: 'gateway', retryable: false },
{ code: 'interpreter_shutdown', layer: 'runtime', retryable: false },
{ code: 'unknown', layer: 'endpoint', retryable: false }
]
it.each(surfaces.map(surface => [surface.code, surface.layer, surface] as const))(
'%s on %s',
(_code, _layer, surface) => {
const plan = errorRecoveryPlan(surface)
const { body } = errorCardText(thread, surface)
if (!plan.retry) {
expect(body).not.toMatch(RETRY_WORDS)
}
}
)
it('a credential rejection keeps Retry, so its body may still say retry', () => {
const surface: ErrorSurface = { authKind: 'api_key', code: 'auth', layer: 'auth', provider: 'openai', retryable: false }
expect(errorRecoveryPlan(surface).retry).toBe(true)
})
})
+131 -4
View File
@@ -1,7 +1,8 @@
// Structured turn-error descriptor forwarded by the gateway (see
// agent/error_surface.py). Names WHICH layer of the stack failed so the error
// card can say "Provider error" / "Gateway error" and offer layer-appropriate
// recovery actions, instead of toasting an opaque string.
// agent/error_surface.py). Names WHICH layer of the stack failed and the
// classifier's failure code so the error card can say what happened in plain
// words and offer code-appropriate recovery actions, instead of toasting an
// opaque string.
//
// Advisory contract: older backends never send this — every consumer must
// keep working when it is absent (legacy string-sniffing stays as fallback).
@@ -19,6 +20,37 @@ export const ERROR_SURFACE_LAYERS = [
export type ErrorSurfaceLayer = (typeof ERROR_SURFACE_LAYERS)[number]
/** Failure codes the error card has dedicated copy for. Values are what the
* backend stamps in `failure_reason` (agent/error_classifier.py
* FailoverReason) plus the gateway's own site codes (SESSION_NOT_OWNED,
* disk_full, stream_drop). Anything else falls back to the layer copy. */
export const ERROR_CODE_KEYS = [
'auth',
'auth_permanent',
'billing',
'rate_limit',
'upstream_rate_limit',
'overloaded',
'server_error',
'timeout',
'stream_drop',
'ssl_cert_verification',
'context_overflow',
'payload_too_large',
'model_not_found',
'provider_policy_blocked',
'content_policy_blocked',
'format_error',
'truncated',
'invalid_response',
'empty_response',
'loop_error',
'SESSION_NOT_OWNED',
'disk_full'
] as const
export type ErrorCodeKey = (typeof ERROR_CODE_KEYS)[number]
export interface ErrorSurface {
layer: ErrorSurfaceLayer
/** Specific failure code (a FailoverReason value or site-specific code). */
@@ -36,6 +68,10 @@ export interface ErrorSurface {
authKind?: 'api_key' | 'oauth'
/** Auth layer only: display name of the failing provider ("Nous Portal"). */
providerLabel?: string
/** Auth layer, api_key only: the env var holding the rejected key
* (OPENAI_API_KEY). Deep-links Settings → Keys to that row. Absent from
* older backends. */
apiKeyEnv?: string
}
/** Validate a wire payload into an ErrorSurface, or null when absent/garbled. */
@@ -45,6 +81,7 @@ export function parseErrorSurface(value: unknown): ErrorSurface | null {
}
const raw = value as {
api_key_env?: unknown
auth_kind?: unknown
code?: unknown
layer?: unknown
@@ -67,7 +104,8 @@ export function parseErrorSurface(value: unknown): ErrorSurface | null {
...(typeof raw.provider === 'string' && raw.provider ? { provider: raw.provider } : {}),
...(typeof raw.model === 'string' && raw.model ? { model: raw.model } : {}),
...(raw.auth_kind === 'oauth' || raw.auth_kind === 'api_key' ? { authKind: raw.auth_kind } : {}),
...(typeof raw.provider_label === 'string' && raw.provider_label ? { providerLabel: raw.provider_label } : {})
...(typeof raw.provider_label === 'string' && raw.provider_label ? { providerLabel: raw.provider_label } : {}),
...(typeof raw.api_key_env === 'string' && raw.api_key_env ? { apiKeyEnv: raw.api_key_env } : {})
}
}
@@ -80,6 +118,95 @@ export function isOAuthReauthSurface(surface: ErrorSurface | null | undefined):
return surface?.layer === 'auth' && surface.authKind === 'oauth' && Boolean(surface.provider)
}
/** True when the failed turn's provider rejected a saved API key — the fix is
* replacing the key in Settings → Keys, then retrying. */
export function isApiKeyRejectedSurface(surface: ErrorSurface | null | undefined): surface is ErrorSurface & {
authKind: 'api_key'
} {
return surface?.layer === 'auth' && surface.authKind === 'api_key'
}
/** Which copy entry the error card (and the matching toast) should read:
* the code table when the code is one we have words for, else the layer
* table, else `generic` (no descriptor — older backend). One resolver so the
* inline card and the global toast never disagree about the same failure. */
export type ErrorCardKey = { code: ErrorCodeKey } | { layer: 'generic' | ErrorSurfaceLayer }
export function errorCardKey(surface: ErrorSurface | null | undefined): ErrorCardKey {
if (!surface) {
return { layer: 'generic' }
}
return (ERROR_CODE_KEYS as readonly string[]).includes(surface.code)
? { code: surface.code as ErrorCodeKey }
: { layer: surface.layer }
}
/** Which recovery buttons the error card offers for a failure. Every flag
* maps to an EXISTING handler in the app; the card only decides visibility. */
export interface ErrorRecoveryPlan {
/** assistant-ui reload of the failed turn. */
retry: boolean
/** Open the model picker (model_not_found). */
chooseModel: boolean
/** Run /compress on this session (context too long). */
compress: boolean
/** requestFreshSession — when this session cannot continue as-is. */
startNewSession: boolean
/** Open the preceding user message in the edit composer (safety refusal). */
editMessage: boolean
/** Reveal the Hermes data folder so the user can free space (disk_full). */
openHermesFolder: boolean
/** Settings → Keys deep link (auth, api_key). */
updateApiKey: boolean
/** Re-run the provider's OAuth sign-in (auth, oauth). */
signInAgain: boolean
/** Settings → Models deep link. */
switchProvider: boolean
}
// Layers where the fix is provider/endpoint/auth config, not a retry.
const SWITCH_PROVIDER_LAYERS: readonly ErrorSurfaceLayer[] = ['auth', 'billing', 'endpoint', 'provider']
// Per-code overrides on top of the layer defaults. `retry: false` here means
// the classifier may call the failure retryable at the transport level, but
// an unchanged retry is known to reproduce it for the user (too long a
// conversation, a blocked prompt, a chat another surface owns).
const CODE_PLANS: Partial<Record<ErrorCodeKey, Partial<ErrorRecoveryPlan>>> = {
SESSION_NOT_OWNED: { retry: false, startNewSession: true },
content_policy_blocked: { editMessage: true, retry: false },
context_overflow: { compress: true, retry: false, startNewSession: true },
disk_full: { openHermesFolder: true, retry: true },
loop_error: { startNewSession: true },
model_not_found: { chooseModel: true, retry: false },
payload_too_large: { compress: true, retry: false, startNewSession: true }
}
export function errorRecoveryPlan(surface: ErrorSurface | null | undefined): ErrorRecoveryPlan {
const oauthReauth = isOAuthReauthSurface(surface)
const apiKeyRejected = isApiKeyRejectedSurface(surface)
const base: ErrorRecoveryPlan = {
chooseModel: false,
compress: false,
editMessage: false,
openHermesFolder: false,
// Retry re-runs the failed prompt in place. Suppressed when the classifier
// says the failure is deterministic — except for a credential rejection,
// where fixing the credential changes the outcome and Retry is the
// natural second click.
retry: !surface || surface.retryable || oauthReauth || apiKeyRejected,
signInAgain: oauthReauth,
startNewSession: false,
switchProvider: surface != null && SWITCH_PROVIDER_LAYERS.includes(surface.layer),
updateApiKey: apiKeyRejected
}
const key = errorCardKey(surface)
return { ...base, ...('code' in key ? CODE_PLANS[key.code] : undefined) }
}
/** Plain-text error-details blob for the error card's "Copy error details". */
export function formatErrorDiagnostics(input: {
appVersion?: string
@@ -0,0 +1,136 @@
/**
* notifyBotOpenFailure copy contract (desktop-34): every failed bot open
* toasts a plain-words title + next step from the plugin bundle, keeps the raw
* RPC/connection error in `detail` only, never says "gateway", and offers the
* Gateways settings tab as its action.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { RosterRow } from './types'
const { hostMock, pluginCtx } = vi.hoisted(() => ({
hostMock: { navigate: vi.fn(), notify: vi.fn(), notifyError: vi.fn(), openSession: vi.fn(), request: vi.fn() },
pluginCtx: { current: null as null | { i18n?: { t: (key: string, ...args: unknown[]) => string } } }
}))
vi.mock('@hermes/plugin-sdk', () => ({
BOT_CHAT_SESSION_HYDRATION_TIMEOUT_MS: 15_000,
host: hostMock,
usePluginI18n: () => (key: string) => key
}))
vi.mock('./routing', () => ({
backendTargetProfile: (route: { targetProfile?: string } | null, name: string) => route?.targetProfile ?? name,
botConnectionRoute: () => null,
botRosterMeta: () => ({}),
botWorkspaceOwnerKey: (bot: { name?: string } | null) => `bot:${bot?.name || 'default'}`,
requestForBot: vi.fn()
}))
vi.mock('./data', () => ({
$botMeta: { get: () => ({}), set: vi.fn() },
botMetaKey: (bot: { name?: string }) => bot?.name ?? '',
botOwner: (owner: RosterRow | string) =>
typeof owner === 'string'
? { bot: { name: owner }, key: owner, name: owner, route: null }
: { bot: owner, key: owner?.name, name: owner?.name, route: null },
persistBotMetaSnapshot: vi.fn(),
saveBotMeta: vi.fn()
}))
vi.mock('./shared', () => ({ getPluginCtx: () => pluginCtx.current }))
type Toast = {
kind?: string
title?: string
message: string
detail?: string
action?: { label: string; onClick: () => void }
}
const BOT = { connectionId: 'studio', connectionLabel: 'Studio Mac', name: 'ops' } as RosterRow
const lastToast = (): Toast => hostMock.notify.mock.calls[hostMock.notify.mock.calls.length - 1][0] as Toast
async function load() {
vi.resetModules()
return import('./canonical-chat')
}
beforeEach(() => {
vi.clearAllMocks()
pluginCtx.current = null
})
describe('notifyBotOpenFailure', () => {
it('needs-update: names the connection, keeps the raw RPC text in detail, offers Gateways', async () => {
const { notifyBotOpenFailure } = await load()
const raw = 'RPC -32601: method not found: bots.canonical'
notifyBotOpenFailure(new Error(raw), BOT, 'open')
const toast = lastToast()
expect(toast.kind).toBe('error')
expect(toast.title).toBe('This bot lives on an older Hermes')
expect(toast.message).toBe('Update Studio Mac, then try again.')
expect(toast.message).not.toContain(raw)
expect(toast.detail).toBe(raw)
expect(`${toast.title} ${toast.message}`).not.toMatch(/gateway|backend|worker/i)
expect(hostMock.notifyError).not.toHaveBeenCalled()
expect(toast.action?.label).toBe('Open Gateways')
toast.action?.onClick()
expect(hostMock.navigate).toHaveBeenCalledWith('/settings?tab=gateway')
})
it('unreachable: plain-words title and next step; the connection error is detail only', async () => {
const { notifyBotOpenFailure } = await load()
const raw = 'WebSocket connect ECONNREFUSED 10.0.0.7:8642'
notifyBotOpenFailure(new Error(raw), BOT, 'reach')
const toast = lastToast()
expect(toast.kind).toBe('error')
expect(toast.title).toBe('Hermes couldn’t reach the computer this bot runs on')
expect(toast.message).toBe('Check it is online and try again.')
expect(toast.message).not.toContain(raw)
expect(toast.detail).toBe(raw)
expect(`${toast.title} ${toast.message}`).not.toMatch(/gateway|backend|worker/i)
expect(toast.action?.label).toBe('Open Gateways')
toast.action?.onClick()
expect(hostMock.navigate).toHaveBeenCalledWith('/settings?tab=gateway')
expect(hostMock.notifyError).not.toHaveBeenCalled()
})
it('open step: names the bot, raw error in detail, same Gateways action', async () => {
const { notifyBotOpenFailure } = await load()
notifyBotOpenFailure(new Error('session.list timed out'), BOT, 'open', 'Ops Bot')
const toast = lastToast()
expect(toast.title).toBe('Could not open Ops Bot’s chat')
expect(toast.message).toBe('Try again.')
expect(toast.detail).toBe('session.list timed out')
expect(toast.action?.label).toBe('Open Gateways')
})
it('resolves copy through the plugin i18n bundle when one is registered', async () => {
pluginCtx.current = {
i18n: {
t: (key: string, ...args: unknown[]) =>
key === 'bot.openNeedsUpdateMessage' ? `[ja] update ${String(args[0])}` : `[ja] ${key}`
}
}
const { notifyBotOpenFailure } = await load()
notifyBotOpenFailure(new Error('unknown method'), BOT, 'open')
expect(lastToast()).toMatchObject({
title: '[ja] bot.openNeedsUpdateTitle',
message: '[ja] update Studio Mac',
action: { label: '[ja] bot.openGateways' }
})
})
})
@@ -12,6 +12,7 @@ import * as sdk from '@hermes/plugin-sdk'
import { host } from '@hermes/plugin-sdk'
import { $botMeta, botMetaKey, botOwner, persistBotMetaSnapshot } from './data'
import { botsText } from './i18n'
import { backendTargetProfile, botConnectionRoute, botRosterMeta, botWorkspaceOwnerKey, requestForBot } from './routing'
import type { RpcErrorLike } from './routing'
import { getPluginCtx } from './shared'
@@ -157,19 +158,63 @@ function botModeGatewayNeedsUpdate(error: unknown) {
return /(?:method not found|no handler for|unknown method|unsupported rpc)/i.test(message)
}
export function notifyBotOpenFailure(error: unknown, bot: RosterRow, fallbackMessage: string) {
/** The one deep link to the Gateways settings tab (the route
* profile-switcher.tsx reaches via SETTINGS_ROUTE; plugins don't import app
* routes, so the literal lives here). */
const GATEWAY_SETTINGS_PATH = '/settings?tab=gateway'
/** Raw error text for the toast's muted `detail` line — never the body. */
function errorDetail(error: unknown): string | undefined {
const text = String((error as RpcErrorLike)?.message || error || '').trim()
return text || undefined
}
/** What the caller was doing when the open failed: `'reach'` — activating
* the bot's connection (a failure here means the computer the bot runs on
* could not be reached); `'open'` — resolving/opening the forever-chat. */
export type BotOpenStep = 'open' | 'reach'
/** Toast a failed bot open. Titles and bodies come from the plugin bundle and
* say what happened + what to do; the raw RPC/connection error only ever
* rides in `detail`. Every toast offers the Gateways settings tab. */
export function notifyBotOpenFailure(error: unknown, bot: RosterRow, step: BotOpenStep, botName?: string) {
const b = botsText().bot
const action = { label: b.openGateways, onClick: () => host.navigate(GATEWAY_SETTINGS_PATH) }
const detail = errorDetail(error)
if (botModeGatewayNeedsUpdate(error)) {
const gateway = bot.connectionLabel || bot.connectionId || 'this gateway'
const connectionLabel = bot.connectionLabel || bot.connectionId || 'Hermes'
host.notify?.({
kind: 'error',
title: 'Update this gateway to use Bot Mode',
message: `Update ${gateway}, then try again.`
title: b.openNeedsUpdateTitle,
message: b.openNeedsUpdateMessage(connectionLabel),
...(detail ? { detail } : {}),
action
})
return
}
host.notifyError?.(error, fallbackMessage)
if (step === 'reach') {
host.notify?.({
kind: 'error',
title: b.openUnreachableTitle,
message: b.openUnreachableMessage,
...(detail ? { detail } : {}),
action
})
return
}
host.notify?.({
kind: 'error',
title: b.openChatFailedTitle(botName || bot.name),
message: b.openChatFailedMessage,
...(detail ? { detail } : {}),
action
})
}
/** THE identity lookup: the profile's session titled exactly "Bot Chat",
@@ -118,6 +118,15 @@ type BotsMessages = {
advancedFailed: string
openAnotherChatUnsupported: string
remoteConnectionsUnsupported: string
/** Bot-open failure toasts (canonical-chat.ts notifyBotOpenFailure). The
* raw RPC/connection error travels in the toast `detail`, never here. */
openNeedsUpdateTitle: string
openNeedsUpdateMessage: (connectionLabel: string) => string
openUnreachableTitle: string
openUnreachableMessage: string
openChatFailedTitle: (botName: string) => string
openChatFailedMessage: string
openGateways: string
/** Stands under the bot's name in a chat it has not spoken in yet. */
chatEmpty: string
/** First line of a brand-new bot's forever-chat — see `kickoffText`. */
@@ -348,6 +357,13 @@ const en: BotsMessages = {
advancedFailed: 'Advanced configuration failed',
openAnotherChatUnsupported: 'Update Hermes Desktop to open another Bot chat.',
remoteConnectionsUnsupported: 'Update Hermes Desktop to chat with bots on other connections.',
openNeedsUpdateTitle: 'This bot lives on an older Hermes',
openNeedsUpdateMessage: connectionLabel => `Update ${connectionLabel}, then try again.`,
openUnreachableTitle: 'Hermes couldn’t reach the computer this bot runs on',
openUnreachableMessage: 'Check it is online and try again.',
openChatFailedTitle: botName => `Could not open ${botName}’s chat`,
openChatFailedMessage: 'Try again.',
openGateways: 'Open Gateways',
chatEmpty: 'Say something to get started.',
kickoff: 'Hey, tell me about yourself!'
},
@@ -567,6 +583,13 @@ const ja: BotsMessages = {
advancedFailed: '詳細設定に失敗しました',
openAnotherChatUnsupported: '別のボットチャットを開くには Hermes Desktop を更新してください。',
remoteConnectionsUnsupported: '他の接続上のボットとチャットするには Hermes Desktop を更新してください。',
openNeedsUpdateTitle: 'このボットは古い Hermes 上で動いています',
openNeedsUpdateMessage: connectionLabel => `${connectionLabel} を更新してから、もう一度お試しください。`,
openUnreachableTitle: 'このボットが動いているコンピューターに Hermes が接続できませんでした',
openUnreachableMessage: 'オンラインか確認して、もう一度お試しください。',
openChatFailedTitle: botName => `${botName} のチャットを開けませんでした`,
openChatFailedMessage: 'もう一度お試しください。',
openGateways: 'ゲートウェイを開く',
chatEmpty: '何か書いて始めましょう。',
kickoff: 'こんにちは、自己紹介をしてください!'
},
@@ -782,6 +805,13 @@ const zh: BotsMessages = {
advancedFailed: '高级配置失败',
openAnotherChatUnsupported: '请更新 Hermes Desktop 以打开另一个机器人聊天。',
remoteConnectionsUnsupported: '请更新 Hermes Desktop 以与其他连接上的机器人聊天。',
openNeedsUpdateTitle: '这个机器人运行在较旧的 Hermes 上',
openNeedsUpdateMessage: connectionLabel => `请更新 ${connectionLabel},然后重试。`,
openUnreachableTitle: 'Hermes 无法连接到运行这个机器人的电脑',
openUnreachableMessage: '请确认它在线后重试。',
openChatFailedTitle: botName => `无法打开 ${botName} 的聊天`,
openChatFailedMessage: '请重试。',
openGateways: '打开网关',
chatEmpty: '说点什么开始吧。',
kickoff: '你好,介绍一下你自己吧!'
},
@@ -996,6 +1026,13 @@ const zhHant: BotsMessages = {
advancedFailed: '進階設定失敗',
openAnotherChatUnsupported: '請更新 Hermes Desktop 以開啟另一個機器人聊天。',
remoteConnectionsUnsupported: '請更新 Hermes Desktop 以與其他連線上的機器人聊天。',
openNeedsUpdateTitle: '這個機器人運行在較舊的 Hermes 上',
openNeedsUpdateMessage: connectionLabel => `請更新 ${connectionLabel},然後再試一次。`,
openUnreachableTitle: 'Hermes 無法連線到運行這個機器人的電腦',
openUnreachableMessage: '請確認它在線上後再試一次。',
openChatFailedTitle: botName => `無法開啟 ${botName} 的聊天`,
openChatFailedMessage: '請再試一次。',
openGateways: '開啟閘道',
chatEmpty: '說點什麼開始吧。',
kickoff: '你好,介紹一下你自己吧!'
},
@@ -265,7 +265,7 @@ export async function openRosterBot(bot: RosterRow): Promise<boolean> {
if (generation === getBotOpenGeneration()) {
$openBotChat.set(null)
restorePreviousGroup()
notifyBotOpenFailure(error, bot, `Could not reach ${bot.connectionLabel || 'the gateway'}`)
notifyBotOpenFailure(error, bot, 'reach')
}
return false
@@ -302,7 +302,7 @@ export async function openRosterBot(bot: RosterRow): Promise<boolean> {
if (generation === getBotOpenGeneration()) {
$openBotChat.set(null)
restorePreviousGroup()
notifyBotOpenFailure(error, bot, `Could not open ${displayName(bot, meta)}'s chat — try again`)
notifyBotOpenFailure(error, bot, 'open', displayName(bot, meta))
}
return false
@@ -442,12 +442,19 @@ describe('terminal kinds beyond completed', () => {
})
})
it('gave_up carries the payload error; crashed and timed_out fall back to the task id', async () => {
it('gave_up: plain-words body, raw payload error only in detail; crashed and timed_out fall back to the task id', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never)
await m.onKanbanEventsFrame('smoke', [ev(101, 'gave_up', { error: 'spawn failed' })])
expect(lastNotify()).toMatchObject({ kind: 'error', message: 'spawn failed' })
await m.onKanbanEventsFrame('smoke', [ev(101, 'gave_up', { error: 'spawn failed: ECONNREFUSED 127.0.0.1:9999' })])
const gaveUp = lastNotify()
expect(gaveUp.kind).toBe('error')
expect(gaveUp.title).toBe('Task stopped')
expect(gaveUp.message).toBe('Hermes couldn’t finish this task. Open Kanban to see why and reassign it.')
expect(gaveUp.message).not.toContain('spawn failed')
expect(gaveUp.detail).toContain('spawn failed: ECONNREFUSED 127.0.0.1:9999')
expect(gaveUp.detail).toContain('t101')
expect(gaveUp.action?.label).toBe('Open Kanban')
await m.onKanbanEventsFrame('smoke', [ev(102, 'crashed'), ev(103, 'timed_out', { limit_seconds: 900 })])
expect(hostMock.notify).toHaveBeenCalledTimes(3)
@@ -455,6 +462,31 @@ describe('terminal kinds beyond completed', () => {
expect(hostMock.notify.mock.calls[2][0]).toMatchObject({ kind: 'warning', message: 't103' })
})
it('gave_up without a payload error still gets the plain-words body and no empty detail noise', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never)
await m.onKanbanEventsFrame('smoke', [ev(101, 'gave_up', null)])
expect(lastNotify()).toMatchObject({
kind: 'error',
message: 'Hermes couldn’t finish this task. Open Kanban to see why and reassign it.',
detail: 't101'
})
})
it('retrying kinds (crashed/timed_out) say Hermes will retry and never expose worker/gateway vocabulary', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never)
await m.onKanbanEventsFrame('smoke', [ev(101, 'crashed'), ev(102, 'timed_out', { limit_seconds: 900 })])
for (const call of hostMock.notify.mock.calls) {
const toast = call[0] as NotifyInput
expect(toast.title).toMatch(/Hermes will retry it automatically/)
expect(`${toast.title} ${toast.message}`).not.toMatch(/worker|gateway|backend/i)
}
})
it('silent kinds (status/archived/unblocked) advance the cursor but never notify', async () => {
const m = await loadModule()
m.bindCompletionNotify(makeRest(() => 100) as never)
@@ -554,6 +586,6 @@ describe('i18n routing', () => {
await m.onKanbanEventsFrame('smoke', [ev(101, 'timed_out')])
expect(lastNotify().title).toBe('Task timed out — will retry')
expect(lastNotify().title).toBe('Task took too long — Hermes will retry it automatically')
})
})
@@ -116,7 +116,9 @@ function trimmed(value: unknown): string {
}
/** The human handoff carried in the event payload, per kind (mirrors the
* payload contract the gateway watcher reads). */
* payload contract the gateway watcher reads). `gave_up` deliberately has no
* payload body: its `error` is raw worker text, which belongs in the toast
* `detail` (see rawErrorFor), and the body is the plain-words i18n hint. */
function bodyFor(kind: string, ev: CompletionEvent): string {
const payload = ev.payload
@@ -129,12 +131,17 @@ function bodyFor(kind: string, ev: CompletionEvent): string {
}
if (kind === 'gave_up') {
return trimmed(payload?.error)
return t('notify.gaveUpBody')
}
return ''
}
/** Raw machine text that must never be the toast body — surfaced muted in `detail`. */
function rawErrorFor(kind: string, ev: CompletionEvent): string {
return kind === 'gave_up' ? trimmed(ev.payload?.error) : ''
}
function notifyOne(kind: string, spec: { titleKey: string; toast: ToastKind }, ev: CompletionEvent): void {
const taskId = (ev.task_id ?? '').trim()
const body = bodyFor(kind, ev)
@@ -153,7 +160,7 @@ function notifyOne(kind: string, spec: { titleKey: string; toast: ToastKind }, e
? t('notify.artifacts', artifacts.length)
: ''
const detail = [taskId, artifactText].filter(Boolean).join(' · ')
const detail = [taskId, artifactText, rawErrorFor(kind, ev)].filter(Boolean).join(' · ')
const title = t(spec.titleKey)
const message = body || taskId || title
host.notify({
+22 -16
View File
@@ -204,6 +204,8 @@ type KanbanMessages = {
blockedTitle: string
blockLoopTitle: string
gaveUpTitle: string
/** Body for gave_up — the raw worker error rides in the toast `detail`. */
gaveUpBody: string
crashedTitle: string
timedOutTitle: string
openKanban: string
@@ -414,10 +416,11 @@ export const en: KanbanMessages = {
notify: {
completedTitle: 'Task completed',
blockedTitle: 'Task blocked — needs your input',
blockLoopTitle: 'Task routed to triage — orchestration attention needed',
gaveUpTitle: 'Task gave up',
crashedTitle: 'Worker crashed — will retry',
timedOutTitle: 'Task timed out — will retry',
blockLoopTitle: 'Task routed to triage — needs a decision',
gaveUpTitle: 'Task stopped',
gaveUpBody: 'Hermes couldn’t finish this task. Open Kanban to see why and reassign it.',
crashedTitle: 'Task hit a problem — Hermes will retry it automatically',
timedOutTitle: 'Task took too long — Hermes will retry it automatically',
openKanban: 'Open Kanban',
artifacts: (n: number) => `${n} artifacts`
}
@@ -625,10 +628,11 @@ const ja: KanbanMessages = {
notify: {
completedTitle: 'タスク完了',
blockedTitle: 'タスクがブロック中 — 入力が必要です',
blockLoopTitle: 'タスクをトリアージへ移動 — オーケストレーションの確認が必要です',
gaveUpTitle: 'タスクを断念しました',
crashedTitle: 'ワーカーがクラッシュ — 再試行します',
timedOutTitle: 'タスクがタイムアウト — 再試行します',
blockLoopTitle: 'タスクをトリアージへ移動 — 判断が必要です',
gaveUpTitle: 'タスクが停止しました',
gaveUpBody: 'Hermes はこのタスクを完了できませんでした。かんばんを開いて原因を確認し、再割り当てしてください。',
crashedTitle: 'タスクで問題が発生 — Hermes が自動で再試行します',
timedOutTitle: 'タスクに時間がかかりすぎました — Hermes が自動で再試行します',
openKanban: 'かんばんを開く',
artifacts: (n: number) => `成果物 ${n} 件`
}
@@ -833,10 +837,11 @@ const zh: KanbanMessages = {
notify: {
completedTitle: '任务已完成',
blockedTitle: '任务受阻 — 需要你的输入',
blockLoopTitle: '任务已转入分类 — 需要编排关注',
gaveUpTitle: '任务已放弃',
crashedTitle: '工作单元崩溃 — 将重试',
timedOutTitle: '任务超时 — 将重试',
blockLoopTitle: '任务已转入分类 — 需要人工决定',
gaveUpTitle: '任务已停止',
gaveUpBody: 'Hermes 无法完成这个任务。打开看板查看原因并重新分配。',
crashedTitle: '任务遇到问题 — Hermes 将自动重试',
timedOutTitle: '任务耗时过长 — Hermes 将自动重试',
openKanban: '打开看板',
artifacts: (n: number) => `${n} 个产物`
}
@@ -1041,10 +1046,11 @@ const zhHant: KanbanMessages = {
notify: {
completedTitle: '任務已完成',
blockedTitle: '任務受阻 — 需要你的輸入',
blockLoopTitle: '任務已轉入分類 — 需要編排關注',
gaveUpTitle: '任務已放棄',
crashedTitle: '工作單元當機 — 將重試',
timedOutTitle: '任務逾時 — 將重試',
blockLoopTitle: '任務已轉入分類 — 需要人工決定',
gaveUpTitle: '任務已停止',
gaveUpBody: 'Hermes 無法完成這個任務。開啟看板查看原因並重新指派。',
crashedTitle: '任務遇到問題 — Hermes 將自動重試',
timedOutTitle: '任務耗時過長 — Hermes 將自動重試',
openKanban: '開啟看板',
artifacts: (n: number) => `${n} 個產物`
}
@@ -1,3 +1,5 @@
import { translateNow } from '@/i18n'
type GatewayReconnectHandler = () => Promise<void> | void
let activeHandler: GatewayReconnectHandler | null = null
@@ -32,3 +34,12 @@ export function reconnectGateway(): Promise<void> {
return inFlight
}
/** Toast button that re-dials the active connection — attached wherever a
* send fails because Hermes is offline (sudo/secret/approval prompts …). */
export function reconnectAction(): { label: string; onClick: () => void } {
return {
label: translateNow('prompts.reconnect'),
onClick: () => void reconnectGateway().catch(() => undefined)
}
}
@@ -0,0 +1,79 @@
import { beforeEach, expect, test, vi } from 'vitest'
vi.mock('@/hermes', () => ({
getActionStatus: vi.fn(),
installSkillFromHub: vi.fn(),
scanSkillHub: vi.fn(),
uninstallSkillFromHub: vi.fn(),
updateSkillsFromHub: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
vi.mock('@/lib/slash-completion-cache', () => ({ invalidateSlashCompletions: vi.fn() }))
vi.mock('@/store/activity', () => ({ upsertDesktopActionTask: vi.fn() }))
vi.mock('@/store/profile', () => ({
$activeGatewayProfile: { subscribe: vi.fn(), get: () => 'default' },
normalizeProfileKey: (v: string | null | undefined) => v || 'default'
}))
import { HubInstallBlockedError, notifyHubActionFailed, parseInstallBlocked } from './hub-actions'
import { $notifications, clearNotifications } from './notifications'
beforeEach(() => clearNotifications())
// The CLI's blocked-install tail is the only signal the Desktop gets; the toast
// must turn it into a cause and a next step — `--force` does not exist on the
// Desktop route, so it must never be the remedy shown.
// Current CLI (`hermes_cli/skills_hub.py::_scan_block_message`): "Not installed:"
// label + plain sentence; the "never installs unverified" clause marks the
// hard-block (unverified source) case.
test('the current CLI "Not installed:" tail parses into findings + trust', () => {
const hardBlock = [
'Scan: 3 findings. Verdict: DANGEROUS',
"Not installed: the security scan found 3 high-risk pattern(s) in 'org/skill' (listed above). " +
'Hermes never installs unverified skills with high-risk findings, even with --force. ' +
'Review the findings or ask the author to fix them; to read the skill without installing, run `hermes skills inspect org/skill`.'
]
expect(parseInstallBlocked(hardBlock)).toEqual({ findings: 3, unverified: true })
// Console wrapping can split the sentence across log lines.
const wrapped = [
'Not installed: the security scan found 1',
"high-risk pattern(s) in 'org/skill' (listed above). Re-run with --force to install anyway."
]
expect(parseInstallBlocked(wrapped)).toEqual({ findings: 1, unverified: false })
// No count in the sentence when the scanner reported a verdict without findings.
const uncounted = ["Not installed: the security scan found high-risk patterns in 'org/skill' (listed above). Re-run with --force to install anyway."]
expect(parseInstallBlocked(uncounted)).toEqual({ findings: 0, unverified: false })
})
test('the legacy "Installation blocked:" tail still parses and toasts a plain explanation', () => {
const lines = [
'Quarantined to quarantine/abc',
'Scan: 2 findings. Verdict: CAUTION',
'Installation blocked: Blocked (community source + caution verdict, 2 findings). Use --force to override.'
]
expect(parseInstallBlocked(lines)).toEqual({ findings: 2, unverified: true })
expect(parseInstallBlocked(['Failed to spawn skills install'])).toBeNull()
notifyHubActionFailed(new HubInstallBlockedError('org/skill', 2, true, lines.join('\n')), 'Skill action failed', 'skill')
const toast = $notifications.get()[0]
expect(toast?.title).toMatch(/Couldn't install skill/)
expect(toast?.message).toMatch(/2 items to review/)
expect(toast?.message).toMatch(/unverified source/)
expect(toast?.message).not.toMatch(/--force/)
expect(toast?.action?.label).toBe('View scan')
expect(toast?.detail).toContain('Installation blocked')
})
test('a non-block failure keeps the generic summary', () => {
notifyHubActionFailed(new Error('network down'), 'Skill action failed')
expect($notifications.get()[0]?.title).toBe('Skill action failed')
expect($notifications.get()[0]?.message).toBe('network down')
})
+97 -1
View File
@@ -4,12 +4,15 @@ import {
getActionStatus,
installSkillFromHub,
type ProfileScope,
scanSkillHub,
uninstallSkillFromHub,
updateSkillsFromHub
} from '@/hermes'
import { translateNow } from '@/i18n'
import { queryClient } from '@/lib/query-client'
import { invalidateSlashCompletions } from '@/lib/slash-completion-cache'
import { upsertDesktopActionTask } from '@/store/activity'
import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
const POLL_MS = 1200
@@ -134,7 +137,14 @@ async function runHubAction(
// the unchanged skills list as "install did nothing" (Aug 2026 report).
// The last log lines carry the subprocess's actual error.
if (exitCode !== null && exitCode !== 0) {
const detail = ($hubActions.get()[key]?.lines ?? []).slice(-3).join('\n').trim()
const lines = $hubActions.get()[key]?.lines ?? []
const blocked = parseInstallBlocked(lines)
if (blocked) {
throw new HubInstallBlockedError(key, blocked.findings, blocked.unverified, lines.slice(-3).join('\n').trim())
}
const detail = lines.slice(-3).join('\n').trim()
throw new Error(detail || `Action exited with code ${exitCode}`)
}
@@ -174,3 +184,89 @@ export function updateHubSkills(profile?: ProfileScope): Promise<void> {
export function closeHubLog(): void {
$hubActiveLog.set(null)
}
// `hermes skills install` exits non-zero when the security scan gate refuses,
// and its printed tail is the only signal the Desktop gets, so parse it into a
// structured failure the toast can explain (tools-runtime-21). `--force` has
// no Desktop route, so the remedy offered is reading the scan, not overriding
// it. Two CLI shapes exist (`hermes_cli/skills_hub.py::_scan_block_message`):
// current: "Not installed: the security scan found 2 high-risk pattern(s) in
// 'org/skill' (listed above). Hermes never installs unverified
// skills with high-risk findings, even with --force. ..."
// (the "never installs unverified" sentence only appears for a
// non-official source; otherwise it says "Re-run with --force").
// legacy: "Installation blocked: Blocked (community source + caution
// verdict, 2 findings). Use --force to override."
// The console may wrap the sentence, so whitespace between words is `\s+`.
const INSTALL_BLOCKED_CURRENT_RE =
/Not installed:\s+the security scan found\s+(?:(?<findings>\d+)\s+)?high-risk\s+pattern/i
const INSTALL_BLOCKED_UNVERIFIED_RE = /never installs\s+unverified/i
const INSTALL_BLOCKED_LEGACY_RE =
/Installation blocked:.*?\((?<source>[a-z_-]+) source \+ (?<verdict>[a-z_]+) verdict, (?<findings>\d+) findings?\)/i
export function parseInstallBlocked(lines: readonly string[]): { findings: number; unverified: boolean } | null {
const text = lines.join('\n')
const current = text.match(INSTALL_BLOCKED_CURRENT_RE)
if (current?.groups) {
return {
findings: current.groups.findings ? Number(current.groups.findings) : 0,
unverified: INSTALL_BLOCKED_UNVERIFIED_RE.test(text)
}
}
const legacy = text.match(INSTALL_BLOCKED_LEGACY_RE)
if (!legacy?.groups) {
return null
}
return { findings: Number(legacy.groups.findings), unverified: legacy.groups.source !== 'official' }
}
export class HubInstallBlockedError extends Error {
constructor(
readonly identifier: string,
readonly findings: number,
readonly unverified: boolean,
detail: string
) {
super(detail)
this.name = 'HubInstallBlockedError'
}
}
/** Toast for a failed hub action: a blocked install explains the scan gate and
* offers "View scan"; anything else keeps the generic summary + raw tail. */
export function notifyHubActionFailed(err: unknown, fallbackTitle: string, skillName?: string, profile?: ProfileScope): void {
if (!(err instanceof HubInstallBlockedError)) {
notifyError(err, fallbackTitle)
return
}
const name = skillName || err.identifier
notify({
kind: 'error',
title: translateNow('skills.hub.installBlockedTitle', name),
message: translateNow('skills.hub.installBlockedMessage', err.findings, err.unverified),
detail: err.message || undefined,
action: {
label: translateNow('skills.hub.viewScan'),
onClick: () =>
void scanSkillHub(err.identifier, typeof profile === 'object' ? profile?.profile : profile)
.then(scan =>
notify({
kind: 'warning',
title: translateNow('skills.hub.installBlockedTitle', name),
message: scan.summary,
detail: scan.findings.map(f => `${f.severity}: ${f.description}`).join('\n') || undefined
})
)
.catch(scanErr => notifyError(scanErr, translateNow('skills.hub.scanFailed')))
}
})
}
+50 -9
View File
@@ -1,6 +1,9 @@
import { beforeEach, expect, test } from 'vitest'
import { en } from '@/i18n/en'
import { $notifications, clearNotifications, isDiskFullErrorMessage, notifyError } from './notifications'
import { $backendRestartRequest, $routeRequest } from './recovery-requests'
beforeEach(() => {
clearNotifications()
@@ -11,8 +14,9 @@ function lastMessage(): string {
}
// Regression for #39365: a gateway auth 401 (bad API_SERVER_KEY) must not be
// summarized as a provider (OpenAI/OpenRouter) API key problem.
test('gateway_auth_failed error is summarized as gateway auth, not provider key', () => {
// summarized as a provider (OpenAI/OpenRouter) API key problem. The toast says
// "sign in again" in plain words and opens Gateways — no env-var names.
test('gateway_auth_failed error is summarized as sign-in, with an Open Gateways action', () => {
notifyError(
new Error(
'401 {"error": {"message": "Invalid gateway API key (API_SERVER_KEY)", "type": "gateway_auth_error", "code": "gateway_auth_failed"}}'
@@ -20,17 +24,51 @@ test('gateway_auth_failed error is summarized as gateway auth, not provider key'
'Request failed'
)
expect(lastMessage()).toContain('API_SERVER_KEY')
expect(lastMessage()).not.toMatch(/OpenAI/i)
expect(lastMessage()).toMatch(/sign in again/i)
expect(lastMessage()).not.toMatch(/API_SERVER_KEY|OpenAI|authentication failed/i)
const action = $notifications.get()[0]?.action
expect(action?.label).toBe(en.notifications.actions.openGateways)
action?.onClick()
expect($routeRequest.get()?.path).toBe('/settings?tab=gateway')
})
test('provider invalid_api_key error still maps to the OpenAI summary', () => {
test('provider invalid_api_key error maps to the OpenAI summary and deep-links to Keys', () => {
notifyError(
new Error('401 {"error": {"message": "Incorrect API key provided", "code": "invalid_api_key"}}'),
'Request failed'
)
expect(lastMessage()).toMatch(/OpenAI rejected the API key/i)
expect(lastMessage()).toMatch(/OpenAI didn't accept your API key/i)
expect(lastMessage()).not.toMatch(/401|invalid_api_key/)
$notifications.get()[0]?.action?.onClick()
expect($routeRequest.get()?.path).toBe('/settings?tab=keys&key=OPENAI_API_KEY')
})
test('ELEVENLABS_API_KEY not set toasts plain copy with an Open Keys action for that key', () => {
notifyError(new Error('ELEVENLABS_API_KEY not set'), 'Voice failed')
expect(lastMessage()).not.toMatch(/ELEVENLABS_API_KEY|STT/)
$notifications.get()[0]?.action?.onClick()
expect($routeRequest.get()?.path).toBe('/settings?tab=keys&key=ELEVENLABS_API_KEY')
})
test('structured storage_* error codes route to Maintenance', () => {
notifyError(new Error('500 {"detail":{"message":"database is locked","code":"storage_locked"}}'), 'Prompt failed')
expect(lastMessage()).toMatch(/data folder/i)
$notifications.get()[0]?.action?.onClick()
expect($routeRequest.get()?.path).toBe('/command-center?section=maintenance')
})
test('405 method-not-allowed toasts a restart in plain words with a Restart Hermes action', () => {
const before = $backendRestartRequest.get()
notifyError(new Error('405 Method Not Allowed'), 'Request failed')
expect(lastMessage()).not.toMatch(/405|Method Not Allowed|backend/i)
expect($notifications.get()[0]?.action?.label).toBe(en.notifications.actions.restartHermes)
$notifications.get()[0]?.action?.onClick()
expect($backendRestartRequest.get()).toBe(before + 1)
})
test('disk-full / ENOSPC errors toast a free-space message', () => {
@@ -64,7 +102,10 @@ test('code-skew 503 unwraps to a restart-required summary, not raw IPC JSON', ()
'Could not load models'
)
expect(lastMessage()).toMatch(/running old code after an update/i)
expect(lastMessage()).not.toMatch(/hermes:api/)
expect(lastMessage()).not.toMatch(/systemctl/)
expect(lastMessage()).toMatch(/still running the old version/i)
expect(lastMessage()).not.toMatch(/hermes:api|systemctl|backend/i)
const before = $backendRestartRequest.get()
expect($notifications.get()[0]?.action?.label).toBe(en.notifications.actions.restartHermes)
$notifications.get()[0]?.action?.onClick()
expect($backendRestartRequest.get()).toBe(before + 1)
})
+68 -20
View File
@@ -2,6 +2,7 @@ import { atom } from 'nanostores'
import { translateNow } from '@/i18n'
import { isLocalBackendSlotWaitTimeout, requestPoolLimitsSettings } from '@/store/pool-limits'
import { requestBackendRestart, requestRoute } from '@/store/recovery-requests'
export type NotificationKind = 'error' | 'warning' | 'info' | 'success'
@@ -92,43 +93,86 @@ export function isDiskFullErrorMessage(message: string): boolean {
)
}
const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string) => string }[] = [
/** Settings deep links the summariser can attach to a toast. */
const KEYS_ROUTE = (envKey: string) => `/settings?tab=keys&key=${encodeURIComponent(envKey)}`
const GATEWAY_SETTINGS_ROUTE = '/settings?tab=gateway'
const MAINTENANCE_ROUTE = '/command-center?section=maintenance'
/** One-click recoveries reused by several rules. */
export const RECOVERY_ACTIONS = {
restartHermes: (): NotificationAction => ({
label: translateNow('notifications.actions.restartHermes'),
onClick: requestBackendRestart
}),
openKeys: (envKey: string): NotificationAction => ({
label: translateNow('notifications.actions.openKeys'),
onClick: () => requestRoute(KEYS_ROUTE(envKey))
}),
openGateways: (): NotificationAction => ({
label: translateNow('notifications.actions.openGateways'),
onClick: () => requestRoute(GATEWAY_SETTINGS_ROUTE)
}),
openMaintenance: (): NotificationAction => ({
label: translateNow('notifications.actions.openMaintenance'),
onClick: () => requestRoute(MAINTENANCE_ROUTE)
})
}
/** Structured storage failure codes the backend puts in RPC/HTTP error data
* (`hermes_state_errors.classify_persistence_error`). */
const STORAGE_CODE_RE = /['"]code['"]\s*:\s*['"](storage_[a-z_]+|disk_full)['"]/i
interface ErrorSummaryRule {
test: (msg: string) => boolean
summarize: (msg: string) => string
/** Recovery button attached to the toast when this rule matches. */
action?: (msg: string) => NotificationAction
}
const ERROR_SUMMARIES: ErrorSummaryRule[] = [
{
// Disk full / ENOSPC — session DB write, backend crash, or any path that
// bubbles "no space left" / SQLITE_FULL through notifyError. Match before
// generic length truncation so the user gets a clear "free space" toast
// instead of a silent send or a raw errno dump.
test: isDiskFullErrorMessage,
summarize: () => translateNow('notifications.errors.diskFull')
summarize: () => translateNow('notifications.errors.diskFull'),
action: () => RECOVERY_ACTIONS.openMaintenance()
},
{
// Any other classified storage failure (locked, corrupt, read-only …):
// the Maintenance panel runs the doctor that names the fix.
test: msg => STORAGE_CODE_RE.test(msg),
summarize: () => translateNow('notifications.errors.storageFailure'),
action: () => RECOVERY_ACTIONS.openMaintenance()
},
{
test: msg => /['"]code['"]\s*:\s*['"]gateway_auth_failed['"]/i.test(msg),
summarize: () => translateNow('notifications.errors.gatewayAuthFailed')
summarize: () => translateNow('notifications.errors.gatewayAuthFailed'),
action: () => RECOVERY_ACTIONS.openGateways()
},
{
test: msg => /incorrect api key provided/i.test(msg) || /['"]code['"]\s*:\s*['"]invalid_api_key['"]/i.test(msg),
summarize: msg => {
const status = msg.match(/(?:error code|status(?:Code)?)[^\d]*(\d{3})/i)?.[1]
return status
? translateNow('notifications.errors.openaiRejectedApiKeyWithStatus', status)
: translateNow('notifications.errors.openaiRejectedApiKey')
}
summarize: () => translateNow('notifications.errors.openaiRejectedApiKey'),
action: () => RECOVERY_ACTIONS.openKeys('OPENAI_API_KEY')
},
{
test: msg => /neither voice_tools_openai_key nor openai_api_key is set/i.test(msg),
summarize: () => translateNow('notifications.errors.openaiTtsNeedsKey')
summarize: () => translateNow('notifications.errors.openaiTtsNeedsKey'),
action: () => RECOVERY_ACTIONS.openKeys('OPENAI_API_KEY')
},
{
test: msg => /ELEVENLABS_API_KEY not set/i.test(msg) || /ElevenLabs STT API error \(HTTP 401\)/i.test(msg),
summarize: msg =>
/ELEVENLABS_API_KEY not set/i.test(msg)
? translateNow('notifications.errors.elevenLabsNeedsKey')
: translateNow('notifications.errors.elevenLabsRejectedKey')
: translateNow('notifications.errors.elevenLabsRejectedKey'),
action: () => RECOVERY_ACTIONS.openKeys('ELEVENLABS_API_KEY')
},
{
test: msg => /method not allowed/i.test(msg),
summarize: () => translateNow('notifications.errors.methodNotAllowed')
summarize: () => translateNow('notifications.errors.methodNotAllowed'),
action: () => RECOVERY_ACTIONS.restartHermes()
},
{
test: msg => /microphone permission/i.test(msg),
@@ -136,7 +180,8 @@ const ERROR_SUMMARIES: { test: (msg: string) => boolean; summarize: (msg: string
},
{
test: msg => /Restart required:/i.test(msg),
summarize: () => translateNow('notifications.errors.codeSkewRestartRequired')
summarize: () => translateNow('notifications.errors.codeSkewRestartRequired'),
action: () => RECOVERY_ACTIONS.restartHermes()
}
]
@@ -144,22 +189,25 @@ function summarizeErrorMessage(message: string, fallback: string) {
const rule = ERROR_SUMMARIES.find(r => r.test(message))
if (rule) {
return rule.summarize(message)
return { action: rule.action?.(message), message: rule.summarize(message) }
}
return message.length > 180 ? fallback : message || fallback
return { action: undefined, message: message.length > 180 ? fallback : message || fallback }
}
// Exported so flows that surface errors inline (e.g. ConfirmDialog's onConfirm
// rethrow) can reuse the same IPC-unwrapping + summarizing as notifyError.
export function readableError(error: unknown, fallback: string): { message: string; detail?: string } {
export function readableError(
error: unknown,
fallback: string
): { message: string; detail?: string; action?: NotificationAction } {
const raw = error instanceof Error ? error.message : typeof error === 'string' ? error : fallback
const unwrapped = raw.match(/Error invoking remote method '[^']+': Error: (.+)$/)?.[1] ?? raw
const cleaned = cleanErrorText(unwrapped)
const detail = cleaned.match(/"detail"\s*:\s*"([^"]+)"/)?.[1] ?? cleaned
const summary = summarizeErrorMessage(detail, fallback)
return { message: summary, detail: detail === summary ? undefined : detail }
return { message: summary.message, detail: detail === summary.message ? undefined : detail, action: summary.action }
}
export function notify(input: NotificationInput): string {
@@ -198,7 +246,7 @@ export function notify(input: NotificationInput): string {
return id
}
export function notifyError(error: unknown, fallback: string): string {
export function notifyError(error: unknown, fallback: string, options: { action?: NotificationAction } = {}): string {
const readable = readableError(error, fallback)
const poolSlotTimeout = isLocalBackendSlotWaitTimeout(error)
@@ -208,7 +256,7 @@ export function notifyError(error: unknown, fallback: string): string {
label: translateNow('desktop.poolSlotTimeoutOpenSettings'),
onClick: requestPoolLimitsSettings
}
: undefined,
: (options.action ?? readable.action),
kind: 'error',
title: fallback,
message: poolSlotTimeout ? translateNow('desktop.poolSlotTimeoutBody') : readable.message,
+2 -1
View File
@@ -785,7 +785,8 @@ describe('device-code poll expiry', () => {
expect(flow.status).toBe('error')
if (flow.status === 'error') {
expect(flow.message).toContain('Sign-in expired waiting for authorization')
expect(flow.message).toMatch(/timed out before you finished/)
expect(flow.message).not.toMatch(/server-side|CLI/)
}
})
+14 -6
View File
@@ -48,7 +48,7 @@ export type OnboardingFlow =
saving: boolean
status: 'confirming_model'
}
| { message: string; provider?: OAuthProvider; start?: OAuthStartResponse; status: 'error' }
| { detail?: string; message: string; provider?: OAuthProvider; start?: OAuthStartResponse; status: 'error' }
export interface DesktopOnboardingState {
/** null until the first runtime check resolves. Seeded from localStorage so
@@ -177,6 +177,14 @@ let providersRefreshPromise: null | Promise<void> = null
const errMessage = (e: unknown) => (e instanceof Error ? e.message : String(e))
// One plain sentence for every way a provider sign-in can fail (start, poll,
// code exchange); the raw error text rides along as `detail` (desktop-09).
function signInDidNotFinish(provider: OAuthProvider, raw: unknown): { message: string; detail?: string } {
const detail = raw instanceof Error ? errMessage(raw) : typeof raw === 'string' ? raw.trim() : ''
return { message: translateNow('onboarding.signInDidNotFinish', provider.name), detail: detail || undefined }
}
const patch = (update: Partial<DesktopOnboardingState>) =>
$desktopOnboarding.set({ ...$desktopOnboarding.get(), ...update })
@@ -832,7 +840,7 @@ export async function startProviderOAuth(provider: OAuthProvider, ctx: Onboardin
return
}
setFlow({ status: 'error', provider, message: `Could not start sign-in: ${errMessage(error)}` })
setFlow({ status: 'error', provider, ...signInDidNotFinish(provider, error) })
}
}
@@ -857,7 +865,7 @@ async function pollSession(provider: OAuthProvider, start: DeviceStart, ctx: Onb
)
} else if (status !== 'pending') {
clearPoll()
setFlow({ status: 'error', provider, start, message: error_message || `Sign-in ${status}.` })
setFlow({ status: 'error', provider, start, ...signInDidNotFinish(provider, error_message || status) })
}
} catch (error) {
if (generation !== flowGeneration) {
@@ -865,7 +873,7 @@ async function pollSession(provider: OAuthProvider, start: DeviceStart, ctx: Onb
}
clearPoll()
setFlow({ status: 'error', provider, start, message: `Polling failed: ${errMessage(error)}` })
setFlow({ status: 'error', provider, start, ...signInDidNotFinish(provider, error) })
}
}
@@ -907,14 +915,14 @@ export async function submitOnboardingCode(ctx: OnboardingContext) {
})
)
} else {
setFlow({ status: 'error', provider, start, message: resp.message || 'Token exchange failed.' })
setFlow({ status: 'error', provider, start, ...signInDidNotFinish(provider, resp.message) })
}
} catch (error) {
if (generation !== flowGeneration) {
return
}
setFlow({ status: 'error', provider, start, message: errMessage(error) })
setFlow({ status: 'error', provider, start, ...signInDidNotFinish(provider, error) })
}
}
@@ -0,0 +1,35 @@
import { atom } from 'nanostores'
/**
* Recovery intents raised from surfaces that have no React Router context or
* no knowledge of the active profile — chiefly notification action buttons
* fired from stores. The shell controller (`app/contrib/wiring.tsx`) owns
* `navigate` and the active profile, so it consumes these and performs the
* navigation / restart. Same shape as `$poolLimitsSettingsRequest` and
* `$billingSettingsRequest`, generalised to any in-app route.
*
* Both atoms carry a `seq` so an identical request twice in a row (the user
* clicks the same toast button again) still fires.
*/
export interface RouteRequest {
seq: number
/** In-app hash route, e.g. `/settings?tab=keys&key=OPENAI_API_KEY`. */
path: string
}
export const $routeRequest = atom<RouteRequest | null>(null)
let routeSeq = 0
export function requestRoute(path: string): void {
routeSeq += 1
$routeRequest.set({ seq: routeSeq, path })
}
/** Restart the local Hermes service for the profile currently in view. */
export const $backendRestartRequest = atom(0)
export function requestBackendRestart(): void {
$backendRestartRequest.set($backendRestartRequest.get() + 1)
}
@@ -0,0 +1,61 @@
import { beforeEach, expect, test, vi } from 'vitest'
const getTerminalBackends = vi.fn()
const selectTerminalBackend = vi.fn()
vi.mock('@/hermes', () => ({
getTerminalBackends: (...args: unknown[]) => getTerminalBackends(...args),
selectTerminalBackend: (...args: unknown[]) => selectTerminalBackend(...args)
}))
import { $notifications, clearNotifications } from './notifications'
import { $routeRequest } from './recovery-requests'
import { unavailableTerminalBackend, warnIfTerminalBackendUnavailable } from './terminal-backend-warning'
const row = (name: string, status: 'needs_setup' | 'ready' | 'unavailable', active = false) => ({
name,
label: name === 'docker' ? 'Docker' : name,
description: '',
active,
status,
detail: status === 'ready' ? '' : 'Docker daemon not reachable'
})
beforeEach(() => {
clearNotifications()
getTerminalBackends.mockReset()
selectTerminalBackend.mockReset()
})
// Before this, a Docker/SSH terminal backend whose probe failed showed only a
// "Needs setup" pill inside Skills → Tools → Terminal; nothing told the user
// shell commands could not run.
test('a selected non-local backend that is not ready warns once with Use Local / Open settings', async () => {
getTerminalBackends.mockResolvedValue({ active: 'docker', backends: [row('local', 'ready'), row('docker', 'unavailable', true)] })
selectTerminalBackend.mockResolvedValue({ ok: true, backend: 'local' })
expect(await warnIfTerminalBackendUnavailable()).toBe(true)
const toast = $notifications.get()[0]
expect(toast?.kind).toBe('warning')
expect(toast?.title).toMatch(/Terminal commands are unavailable/)
expect(toast?.message).toContain('Docker')
expect(toast?.detail).toBe('Docker daemon not reachable')
// Plain language: the toast names the tool (Docker), never "backend".
expect(`${toast?.title} ${toast?.message} ${toast?.action?.label}`).not.toMatch(/backend/i)
toast?.action?.onClick()
expect($routeRequest.get()?.path).toBe('/skills?tab=toolsets')
toast?.secondaryAction?.onClick()
expect(selectTerminalBackend).toHaveBeenCalledWith('local')
})
test('local or ready backends and probe failures stay silent', async () => {
expect(unavailableTerminalBackend([row('local', 'ready', true), row('docker', 'unavailable')])).toBeNull()
expect(unavailableTerminalBackend([row('docker', 'ready', true)])).toBeNull()
getTerminalBackends.mockRejectedValue(new Error('404'))
expect(await warnIfTerminalBackendUnavailable()).toBe(false)
expect($notifications.get()).toHaveLength(0)
})
@@ -0,0 +1,60 @@
import { getTerminalBackends, selectTerminalBackend } from '@/hermes'
import { translateNow } from '@/i18n'
import { notify, notifyError } from '@/store/notifications'
import { requestRoute } from '@/store/recovery-requests'
import type { TerminalBackendInfo } from '@/types/hermes'
/**
* Proactive warning when the selected terminal backend (Docker, SSH, …)
* fails its probe. Without this the only signal is a "Needs setup" pill inside
* Skills → Tools → Terminal, so the user learns that shell commands cannot run
* only when a tool call fails. One toast per boot, never for the Local backend
* (it needs no probe), and silent when the probe request itself fails — that
* is an older/remote backend, not a broken terminal.
*/
const TERMINAL_TOOLSET_ROUTE = '/skills?tab=toolsets'
const TOAST_ID = 'terminal-backend-unavailable'
/** The active backend row when it is selected but not ready, else null. */
export function unavailableTerminalBackend(backends: readonly TerminalBackendInfo[]): TerminalBackendInfo | null {
const active = backends.find(backend => backend.active)
return active && active.name !== 'local' && active.status !== 'ready' ? active : null
}
export async function warnIfTerminalBackendUnavailable(): Promise<boolean> {
let backends: TerminalBackendInfo[]
try {
backends = (await getTerminalBackends()).backends
} catch {
return false
}
const broken = unavailableTerminalBackend(backends)
if (!broken) {
return false
}
const copy = 'settings.toolsets.terminalBackend'
notify({
id: TOAST_ID,
kind: 'warning',
title: translateNow(`${copy}.unavailableTitle`),
message: translateNow(`${copy}.unavailableMessage`, broken.label),
detail: broken.detail || undefined,
action: { label: translateNow(`${copy}.openBackendSettings`), onClick: () => requestRoute(TERMINAL_TOOLSET_ROUTE) },
secondaryAction: {
label: translateNow(`${copy}.useLocal`),
onClick: () =>
void selectTerminalBackend('local')
.then(() => notify({ kind: 'success', message: translateNow(`${copy}.switchedToLocal`) }))
.catch(err => notifyError(err, translateNow(`${copy}.failedSelect`, 'local')))
}
})
return true
}
@@ -624,9 +624,13 @@ describe('createGatewayEventHandler', () => {
const messages = getTurnState().activity.map(a => a.text)
expect(messages.some(m => m.includes('gateway startup timed out'))).toBe(true)
// Says it is still waiting and where to look — never the interpreter path or cwd.
expect(messages.some(m => /still waiting/i.test(m) && m.includes('/logs'))).toBe(true)
expect(messages.some(m => m.includes('/opt/venv/bin/python') || m.includes('/repo'))).toBe(false)
// Failure-looking stderr lines are echoed inline; bookkeeping lines are not.
expect(messages.some(m => m.includes('ModuleNotFoundError'))).toBe(true)
expect(messages.some(m => m.includes('FileNotFoundError'))).toBe(true)
expect(messages.some(m => m.includes('[startup] timed out'))).toBe(false)
})
it('prefers raw text over Rich-rendered ANSI on message.complete (#16391)', () => {
@@ -1223,7 +1227,7 @@ describe('createGatewayEventHandler', () => {
const onEvent = createGatewayEventHandler(ctx)
onEvent({ payload: { line: 'Traceback: noisy but non-fatal' }, type: 'gateway.stderr' } as any)
onEvent({ payload: { line: 'INFO hermes.mcp: 3 servers discovered' }, type: 'gateway.stderr' } as any)
onEvent({ payload: { preview: 'bad framing' }, type: 'gateway.protocol_error' } as any)
serverRequest('approval', { command: 'rm -rf /tmp/nope', description: 'dangerous command' })
onEvent({ payload: {}, type: 'gateway.ready' } as any)
@@ -1232,8 +1236,8 @@ describe('createGatewayEventHandler', () => {
await Promise.resolve()
expect(getOverlayState().approval).toMatchObject({ description: 'dangerous command' })
// Plain stderr chatter never reaches Activity (it stays in /logs).
expect(getTurnState().activity).toMatchObject([
{ text: 'Traceback: noisy but non-fatal', tone: 'info' },
{ text: 'protocol noise detected · /logs to inspect', tone: 'info' },
{ text: 'protocol noise: bad framing', tone: 'info' },
{ text: 'command catalog unavailable: cold start', tone: 'info' }
@@ -1652,6 +1656,104 @@ describe('createGatewayEventHandler', () => {
expect(getOverlayState().sudo).toBeNull()
})
it('tells the user a timed-out password prompt was withdrawn and the step skipped', () => {
const ctx = buildCtx([])
const onEvent = createGatewayEventHandler(ctx)
serverRequest('sudo', {}, 'sudo-1')
onEvent({ payload: { id: 'sudo-1', method: 'sudo', reason: 'timeout' }, type: 'request.cancel' } as any)
expect(getOverlayState().sudo).toBeNull()
const lines = (ctx.system.sys as any).mock.calls.map((c: unknown[]) => String(c[0]))
expect(lines.some((l: string) => /prompt closed/i.test(l) && /skipped/.test(l))).toBe(true)
// An interrupted prompt is the user's own doing — no notice.
serverRequest('sudo', {}, 'sudo-2')
onEvent({ payload: { id: 'sudo-2', method: 'sudo', reason: 'interrupted' }, type: 'request.cancel' } as any)
expect((ctx.system.sys as any).mock.calls.length).toBe(lines.length)
})
it('renders a failed turn from error_surface instead of the raw provider JSON', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
const raw = 'Error code: 401 - {"error": {"message": "Incorrect API key provided", "type": "invalid_request_error"}}'
onEvent({
payload: {
error: raw,
error_surface: { code: 'auth', layer: 'auth', provider: 'openai', retryable: false },
recoverable: true,
status: 'error',
text: `Error: ${raw}`
},
type: 'message.complete'
} as any)
const assistant = appended.filter(m => m.role === 'assistant')
expect(assistant).toHaveLength(1)
const [title, details] = assistant[0]!.text.split('\n')
expect(title).not.toMatch(/^Error(?: code)?:/)
expect(title).toMatch(/API key/)
expect(details).toMatch(/^Details: .*Incorrect API key provided/)
expect(assistant[0]!.text).toContain('/model')
expect(assistant[0]!.text).toContain('/retry')
})
it('keeps interim assistant segments on a failed turn and replaces only the bare error slot', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({ payload: { text: 'Let me look that up first.' }, type: 'message.interim' } as any)
onEvent({
payload: {
error: 'boom',
error_surface: { code: 'server_error', layer: 'provider', retryable: true },
recoverable: true,
status: 'error',
text: 'Error: boom'
},
type: 'message.complete'
} as any)
const assistant = appended.filter(m => m.role === 'assistant')
expect(assistant.some(m => m.text === 'Let me look that up first.')).toBe(true)
expect(assistant.some(m => /^Error: boom/.test(m.text))).toBe(false)
expect(assistant.at(-1)!.text).toMatch(/internal error/)
expect(assistant.at(-1)!.text).toContain('/retry')
})
it('keeps streamed partial text on a failed turn (only the empty-reply case is rewritten)', () => {
const appended: Msg[] = []
const onEvent = createGatewayEventHandler(buildCtx(appended))
onEvent({
payload: { error: 'stream dropped', partial: true, status: 'error', text: 'Here is the first half' },
type: 'message.complete'
} as any)
expect(appended.some(m => m.role === 'assistant' && m.text === 'Here is the first half')).toBe(true)
})
it('shows the reconnect countdown from gateway.reconnecting in the status bar', () => {
const onEvent = createGatewayEventHandler(buildCtx([]))
onEvent({ payload: { attempt: 2, delay_ms: 4000 }, type: 'gateway.reconnecting' } as any)
expect(getUiState().status).toMatch(/retrying in 4s/)
expect(getUiState().status).toMatch(/attempt 2/)
})
it('glosses a version-skew error event as an /update pointer', () => {
const ctx = buildCtx([])
const onEvent = createGatewayEventHandler(ctx)
onEvent({ payload: { message: 'invalid params for prompt.submit: turn_author: Extra inputs are not permitted' }, type: 'error' } as any)
const line = String((ctx.system.sys as any).mock.calls.at(-1)?.[0])
expect(line).toContain('/update')
expect(line).not.toContain('turn_author')
})
// ── Batch (multi-question) clarify ─────────────────────────────────
it('parses a batch clarify request into a questions overlay', () => {
@@ -1,3 +1,4 @@
import { JsonRpcGatewayError } from '@hermes/shared/json-rpc-channel'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createSlashHandler } from '../app/createSlashHandler.js'
@@ -934,6 +935,54 @@ describe('createSlashHandler', () => {
}
})
it('surfaces the slash worker failure itself instead of the command.dispatch refusal', async () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) => {
if (method === 'slash.exec') {
return Promise.reject(new JsonRpcGatewayError('slash worker timed out', { code: 5030 }))
}
return Promise.reject(new JsonRpcGatewayError('not a quick/plugin/bundle/skill command: insights', { code: 4018 }))
})
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
expect(createSlashHandler(ctx)('/insights')).toBe(true)
await vi.waitFor(() => expect(ctx.transcript.sys).toHaveBeenCalled())
expect(ctx.gateway.gw.request).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
const line = String(ctx.transcript.sys.mock.calls.at(-1)?.[0])
expect(line).toContain('/insights')
expect(line).toMatch(/timed out/)
expect(line).not.toMatch(/quick\/plugin\/bundle\/skill/)
})
it('still falls back to command.dispatch on a 4018 "not mine" refusal', async () => {
patchUiState({ sid: 'sid-abc' })
const ctx = buildCtx({
gateway: {
gw: {
getLogTail: vi.fn(() => ''),
request: vi.fn((method: string) =>
method === 'slash.exec'
? Promise.reject(new JsonRpcGatewayError('skill command: use command.dispatch for /x', { code: 4018 }))
: Promise.resolve({ type: 'alias', target: 'help' })
)
},
rpc: vi.fn(() => Promise.resolve({}))
}
})
createSlashHandler(ctx)('/x')
await vi.waitFor(() => expect(ctx.gateway.gw.request).toHaveBeenCalledWith('command.dispatch', expect.anything()))
})
it('handles command.dispatch payloads returned directly by slash.exec', async () => {
patchUiState({ sid: 'sid-abc' })
+273
View File
@@ -0,0 +1,273 @@
import { JsonRpcGatewayError } from '@hermes/shared/json-rpc-channel'
import { describe, expect, it } from 'vitest'
import {
backendGaveUp,
describeCredentialWarning,
describeRpcError,
describeSlashExecError,
describeTurnFailure,
isVersionSkewError,
lastStderrLine,
promptTimeoutNotice,
setRpcErrorLogSink,
shouldFallbackToDispatch,
stderrLooksLikeProblem,
stderrProblemActivity
} from '../app/userMessages.js'
// Behaviour contracts for the user-facing wording, not snapshots: each test
// asserts the message names what happened and cites the real next step.
describe('describeTurnFailure', () => {
it('turns an auth error_surface into a plain title, a Details line and the /model + /retry hint', () => {
const raw =
'Error code: 401 - {"error": {"message": "Incorrect API key provided", "type": "invalid_request_error"}}'
const text = describeTurnFailure({
error: raw,
error_surface: { code: 'auth', layer: 'auth', provider: 'openai', retryable: false },
recoverable: true
})
const [title, details] = text.split('\n')
expect(title).toMatch(/rejected the API key \(openai\)/)
expect(title).not.toMatch(/Error code|401|\{/)
expect(details).toMatch(/^Details: /)
expect(details).toContain('Incorrect API key provided')
expect(text).toContain('/model')
expect(text).toContain('/retry')
})
it('falls back to the layer copy, and to a generic title, when the code is unknown', () => {
const streaming = describeTurnFailure({
error: 'peer closed connection',
error_surface: { code: 'weird', layer: 'streaming', retryable: true }
})
expect(streaming).toMatch(/dropped mid-reply/)
expect(streaming).toContain('/retry')
const bare = describeTurnFailure({ error: 'boom' })
expect(bare.split('\n')[0]).toMatch(/^The request failed\./)
expect(bare).toContain('Details: boom')
})
it('drops the /retry pointer when the backend says the turn is not recoverable', () => {
const text = describeTurnFailure({
error: 'x',
error_surface: { code: 'model_not_found', layer: 'provider', retryable: false },
recoverable: false
})
expect(text).toContain('/model')
})
it('honours error_surface.retryable=false even though the backend always sets recoverable=true', () => {
const notRetryable = describeTurnFailure({
error: 'x',
error_surface: { code: 'weird', layer: 'provider', retryable: false },
recoverable: true
})
expect(notRetryable).not.toContain('/retry')
expect(notRetryable).toContain('/model')
const retryable = describeTurnFailure({
error: 'x',
error_surface: { code: 'weird', layer: 'provider', retryable: true },
recoverable: true
})
expect(retryable).toContain('/retry')
})
})
describe('describeRpcError', () => {
it('rewrites transport errors without exposing RPC method names', () => {
for (const raw of ['gateway not connected: prompt.submit', 'gateway not running']) {
const text = describeRpcError(new Error(raw))
expect(text).not.toContain('prompt.submit')
expect(text).not.toMatch(/\bgateway\b/)
expect(text).toMatch(/not connected/)
expect(text).toContain('/logs')
}
})
it('keeps the timeout seconds but drops the method', () => {
const text = describeRpcError(new Error('request timed out after 120s: slash.exec'))
expect(text).toContain('120s')
expect(text).not.toContain('slash.exec')
expect(text).toContain('/logs')
})
it('explains a stale session id as saved-and-reopenable, not as loss', () => {
const text = describeRpcError(new JsonRpcGatewayError('session not found', { code: 4001 }))
expect(text).not.toMatch(/not found/)
expect(text).toMatch(/saved/)
expect(text).toContain('/resume')
})
it('passes ordinary domain errors through unchanged', () => {
expect(describeRpcError(new JsonRpcGatewayError('hash required', { code: 4014 }))).toBe('hash required')
})
it('does not treat every 4001 as a stale session: the backend reuses the code for other refusals', () => {
for (const raw of ['no active session to retry', 'slug and api_key are required', 'session ownership changed']) {
expect(describeRpcError(new JsonRpcGatewayError(raw, { code: 4001 }))).toBe(raw)
}
expect(describeRpcError(new JsonRpcGatewayError('session not found or not owned by this transport', { code: 4001 }))).toContain(
'/resume'
)
})
it('records the raw wire text it replaced in the log sink', () => {
const lines: string[] = []
setRpcErrorLogSink(line => lines.push(line))
try {
describeRpcError(new JsonRpcGatewayError('session not found', { code: 4001 }))
describeRpcError(new Error('gateway not connected: prompt.submit'))
describeRpcError(new JsonRpcGatewayError('hash required', { code: 4014 }))
} finally {
setRpcErrorLogSink(null)
}
expect(lines).toHaveLength(2)
expect(lines[0]).toContain('4001')
expect(lines[0]).toContain('session not found')
expect(lines[1]).toContain('prompt.submit')
})
})
describe('isVersionSkewError', () => {
it('recognises both skew shapes and points at /update', () => {
const extra = new JsonRpcGatewayError(
'invalid params for prompt.submit: turn_author: Extra inputs are not permitted',
{ code: 4000 }
)
const unknown = new JsonRpcGatewayError('unknown method: session.control.read', { code: -32601 })
expect(isVersionSkewError(extra)).toBe(true)
expect(isVersionSkewError(unknown)).toBe(true)
expect(describeRpcError(extra)).toContain('/update')
expect(describeRpcError(extra)).not.toContain('turn_author')
expect(isVersionSkewError(new JsonRpcGatewayError('session_id required', { code: 4000 }))).toBe(false)
})
})
describe('slash.exec fallback policy', () => {
it('falls back to command.dispatch only for the two "slash.exec does not own this" 4018 refusals', () => {
expect(
shouldFallbackToDispatch(new JsonRpcGatewayError('skill command: use command.dispatch for /x', { code: 4018 }))
).toBe(true)
expect(
shouldFallbackToDispatch(
new JsonRpcGatewayError(
'snapshot restore mutates live config/state; use command.dispatch for /snapshot restore',
{ code: 4018 }
)
)
).toBe(true)
expect(shouldFallbackToDispatch(new JsonRpcGatewayError('slash worker timed out', { code: 5030 }))).toBe(false)
expect(shouldFallbackToDispatch(new JsonRpcGatewayError('session not found', { code: 4001 }))).toBe(false)
})
it('does not re-dispatch a 4018 that command.dispatch itself already returned (would re-run /retry, /undo, bundles)', () => {
for (const raw of [
'retry cannot safely reconstruct or combine attached media',
'bundle dispatch failed: boom',
'not a quick/plugin/bundle/skill command: zzz',
'quick command failed with exit code 1'
]) {
expect(shouldFallbackToDispatch(new JsonRpcGatewayError(raw, { code: 4018 }))).toBe(false)
}
// slash.exec never emits 4011; a code the TUI does not know is not a fallback ticket either.
expect(shouldFallbackToDispatch(new JsonRpcGatewayError('unknown command: zzz', { code: 4011 }))).toBe(false)
})
it('names the command and the helper failure instead of the fallback refusal', () => {
const timeout = describeSlashExecError('status', new JsonRpcGatewayError('slash worker timed out', { code: 5030 }))
expect(timeout).toMatch(/^\/status did not finish/)
expect(timeout).toMatch(/timed out/)
expect(timeout).toContain('/logs')
expect(timeout).not.toMatch(/quick\/plugin\/bundle\/skill/)
const crash = describeSlashExecError(
'journey',
new JsonRpcGatewayError('slash worker closed pipe: ValueError: bad', { code: 5030 })
)
expect(crash).toMatch(/^\/journey did not finish/)
expect(crash).toContain('Details: ValueError: bad')
})
})
describe('backend lifecycle copy', () => {
it('names the exit code, the last real stderr line, /logs and hermes doctor', () => {
const tail =
'[lifecycle] child exit code=1\nModuleNotFoundError: No module named pydantic\n[lifecycle] scheduling gateway reconnect in 1000ms (attempt 1)'
const text = backendGaveUp(1, lastStderrLine(tail))
expect(text).toContain('exit code 1')
expect(text).toContain('Details: ModuleNotFoundError: No module named pydantic')
expect(text).not.toContain('[lifecycle]')
expect(text).toContain('/logs')
expect(text).toContain('hermes doctor')
expect(text).toContain('/resume')
expect(text).not.toMatch(/\bgateway\b/)
})
it('only failure-looking stderr earns an activity row', () => {
expect(stderrLooksLikeProblem(' File "/x/run_agent.py", line 812, in _call_model')).toBe(false)
expect(stderrLooksLikeProblem('Traceback (most recent call last):')).toBe(true)
expect(stderrLooksLikeProblem('[gateway-turn] ValueError: nope')).toBe(true)
expect(stderrLooksLikeProblem('ValueError: nope')).toBe(true)
expect(stderrLooksLikeProblem('INFO hermes.mcp: discovered 3 servers')).toBe(false)
})
it('ignores dependency warning lines and does not call the failure a "backend" problem', () => {
expect(stderrLooksLikeProblem('/x/site-packages/foo.py:12: DeprecationWarning: use bar instead')).toBe(false)
expect(stderrLooksLikeProblem('UserWarning: something is odd')).toBe(false)
const row = stderrProblemActivity('ValueError: nope')
expect(row).toContain('(ValueError)')
expect(row).toContain('/logs')
expect(row).not.toMatch(/\bbackend\b/)
})
})
describe('promptTimeoutNotice', () => {
it('explains a timed-out password/vault prompt and stays silent for other reasons', () => {
const sudo = promptTimeoutNotice('sudo', 'timeout')
expect(sudo).toMatch(/Password prompt closed/)
expect(sudo).toMatch(/skipped/)
// The timeout lengths live in Python (agent_callbacks.py); the copy must not hard-code them.
expect(sudo).not.toMatch(/\d+ minutes?/)
expect(promptTimeoutNotice('vault.code', 'timeout')).not.toMatch(/\d+ minutes?/)
expect(promptTimeoutNotice('vault.code', 'timeout')).toMatch(/code/)
expect(promptTimeoutNotice('sudo', 'interrupted')).toBeNull()
expect(promptTimeoutNotice('approval', 'timeout')).toBeNull()
})
})
describe('describeCredentialWarning', () => {
it('adds the /model fix to the backend missing-key warning', () => {
const text = describeCredentialWarning("No API key configured for provider 'openai'. First message will fail.")
expect(text).toContain('openai')
expect(text).toContain('/model')
expect(text).toContain('/setup')
expect(describeCredentialWarning('something else')).toBe('something else')
})
})
+65 -16
View File
@@ -35,6 +35,17 @@ import { forgetServerRequest } from './serverRequestStore.js'
import { turnController } from './turnController.js'
import { getTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
import {
BACKEND_SLOW_START,
BACKEND_SLOW_START_STATUS,
backendReconnecting,
describeRpcError,
describeTurnFailure,
isBareErrorText,
promptTimeoutNotice,
stderrLooksLikeProblem,
stderrProblemActivity
} from './userMessages.js'
import { isWakeUserDisabled } from './wakeState.js'
const NO_PROVIDER_RE = /\bNo (?:LLM|inference) provider configured\b/i
@@ -968,13 +979,26 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
case 'gateway.stderr': {
// Every raw line is already in the /logs buffer (gatewayClient.pushLog).
// Only failure-looking lines earn an activity row, and a traceback's
// many lines collapse into one (pushActivity dedupes a repeated tail).
if (!ev.payload) {
return
}
const line = String(ev.payload.line).slice(0, 120)
const line = String(ev.payload.line)
turnController.pushActivity(line, 'info')
if (stderrLooksLikeProblem(line)) {
turnController.pushActivity(stderrProblemActivity(line), 'warn')
}
return
}
case 'gateway.reconnecting': {
const { attempt, delay_ms: delayMs } = ev.payload ?? {}
setStatus(backendReconnecting(attempt, delayMs))
return
}
@@ -1100,25 +1124,22 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
}
case 'gateway.start_timeout': {
const { cwd, python, stderr_tail: stderrTail } = ev.payload ?? {}
const trace = python || cwd ? ` · ${String(python || '')} ${String(cwd || '')}`.trim() : ''
// Still waiting (the ready timer does not give up) — say so, and point
// at /logs for the interpreter/cwd/stderr detail instead of printing
// paths here. Only failure-looking stderr lines are echoed inline so
// "wrong python" / "missing dep" stay diagnosable at a glance.
const { stderr_tail: stderrTail } = ev.payload ?? {}
setStatus('gateway startup timeout')
turnController.pushActivity(`gateway startup timed out${trace} · /logs to inspect`, 'error')
setStatus(BACKEND_SLOW_START_STATUS)
turnController.pushActivity(BACKEND_SLOW_START, 'warn')
// Surface the most useful stderr lines inline so users can tell
// "wrong python", "missing dep", and "config parse failure"
// apart without leaving the TUI. Filter blank rows BEFORE
// taking the last N so trailing empty lines in the buffer
// don't crowd out actual content; truncate to match the
// 120-char clip used for `gateway.stderr` activity entries.
const STDERR_LINE_CAP = 120
const STDERR_LINES_MAX = 8
const STDERR_LINES_MAX = 4
const tailLines = (stderrTail ?? '')
.split('\n')
.map(l => l.trim())
.filter(Boolean)
.filter(l => l && stderrLooksLikeProblem(l))
.slice(-STDERR_LINES_MAX)
for (const line of tailLines) {
@@ -1267,6 +1288,15 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
// A password/secret/vault card that timed out vanished silently; say
// what happened and how to get it back. Clarify already records its
// own "(timed out)" line via tool.complete.
const timeoutNotice = promptTimeoutNotice(ev.payload?.method, ev.payload?.reason)
if (timeoutNotice) {
sys(timeoutNotice)
}
forgetServerRequest(id)
patchOverlayState(prev => {
const next = { ...prev }
@@ -1462,7 +1492,26 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {})
if (!wasInterrupted) {
const msgs: Msg[] = finalMessages.length ? finalMessages : [{ role: 'assistant', text: finalText }]
const payload = ev.payload ?? {}
// A failed turn with no reply: the backend's assistant-slot text is
// "Error: <raw provider body>". Render the structured error_surface
// (layer/code/retryable) as a plain title + Details + next step
// instead; a partial reply keeps its streamed text.
const failed = payload.status === 'error' && !payload.partial && isBareErrorText(finalText, payload.error)
// Only the trailing bare-error slot is replaced; interim segments the
// model streamed before the failure stay in the transcript.
const msgs: Msg[] = failed
? [
...finalMessages.filter(
(m, i) => !(i === finalMessages.length - 1 && m.role === 'assistant' && isBareErrorText(m.text, payload.error))
),
{ role: 'assistant', text: describeTurnFailure(payload) }
]
: finalMessages.length
? finalMessages
: [{ role: 'assistant', text: finalText }]
msgs.forEach(appendMessage)
// Pet beat: celebrate a finished plan, otherwise a clean-finish wave.
@@ -1531,7 +1580,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev:
return
}
sys(`error: ${message}`)
sys(`error: ${describeRpcError(new Error(message))}`)
setStatus('ready')
}
}
+15 -1
View File
@@ -10,6 +10,7 @@ import { scoreSlashMenuItem } from './slash/fuzzyScore.js'
import { findSlashCommand } from './slash/registry.js'
import type { SlashRunCtx } from './slash/types.js'
import { getUiState } from './uiStore.js'
import { describeSlashExecError, shouldFallbackToDispatch } from './userMessages.js'
export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => boolean {
const { gw } = ctx.gateway
@@ -168,7 +169,20 @@ export function createSlashHandler(ctx: SlashHandlerContext): (cmd: string) => b
long ? page(text, parsed.name[0]!.toUpperCase() + parsed.name.slice(1)) : sys(text)
})
.catch(() => {
.catch((execErr: unknown) => {
// Only "slash.exec does not own this command" refusals (4011/4018) may
// fall through to command.dispatch. A helper timeout/crash (5030) must
// be shown as itself — the fallback's "not a quick/plugin/bundle/skill
// command" refusal used to bury the real cause and imply the command
// did not exist.
if (!shouldFallbackToDispatch(execErr)) {
if (!stale()) {
sys(`error: ${describeSlashExecError(parsed.name, execErr)}`)
}
return
}
gw.request('command.dispatch', { arg: parsed.arg, name: parsed.name, session_id: sid })
.then((raw: unknown) => {
if (stale()) {
+2 -1
View File
@@ -17,6 +17,7 @@ import type { PanelSection } from '../../../types.js'
import { applyDelegationStatus, getDelegationState } from '../../delegationStore.js'
import { patchOverlayState } from '../../overlayStore.js'
import { getSpawnHistory, pushDiskSnapshot, setDiffPair, type SpawnSnapshot } from '../../spawnHistoryStore.js'
import { NO_SKILLS_INSTALLED } from '../../userMessages.js'
import type { SlashCommand } from '../types.js'
interface SkillInfo {
@@ -529,7 +530,7 @@ export const opsCommands: SlashCommand[] = [
const cats = Object.entries(r.skills ?? {}).sort()
if (!cats.length) {
return sys('no skills available')
return sys(NO_SKILLS_INSTALLED)
}
panel(
+33 -8
View File
@@ -67,6 +67,13 @@ import { useComposerState } from './useComposerState.js'
import { useConfigSync } from './useConfigSync.js'
import { shouldDetachEditedHistoryInput, useInputHandlers } from './useInputHandlers.js'
import { useLongRunToolCharms } from './useLongRunToolCharms.js'
import {
BACKEND_GAVE_UP_ACTIVITY,
BACKEND_RESTARTING,
BACKEND_RESTARTING_ACTIVITY,
backendGaveUp,
lastStderrLine
} from './userMessages.js'
import { useSessionLifecycle } from './useSessionLifecycle.js'
import { useSubmission } from './useSubmission.js'
@@ -244,6 +251,8 @@ export function useMainApp(gw: GatewayClient) {
const lastUserMsgRef = useRef(lastUserMsg)
const recoverSidRef = useRef<null | string>(null)
const recoveryAtRef = useRef<number[]>([])
// "Hermes stopped and could not be restarted" is said once per outage; reset on gateway.ready.
const gaveUpRef = useRef(false)
const msgIdsRef = useRef(new WeakMap<Msg, string>())
const msgIdSeqRef = useRef(0)
const heightCachesRef = useRef(new Map<string, Map<string, number>>())
@@ -937,7 +946,13 @@ export function useMainApp(gw: GatewayClient) {
onServerRequestRef.current = onServerRequest
useEffect(() => {
const handler = (ev: AnyGatewayEvent) => onEventRef.current(ev)
const handler = (ev: AnyGatewayEvent) => {
if (ev.type === 'gateway.ready') {
gaveUpRef.current = false
}
onEventRef.current(ev)
}
const requestHandler = (request: ServerRequest) => {
if (!onServerRequestRef.current(request)) {
@@ -945,7 +960,7 @@ export function useMainApp(gw: GatewayClient) {
}
}
const exitHandler = () => {
const exitHandler = (code: null | number) => {
turnController.reset()
// A still-owned child dying while the TUI is alive is an *unexpected*
@@ -963,20 +978,30 @@ export function useMainApp(gw: GatewayClient) {
// dead/respawning gateway. recoverSidRef carries the session forward, and
// resumeById restores sid once the fresh gateway is ready.
recoveryAtRef.current = plan.attempts
patchUiState({ busy: false, compacting: false, sid: null, status: 'gateway exited' })
patchUiState({ busy: false, compacting: false, sid: null, status: 'restarting…' })
if (plan.recover && plan.sid) {
recoverSidRef.current = plan.sid
turnController.pushActivity('gateway exited · recovering session…', 'warn')
sys('gateway exited — recovering your session (any in-flight reply was lost)')
turnController.pushActivity(BACKEND_RESTARTING_ACTIVITY, 'warn')
sys(BACKEND_RESTARTING)
gw.start()
return
}
recoverSidRef.current = null
turnController.pushActivity('gateway exited · /logs to inspect', 'error')
sys('error: gateway exited')
// Budget spent (crash loop) or nothing to recover: GatewayClient keeps
// retrying on its backoff — say so ONCE, with the exit code and the last
// stderr line, rather than repeating "gateway exited" every tick. Keep the
// recovery target: when that background reconnect eventually succeeds,
// gateway.ready must reopen the SAME chat instead of forging a new one.
recoverSidRef.current = plan.sid
patchUiState({ status: 'stopped' })
if (!gaveUpRef.current) {
gaveUpRef.current = true
turnController.pushActivity(BACKEND_GAVE_UP_ACTIVITY, 'error')
sys(`error: ${backendGaveUp(code, lastStderrLine(gw.getLogTail(20)))}`)
}
}
gw.on('event', handler)
+2 -1
View File
@@ -25,6 +25,7 @@ import { scheduleResumeScrollToBottom } from './sessionResumeView.js'
import { turnController } from './turnController.js'
import { patchTurnState } from './turnStore.js'
import { getUiState, patchUiState } from './uiStore.js'
import { describeCredentialWarning } from './userMessages.js'
export { refreshSessionView, scheduleResumeScrollToBottom } from './sessionResumeView.js'
@@ -226,7 +227,7 @@ export function useSessionLifecycle(opts: UseSessionLifecycleOptions) {
}
if (info?.credential_warning) {
sys(`warning: ${info.credential_warning}`)
sys(`warning: ${describeCredentialWarning(info.credential_warning)}`)
}
if (info?.config_warning) {
+319
View File
@@ -0,0 +1,319 @@
// User-facing wording for gateway/transport failures in the TUI. Pure functions
// so the copy — and the "what happened / what to do" shape — is unit-testable
// without rendering. Every slash command cited here exists in
// ui-tui/src/app/slash/commands (/logs, /retry, /model, /update, /resume,
// /sessions, /quit) and `hermes doctor` is a real subcommand.
import type { ErrorSurface } from '@hermes/shared/gateway-events'
/** JSON-RPC error codes the gateway answers with. */
export const RPC_INVALID_PARAMS = 4000
export const RPC_SESSION_NOT_FOUND = 4001
export const RPC_NOT_DISPATCHABLE = 4018
export const RPC_UNKNOWN_METHOD = -32601
const DETAIL_LIMIT = 300
interface RpcErrorShape {
code?: number
message?: string
}
const rpcShape = (err: unknown): RpcErrorShape =>
err instanceof Error ? { code: (err as { code?: number }).code, message: err.message } : {}
const detailLine = (raw: string | undefined): string | null => {
const text = (raw ?? '').replace(/\s+/g, ' ').trim()
if (!text) {
return null
}
return `Details: ${text.length > DETAIL_LIMIT ? `${text.slice(0, DETAIL_LIMIT - 1)}…` : text}`
}
// ── Backend process lifecycle ─────────────────────────────────────────────
export const BACKEND_RESTARTING =
'Hermes stopped unexpectedly — restarting and reopening your chat (the reply in progress was lost).'
export const BACKEND_RESTARTING_ACTIVITY = 'Hermes stopped unexpectedly · restarting…'
export const backendGaveUp = (code: null | number, lastLine?: string): string => {
const exit = code === null ? '' : ` (exit code ${code})`
const detail = detailLine(lastLine)
return [
`Hermes stopped${exit} and could not be restarted. Your chat is saved.`,
detail,
'Hermes keeps trying to reconnect in the background and reopens this chat when it succeeds; if it does not, type /resume.',
'Type /logs for the full log, or /quit and run `hermes doctor` to check the install.'
]
.filter(Boolean)
.join('\n')
}
export const BACKEND_GAVE_UP_ACTIVITY = 'Hermes stopped · /logs for details'
/** Last line of the backend log tail that is not our own [lifecycle]/[startup] bookkeeping. */
export const lastStderrLine = (tail: string): string | undefined =>
tail
.split('\n')
.map(l => l.trim())
.filter(l => l && !/^\[(?:lifecycle|startup|protocol|sidecar|spawn)\]/.test(l))
.at(-1)
export const backendReconnecting = (attempt: number | undefined, delayMs: number | undefined): string => {
const secs = Math.max(1, Math.round((delayMs ?? 1000) / 1000))
const n = attempt && attempt > 0 ? ` (attempt ${attempt})` : ''
return `retrying in ${secs}s${n}`
}
export const BACKEND_SLOW_START =
'Hermes is taking longer than usual to start. Still waiting… If it never connects: /logs shows the last backend output; /quit and run `hermes doctor`.'
export const BACKEND_SLOW_START_STATUS = 'still starting…'
// ── stderr noise ──────────────────────────────────────────────────────────
// Only real failures: a traceback, a CRITICAL log line, an `XxxError:` / `XxxException:`
// head, or the gateway's own turn/exit markers. Dependency `DeprecationWarning` /
// `UserWarning` lines are noise and stay in /logs only.
const STDERR_PROBLEM_RE = /Traceback|\b[A-Z][A-Za-z]*(?:Error|Exception)\b:|CRITICAL|\[gateway-turn\]|\[gateway-exit\]/
/** Only lines that look like a failure earn an activity row; the rest stay in /logs. */
export const stderrLooksLikeProblem = (line: string): boolean => STDERR_PROBLEM_RE.test(line)
export const stderrProblemActivity = (line: string): string => {
const m = /([A-Z][A-Za-z]*(?:Error|Exception)):/.exec(line)
const what = m ? ` (${m[1]})` : ''
return `Something went wrong inside Hermes${what} · /logs for details`
}
// ── RPC errors ────────────────────────────────────────────────────────────
const VERSION_SKEW_RE = /Extra inputs are not permitted|^unknown method:/
/** The Ink bundle and the Python backend disagree on the wire: stale dist or an older attached backend. */
export const isVersionSkewError = (err: unknown): boolean => {
const { code, message } = rpcShape(err)
return (
code === RPC_UNKNOWN_METHOD ||
(code === RPC_INVALID_PARAMS && VERSION_SKEW_RE.test(message ?? '')) ||
(code === undefined && VERSION_SKEW_RE.test(message ?? ''))
)
}
export const VERSION_SKEW_MESSAGE =
'The terminal UI and the Hermes backend are out of sync (different versions). Run /update, or exit and run `hermes update`, then start the TUI again.'
const SESSION_NOT_FOUND_RE = /session not found/i
const NOT_CONNECTED_RE = /^gateway not (?:connected|running)\b/
const TIMED_OUT_RE = /^request timed out after (\d+)s/
type RpcErrorRow = [matcher: (code: number | undefined, text: string) => RegExpExecArray | boolean | null, render: (m: RegExpExecArray | null) => string]
// Ordered: first matching row wins. 4001 is reused by the backend for unrelated
// refusals ("no active session", "slug is required", NOT_OWNER), so the code
// alone must not trigger the /resume copy — only the "session not found" text.
const RPC_ERROR_ROWS: RpcErrorRow[] = [
[
(code, text) => (code === RPC_SESSION_NOT_FOUND || code === undefined) && SESSION_NOT_FOUND_RE.test(text),
() =>
'This chat is no longer attached to the backend (it was idle or the backend restarted). Your history is saved: type /resume to reopen it.'
],
[
(_code, text) => NOT_CONNECTED_RE.test(text),
() =>
'Hermes is not connected right now, so that was not sent. It reconnects automatically; wait a moment and try again, or type /logs if this persists.'
],
[
(_code, text) => TIMED_OUT_RE.exec(text),
m => `Hermes did not answer within ${m?.[1] ?? '?'}s. Try again; if it keeps happening, type /logs and report the last lines.`
]
]
let rpcErrorLogSink: ((line: string) => void) | null = null
/** Where describeRpcError records the raw wire text it replaced (the /logs buffer). */
export const setRpcErrorLogSink = (sink: ((line: string) => void) | null): void => {
rpcErrorLogSink = sink
}
const logReplacedWireText = (code: number | undefined, text: string): void => {
rpcErrorLogSink?.(`[rpc] ${code === undefined ? '' : `code=${code} `}${text}`)
}
/** Rewrite transport/session errors into plain words; other errors pass through. */
export const describeRpcError = (err: unknown): string => {
const { code, message } = rpcShape(err)
const text = message ?? (typeof err === 'string' && err.trim() ? err : 'request failed')
if (isVersionSkewError(err)) {
logReplacedWireText(code, text)
return VERSION_SKEW_MESSAGE
}
for (const [matcher, render] of RPC_ERROR_ROWS) {
const m = matcher(code, text)
if (m) {
logReplacedWireText(code, text)
return render(m === true ? null : m)
}
}
return text
}
/** The slash worker (built-in command helper) failed; name the command, not the helper. */
export const describeSlashExecError = (command: string, err: unknown): string => {
const { message } = rpcShape(err)
const text = message ?? ''
if (/slash worker timed out/.test(text)) {
return `/${command} did not finish: the command helper timed out. Try again; if it keeps happening, type /logs and report the last lines.`
}
if (/slash worker (?:exited|closed pipe|start failed)/.test(text)) {
const detail = detailLine(text.replace(/^slash worker (?:exited|closed pipe:?|start failed:?)\s*/, ''))
return [`/${command} did not finish: the command helper crashed. Try again; type /logs for the trace.`, detail]
.filter(Boolean)
.join('\n')
}
return describeRpcError(err)
}
// slash.exec answers 4018 with exactly these texts when it does NOT own the
// command (tui_gateway/methods_tools.py). Every other 4018 came from a
// command.dispatch handler slash.exec already forwarded to (/retry, /undo,
// /compress, /queue, bundles): re-dispatching would run a mutating command twice.
const NOT_MINE_REFUSAL_RE = /^skill command: use command\.dispatch for \/|use command\.dispatch for \/snapshot restore/
/** command.dispatch is only a fallback for "slash.exec does not own this command" refusals. */
export const shouldFallbackToDispatch = (err: unknown): boolean => {
const { code, message } = rpcShape(err)
if (code === RPC_NOT_DISPATCHABLE) {
return NOT_MINE_REFUSAL_RE.test(message ?? '')
}
if (code !== undefined) {
return false
}
// Legacy/attached backends without a code: keep the historical behaviour
// unless the text is unmistakably a helper failure.
return !/slash worker|timed out|not connected|not running/.test(message ?? '')
}
// ── Turn failures (message.complete status=error) ─────────────────────────
const TURN_CODE_COPY: Record<string, [string, string]> = {
auth: ['The model provider rejected the API key', 'Fix the key with /model, then /retry.'],
auth_permanent: ['The model provider rejected the API key', 'Fix the key with /model, then /retry.'],
billing: ['The model provider reports no credit left', 'Top up the account or switch with /model.'],
billing_unverified: ['The model provider reports no credit left', 'Top up the account or switch with /model.'],
content_policy_blocked: ['The model provider refused this request (content policy)', 'Rephrase and send again.'],
context_overflow: ['The conversation is too long for this model', 'Run /compress, then /retry.'],
format_error: ['The model provider rejected the request format', 'Try /retry; if it persists, switch with /model.'],
model_not_found: ['The model provider does not know this model', 'Pick another model with /model.'],
overloaded: ['The model provider is overloaded', 'Wait a moment, then /retry.'],
payload_too_large: ['The request was too large for this model', 'Run /compress, then /retry.'],
provider_policy_blocked: ['The model provider refused this request (account policy)', 'Switch with /model.'],
rate_limit: ['The model provider is rate-limiting requests', 'Wait a moment, then /retry.'],
server_error: ['The model provider had an internal error', 'Wait a moment, then /retry.'],
ssl_cert_verification: [
'The connection to the model provider could not be verified (TLS)',
"Check the endpoint's certificate, then /retry."
],
timeout: ['The model provider did not answer in time', 'Try /retry; if it keeps happening, switch with /model.'],
upstream_rate_limit: ['The model provider is rate-limiting requests', 'Wait a moment, then /retry.']
}
const TURN_LAYER_COPY: Record<string, [string, string]> = {
auth: ['The model provider rejected the credentials', 'Fix them with /model, then /retry.'],
billing: ['The model provider reports no credit left', 'Top up the account or switch with /model.'],
disk: ['The disk is full, so Hermes could not save the turn', 'Free some space, then /retry.'],
endpoint: ['Your custom model endpoint did not answer', 'Check the endpoint is running, then /retry.'],
gateway: ['Hermes hit an internal error while running this turn', 'Send /retry; type /logs for the trace.'],
provider: ['The model provider returned an error', 'Send /retry, or switch with /model.'],
streaming: ['The connection to the model provider dropped mid-reply', 'Send /retry.']
}
const TURN_DEFAULT_COPY: [string, string] = ['The request failed', 'Send /retry, or switch with /model.']
export interface TurnFailure {
error?: null | string
error_surface?: ErrorSurface | null | Record<string, unknown>
recoverable?: boolean | null
}
/** Plain title + dimmed detail + next step for a failed turn with no reply text. */
export const describeTurnFailure = (payload: TurnFailure): string => {
const surface = (payload.error_surface ?? {}) as { code?: unknown; layer?: unknown; provider?: unknown }
const code = typeof surface.code === 'string' ? surface.code : ''
const layer = typeof surface.layer === 'string' ? surface.layer : ''
const provider = typeof surface.provider === 'string' && surface.provider ? ` (${surface.provider})` : ''
const [title, hint] = TURN_CODE_COPY[code] ?? TURN_LAYER_COPY[layer] ?? TURN_DEFAULT_COPY
// The backend always sets recoverable=true on a turn error; error_surface.retryable
// is the signal that actually says whether /retry can help.
const retryable = (surface as { retryable?: unknown }).retryable !== false && payload.recoverable !== false
const nextStep = retryable ? hint : hint.replace(/(?:Send|Try) \/retry/, 'Pick another model with /model')
const raw = (payload.error ?? '').replace(/^Error:\s*/, '')
return [`${title}${provider}. Your message was not answered.`, detailLine(raw), nextStep].filter(Boolean).join('\n')
}
/** True when the assistant slot carries nothing but the backend's "Error: …" fallback text. */
export const isBareErrorText = (text: string, error: null | string | undefined): boolean => {
const t = text.trim()
return !t || t === `Error: ${error ?? ''}`.trim() || t === (error ?? '').trim()
}
// ── Withdrawn password / secret prompts ───────────────────────────────────
const PROMPT_TIMEOUT_COPY: Record<string, string> = {
secret:
'Secret prompt closed: no answer in time, so the step that needed it was skipped. Send your request again when you are ready to enter it.',
sudo: 'Password prompt closed: no answer in time, so the command was skipped. Send your request again when you are ready to enter it.',
'vault.code':
'Verification-code prompt closed: no answer in time, so the sign-in was skipped. Send your request again when you have the code.',
'vault.save_login':
'Save-login prompt closed: no answer in time, so nothing was saved. Send your request again when you are ready.',
'vault.unlock_prompt':
'Unlock prompt closed: no answer in time, so the password manager stayed locked. Send your request again when you are ready to unlock it.'
}
export const promptTimeoutNotice = (method: string | undefined, reason: string | undefined): null | string =>
reason === 'timeout' && method ? (PROMPT_TIMEOUT_COPY[method] ?? null) : null
// ── session.info warnings ─────────────────────────────────────────────────
const MISSING_KEY_RE = /^No API key configured for provider '([^']*)'/
/** The backend's credential warning names the break; add the fix (/model saves a key in place). */
export const describeCredentialWarning = (warning: string): string => {
const m = MISSING_KEY_RE.exec(warning)
if (!m) {
return warning
}
const provider = m[1] || 'the current provider'
return `No API key is set for ${provider}, so messages will fail. Type /model, pick ${provider}, and paste a key (or run /setup).`
}
// ── Empty states ──────────────────────────────────────────────────────────
export const NO_SKILLS_INSTALLED =
'No skills installed yet. Type /skills browse to see the catalog, or /skills install <name>.'
+2 -1
View File
@@ -1,6 +1,7 @@
import { Box, Text, useInput, useStdout } from '@hermes/ink'
import { useEffect, useState } from 'react'
import { NO_SKILLS_INSTALLED } from '../app/userMessages.js'
import type { GatewayClient } from '../gatewayClient.js'
import { rpcErrorMessage } from '../lib/rpc.js'
import type { Theme } from '../theme.js'
@@ -198,7 +199,7 @@ export function SkillsHub({ gw, maxWidth, onClose, t }: SkillsHubProps) {
if (!cats.length) {
return (
<Box flexDirection="column" width={width}>
<Text color={t.color.muted}>no skills available</Text>
<Text color={t.color.muted}>{NO_SKILLS_INSTALLED}</Text>
<OverlayHint t={t}>Esc/q cancel</OverlayHint>
</Box>
)
+3
View File
@@ -5,6 +5,7 @@ import './lib/forceTruecolor.js'
import type { FrameEvent } from '@hermes/ink'
import { setRpcErrorLogSink } from './app/userMessages.js'
import { DASHBOARD_TUI_MODE, TERMUX_TUI_MODE } from './config/env.js'
import { GatewayClient } from './gatewayClient.js'
import { setupGracefulExit } from './lib/gracefulExit.js'
@@ -50,6 +51,8 @@ if (TERMUX_TUI_MODE) {
const gw = new GatewayClient()
// describeRpcError replaces raw wire errors with plain copy; keep the original in /logs.
setRpcErrorLogSink(line => gw.recordLog(line))
gw.start()
const dumpNotice = (snap: MemorySnapshot, dump: HeapDumpResult | null) =>
+5
View File
@@ -642,6 +642,11 @@ export class GatewayClient extends EventEmitter {
this.logs.push(truncateLine(line))
}
/** Record a client-side diagnostic line in the /logs tail (raw wire text the UI replaced with plain copy). */
recordLog(line: string) {
this.pushLog(line)
}
// Death-explaining breadcrumbs (spawn / exit / kill / replace) — kept in the
// in-memory tail for /logs AND persisted to the gateway crash log so the
// reason survives a parent exit and lands next to the child's SIGTERM panic.
+10 -1
View File
@@ -1,7 +1,16 @@
import { describeRpcError } from '../app/userMessages.js'
export type RpcResult = Record<string, any>
export const asRpcResult = <T extends RpcResult = RpcResult>(value: unknown): T | null =>
!value || typeof value !== 'object' || Array.isArray(value) ? null : (value as T)
// Every `error: …` line the TUI prints for a failed RPC goes through here, so
// transport-level failures (backend down, stale session id, version skew,
// timeouts) read as what happened + what to do instead of the wire text.
export const rpcErrorMessage = (err: unknown) =>
err instanceof Error && err.message ? err.message : typeof err === 'string' && err.trim() ? err : 'request failed'
err instanceof Error && err.message
? describeRpcError(err)
: typeof err === 'string' && err.trim()
? describeRpcError(err)
: 'request failed'