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<anyhow::Error> 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
This commit is contained in:
Katie Geer authored and GitHub committed 2026-04-10 17:17:28 -04:00
1 parent c14a48042a
commit 1f317acbf7
4 files changed
+346 -27

No files matched your search

+15 -5
View File
@@ -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);
+22 -3
View File
@@ -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<Self>,
) -> Result<mpsc::UnboundedReceiver<Result<ThreadEvent>>> {
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()
+84 -19
View File
@@ -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<SharedString>,
@@ -121,12 +149,57 @@ pub(crate) enum ThreadError {
impl From<anyhow::Error> for ThreadError {
fn from(error: anyhow::Error) -> Self {
if error.is::<language_model::PaymentRequiredError>() {
if error.is::<MaxOutputTokensError>() {
Self::MaxOutputTokens
} else if error.is::<NoModelConfiguredError>() {
Self::NoModelSelected
} else if error.is::<language_model::PaymentRequiredError>() {
Self::PaymentRequired
} else if let Some(acp_error) = error.downcast_ref::<acp::Error>()
&& acp_error.code == acp::ErrorCode::AuthRequired
{
Self::AuthenticationRequired(acp_error.message.clone().into())
} else if let Some(lm_error) = error.downcast_ref::<LanguageModelCompletionError>() {
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()
);
});
}
@@ -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<Self>,
) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> impl IntoElement {
Button::new("upgrade", "Upgrade")
.label_size(LabelSize::Small)