diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index d11a53b59a..f1020f81ef 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -48,6 +48,7 @@ use crate::conversation_history::ConversationHistory; use crate::environment_context::EnvironmentContext; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::error::error_event_from; use crate::exec::ExecToolCallOutput; #[cfg(test)] use crate::exec::StreamOutput; @@ -388,7 +389,10 @@ impl Session { error!("{message}"); post_session_configured_error_events.push(Event { id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::Error(ErrorEvent { message }), + msg: EventMsg::Error(ErrorEvent { + message, + markdown_message: None, + }), }); (McpConnectionManager::default(), Default::default()) } @@ -401,7 +405,10 @@ impl Session { error!("{message}"); post_session_configured_error_events.push(Event { id: INITIAL_SUBMIT_ID.to_owned(), - msg: EventMsg::Error(ErrorEvent { message }), + msg: EventMsg::Error(ErrorEvent { + message, + markdown_message: None, + }), }); } } @@ -1458,6 +1465,7 @@ async fn submission_loop( id: sub.id.clone(), msg: EventMsg::Error(ErrorEvent { message: "Failed to shutdown rollout recorder".to_string(), + markdown_message: None, }), }; sess.send_event(event).await; @@ -1841,6 +1849,7 @@ pub(crate) async fn run_task( message: format!( "Conversation is still above the token limit after automatic summarization (limit {limit_str}, current {current_tokens}). Please start a new session or trim your input." ), + markdown_message: None, }), }; sess.send_event(event).await; @@ -1871,9 +1880,7 @@ pub(crate) async fn run_task( info!("Turn error: {e:#}"); let event = Event { id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: e.to_string(), - }), + msg: EventMsg::Error(error_event_from(&e)), }; sess.send_event(event).await; // let the user continue the conversation diff --git a/codex-rs/core/src/codex/compact.rs b/codex-rs/core/src/codex/compact.rs index 40c9da7b34..9db6f82906 100644 --- a/codex-rs/core/src/codex/compact.rs +++ b/codex-rs/core/src/codex/compact.rs @@ -7,9 +7,9 @@ use crate::Prompt; use crate::client_common::ResponseEvent; use crate::error::CodexErr; use crate::error::Result as CodexResult; +use crate::error::error_event_from; use crate::protocol::AgentMessageEvent; use crate::protocol::CompactedItem; -use crate::protocol::ErrorEvent; use crate::protocol::Event; use crate::protocol::EventMsg; use crate::protocol::InputItem; @@ -108,9 +108,7 @@ async fn run_compact_task_inner( .await; let event = Event { id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: e.to_string(), - }), + msg: EventMsg::Error(error_event_from(&e)), }; sess.send_event(event).await; return; @@ -131,9 +129,7 @@ async fn run_compact_task_inner( } else { let event = Event { id: sub_id.clone(), - msg: EventMsg::Error(ErrorEvent { - message: e.to_string(), - }), + msg: EventMsg::Error(error_event_from(&e)), }; sess.send_event(event).await; return; diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index 6fad448b44..8789ecbca1 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -2,6 +2,7 @@ use crate::exec::ExecToolCallOutput; use crate::token_data::KnownPlan; use crate::token_data::PlanType; use codex_protocol::ConversationId; +use codex_protocol::protocol::ErrorEvent; use codex_protocol::protocol::RateLimitSnapshot; use reqwest::StatusCode; use serde_json; @@ -12,6 +13,8 @@ use tokio::task::JoinError; pub type Result = std::result::Result; +const PRICING_URL: &str = "https://openai.com/chatgpt/pricing"; + #[derive(Error, Debug)] pub enum SandboxErr { /// Error from sandbox execution @@ -197,7 +200,7 @@ impl std::fmt::Display for UsageLimitReachedError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { let message = match self.plan_type.as_ref() { Some(PlanType::Known(KnownPlan::Plus)) => format!( - "You've hit your usage limit. Upgrade to Pro (https://openai.com/chatgpt/pricing){}", + "You've hit your usage limit. Upgrade to Pro ({PRICING_URL}){}", retry_suffix_after_or(self.resets_in_seconds) ), Some(PlanType::Known(KnownPlan::Team)) | Some(PlanType::Known(KnownPlan::Business)) => { @@ -207,8 +210,9 @@ impl std::fmt::Display for UsageLimitReachedError { ) } Some(PlanType::Known(KnownPlan::Free)) => { - "You've hit your usage limit. Upgrade to Plus to continue using Codex (https://openai.com/chatgpt/pricing)." - .to_string() + format!( + "You've hit your usage limit. Upgrade to Plus to continue using Codex ({PRICING_URL})." + ) } Some(PlanType::Known(KnownPlan::Pro)) | Some(PlanType::Known(KnownPlan::Enterprise)) @@ -226,6 +230,40 @@ impl std::fmt::Display for UsageLimitReachedError { } } +impl UsageLimitReachedError { + pub(crate) fn markdown_message(&self) -> String { + match self.plan_type.as_ref() { + Some(PlanType::Known(KnownPlan::Plus)) => format!( + "You've hit your usage limit. Upgrade to [Pro]({PRICING_URL}){}", + retry_suffix_after_or(self.resets_in_seconds) + ), + Some(PlanType::Known(KnownPlan::Free)) => format!( + "You've hit your usage limit. [Upgrade to Plus]({PRICING_URL}) to continue using Codex." + ), + _ => self.to_string(), + } + } +} + +impl CodexErr { + pub(crate) fn markdown_message(&self) -> Option { + match self { + CodexErr::UsageLimitReached(err) => Some(err.markdown_message()), + CodexErr::UsageNotIncluded => Some(format!( + "To use Codex with your ChatGPT plan, [upgrade to Plus]({PRICING_URL})." + )), + _ => None, + } + } +} + +pub(crate) fn error_event_from(err: &CodexErr) -> ErrorEvent { + ErrorEvent { + message: err.to_string(), + markdown_message: err.markdown_message(), + } +} + fn retry_suffix(resets_in_seconds: Option) -> String { if let Some(secs) = resets_in_seconds { let reset_duration = format_reset_duration(secs); @@ -348,6 +386,19 @@ mod tests { ); } + #[test] + fn usage_limit_reached_error_markdown_plus_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_in_seconds: Some(60), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.markdown_message(), + "You've hit your usage limit. Upgrade to [Pro](https://openai.com/chatgpt/pricing) or try again in 1 minute." + ); + } + #[test] fn usage_limit_reached_error_formats_free_plan() { let err = UsageLimitReachedError { @@ -361,6 +412,19 @@ mod tests { ); } + #[test] + fn usage_limit_reached_error_markdown_free_plan() { + let err = UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Free)), + resets_in_seconds: Some(3600), + rate_limits: Some(rate_limit_snapshot()), + }; + assert_eq!( + err.markdown_message(), + "You've hit your usage limit. [Upgrade to Plus](https://openai.com/chatgpt/pricing) to continue using Codex." + ); + } + #[test] fn usage_limit_reached_error_formats_default_when_none() { let err = UsageLimitReachedError { @@ -413,6 +477,31 @@ mod tests { ); } + #[test] + fn codex_err_usage_not_included_markdown() { + let err = CodexErr::UsageNotIncluded; + assert_eq!( + err.markdown_message(), + Some("To use Codex with your ChatGPT plan, [upgrade to Plus](https://openai.com/chatgpt/pricing).".to_string()) + ); + } + + #[test] + fn codex_err_usage_limit_reached_markdown() { + let err = CodexErr::UsageLimitReached(UsageLimitReachedError { + plan_type: Some(PlanType::Known(KnownPlan::Plus)), + resets_in_seconds: Some(120), + rate_limits: Some(rate_limit_snapshot()), + }); + assert_eq!( + err.markdown_message(), + Some( + "You've hit your usage limit. Upgrade to [Pro](https://openai.com/chatgpt/pricing) or try again in 2 minutes." + .to_string() + ) + ); + } + #[test] fn usage_limit_reached_includes_minutes_when_available() { let err = UsageLimitReachedError { diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index f6db083407..6e13d985f0 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -583,7 +583,7 @@ async fn auto_compact_stops_after_failed_attempt() { .unwrap(); let error_event = wait_for_event(&codex, |ev| matches!(ev, EventMsg::Error(_))).await; - let EventMsg::Error(ErrorEvent { message }) = error_event else { + let EventMsg::Error(ErrorEvent { message, .. }) = error_event else { panic!("expected error event"); }; assert!( diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index f9aa3f8598..a9d1968950 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -157,7 +157,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { fn process_event(&mut self, event: Event) -> CodexStatus { let Event { id: _, msg } = event; match msg { - EventMsg::Error(ErrorEvent { message }) => { + EventMsg::Error(ErrorEvent { message, .. }) => { let prefix = "ERROR:".style(self.red); ts_msg!(self, "{prefix} {message}"); } diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index a995b46376..b830bcbc59 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -434,6 +434,7 @@ fn error_event_produces_error() { "e1", EventMsg::Error(codex_core::protocol::ErrorEvent { message: "boom".to_string(), + markdown_message: None, }), )); assert_eq!( @@ -469,6 +470,7 @@ fn error_followed_by_task_complete_produces_turn_failed() { "e1", EventMsg::Error(ErrorEvent { message: "boom".to_string(), + markdown_message: None, }), ); assert_eq!( diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index f09dc98cbc..ea58bdce3d 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -15,6 +15,7 @@ use codex_core::NewConversation; use codex_core::config::Config as CodexConfig; use codex_core::protocol::AgentMessageEvent; use codex_core::protocol::ApplyPatchApprovalRequestEvent; +use codex_core::protocol::ErrorEvent; use codex_core::protocol::Event; use codex_core::protocol::EventMsg; use codex_core::protocol::ExecApprovalRequestEvent; @@ -194,10 +195,12 @@ async fn run_codex_tool_session_inner( } EventMsg::Error(err_event) => { // Return a response to conclude the tool call when the Codex session reports an error (e.g., interruption). - let result = json!({ - "error": err_event.message, - }); + let result = error_result_json(&err_event); outgoing.send_response(request_id.clone(), result).await; + running_requests_id_to_codex_uuid + .lock() + .await + .remove(&request_id); break; } EventMsg::ApplyPatchApprovalRequest(ApplyPatchApprovalRequestEvent { @@ -310,3 +313,41 @@ async fn run_codex_tool_session_inner( } } } + +fn error_result_json(err_event: &ErrorEvent) -> serde_json::Value { + let mut result = json!({ + "error": err_event.message.clone(), + }); + if let Some(markdown_message) = &err_event.markdown_message { + result["errorMarkdown"] = json!(markdown_message); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn error_result_without_markdown() { + let event = ErrorEvent { + message: "raw".to_string(), + markdown_message: None, + }; + assert_eq!(error_result_json(&event), json!({ "error": "raw" })); + } + + #[test] + fn error_result_with_markdown() { + let event = ErrorEvent { + message: "raw".to_string(), + markdown_message: Some("md".to_string()), + }; + assert_eq!( + error_result_json(&event), + json!({ + "error": "raw", + "errorMarkdown": "md", + }) + ); + } +} diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 3b6520be67..239314f49b 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -534,6 +534,9 @@ pub struct ExitedReviewModeEvent { #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct ErrorEvent { pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pub markdown_message: Option, } #[derive(Debug, Clone, Deserialize, Serialize, TS)] diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 2237c678c7..08104e2298 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -1395,7 +1395,7 @@ impl ChatWidget { self.set_token_info(ev.info); self.on_rate_limit_snapshot(ev.rate_limits); } - EventMsg::Error(ErrorEvent { message }) => self.on_error(message), + EventMsg::Error(ErrorEvent { message, .. }) => self.on_error(message), EventMsg::TurnAborted(ev) => match ev.reason { TurnAbortReason::Interrupted => { self.on_interrupted_turn(ev.reason);