From d0b48d63efb88ac4f9f5eb447a39ea95cb2b0542 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 2 Oct 2026 16:07:52 +0700 Subject: [PATCH] fix: make common error messages short and helpful (#163381) * fix: make common error messages short and helpful Lead with a useful next step across chat, CLI, and Control UI failures. Keep diagnostics available on demand and preserve model fallback through typed failure metadata. * fix: preserve recovery guidance in concise errors * test(codex): include model catalog reads in context checks * test(ui): preserve diagnostic content assertions after expansion * fix(ui): keep Stop recovery instructions visible --- docs/cli/index.md | 1 + docs/web/control-ui/feature-reference.md | 2 +- ...age-dispatch.preview.telegram-http.test.ts | 2 +- src/agents/auth-profiles/failure-copy.test.ts | 57 +-------- src/agents/code-mode.bridge.lifecycle.test.ts | 5 +- ...t-helpers.formatassistanterrortext.test.ts | 80 ++++++------ ...ent-helpers.sanitizeuserfacingtext.test.ts | 7 +- .../embedded-agent-helpers/error-text.ts | 25 ++-- .../result-fallback-classifier.test.ts | 120 +++++++++++------- .../result-fallback-classifier.ts | 35 +++-- ...odex-server-error-fallback.test-support.ts | 2 +- ...der-fallback-error-context.test-support.ts | 4 +- .../run.harness-prompt-error.test-support.ts | 4 +- .../run/assistant-failure.failover.test.ts | 4 +- .../run/auth-controller.ts | 3 - .../run/payloads.errors.test.ts | 19 +-- .../embedded-agent-runner/run/payloads.ts | 19 ++- .../run/prompt-failure.test.ts | 2 +- .../thinking.request-shape-errors.test.ts | 2 +- .../embedded-agent-runner/thinking.test.ts | 2 +- ...agent-subscribe.handlers.lifecycle.test.ts | 10 +- ...sion.subscribeembeddedagentsession.test.ts | 2 +- .../assistant-request-failure-copy.test.ts | 79 ++++++------ .../assistant-request-failure-copy.ts | 112 +++++++--------- src/agents/failover/classify.test.ts | 4 +- .../failover-classification.corpus.test.ts | 6 +- .../provider-structured-signals.test.ts | 42 +++--- src/agents/failover/user-copy.test.ts | 37 +++--- src/agents/failover/user-copy.ts | 107 ++++++---------- src/agents/live-model-errors.test.ts | 2 +- .../openai-transport-stream.streaming.test.ts | 2 +- src/auto-reply/reply-payload.ts | 3 + ...eply.triggers.trigger-handling.e2e.test.ts | 4 +- ...agent-runner-direct-runtime-config.test.ts | 6 +- .../reply/agent-runner-error-handler.ts | 8 +- ...ent-runner-execution-auth-failures.test.ts | 16 +-- ...er-execution-conversation-failures.test.ts | 5 +- ...runner-execution-provider-failures.test.ts | 34 +++-- ...runner-execution-terminal-failures.test.ts | 40 +++--- .../agent-runner-execution.test-support.ts | 4 +- .../reply/agent-runner-failure-reply.test.ts | 8 +- .../reply/agent-runner-failure-reply.ts | 9 +- .../reply/agent-runner-fallback-settlement.ts | 3 +- src/auto-reply/reply/agent-runner-payloads.ts | 2 +- src/auto-reply/reply/agent-runner-utils.ts | 11 +- .../agent-runner.misc.runreplyagent.test.ts | 6 +- .../agent-runner.runreplyagent.e2e.test.ts | 10 +- src/auto-reply/reply/commands-approve.test.ts | 12 +- src/auto-reply/reply/commands-approve.ts | 11 +- src/auto-reply/reply/commands-btw.ts | 8 +- src/auto-reply/reply/commands-tts.ts | 13 +- ...atch-from-config.terminal-recovery.test.ts | 6 +- .../reply/followup-delivery.channel.test.ts | 5 +- src/cli/failure-output.contention.test.ts | 21 ++- src/cli/failure-output.test.ts | 40 +++++- src/cli/failure-output.ts | 55 ++++++-- src/cli/run-main.exit.test.ts | 2 +- ...-display-projection.provider-owner.test.ts | 8 +- .../server-methods/chat-aborted-partial.ts | 1 + .../chat-send-dispatch-errors.test.ts | 4 +- .../server.chat-recovered-output.test.ts | 2 +- src/infra/update-candidate-canary.test.ts | 8 +- src/shared/assistant-error-format.test.ts | 2 +- src/shared/assistant-error-format.ts | 31 ++--- src/tui/tui-formatters.test.ts | 2 +- ui/src/components/lazy-view-error.ts | 9 +- ui/src/components/login-gate-feedback.ts | 1 + ui/src/components/login-gate.test.ts | 8 +- ui/src/e2e/chat-error-details.e2e.test.ts | 10 +- ...hat-flow.stream-reconciliation.e2e.test.ts | 11 +- ui/src/e2e/chat-loading-deadlines.e2e.test.ts | 11 +- .../e2e/chat-placement-recovery.e2e.test.ts | 29 ++++- ui/src/e2e/chat-provider-review.e2e.test.ts | 16 ++- ui/src/e2e/chat-run-lifecycle.e2e.test.ts | 11 +- .../chat-stop-response-ownership.e2e.test.ts | 16 ++- .../cloud-reconciliation-followup.e2e.test.ts | 13 +- .../e2e/lazy-route-error-layout.e2e.test.ts | 9 ++ ui/src/e2e/login-gate.e2e.test.ts | 4 +- ui/src/i18n/locales/en-login.ts | 5 +- ui/src/i18n/locales/en.ts | 4 + ui/src/lib/gateway-errors.ts | 2 +- ui/src/pages/chat/chat-gateway.test.ts | 2 +- ui/src/pages/chat/chat-gateway.ts | 14 +- ui/src/pages/chat/chat-view-notices.test.ts | 4 +- ui/src/pages/chat/chat-view-notices.ts | 26 ++-- ui/src/pages/chat/run-lifecycle.ts | 6 +- ui/src/pages/usage/usage-page-details.test.ts | 6 +- ui/src/styles/components.css | 6 + 88 files changed, 804 insertions(+), 617 deletions(-) diff --git a/docs/cli/index.md b/docs/cli/index.md index 08c65af31a3c..207497039164 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -75,6 +75,7 @@ Use `--` to stop option parsing. Command words still dispatch after it: for exam and pure side-effect commands may omit `--json` when they have no meaningful report to return. - Long-running commands show a progress indicator (`OSC 9;4` when supported). +- Connection failures give a short explanation and a status-check command. If a request may have completed, check its result before retrying. Unexpected failures point to a recovery command; use `OPENCLAW_DEBUG=1` when rerunning to include diagnostics. JSON failures keep their diagnostic fields. ### JSON failures diff --git a/docs/web/control-ui/feature-reference.md b/docs/web/control-ui/feature-reference.md index 9ecb3b16a9ad..ffaa22e04b07 100644 --- a/docs/web/control-ui/feature-reference.md +++ b/docs/web/control-ui/feature-reference.md @@ -19,7 +19,7 @@ Control UI capabilities grouped by area, each with the Gateway RPC methods behin - Thinking controls and `/think` use the selected model's published choices and default. A model with no choices offers no effort control or default. Missing capability metadata is shown as unknown, without guessed levels; an explicit command still uses server validation. Saved overrides and matching inherited choices remain separate from the active fallback model. - Opening or refreshing chat requests up to 80 recent messages. Each background warming pass reads at most two inactive sessions sequentially, with up to 20 messages per session, after presented chat loads finish. Automatic warming waits for a visible conversation on the current page; dashboard-only views still warm the session you hover or keyboard-focus. Hover and keyboard intent use a short delay without waiting for browser idle time or another session's cooldown; the intended session's own cooldown still applies. Scrolling back requests up to 1,000 older messages per page and prefetches the next page. Per-message text caps and response-byte limits can reduce these counts. - A previous run's error banner clears when Chat adopts a new run or history confirms a newer successful run. Retiring the banner does not erase recorded diagnostics. A late error from the same run can remain visible beside its delivered answer; reconnecting or refreshing metadata alone does not establish recovery. - - Run error banners preserve diagnostic file paths while masking credentials. Expand **Details** to read a long error and use **Copy error** to copy the complete displayed diagnostic. After reopening a chat, recorded failures before a reply supply the diagnostic for their exact run instead of the shortened session-list summary. + - Run error banners show a short explanation and next step. Diagnostic file paths stay behind **Details**, with credentials masked. Expand **Details** to read the full error and use **Copy error** to copy the complete displayed diagnostic. After reopening a chat, recorded failures before a reply supply the diagnostic for their exact run instead of the shortened session-list summary. - A saved assistant answer replaces its live stream without waiting for the run to finish. Refreshing history or reconnecting while that reply finishes does not add another copy of the saved answer. Later streamed continuations remain visible. Remote workspace reconciliation can keep the working indicator and Stop control active after the answer appears; a later reconciliation failure remains visible beside the answer. - Scroll up to read earlier messages without following incoming output. Messages from another participant preserve your position, even at the end; typing indicators do not move the transcript. Sending a message from this pane, submitting a transcript command such as `/help`, or using the down-arrow button returns to the latest message, including when the composer or progress card resizes. Scrolling manually interrupts that movement or a restored scroll position; keys handled by text fields or media controls do not. Messages continue to reserve their space as full text, images, and tool output load. - Session and GitHub hover cards close when their source pane is hidden or retired, including when you use browser Back or Forward. diff --git a/extensions/telegram/src/bot-message-dispatch.preview.telegram-http.test.ts b/extensions/telegram/src/bot-message-dispatch.preview.telegram-http.test.ts index 7e8dd0ed730c..e7f7b95113be 100644 --- a/extensions/telegram/src/bot-message-dispatch.preview.telegram-http.test.ts +++ b/extensions/telegram/src/bot-message-dispatch.preview.telegram-http.test.ts @@ -582,7 +582,7 @@ describe("Telegram preview and presentation delivery through HTTP", () => { expect(visibleBeforeFailure).toEqual([partial]); const visible = [...visibleMessages.values()]; expect(visible, JSON.stringify({ calls, acceptedCalls })).toHaveLength(1); - expect(visible[0]).toContain("Please try again"); + expect(visible[0]).toContain("Check the conversation before trying again"); expect(visible[0]).toContain(partial); expect(JSON.stringify(calls)).not.toContain("private-provider-failure"); }); diff --git a/src/agents/auth-profiles/failure-copy.test.ts b/src/agents/auth-profiles/failure-copy.test.ts index 346610bafd2e..c52332ca9574 100644 --- a/src/agents/auth-profiles/failure-copy.test.ts +++ b/src/agents/auth-profiles/failure-copy.test.ts @@ -63,56 +63,13 @@ describe("renderAuthProfileFailoverCopy", () => { }); }); - describe("cause handling", () => { - it("returns the cause text verbatim when the reason has no actionable copy", () => { - const causeText = "upstream provider returned 502"; - const message = formatAuthProfileFailureMessage({ - reason: "unknown", - provider: PROVIDER, - allInCooldown: false, - causeText, - }); - expect(message).toBe(causeText); - }); - - it("appends a diagnostic suffix when the cause adds detail beyond the description", () => { - const message = formatAuthProfileFailureMessage({ - reason: "auth", - provider: PROVIDER, - allInCooldown: false, - causeText: "invalid_grant", - }); - expect(message).toContain("(invalid_grant)"); - }); - - it("does not append a diagnostic suffix when the cause text is already in the description", () => { - // Derive the description sentence by formatting once without a cause, then stripping - // the mocked recovery hint. Using that sentence as the cause should be deduped. - const withoutCause = formatAuthProfileFailureMessage({ - reason: "auth", - provider: PROVIDER, - allInCooldown: false, - }); - const description = withoutCause - .replace(new RegExp(`\\s*${LOGIN_HINT_SENTINEL}:[^\\s]+\\s*$`), "") - .trim(); - const withDuplicateCause = formatAuthProfileFailureMessage({ - reason: "auth", - provider: PROVIDER, - allInCooldown: false, - causeText: description, - }); - expect(withDuplicateCause).toBe(withoutCause); - }); - - it("produces non-empty copy for unknown reasons with no cause", () => { - const message = formatAuthProfileFailureMessage({ - reason: "unknown", - provider: PROVIDER, - allInCooldown: false, - }); - expect(message).toContain(PROVIDER); - expect(message.length).toBeGreaterThan(0); + it("keeps recovery actionable for an unknown failure", () => { + const message = formatAuthProfileFailureMessage({ + reason: "unknown", + provider: PROVIDER, + allInCooldown: false, }); + expect(message).toContain(PROVIDER); + expect(message).toContain(LOGIN_HINT_SENTINEL); }); }); diff --git a/src/agents/code-mode.bridge.lifecycle.test.ts b/src/agents/code-mode.bridge.lifecycle.test.ts index f26ef01821df..b16fa1bf6e58 100644 --- a/src/agents/code-mode.bridge.lifecycle.test.ts +++ b/src/agents/code-mode.bridge.lifecycle.test.ts @@ -480,7 +480,10 @@ describe("Code Mode subscribed bridge lifecycle", () => { expect(payloads).toEqual([expect.objectContaining({ text: answer })]); } else { expect(payloads).toEqual([ - expect.objectContaining({ isError: true, text: expect.stringMatching(/rate limit/i) }), + expect.objectContaining({ + isError: true, + text: expect.stringContaining("The AI service needs a short break"), + }), ]); } }, diff --git a/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts b/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts index a21286718420..d9e742926182 100644 --- a/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts +++ b/src/agents/embedded-agent-helpers.formatassistanterrortext.test.ts @@ -18,16 +18,14 @@ import { withPreparedFailoverProviders } from "./test-helpers/provider-failover- describe("formatAssistantErrorText", () => { const BILLING_ERROR_USER_MESSAGE = - "⚠️ API provider returned a billing error — your API key has run out of credits or has an insufficient balance. Check your provider's billing dashboard and top up or switch to a different API key."; + "⚠️ The AI service reported a billing problem. Check your account's credit balance and usage limits before trying again."; const makeAssistantError = (errorMessage: string): AssistantMessage => makeAssistantMessageFixture({ errorMessage, content: [{ type: "text", text: errorMessage }], }); const authInvalidTokenCopy = - "Authentication failed (provider returned HTTP 401). " + - "Your provider token may have expired — try the request again in a moment. " + - "If the failure persists, re-authenticate this provider."; + "Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`."; it.each([ [ @@ -50,25 +48,24 @@ describe("formatAssistantErrorText", () => { `, - "The provider returned an HTML error page instead of an API response. This usually means a CDN or gateway (e.g. Cloudflare) blocked the request. Retry in a moment or check provider status.", + "Couldn't reach the AI service. Try again in a moment. If it continues, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", ], ["request ended without sending any chunks", "LLM request timed out."], [ "file lock timeout for /tmp/openclaw-oauth-refresh.lock", - "Authentication refresh is already in progress elsewhere and this attempt timed out waiting for it. Retry in a moment.", + "Another sign-in is still in progress. Wait a moment, then try again.", ], [ "403 Access denied", - "Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.", + "Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`.", ], [ "407 Proxy Authentication Required", - "LLM request failed: proxy or tunnel configuration blocked the provider request.", + "Couldn't connect to the AI service. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", ], [ "Hostname/IP does not match certificate's altnames: Host: api.example.com", - "LLM request failed: TLS certificate validation rejected the provider endpoint. " + - "Check the endpoint hostname, proxy, and local certificate trust.", + "Couldn't connect securely to the AI service. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", ], ])("formats assistant error: %s", (raw, expected) => { expect(formatAssistantErrorText(makeAssistantError(raw))).toBe(expected); @@ -89,7 +86,9 @@ describe("formatAssistantErrorText", () => { const result = formatAssistantErrorText(msg); expect(result).toContain("4381 min"); expect(result).toContain("go plan"); - expect(result).not.toBe("⚠️ API rate limit reached. Please try again later."); + expect(result).not.toBe( + "⚠️ The AI service needs a short break. Please try again in a few minutes.", + ); }); it("returns context overflow for Anthropic 'Request size exceeds model context window'", () => { @@ -120,7 +119,7 @@ describe("formatAssistantErrorText", () => { title: "uses classified rate-limit copy for Z.AI rate-limit errors", errorText: '429 status code (exceeded limit)\n{"code":1305,"message":"The service may be temporarily overloaded, please try again later."}', - expected: "⚠️ API rate limit reached. Please try again later.", + expected: "⚠️ The AI service needs a short break. Please try again in a few minutes.", }, { title: "rewrites generic provider internal errors without support request ids", @@ -132,26 +131,28 @@ describe("formatAssistantErrorText", () => { title: "returns upstream HTML copy for prefixed 521 HTML rate-limit pages", errorText: "Error: 521 rate limit", expected: - "The provider returned an HTML error page instead of an API response. This usually means a CDN or gateway (e.g. Cloudflare) blocked the request. Retry in a moment or check provider status.", + "Couldn't reach the AI service. Try again in a moment. If it continues, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", }, { title: "returns an explicit re-authentication message for OAuth refresh failures", errorText: "OAuth token refresh failed for openai: invalid_grant. Please try again or re-authenticate.", - expected: "Authentication refresh failed. Re-authenticate this provider and try again.", + expected: + "Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`.", }, { title: "returns re-authentication guidance after an account switch", errorText: "Your access token could not be refreshed because you have since logged out or signed in to another account. Please sign in again.", - expected: "Authentication refresh failed. Re-authenticate this provider and try again.", + expected: + "Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`.", }, { title: "returns a timeout-specific message for OAuth refresh hard timeouts", errorText: 'OAuth refresh call "refreshProviderOAuthCredentialWithPlugin(openai)" exceeded hard timeout (120000ms)', expected: - "Authentication refresh timed out before the provider completed. Retry in a moment; re-authenticate only if it keeps failing.", + "Signing in took too long. Try again in a moment. If it keeps happening, sign in again under Models in the Control UI.", }, { title: "sanitizes invalid streaming event order errors", @@ -201,23 +202,22 @@ describe("formatAssistantErrorText", () => { model: "gpt-5.6-luna", }); - expect(userFacing).toBe( - "⚠️ openai/gpt-5.6-luna request failed (provider internal error, HTTP 500). " + - "This is usually temporary — try again shortly.", - ); + expect(userFacing).toBe("⚠️ The AI service is having trouble. Please try again in a moment."); expect(userFacing).not.toContain("opaque-provider-canary"); }, ); it.each(["opaque-private-provider-detail"])( - "keeps model context without assigning an unclassified failure: %s", + "points unclassified failures to diagnostics: %s", (raw) => { expect( formatUserFacingAssistantErrorText(makeAssistantError(raw), { provider: "openai", model: "test-model", }), - ).toBe("⚠️ Agent run failed (model: openai/test-model)."); + ).toBe( + "⚠️ OpenClaw couldn't finish this reply. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow` in your terminal.", + ); }, ); @@ -271,8 +271,7 @@ describe("formatAssistantErrorText", () => { }); expect(userFacing).toBe( - "⚠️ openai/gpt-5.6-luna request failed (authentication failed, HTTP 401). " + - "Re-authenticate the provider and try again.", + "⚠️ Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`.", ); expect(userFacing).not.toContain("opaque-auth-canary"); }); @@ -302,8 +301,7 @@ describe("formatAssistantErrorText", () => { }); expect(formatUserFacingAssistantErrorText(msg)).toBe( - "⚠️ openai/gpt-5.6-luna request failed (provider internal error). " + - "This is usually temporary — try again shortly.", + "⚠️ The AI service is having trouble. Please try again in a moment.", ); }); it("uses generic user-facing copy for escaped structured provider messages", () => { @@ -314,7 +312,7 @@ describe("formatAssistantErrorText", () => { ); expect(formatAssistantErrorText(msg)).toBe("LLM request rejected: SECRET\nCANARY"); expect(formatUserFacingAssistantErrorText(msg)).toBe( - "LLM request failed: provider rejected the request schema or tool payload.", + "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI.", ); }); it("surfaces allowlisted token limits from structured provider messages", () => { @@ -331,7 +329,7 @@ describe("formatAssistantErrorText", () => { const userFacing = formatUserFacingAssistantErrorText(msg); expect(userFacing).toBe( - "LLM request rejected: configured maxTokens is 384000, above the provider maximum of 65536. Lower maxTokens and try again.", + "The reply length is set too high for this model. Lower its reply limit in the Control UI settings, or choose another model.", ); expect(userFacing).not.toContain("deepseek-v4-flash:0731"); }); @@ -353,7 +351,7 @@ describe("formatAssistantErrorText", () => { const userFacing = formatUserFacingAssistantErrorText(msg); expect(userFacing).toBe( - "LLM request rejected: configured maxTokens is 384000, above the provider maximum of 65536. Lower maxTokens and try again.", + "The reply length is set too high for this model. Lower its reply limit in the Control UI settings, or choose another model.", ); expect(userFacing).not.toContain("deepseek-v4-flash:0731"); }); @@ -363,10 +361,10 @@ describe("formatAssistantErrorText", () => { ])("surfaces token limits from provider-wrapped HTTP error %s", (raw) => { const msg = makeAssistantError(raw); expect(formatAssistantErrorText(msg)).toBe( - "LLM request rejected: configured maxTokens is 384000, above the provider maximum of 65536. Lower maxTokens and try again.", + "The reply length is set too high for this model. Lower its reply limit in the Control UI settings, or choose another model.", ); expect(formatUserFacingAssistantErrorText(msg)).toBe( - "LLM request rejected: configured maxTokens is 384000, above the provider maximum of 65536. Lower maxTokens and try again.", + "The reply length is set too high for this model. Lower its reply limit in the Control UI settings, or choose another model.", ); }); it("returns a friendly billing message for HTTP 402 errors", () => { @@ -406,7 +404,9 @@ describe("formatAssistantErrorText", () => { ); const result = formatAssistantErrorText(msg); expect(result).toContain("30 seconds"); - expect(result).not.toBe("⚠️ API rate limit reached. Please try again later."); + expect(result).not.toBe( + "⚠️ The AI service needs a short break. Please try again in a few minutes.", + ); expect(formatUserFacingAssistantErrorText(msg)).toContain("30 seconds"); }); @@ -418,8 +418,14 @@ describe("formatAssistantErrorText", () => { }); it.each([ - ["ENOTFOUND", "LLM request failed: DNS lookup for the provider endpoint failed."], - ["UNRECOGNIZED", "LLM request failed: network connection error."], + [ + "ENOTFOUND", + "Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", + ], + [ + "UNRECOGNIZED", + "Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", + ], ])("uses structured transport code %s with a generic provider message", (errorCode, expected) => { const message = { ...makeAssistantError("Connection error."), errorCode }; expect(formatAssistantErrorText(message)).toBe(expected); @@ -441,7 +447,7 @@ describe("formatAssistantErrorText", () => { '{"type":"error","error":{"type":"permission_error","message":"Missing scopes: api.responses.write model.request"},"code":401}', ); expect(formatAssistantErrorText(msg, { provider: "openai" })).toBe( - "Authentication is missing the required OpenAI ChatGPT scopes. Re-run OpenAI login and try again.", + "This login doesn't have the access OpenClaw needs. Sign in again under Models in the Control UI.", ); }); @@ -492,10 +498,10 @@ describe("formatAssistantErrorText", () => { }); expect(formatAssistantErrorText(msg)).toBe( - "The selected model was not found by the provider. Check the model id or choose a different model.", + "This model was not found. Choose another model in the Control UI.", ); expect(formatUserFacingAssistantErrorText(msg)).toBe( - "The selected model was not found by the provider. Check the model id or choose a different model.", + "This model was not found. Choose another model in the Control UI.", ); }); }); diff --git a/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts index f0e689a5adbe..e093cbbd4cc8 100644 --- a/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts @@ -43,7 +43,7 @@ describe("sanitizeUserFacingText", () => { it("rewrites billing error-shaped text with errorContext", () => { const text = "billing: please upgrade your plan"; - expect(renderUserFacingText(text, { errorContext: true })).toContain("billing error"); + expect(renderUserFacingText(text, { errorContext: true })).toContain("billing problem"); }); it("rewrites exec denied payloads with errorContext", () => { @@ -90,7 +90,10 @@ describe("sanitizeUserFacingText", () => { }); it.each([ - ["Error: fetch failed", "LLM request failed: network connection error."], + [ + "Error: fetch failed", + "Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", + ], ["Error: request timed out", "LLM request timed out."], ])("keeps provider presentation for unmarked errors: %s", (text, expected) => { expect(renderUserFacingText(text, { errorContext: true })).toBe(expected); diff --git a/src/agents/embedded-agent-helpers/error-text.ts b/src/agents/embedded-agent-helpers/error-text.ts index ad0b39cc7102..590bf1fe94aa 100644 --- a/src/agents/embedded-agent-helpers/error-text.ts +++ b/src/agents/embedded-agent-helpers/error-text.ts @@ -45,29 +45,30 @@ const sandboxToolPolicyAuditMessages = new WeakSet(); export const GENERIC_ASSISTANT_ERROR_TEXT = "LLM request failed."; export const SYNTHESIZED_TIMEOUT_ERROR_TEXT = "LLM request timed out."; const MODEL_NOT_FOUND_USER_TEXT = - "The selected model was not found by the provider. Check the model id or choose a different model."; + "This model was not found. Choose another model in the Control UI."; const RUNTIME_FAILURE_COPY: Partial< Record, string> > = { - auth_refresh: "Authentication refresh failed. Re-authenticate this provider and try again.", - refresh_contention: - "Authentication refresh is already in progress elsewhere and this attempt timed out waiting for it. Retry in a moment.", + auth_refresh: + "Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`.", + refresh_contention: "Another sign-in is still in progress. Wait a moment, then try again.", refresh_timeout: - "Authentication refresh timed out before the provider completed. Retry in a moment; re-authenticate only if it keeps failing.", + "Signing in took too long. Try again in a moment. If it keeps happening, sign in again under Models in the Control UI.", callback_timeout: - "Browser OAuth did not complete before manual fallback kicked in. Retry the login flow and paste the redirect URL if prompted.", + "Sign-in wasn't completed. Try signing in again. If asked for a link, paste the full link from your browser.", callback_validation: - "Browser OAuth returned an invalid or incomplete callback. Retry the login flow and make sure the full redirect URL is pasted if prompted.", + "The sign-in link wasn't accepted. Try signing in again. If asked for a link, paste the full link from your browser.", auth_scope: - "Authentication is missing the required OpenAI ChatGPT scopes. Re-run OpenAI login and try again.", + "This login doesn't have the access OpenClaw needs. Sign in again under Models in the Control UI.", auth_html: - "Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.", + "Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`.", auth_invalid_token: AUTH_INVALID_TOKEN_USER_TEXT, upstream_html: - "The provider returned an HTML error page instead of an API response. This usually means a CDN or gateway (e.g. Cloudflare) blocked the request. Retry in a moment or check provider status.", - proxy: "LLM request failed: proxy or tunnel configuration blocked the provider request.", + "Couldn't reach the AI service. Try again in a moment. If it continues, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", + proxy: + "Couldn't connect to the AI service. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", tls_certificate: - "LLM request failed: TLS certificate validation rejected the provider endpoint. Check the endpoint hostname, proxy, and local certificate trust.", + "Couldn't connect securely to the AI service. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", model_not_found: MODEL_NOT_FOUND_USER_TEXT, }; const TOOL_CALL_INPUT_MISSING_RE = diff --git a/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts b/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts index 924efff2e847..c7c2ab7d62ca 100644 --- a/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts +++ b/src/agents/embedded-agent-runner/result-fallback-classifier.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vitest"; import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "../failover/user-copy.js"; import { runWithModelFallback } from "../model-fallback-runner.js"; +import { makeAssistantMessageFixture } from "../test-helpers/assistant-message-fixtures.js"; import { resolveEmbeddedCyberFailoverConfig } from "./embedded-cyber-failover.js"; import { classifyEmbeddedAgentRunResultForModelFallback } from "./result-fallback-classifier.js"; +import { buildPayloads } from "./run/payloads.test-helpers.js"; const supplementalSpeechPayload = { mediaUrl: "file:///tmp/answer.mp3", @@ -212,59 +214,91 @@ describe("classifyEmbeddedAgentRunResultForModelFallback", () => { it.each([ { name: "a generic external runner failure", - payload: { text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }, + createPayloads: () => [{ text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT }], code: "generic_external_run_failure", + reason: "format", }, { name: "a transient status notice without a final reply", - payload: { text: "Still working", isStatusNotice: true }, + createPayloads: () => [{ text: "Still working", isStatusNotice: true }], code: "empty_result", + reason: "format", }, { name: "supplemental speech without a final reply", - payload: supplementalSpeechPayload, + createPayloads: () => [supplementalSpeechPayload], code: "empty_result", - }, - ])("advances to the configured fallback after $name", async ({ payload, code }) => { - const runs: Array<{ provider: string; model: string }> = []; - const result = await runWithModelFallback({ - cfg: undefined, - provider: "external", - model: "primary", - fallbacksOverride: ["external/fallback"], - skipAuthProfileRuntime: true, - run: async (provider, model) => { - runs.push({ provider, model }); - return runs.length === 1 - ? { - payloads: [payload], - meta: { durationMs: 1 }, - } - : { payloads: [{ text: "fallback ok" }], meta: { durationMs: 1 } }; - }, - classifyResult: ({ provider, model, result: runResult }) => - classifyEmbeddedAgentRunResultForModelFallback({ - provider, - model, - result: runResult, - }), - }); - - expect(runs).toEqual([ - { provider: "external", model: "primary" }, - { provider: "external", model: "fallback" }, - ]); - expect(result.result.payloads).toEqual([{ text: "fallback ok" }]); - expect(result.attempts[0]).toMatchObject({ - provider: "external", - model: "primary", reason: "format", - code, - }); - if (code === "generic_external_run_failure") { - expect(result.attempts[0]?.error).toBe(GENERIC_EXTERNAL_RUN_FAILURE_TEXT); - } - }); + }, + ...[ + { + name: "a concise rate-limit reply", + errorMessage: "429 Too Many Requests PRIVATE_CANARY", + reason: "rate_limit", + }, + { + name: "a concise sign-in reply", + errorMessage: "401 Unauthorized: invalid api key PRIVATE_CANARY", + reason: "auth", + }, + ].map(({ name, errorMessage, reason }) => ({ + name, + reason, + code: "embedded_error_payload", + createPayloads: () => + buildPayloads({ + provider: "external", + providerOwner: { id: "external" }, + lastAssistant: makeAssistantMessageFixture({ + provider: "external", + model: "primary", + errorMessage, + }), + }), + })), + ])( + "advances to the configured fallback after $name", + async ({ createPayloads, code, reason }) => { + const runs: Array<{ provider: string; model: string }> = []; + const result = await runWithModelFallback({ + cfg: undefined, + provider: "external", + model: "primary", + fallbacksOverride: ["external/fallback"], + skipAuthProfileRuntime: true, + run: async (provider, model) => { + runs.push({ provider, model }); + return runs.length === 1 + ? { + payloads: createPayloads(), + meta: { durationMs: 1 }, + } + : { payloads: [{ text: "fallback ok" }], meta: { durationMs: 1 } }; + }, + classifyResult: ({ provider, model, result: runResult }) => + classifyEmbeddedAgentRunResultForModelFallback({ + provider, + model, + result: runResult, + }), + }); + + expect(runs).toEqual([ + { provider: "external", model: "primary" }, + { provider: "external", model: "fallback" }, + ]); + expect(result.result.payloads).toEqual([{ text: "fallback ok" }]); + expect(result.attempts[0]).toMatchObject({ + provider: "external", + model: "primary", + reason, + code, + }); + if (code === "generic_external_run_failure") { + expect(result.attempts[0]?.error).toBe(GENERIC_EXTERNAL_RUN_FAILURE_TEXT); + } + }, + ); it("classifies Codex subscription usage-limit payloads as rate-limit fallback", () => { const errorText = diff --git a/src/agents/embedded-agent-runner/result-fallback-classifier.ts b/src/agents/embedded-agent-runner/result-fallback-classifier.ts index 9d36618a07ce..efca7ea54a13 100644 --- a/src/agents/embedded-agent-runner/result-fallback-classifier.ts +++ b/src/agents/embedded-agent-runner/result-fallback-classifier.ts @@ -1,4 +1,5 @@ import { asOptionalObjectRecord } from "@openclaw/normalization-core/record-coerce"; +import { getReplyPayloadMetadata } from "../../auto-reply/reply-payload.js"; import { isSilentReplyPayloadText } from "../../auto-reply/tokens.js"; import { classifyFailoverReason } from "../failover/classify.js"; import type { FailoverReason } from "../failover/signal.js"; @@ -148,14 +149,9 @@ function classifyHarnessResult(params: { } } -function classifyProviderErrorPayloadReason( - errorText: string, - provider: string, +function providerErrorPayloadReason( + failoverReason: FailoverReason | null, ): ProviderErrorPayloadFailoverReason | null { - if (!errorText.trim()) { - return null; - } - const failoverReason = classifyFailoverReason(errorText, { provider }); switch (failoverReason) { case "auth": case "auth_permanent": @@ -258,19 +254,34 @@ export function classifyEmbeddedAgentRunResultForModelFallback(params: { return harnessClassification; } - const errorText = payloads - .filter((payload) => payload?.isError === true) + const errorPayloads = payloads.filter((payload) => payload?.isError === true); + const errorText = errorPayloads + .map((payload) => (typeof payload.text === "string" ? payload.text : "")) + .join("\n"); + const providerFailure = errorPayloads + .map((payload) => getReplyPayloadMetadata(payload)?.providerFailure) + .find((failure) => failure && providerErrorPayloadReason(failure.reason)); + // External and serialized payloads may carry only the original error text. + // A classified native payload (including a null reason) must not be reinterpreted as copy changes. + const unclassifiedErrorText = errorPayloads + .filter((payload) => !getReplyPayloadMetadata(payload)?.providerFailure) .map((payload) => (typeof payload.text === "string" ? payload.text : "")) .join("\n"); // Provider error payloads are auth/profile health signals even when they arrive as an // embedded result rather than a transport exception. - const failoverReason = classifyProviderErrorPayloadReason(errorText, params.provider); + const failoverReason = providerErrorPayloadReason( + providerFailure?.reason ?? + (unclassifiedErrorText.trim() + ? classifyFailoverReason(unclassifiedErrorText, { provider: params.provider }) + : null), + ); if (failoverReason) { + const rawError = providerFailure?.rawError ?? unclassifiedErrorText; return { - message: `${params.provider}/${params.model} ended with a provider error: ${errorText}`, + message: `${params.provider}/${params.model} ended with a provider error: ${rawError}`, reason: failoverReason, code: "embedded_error_payload", - rawError: errorText, + rawError, }; } diff --git a/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test-support.ts b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test-support.ts index 2cf6c9fe1349..34f4bd48b8af 100644 --- a/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test-support.ts +++ b/src/agents/embedded-agent-runner/run.codex-server-error-fallback.test-support.ts @@ -73,7 +73,7 @@ describe("runEmbeddedAgent Codex server_error fallback handoff", () => { await expect(promise).rejects.toBeInstanceOf(MockedFailoverError); await expect(promise).rejects.toThrow( - "⚠️ openai/gpt-5.4 request failed (provider internal error). This is usually temporary — try again shortly.", + "⚠️ The AI service is having trouble. Please try again in a moment.", ); // Initial attempt plus the full same-model transient retry budget. expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(9); diff --git a/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test-support.ts b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test-support.ts index 17319699b722..93e018374e64 100644 --- a/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test-support.ts +++ b/src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test-support.ts @@ -229,7 +229,9 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => { const promise = runCompactionRemovedFallbackAttempt(state); await expect(promise).rejects.toBeInstanceOf(MockedFailoverError); - await expect(promise).rejects.toThrow("⚠️ Agent run failed (model: anthropic/test-model)."); + await expect(promise).rejects.toThrow( + "⚠️ OpenClaw couldn't finish this reply. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow` in your terminal.", + ); expect(mockedIsFailoverAssistantError).toHaveBeenCalledTimes(2); expect(getLastFormattedAssistant()).toMatchObject({ provider: "anthropic", diff --git a/src/agents/embedded-agent-runner/run.harness-prompt-error.test-support.ts b/src/agents/embedded-agent-runner/run.harness-prompt-error.test-support.ts index 053af575f9e0..5c3b55e7b7f4 100644 --- a/src/agents/embedded-agent-runner/run.harness-prompt-error.test-support.ts +++ b/src/agents/embedded-agent-runner/run.harness-prompt-error.test-support.ts @@ -80,13 +80,13 @@ describe("harness prompt failure presentation", () => { preflight ? userMessage : known - ? "selected model is unavailable from the provider" + ? "This model was not found." : "couldn't generate a response", ); expect(text).not.toContain(error.message); if (known) { expect(text).toContain( - "Select an available model or update the model configuration, then try again.", + "Choose another model in the Control UI or run `openclaw configure`.", ); } if (hadPotentialSideEffects) { diff --git a/src/agents/embedded-agent-runner/run/assistant-failure.failover.test.ts b/src/agents/embedded-agent-runner/run/assistant-failure.failover.test.ts index 2f8c817526ff..b74e184f220a 100644 --- a/src/agents/embedded-agent-runner/run/assistant-failure.failover.test.ts +++ b/src/agents/embedded-agent-runner/run/assistant-failure.failover.test.ts @@ -277,7 +277,9 @@ describe("assistant failure recovery", () => { if (reason === "auth") { expect(failure.message).toBe(AUTH_INVALID_TOKEN_USER_TEXT); } else if (reason === "rate_limit") { - expect(failure.message).toBe("⚠️ API rate limit reached. Please try again later."); + expect(failure.message).toBe( + "⚠️ The AI service needs a short break. Please try again in a few minutes.", + ); } expect(input.traceAttempts[0]?.status).toBe(status); } diff --git a/src/agents/embedded-agent-runner/run/auth-controller.ts b/src/agents/embedded-agent-runner/run/auth-controller.ts index 9449d773c69c..d6b4e54f3045 100644 --- a/src/agents/embedded-agent-runner/run/auth-controller.ts +++ b/src/agents/embedded-agent-runner/run/auth-controller.ts @@ -442,9 +442,6 @@ export function createEmbeddedRunAuthController(params: { reason, provider, allInCooldown: failoverParams.allInCooldown, - causeText: failoverParams.error - ? formatErrorMessage(failoverParams.error).trim() - : undefined, recoveryHint: buildProviderAuthRecoveryHint({ provider, config: params.config, diff --git a/src/agents/embedded-agent-runner/run/payloads.errors.test.ts b/src/agents/embedded-agent-runner/run/payloads.errors.test.ts index c5c25196d64a..de56e470b641 100644 --- a/src/agents/embedded-agent-runner/run/payloads.errors.test.ts +++ b/src/agents/embedded-agent-runner/run/payloads.errors.test.ts @@ -114,7 +114,8 @@ describe("buildEmbeddedRunPayloads tool-error silence", () => { describe("buildEmbeddedRunPayloads", () => { const OVERLOADED_FALLBACK_TEXT = "The AI service is temporarily overloaded. Please try again in a moment."; - const REDACTED_TEST_MODEL_FAILURE_TEXT = "⚠️ Agent run failed (model: openai/test-model)."; + const REDACTED_TEST_MODEL_FAILURE_TEXT = + "⚠️ OpenClaw couldn't finish this reply. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow` in your terminal."; const errorJson = '{"type":"error","error":{"details":null,"type":"overloaded_error","message":"Overloaded"},"request_id":"req_011CX7DwS7tSvggaNHmefwWg"}'; const errorJsonPretty = `{ @@ -391,7 +392,7 @@ describe("buildEmbeddedRunPayloads", () => { }); expectSinglePayloadSummary(payloads, { - text: "LLM request failed: provider rejected the request schema or tool payload.", + text: "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI.", isError: true, }); expectNoPayloadTextContaining(payloads, "SECRET_CANARY_69737"); @@ -410,7 +411,7 @@ describe("buildEmbeddedRunPayloads", () => { }); expectSinglePayloadSummary(payloads, { - text: "LLM request rejected: configured maxTokens is 384000, above the provider maximum of 65536. Lower maxTokens and try again.", + text: "The reply length is set too high for this model. Lower its reply limit in the Control UI settings, or choose another model.", isError: true, }); expectNoPayloadTextContaining(payloads, "deepseek-v4-flash:0731"); @@ -427,7 +428,7 @@ describe("buildEmbeddedRunPayloads", () => { }); expectSinglePayloadSummary(payloads, { - text: "LLM request failed: provider rejected the request schema or tool payload.", + text: "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI.", isError: true, }); expectNoPayloadTextContaining(payloads, "1234567890123456"); @@ -444,7 +445,7 @@ describe("buildEmbeddedRunPayloads", () => { }); expectSinglePayloadSummary(payloads, { - text: "LLM request failed: provider rejected the request schema or tool payload.", + text: "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI.", isError: true, }); expectNoPayloadTextContaining(payloads, "provider maximum of 5"); @@ -480,7 +481,7 @@ describe("buildEmbeddedRunPayloads", () => { }); expectSinglePayloadSummary(payloads, { - text: "The selected model was not found by the provider. Check the model id or choose a different model.", + text: "This model was not found. Choose another model in the Control UI.", isError: true, }); expectNoPayloadTextContaining(payloads, "some-model-id"); @@ -499,7 +500,7 @@ describe("buildEmbeddedRunPayloads", () => { }); expectSinglePayloadSummary(payloads, { - text: "LLM request failed: provider rejected the request schema or tool payload.", + text: "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI.", isError: true, }); expectNoPayloadTextContaining(payloads, "SECRET"); @@ -574,13 +575,13 @@ describe("buildEmbeddedRunPayloads", () => { { label: "connection failures", rawError: "connect ECONNREFUSED 127.0.0.1:443", - visibleError: "connection refused", + visibleError: "Couldn't connect to the AI service", }, { label: "authentication refresh timeouts", rawError: 'OAuth refresh call "refreshProviderOAuthCredentialWithPlugin(openai)" exceeded hard timeout (120000ms)', - visibleError: "Authentication refresh timed out", + visibleError: "Signing in took too long", }, ])( "preserves $label while terminal timeout handling is deferred", diff --git a/src/agents/embedded-agent-runner/run/payloads.ts b/src/agents/embedded-agent-runner/run/payloads.ts index e9fdfc28ee6c..efae3f74e945 100644 --- a/src/agents/embedded-agent-runner/run/payloads.ts +++ b/src/agents/embedded-agent-runner/run/payloads.ts @@ -27,6 +27,7 @@ import { resolveRawAssistantAnswerText } from "../../../shared/assistant-answer- import { trimTextPreservingCode } from "../../../shared/text/text-projection.js"; import { classifyOAuthRefreshFailure } from "../../auth-profiles/oauth-refresh-failure.js"; import { + classifyAssistantFailoverReason, formatAssistantErrorText, formatUserFacingAssistantErrorText, normalizeTextForComparison, @@ -200,7 +201,23 @@ export function buildEmbeddedRunPayloads(params: { isError: true, ...(providerLoginRecovery ? { presentation: providerLoginRecovery.presentation } : {}), }; - replyItems.push(setReplyPayloadMetadata(errorPayload, { terminalProviderError: true })); + replyItems.push( + setReplyPayloadMetadata(errorPayload, { + terminalProviderError: true, + ...(assistantForPayload && + (rawErrorMessage || + assistantForPayload.errorCode || + assistantForPayload.errorType || + assistantForPayload.errorBody) + ? { + providerFailure: { + reason: classifyAssistantFailoverReason(assistantForPayload, errorContext), + rawError: rawErrorMessage, + }, + } + : {}), + }), + ); } const reasoningText = suppressAssistantArtifacts || runAborted || lastAssistantNeedsErrorSurface diff --git a/src/agents/embedded-agent-runner/run/prompt-failure.test.ts b/src/agents/embedded-agent-runner/run/prompt-failure.test.ts index 7bb18246cf7b..a57b5489d82d 100644 --- a/src/agents/embedded-agent-runner/run/prompt-failure.test.ts +++ b/src/agents/embedded-agent-runner/run/prompt-failure.test.ts @@ -448,7 +448,7 @@ describe("handleEmbeddedPromptFailure", () => { resolvedVerboseLevel: "off", }), ).toMatchObject({ - text: "LLM request failed: the Gateway rejected a session transcript entry. Compact or reset this session and try again.", + text: "OpenClaw couldn't read this conversation's history. Try /compact, or start a new conversation with /new.", isError: true, }); }); diff --git a/src/agents/embedded-agent-runner/thinking.request-shape-errors.test.ts b/src/agents/embedded-agent-runner/thinking.request-shape-errors.test.ts index eb54a83a42ec..40e189e26f86 100644 --- a/src/agents/embedded-agent-runner/thinking.request-shape-errors.test.ts +++ b/src/agents/embedded-agent-runner/thinking.request-shape-errors.test.ts @@ -6,7 +6,7 @@ import { castAgentMessages } from "../test-helpers/agent-message-fixtures.js"; import { wrapAnthropicStreamWithRecovery } from "./thinking.js"; const genericizedProviderError = - "LLM request failed: provider rejected the request schema or tool payload."; + "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI."; const unsupportedThinkingParamMessage = '"thinking.type.disabled" is not supported for this model. Use "thinking.type.between_tools" for the lowest thinking setting, or "thinking.type.adaptive" and "output_config.effort" to control thinking behavior.'; diff --git a/src/agents/embedded-agent-runner/thinking.test.ts b/src/agents/embedded-agent-runner/thinking.test.ts index 6f4e384e3183..c947427f6f0a 100644 --- a/src/agents/embedded-agent-runner/thinking.test.ts +++ b/src/agents/embedded-agent-runner/thinking.test.ts @@ -381,7 +381,7 @@ describe("wrapAnthropicStreamWithRecovery", () => { "thinking or redacted_thinking blocks in the latest assistant message cannot be modified", ); const genericizedProviderError = - "LLM request failed: provider rejected the request schema or tool payload."; + "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI."; const terminalThinkingSignatureError = "ValidationException: invalid signature on thinking block in message history"; diff --git a/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts b/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts index 3d0eb847c589..c3c76c525119 100644 --- a/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/embedded-agent-subscribe.handlers.lifecycle.test.ts @@ -83,7 +83,7 @@ describe("embedded lifecycle", () => { await handleAgentEnd(ctx); expect(warnMeta(ctx)).toMatchObject({ error: - "Authentication failed at the provider. Re-authenticate and verify your provider credentials and account access.", + "Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`.", providerRuntimeFailureKind: "auth_html", }); expect(warnMeta(ctx).consoleMessage).not.toContain("rawError="); @@ -101,8 +101,7 @@ describe("embedded lifecycle", () => { { raw: '{"type":"error","error":{"type":"server_error","message":"Upstream failed x-api-key: SECRET_CANARY_69737"}}', secret: "SECRET_CANARY_69737", - error: - "⚠️ LLM request failed (provider internal error). This is usually temporary — try again shortly.", + error: "⚠️ The AI service is having trouble. Please try again in a moment.", observation: { providerErrorType: "server_error", providerErrorMessagePreview: "Upstream failed x-api-key: ***", @@ -134,7 +133,7 @@ describe("embedded lifecycle", () => { }); await handleAgentEnd(ctx); expect(warnMeta(ctx).consoleMessage).toBe( - "embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=LLM request failed: connection refused by the provider endpoint. rawError=connection refused", + "embedded run agent end: runId=run-1 isError=true model=claude sonnet 4 provider=anthropic]8;;https://evil.test error=Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`. rawError=connection refused", ); for (const control of ["\n", "\r", "\t", "\u001b", "\u009b"]) { expect(warnMeta(ctx).consoleMessage).not.toContain(control); @@ -314,7 +313,8 @@ describe("embedded lifecycle", () => { await end; expectEvent(onAgentEvent, { phase: "error", - error: "LLM request failed: connection refused by the provider endpoint.", + error: + "Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", errorObservation: expect.objectContaining({ providerRuntimeFailureKind: "timeout" }), livenessState: "blocked", }); diff --git a/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts index 0b0f545b0839..6c6b22325695 100644 --- a/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts +++ b/src/agents/embedded-agent-subscribe.subscribe-embedded-agent-session.subscribeembeddedagentsession.test.ts @@ -812,7 +812,7 @@ describe("subscribeEmbeddedAgentSession", () => { } const error = (lifecycleError.data as { error?: unknown } | undefined)?.error; expect(typeof error).toBe("string"); - expect(error).toContain("API rate limit reached"); + expect(error).toContain("The AI service needs a short break"); }); it("reads terminal abort state before emitting lifecycle:end", () => { diff --git a/src/agents/failover/assistant-request-failure-copy.test.ts b/src/agents/failover/assistant-request-failure-copy.test.ts index b42621acb4d7..7081ff01cbd8 100644 --- a/src/agents/failover/assistant-request-failure-copy.test.ts +++ b/src/agents/failover/assistant-request-failure-copy.test.ts @@ -6,14 +6,18 @@ import { renderAssistantRequestFailureCopy } from "./assistant-request-failure-c describe("renderAssistantRequestFailureCopy", () => { const target = { provider: "openai", model: "test-model" }; - const runFailure = "⚠️ Agent run failed (model: openai/test-model)."; + const runFailure = + "⚠️ OpenClaw couldn't finish this reply. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow` in your terminal."; it.each([ [ "Invalid session transcript entry: model_change PRIVATE_CANARY", - "LLM request failed: the Gateway rejected a session transcript entry. Compact or reset this session and try again.", + "OpenClaw couldn't read this conversation's history. Try /compact, or start a new conversation with /new.", + ], + [ + "invalid session", + "⚠️ Your AI session expired. Start a new conversation with /new. If it happens again, sign in under Models in the Control UI.", ], - ["invalid session", "⚠️ openai/test-model request failed (provider session expired)."], ])("distinguishes local transcript errors from provider expiry: %s", (errorMessage, expected) => { expect( formatUserFacingAssistantErrorText(makeAssistantMessageFixture({ ...target, errorMessage })), @@ -38,28 +42,20 @@ describe("renderAssistantRequestFailureCopy", () => { }); it.each([ - ["database is locked", "was busy", "Retry; if it repeats, check Gateway storage health."], - ["database or disk is full", "was full", "Free disk space on the Gateway host and retry."], - [ - "attempt to write a readonly database", - "was read-only", - "Check Gateway storage permissions and retry.", - ], - [ - "disk I/O error", - "had an I/O error", - "Check Gateway storage health and filesystem access before retrying.", - ], + ["database is locked", "busy saving", "check the conversation"], + ["database or disk is full", "disk is full", "Free up space"], + ["attempt to write a readonly database", "permission to save", "Check folder permissions"], + ["disk I/O error", "couldn't save", "Check the storage"], ])( - "names the internal storage failure %s without provider attribution", + "gives useful storage guidance without internal details: %s", (errorMessage, detail, nextStep) => { - expect( - formatUserFacingAssistantErrorText( - makeAssistantMessageFixture({ ...target, errorMessage }), - ), - ).toBe( - `⚠️ Agent run failed: the Gateway state database ${detail} (SQLite: ${errorMessage}). ${nextStep}`, + const copy = formatUserFacingAssistantErrorText( + makeAssistantMessageFixture({ ...target, errorMessage }), ); + expect(copy).toContain(detail); + expect(copy).toContain(nextStep); + expect(copy).toContain("openclaw logs --follow"); + expect(copy).not.toMatch(/SQLite|openai\/test-model|I\/O/); }, ); @@ -70,11 +66,11 @@ describe("renderAssistantRequestFailureCopy", () => { errorMessage: "PRIVATE_PROVIDER_DETAIL", }); expect(formatUserFacingAssistantErrorText(error)).toBe( - "⚠️ The provider returned an unfinished tool call. Earlier actions may have completed; verify their results before continuing.", + "⚠️ The task couldn't finish. Some actions may have completed; check their results before continuing.", ); }); - it("shows the provider cache limit after a tool-result request is rejected", () => { + it("gives recovery guidance after a tool-result request is rejected", () => { const detail = "A maximum of 4 blocks with cache_control may be provided. Found 5."; const errorBody = JSON.stringify({ error: { @@ -94,7 +90,7 @@ describe("renderAssistantRequestFailureCopy", () => { }), ), ).toBe( - "LLM request rejected: provider allows at most 4 cache_control blocks; the request contained 5.", + "The AI service couldn't accept this conversation. Start a new conversation with /new, or choose another model in the Control UI.", ); }); @@ -106,25 +102,28 @@ describe("renderAssistantRequestFailureCopy", () => { }); it.each([undefined, null, "unclassified", "unknown"] as const)( - "keeps the model as context when reason is %s", + "keeps technical context out of the reply when reason is %s", (reason) => { expect(renderAssistantRequestFailureCopy({ ...target, reason })).toBe(runFailure); }, ); - it.each(["empty_response", "no_error_details"] as const)( - "retains provider attribution for the recognized %s terminal", - (reason) => { - expect(renderAssistantRequestFailureCopy({ ...target, reason })).toBe( - "⚠️ openai/test-model request failed.", - ); - expect(renderAssistantRequestFailureCopy({ reason })).toBeUndefined(); - }, - ); + it("distinguishes empty replies from failures without details", () => { + expect(renderAssistantRequestFailureCopy({ ...target, reason: "empty_response" })).toContain( + "empty reply", + ); + expect(renderAssistantRequestFailureCopy({ reason: "empty_response" })).toContain( + "empty reply", + ); + expect(renderAssistantRequestFailureCopy({ ...target, reason: "no_error_details" })).toBe( + runFailure, + ); + expect(renderAssistantRequestFailureCopy({ reason: "no_error_details" })).toBeUndefined(); + }); it.each([ - [{ provider: "openai" }, "⚠️ Agent run failed (provider: openai)."], - [{ model: "test-model" }, "⚠️ Agent run failed (model: test-model)."], + [{ provider: "openai" }, runFailure], + [{ model: "test-model" }, runFailure], [{}, undefined], ] as const)("handles partial model context %j", (facts, expected) => { expect(renderAssistantRequestFailureCopy(facts)).toBe(expected); @@ -132,14 +131,12 @@ describe("renderAssistantRequestFailureCopy", () => { it("requires a valid HTTP status before asserting a request failure", () => { expect(renderAssistantRequestFailureCopy({ ...target, status: 0 })).toBe(runFailure); - expect(renderAssistantRequestFailureCopy({ ...target, status: 400 })).toBe( - "⚠️ openai/test-model request failed (HTTP 400).", - ); + expect(renderAssistantRequestFailureCopy({ ...target, status: 400 })).toBe(runFailure); }); it("retains classified guidance without an HTTP status", () => { expect(renderAssistantRequestFailureCopy({ ...target, reason: "auth" })).toBe( - "⚠️ openai/test-model request failed (authentication failed). Re-authenticate the provider and try again.", + "⚠️ Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`.", ); }); }); diff --git a/src/agents/failover/assistant-request-failure-copy.ts b/src/agents/failover/assistant-request-failure-copy.ts index 867f4aafc9ad..f38bd292dce9 100644 --- a/src/agents/failover/assistant-request-failure-copy.ts +++ b/src/agents/failover/assistant-request-failure-copy.ts @@ -17,9 +17,9 @@ import type { FailoverReason } from "./signal.js"; export const ERROR_PREFIX_RE = /^(?:error|(?:[a-z][\w-]*\s+)?api\s*error|openai\s*error|anthropic\s*error|gateway\s*error|codex\s*error|request failed|failed|exception)(?:\s+\d{3})?[:\s-]+/i; export const PROVIDER_SCHEMA_REJECTION_USER_TEXT = - "LLM request failed: provider rejected the request schema or tool payload."; + "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI."; const GATEWAY_SESSION_TRANSCRIPT_VALIDATION_USER_TEXT = - "LLM request failed: the Gateway rejected a session transcript entry. Compact or reset this session and try again."; + "OpenClaw couldn't read this conversation's history. Try /compact, or start a new conversation with /new."; const PROVIDER_OUTPUT_TOKEN_LIMIT_RE = /^['"]?max_(?:tokens|output_tokens|completion_tokens|new_tokens)['"]?\s*(?:[:=]\s*)?\(?(\d[\d,]*)\)?\s+exceeds?\b.{0,120}?\b(?:maximum|max|limit)\b(?:\s+(?:output\s+)?tokens?)?(?:\s+(?:is|of)|\s*[:=])?\s*\(?(\d[\d,]*)\)?(?:\D|$)/i; const PROVIDER_CACHE_CONTROL_LIMIT_RE = @@ -34,35 +34,44 @@ type AssistantRequestFailureCopyFacts = { code?: string; }; +export const ERROR_DETAILS_HINT = + "For details, open Settings → Logs in the Control UI or run `openclaw logs --follow` in your terminal."; + const STORAGE_FAILURE_COPY: Record = { SQLITE_BUSY: - "the Gateway state database was busy (SQLite: database is locked). Retry; if it repeats, check Gateway storage health.", + "OpenClaw is busy saving your conversation. Wait a moment, then check the conversation before trying again.", SQLITE_LOCKED: - "the Gateway state database was locked (SQLite: database table is locked). Retry; if it repeats, check Gateway storage health.", + "OpenClaw is busy saving your conversation. Wait a moment, then check the conversation before trying again.", SQLITE_FULL: - "the Gateway state database was full (SQLite: database or disk is full). Free disk space on the Gateway host and retry.", + "OpenClaw couldn't save your conversation because the disk is full. Free up space on the computer running OpenClaw before continuing.", SQLITE_READONLY: - "the Gateway state database was read-only (SQLite: attempt to write a readonly database). Check Gateway storage permissions and retry.", + "OpenClaw doesn't have permission to save your conversation. Check folder permissions on the computer running OpenClaw.", SQLITE_IOERR: - "the Gateway state database had an I/O error (SQLite: disk I/O error). Check Gateway storage health and filesystem access before retrying.", + "OpenClaw couldn't save your conversation. Check the storage on the computer running OpenClaw before continuing.", transcript_writer_fenced: - "the transcript writer no longer owned this session. Retry in the current session; if it repeats, check Gateway logs.", + "This conversation changed while OpenClaw was working. Check its latest messages before continuing.", }; -const ASSISTANT_REQUEST_FAILURE_REASON = { - auth: "authentication failed", - auth_permanent: "authentication was rejected", - format: "request format rejected", - rate_limit: "rate limited", - overloaded: "provider overloaded", - billing: "provider billing issue", - server_error: "provider internal error", - timeout: "request timed out", - tls_certificate: "TLS certificate error", - context_overflow: "context limit exceeded", - model_not_found: "model not found", - session_expired: "provider session expired", - empty_response: "", +const ASSISTANT_REQUEST_FAILURE_COPY = { + auth: "Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`.", + auth_permanent: + "The AI service isn't accepting your login. Sign in again under Models in the Control UI or run `openclaw configure`.", + format: PROVIDER_SCHEMA_REJECTION_USER_TEXT, + rate_limit: "The AI service needs a short break. Please try again in a few minutes.", + overloaded: "The AI service is busy. Please try again in a moment, or choose another model.", + billing: + "The AI service reported a billing problem. Check your account's credit balance and usage limits before trying again.", + server_error: "The AI service is having trouble. Please try again in a moment.", + timeout: + "The request took too long. Check the conversation for any completed work before trying again.", + tls_certificate: `Couldn't connect securely to the AI service. ${ERROR_DETAILS_HINT}`, + context_overflow: + "This conversation is too long for the model. Try /compact, or start a new conversation with /new.", + model_not_found: + "This model was not found. Choose another model in the Control UI or run `openclaw configure`.", + session_expired: + "Your AI session expired. Start a new conversation with /new. If it happens again, sign in under Models in the Control UI.", + empty_response: "The AI service returned an empty reply. Please try again.", no_error_details: "", unclassified: "", unknown: "", @@ -73,53 +82,28 @@ export function renderAssistantRequestFailureCopy( facts: AssistantRequestFailureCopyFacts, ): string | undefined { if (facts.storageFailure) { - return `⚠️ Agent run failed: ${STORAGE_FAILURE_COPY[facts.storageFailure]}`; + return `⚠️ ${STORAGE_FAILURE_COPY[facts.storageFailure]} ${ERROR_DETAILS_HINT}`; } if (facts.code === "incomplete_tool_call") { - return "⚠️ The provider returned an unfinished tool call. Earlier actions may have completed; verify their results before continuing."; + return "⚠️ The task couldn't finish. Some actions may have completed; check their results before continuing."; } - const provider = facts.provider?.trim(); - const model = facts.model?.trim(); - const target = provider && model ? `${provider}/${model}` : provider || model; - const normalizedReason = + const reason = facts.reason === "timeout" && typeof facts.status === "number" && facts.status >= 500 ? "server_error" : facts.reason; - // Failover statuses can be synthesized locally; a timeout does not prove an HTTP response. - if (normalizedReason === "timeout") { - return "⚠️ The request timed out. Please try again. If it keeps happening, try a shorter request or a different model."; + const copy = reason ? ASSISTANT_REQUEST_FAILURE_COPY[reason] : undefined; + if (copy) { + return `⚠️ ${copy}`; } - const reason = normalizedReason ? ASSISTANT_REQUEST_FAILURE_REASON[normalizedReason] : undefined; - const httpStatus = facts.status; - const status = - typeof httpStatus === "number" && - Number.isInteger(httpStatus) && - httpStatus >= 100 && - httpStatus <= 599 - ? `HTTP ${httpStatus}` - : undefined; - // A recognized provider terminal can have no displayable reason. - const unclassified = - !facts.reason || facts.reason === "unclassified" || facts.reason === "unknown"; - if (!reason && !status && (!target || unclassified)) { - return target ? `⚠️ Agent run failed (${model ? "model" : "provider"}: ${target}).` : undefined; + const hasStatus = + typeof facts.status === "number" && + Number.isInteger(facts.status) && + facts.status >= 100 && + facts.status <= 599; + if (!hasStatus && !facts.provider?.trim() && !facts.model?.trim()) { + return undefined; } - const details = [reason, status].filter(Boolean); - const summary = `⚠️ ${target ? `${target} request failed` : "LLM request failed"}${details.length > 0 ? ` (${details.join(", ")})` : ""}.`; - if ( - normalizedReason === "overloaded" || - normalizedReason === "server_error" || - normalizedReason === "rate_limit" - ) { - return `${summary} This is usually temporary — try again shortly.`; - } - if (facts.reason === "auth" || facts.reason === "auth_permanent") { - return `${summary} Re-authenticate the provider and try again.`; - } - if (facts.reason === "billing") { - return `${summary} Check ${provider ? `${provider} billing` : "provider billing"} and try again.`; - } - return summary; + return `⚠️ OpenClaw couldn't finish this reply. ${ERROR_DETAILS_HINT}`; } /** Surface bounded rejection facts without arbitrary provider-controlled text. */ @@ -133,14 +117,14 @@ export function renderFormatErrorCopy(raw: string): string { } const cacheLimit = candidate.match(PROVIDER_CACHE_CONTROL_LIMIT_RE); if (cacheLimit) { - return `LLM request rejected: provider allows at most ${cacheLimit[1]} cache_control blocks; the request contained ${cacheLimit[2]}.`; + return "The AI service couldn't accept this conversation. Start a new conversation with /new, or choose another model in the Control UI."; } const match = candidate.length <= 300 ? candidate.match(PROVIDER_OUTPUT_TOKEN_LIMIT_RE) : null; const [, value, maximum] = match ?? []; if (!value || !maximum) { return PROVIDER_SCHEMA_REJECTION_USER_TEXT; } - return `LLM request rejected: configured maxTokens is ${value}, above the provider maximum of ${maximum}. Lower maxTokens and try again.`; + return "The reply length is set too high for this model. Lower its reply limit in the Control UI settings, or choose another model."; } /** Share bounded request-limit facts between live failures and persisted chat history. */ @@ -209,7 +193,7 @@ export function renderRecordedAssistantFailureCopy(message: { isContextOverflowErrorFromTables(value)), ) ) { - return "Context overflow: this conversation is too large for the model. Try /compact, use /new to start a fresh session, or retry the command with a tighter output limit."; + return "This conversation is too long for the model. Try /compact, or start a new conversation with /new."; } const classifiedCopy = renderAssistantRequestFailureCopy({ code, diff --git a/src/agents/failover/classify.test.ts b/src/agents/failover/classify.test.ts index 9ddcefb118a7..4a867162254e 100644 --- a/src/agents/failover/classify.test.ts +++ b/src/agents/failover/classify.test.ts @@ -368,7 +368,7 @@ it("classifies the generic LLM request failure as transient", () => { it("does not match schema rejection copy as a generic timeout", () => { expect( isTimeoutErrorMessage( - "LLM request failed: provider rejected the request schema or tool payload.", + "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI.", ), ).toBe(false); }); @@ -397,6 +397,6 @@ it("keeps HTTP 429 overload wording in rate-limit backoff and copy", () => { '429 status code (exceeded limit)\n{"code":1305,"message":"The service may be temporarily overloaded, please try again later."}'; expect(classifyFailoverReason(message)).toBe("rate_limit"); expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw: message })).toBe( - "⚠️ API rate limit reached. Please try again later.", + "⚠️ The AI service needs a short break. Please try again in a few minutes.", ); }); diff --git a/src/agents/failover/failover-classification.corpus.test.ts b/src/agents/failover/failover-classification.corpus.test.ts index 681188f2f2ec..f8137bb4ac63 100644 --- a/src/agents/failover/failover-classification.corpus.test.ts +++ b/src/agents/failover/failover-classification.corpus.test.ts @@ -165,7 +165,7 @@ describe("cross-layer failover behavior", () => { reason: "rate_limit", }); expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw: message })).toBe( - "⚠️ API rate limit reached. Please try again later.", + "⚠️ The AI service needs a short break. Please try again in a few minutes.", ); }); @@ -236,9 +236,7 @@ describe("cross-layer failover behavior", () => { expect(facet).toBeNull(); expect(classifyReplyRequest({ message })).toMatchObject({ code: "provider_model_unavailable", - userMessage: expect.stringContaining( - "Select an available model or update the model configuration, then try again.", - ), + userMessage: expect.stringContaining("Choose another model in the Control UI"), }); }); diff --git a/src/agents/failover/provider-structured-signals.test.ts b/src/agents/failover/provider-structured-signals.test.ts index 93ae0003e174..fc535c8bc945 100644 --- a/src/agents/failover/provider-structured-signals.test.ts +++ b/src/agents/failover/provider-structured-signals.test.ts @@ -45,12 +45,12 @@ describe("provider failover hook structured signals", () => { }, { errorMessage: "opaque provider refusal", - copy: "⚠️ Agent run failed (model: openai/test-model).", + copy: "⚠️ OpenClaw couldn't finish this reply. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow` in your terminal.", runtimeKind: "unclassified", }, { errorMessage: "model input limit reached", - copy: "⚠️ Agent run failed (model: openai/test-model).", + copy: "⚠️ OpenClaw couldn't finish this reply. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow` in your terminal.", runtimeKind: "unclassified", }, { @@ -60,7 +60,7 @@ describe("provider failover hook structured signals", () => { }, { errorMessage: '429 {"error":{"type":"rate_limit_error","message":"Too many requests"}}', - copy: "⚠️ API rate limit reached. Please try again later.", + copy: "⚠️ The AI service needs a short break. Please try again in a few minutes.", runtimeKind: "rate_limit", }, ])( @@ -95,11 +95,10 @@ describe("provider failover hook structured signals", () => { }); const copies = { billing: formatBillingErrorMessage("custom-route", message.model), - rate_limit: "⚠️ API rate limit reached. Please try again later.", + rate_limit: "⚠️ The AI service needs a short break. Please try again in a few minutes.", context_overflow: "Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.", - model_not_found: - "The selected model was not found by the provider. Check the model id or choose a different model.", + model_not_found: "This model was not found. Choose another model in the Control UI.", format: PROVIDER_SCHEMA_REJECTION_USER_TEXT, }; expect( @@ -136,17 +135,20 @@ describe("provider failover hook structured signals", () => { ); it.each([ - { errorCode: "RESOURCE_EXHAUSTED", copy: "⚠️ API rate limit reached. Please try again later." }, + { + errorCode: "RESOURCE_EXHAUSTED", + copy: "⚠️ The AI service needs a short break. Please try again in a few minutes.", + }, { errorMessage: '400 {"error":{"type":"invalid_request_error","message":"provider refusal"}}', errorCode: "RESOURCE_EXHAUSTED", - copy: "⚠️ API rate limit reached. Please try again later.", + copy: "⚠️ The AI service needs a short break. Please try again in a few minutes.", }, { errorType: "invalid_request_error", copy: PROVIDER_SCHEMA_REJECTION_USER_TEXT }, { errorMessage: undefined, errorCode: "RESOURCE_EXHAUSTED", - copy: "⚠️ API rate limit reached. Please try again later.", + copy: "⚠️ The AI service needs a short break. Please try again in a few minutes.", }, { errorMessage: undefined, @@ -175,7 +177,7 @@ describe("provider failover hook structured signals", () => { { errorMessage: '{"error":{"type":"invalid_request_error","message":"provider refusal"}}', errorCode: "RESOURCE_EXHAUSTED", - copy: "⚠️ API rate limit reached. Please try again later.", + copy: "⚠️ The AI service needs a short break. Please try again in a few minutes.", }, ])( "presents structured signal $errorCode $errorType $errorBody without discovery", @@ -195,35 +197,31 @@ describe("provider failover hook structured signals", () => { it.each([ { errorCode: "DEACTIVATED_WORKSPACE", - detail: "authentication was rejected", - hint: "Re-authenticate the provider and try again.", + expected: + "⚠️ The AI service isn't accepting your login. Sign in again under Models in the Control UI or run `openclaw configure`.", }, { errorType: "upstream_error", - detail: "provider internal error", - hint: "This is usually temporary — try again shortly.", + expected: "⚠️ The AI service is having trouble. Please try again in a moment.", }, { errorBody: '{"error":{"type":"upstream_error"}}', - detail: "provider internal error", - hint: "This is usually temporary — try again shortly.", + expected: "⚠️ The AI service is having trouble. Please try again in a moment.", }, { errorMessage: undefined, errorType: "upstream_error", - detail: "provider internal error", - hint: "This is usually temporary — try again shortly.", + expected: "⚠️ The AI service is having trouble. Please try again in a moment.", }, ])( "carries structured $errorCode $errorType $errorBody into safe composed copy", - ({ detail, hint, ...fields }) => { + ({ expected, ...fields }) => { const message = makeAssistantMessageFixture({ errorMessage: "RAW_BODY_CANARY Authorization: Bearer secret-canary https://private.invalid/body", ...fields, }); const text = formatUserFacingAssistantErrorText(message); - const expected = `⚠️ openai/test-model request failed (${detail}). ${hint}`; expect(text).toBe(expected); if ("errorMessage" in fields && fields.errorMessage === undefined) { expect(formatAssistantErrorText(message)).toBe(expected); @@ -248,9 +246,7 @@ describe("provider failover hook structured signals", () => { const text = formatUserFacingAssistantErrorText(message, { providerOwner: { id: "prepared-owner", classifyFailoverReason }, }); - expect(text).toBe( - "⚠️ custom-route/test-model request failed (provider internal error, HTTP 403). This is usually temporary — try again shortly.", - ); + expect(text).toBe("⚠️ The AI service is having trouble. Please try again in a moment."); expect(text).not.toMatch(/RAW_BODY_CANARY|Authorization|secret-canary|private\.invalid/); expect(classifyFailoverReason).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/src/agents/failover/user-copy.test.ts b/src/agents/failover/user-copy.test.ts index 315aabda61ac..74fdb18fbc35 100644 --- a/src/agents/failover/user-copy.test.ts +++ b/src/agents/failover/user-copy.test.ts @@ -35,12 +35,11 @@ describe("failover user copy", () => { }); const tokenLimitCopy = - "LLM request rejected: configured maxTokens is 384000, above the provider maximum of 65536. Lower maxTokens and try again."; + "The reply length is set too high for this model. Lower its reply limit in the Control UI settings, or choose another model."; it("renders only the allowlisted selected-profile code", () => { expect(renderFailoverCodeUserCopy("selected_auth_profile_unavailable")).toBe( - "The selected auth profile is unavailable in this agent's OpenClaw credential store. " + - "Import or migrate that credential into the agent, select another configured profile, or run `openclaw configure`, then retry.", + "This saved login isn't available. Choose another login under Models in the Control UI or run `openclaw configure`.", ); expect(renderFailoverCodeUserCopy("plugin_selected_profile_unavailable")).toBeUndefined(); expect( @@ -51,7 +50,7 @@ describe("failover user copy", () => { it("renders transient copy from the classified reason", () => { const raw = "429 Too Many Requests: model overloaded"; expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw })).toBe( - "⚠️ API rate limit reached. Please try again later.", + "⚠️ The AI service needs a short break. Please try again in a few minutes.", ); expect(renderRateLimitOrOverloadedCopy({ reason: "overloaded", raw })).toBe( "The AI service is temporarily overloaded. Please try again in a moment.", @@ -65,7 +64,7 @@ describe("failover user copy", () => { ], [ "All models failed (2): a/m: try again in 17 minutes (rate_limit) | b/m: 429 (rate_limit)", - "⚠️ All models failed (2): a/m: try again in 17 minutes (rate_limit) | b/m: 429 (rate_limit)", + "⚠️ try again in 17 minutes", ], ])("preserves bounded provider retry detail: %s", (raw, expected) => { expect(renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw })).toBe(expected); @@ -85,14 +84,14 @@ describe("failover user copy", () => { "A maximum of many blocks with cache_control may be provided. Found 5.", ])("does not echo arbitrary cache-limit error text: %s", (raw) => { expect(renderFormatErrorCopy(raw)).toBe( - "LLM request failed: provider rejected the request schema or tool payload.", + "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI.", ); }); it("keeps overlong provider-controlled limit text generic", () => { const raw = `400 max_tokens (384000) exceeds ${"x".repeat(301)} maximum output tokens (65536)`; expect(renderFormatErrorCopy(raw)).toBe( - "LLM request failed: provider rejected the request schema or tool payload.", + "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI.", ); }); @@ -106,7 +105,7 @@ describe("failover user copy", () => { cooldownExpiry: now + 45_000, nowMs: now, }), - ).toBe("⚠️ Rate-limited — ready in ~45s. Please wait a moment."); + ).toBe("⚠️ The AI service needs a short break. Please try again in ~45s."); expect( renderRateLimitReplyCopy({ message: "limited", @@ -117,9 +116,7 @@ describe("failover user copy", () => { ], nowMs: now, }), - ).toBe( - "⚠️ All attempted models were rate-limited or overloaded. Please try again in a few minutes.", - ); + ).toBe("⚠️ The AI services are busy. Please try again in a few minutes."); }); it("preserves the first bounded provider hint from structured exhausted attempts", () => { @@ -152,7 +149,7 @@ describe("failover user copy", () => { reason: "rate_limit", attempts: [{ provider: "mock", model: "model", reason: "rate_limit", error }], }), - ).toBe("⚠️ The model request was rate-limited. Please try again in a few minutes."); + ).toBe("⚠️ The AI service needs a short break. Please try again in a few minutes."); }); it("uses neutral billing copy for subscription credentials", () => { @@ -166,16 +163,16 @@ describe("failover user copy", () => { "⚠️ Anthropic (claude) returned a billing error — check your account for subscription or usage limits, then try again.", ); expect(renderBillingReplyCopy({})).toBe( - "⚠️ API provider returned a billing error — your API key has run out of credits or has an insufficient balance. Check your provider's billing dashboard and top up or switch to a different API key.", + "⚠️ The AI service reported a billing problem. Check your account's credit balance and usage limits before trying again.", ); }); it("renders provider-safe missing-key guidance", () => { expect(renderMissingApiKeyReplyCopy({ provider: "openai", providerGuidance: true })).toContain( - "Missing API key for OpenAI on the gateway", + "openclaw configure", ); expect(renderMissingApiKeyReplyCopy({ provider: "provider-with-secret-name" })).toBe( - "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again.", + "⚠️ This AI service isn't set up yet. Sign in under Models in the Control UI or run `openclaw configure`.", ); }); @@ -194,7 +191,7 @@ describe("failover user copy", () => { replayPrevented: true, }), ).toBe( - "⚠️ CLI turn (routing openai/gpt-5.6-sol): timed out after 90s (overall turn limit). The gateway is unaffected. It also stopped 2 CLI background tasks and 1 active CLI tool call; that work shares the parent CLI process. Effects may be partial; check before retrying. OpenClaw did not replay this turn automatically. For long work, use a detached OpenClaw sub-agent (no run timeout by default), or raise `agents.defaults.timeoutSeconds`.", + "⚠️ The task took too long. Some work may have completed. Check its results before trying again. Try a smaller task, or increase the task time limit in the Control UI settings.", ); }); @@ -217,7 +214,7 @@ describe("failover user copy", () => { "unexpected status 404 Not Found: The model `gpt-x` does not exist", { errorContext: true }, ), - ).toMatch(/^⚠️ The selected model is unavailable from the provider/); + ).toMatch(/^⚠️ This model was not found/); }); it("keeps non-401 auth text and non-error context out of the provider copy", () => { @@ -240,7 +237,9 @@ describe("rate limit copy from a failover chain summary", () => { expect(aggregate.length).toBeGreaterThan(300); const copy = renderRateLimitOrOverloadedCopy({ reason: "rate_limit", raw: aggregate }); expect(copy).toContain("resets 6:20pm (Europe/London)"); - expect(copy).not.toBe("⚠️ API rate limit reached. Please try again later."); + expect(copy).not.toBe( + "⚠️ The AI service needs a short break. Please try again in a few minutes.", + ); }); it("still falls back to the generic message when no leg carries a hint", () => { @@ -248,6 +247,6 @@ describe("rate limit copy from a failover chain summary", () => { reason: "rate_limit", raw: "All models failed (2): anthropic/claude: 429 (rate_limit) | openai/gpt-5.4: 429 (rate_limit)", }); - expect(copy).toContain("API rate limit reached"); + expect(copy).toContain("Please try again in a few minutes"); }); }); diff --git a/src/agents/failover/user-copy.ts b/src/agents/failover/user-copy.ts index 8c5abe45dc26..5fee43bce5d1 100644 --- a/src/agents/failover/user-copy.ts +++ b/src/agents/failover/user-copy.ts @@ -15,7 +15,7 @@ import { } from "../../shared/assistant-error-format.js"; import { formatExecDeniedUserMessage } from "../exec-approval-result.js"; import type { CliTimeoutContext, FallbackAttemptRecord } from "../failover-error.js"; -import { ERROR_PREFIX_RE } from "./assistant-request-failure-copy.js"; +import { ERROR_DETAILS_HINT, ERROR_PREFIX_RE } from "./assistant-request-failure-copy.js"; import { classifyFailoverReasonCore } from "./classify-core.js"; import { isPeriodicUsageLimitErrorMessage, @@ -28,14 +28,12 @@ import { } from "./request-error-facets.js"; import type { FailoverClassification, FailoverReason } from "./signal.js"; -const RATE_LIMIT_ERROR_USER_MESSAGE = "⚠️ API rate limit reached. Please try again later."; +const RATE_LIMIT_ERROR_USER_MESSAGE = + "⚠️ The AI service needs a short break. Please try again in a few minutes."; export const AUTH_INVALID_TOKEN_USER_TEXT = - "Authentication failed (provider returned HTTP 401). " + - "Your provider token may have expired — try the request again in a moment. " + - "If the failure persists, re-authenticate this provider."; + "Couldn't sign in to the AI service. Sign in again under Models in the Control UI or run `openclaw configure`."; const SELECTED_AUTH_PROFILE_UNAVAILABLE_USER_TEXT = - "The selected auth profile is unavailable in this agent's OpenClaw credential store. " + - "Import or migrate that credential into the agent, select another configured profile, or run `openclaw configure`, then retry."; + "This saved login isn't available. Choose another login under Models in the Control UI or run `openclaw configure`."; export const renderFailoverCodeUserCopy = (code: unknown): string | undefined => code === "selected_auth_profile_unavailable" ? SELECTED_AUTH_PROFILE_UNAVAILABLE_USER_TEXT @@ -45,7 +43,7 @@ const MODEL_CAPACITY_ERROR_USER_MESSAGE = const OVERLOADED_ERROR_USER_MESSAGE = "The AI service is temporarily overloaded. Please try again in a moment."; const RATE_LIMIT_RETRY_MESSAGE = - "⚠️ The model request was rate-limited. Please try again in a few minutes."; + "⚠️ The AI service needs a short break. Please try again in a few minutes."; const MODEL_CAPACITY_ERROR_RE = /\b(?:selected\s+)?model\s+(?:is\s+)?at capacity\b/i; const RATE_LIMIT_SPECIFIC_HINT_RE = /\bmin(ute)?s?\b|\bhours?\b|\bseconds?\b|\btry again in\b|\bresets?\b|\bplan\b|\bquota\b/i; @@ -71,8 +69,8 @@ export function formatBillingErrorMessage( : "⚠️ API provider returned a billing error — check your account for subscription or usage limits, then try again."; } return providerLabel - ? `⚠️ ${providerLabel} returned a billing error — your API key has run out of credits or has an insufficient balance. Check your ${providerName} billing dashboard and top up or switch to a different API key.` - : "⚠️ API provider returned a billing error — your API key has run out of credits or has an insufficient balance. Check your provider's billing dashboard and top up or switch to a different API key."; + ? `⚠️ ${providerLabel} returned a billing error — check your account's balance and usage limits before trying again.` + : "⚠️ The AI service reported a billing problem. Check your account's credit balance and usage limits before trying again."; } const BILLING_ERROR_USER_MESSAGE = formatBillingErrorMessage(); @@ -88,7 +86,7 @@ function extractProviderRateLimitMessage(raw: string): string | undefined { if (isCloudflareOrHtmlErrorPage(withoutPrefix)) { return undefined; } - const trimmed = candidate.trim(); + const trimmed = candidate.trim().replace(/\s+\((?:rate_limit|overloaded|unknown)\)$/iu, ""); if ( trimmed.length > 300 || trimmed.startsWith("{") || @@ -111,17 +109,13 @@ export function renderRateLimitOrOverloadedCopy(params: { if (params.reason === "overloaded") { return OVERLOADED_ERROR_USER_MESSAGE; } - const direct = extractProviderRateLimitMessage(raw); - if (direct) { - return direct; - } for (const leg of splitFailoverAggregateLegs(raw)) { const fromLeg = extractProviderRateLimitMessage(leg); if (fromLeg) { return fromLeg; } } - return RATE_LIMIT_ERROR_USER_MESSAGE; + return extractProviderRateLimitMessage(raw) ?? RATE_LIMIT_ERROR_USER_MESSAGE; } export function formatDiskSpaceErrorCopy(raw: string): string | undefined { @@ -272,7 +266,7 @@ export function renderSanitizedUserFacingText( } export const GENERIC_EXTERNAL_RUN_FAILURE_TEXT = - "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session."; + "⚠️ OpenClaw couldn't finish this request. Check the conversation before trying again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`."; // A failed background turn can have partial effects; it does not establish chat health. const HEARTBEAT_FAILURE_LEAD = "⚠️ The background check did not complete."; const HEARTBEAT_FAILURE_LOG_HINT = "Troubleshooting: run `openclaw logs --follow` in a terminal."; @@ -290,12 +284,12 @@ export function renderHeartbeatRunFailureCopy(reason?: string): string { export const PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE = "⚠️ The model provider rejected the conversation state. Please try again, or use /new to start a fresh session."; const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE = - "⚠️ The model provider returned HTTP 429 before replying. This can mean rate limiting, exhausted quota, or an account balance/billing issue. Check the selected provider/model, API key, and provider billing/quota dashboard, then try again."; + "⚠️ The AI service can't accept more requests right now. Wait a few minutes, then try again. If it continues, check your account's usage and billing limits."; const PROVIDER_INTERNAL_ERROR_USER_MESSAGE = "⚠️ The model provider returned a temporary internal error before replying. Try again in a moment, or switch to another model if it keeps happening."; const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`; const PROVIDER_MODEL_UNAVAILABLE_USER_MESSAGE = - "⚠️ The selected model is unavailable from the provider — it may have been renamed, retired, or is not offered on this account. Select an available model or update the model configuration, then try again."; + "⚠️ This model was not found. Choose another model in the Control UI or run `openclaw configure`."; const PROVIDER_REQUEST_COPY = { provider_authentication_error: PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE, @@ -432,15 +426,15 @@ export function renderRateLimitReplyCopy(params: { if (typeof expiry === "number" && expiry > nowMs) { const secsLeft = Math.max(1, Math.ceil((expiry - nowMs) / 1000)); return secsLeft <= 60 - ? `⚠️ Rate-limited — ready in ~${secsLeft}s. Please wait a moment.` - : `⚠️ Rate-limited — ready in ~${Math.ceil(secsLeft / 60)} min. Please try again shortly.`; + ? `⚠️ The AI service needs a short break. Please try again in ~${secsLeft}s.` + : `⚠️ The AI service needs a short break. Please try again in ~${Math.ceil(secsLeft / 60)} min.`; } const attemptedModels = new Set( attempts.map((attempt) => `${attempt.provider}/${attempt.model}`), ); return attemptedModels.size > 1 && attempts.every((attempt) => attempt.reason === "rate_limit" || attempt.reason === "overloaded") - ? "⚠️ All attempted models were rate-limited or overloaded. Please try again in a few minutes." + ? "⚠️ The AI services are busy. Please try again in a few minutes." : RATE_LIMIT_RETRY_MESSAGE; } @@ -470,8 +464,6 @@ export function renderBillingReplyCopy(params: { : BILLING_ERROR_USER_MESSAGE; } -const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai"]); - export function renderMissingApiKeyReplyCopy(params?: { provider: string; providerGuidance?: boolean; @@ -481,21 +473,17 @@ export function renderMissingApiKeyReplyCopy(params?: { return null; } if (provider === "openai" && params?.providerGuidance) { - return "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-6-astra` with the OpenAI OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs."; + return "⚠️ OpenAI needs a different sign-in for this model. Open Models in the Control UI or run `openclaw configure` to choose how to connect."; } - if (provider === "openai") { - return '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.'; - } - return SAFE_MISSING_API_KEY_PROVIDERS.has(provider) - ? `⚠️ Missing API key for provider "${provider}". Configure the gateway auth for that provider, then try again.` - : "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again."; + return provider === "openai" + ? "⚠️ Couldn't connect to OpenAI. Run `openclaw doctor --fix`, then try again. If it still fails, open Models in the Control UI or run `openclaw configure`." + : "⚠️ This AI service isn't set up yet. Sign in under Models in the Control UI or run `openclaw configure`."; } const CLI_BACKEND_NO_OUTPUT_STALL_RE = /\bCLI produced no output for\s+(\d+)\s*s\s+and was terminated\b/iu; const CLI_BACKEND_OVERALL_TIMEOUT_RE = /\bCLI exceeded timeout\s*\(\s*(\d+)\s*s\s*\)\s+and was terminated\b/iu; -const CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE = /\b([\w.-]+\/[A-Za-z][\w.-]*)\s*:\s*CLI\b/iu; export function renderCliTimeoutReplyCopy(params: { message: string; @@ -510,39 +498,27 @@ export function renderCliTimeoutReplyCopy(params: { if (!Number.isFinite(seconds)) { return null; } - const routedModelRef = params.message.match(CLI_BACKEND_ROUTING_REF_BEFORE_ERROR_RE)?.[1]; - const routingSuffix = routedModelRef ? ` (routing ${routedModelRef})` : ""; const mode = timeout?.mode ?? (stall ? "no-output" : "overall"); - const stoppedWork: string[] = []; - if (timeout?.backgroundTaskCount) { - stoppedWork.push( - `${timeout.backgroundTaskCount} CLI background ${timeout.backgroundTaskCount === 1 ? "task" : "tasks"}`, - ); - } - if (timeout?.activeToolCount) { - stoppedWork.push( - `${timeout.activeToolCount} active CLI tool ${timeout.activeToolCount === 1 ? "call" : "calls"}`, - ); - } - let workStatus = - stoppedWork.length > 0 - ? ` It also stopped ${stoppedWork.join(" and ")}; that work shares the parent CLI process. Effects may be partial; check before retrying.` - : timeout?.observedActivity - ? " The CLI had already begun work, so effects may be partial; check before retrying." - : ""; - if (params.replayPrevented) { - workStatus += " OpenClaw did not replay this turn automatically."; - } - return mode === "no-output" - ? `⚠️ CLI subprocess${routingSuffix}: no output for ${seconds}s, so the no-output watchdog stopped it. This is separate from the overall agent timeout; the gateway is unaffected.${workStatus} Check for an interactive prompt. The CLI backend ${params.provider ?? ""} produced no output before its watchdog expired.` - : `⚠️ CLI turn${routingSuffix}: timed out after ${seconds}s (overall turn limit). The gateway is unaffected.${workStatus} For long work, use a detached OpenClaw sub-agent (no run timeout by default), or raise \`agents.defaults.timeoutSeconds\`.`; + const mayHaveCompletedWork = + timeout?.observedActivity || + timeout?.backgroundTaskCount || + timeout?.activeToolCount || + params.replayPrevented; + const workStatus = mayHaveCompletedWork + ? " Some work may have completed. Check its results before trying again." + : ""; + const summary = mode === "no-output" ? "The task stopped responding." : "The task took too long."; + const remedy = + mode === "no-output" + ? "Check for a sign-in or confirmation prompt in the terminal." + : "Try a smaller task, or increase the task time limit in the Control UI settings."; + return `⚠️ ${summary}${workStatus} ${remedy}`; } type AuthProfileFailureCopyParams = { reason: FailoverReason; provider: string; allInCooldown: boolean; - causeText?: string; recoveryHint?: string; }; @@ -598,19 +574,14 @@ export function renderAuthProfileFailoverCopy(params: AuthProfileFailureCopyPara const description = params.allInCooldown ? AUTH_PROFILE_COOLDOWN_COPY[params.reason](params.provider) : AUTH_PROFILE_DIRECT_COPY[params.reason]?.(params.provider); - if (!description) { - return params.causeText?.trim() || authProfileUnavailableCopy(params.provider); - } const hint = AUTH_PROFILE_RECOVERY_REASONS.has(params.reason) ? params.recoveryHint : null; - const causeText = params.causeText?.trim() ?? ""; - const suffix = causeText && !description.includes(causeText) ? ` (${causeText})` : ""; - return `${[description, hint].filter(Boolean).join(" ")}${suffix}`; + return [description ?? authProfileUnavailableCopy(params.provider), hint] + .filter(Boolean) + .join(" "); } -const CONTROL_UI_LOG_HINT = "To view logs, run `openclaw logs --follow` in a terminal."; - -export function renderControlUiAgentFailureCopy(errorText: string): string { - return `⚠️ Agent failed before reply: ${errorText.trim().replace(/\.\s*$/, "")}.\n${CONTROL_UI_LOG_HINT}`; +export function renderControlUiAgentFailureCopy(): string { + return `⚠️ OpenClaw couldn't finish this reply. Check the conversation before trying again. ${ERROR_DETAILS_HINT}`; } export function replaceGenericExternalRunFailureText(text: string): { diff --git a/src/agents/live-model-errors.test.ts b/src/agents/live-model-errors.test.ts index 0bba3c6b6411..8f7623a69271 100644 --- a/src/agents/live-model-errors.test.ts +++ b/src/agents/live-model-errors.test.ts @@ -14,7 +14,7 @@ describe("live model error helpers", () => { expect(isModelNotFoundErrorMessage("The model gpt-foo does not exist.")).toBe(true); expect( isModelNotFoundErrorMessage( - "The selected model was not found by the provider. Check the model id or choose a different model.", + "This model was not found. Choose another model in the Control UI.", ), ).toBe(true); expect(isModelNotFoundErrorMessage('{"code":404,"message":"model not found"}')).toBe(true); diff --git a/src/agents/openai-transport-stream.streaming.test.ts b/src/agents/openai-transport-stream.streaming.test.ts index 0702bef92b3c..a11c13ba088d 100644 --- a/src/agents/openai-transport-stream.streaming.test.ts +++ b/src/agents/openai-transport-stream.streaming.test.ts @@ -163,7 +163,7 @@ describe("openai transport stream", () => { expect(String(errorPayload?.errorBody)).toContain("Not supported model some-model-id"); expect(classifyAssistantFailoverReason(errorPayload as never)).toBe("model_not_found"); expect(formatUserFacingAssistantErrorText(errorPayload as never)).toBe( - "The selected model was not found by the provider. Check the model id or choose a different model.", + "This model was not found. Choose another model in the Control UI.", ); } finally { await new Promise((resolve, reject) => { diff --git a/src/auto-reply/reply-payload.ts b/src/auto-reply/reply-payload.ts index d5d555fd3bc6..142f9524fb59 100644 --- a/src/auto-reply/reply-payload.ts +++ b/src/auto-reply/reply-payload.ts @@ -4,6 +4,7 @@ import { normalizeOptionalString, readNonBlankString, } from "@openclaw/normalization-core/string-coerce"; +import type { FailoverReason } from "../agents/failover/signal.js"; /** Reply payload contracts and metadata helpers shared by dispatch and channel renderers. */ import type { ProgressContinuationCapability } from "../channels/progress-continuation.js"; import type { HarnessCompletionRecovery } from "../config/sessions/restart-recovery-types.js"; @@ -328,6 +329,8 @@ export type ReplyPayloadMetadata = { beforeAgentRunBlocked?: boolean; /** Payload preparation generated this provider error; it is not an authored answer. */ terminalProviderError?: true; + /** Model fallback uses observed failure facts, never the displayed wording. */ + providerFailure?: { reason: FailoverReason | null; rawError?: string }; /** The warning owner observed this tool failure; presentation text is not evidence. */ toolErrorWarning?: { toolName: string }; /** Warning synthesized from an observed tool error after the run produced assistant output. */ diff --git a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts index 30bd35258ef7..2b505db8995a 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.e2e.test.ts @@ -83,7 +83,7 @@ vi.mock("./reply/agent-runner.runtime.js", () => ({ if (/context window exceeded/i.test(message)) { return "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model."; } - return renderControlUiAgentFailureCopy(message); + return renderControlUiAgentFailureCopy(); }; const stripHeartbeat = (text?: string) => { const trimmed = text?.trim(); @@ -385,7 +385,7 @@ describe("trigger handling", () => { for (const testCase of [ { error: "sandbox is not defined.", - expected: renderControlUiAgentFailureCopy("sandbox is not defined."), + expected: renderControlUiAgentFailureCopy(), }, { error: "Context window exceeded", diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts index be91b2bbdfe0..1da2fdac3715 100644 --- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts @@ -726,7 +726,7 @@ describe("runReplyAgent runtime config", () => { if (!result || Array.isArray(result)) { throw new Error("expected a single preflight compaction failure reply payload"); } - expect(result.text).toContain("auto-compaction could not recover"); + expect(result.text).toContain("OpenClaw couldn't shorten it."); expect(getReplyPayloadMetadata(result)?.deliverDespiteSourceReplySuppression).toBe(true); expect(followupRun.run.sessionId).toBe(sessionSnapshot.sessionId); expect(sessionEntry).toEqual(sessionSnapshot); @@ -866,8 +866,8 @@ describe("runReplyAgent runtime config", () => { if (!result || Array.isArray(result)) { throw new Error("expected a single preflight compaction failure reply payload"); } - expect(result.text).toContain("Context is too large"); - expect(result.text).toContain("auto-compaction could not recover"); + expect(result.text).toContain("This conversation is too long"); + expect(result.text).toContain("OpenClaw couldn't shorten it."); expect(result.text).toContain("/compact"); expect(result.text).toContain("/new"); const metadata = getReplyPayloadMetadata(result); diff --git a/src/auto-reply/reply/agent-runner-error-handler.ts b/src/auto-reply/reply/agent-runner-error-handler.ts index 9bf04784f790..63b83929353f 100644 --- a/src/auto-reply/reply/agent-runner-error-handler.ts +++ b/src/auto-reply/reply/agent-runner-error-handler.ts @@ -144,9 +144,7 @@ export async function handleAgentExecutionError(params: { ); takePendingLifecycleTerminal().emit("error", err); const switchErrorText = params.shouldSurfaceToControlUi - ? renderControlUiAgentFailureCopy( - "model switch could not be completed. The requested model may be temporarily unavailable.", - ) + ? "⚠️ Couldn't switch models. Choose another model in the Control UI, then try again." : isVerboseFailureDetailEnabled(turn.resolvedVerboseLevel) ? "⚠️ Agent failed before reply: model switch could not be completed. " + "The requested model may be temporarily unavailable. Please try again shortly." @@ -181,7 +179,7 @@ export async function handleAgentExecutionError(params: { ); const text = params.shouldSurfaceToControlUi && err.userMessage === undefined - ? renderControlUiAgentFailureCopy(message) + ? renderControlUiAgentFailureCopy() : externalReply.text; return await settleFailure({ text }, externalReply.isGenericRunnerFailure); } @@ -280,7 +278,7 @@ export async function handleAgentExecutionError(params: { ? "⚠️ Context overflow — prompt too large for this model. Try a shorter message or a larger-context model." : (externalRunFailureReply?.text ?? (params.shouldSurfaceToControlUi - ? renderControlUiAgentFailureCopy(message) + ? renderControlUiAgentFailureCopy() : (useHeartbeatFailureCopy ?? turn.isHeartbeat) ? HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT : GENERIC_EXTERNAL_RUN_FAILURE_TEXT))); diff --git a/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts index d76f132ef8ef..4adb9650ad23 100644 --- a/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts @@ -390,7 +390,7 @@ describe("executeAgentTurn: authentication failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - "⚠️ Missing API key for OpenAI on the gateway. Use `openai/gpt-6-astra` with the OpenAI OAuth profile, or set `OPENAI_API_KEY` for direct OpenAI API-key runs.", + "⚠️ OpenAI needs a different sign-in for this model. Open Models in the Control UI or run `openclaw configure` to choose how to connect.", ); } }); @@ -409,7 +409,7 @@ describe("executeAgentTurn: authentication failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.', + "⚠️ Couldn't connect to OpenAI. Run `openclaw doctor --fix`, then try again. If it still fails, open Models in the Control UI or run `openclaw configure`.", ); } }); @@ -432,7 +432,7 @@ describe("executeAgentTurn: authentication failures", () => { if (result.kind === "final") { expect(result.payload.text).toContain("Couldn't sign in to openai."); expect(result.payload.text).toContain("openclaw configure"); - expect(result.payload.text).toContain("(invalid_grant)"); + expect(result.payload.text).not.toContain("invalid_grant"); expect(result.payload.text).not.toContain("Auth profile failover exhausted"); } }); @@ -456,7 +456,7 @@ describe("executeAgentTurn: authentication failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - "The selected auth profile is unavailable in this agent's OpenClaw credential store. Import or migrate that credential into the agent, select another configured profile, or run `openclaw configure`, then retry.", + "This saved login isn't available. Choose another login under Models in the Control UI or run `openclaw configure`.", ); expect(result.payload.text).not.toContain("openai:private"); expect(result.payload.text).not.toContain("arbitrary plugin detail"); @@ -481,13 +481,13 @@ describe("executeAgentTurn: authentication failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toContain("Couldn't reach openai"); - expect(result.payload.text).toContain("messages must alternate roles"); + expect(result.payload.text).not.toContain("messages must alternate roles"); expect(result.payload.text).not.toContain("models auth login"); expect(result.payload.text).not.toContain("openclaw configure"); } }); - it("points stale openai missing-key failures at doctor repair with re-auth fallback", async () => { + it("points missing-key failures to account setup", async () => { state.runEmbeddedAgentMock.mockRejectedValueOnce( new ProviderAuthError( "missing-provider-auth", @@ -502,7 +502,7 @@ describe("executeAgentTurn: authentication failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - '⚠️ Missing API key for provider "openai". Run `openclaw doctor --fix` to repair stale OpenAI model/session routes, restart the gateway if doctor asks, then try again. If doctor has nothing to repair or the error persists, re-auth with `openclaw models auth login --provider openai` or run `openclaw configure`.', + "⚠️ Couldn't connect to OpenAI. Run `openclaw doctor --fix`, then try again. If it still fails, open Models in the Control UI or run `openclaw configure`.", ); } }); @@ -522,7 +522,7 @@ describe("executeAgentTurn: authentication failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - "⚠️ Missing API key for the selected provider on the gateway. Configure provider auth, then try again.", + "⚠️ This AI service isn't set up yet. Sign in under Models in the Control UI or run `openclaw configure`.", ); } }); diff --git a/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts index a9e7ec3734f6..1e39d8877c55 100644 --- a/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts @@ -113,7 +113,7 @@ describe("executeAgentTurn: conversation failures", () => { } }); - it("keeps actionable provider errors on internal control surfaces", async () => { + it("shows recovery guidance without provider diagnostics on internal control surfaces", async () => { state.isInternalMessageChannelMock.mockReturnValue(true); const providerError = "provider failed with actionable details"; state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error(providerError)); @@ -134,7 +134,8 @@ describe("executeAgentTurn: conversation failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { - expect(result.payload.text).toContain(providerError); + expect(result.payload.text).toContain("OpenClaw couldn't finish this reply."); + expect(result.payload.text).not.toContain(providerError); expect(result.payload.text).toContain("openclaw logs --follow"); expect(result.payload.text).toMatch(/terminal/i); } diff --git a/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts index 69958b135e37..a3ad42805996 100644 --- a/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts @@ -166,11 +166,13 @@ describe("executeAgentTurn: provider failures", () => { expect(result.payload.text).not.toContain("diagnostic-canary"); } else { expect(result.payload.isError).toBe(true); - expect(result.payload.text).toContain("Agent failed before reply:"); - expect(result.payload.text).toContain("reconnect before continuing"); if (surface === "control UI") { + expect(result.payload.text).toContain("Check the conversation before trying again"); expect(result.payload.text).toContain("openclaw logs --follow"); + expect(result.payload.text).not.toContain("diagnostic-canary"); } else { + expect(result.payload.text).toContain("Agent failed before reply:"); + expect(result.payload.text).toContain("reconnect before continuing"); expect(result.payload.text!.length).toBeLessThanOrEqual(1020); } } @@ -250,7 +252,7 @@ describe("executeAgentTurn: provider failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN); - expect(result.payload.text).toContain('Missing API key for provider "openai"'); + expect(result.payload.text).toContain("openclaw doctor --fix"); } }, ); @@ -349,7 +351,7 @@ describe("executeAgentTurn: provider failures", () => { expect(result.payload.isError).toBe(true); expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN); expect(result.payload.text).toBe( - "⚠️ The model request was rate-limited. Please try again in a few minutes.", + "⚠️ The AI service needs a short break. Please try again in a few minutes.", ); } }, @@ -407,9 +409,7 @@ describe("executeAgentTurn: provider failures", () => { resolvedVerboseLevel: "off", }); - expect(payload?.text).toBe( - "⚠️ All attempted models were rate-limited or overloaded. Please try again in a few minutes.", - ); + expect(payload?.text).toBe("⚠️ The AI services are busy. Please try again in a few minutes."); }); it("surfaces typed periodic rate-limit details through known failure payloads in group chats", () => { @@ -515,8 +515,8 @@ describe("executeAgentTurn: provider failures", () => { expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expect(result.kind).toBe("final"); if (result.kind === "final") { - expect(result.payload.text).toContain("overall turn limit"); - expect(result.payload.text).toContain("did not replay this turn automatically"); + expect(result.payload.text).toContain("task took too long"); + expect(result.payload.text).toContain("Check its results before trying again"); } }, ); @@ -562,9 +562,9 @@ describe("executeAgentTurn: provider failures", () => { expect(wholeTurnRetries).toBe(0); expect(result.kind).toBe("final"); if (result.kind === "final") { - expect(result.payload.text).toContain("overall turn limit"); - expect(result.payload.text).toMatch(/effects may be partial/i); - expect(result.payload.text).toContain("did not replay this turn automatically"); + expect(result.payload.text).toContain("task took too long"); + expect(result.payload.text).toContain("Some work may have completed"); + expect(result.payload.text).toContain("Check its results before trying again"); } }); @@ -589,10 +589,9 @@ describe("executeAgentTurn: provider failures", () => { expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expect(result.kind).toBe("final"); if (result.kind === "final") { - expect(result.payload.text).toContain("no-output watchdog"); - expect(result.payload.text).toContain("1 active CLI tool call"); - expect(result.payload.text).toMatch(/effects may be partial/i); - expect(result.payload.text).toContain("did not replay this turn automatically"); + expect(result.payload.text).toContain("task stopped responding"); + expect(result.payload.text).toContain("Some work may have completed"); + expect(result.payload.text).toContain("Check its results before trying again"); } }); @@ -847,8 +846,7 @@ describe("executeAgentTurn: provider failures", () => { expect(result.kind).toBe("final"); if (result.kind === "final") { expect(result.payload.text).toBe( - "⚠️ LLM request failed (provider internal error). " + - "This is usually temporary — try again shortly.", + "⚠️ The AI service is having trouble. Please try again in a moment.", ); expect(result.payload.text).not.toContain("Something exploded"); } diff --git a/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts index 81367ca88377..e90d6e6619c1 100644 --- a/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts @@ -667,28 +667,22 @@ describe("executeAgentTurn: terminal failures", () => { } expect(result.payload.text).not.toBe(GENERIC_RUN_FAILURE_TEXT); expect(result.payload.text).not.toContain("Claude CLI"); - expect(result.payload.text).toContain("gateway is unaffected"); - if (mode === "overall") { - expect(result.payload.text).toContain("overall turn limit"); - expect(result.payload.text).toContain("detached OpenClaw sub-agent"); - expect(result.payload.text).toContain("agents.defaults.timeoutSeconds"); - expect(result.payload.text).not.toContain("noOutputTimeoutMs"); - } else { - expect(result.payload.text).toContain("CLI subprocess"); - expect(result.payload.text).toContain("no-output watchdog"); - expect(result.payload.text).toContain("separate from the overall agent timeout"); - expect(result.payload.text).toContain("produced no output before its watchdog expired"); - expect(result.payload.text).not.toContain("noOutputTimeoutMs"); - expect(result.payload.text).not.toContain("agents.defaults.timeoutSeconds"); - } + expect(result.payload.text).toContain( + mode === "overall" + ? "task time limit in the Control UI settings" + : "prompt in the terminal", + ); + expect(result.payload.text).toContain( + mode === "overall" ? "task took too long" : "task stopped responding", + ); expect(result.payload.text).not.toContain("/new"); if (routingSubstring) { - expect(result.payload.text).toContain(routingSubstring); + expect(result.payload.text).not.toContain(routingSubstring); } }, ); - it("explains that CLI background tasks share the timed-out parent process", () => { + it("warns that interrupted CLI background work may have completed", () => { const payload = buildKnownAgentRunFailureReplyPayload({ err: createCliTimeoutError( { provider: "claude-cli" }, @@ -705,21 +699,19 @@ describe("executeAgentTurn: terminal failures", () => { resolvedVerboseLevel: "off", }); - expect(payload?.text).toContain("1 CLI background task"); - expect(payload?.text).toContain("1 active CLI tool call"); - expect(payload?.text).toContain("shares the parent CLI process"); - expect(payload?.text).toContain("Effects may be partial"); - expect(payload?.text).toContain("no run timeout by default"); + expect(payload?.text).toContain("Some work may have completed"); + expect(payload?.text).toContain("Check its results before trying again"); + expect(payload?.text).toContain("task time limit in the Control UI settings"); }); it.each([ { rejection: new Error("codex app-server client closed before turn completed"), - expected: "connection closed", + expected: "Lost the connection", }, { rejection: new Error("codex app-server turn idle timed out waiting for turn/completed"), - expected: "did not replay the turn automatically", + expected: "hasn't confirmed whether the task finished", }, ])( "surfaces Codex app-server bridge failures instead of generic copy", @@ -736,7 +728,7 @@ describe("executeAgentTurn: terminal failures", () => { throw new Error("expected final reply"); } expect(result.payload.text).not.toBe(GENERIC_RUN_FAILURE_TEXT); - expect(result.payload.text).toContain("Codex app-server"); + expect(result.payload.text).toContain("may still be running"); expect(result.payload.text).toContain(expected); }, ); diff --git a/src/auto-reply/reply/agent-runner-execution.test-support.ts b/src/auto-reply/reply/agent-runner-execution.test-support.ts index 917b4d658f71..a05f7c0063ae 100644 --- a/src/auto-reply/reply/agent-runner-execution.test-support.ts +++ b/src/auto-reply/reply/agent-runner-execution.test-support.ts @@ -38,7 +38,7 @@ type RunCliAgent = typeof import("../../agents/cli-runner.js").runCliAgent; export const PROVIDER_AUTHENTICATION_ERROR_USER_MESSAGE = `⚠️ ${AUTH_INVALID_TOKEN_USER_TEXT}`; export { createMockReplyOperation } from "./test-helpers.js"; export const PROVIDER_RATE_LIMIT_OR_QUOTA_ERROR_USER_MESSAGE = - "⚠️ The model provider returned HTTP 429 before replying. This can mean rate limiting, exhausted quota, or an account balance/billing issue. Check the selected provider/model, API key, and provider billing/quota dashboard, then try again."; + "⚠️ The AI service can't accept more requests right now. Wait a few minutes, then try again. If it continues, check your account's usage and billing limits."; export const PROVIDER_INTERNAL_ERROR_USER_MESSAGE = "⚠️ The model provider returned a temporary internal error before replying. Try again in a moment, or switch to another model if it keeps happening."; @@ -83,7 +83,7 @@ const state = vi.hoisted(() => ({ })); export const GENERIC_RUN_FAILURE_TEXT = - "⚠️ Something went wrong while processing your request. Please try again, or use /new to start a fresh session."; + "⚠️ OpenClaw couldn't finish this request. Check the conversation before trying again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`."; export function makeTestModel(id: string, contextTokens: number): ModelDefinitionConfig { return { id, diff --git a/src/auto-reply/reply/agent-runner-failure-reply.test.ts b/src/auto-reply/reply/agent-runner-failure-reply.test.ts index d139b9ccaa50..eceb41aff931 100644 --- a/src/auto-reply/reply/agent-runner-failure-reply.test.ts +++ b/src/auto-reply/reply/agent-runner-failure-reply.test.ts @@ -140,7 +140,7 @@ describe("buildExternalRunFailureReply", () => { expect(verbose.isGenericRunnerFailure).toBe(false); }); - it("keeps unclassified model context visible without exposing raw detail", () => { + it("points unclassified failures to logs without exposing raw detail", () => { const message = "opaque-private-provider-detail"; const reply = buildExternalRunFailureReply( { @@ -155,7 +155,7 @@ describe("buildExternalRunFailureReply", () => { ); expect(reply.isGenericRunnerFailure).toBe(false); - expect(reply.text).toContain("openai/test-model"); + expect(reply.text).toContain("openclaw logs --follow"); expect(reply.text).not.toContain(message); }); @@ -225,7 +225,7 @@ describe("buildExternalRunFailureReply", () => { expect(reply.text).not.toMatch(/HTTP|openai\/test-model|context preparation/); } else if (error.reason === "timeout") { expect(reply.text).toBe( - "⚠️ The request timed out. Please try again. If it keeps happening, try a shorter request or a different model.", + "⚠️ The request took too long. Check the conversation for any completed work before trying again.", ); expect(error).toMatchObject({ reason: "timeout", @@ -234,7 +234,7 @@ describe("buildExternalRunFailureReply", () => { model: "test-model", }); } else { - expect(reply.text).toContain("openai/test-model"); + expect(reply.text).toContain("AI service is busy"); expect(reply.text).not.toMatch(/local worker/i); } }); diff --git a/src/auto-reply/reply/agent-runner-failure-reply.ts b/src/auto-reply/reply/agent-runner-failure-reply.ts index 80bbe0e56864..d8064b37e3cc 100644 --- a/src/auto-reply/reply/agent-runner-failure-reply.ts +++ b/src/auto-reply/reply/agent-runner-failure-reply.ts @@ -159,10 +159,10 @@ function buildCodexAppServerFailureText(message: string): string | null { return "⚠️ This Codex session changed before your message could run. Please send it again."; } if (CODEX_APP_SERVER_CLIENT_CLOSED_BEFORE_REPLY_RE.test(normalizedMessage)) { - return "⚠️ Codex app-server connection closed before this turn finished. OpenClaw retried once when the stdio turn was still replay-safe; please try again if this keeps happening."; + return "⚠️ Lost the connection to Codex before it confirmed the task was finished. It may still be running. Check the conversation in the Control UI before trying again."; } if (CODEX_APP_SERVER_TURN_COMPLETION_IDLE_TIMEOUT_RE.test(normalizedMessage)) { - return "⚠️ Codex app-server stopped before confirming turn completion. OpenClaw did not replay the turn automatically because it may still be active; try again, or use /new if the session stays stuck."; + return "⚠️ Codex hasn't confirmed whether the task finished. It may still be running. Check the conversation in the Control UI before trying again."; } return null; } @@ -185,8 +185,8 @@ export function buildPreflightCompactionFailureText( const isTimeout = classifyCompactionReason(reason) === "timeout"; const reasonSuffix = options?.includeDetails && reason && !isTimeout ? ` Reason: ${reason}.` : ""; const summary = isTimeout - ? "⚠️ Context is too large and auto-compaction timed out before it could finish." - : "⚠️ Context is too large and auto-compaction could not recover this turn."; + ? "⚠️ This conversation is too long, and shortening it took too long." + : "⚠️ This conversation is too long, and OpenClaw couldn't shorten it."; return `${summary}${reasonSuffix} Try again, use /compact, or use /new to start a fresh session.`; } @@ -198,7 +198,6 @@ export function buildAuthProfileFailoverFailureText(error: unknown): string | nu reason: error.reason, provider: error.provider, allInCooldown: error.authProfileFailure.allInCooldown, - causeText: error.cause ? formatErrorMessage(error.cause).trim() : undefined, recoveryHint: buildProviderAuthRecoveryHint({ provider: error.provider }), }); } diff --git a/src/auto-reply/reply/agent-runner-fallback-settlement.ts b/src/auto-reply/reply/agent-runner-fallback-settlement.ts index 900e3af7c1f5..27d7daee65dd 100644 --- a/src/auto-reply/reply/agent-runner-fallback-settlement.ts +++ b/src/auto-reply/reply/agent-runner-fallback-settlement.ts @@ -7,7 +7,6 @@ import { } from "../../agents/failover/user-copy.js"; import { logVerbose } from "../../globals.js"; import { emitAgentEvent } from "../../infra/agent-events.js"; -import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js"; import { resolveSourceReplyPolicy } from "./agent-runner-core.js"; @@ -115,7 +114,7 @@ export async function settleAgentFallbackCycle(params: { activeSessionEntry: turn.getActiveSessionEntry(), }) : cycle.shouldSurfaceToControlUi - ? renderControlUiAgentFailureCopy(formatErrorMessage(embeddedError)) + ? renderControlUiAgentFailureCopy() : PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE; return { kind: "final", diff --git a/src/auto-reply/reply/agent-runner-payloads.ts b/src/auto-reply/reply/agent-runner-payloads.ts index d203e0083e4e..c591053c4338 100644 --- a/src/auto-reply/reply/agent-runner-payloads.ts +++ b/src/auto-reply/reply/agent-runner-payloads.ts @@ -182,7 +182,7 @@ export async function buildReplyPayloads(params: { let text = payload.text; if (payload.isError && text && isBunFetchSocketError(text)) { - text = formatBunFetchSocketError(text); + text = formatBunFetchSocketError(); } if (text?.includes("HEARTBEAT_OK")) { diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 59fce2e53de0..58b7fa7b3459 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -187,15 +187,8 @@ export const isBunFetchSocketError = (message?: string) => message ? BUN_FETCH_SOCKET_ERROR_RE.test(message) : false; /** Formats Bun socket-close errors for user-facing reply output. */ -export const formatBunFetchSocketError = (message: string) => { - const trimmed = message.trim(); - return [ - "⚠️ LLM connection failed. This could be due to server issues, network problems, or context length exceeded (e.g., with local LLMs like LM Studio). Original error:", - "```", - trimmed || "Unknown error", - "```", - ].join("\n"); -}; +export const formatBunFetchSocketError = () => + "⚠️ Lost the connection to the AI service. Check the conversation before trying again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`."; /** Remaps the original inline request without reusing a queued model's clamped level. */ export function resolveRunThinkingLevelForFallbackCandidate( diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index e4687379412d..7119a430e697 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -2466,7 +2466,7 @@ describe("runReplyAgent transient HTTP failures", () => { expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); const payload = Array.isArray(result) ? result[0] : result; - expect(payload?.text).toContain("provider internal error"); + expect(payload?.text).toContain("The AI service is having trouble"); }); }); @@ -2491,7 +2491,7 @@ describe("runReplyAgent billing error classification", () => { }).run(); const payload = Array.isArray(result) ? result[0] : result; - expect(payload?.text).toContain("billing error"); + expect(payload?.text).toContain("billing problem"); expect(payload?.text).not.toContain("Context overflow"); }); }); @@ -2523,7 +2523,7 @@ describe("runReplyAgent mid-turn rate-limit fallback", () => { const result = await createRun(); const payload = Array.isArray(result) ? result[0] : result; - expect(payload?.text).toContain("API rate limit reached"); + expect(payload?.text).toContain("The AI service needs a short break"); }); it("preserves successful media-only replies that use legacy mediaUrl", async () => { diff --git a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts index eb5ed9da7627..e93e1a596f9a 100644 --- a/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts +++ b/src/auto-reply/reply/agent-runner.runreplyagent.e2e.test.ts @@ -4119,7 +4119,7 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce(); expect(payloads.map((payload) => payload?.text)).toHaveLength(1); - expect(payloads[0]?.text).toContain("provider internal error"); + expect(payloads[0]?.text).toContain("The AI service is having trouble"); }); it("announces model fallback transitions across verbose levels", async () => { @@ -4479,7 +4479,7 @@ describe("runReplyAgent typing (heartbeat)", () => { expect(onBlockReply).toHaveBeenCalledOnce(); expect(onBlockReply).toHaveBeenCalledWith( expect.objectContaining({ - text: "LLM request failed: provider rejected the request schema or tool payload.", + text: "The AI service couldn't accept this request. Try a new conversation with /new, or choose another model in the Control UI.", isError: true, }), ); @@ -5902,9 +5902,9 @@ describe("runReplyAgent typing (heartbeat)", () => { const res = await run(); const payloads = Array.isArray(res) ? res : res ? [res] : []; expect(payloads.length).toBe(1); - expect(payloads[0]?.text).toContain("LLM connection failed"); - expect(payloads[0]?.text).toContain("socket connection was closed unexpectedly"); - expect(payloads[0]?.text).toContain("```"); + expect(payloads[0]?.text).toContain("Lost the connection to the AI service"); + expect(payloads[0]?.text).toContain("openclaw logs --follow"); + expect(payloads[0]?.text).not.toContain("socket connection was closed unexpectedly"); }); }); diff --git a/src/auto-reply/reply/commands-approve.test.ts b/src/auto-reply/reply/commands-approve.test.ts index caada0c65e02..9fce8f9b2cab 100644 --- a/src/auto-reply/reply/commands-approve.test.ts +++ b/src/auto-reply/reply/commands-approve.test.ts @@ -518,7 +518,7 @@ describe("handleApproveCommand", () => { true, ); - expect(result?.reply?.text).toContain("unknown or expired approval id"); + expect(result?.reply?.text).toContain("That approval is no longer available"); expect(resolveApprovalOverGatewayMock).toHaveBeenCalledTimes(2); expect(approvalResolverRequest(0).approvalKind).toBeUndefined(); expect(approvalResolverRequest(1).approvalKind).toBeUndefined(); @@ -562,7 +562,7 @@ describe("handleApproveCommand", () => { }, }); - expect(result?.reply?.text).toContain("owner authority changed"); + expect(result?.reply?.text).toContain("Check the request in the Control UI"); const canonicalCalls = resolveApprovalOverGatewayMock.mock.calls.filter( ([request]) => (request as { approvalKind?: string }).approvalKind === "system-agent", ); @@ -582,7 +582,7 @@ describe("handleApproveCommand", () => { }); }); - it("returns the underlying not-found error for plugin-only approval routing", async () => { + it("explains an expired approval for plugin-only approval routing", async () => { setActivePluginRegistry( createTestRegistry([ { @@ -624,8 +624,8 @@ describe("handleApproveCommand", () => { ); expect(result?.shouldContinue).toBe(false); - expect(result?.reply?.text).toContain("Failed to submit approval"); - expect(result?.reply?.text).toContain("unknown or expired approval id"); + expect(result?.reply?.text).toContain("approval is no longer available"); + expect(result?.reply?.text).toContain("Control UI"); expect(resolveApprovalOverGatewayMock).toHaveBeenCalledTimes(1); expectApprovalResolverCall({ method: "plugin.approval.resolve", id: "abc123" }); }); @@ -704,7 +704,7 @@ describe("handleApproveCommand", () => { resolveApprovalOverGatewayMock.mockRejectedValue( new Error("unknown or expired approval id"), ), - expectedText: "unknown or expired approval id", + expectedText: "That approval is no longer available", expectResolverCalls: 2, }, { diff --git a/src/auto-reply/reply/commands-approve.ts b/src/auto-reply/reply/commands-approve.ts index 88ef326d6afd..7ed160cd0f1a 100644 --- a/src/auto-reply/reply/commands-approve.ts +++ b/src/auto-reply/reply/commands-approve.ts @@ -17,10 +17,13 @@ import { type ApprovalCommandAuthorization, } from "../../infra/channel-approval-auth.js"; import { formatErrorMessage } from "../../infra/errors.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; import { resolveChannelAccountId } from "./channel-context.js"; import { commandReply, requireGatewayClientScope } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; +const log = createSubsystemLogger("auto-reply/commands-approve"); + const COMMAND_REGEX = /^\/?approve(?:\s|$)/i; const FOREIGN_COMMAND_MENTION_REGEX = /^\/approve@([^\s]+)(?:\s|$)/i; @@ -289,8 +292,14 @@ export async function handleApproveCommandFromContext( if (systemAgentRefusedForOwner) { return ownerOnlyResult; } + return commandReply( + "That approval is no longer available. Check the request in the Control UI.", + ); } - return commandReply(`❌ Failed to submit approval: ${formatErrorMessage(error)}`); + log.warn(`Approval submission failed: ${formatErrorMessage(error)}`); + return commandReply( + "⚠️ Couldn't confirm that approval. Check the request in the Control UI before trying again.", + ); } } diff --git a/src/auto-reply/reply/commands-btw.ts b/src/auto-reply/reply/commands-btw.ts index a54fbbcd26b3..cda16afe0ac6 100644 --- a/src/auto-reply/reply/commands-btw.ts +++ b/src/auto-reply/reply/commands-btw.ts @@ -11,6 +11,8 @@ import { mintMessageActionTurnCapability, revokeMessageActionTurnCapability, } from "../../gateway/message-action-turn-capability.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; import { extractBtwQuestion } from "./btw-command.js"; import { commandReply, defineAuthorizedTextCommand } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; @@ -18,6 +20,8 @@ import { resolveCurrentTurnImages } from "./current-turn-images.js"; const BTW_USAGE = "Usage: /btw [side question]"; +const log = createSubsystemLogger("auto-reply/commands-btw"); + /** Command handler for /btw side questions. */ export const handleBtwCommand: CommandHandler = defineAuthorizedTextCommand( { label: "/btw", match: (body) => extractBtwQuestion(body) }, @@ -163,11 +167,11 @@ export const handleBtwCommand: CommandHandler = defineAuthorizedTextCommand( reply: reply ? { ...reply, btw: { question } } : reply, }; } catch (error) { - const message = error instanceof Error ? error.message.trim() : ""; + log.warn(`Side question failed: ${formatErrorMessage(error)}`); return { shouldContinue: false, reply: { - text: `⚠️ /btw failed${message ? `: ${message}` : "."}`, + text: "⚠️ Couldn't answer that side question. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", btw: { question }, isError: true, }, diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index 41c4b47e85d9..5f5081cf62f1 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -6,6 +6,7 @@ import { } from "@openclaw/normalization-core/string-coerce"; import { readLatestAssistantTextFromSessionTranscript } from "../../config/sessions.js"; import { resolveSessionStorePathForScope } from "../../config/sessions/session-store-path.js"; +import { createSubsystemLogger } from "../../logging/subsystem.js"; import { isUnscopedSessionKeySentinel, resolveAgentIdFromSessionKey, @@ -48,6 +49,8 @@ import { } from "./commands-session-store.js"; import type { CommandHandler, CommandHandlerResult } from "./commands-types.js"; +const log = createSubsystemLogger("auto-reply/commands-tts"); + type ParsedTtsCommand = { action: string; args: string; @@ -230,7 +233,10 @@ async function handleTtsLatestAction( agentId: targetAgentId, }); if ("error" in audio) { - return stopWithText(`❌ Error generating audio: ${audio.error}`); + log.warn(`Audio generation failed: ${audio.error}`); + return stopWithText( + "⚠️ Couldn't create the audio. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", + ); } params.sessionEntry.lastTtsReadLatestHash = hash; @@ -350,7 +356,10 @@ export const handleTtsCommands: CommandHandler = defineAuthorizedTextCommand( if (!("error" in audio)) { return { shouldContinue: false, reply: audio.reply }; } - return stopWithText(`❌ Error generating audio: ${audio.error}`); + log.warn(`Audio generation failed: ${audio.error}`); + return stopWithText( + "⚠️ Couldn't create the audio. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", + ); } if (action === "provider") { diff --git a/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts b/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts index edde275b4956..070e3e887241 100644 --- a/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts +++ b/src/auto-reply/reply/dispatch-from-config.terminal-recovery.test.ts @@ -166,7 +166,9 @@ describe("dispatchReplyFromConfig visible admission recovery", () => { text: "partial telegram reply", }); expect(dispatchParams.dispatcher.sendFinalReply).toHaveBeenCalledWith( - expect.objectContaining({ text: expect.stringContaining("Something went wrong") }), + expect.objectContaining({ + text: expect.stringContaining("Check the conversation before trying again"), + }), ); }); @@ -280,7 +282,7 @@ describe("dispatchReplyFromConfig visible admission recovery", () => { ); if (expectedFinal) { expect(delivered.at(-1)?.payload).toMatchObject({ - text: expect.stringContaining("Something went wrong"), + text: expect.stringContaining("Check the conversation before trying again"), isError: true, }); } diff --git a/src/auto-reply/reply/followup-delivery.channel.test.ts b/src/auto-reply/reply/followup-delivery.channel.test.ts index b067e4c89043..bbe528dbbdb0 100644 --- a/src/auto-reply/reply/followup-delivery.channel.test.ts +++ b/src/auto-reply/reply/followup-delivery.channel.test.ts @@ -245,7 +245,10 @@ describe("follow-up delivery channel boundary", () => { }); expect(onBlockReply).toHaveBeenCalledExactlyOnceWith( - expect.objectContaining({ isError: true, text: expect.stringMatching(/rate limit/i) }), + expect.objectContaining({ + isError: true, + text: expect.stringContaining("The AI service needs a short break"), + }), ); }); it.each<{ diff --git a/src/cli/failure-output.contention.test.ts b/src/cli/failure-output.contention.test.ts index b5279ebd5bd5..7cc9c2c92efe 100644 --- a/src/cli/failure-output.contention.test.ts +++ b/src/cli/failure-output.contention.test.ts @@ -1,7 +1,21 @@ import { expect, it } from "vitest"; import { GatewayStateOwnerContentionError } from "../infra/gateway-state-owner.js"; +import { DoctorUnreadableStateDatabaseError } from "../infra/state-repair-message.js"; +import { OpenClawDatabaseSchemaPreflightError } from "../state/openclaw-database-preflight.messages.js"; import { formatCliFailureLines, formatCliJsonFailure } from "./failure-output.js"; +it.each([ + new DoctorUnreadableStateDatabaseError("/state/openclaw.sqlite", "unreadable"), + new OpenClawDatabaseSchemaPreflightError([ + { kind: "state", path: "/state/openclaw.sqlite", foundVersion: 20, supportedVersion: 19 }, + ]), +])("preserves the manual recovery when Doctor cannot repair $name", (error) => { + const output = formatCliFailureLines({ title: "Command failed", error, env: {} }).join("\n"); + expect(output).toContain("restore"); + expect(output).toContain("backup"); + expect(output).not.toContain("For help, run `openclaw doctor`"); +}); + it.each(["nested", "message-only"])( "preserves %s contention output and classifies the Doctor hint by error identity", (kind) => { @@ -19,10 +33,9 @@ it.each(["nested", "message-only"])( }); expect(formatCliFailureLines({ title: "The CLI command failed.", error, env: {} })).toEqual([ "[openclaw] The CLI command failed.", - `[openclaw] Reason: ${error.message}`, - "[openclaw] Debug: set OPENCLAW_DEBUG=1 to include the stack trace.", - ...(kind === "message-only" ? ["[openclaw] Try: openclaw doctor"] : []), - "[openclaw] Help: openclaw --help", + kind === "nested" + ? "[openclaw] Another OpenClaw process is using your data. Wait for it to finish before trying again." + : "[openclaw] For help, run `openclaw doctor`.", ]); expect(error.message).toContain("Wait for the other OpenClaw process to finish, then retry."); }, diff --git a/src/cli/failure-output.test.ts b/src/cli/failure-output.test.ts index 2f895ade9b27..c401a3f35005 100644 --- a/src/cli/failure-output.test.ts +++ b/src/cli/failure-output.test.ts @@ -27,6 +27,36 @@ const EXPLICIT_GATEWAY_AUTH_MESSAGE = [ ].join("\n"); describe("formatCliJsonFailure", () => { + it.each([false, true])( + "keeps connection diagnostics out of normal output (sent=%s)", + (requestDispatched) => { + const diagnostic = "gateway closed (1006): PRIVATE_CANARY\nConfig: /state/openclaw.json"; + const error = new GatewayTransportError({ + kind: "closed", + message: diagnostic, + requestDispatched, + connectionDetails: { + url: "ws://127.0.0.1:18789", + urlSource: "local loopback", + message: diagnostic, + }, + }); + const output = formatCliFailureLines({ title: "Command failed", error, env: {} }).join("\n"); + expect(output).toContain("openclaw gateway status"); + expect(output).not.toContain("PRIVATE_CANARY"); + expect(output).not.toContain("/state/"); + expect(output.includes("may have completed")).toBe(requestDispatched); + expect(formatCliJsonFailure(error, { env: {} }).error.message).toBe(diagnostic); + expect( + formatCliFailureLines({ + title: "Command failed", + error, + env: { OPENCLAW_DEBUG: "1" }, + }).join("\n"), + ).toBe(diagnostic); + }, + ); + it("preserves the typed schema refusal when a runner migration fails before Doctor starts", () => { const databases = [ { @@ -40,6 +70,9 @@ describe("formatCliJsonFailure", () => { targetVersion: "2026.9.4", cause: new Error("content migration failed"), }); + const output = formatCliFailureLines({ title: "Update failed", error, env: {} }).join("\n"); + expect(output).toContain("Let the updater restore the previous package and exit"); + expect(output).toContain("openclaw doctor --fix"); expect(formatCliJsonFailure(error, { env: {} })).toMatchObject({ ok: false, error: { @@ -184,10 +217,7 @@ describe("formatCliFailureLines", () => { expect(lines).toEqual([ "[openclaw] Could not start the CLI.", - "[openclaw] Reason: config file is invalid", - "[openclaw] Debug: set OPENCLAW_DEBUG=1 to include the stack trace.", - "[openclaw] Try: openclaw doctor", - "[openclaw] Help: openclaw --help", + "[openclaw] For help, run `openclaw doctor`.", ]); }); @@ -304,7 +334,7 @@ describe("formatCliFailureLines", () => { }); expect(lines).not.toContain("[openclaw] Stack:"); - expect(lines).toContain("[openclaw] Debug: set OPENCLAW_DEBUG=1 to include the stack trace."); + expect(lines).toContain("[openclaw] For help, run `openclaw doctor`."); }, ); }); diff --git a/src/cli/failure-output.ts b/src/cli/failure-output.ts index 8085bcf01bc3..c7dd01fcf57a 100644 --- a/src/cli/failure-output.ts +++ b/src/cli/failure-output.ts @@ -192,14 +192,56 @@ function pushPrefixed(out: string[], value: string): void { } export function formatCliFailureLines(options: FormatCliFailureOptions): string[] { + const env = options.env ?? process.env; + const showDebugDetails = shouldShowDebugDetails(options.argv, env); + // Update subprocesses use both marker values and retain captured reasons for recovery. + const showUpdateDiagnostics = ["0", "1"].includes(env.OPENCLAW_UPDATE_IN_PROGRESS ?? ""); + if (isGatewayTransportError(options.error) && !showDebugDetails && !showUpdateDiagnostics) { + const error = options.error; + return [ + error.kind === "timeout" + ? "OpenClaw took too long to respond." + : error.requestDispatched + ? "Lost the connection to OpenClaw." + : "Couldn't connect to OpenClaw.", + ...(error.requestDispatched + ? ["Your request may have completed. Check its result before trying again."] + : []), + `Check the Control UI or run \`${formatCliCommand("openclaw gateway status", options.env)}\` in your terminal.`, + ]; + } if (isExpectedCliError(options.error)) { const output = resolveExpectedCliOutput(options.error); return output.humanOutputWritten ? [] : output.humanOutput.trimEnd().split("\n"); } // Default output stays terse; causes and stack traces require explicit debug intent. - const env = options.env ?? process.env; - const showDebugDetails = shouldShowDebugDetails(options.argv, env); + const stateBusy = collectNestedErrorCandidates(options.error).some( + (error) => error instanceof Error && error.name === "GatewayStateOwnerContentionError", + ); + if (!showDebugDetails && !showUpdateDiagnostics) { + if ( + options.error instanceof UpdateSchemaRefusalError || + (options.error instanceof Error && + (options.error.name === "DoctorUnreadableStateDatabaseError" || + options.error.name === "OpenClawDatabaseSchemaPreflightError")) + ) { + // Doctor cannot repair these refusals; their producers own the required recovery steps. + const lines = ["[openclaw] OpenClaw needs a manual recovery step."]; + lines.push( + `[openclaw] Reason: ${formatCliOperatorError(options.error, { argv: options.argv, env })}`, + ); + return lines; + } + return [ + `[openclaw] ${options.title}`, + stateBusy + ? "[openclaw] Another OpenClaw process is using your data. Wait for it to finish before trying again." + : options.includeDoctorHint === false + ? `[openclaw] For details, open Settings → Logs in the Control UI or run \`${formatCliCommand("openclaw logs --follow", env)}\`.` + : `[openclaw] For help, run \`${formatCliCommand("openclaw doctor", env)}\`.`, + ]; + } const lines = [ `[openclaw] ${options.title}`, `[openclaw] Reason: ${formatCliOperatorError(options.error, { @@ -211,17 +253,10 @@ export function formatCliFailureLines(options: FormatCliFailureOptions): string[ if (showDebugDetails) { lines.push("[openclaw] Stack:"); pushPrefixed(lines, formatUncaughtError(options.error)); - } else { - lines.push("[openclaw] Debug: set OPENCLAW_DEBUG=1 to include the stack trace."); } // Doctor needs the same state owner; inspect wrappers without loading the SQLite runtime. - if ( - options.includeDoctorHint !== false && - !collectNestedErrorCandidates(options.error).some( - (error) => error instanceof Error && error.name === "GatewayStateOwnerContentionError", - ) - ) { + if (options.includeDoctorHint !== false && !stateBusy) { lines.push(`[openclaw] Try: ${formatCliCommand("openclaw doctor", env)}`); } lines.push(`[openclaw] Help: ${formatCliCommand("openclaw --help", env)}`); diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index b0035049c2a0..2a1fdeaf608d 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -162,7 +162,7 @@ describe("runCli exit behavior", () => { expect(consoleErrorSpy).toHaveBeenCalledWith( "[openclaw] OpenClaw hit an unexpected runtime error.", ); - expect(consoleErrorSpy).toHaveBeenCalledWith("[openclaw] Reason: boom"); + expect(consoleErrorSpy).toHaveBeenCalledWith("[openclaw] For help, run `openclaw doctor`."); expect(restoreRuntimeTerminalStateMock).toHaveBeenCalledWith("uncaught exception", { resumeStdinIfPaused: false, }); diff --git a/src/gateway/chat-display-projection.provider-owner.test.ts b/src/gateway/chat-display-projection.provider-owner.test.ts index 530039ae4291..fbfceead439d 100644 --- a/src/gateway/chat-display-projection.provider-owner.test.ts +++ b/src/gateway/chat-display-projection.provider-owner.test.ts @@ -18,14 +18,14 @@ it.each([ ["prompt reached the tenant maximum", "The agent run failed before producing a reply."], [ "database is locked", - "⚠️ Agent run failed: the Gateway state database was busy (SQLite: database is locked). Retry; if it repeats, check Gateway storage health.", + "⚠️ OpenClaw is busy saving your conversation. Wait a moment, then check the conversation before trying again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow` in your terminal.", ], ])("projects recorded failures without discovering provider policy: %s", (error, text) => { expect(failure(error)).toMatchObject({ content: [{ type: "text", text }] }); expect(classifyProviderFailoverSignalWithPlugin).not.toHaveBeenCalled(); }); -it("shows the upstream cache limit without proxy metadata", () => { +it("shows cache-limit recovery guidance without proxy metadata", () => { const errorBody = JSON.stringify({ error: { message: "All target providers failed.", @@ -48,7 +48,7 @@ it("shows the upstream cache limit without proxy metadata", () => { content: [ { type: "text", - text: "LLM request rejected: provider allows at most 4 cache_control blocks; the request contained 5.", + text: "The AI service couldn't accept this conversation. Start a new conversation with /new, or choose another model in the Control UI.", }, ], }); @@ -66,7 +66,7 @@ it("keeps safe failure guidance alongside partial reply text", () => { content: [ { type: "text", - text: "⚠️ LLM request failed (rate limited, HTTP 429). This is usually temporary — try again shortly.\n\nThe first step completed.", + text: "⚠️ The AI service needs a short break. Please try again in a few minutes.\n\nThe first step completed.", }, ], }); diff --git a/src/gateway/server-methods/chat-aborted-partial.ts b/src/gateway/server-methods/chat-aborted-partial.ts index c2e270a1a573..85309aae043e 100644 --- a/src/gateway/server-methods/chat-aborted-partial.ts +++ b/src/gateway/server-methods/chat-aborted-partial.ts @@ -154,6 +154,7 @@ export function deferAbortedPartialPersistence( sessionKey: snapshot.value.sessionKey, agentId: snapshot.value.agentId, errorMessage: warning, + stopReason: "aborted-partial-persistence-failed", }); } catch (error) { // Delivery failure cannot retain a finished producer's successor fence. diff --git a/src/gateway/server-methods/chat-send-dispatch-errors.test.ts b/src/gateway/server-methods/chat-send-dispatch-errors.test.ts index 7a6e90444c1e..42f53eb0b2c9 100644 --- a/src/gateway/server-methods/chat-send-dispatch-errors.test.ts +++ b/src/gateway/server-methods/chat-send-dispatch-errors.test.ts @@ -292,8 +292,8 @@ describe("createChatSendDispatchErrorLifecycle", () => { if (missingProfile) { const recovery = renderFailoverCodeUserCopy("selected_auth_profile_unavailable")!; const storedError = loadSessionEntry(target)?.lastRunError; - expect(storedError).toMatch(/^The selected auth profile is unavailable/u); - expect(storedError).toContain("`openclaw configure`, then retry."); + expect(storedError).toMatch(/^This saved login isn't available\./u); + expect(storedError).toContain("run `openclaw configure`."); expect(storedError?.length).toBeLessThanOrEqual(160); expect(JSON.stringify(messages)).toContain(recovery); expect(JSON.stringify(messages)).not.toContain("openai:removed"); diff --git a/src/gateway/server.chat-recovered-output.test.ts b/src/gateway/server.chat-recovered-output.test.ts index 755481c697c5..d12bac6e7253 100644 --- a/src/gateway/server.chat-recovered-output.test.ts +++ b/src/gateway/server.chat-recovered-output.test.ts @@ -241,7 +241,7 @@ describe("registered chat.send recovered output over Responses HTTP", () => { if (failed) { expect(completed.status).toBe("error"); expect(messageText(history.messages.at(-1))).toBe( - `⚠️ LLM request failed (provider internal error). This is usually temporary — try again shortly.\n\n${prefix}`, + `⚠️ The AI service is having trouble. Please try again in a moment.\n\n${prefix}`, ); expect(terminal.some((event) => event.state === "error")).toBe(true); const deltas = events.filter( diff --git a/src/infra/update-candidate-canary.test.ts b/src/infra/update-candidate-canary.test.ts index 20cb05c8368f..c5916c2b4337 100644 --- a/src/infra/update-candidate-canary.test.ts +++ b/src/infra/update-candidate-canary.test.ts @@ -718,8 +718,12 @@ describe("update candidate canary", () => { child.stderr.write("openclaw-update-canary-progress: cli.main.gateway-run-bootstrap\n"); } else { child.stderr.write( - formatCliFailureLines({ title: "The CLI command failed.", error, env: {} }).join("\n") + - "\n", + formatCliFailureLines({ + title: "The CLI command failed.", + error, + argv: args, + env: options.env, + }).join("\n") + "\n", ); } if (["lint", "startup", "config"].includes(scenario)) { diff --git a/src/shared/assistant-error-format.test.ts b/src/shared/assistant-error-format.test.ts index 881a17cd6549..32518227d493 100644 --- a/src/shared/assistant-error-format.test.ts +++ b/src/shared/assistant-error-format.test.ts @@ -135,7 +135,7 @@ describe("HTTP status consumers", () => { ].join("\n"); expect(formatRawAssistantErrorForUi(raw)).toBe( - "The AI service is temporarily unavailable (HTTP 502). Please try again in a moment.", + "Couldn't reach the AI service. Try again in a moment. If it continues, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", ); }); diff --git a/src/shared/assistant-error-format.ts b/src/shared/assistant-error-format.ts index c17ddf1c5a9f..4f24ee66e6dd 100644 --- a/src/shared/assistant-error-format.ts +++ b/src/shared/assistant-error-format.ts @@ -246,18 +246,8 @@ export function formatRawAssistantErrorForUi(raw?: string): string { return GENERIC_PROVIDER_INTERNAL_ERROR_USER_MESSAGE; } - const leadingStatus = extractLeadingHttpStatus(trimmed); - const isHtmlChallenge = isCloudflareOrHtmlErrorPage(trimmed); - if (leadingStatus && isHtmlChallenge) { - return `The AI service is temporarily unavailable (HTTP ${leadingStatus.code}). Please try again in a moment.`; - } - - if (isHtmlChallenge) { - return ( - "The provider returned an HTML error page instead of an API response. " + - "This usually means a CDN or gateway (e.g. Cloudflare) blocked the request. " + - "Retry in a moment or check provider status." - ); + if (isCloudflareOrHtmlErrorPage(trimmed)) { + return "Couldn't reach the AI service. Try again in a moment. If it continues, open Settings → Logs in the Control UI or run `openclaw logs --follow`."; } const httpMatch = extractHttpStatusMatch(trimmed.match(HTTP_STATUS_PREFIX_RE)); @@ -281,26 +271,31 @@ const TRANSPORT_ERRORS = [ { code: /\beconnrefused\b/i, phrases: ["connection refused", "actively refused"], - message: "LLM request failed: connection refused by the provider endpoint.", + message: + "Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", }, { code: /\beconnreset\b|\beconnaborted\b|\benetreset\b|\bepipe\b/i, phrases: ["socket hang up", "connection reset", "connection aborted"], - message: "LLM request failed: network connection was interrupted.", + message: + "Lost the connection to the AI service. Check the conversation before trying again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", }, { code: /\benotfound\b|\beai_again\b/i, phrases: ["getaddrinfo", "no such host", "dns"], - message: "LLM request failed: DNS lookup for the provider endpoint failed.", + message: + "Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", }, { code: /\benetunreach\b|\behostunreach\b|\behostdown\b/i, phrases: ["network is unreachable", "host is unreachable"], - message: "LLM request failed: the provider endpoint is unreachable from this host.", + message: + "Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", }, { phrases: ["fetch failed", "connection error", "network request failed"], - message: "LLM request failed: network connection error.", + message: + "Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", }, ]; @@ -319,7 +314,7 @@ export function formatTransportErrorCopy(raw: string): string | undefined { } } if (raw.includes("网络错误") || raw.includes("网络异常") || raw.includes("连接错误")) { - return "LLM request failed: provider reported a network error."; + return "Couldn't connect to the AI service. Check your connection, then try again. For details, open Settings → Logs in the Control UI or run `openclaw logs --follow`."; } return undefined; } diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 4dd24e2a88f4..91a697847ca0 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -24,7 +24,7 @@ describe("resolveFinalAssistantText", () => { const rendered = resolveFinalAssistantText({ errorMessage: raw }); expect(rendered).toBe( - "The AI service is temporarily unavailable (HTTP 502). Please try again in a moment.", + "Couldn't reach the AI service. Try again in a moment. If it continues, open Settings → Logs in the Control UI or run `openclaw logs --follow`.", ); expect(rendered).not.toContain(""); }); diff --git a/ui/src/components/lazy-view-error.ts b/ui/src/components/lazy-view-error.ts index 719c63c60d63..ea59e844fcd9 100644 --- a/ui/src/components/lazy-view-error.ts +++ b/ui/src/components/lazy-view-error.ts @@ -140,7 +140,14 @@ export function renderPanelErrorState({
${title}
${subtitle}
${actions ? html`
${actions}
` : nothing} - ${detail ? html`${detail}` : nothing} + ${ + detail + ? html`
+ ${t("chat.details")} + ${detail} +
` + : nothing + } `; } diff --git a/ui/src/components/login-gate-feedback.ts b/ui/src/components/login-gate-feedback.ts index 87dae67d7cfa..622f62816902 100644 --- a/ui/src/components/login-gate-feedback.ts +++ b/ui/src/components/login-gate-feedback.ts @@ -166,6 +166,7 @@ export function resolveLoginFailureFeedback( tone: "pending", rawError, titleKey: "login.failure.profileUnavailable.title", + summaryKey: "login.failure.profileUnavailable.summary", stepKeys: [ "login.failure.profileUnavailable.stepRetry", "login.failure.profileUnavailable.stepAdmin", diff --git a/ui/src/components/login-gate.test.ts b/ui/src/components/login-gate.test.ts index 307bbb3ded1c..0f699b82d265 100644 --- a/ui/src/components/login-gate.test.ts +++ b/ui/src/components/login-gate.test.ts @@ -186,12 +186,14 @@ describe("login gate failure recovery", () => { expect(failure?.getAttribute("data-kind")).toBe("profile-unavailable"); expect(failure?.querySelector(".login-gate__failure-title")?.textContent).toBe( - "Profile verification unavailable", + "Couldn't verify your account", + ); + expect(failure?.querySelector(".login-gate__failure-summary")?.textContent).toBe( + "OpenClaw couldn't check your account right now. Please try again shortly.", ); - expect(failure?.querySelector(".login-gate__failure-summary")?.textContent).toBe(error); expect(failure?.querySelector(".login-gate__failure-steps")?.textContent).toContain("Retry"); expect(failure?.querySelector(".login-gate__failure-steps")?.textContent).toContain( - "Gateway administrator", + "person who manages OpenClaw", ); expect(failure?.querySelectorAll(".login-gate__failure-steps code")).toHaveLength(0); expect(failure?.querySelector(".login-gate__failure-raw")?.textContent).toBe(error); diff --git a/ui/src/e2e/chat-error-details.e2e.test.ts b/ui/src/e2e/chat-error-details.e2e.test.ts index bf7174337b42..02c75b619ed8 100644 --- a/ui/src/e2e/chat-error-details.e2e.test.ts +++ b/ui/src/e2e/chat-error-details.e2e.test.ts @@ -114,7 +114,9 @@ suite.define(() => { const alert = page.locator(".chat-error"); await alert.waitFor(); await captureDiagnosticProof(page, "session-change-collapsed"); - expect(await alert.locator("summary strong").textContent()).toBe(`Error: ${recovery}`); + expect(await alert.locator("summary strong").textContent()).toBe( + "Couldn't finish this reply. Check the conversation before trying again.", + ); expect(await page.locator(".chat-thread").textContent()).toContain(prompt); await alert.locator("summary").click(); const details = alert.getByLabel("Error details", { exact: true }); @@ -218,6 +220,12 @@ suite.define(() => { await captureDiagnosticProof(currentPage, `run-error-${source}-collapsed`); const summary = alert.locator("summary"); expect(await summary.count()).toBe(1); + expect(await summary.textContent()).toContain( + "Check the conversation before trying again.", + ); + expect(await summary.textContent()).not.toContain(skillPath); + expect(await summary.textContent()).not.toContain("INVALID_BUNDLE"); + expect(await alert.getByLabel("Error details", { exact: true }).isVisible()).toBe(false); await summary.focus(); await summary.press("Enter"); const details = alert.getByLabel("Error details", { exact: true }); diff --git a/ui/src/e2e/chat-flow.stream-reconciliation.e2e.test.ts b/ui/src/e2e/chat-flow.stream-reconciliation.e2e.test.ts index 0b1f3a6c1a30..f4ab931d38a5 100644 --- a/ui/src/e2e/chat-flow.stream-reconciliation.e2e.test.ts +++ b/ui/src/e2e/chat-flow.stream-reconciliation.e2e.test.ts @@ -498,7 +498,16 @@ suite.define(() => { await page.getByRole("button", { name: "Stop generating" }).waitFor({ state: "hidden" }); await page.locator(".chat-working-indicator").waitFor({ state: "hidden" }); if (terminal === "error") { - await page.locator(".chat-error strong", { hasText: errorMessage }).waitFor(); + const failure = page.locator(".chat-error").filter({ hasText: errorMessage }); + await failure + .locator("summary strong") + .getByText("Couldn't finish this reply. Check the conversation before trying again.") + .waitFor(); + await failure.locator("summary").click(); + await failure.getByLabel("Error details", { exact: true }).waitFor(); + expect( + await failure.getByLabel("Error details", { exact: true }).textContent(), + ).toContain(errorMessage); } await emitDelta(text, text.slice(partial.length)); await expect.poll(() => page.locator(".chat-bubble.streaming").count()).toBe(0); diff --git a/ui/src/e2e/chat-loading-deadlines.e2e.test.ts b/ui/src/e2e/chat-loading-deadlines.e2e.test.ts index 0d9874e851a2..994b0134adf3 100644 --- a/ui/src/e2e/chat-loading-deadlines.e2e.test.ts +++ b/ui/src/e2e/chat-loading-deadlines.e2e.test.ts @@ -201,10 +201,15 @@ suite.define(() => { expect(await gateway.getRequests("chat.send")).toHaveLength(0); await alert.waitFor(); await alert - .locator(".chat-error__content > strong") - .getByText(renderedDiagnostic) + .locator("summary strong") + .getByText("Couldn't finish this reply. Check the conversation before trying again.") .waitFor(); - expect(await alert.locator("details").count()).toBe(0); + expect(await alert.locator("details").getAttribute("open")).toBeNull(); + await alert.locator("summary").click(); + await alert.getByLabel("Error details", { exact: true }).waitFor(); + expect(await alert.getByLabel("Error details", { exact: true }).textContent()).toContain( + renderedDiagnostic, + ); await sendButton.click(); } const send = await gateway.waitForRequest("chat.send"); diff --git a/ui/src/e2e/chat-placement-recovery.e2e.test.ts b/ui/src/e2e/chat-placement-recovery.e2e.test.ts index 43cae4574533..437a61eaa5e7 100644 --- a/ui/src/e2e/chat-placement-recovery.e2e.test.ts +++ b/ui/src/e2e/chat-placement-recovery.e2e.test.ts @@ -47,9 +47,18 @@ suite.define(() => { try { await page.goto(controlUiSessionUrl(suite.server.baseUrl, session.key)); - await page - .getByText(`Runner failed: ${session.placement.recoveryError}`, { exact: true }) + const failure = page + .locator(".chat-error") + .filter({ hasText: session.placement.recoveryError }); + await failure + .locator("summary strong") + .getByText("Couldn't finish this reply. Check the conversation before trying again.") .waitFor(); + await failure.locator("summary").click(); + await failure.getByLabel("Error details", { exact: true }).waitFor(); + expect(await failure.getByLabel("Error details", { exact: true }).textContent()).toBe( + `Runner failed: ${session.placement.recoveryError}`, + ); const composer = page.getByRole("textbox", { name: "Chat composer" }); if (proofDir) { await page.screenshot({ path: path.join(proofDir, "failed-worker-composer.png") }); @@ -116,10 +125,18 @@ suite.define(() => { try { await page.goto(controlUiSessionUrl(suite.server.baseUrl, session.key)); - const error = page.getByText(`Runner failed: ${session.placement.recoveryError}`, { - exact: true, - }); - await error.waitFor(); + const error = page + .locator(".chat-error") + .filter({ hasText: session.placement.recoveryError }); + await error + .locator("summary strong") + .getByText("Couldn't finish this reply. Check the conversation before trying again.") + .waitFor(); + await error.locator("summary").click(); + await error.getByLabel("Error details", { exact: true }).waitFor(); + expect(await error.getByLabel("Error details", { exact: true }).textContent()).toBe( + `Runner failed: ${session.placement.recoveryError}`, + ); await page.getByRole("button", { name: "Restart session…", exact: true }).click(); const local = page.locator('[data-value="gateway"]'); await local.waitFor(); diff --git a/ui/src/e2e/chat-provider-review.e2e.test.ts b/ui/src/e2e/chat-provider-review.e2e.test.ts index 865c10297f2c..a282a2ffca93 100644 --- a/ui/src/e2e/chat-provider-review.e2e.test.ts +++ b/ui/src/e2e/chat-provider-review.e2e.test.ts @@ -56,13 +56,19 @@ suite.define(() => { errorMessage: "This request was blocked by our safety systems. Reason: Potentially unintended activity.", }); - await page - .getByText( + const failure = page.locator(".chat-error").filter({ + hasText: "This request was blocked by our safety systems. Reason: Potentially unintended activity.", - { exact: false }, - ) - .first() + }); + await failure + .locator("summary strong") + .getByText("Couldn't finish this reply. Check the conversation before trying again.") .waitFor(); + await failure.locator("summary").click(); + await failure.getByLabel("Error details", { exact: true }).waitFor(); + expect(await failure.getByLabel("Error details", { exact: true }).textContent()).toContain( + "This request was blocked by our safety systems. Reason: Potentially unintended activity.", + ); await captureUiProof(suite, page, "provider-review", "before-generic-error.png"); const providerReview = { diff --git a/ui/src/e2e/chat-run-lifecycle.e2e.test.ts b/ui/src/e2e/chat-run-lifecycle.e2e.test.ts index f2934f69f117..2cefba2a0c2d 100644 --- a/ui/src/e2e/chat-run-lifecycle.e2e.test.ts +++ b/ui/src/e2e/chat-run-lifecycle.e2e.test.ts @@ -91,10 +91,15 @@ suite.define(() => { const failedAlert = currentPage.getByRole("alert").filter({ hasText: renderedDiagnostic }); await failedAlert.waitFor(); await failedAlert - .locator(".chat-error__content > strong") - .getByText(renderedDiagnostic) + .locator("summary strong") + .getByText("Couldn't finish this reply. Check the conversation before trying again.") .waitFor(); - expect(await failedAlert.locator("details").count()).toBe(0); + expect(await failedAlert.locator("details").getAttribute("open")).toBeNull(); + await failedAlert.locator("summary").click(); + await failedAlert.getByLabel("Error details", { exact: true }).waitFor(); + expect(await failedAlert.getByLabel("Error details", { exact: true }).textContent()).toContain( + renderedDiagnostic, + ); expect(await currentPage.locator(".chat-group.assistant").count()).toBe(0); expect(await currentPage.getByRole("button", { name: "Stop generating" }).count()).toBe(0); diff --git a/ui/src/e2e/chat-stop-response-ownership.e2e.test.ts b/ui/src/e2e/chat-stop-response-ownership.e2e.test.ts index 7d00c2c8c945..fd8697a5139e 100644 --- a/ui/src/e2e/chat-stop-response-ownership.e2e.test.ts +++ b/ui/src/e2e/chat-stop-response-ownership.e2e.test.ts @@ -7,10 +7,11 @@ import { createControlUiE2eSuite } from "./control-ui-e2e-suite.test-support.ts" const suite = createControlUiE2eSuite({ name: "Control UI Stop response ownership" }); suite.define(() => { - it.each(["current", "replacement", "button", "command"] as const)( + it.each(["current", "replacement", "button", "command", "deferred save"] as const)( "keeps a delayed Stop response with its original run (%s)", async (scenario) => { - const saveWarning = scenario === "button" || scenario === "command"; + const saveWarning = + scenario === "button" || scenario === "command" || scenario === "deferred save"; const pageOptions = { viewport: { width: 1200, height: 800 }, ...(saveWarning @@ -83,8 +84,17 @@ suite.define(() => { await gateway.resolveDeferred("chat.abort", { aborted: true, runIds: [runId], - warning: notice, + ...(scenario === "deferred save" ? {} : { warning: notice }), }); + if (scenario === "deferred save") { + await gateway.emitGatewayEvent("chat", { + sessionKey, + runId, + state: "error", + errorMessage: notice, + stopReason: "aborted-partial-persistence-failed", + }); + } } else { await gateway.rejectDeferred("chat.abort", { code: "UNAVAILABLE", message: notice }); } diff --git a/ui/src/e2e/cloud-reconciliation-followup.e2e.test.ts b/ui/src/e2e/cloud-reconciliation-followup.e2e.test.ts index eaf61d9817c1..f379bd2bcf3a 100644 --- a/ui/src/e2e/cloud-reconciliation-followup.e2e.test.ts +++ b/ui/src/e2e/cloud-reconciliation-followup.e2e.test.ts @@ -254,9 +254,18 @@ suite.define(() => { sessionKey, }); await page.getByText("Runner failed", { exact: true }).waitFor(); - await page - .getByText("Workspace reconciliation failed: local worktree is locked.", { exact: false }) + const failure = page.locator(".chat-error").filter({ + hasText: "Workspace reconciliation failed: local worktree is locked.", + }); + await failure + .locator("summary strong") + .getByText("Couldn't finish this reply. Check the conversation before trying again.") .waitFor(); + await failure.locator("summary").click(); + await failure.getByLabel("Error details", { exact: true }).waitFor(); + expect(await failure.getByLabel("Error details", { exact: true }).textContent()).toContain( + "Workspace reconciliation failed: local worktree is locked.", + ); if (captureUiProofEnabled) { await page.screenshot({ fullPage: true, diff --git a/ui/src/e2e/lazy-route-error-layout.e2e.test.ts b/ui/src/e2e/lazy-route-error-layout.e2e.test.ts index 3e4ad5ce6aae..e0205df1509a 100644 --- a/ui/src/e2e/lazy-route-error-layout.e2e.test.ts +++ b/ui/src/e2e/lazy-route-error-layout.e2e.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { expect, it } from "vitest"; import { controlUiSessionPath, @@ -54,6 +55,14 @@ suite.define(() => { const error = page.locator(".lazy-view-error"); await error.getByText("Panel failed to load", { exact: true }).waitFor(); + expect(await error.getByText(gatewayError, { exact: true }).isVisible()).toBe(false); + if (process.env.OPENCLAW_CAPTURE_UI_PROOF === "1") { + await page.screenshot({ + path: path.join(suite.artifactDir, `panel-error-${viewport.name}.png`), + animations: "disabled", + }); + } + await error.locator("summary").click(); await error.getByText(gatewayError, { exact: true }).waitFor(); const layout = await error.evaluate((node) => { const content = [ diff --git a/ui/src/e2e/login-gate.e2e.test.ts b/ui/src/e2e/login-gate.e2e.test.ts index bd6ea7a743cf..84b9e2d8955a 100644 --- a/ui/src/e2e/login-gate.e2e.test.ts +++ b/ui/src/e2e/login-gate.e2e.test.ts @@ -378,7 +378,7 @@ suite.define(() => { retryable: true, }, expectedKind: "profile-unavailable", - expectedTitle: "Profile verification unavailable", + expectedTitle: "Couldn't verify your account", }, { name: "GitHub profile rate limit", @@ -390,7 +390,7 @@ suite.define(() => { retryable: true, }, expectedKind: "profile-unavailable", - expectedTitle: "Profile verification unavailable", + expectedTitle: "Couldn't verify your account", }, ])("renders $name guidance from the application gateway snapshot", async (fixture) => { const viewport = { height: 900, width: 1280 }; diff --git a/ui/src/i18n/locales/en-login.ts b/ui/src/i18n/locales/en-login.ts index 51e2b77c5520..573926ee61db 100644 --- a/ui/src/i18n/locales/en-login.ts +++ b/ui/src/i18n/locales/en-login.ts @@ -32,10 +32,11 @@ const enLogin = { retrying: "Retrying now…", }, profileUnavailable: { - title: "Profile verification unavailable", + title: "Couldn't verify your account", + summary: "OpenClaw couldn't check your account right now. Please try again shortly.", stepRetry: "Retry shortly.", stepAdmin: - "If this continues, ask a Gateway administrator to check the identity provider and GitHub API credential.", + "If this continues, ask the person who manages OpenClaw to check account access.", }, verifiedUserRequired: { title: "Verified identity required", diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 3d802f537506..254418b39988 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -3012,6 +3012,10 @@ export const en: TranslationMap & { }, cloudWorkerFailed: "Runner failed: {error}", errorDetails: "Error details", + errorReplySummary: "Couldn't finish this reply. Check the conversation before trying again.", + errorBusySummary: "OpenClaw is busy. Check status before trying again.", + errorSignInSummary: "Couldn't sign in to the AI service. Sign in again under Models.", + errorStartSummary: "Couldn't start this conversation. Open Settings → Logs for details.", checkStatus: "Check status", details: "Details", copyError: "Copy error", diff --git a/ui/src/lib/gateway-errors.ts b/ui/src/lib/gateway-errors.ts index 6eca0811ba98..2ada2911b72a 100644 --- a/ui/src/lib/gateway-errors.ts +++ b/ui/src/lib/gateway-errors.ts @@ -53,5 +53,5 @@ export function isArchiveAccessDeniedError(err: unknown): boolean { } export function formatMissingOperatorReadScopeMessage(feature: string): string { - return `This connection is missing operator.read, so ${feature} cannot be loaded yet.`; + return `You don't have permission to view ${feature}. Ask the person who manages OpenClaw for access.`; } diff --git a/ui/src/pages/chat/chat-gateway.test.ts b/ui/src/pages/chat/chat-gateway.test.ts index 89282f811a02..a717b3e6534d 100644 --- a/ui/src/pages/chat/chat-gateway.test.ts +++ b/ui/src/pages/chat/chat-gateway.test.ts @@ -2238,7 +2238,7 @@ describe("loadChatHistory retry handling", () => { expect(getChatHistoryLoadState(state)).toMatchObject({ phase: "failed", message: - "This connection is missing operator.read, so existing chat history cannot be loaded yet.", + "You don't have permission to view existing chat history. Ask the person who manages OpenClaw for access.", retryable: false, }); expect(state.lastError).toBeNull(); diff --git a/ui/src/pages/chat/chat-gateway.ts b/ui/src/pages/chat/chat-gateway.ts index 5701d572bf5c..5e897abdaeba 100644 --- a/ui/src/pages/chat/chat-gateway.ts +++ b/ui/src/pages/chat/chat-gateway.ts @@ -82,7 +82,11 @@ function resolveGatewayErrorText( ): string { const errorText = payload.errorMessage?.trim(); if (errorText) { - if (payload.state === "error" && payload.errorKind === "state_contention") { + if ( + payload.state === "error" && + (payload.errorKind === "state_contention" || + payload.stopReason === "aborted-partial-persistence-failed") + ) { return errorText; } const summary = @@ -126,9 +130,11 @@ export function handleChatGatewayEvent(state: ChatState, incoming?: ChatEventPay const errorKind = payload.state === "error" && payload.errorKind === "state_contention" ? "state_contention" - : payload.errorDetail?.providerRuntimeFailureKind === "auth_refresh" - ? "auth_refresh" - : undefined; + : payload.state === "error" && payload.stopReason === "aborted-partial-persistence-failed" + ? "stop" + : payload.errorDetail?.providerRuntimeFailureKind === "auth_refresh" + ? "auth_refresh" + : undefined; const normalizedFinalMessage = payload.state === "final" ? normalizeFinalAssistantMessage(payload.message) : null; const hadActiveRunBeforeEvent = state.chatRunId !== null; diff --git a/ui/src/pages/chat/chat-view-notices.test.ts b/ui/src/pages/chat/chat-view-notices.test.ts index 1b723020df66..07f6a6b1e5d6 100644 --- a/ui/src/pages/chat/chat-view-notices.test.ts +++ b/ui/src/pages/chat/chat-view-notices.test.ts @@ -168,7 +168,9 @@ it.each([true, false])( expect(notice?.getAttribute("role")).toBe("status"); const details = notice?.querySelector("details"); expect(details?.open).toBe(false); - expect(details?.querySelector("strong")?.textContent).toBe(diagnostic.split("\n")[0]); + expect(details?.querySelector("strong")?.textContent).toBe( + "OpenClaw is busy. Check status before trying again.", + ); expect(details?.querySelector("pre")?.textContent).toBe(diagnostic); expect(notice?.querySelector("img")).toBeNull(); const check = notice?.querySelector(".chat-error__refresh"); diff --git a/ui/src/pages/chat/chat-view-notices.ts b/ui/src/pages/chat/chat-view-notices.ts index bc9a9e9ab984..284d778c2874 100644 --- a/ui/src/pages/chat/chat-view-notices.ts +++ b/ui/src/pages/chat/chat-view-notices.ts @@ -118,15 +118,15 @@ function renderErrorNotice( action: TemplateResult | typeof nothing = nothing, displayError = formatWebUiIconErrorText(error), tone: "danger" | "warn" = "danger", + summary?: string, ) { const lines = displayError .trim() .split(/\r?\n/u) .map((line) => line.replace(/\s+/gu, " ").trim()); - const [firstLine = ""] = lines; - const summary = clampText(firstLine); - const hasDetails = lines.some((line) => line !== "" && line !== summary); - // Keep the bounded summary readable without opening the technical details. + // Local action errors already contain recovery instructions; keep those visible. + const title = summary ?? clampText(lines[0] ?? ""); + const hasDetails = lines.some((line) => line !== "" && line !== title); return html`
- ${summary} + ${title} ${t("chat.details")} ${renderCopyButton(error, t("chat.copyError"))} @@ -148,7 +148,7 @@ function renderErrorNotice( ${displayError} ` : html`${summary}${renderCopyButton(error, t("chat.copyError"))}${title}${renderCopyButton(error, t("chat.copyError"))}` } ${action} @@ -195,7 +195,7 @@ export function renderChatComposerNotices(props: ChatComposerNoticesProps) { return html` ${props.providerReviewNotice ?? nothing} ${renderProviderPolicyNotice(props.providerPolicyNotice)} - ${props.runError ? renderErrorNotice(props.runError.summary, refresh, undefined, contention ? "warn" : "danger") : nothing} + ${props.runError ? renderErrorNotice(props.runError.summary, refresh, undefined, contention ? "warn" : "danger", props.runError.kind === "stop" ? undefined : t(contention ? "chat.errorBusySummary" : props.runError.kind === "auth_refresh" ? "chat.errorSignInSummary" : "chat.errorReplySummary")) : nothing} ${renderWorkspaceConflictNotice({ conflict: props.workspaceConflict ?? undefined, onDismiss: props.onDismissWorkspaceConflict, @@ -271,5 +271,15 @@ function renderPlacementStartupError( ${t(checking ? "chat.queue.checkDelivery" : "common.retry")} ` : nothing; - return renderErrorNotice(error, action, displayError); + return renderErrorNotice( + error, + action, + displayError, + "danger", + checking + ? t("chat.queue.checkDeliveryHelp") + : status.discardAndReload + ? displayError + : t("chat.errorStartSummary"), + ); } diff --git a/ui/src/pages/chat/run-lifecycle.ts b/ui/src/pages/chat/run-lifecycle.ts index 53d4b7ef7341..e3211ad001bf 100644 --- a/ui/src/pages/chat/run-lifecycle.ts +++ b/ui/src/pages/chat/run-lifecycle.ts @@ -50,7 +50,7 @@ export type ChatHistoryRunObservation = { }; export type ChatRunError = { - kind?: "auth_refresh" | "state_contention"; + kind?: "auth_refresh" | "state_contention" | "stop"; summary: string; /** Display ownership only; the session reducer retains each run's diagnostic. */ runId?: string; @@ -269,11 +269,11 @@ async function settleChatAbortResponse( } else if (state.chatRunId) { setChatError(state, message); } else { - setChatRunError(state, message, intent.runId ?? undefined); + setChatRunError(state, message, intent.runId ?? undefined, "stop"); } state.requestUpdate?.(); } else if (result.warning) { - setChatRunError(state, result.warning, intent.runId ?? undefined); + setChatRunError(state, result.warning, intent.runId ?? undefined, "stop"); state.requestUpdate?.(); } else if (result.noActiveRun && state.connected) { // Only the refreshed owner may retire a run that is still finalizing. diff --git a/ui/src/pages/usage/usage-page-details.test.ts b/ui/src/pages/usage/usage-page-details.test.ts index 966883a094de..c20c93ffae3b 100644 --- a/ui/src/pages/usage/usage-page-details.test.ts +++ b/ui/src/pages/usage/usage-page-details.test.ts @@ -771,14 +771,16 @@ describe("UsagePage detail requests", () => { expect(page.details.timeSeries.data).toBeNull(); expect(page.details.timeSeries.status).toEqual({ - error: "This connection is missing operator.read, so usage details cannot be loaded yet.", + error: + "You don't have permission to view usage details. Ask the person who manages OpenClaw for access.", hasLoaded: false, stale: false, awaitingGateway: false, }); expect(page.details.sessionLogs.data).toBeNull(); expect(page.details.sessionLogs.status).toEqual({ - error: "This connection is missing operator.read, so usage details cannot be loaded yet.", + error: + "You don't have permission to view usage details. Ask the person who manages OpenClaw for access.", hasLoaded: false, stale: false, awaitingGateway: false, diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index bd7142cbee62..db953251aae9 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -2328,7 +2328,13 @@ select.input { margin-top: 8px; } +.lazy-view-error__details { + max-width: 100%; + color: var(--muted); +} + .lazy-view-error__detail { + display: block; margin-top: 8px; max-width: 100%; color: var(--muted);