diff --git a/app/src/ai/agent_management/agent_management_model.rs b/app/src/ai/agent_management/agent_management_model.rs index 4d17d972675..f94da9a3993 100644 --- a/app/src/ai/agent_management/agent_management_model.rs +++ b/app/src/ai/agent_management/agent_management_model.rs @@ -177,6 +177,35 @@ impl AgentNotificationsModel { ctx, ); } + CLIAgentSessionStatus::Failed { + error_type, + message, + } => { + let title = session_context + .display_title() + .unwrap_or_else(|| format!("{} failed", agent.display_name())); + let body = match (message.as_deref(), error_type.as_deref()) { + (Some(msg), Some(kind)) => format!("{kind}: {msg}"), + (Some(msg), None) => msg.to_owned(), + (None, Some(kind)) => kind.to_owned(), + (None, None) => "The agent encountered an error.".to_owned(), + }; + let metadata = TerminalViewMetadata::lookup(*terminal_view_id, ctx); + self.add_notification( + title, + body, + NotificationCategory::Error, + NotificationSourceAgent::CLI { + agent: *agent, + is_ambient: metadata.is_ambient, + }, + NotificationOrigin::CLISession(*terminal_view_id), + *terminal_view_id, + vec![], + metadata.branch, + ctx, + ); + } CLIAgentSessionStatus::Blocked { message } => { let title = session_context .display_title() diff --git a/app/src/ai/agent_sdk/driver.rs b/app/src/ai/agent_sdk/driver.rs index 472b5f57728..abc392f0744 100644 --- a/app/src/ai/agent_sdk/driver.rs +++ b/app/src/ai/agent_sdk/driver.rs @@ -2853,7 +2853,8 @@ impl AgentDriver { ) { log::info!( "Ignoring runtime failure for {harness_name}: \ - session already marked Success (pattern={}, excerpt={})", + session already marked Success or Failed via plugin \ + (pattern={}, excerpt={})", error.pattern, error.excerpt, ); @@ -3435,7 +3436,9 @@ impl AgentDriver { // Drive idle-on-complete timer for the harness exit signal. match status { - CLIAgentSessionStatus::Success | CLIAgentSessionStatus::Blocked { .. } => { + CLIAgentSessionStatus::Success + | CLIAgentSessionStatus::Failed { .. } + | CLIAgentSessionStatus::Blocked { .. } => { if let Some(idle_timeout) = me.idle_on_complete { log::info!( "Ambient agent CLI lifecycle: event=idle_timeout_scheduled task_id={:?} terminal_view_id={terminal_view_id:?} timeout={idle_timeout:?}", diff --git a/app/src/ai/agent_sdk/driver/harness/claude_code.rs b/app/src/ai/agent_sdk/driver/harness/claude_code.rs index 3e421efc722..c7ea08bc7cf 100644 --- a/app/src/ai/agent_sdk/driver/harness/claude_code.rs +++ b/app/src/ai/agent_sdk/driver/harness/claude_code.rs @@ -416,6 +416,7 @@ impl ClaudeHarnessRunner { cli_agent_session_status(&self.terminal_driver, foreground).await, Some(crate::terminal::cli_agent_sessions::CLIAgentSessionStatus::Blocked { .. }) | Some(crate::terminal::cli_agent_sessions::CLIAgentSessionStatus::InProgress) + | Some(crate::terminal::cli_agent_sessions::CLIAgentSessionStatus::Failed { .. }) ) } diff --git a/app/src/ai/agent_sdk/driver/harness/codex.rs b/app/src/ai/agent_sdk/driver/harness/codex.rs index 98004bef72d..136c3b20f0d 100644 --- a/app/src/ai/agent_sdk/driver/harness/codex.rs +++ b/app/src/ai/agent_sdk/driver/harness/codex.rs @@ -87,6 +87,9 @@ impl ThirdPartyHarness for CodexHarness { // OAuth refresh failures — all five Codex variants share this // substring (see upstream session/token messages). "could not be refreshed", + // Generically check for invalid request errors. + // Keep this last so more specific patterns can be matched first. + "\"type\": \"invalid_request_error\"", ] } diff --git a/app/src/ai/agent_sdk/driver/harness_output_monitor.rs b/app/src/ai/agent_sdk/driver/harness_output_monitor.rs index 0d92a150a88..3afa0778741 100644 --- a/app/src/ai/agent_sdk/driver/harness_output_monitor.rs +++ b/app/src/ai/agent_sdk/driver/harness_output_monitor.rs @@ -197,7 +197,12 @@ pub(crate) async fn watch_block_for_errors( } pub(crate) fn should_suppress_runtime_failure(status: Option<&CLIAgentSessionStatus>) -> bool { - matches!(status, Some(CLIAgentSessionStatus::Success)) + // On CLIAgentSessionStatus::Failed, we directly update the task status, + // so we don't need to do runtime pattern matching to find the failure. + matches!( + status, + Some(CLIAgentSessionStatus::Success) | Some(CLIAgentSessionStatus::Failed { .. }) + ) } /// Cap excerpt length so we don't blow up status messages or logs with diff --git a/app/src/ai/blocklist/local_agent_task_sync_model.rs b/app/src/ai/blocklist/local_agent_task_sync_model.rs index 5a4a4adefa6..b139bfae038 100644 --- a/app/src/ai/blocklist/local_agent_task_sync_model.rs +++ b/app/src/ai/blocklist/local_agent_task_sync_model.rs @@ -548,6 +548,22 @@ fn map_cli_session_status( match status { CLIAgentSessionStatus::InProgress => (AgentTaskState::InProgress, None), CLIAgentSessionStatus::Success => (AgentTaskState::Succeeded, None), + CLIAgentSessionStatus::Failed { + error_type, + message, + } => { + // User-actionable errors (bad credentials, org restrictions, billing) map to + // FAILED. Everything else (rate limits, server errors, model errors, etc.) + // maps to ERROR since they are typically Anthropic's or Warp's fault. + // The list of error types on Claude Code comes from https://code.claude.com/docs/en/hooks#stopfailure-input + let task_state = match error_type.as_deref() { + Some("authentication_failed" | "oauth_org_not_allowed" | "billing_error") => { + AgentTaskState::Failed + } + _ => AgentTaskState::Error, + }; + (task_state, message.as_ref().map(TaskStatusUpdate::message)) + } CLIAgentSessionStatus::Blocked { message } => ( AgentTaskState::Blocked, message.as_ref().map(TaskStatusUpdate::message), diff --git a/app/src/terminal/cli_agent_sessions/event/mod.rs b/app/src/terminal/cli_agent_sessions/event/mod.rs index 18e96dee42b..142c44c2288 100644 --- a/app/src/terminal/cli_agent_sessions/event/mod.rs +++ b/app/src/terminal/cli_agent_sessions/event/mod.rs @@ -19,6 +19,7 @@ pub enum CLIAgentEventType { PromptSubmit, ToolComplete, Stop, + StopFailure, PermissionRequest, PermissionReplied, QuestionAsked, @@ -46,6 +47,9 @@ pub struct CLIAgentEventPayload { pub tool_name: Option, pub tool_input_preview: Option, pub plugin_version: Option, + /// On Claude Code, this comes from the `StopFailure` hook (e.g. `"rate_limit"`). + /// Not implemented for Codex. + pub error_type: Option, } /// A parsed event from a CLI agent plugin. diff --git a/app/src/terminal/cli_agent_sessions/event/v1.rs b/app/src/terminal/cli_agent_sessions/event/v1.rs index ae7077b4918..c699cfca814 100644 --- a/app/src/terminal/cli_agent_sessions/event/v1.rs +++ b/app/src/terminal/cli_agent_sessions/event/v1.rs @@ -18,6 +18,7 @@ pub(super) fn parse(body: &str) -> Option { "prompt_submit" => CLIAgentEventType::PromptSubmit, "tool_complete" => CLIAgentEventType::ToolComplete, "stop" => CLIAgentEventType::Stop, + "stop_failure" => CLIAgentEventType::StopFailure, "permission_request" => CLIAgentEventType::PermissionRequest, "permission_replied" => CLIAgentEventType::PermissionReplied, "question_asked" => CLIAgentEventType::QuestionAsked, @@ -53,6 +54,7 @@ pub(super) fn parse(body: &str) -> Option { tool_name: raw.tool_name, tool_input_preview, plugin_version: raw.plugin_version, + error_type: raw.error_type, }, source: CLIAgentEventSource::RichPlugin, }) @@ -73,4 +75,5 @@ struct RawEvent { tool_name: Option, tool_input: Option, plugin_version: Option, + error_type: Option, } diff --git a/app/src/terminal/cli_agent_sessions/mod.rs b/app/src/terminal/cli_agent_sessions/mod.rs index b7b0de8da21..6344278287b 100644 --- a/app/src/terminal/cli_agent_sessions/mod.rs +++ b/app/src/terminal/cli_agent_sessions/mod.rs @@ -17,7 +17,13 @@ use crate::ai::blocklist::InputConfig; pub enum CLIAgentSessionStatus { InProgress, Success, - Blocked { message: Option }, + Failed { + error_type: Option, + message: Option, + }, + Blocked { + message: Option, + }, } impl CLIAgentSessionStatus { @@ -26,6 +32,7 @@ impl CLIAgentSessionStatus { match self { CLIAgentSessionStatus::InProgress => ConversationStatus::InProgress, CLIAgentSessionStatus::Success => ConversationStatus::Success, + CLIAgentSessionStatus::Failed { .. } => ConversationStatus::Error, CLIAgentSessionStatus::Blocked { message } => ConversationStatus::Blocked { blocked_action: message.clone().unwrap_or_default(), }, @@ -206,6 +213,15 @@ impl CLIAgentSession { self.clear_permission_scoped_state(); CLIAgentSessionStatus::Success } + CLIAgentEventType::StopFailure => { + self.session_context.query = event.payload.query.clone(); + self.session_context.response = event.payload.response.clone(); + self.clear_permission_scoped_state(); + CLIAgentSessionStatus::Failed { + error_type: event.payload.error_type.clone(), + message: event.payload.response.clone(), + } + } CLIAgentEventType::PermissionRequest => { self.session_context.summary = event.payload.summary.clone(); self.session_context.tool_name = event.payload.tool_name.clone(); diff --git a/app/src/terminal/view.rs b/app/src/terminal/view.rs index b366240311b..66870367983 100644 --- a/app/src/terminal/view.rs +++ b/app/src/terminal/view.rs @@ -13479,7 +13479,9 @@ impl TerminalView { ctx, ); } - CLIAgentSessionStatus::InProgress | CLIAgentSessionStatus::Success => { + CLIAgentSessionStatus::InProgress + | CLIAgentSessionStatus::Success + | CLIAgentSessionStatus::Failed { .. } => { // Auto-open rich input when the agent resumes or completes. if !self.has_active_cli_agent_input_session(ctx) { self.open_cli_agent_rich_input(CLIAgentInputEntrypoint::AutoShow, ctx); @@ -13511,6 +13513,8 @@ impl TerminalView { let trigger = if matches!(status, CLIAgentSessionStatus::Blocked { .. }) { NotificationsTrigger::NeedsAttention + } else if matches!(status, CLIAgentSessionStatus::Failed { .. }) { + NotificationsTrigger::AgentTaskCompleted(false) } else { NotificationsTrigger::AgentTaskCompleted(true) };