From 66878996ddff7360eb8cf4f7e320070fe652d517 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Mon, 14 Sep 2026 23:01:25 -0700 Subject: [PATCH] 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). --- .../electron/bootstrap-failure-copy.test.ts | 40 +++ .../electron/bootstrap-failure-copy.ts | 88 +++++ apps/desktop/electron/main.ts | 88 +++-- .../electron/plugin-compat-notice.test.ts | 16 +- apps/desktop/electron/plugin-compat-notice.ts | 25 +- apps/desktop/electron/updater-process.test.ts | 15 + apps/desktop/electron/updater-process.ts | 14 + apps/desktop/src/app/contrib/wiring.tsx | 44 +++ .../src/app/cron/cron-job-model.test.ts | 24 ++ apps/desktop/src/app/cron/cron-job-model.ts | 24 ++ apps/desktop/src/app/cron/index.tsx | 34 +- .../gateway/hooks/use-gateway-boot.test.tsx | 60 +++- .../src/app/gateway/hooks/use-gateway-boot.ts | 59 +++- apps/desktop/src/app/messaging/index.tsx | 8 +- .../agent-init-error.test.tsx | 7 +- .../input-requests.approval-timeout.test.ts | 77 +++++ .../gateway-event/input-requests.ts | 31 +- .../gateway-event/status.test.ts | 92 +++++ .../gateway-event/status.ts | 45 ++- .../use-message-stream/gateway-event/types.ts | 8 +- .../session/hooks/use-message-stream/index.ts | 8 +- .../hooks/use-prompt-actions/index.test.tsx | 2 +- .../session/hooks/use-prompt-actions/index.ts | 9 + .../src/app/settings/plugin-install-modal.tsx | 2 +- .../src/app/settings/toolset-config-panel.tsx | 30 +- .../src/app/skills/embedded-hub-picker.tsx | 4 +- apps/desktop/src/app/skills/index.tsx | 6 +- apps/desktop/src/app/updates-overlay.tsx | 41 ++- .../components/assistant-ui/clarify-tool.tsx | 5 +- .../assistant-ui/mcp-setup-tool.tsx | 3 +- .../thread/assistant-message.test.tsx | 146 +++++++- .../assistant-ui/thread/assistant-message.tsx | 242 +++++++++---- .../components/assistant-ui/tool/approval.tsx | 3 +- .../src/components/boot-failure-cause.test.ts | 45 +++ .../src/components/boot-failure-cause.ts | 61 ++++ .../src/components/boot-failure-overlay.tsx | 21 +- .../components/desktop-install-overlay.tsx | 32 +- .../desktop/src/components/error-boundary.tsx | 18 +- .../src/components/first-run-remote-form.tsx | 10 +- .../src/components/notifications.test.tsx | 2 +- .../src/components/onboarding/flow.tsx | 23 +- .../src/components/prompt-overlays.tsx | 11 +- apps/desktop/src/i18n/ar.ts | 1 - apps/desktop/src/i18n/en.ts | 291 +++++++++++++--- apps/desktop/src/i18n/ja.ts | 1 - apps/desktop/src/i18n/ru.ts | 3 +- apps/desktop/src/i18n/types.ts | 100 +++++- apps/desktop/src/i18n/zh-hant.ts | 1 - apps/desktop/src/i18n/zh.ts | 3 +- apps/desktop/src/lib/error-surface-copy.ts | 52 +++ apps/desktop/src/lib/error-surface.test.ts | 56 ++- apps/desktop/src/lib/error-surface.ts | 135 +++++++- .../canonical-chat-open-failure-toast.test.ts | 136 ++++++++ .../src/plugins/hermes-bots/canonical-chat.ts | 55 ++- apps/desktop/src/plugins/hermes-bots/i18n.ts | 37 ++ .../src/plugins/hermes-bots/roster-actions.ts | 4 +- .../plugins/kanban/completion-notify.test.ts | 40 ++- .../src/plugins/kanban/completion-notify.ts | 13 +- apps/desktop/src/plugins/kanban/i18n.ts | 38 ++- apps/desktop/src/store/gateway-reconnect.ts | 11 + .../src/store/hub-actions.blocked.test.ts | 79 +++++ apps/desktop/src/store/hub-actions.ts | 98 +++++- apps/desktop/src/store/notifications.test.ts | 59 +++- apps/desktop/src/store/notifications.ts | 88 +++-- apps/desktop/src/store/onboarding.test.ts | 3 +- apps/desktop/src/store/onboarding.ts | 20 +- apps/desktop/src/store/recovery-requests.ts | 35 ++ .../store/terminal-backend-warning.test.ts | 61 ++++ .../src/store/terminal-backend-warning.ts | 60 ++++ .../createGatewayEventHandler.test.ts | 108 +++++- .../src/__tests__/createSlashHandler.test.ts | 49 +++ ui-tui/src/__tests__/userMessages.test.ts | 273 +++++++++++++++ ui-tui/src/app/createGatewayEventHandler.ts | 81 ++++- ui-tui/src/app/createSlashHandler.ts | 16 +- ui-tui/src/app/slash/commands/ops.ts | 3 +- ui-tui/src/app/useMainApp.ts | 41 ++- ui-tui/src/app/useSessionLifecycle.ts | 3 +- ui-tui/src/app/userMessages.ts | 319 ++++++++++++++++++ ui-tui/src/components/skillsHub.tsx | 3 +- ui-tui/src/entry.tsx | 3 + ui-tui/src/gatewayClient.ts | 5 + ui-tui/src/lib/rpc.ts | 11 +- 82 files changed, 3593 insertions(+), 325 deletions(-) create mode 100644 apps/desktop/electron/bootstrap-failure-copy.test.ts create mode 100644 apps/desktop/electron/bootstrap-failure-copy.ts create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.approval-timeout.test.ts create mode 100644 apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/status.test.ts create mode 100644 apps/desktop/src/components/boot-failure-cause.test.ts create mode 100644 apps/desktop/src/components/boot-failure-cause.ts create mode 100644 apps/desktop/src/lib/error-surface-copy.ts create mode 100644 apps/desktop/src/plugins/hermes-bots/canonical-chat-open-failure-toast.test.ts create mode 100644 apps/desktop/src/store/hub-actions.blocked.test.ts create mode 100644 apps/desktop/src/store/recovery-requests.ts create mode 100644 apps/desktop/src/store/terminal-backend-warning.test.ts create mode 100644 apps/desktop/src/store/terminal-backend-warning.ts create mode 100644 ui-tui/src/__tests__/userMessages.test.ts create mode 100644 ui-tui/src/app/userMessages.ts diff --git a/apps/desktop/electron/bootstrap-failure-copy.test.ts b/apps/desktop/electron/bootstrap-failure-copy.test.ts new file mode 100644 index 0000000000..64dc037a13 --- /dev/null +++ b/apps/desktop/electron/bootstrap-failure-copy.test.ts @@ -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') +}) diff --git a/apps/desktop/electron/bootstrap-failure-copy.ts b/apps/desktop/electron/bootstrap-failure-copy.ts new file mode 100644 index 0000000000..c5c8e1cf0f --- /dev/null +++ b/apps/desktop/electron/bootstrap-failure-copy.ts @@ -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 = 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}` + ) +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7e3b10cffc..dbde42775c 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -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 diff --git a/apps/desktop/electron/plugin-compat-notice.test.ts b/apps/desktop/electron/plugin-compat-notice.test.ts index e474879f15..0387333d81 100644 --- a/apps/desktop/electron/plugin-compat-notice.test.ts +++ b/apps/desktop/electron/plugin-compat-notice.test.ts @@ -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)) }) diff --git a/apps/desktop/electron/plugin-compat-notice.ts b/apps/desktop/electron/plugin-compat-notice.ts index 5cd56f3f60..1a94443d36 100644 --- a/apps/desktop/electron/plugin-compat-notice.ts +++ b/apps/desktop/electron/plugin-compat-notice.ts @@ -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 } } diff --git a/apps/desktop/electron/updater-process.test.ts b/apps/desktop/electron/updater-process.test.ts index 96fc1de2c8..dcbe0914dc 100644 --- a/apps/desktop/electron/updater-process.test.ts +++ b/apps/desktop/electron/updater-process.test.ts @@ -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() diff --git a/apps/desktop/electron/updater-process.ts b/apps/desktop/electron/updater-process.ts index 04eef34f64..70af87a288 100644 --- a/apps/desktop/electron/updater-process.ts +++ b/apps/desktop/electron/updater-process.ts @@ -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): 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). diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 73f11ea394..097e0d10fd 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -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()) 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) { diff --git a/apps/desktop/src/app/cron/cron-job-model.test.ts b/apps/desktop/src/app/cron/cron-job-model.test.ts index 8c45f66bdd..c76df80abf 100644 --- a/apps/desktop/src/app/cron/cron-job-model.test.ts +++ b/apps/desktop/src/app/cron/cron-job-model.test.ts @@ -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 `." + + 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( diff --git a/apps/desktop/src/app/cron/cron-job-model.ts b/apps/desktop/src/app/cron/cron-job-model.ts index 9b800aea70..cbbe43e29d 100644 --- a/apps/desktop/src/app/cron/cron-job-model.ts +++ b/apps/desktop/src/app/cron/cron-job-model.ts @@ -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 = { diff --git a/apps/desktop/src/app/cron/index.tsx b/apps/desktop/src/app/cron/index.tsx index ee7f0af5aa..9bbf2e44ff 100644 --- a/apps/desktop/src/app/cron/index.tsx +++ b/apps/desktop/src/app/cron/index.tsx @@ -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 ? ( -
- - {job.last_error} +
+
+ + + {c.lastRunFailed} {lastErrorSummary(job.last_error)} + +
+
+ + {c.editJob} + + + {c.runAgain} + +
) : null} diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx index 7c77fb602a..2bef049675 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx @@ -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) { 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() + 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(() => undefined)) + ;(window as { hermesDesktop?: unknown }).hermesDesktop = desktop + + render() + 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() + 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 diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index baa0899e21..94afeec195 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -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) { diff --git a/apps/desktop/src/app/messaging/index.tsx b/apps/desktop/src/app/messaging/index.tsx index aed723a581..e36ba08bd4 100644 --- a/apps/desktop/src/app/messaging/index.tsx +++ b/apps/desktop/src/app/messaging/index.tsx @@ -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) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/agent-init-error.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/agent-init-error.test.tsx index 3be0467bd8..2706ed6e46 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/agent-init-error.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/agent-init-error.test.tsx @@ -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', () => { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.approval-timeout.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.approval-timeout.test.ts new file mode 100644 index 0000000000..72aa6e7d58 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.approval-timeout.test.ts @@ -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): 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) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts index 56aab46b87..a9ff3ad2ea 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/input-requests.ts @@ -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) { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/status.test.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/status.test.ts new file mode 100644 index 0000000000..654573dba1 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/status.test.ts @@ -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() }, + 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() + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/status.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/status.ts index 3fb0ff7e75..330fe017ac 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/status.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/status.ts @@ -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) { diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/types.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/types.ts index 1733428ffd..575e1565e1 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/types.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event/types.ts @@ -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: ( diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 86c23aa74d..1d0dcf0730 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -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 } : {}) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx index edbf615359..a921f1763f 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.test.tsx @@ -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 } }) diff --git a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts index 63d346a591..beefd9b04c 100644 --- a/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-prompt-actions/index.ts @@ -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] diff --git a/apps/desktop/src/app/settings/plugin-install-modal.tsx b/apps/desktop/src/app/settings/plugin-install-modal.tsx index 5aaa636c62..0dfb390e18 100644 --- a/apps/desktop/src/app/settings/plugin-install-modal.tsx +++ b/apps/desktop/src/app/settings/plugin-install-modal.tsx @@ -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: { diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index cfb8d695b2..176342cdcb 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -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?.() diff --git a/apps/desktop/src/app/skills/embedded-hub-picker.tsx b/apps/desktop/src/app/skills/embedded-hub-picker.tsx index 5c750c2e9f..9139cecbe9 100644 --- a/apps/desktop/src/app/skills/embedded-hub-picker.tsx +++ b/apps/desktop/src/app/skills/embedded-hub-picker.tsx @@ -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) diff --git a/apps/desktop/src/app/skills/index.tsx b/apps/desktop/src/app/skills/index.tsx index a363be5e84..4a57291c58 100644 --- a/apps/desktop/src/app/skills/index.tsx +++ b/apps/desktop/src/app/skills/index.tsx @@ -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) { diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 23912bde34..4fc0979143 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -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 ( openExternalLink(INSTALLER_URL)} size="sm"> + {u.openDownloadPage} + + ) : undefined + } + body={lead} + detail={detail ?? undefined} icon={} title={u.notAvailableTitle} /> @@ -210,9 +236,16 @@ function IdleView({ return ( - {u.tryAgain} - +
+ + {target === 'backend' && ( + + )} +
} body={u.connectionRetry} detail={status.message} diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index fe64f34974..349a3dd1df 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -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 } diff --git a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx index cbbc221dfb..7fbc323a10 100644 --- a/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/mcp-setup-tool.tsx @@ -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 } diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx index 0f75238f05..48b97aef5f 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.test.tsx @@ -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, 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 {`${location.pathname}${location.search}`} +} + 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() + + 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( + + ) + + 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( + + ) + + 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() + + 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( + + ) + + 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() + + 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( + + + + + ) + + 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', () => { diff --git a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx index 76fa933395..7494401bdc 100644 --- a/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/assistant-message.tsx @@ -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
- - +
{onDismissError && ( { // // 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 ( <> -
{label}
- {isOAuthReauthSurface(surface) && ( -
{t.assistant.thread.errorOauthExpired(surface.providerLabel || surface.provider)}
+
{title}
+
{body}
+ {errorText && ( +
+ {t.assistant.thread.errorDetails} +
{errorText}
+
)} ) @@ -484,12 +501,100 @@ const ErrorLayerLabel: FC = () => { // Isolated because useNavigate() THROWS outside a (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 ( - + ) +} + +// Settings → Keys deep link for a rejected API key: `?tab=keys` plus +// `&key=` 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 ( + + ) +} + +// "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 ( + ) @@ -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 (
- {ownershipRefusal && ( + {plan.editMessage && } + {plan.compress && } + {plan.chooseModel && ( + + )} + {plan.startNewSession && ( )} - {oauthReauth && ( + {plan.signInAgain && isOAuthReauthSurface(surface) && ( )} - {retryable && ( + {plan.updateApiKey && inRouter && ( + } + label={copy.errorUpdateApiKey} + to={updateApiKeyRoute(surface)} + /> + )} + {plan.openHermesFolder && localFolders && ( + + )} + {plan.retry && ( )} - {showSwitchProvider && inRouter && } - {window.hermesDesktop?.logsRoot && ( + {plan.switchProvider && inRouter && ( + + )} + {localFolders && ( diff --git a/apps/desktop/src/components/assistant-ui/tool/approval.tsx b/apps/desktop/src/components/assistant-ui/tool/approval.tsx index e982bfe982..c82877cac4 100644 --- a/apps/desktop/src/components/assistant-ui/tool/approval.tsx +++ b/apps/desktop/src/components/assistant-ui/tool/approval.tsx @@ -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 } diff --git a/apps/desktop/src/components/boot-failure-cause.test.ts b/apps/desktop/src/components/boot-failure-cause.test.ts new file mode 100644 index 0000000000..89f30e9665 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-cause.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/components/boot-failure-cause.ts b/apps/desktop/src/components/boot-failure-cause.ts new file mode 100644 index 0000000000..a8d36fc656 --- /dev/null +++ b/apps/desktop/src/components/boot-failure-cause.ts @@ -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 +): 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 } +} diff --git a/apps/desktop/src/components/boot-failure-overlay.tsx b/apps/desktop/src/components/boot-failure-overlay.tsx index f001e50f29..e6a432df69 100644 --- a/apps/desktop/src/components/boot-failure-overlay.tsx +++ b/apps/desktop/src/components/boot-failure-overlay.tsx @@ -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() {
- {sshFailureMessage(connectionConfig, boot.error, t.settings.gateway)} + {failureCopy.headline} + {failureCopy.rawDetail ? ( +
+ {copy.details} +
+                  {failureCopy.rawDetail}
+                
+
+ ) : null}
diff --git a/apps/desktop/src/components/desktop-install-overlay.tsx b/apps/desktop/src/components/desktop-install-overlay.tsx index 4f81483239..a8e6c0c9dc 100644 --- a/apps/desktop/src/components/desktop-install-overlay.tsx +++ b/apps/desktop/src/components/desktop-install-overlay.tsx @@ -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
{copy.error}
-

{state.error}

+

{failureLead}

+ {failureDetail ? ( +

+ {failureDetail} +

+ ) : null}
)} @@ -673,6 +696,13 @@ export function DesktopInstallOverlay({ enabled = true }: DesktopInstallOverlayP %LOCALAPPDATA%\hermes\logs\
+ +
) diff --git a/apps/desktop/src/components/first-run-remote-form.tsx b/apps/desktop/src/components/first-run-remote-form.tsx index 411bb54fbc..0df6cf9c12 100644 --- a/apps/desktop/src/components/first-run-remote-form.tsx +++ b/apps/desktop/src/components/first-run-remote-form.tsx @@ -259,7 +259,15 @@ export function FirstRunRemoteForm({ onBack }: FirstRunRemoteFormProps) { {probeStatus === 'error' ? (
- {probe?.error || copy.probeError} +
+ {copy.probeError} + {probe?.error ? ( +
+ {copy.probeErrorDetails} +
{probe.error}
+
+ ) : null} +
) : null} diff --git a/apps/desktop/src/components/notifications.test.tsx b/apps/desktop/src/components/notifications.test.tsx index 2608f41a98..9a0a6823f5 100644 --- a/apps/desktop/src/components/notifications.test.tsx +++ b/apps/desktop/src/components/notifications.test.tsx @@ -65,7 +65,7 @@ describe('toast titles', () => { ) - 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' })) diff --git a/apps/desktop/src/components/onboarding/flow.tsx b/apps/desktop/src/components/onboarding/flow.tsx index d3a8f0783d..1e042f67f5 100644 --- a/apps/desktop/src/components/onboarding/flow.tsx +++ b/apps/desktop/src/components/onboarding/flow.tsx @@ -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 (
{flow.message || t.onboarding.signInFailed}
-
- + + {failedProvider ? ( + + ) : null}
) diff --git a/apps/desktop/src/components/prompt-overlays.tsx b/apps/desktop/src/components/prompt-overlays.tsx index 9aff32f3e4..ac18b6a42d 100644 --- a/apps/desktop/src/components/prompt-overlays.tsx +++ b/apps/desktop/src/components/prompt-overlays.tsx @@ -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 } diff --git a/apps/desktop/src/i18n/ar.ts b/apps/desktop/src/i18n/ar.ts index f06a030c0e..dde6c02184 100644 --- a/apps/desktop/src/i18n/ar.ts +++ b/apps/desktop/src/i18n/ar.ts @@ -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: 'بعد التحديث ما زال هذا الخلفية يشغّل كودا قديما. أعد تشغيله لتحميل الكود الجديد.' }, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 30d44360e3..49f196207a 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -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' }, diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index a6867ac7e3..0b676aa597 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -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: 'アップデート後、このバックエンドは古いコードのままです。再起動して新しいコードを読み込んでください。' diff --git a/apps/desktop/src/i18n/ru.ts b/apps/desktop/src/i18n/ru.ts index 501d7504b1..d6c49e4ce8 100644 --- a/apps/desktop/src/i18n/ru.ts +++ b/apps/desktop/src/i18n/ru.ts @@ -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: { diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 8d7f9faa8b..c70f0ad0dc 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -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 + lastRunFailed: string + editJob: string + runAgain: string deliveryLabels: Record scheduleLabels: Record scheduleHints: Record @@ -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 + /** Auth layer, keyed on how the provider is credentialed. The OAuth + * body is `errorOauthExpired` (already translated per locale). */ + errorAuthKinds: { api_key: ErrorCardCopy; oauth: Pick } + /** 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 } diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 7f076a766e..56f1193614 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -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: '更新後此後端仍在執行舊程式碼。請重新啟動以載入新程式碼。' }, diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 36402d3473..ba1f74d9a9 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -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: { diff --git a/apps/desktop/src/lib/error-surface-copy.ts b/apps/desktop/src/lib/error-surface-copy.ts new file mode 100644 index 0000000000..122ed35914 --- /dev/null +++ b/apps/desktop/src/lib/error-surface-copy.ts @@ -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] } +} diff --git a/apps/desktop/src/lib/error-surface.test.ts b/apps/desktop/src/lib/error-surface.test.ts index 3ed04ca4b2..b4cf61a9a6 100644 --- a/apps/desktop/src/lib/error-surface.test.ts +++ b/apps/desktop/src/lib/error-surface.test.ts @@ -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) + }) +}) diff --git a/apps/desktop/src/lib/error-surface.ts b/apps/desktop/src/lib/error-surface.ts index 0fa96ef0ca..db1798ce6f 100644 --- a/apps/desktop/src/lib/error-surface.ts +++ b/apps/desktop/src/lib/error-surface.ts @@ -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>> = { + 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 diff --git a/apps/desktop/src/plugins/hermes-bots/canonical-chat-open-failure-toast.test.ts b/apps/desktop/src/plugins/hermes-bots/canonical-chat-open-failure-toast.test.ts new file mode 100644 index 0000000000..8c7945a99d --- /dev/null +++ b/apps/desktop/src/plugins/hermes-bots/canonical-chat-open-failure-toast.test.ts @@ -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' } + }) + }) +}) diff --git a/apps/desktop/src/plugins/hermes-bots/canonical-chat.ts b/apps/desktop/src/plugins/hermes-bots/canonical-chat.ts index f25c72963b..81abb9afae 100644 --- a/apps/desktop/src/plugins/hermes-bots/canonical-chat.ts +++ b/apps/desktop/src/plugins/hermes-bots/canonical-chat.ts @@ -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", diff --git a/apps/desktop/src/plugins/hermes-bots/i18n.ts b/apps/desktop/src/plugins/hermes-bots/i18n.ts index d6b96cf348..f0f521c023 100644 --- a/apps/desktop/src/plugins/hermes-bots/i18n.ts +++ b/apps/desktop/src/plugins/hermes-bots/i18n.ts @@ -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: '你好,介紹一下你自己吧!' }, diff --git a/apps/desktop/src/plugins/hermes-bots/roster-actions.ts b/apps/desktop/src/plugins/hermes-bots/roster-actions.ts index e6e699c0f5..95e60f45b4 100644 --- a/apps/desktop/src/plugins/hermes-bots/roster-actions.ts +++ b/apps/desktop/src/plugins/hermes-bots/roster-actions.ts @@ -265,7 +265,7 @@ export async function openRosterBot(bot: RosterRow): Promise { 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 { 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 diff --git a/apps/desktop/src/plugins/kanban/completion-notify.test.ts b/apps/desktop/src/plugins/kanban/completion-notify.test.ts index dc628f4eca..f614a55a0e 100644 --- a/apps/desktop/src/plugins/kanban/completion-notify.test.ts +++ b/apps/desktop/src/plugins/kanban/completion-notify.test.ts @@ -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') }) }) diff --git a/apps/desktop/src/plugins/kanban/completion-notify.ts b/apps/desktop/src/plugins/kanban/completion-notify.ts index bc57919611..cba2f23f49 100644 --- a/apps/desktop/src/plugins/kanban/completion-notify.ts +++ b/apps/desktop/src/plugins/kanban/completion-notify.ts @@ -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({ diff --git a/apps/desktop/src/plugins/kanban/i18n.ts b/apps/desktop/src/plugins/kanban/i18n.ts index 2dda287fdc..57c8c105e8 100644 --- a/apps/desktop/src/plugins/kanban/i18n.ts +++ b/apps/desktop/src/plugins/kanban/i18n.ts @@ -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} 個產物` } diff --git a/apps/desktop/src/store/gateway-reconnect.ts b/apps/desktop/src/store/gateway-reconnect.ts index b9883aff87..8c5a59a696 100644 --- a/apps/desktop/src/store/gateway-reconnect.ts +++ b/apps/desktop/src/store/gateway-reconnect.ts @@ -1,3 +1,5 @@ +import { translateNow } from '@/i18n' + type GatewayReconnectHandler = () => Promise | void let activeHandler: GatewayReconnectHandler | null = null @@ -32,3 +34,12 @@ export function reconnectGateway(): Promise { 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) + } +} diff --git a/apps/desktop/src/store/hub-actions.blocked.test.ts b/apps/desktop/src/store/hub-actions.blocked.test.ts new file mode 100644 index 0000000000..d72e1a6743 --- /dev/null +++ b/apps/desktop/src/store/hub-actions.blocked.test.ts @@ -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') +}) diff --git a/apps/desktop/src/store/hub-actions.ts b/apps/desktop/src/store/hub-actions.ts index 4fde09ae42..d1cf3f320d 100644 --- a/apps/desktop/src/store/hub-actions.ts +++ b/apps/desktop/src/store/hub-actions.ts @@ -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 { 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+(?:(?\d+)\s+)?high-risk\s+pattern/i + +const INSTALL_BLOCKED_UNVERIFIED_RE = /never installs\s+unverified/i + +const INSTALL_BLOCKED_LEGACY_RE = + /Installation blocked:.*?\((?[a-z_-]+) source \+ (?[a-z_]+) verdict, (?\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'))) + } + }) +} diff --git a/apps/desktop/src/store/notifications.test.ts b/apps/desktop/src/store/notifications.test.ts index c33860e4a4..e065c7daf9 100644 --- a/apps/desktop/src/store/notifications.test.ts +++ b/apps/desktop/src/store/notifications.test.ts @@ -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) }) diff --git a/apps/desktop/src/store/notifications.ts b/apps/desktop/src/store/notifications.ts index 6fa272d5ea..f741cf228f 100644 --- a/apps/desktop/src/store/notifications.ts +++ b/apps/desktop/src/store/notifications.ts @@ -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, diff --git a/apps/desktop/src/store/onboarding.test.ts b/apps/desktop/src/store/onboarding.test.ts index 26fad91790..f41cbbad76 100644 --- a/apps/desktop/src/store/onboarding.test.ts +++ b/apps/desktop/src/store/onboarding.test.ts @@ -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/) } }) diff --git a/apps/desktop/src/store/onboarding.ts b/apps/desktop/src/store/onboarding.ts index 9b6c486886..fada42bd92 100644 --- a/apps/desktop/src/store/onboarding.ts +++ b/apps/desktop/src/store/onboarding.ts @@ -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 = 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) => $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) }) } } diff --git a/apps/desktop/src/store/recovery-requests.ts b/apps/desktop/src/store/recovery-requests.ts new file mode 100644 index 0000000000..38cc480602 --- /dev/null +++ b/apps/desktop/src/store/recovery-requests.ts @@ -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(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) +} diff --git a/apps/desktop/src/store/terminal-backend-warning.test.ts b/apps/desktop/src/store/terminal-backend-warning.test.ts new file mode 100644 index 0000000000..fc69b5a625 --- /dev/null +++ b/apps/desktop/src/store/terminal-backend-warning.test.ts @@ -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) +}) diff --git a/apps/desktop/src/store/terminal-backend-warning.ts b/apps/desktop/src/store/terminal-backend-warning.ts new file mode 100644 index 0000000000..9c6238e5b7 --- /dev/null +++ b/apps/desktop/src/store/terminal-backend-warning.ts @@ -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 { + 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 +} diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 2ea8cdd6cb..e984b4675a 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -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', () => { diff --git a/ui-tui/src/__tests__/createSlashHandler.test.ts b/ui-tui/src/__tests__/createSlashHandler.test.ts index 67f2609f91..ad8b1323db 100644 --- a/ui-tui/src/__tests__/createSlashHandler.test.ts +++ b/ui-tui/src/__tests__/createSlashHandler.test.ts @@ -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' }) diff --git a/ui-tui/src/__tests__/userMessages.test.ts b/ui-tui/src/__tests__/userMessages.test.ts new file mode 100644 index 0000000000..46c2e52be1 --- /dev/null +++ b/ui-tui/src/__tests__/userMessages.test.ts @@ -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') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 2155386ae1..8f878fd266 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -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: ". 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') } } diff --git a/ui-tui/src/app/createSlashHandler.ts b/ui-tui/src/app/createSlashHandler.ts index 75f19c7af5..fd8bc359fa 100644 --- a/ui-tui/src/app/createSlashHandler.ts +++ b/ui-tui/src/app/createSlashHandler.ts @@ -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()) { diff --git a/ui-tui/src/app/slash/commands/ops.ts b/ui-tui/src/app/slash/commands/ops.ts index f6f1d5ed5b..28aeb59b67 100644 --- a/ui-tui/src/app/slash/commands/ops.ts +++ b/ui-tui/src/app/slash/commands/ops.ts @@ -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( diff --git a/ui-tui/src/app/useMainApp.ts b/ui-tui/src/app/useMainApp.ts index df15d49489..86b7516fcb 100644 --- a/ui-tui/src/app/useMainApp.ts +++ b/ui-tui/src/app/useMainApp.ts @@ -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) const recoveryAtRef = useRef([]) + // "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()) const msgIdSeqRef = useRef(0) const heightCachesRef = useRef(new Map>()) @@ -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) diff --git a/ui-tui/src/app/useSessionLifecycle.ts b/ui-tui/src/app/useSessionLifecycle.ts index f14a6c8f30..959c469e39 100644 --- a/ui-tui/src/app/useSessionLifecycle.ts +++ b/ui-tui/src/app/useSessionLifecycle.ts @@ -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) { diff --git a/ui-tui/src/app/userMessages.ts b/ui-tui/src/app/userMessages.ts new file mode 100644 index 0000000000..dc48162b83 --- /dev/null +++ b/ui-tui/src/app/userMessages.ts @@ -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 = { + 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 = { + 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 + 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 = { + 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 .' diff --git a/ui-tui/src/components/skillsHub.tsx b/ui-tui/src/components/skillsHub.tsx index 87ec3339d3..8f8719c7ec 100644 --- a/ui-tui/src/components/skillsHub.tsx +++ b/ui-tui/src/components/skillsHub.tsx @@ -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 ( - no skills available + {NO_SKILLS_INSTALLED} Esc/q cancel ) diff --git a/ui-tui/src/entry.tsx b/ui-tui/src/entry.tsx index 6f856cf57a..79bc67daf5 100644 --- a/ui-tui/src/entry.tsx +++ b/ui-tui/src/entry.tsx @@ -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) => diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 29f709453b..31f8881a06 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -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. diff --git a/ui-tui/src/lib/rpc.ts b/ui-tui/src/lib/rpc.ts index f95b4d4a05..f2fc1d0aed 100644 --- a/ui-tui/src/lib/rpc.ts +++ b/ui-tui/src/lib/rpc.ts @@ -1,7 +1,16 @@ +import { describeRpcError } from '../app/userMessages.js' + export type RpcResult = Record export const asRpcResult = (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'