From 4e0022cbc0596678837ce10d66f67404a592e68c Mon Sep 17 00:00:00 2001 From: Katie Geer Date: Fri, 10 Apr 2026 14:17:28 -0700 Subject: [PATCH] agent_ui: Replace raw error messages with user-friendly copy in the agent panel (#53099) Replaces raw provider error strings in the agent panel with specific, user-friendly error callouts. Each error now has a clear title, actionable copy, and appropriate buttons instead of the generic "An Error Happened" fallback. Error variants added: Variant | Title | Body | Trigger | |---|---|---|---| | `RateLimitExceeded` | Rate Limit Reached | {Provider}'s rate limit was reached. Zed will retry automatically. You can also wait a moment and try again. | Provider rate limit exhausted after retries | | `ServerOverloaded` | Provider Unavailable | {Provider}'s servers are temporarily unavailable. Zed will retry automatically. If the problem persists, check the provider's status page. | Provider server overloaded or internal server error | | `PromptTooLarge` | Context Too Large | This conversation is too long for the model's context window. Start a new thread or remove some attached files to continue. | Conversation exceeds model's context window | | `NoApiKey` | API Key Missing | No API key is configured for {Provider}. Add your key via the Agent Panel settings to continue. | No API key configured for a direct provider | | `StreamError` | Connection Interrupted | The connection to {Provider}'s API was interrupted. Zed will retry automatically. If the problem persists, check your network connection. | Stream dropped or I/O error during generation | | `InvalidApiKey` | Invalid API Key | The API key for {Provider} is invalid or has expired. Update your key via the Agent Panel settings to continue. | API key present but invalid or expired | | `PermissionDenied` | Permission Denied | {Provider}'s API rejected the request due to insufficient permissions. Check that your API key has access to this model. | API key lacks access to the requested model | | `RequestFailed` | Request Failed | The request could not be completed after multiple attempts. Try again in a moment. | Upstream provider unreachable after retries | | `MaxOutputTokens` | Output Limit Reached | The model stopped because it reached its maximum output length. You can ask it to continue where it left off. | Model hit its maximum output token budget | | `NoModelSelected` | No Model Selected | Select a model from the model picker below to get started. | No model configured when a message is sent | | `ApiError` | API Error | {Provider}'s API returned an unexpected error. If the problem persists, try switching models or restarting Zed. ## Approach - Added typed errors (`NoModelConfiguredError`, `MaxOutputTokensError`) where previously raw strings were used, so they can be reliably downcast - Extended `From for ThreadError` to downcast `LanguageModelCompletionError` variants before falling through to the generic `Other` case - Each variant has a dedicated `render_*` function with appropriate buttons (retry icon, New Thread, or none) - Telemetry events updated with specific `kind` labels for each new variant Release Notes: - Improved error messages in the agent panel to show specific, actionable copy instead of raw provider error strings Self-Review Checklist: - [ x] I've reviewed my own diff for quality, security, and reliability - [n/a] Unsafe blocks (if any) have justifying comments - [ x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [n/a ] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: Improved error messages in the agent panel to show specific, actionable copy instead of raw provider error strings --- crates/acp_thread/src/acp_thread.rs | 20 +- crates/agent/src/thread.rs | 25 +- crates/agent_ui/src/conversation_view.rs | 103 ++++++-- .../src/conversation_view/thread_view.rs | 225 ++++++++++++++++++ 4 files changed, 346 insertions(+), 27 deletions(-) diff --git a/crates/acp_thread/src/acp_thread.rs b/crates/acp_thread/src/acp_thread.rs index 7fb48c132f971fd3449d116b22bd4437c1ebf611..2f3973fbcc94e2d06bdc08a91d61c53809a951ed 100644 --- a/crates/acp_thread/src/acp_thread.rs +++ b/crates/acp_thread/src/acp_thread.rs @@ -36,6 +36,18 @@ use util::path_list::PathList; use util::{ResultExt, get_default_system_shell_preferring_bash, paths::PathStyle}; use uuid::Uuid; +/// Returned when the model stops because it exhausted its output token budget. +#[derive(Debug)] +pub struct MaxOutputTokensError; + +impl std::fmt::Display for MaxOutputTokensError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "output token limit reached") + } +} + +impl std::error::Error for MaxOutputTokensError {} + /// Key used in ACP ToolCall meta to store the tool's programmatic name. /// This is a workaround since ACP's ToolCall doesn't have a dedicated name field. pub const TOOL_NAME_META_KEY: &str = "tool_name"; @@ -2272,17 +2284,15 @@ impl AcpThread { .is_some_and(|max| u.output_tokens >= max) }); - let message = if exceeded_max_output_tokens { + if exceeded_max_output_tokens { log::error!( "Max output tokens reached. Usage: {:?}", this.token_usage ); - "Maximum output tokens reached" } else { log::error!("Max tokens reached. Usage: {:?}", this.token_usage); - "Maximum tokens reached" - }; - return Err(anyhow!(message)); + } + return Err(anyhow!(MaxOutputTokensError)); } let canceled = matches!(r.stop_reason, acp::StopReason::Cancelled); diff --git a/crates/agent/src/thread.rs b/crates/agent/src/thread.rs index e3a075ada62b6108c489779d5261c1c89afec8aa..bd9ef285169bf98ce196990156a269e830ccd738 100644 --- a/crates/agent/src/thread.rs +++ b/crates/agent/src/thread.rs @@ -64,6 +64,18 @@ const TOOL_CANCELED_MESSAGE: &str = "Tool canceled by user"; pub const MAX_TOOL_NAME_LENGTH: usize = 64; pub const MAX_SUBAGENT_DEPTH: u8 = 1; +/// Returned when a turn is attempted but no language model has been selected. +#[derive(Debug)] +pub struct NoModelConfiguredError; + +impl std::fmt::Display for NoModelConfiguredError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "no language model configured") + } +} + +impl std::error::Error for NoModelConfiguredError {} + /// Context passed to a subagent thread for lifecycle management #[derive(Clone, Debug, Serialize, Deserialize)] pub struct SubagentContext { @@ -1772,7 +1784,9 @@ impl Thread { &mut self, cx: &mut Context, ) -> Result>> { - let model = self.model().context("No language model configured")?; + let model = self + .model() + .ok_or_else(|| anyhow!(NoModelConfiguredError))?; log::info!("Thread::send called with model: {}", model.name().0); self.advance_prompt_id(); @@ -1896,7 +1910,10 @@ impl Thread { // mid-turn changes (e.g. the user switches model, toggles tools, // or changes profile) take effect between tool-call rounds. let (model, request) = this.update(cx, |this, cx| { - let model = this.model.clone().context("No language model configured")?; + let model = this + .model + .clone() + .ok_or_else(|| anyhow!(NoModelConfiguredError))?; this.refresh_turn_tools(cx); let request = this.build_completion_request(intent, cx)?; anyhow::Ok((model, request)) @@ -2742,7 +2759,9 @@ impl Thread { completion_intent }; - let model = self.model().context("No language model configured")?; + let model = self + .model() + .ok_or_else(|| anyhow!(NoModelConfiguredError))?; let tools = if let Some(turn) = self.running_turn.as_ref() { turn.tools .iter() diff --git a/crates/agent_ui/src/conversation_view.rs b/crates/agent_ui/src/conversation_view.rs index d38e1344701fc8681b0feaf2fa7843611750532d..528e38333144524c4a4dffa63a7a8b107c829e41 100644 --- a/crates/agent_ui/src/conversation_view.rs +++ b/crates/agent_ui/src/conversation_view.rs @@ -1,12 +1,15 @@ use acp_thread::{ AcpThread, AcpThreadEvent, AgentSessionInfo, AgentThreadEntry, AssistantMessage, - AssistantMessageChunk, AuthRequired, LoadError, MentionUri, PermissionOptionChoice, - PermissionOptions, PermissionPattern, RetryStatus, SelectedPermissionOutcome, ThreadStatus, - ToolCall, ToolCallContent, ToolCallStatus, UserMessageId, + AssistantMessageChunk, AuthRequired, LoadError, MaxOutputTokensError, MentionUri, + PermissionOptionChoice, PermissionOptions, PermissionPattern, RetryStatus, + SelectedPermissionOutcome, ThreadStatus, ToolCall, ToolCallContent, ToolCallStatus, + UserMessageId, }; use acp_thread::{AgentConnection, Plan}; use action_log::{ActionLog, ActionLogTelemetry, DiffStats}; -use agent::{NativeAgentServer, NativeAgentSessionList, SharedThread, ThreadStore}; +use agent::{ + NativeAgentServer, NativeAgentSessionList, NoModelConfiguredError, SharedThread, ThreadStore, +}; use agent_client_protocol as acp; #[cfg(test)] use agent_servers::AgentServerDelegate; @@ -34,7 +37,7 @@ use gpui::{ list, point, pulsating_between, }; use language::Buffer; -use language_model::LanguageModelRegistry; +use language_model::{LanguageModelCompletionError, LanguageModelRegistry}; use markdown::{Markdown, MarkdownElement, MarkdownFont, MarkdownStyle}; use parking_lot::RwLock; use project::{AgentId, AgentServerStore, Project, ProjectEntryId}; @@ -113,6 +116,31 @@ pub(crate) enum ThreadError { PaymentRequired, Refusal, AuthenticationRequired(SharedString), + RateLimitExceeded { + provider: SharedString, + }, + ServerOverloaded { + provider: SharedString, + }, + PromptTooLarge, + NoApiKey { + provider: SharedString, + }, + StreamError { + provider: SharedString, + }, + InvalidApiKey { + provider: SharedString, + }, + PermissionDenied { + provider: SharedString, + }, + RequestFailed, + MaxOutputTokens, + NoModelSelected, + ApiError { + provider: SharedString, + }, Other { message: SharedString, acp_error_code: Option, @@ -121,12 +149,57 @@ pub(crate) enum ThreadError { impl From for ThreadError { fn from(error: anyhow::Error) -> Self { - if error.is::() { + if error.is::() { + Self::MaxOutputTokens + } else if error.is::() { + Self::NoModelSelected + } else if error.is::() { Self::PaymentRequired } else if let Some(acp_error) = error.downcast_ref::() && acp_error.code == acp::ErrorCode::AuthRequired { Self::AuthenticationRequired(acp_error.message.clone().into()) + } else if let Some(lm_error) = error.downcast_ref::() { + use LanguageModelCompletionError::*; + match lm_error { + RateLimitExceeded { provider, .. } => Self::RateLimitExceeded { + provider: provider.to_string().into(), + }, + ServerOverloaded { provider, .. } | ApiInternalServerError { provider, .. } => { + Self::ServerOverloaded { + provider: provider.to_string().into(), + } + } + PromptTooLarge { .. } => Self::PromptTooLarge, + NoApiKey { provider } => Self::NoApiKey { + provider: provider.to_string().into(), + }, + StreamEndedUnexpectedly { provider } + | ApiReadResponseError { provider, .. } + | DeserializeResponse { provider, .. } + | HttpSend { provider, .. } => Self::StreamError { + provider: provider.to_string().into(), + }, + AuthenticationError { provider, .. } => Self::InvalidApiKey { + provider: provider.to_string().into(), + }, + PermissionError { provider, .. } => Self::PermissionDenied { + provider: provider.to_string().into(), + }, + UpstreamProviderError { .. } => Self::RequestFailed, + BadRequestFormat { provider, .. } + | HttpResponseError { provider, .. } + | ApiEndpointNotFound { provider } => Self::ApiError { + provider: provider.to_string().into(), + }, + _ => { + let message: SharedString = format!("{:#}", error).into(); + Self::Other { + message, + acp_error_code: None, + } + } + } } else { let message: SharedString = format!("{:#}", error).into(); @@ -6625,19 +6698,11 @@ pub(crate) mod tests { conversation_view.read_with(cx, |conversation_view, cx| { let state = conversation_view.active_thread().unwrap(); let error = &state.read(cx).thread_error; - match error { - Some(ThreadError::Other { message, .. }) => { - assert!( - message.contains("Maximum tokens reached"), - "Expected 'Maximum tokens reached' error, got: {}", - message - ); - } - other => panic!( - "Expected ThreadError::Other with 'Maximum tokens reached', got: {:?}", - other.is_some() - ), - } + assert!( + matches!(error, Some(ThreadError::MaxOutputTokens)), + "Expected ThreadError::MaxOutputTokens, got: {:?}", + error.is_some() + ); }); } diff --git a/crates/agent_ui/src/conversation_view/thread_view.rs b/crates/agent_ui/src/conversation_view/thread_view.rs index 32fe52480e2c347cc482b2296a107ee8731fb672..6fa5f5999c84c5190163be5904828cbbd3ebf053 100644 --- a/crates/agent_ui/src/conversation_view/thread_view.rs +++ b/crates/agent_ui/src/conversation_view/thread_view.rs @@ -1259,6 +1259,62 @@ impl ThreadView { ThreadError::AuthenticationRequired(message) => { ("authentication_required", None, message.clone()) } + ThreadError::RateLimitExceeded { provider } => ( + "rate_limit_exceeded", + None, + format!("{provider}'s rate limit was reached.").into(), + ), + ThreadError::ServerOverloaded { provider } => ( + "server_overloaded", + None, + format!("{provider}'s servers are temporarily unavailable.").into(), + ), + ThreadError::PromptTooLarge => ( + "prompt_too_large", + None, + "Context too large for the model's context window.".into(), + ), + ThreadError::NoApiKey { provider } => ( + "no_api_key", + None, + format!("No API key configured for {provider}.").into(), + ), + ThreadError::StreamError { provider } => ( + "stream_error", + None, + format!("Connection to {provider}'s API was interrupted.").into(), + ), + ThreadError::InvalidApiKey { provider } => ( + "invalid_api_key", + None, + format!("Invalid or expired API key for {provider}.").into(), + ), + ThreadError::PermissionDenied { provider } => ( + "permission_denied", + None, + format!( + "{provider}'s API rejected the request due to insufficient permissions." + ) + .into(), + ), + ThreadError::RequestFailed => ( + "request_failed", + None, + "Request could not be completed after multiple attempts.".into(), + ), + ThreadError::MaxOutputTokens => ( + "max_output_tokens", + None, + "Model reached its maximum output length.".into(), + ), + ThreadError::NoModelSelected => { + ("no_model_selected", None, "No model selected.".into()) + } + ThreadError::ApiError { provider } => ( + "api_error", + None, + format!("{provider}'s API returned an unexpected error.").into(), + ), ThreadError::Other { acp_error_code, message, @@ -8088,6 +8144,109 @@ impl ThreadView { self.render_authentication_required_error(error.clone(), cx) } ThreadError::PaymentRequired => self.render_payment_required_error(cx), + ThreadError::RateLimitExceeded { provider } => self.render_error_callout( + "Rate Limit Reached", + format!( + "{provider}'s rate limit was reached. Zed will retry automatically. \ + You can also wait a moment and try again." + ) + .into(), + true, + true, + cx, + ), + ThreadError::ServerOverloaded { provider } => self.render_error_callout( + "Provider Unavailable", + format!( + "{provider}'s servers are temporarily unavailable. Zed will retry \ + automatically. If the problem persists, check the provider's status page." + ) + .into(), + true, + true, + cx, + ), + ThreadError::PromptTooLarge => self.render_prompt_too_large_error(cx), + ThreadError::NoApiKey { provider } => self.render_error_callout( + "API Key Missing", + format!( + "No API key is configured for {provider}. \ + Add your key via the Agent Panel settings to continue." + ) + .into(), + false, + true, + cx, + ), + ThreadError::StreamError { provider } => self.render_error_callout( + "Connection Interrupted", + format!( + "The connection to {provider}'s API was interrupted. Zed will retry \ + automatically. If the problem persists, check your network connection." + ) + .into(), + true, + true, + cx, + ), + ThreadError::InvalidApiKey { provider } => self.render_error_callout( + "Invalid API Key", + format!( + "The API key for {provider} is invalid or has expired. \ + Update your key via the Agent Panel settings to continue." + ) + .into(), + false, + false, + cx, + ), + ThreadError::PermissionDenied { provider } => self.render_error_callout( + "Permission Denied", + format!( + "{provider}'s API rejected the request due to insufficient permissions. \ + Check that your API key has access to this model." + ) + .into(), + false, + false, + cx, + ), + ThreadError::RequestFailed => self.render_error_callout( + "Request Failed", + "The request could not be completed after multiple attempts. \ + Try again in a moment." + .into(), + true, + false, + cx, + ), + ThreadError::MaxOutputTokens => self.render_error_callout( + "Output Limit Reached", + "The model stopped because it reached its maximum output length. \ + You can ask it to continue where it left off." + .into(), + false, + false, + cx, + ), + ThreadError::NoModelSelected => self.render_error_callout( + "No Model Selected", + "Select a model from the model picker below to get started.".into(), + false, + false, + cx, + ), + ThreadError::ApiError { provider } => self.render_error_callout( + "API Error", + format!( + "{provider}'s API returned an unexpected error. \ + If the problem persists, try switching models or restarting Zed." + ) + .into(), + true, + true, + cx, + ), }; Some(div().child(content)) @@ -8148,6 +8307,72 @@ impl ThreadView { .dismiss_action(self.dismiss_error_button(cx)) } + fn render_error_callout( + &self, + title: &'static str, + message: SharedString, + show_retry: bool, + show_copy: bool, + cx: &mut Context, + ) -> Callout { + let can_resume = show_retry && self.thread.read(cx).can_retry(cx); + let show_actions = can_resume || show_copy; + + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircle) + .title(title) + .description(message.clone()) + .when(show_actions, |callout| { + callout.actions_slot( + h_flex() + .gap_0p5() + .when(can_resume, |this| this.child(self.retry_button(cx))) + .when(show_copy, |this| { + this.child(self.create_copy_button(message.clone())) + }), + ) + }) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn render_prompt_too_large_error(&self, cx: &mut Context) -> Callout { + const MESSAGE: &str = "This conversation is too long for the model's context window. \ + Start a new thread or remove some attached files to continue."; + + Callout::new() + .severity(Severity::Error) + .icon(IconName::XCircle) + .title("Context Too Large") + .description(MESSAGE) + .actions_slot( + h_flex() + .gap_0p5() + .child(self.new_thread_button(cx)) + .child(self.create_copy_button(MESSAGE)), + ) + .dismiss_action(self.dismiss_error_button(cx)) + } + + fn retry_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("retry", "Retry") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, _, cx| { + this.retry_generation(cx); + })) + } + + fn new_thread_button(&self, cx: &mut Context) -> impl IntoElement { + Button::new("new_thread", "New Thread") + .label_size(LabelSize::Small) + .style(ButtonStyle::Filled) + .on_click(cx.listener(|this, _, window, cx| { + this.clear_thread_error(cx); + window.dispatch_action(NewThread.boxed_clone(), cx); + })) + } + fn upgrade_button(&self, cx: &mut Context) -> impl IntoElement { Button::new("upgrade", "Upgrade") .label_size(LabelSize::Small)