From 17972339896f636f3f1f3028222f9293ee239867 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 08:16:44 +0000 Subject: [PATCH] fix(tui): surface model errors --- CHANGELOG.md | 1 + ...lpers.formatrawassistanterrorforui.test.ts | 21 ++++++ src/agents/pi-embedded-helpers.ts | 2 + src/agents/pi-embedded-helpers/errors.ts | 66 +++++++++++++++++++ src/auto-reply/reply/agent-runner-utils.ts | 2 +- src/tui/tui-event-handlers.ts | 8 ++- src/tui/tui-formatters.test.ts | 31 +++++++++ src/tui/tui-formatters.ts | 10 ++- 8 files changed, 138 insertions(+), 3 deletions(-) create mode 100644 src/agents/pi-embedded-helpers.formatrawassistanterrorforui.test.ts create mode 100644 src/tui/tui-formatters.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cf9603a8..8de3167d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -73,6 +73,7 @@ - UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor. - TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. - TUI: add a bright spinner + elapsed time in the status line for send/stream/run states. +- TUI: show LLM error messages (rate limits, auth, etc.) instead of `(no output)`. - Gateway/Dev: ensure `pnpm gateway:dev` always uses the dev profile config + state (`~/.clawdbot-dev`). - macOS: fix cron preview/testing payload to use `channel` key. (#867) — thanks @wes-davis. - Telegram: honor `channels.telegram.timeoutSeconds` for grammY API requests. (#863) — thanks @Snaver. diff --git a/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.test.ts b/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.test.ts new file mode 100644 index 000000000..95e162f34 --- /dev/null +++ b/src/agents/pi-embedded-helpers.formatrawassistanterrorforui.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { formatRawAssistantErrorForUi } from "./pi-embedded-helpers.js"; + +describe("formatRawAssistantErrorForUi", () => { + it("renders HTTP code + type + message from Anthropic payloads", () => { + const text = formatRawAssistantErrorForUi( + '429 {"type":"error","error":{"type":"rate_limit_error","message":"Rate limited."},"request_id":"req_123"}', + ); + + expect(text).toContain("HTTP 429"); + expect(text).toContain("rate_limit_error"); + expect(text).toContain("Rate limited."); + expect(text).toContain("req_123"); + }); + + it("renders a generic unknown error message when raw is empty", () => { + expect(formatRawAssistantErrorForUi("")).toContain("unknown error"); + }); +}); + diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index 0f38772f4..f92e02992 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -7,11 +7,13 @@ export { } from "./pi-embedded-helpers/bootstrap.js"; export { classifyFailoverReason, + formatRawAssistantErrorForUi, formatAssistantErrorText, getApiErrorPayloadFingerprint, isAuthAssistantError, isAuthErrorMessage, isBillingAssistantError, + parseApiErrorInfo, isBillingErrorMessage, isCloudCodeAssistFormatError, isCompactionFailureError, diff --git a/src/agents/pi-embedded-helpers/errors.ts b/src/agents/pi-embedded-helpers/errors.ts index 2b76592ee..6eb6383cf 100644 --- a/src/agents/pi-embedded-helpers/errors.ts +++ b/src/agents/pi-embedded-helpers/errors.ts @@ -100,6 +100,72 @@ export function isRawApiErrorPayload(raw?: string): boolean { return getApiErrorPayloadFingerprint(raw) !== null; } +export type ApiErrorInfo = { + httpCode?: string; + type?: string; + message?: string; + requestId?: string; +}; + +export function parseApiErrorInfo(raw?: string): ApiErrorInfo | null { + if (!raw) return null; + const trimmed = raw.trim(); + if (!trimmed) return null; + + let httpCode: string | undefined; + let candidate = trimmed; + + const httpPrefixMatch = candidate.match(/^(\d{3})\s+(.+)$/s); + if (httpPrefixMatch) { + httpCode = httpPrefixMatch[1]; + candidate = httpPrefixMatch[2].trim(); + } + + const payload = parseApiErrorPayload(candidate); + if (!payload) return null; + + const requestId = + typeof payload.request_id === "string" + ? payload.request_id + : typeof payload.requestId === "string" + ? payload.requestId + : undefined; + + const topType = typeof payload.type === "string" ? payload.type : undefined; + const topMessage = typeof payload.message === "string" ? payload.message : undefined; + + let errType: string | undefined; + let errMessage: string | undefined; + if (payload.error && typeof payload.error === "object" && !Array.isArray(payload.error)) { + const err = payload.error as Record; + if (typeof err.type === "string") errType = err.type; + if (typeof err.code === "string" && !errType) errType = err.code; + if (typeof err.message === "string") errMessage = err.message; + } + + return { + httpCode, + type: errType ?? topType, + message: errMessage ?? topMessage, + requestId, + }; +} + +export function formatRawAssistantErrorForUi(raw?: string): string { + const trimmed = (raw ?? "").trim(); + if (!trimmed) return "LLM request failed with an unknown error."; + + const info = parseApiErrorInfo(trimmed); + if (info?.message) { + const prefix = info.httpCode ? `HTTP ${info.httpCode}` : "LLM error"; + const type = info.type ? ` ${info.type}` : ""; + const requestId = info.requestId ? ` (request_id: ${info.requestId})` : ""; + return `${prefix}${type}: ${info.message}${requestId}`; + } + + return trimmed.length > 600 ? `${trimmed.slice(0, 600)}…` : trimmed; +} + export function formatAssistantErrorText( msg: AssistantMessage, opts?: { cfg?: ClawdbotConfig; sessionKey?: string }, diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 4f955b8aa..5caf4399e 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -1,7 +1,7 @@ import type { NormalizedUsage } from "../../agents/usage.js"; import { getChannelDock } from "../../channels/dock.js"; import type { ChannelThreadingToolContext } from "../../channels/plugins/types.js"; -import { normalizeChannelId } from "../../channels/plugins/index.js"; +import { normalizeChannelId } from "../../channels/registry.js"; import type { ClawdbotConfig } from "../../config/config.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import { estimateUsageCost, formatTokenCount, formatUsd } from "../../utils/usage-format.js"; diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 4d9658f67..10de1438a 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -47,6 +47,12 @@ export function createEventHandlers(context: EventHandlerContext) { setActivityStatus("streaming"); } if (evt.state === "final") { + const stopReason = + evt.message && typeof evt.message === "object" && !Array.isArray(evt.message) + ? typeof (evt.message as Record).stopReason === "string" + ? ((evt.message as Record).stopReason as string) + : "" + : ""; const text = extractTextFromMessage(evt.message, { includeThinking: state.showThinking, }); @@ -57,7 +63,7 @@ export function createEventHandlers(context: EventHandlerContext) { chatLog.finalizeAssistant(finalText, evt.runId); noteFinalizedRun(evt.runId); state.activeChatRunId = null; - setActivityStatus("idle"); + setActivityStatus(stopReason === "error" ? "error" : "idle"); } if (evt.state === "aborted") { chatLog.addSystem("run aborted"); diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts new file mode 100644 index 000000000..3e74e1e72 --- /dev/null +++ b/src/tui/tui-formatters.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { extractTextFromMessage } from "./tui-formatters.js"; + +describe("extractTextFromMessage", () => { + it("renders errorMessage when assistant content is empty", () => { + const text = extractTextFromMessage({ + role: "assistant", + content: [], + stopReason: "error", + errorMessage: + '429 {"type":"error","error":{"type":"rate_limit_error","message":"This request would exceed your account\\u0027s rate limit. Please try again later."},"request_id":"req_123"}', + }); + + expect(text).toContain("HTTP 429"); + expect(text).toContain("rate_limit_error"); + expect(text).toContain("req_123"); + }); + + it("falls back to a generic message when errorMessage is missing", () => { + const text = extractTextFromMessage({ + role: "assistant", + content: [], + stopReason: "error", + errorMessage: "", + }); + + expect(text).toContain("unknown error"); + }); +}); + diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 98ca38f05..c869a52c3 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -1,4 +1,5 @@ import { formatTokenCount } from "../utils/usage-format.js"; +import { formatRawAssistantErrorForUi } from "../agents/pi-embedded-helpers.js"; export function resolveFinalAssistantText(params: { finalText?: string | null; @@ -38,7 +39,14 @@ export function extractTextFromMessage( ): string { if (!message || typeof message !== "object") return ""; const record = message as Record; - return extractTextBlocks(record.content, opts); + const text = extractTextBlocks(record.content, opts); + if (text) return text; + + const stopReason = typeof record.stopReason === "string" ? record.stopReason : ""; + if (stopReason !== "error") return ""; + + const errorMessage = typeof record.errorMessage === "string" ? record.errorMessage : ""; + return formatRawAssistantErrorForUi(errorMessage); } export function formatTokens(total?: number | null, context?: number | null) {