diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 0d0ff1eac3ae..f65655f71dfd 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1385,6 +1385,7 @@ dependencies = [ "serde_json", "tempfile", "tokio", + "wiremock", ] [[package]] diff --git a/codex-rs/core/src/chat_completions.rs b/codex-rs/core/src/chat_completions.rs index 7fa2077087fa..f778a7490ef9 100644 --- a/codex-rs/core/src/chat_completions.rs +++ b/codex-rs/core/src/chat_completions.rs @@ -869,6 +869,9 @@ where // Not an assistant message – forward immediately. return Poll::Ready(Some(Ok(ResponseEvent::OutputItemDone { item, sequence_number: None, output_index: None }))); } + Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))) => { + return Poll::Ready(Some(Ok(ResponseEvent::RateLimits(snapshot)))); + } Poll::Ready(Some(Ok(ResponseEvent::Completed { response_id, token_usage, diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 21e284c6dc16..6eda09e99bbf 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -11,6 +11,7 @@ use eventsource_stream::Eventsource; use futures::prelude::*; // use regex_lite::Regex; use reqwest::StatusCode; +use reqwest::header::HeaderMap; use serde::Deserialize; use serde::Serialize; use serde_json::Value; @@ -44,6 +45,7 @@ use crate::model_family::ModelFamily; use crate::model_provider_info::ModelProviderInfo; use crate::model_provider_info::WireApi; use crate::openai_tools::create_tools_json_for_responses_api; +use codex_protocol::protocol::RateLimitSnapshotEvent; use crate::protocol::TokenUsage; use crate::util::backoff; use std::sync::Arc; @@ -359,6 +361,15 @@ impl ModelClient { } let (tx_event, rx_event) = mpsc::channel::>(1600); + if let Some(snapshot) = parse_rate_limit_snapshot(resp.headers()) + && tx_event + .send(Ok(ResponseEvent::RateLimits(snapshot))) + .await + .is_err() + { + debug!("receiver dropped rate limit snapshot event"); + } + // spawn task to process SSE let stream = resp.bytes_stream().map_err(CodexErr::Reqwest); let debug_logger = Arc::clone(&self.debug_logger); @@ -633,6 +644,38 @@ fn attach_item_ids(payload_json: &mut Value, original_items: &[ResponseItem]) { } } +fn parse_rate_limit_snapshot(headers: &HeaderMap) -> Option { + let primary_used_percent = parse_header_f64(headers, "x-codex-primary-used-percent")?; + let weekly_used_percent = parse_header_f64(headers, "x-codex-protection-used-percent")?; + let primary_to_weekly_ratio_percent = + parse_header_f64(headers, "x-codex-primary-over-protection-limit-percent")?; + let primary_window_minutes = parse_header_u64(headers, "x-codex-primary-window-minutes")?; + let weekly_window_minutes = parse_header_u64(headers, "x-codex-protection-window-minutes")?; + + Some(RateLimitSnapshotEvent { + primary_used_percent, + weekly_used_percent, + primary_to_weekly_ratio_percent, + primary_window_minutes, + weekly_window_minutes, + }) +} + +fn parse_header_f64(headers: &HeaderMap, name: &str) -> Option { + parse_header_str(headers, name)? + .parse::() + .ok() + .filter(|v| v.is_finite()) +} + +fn parse_header_u64(headers: &HeaderMap, name: &str) -> Option { + parse_header_str(headers, name)?.parse::().ok() +} + +fn parse_header_str<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> { + headers.get(name)?.to_str().ok() +} + async fn process_sse( stream: S, tx_event: mpsc::Sender>, diff --git a/codex-rs/core/src/client_common.rs b/codex-rs/core/src/client_common.rs index 249279db5174..eef41a26c971 100644 --- a/codex-rs/core/src/client_common.rs +++ b/codex-rs/core/src/client_common.rs @@ -5,6 +5,7 @@ use crate::environment_context::EnvironmentContext; use crate::error::Result; use crate::model_family::ModelFamily; use crate::openai_tools::OpenAiTool; +use codex_protocol::protocol::RateLimitSnapshotEvent; use crate::protocol::TokenUsage; use codex_apply_patch::APPLY_PATCH_TOOL_INSTRUCTIONS; use codex_protocol::models::ContentItem; @@ -251,6 +252,7 @@ pub enum ResponseEvent { call_id: String, query: Option, }, + RateLimits(RateLimitSnapshotEvent), } #[derive(Debug, Serialize)] diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 529f7eedc41c..1fdfe4520b6c 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -3268,6 +3268,9 @@ async fn try_run_turn( sess.tx_event.send(stamped).await.ok(); } } + ResponseEvent::RateLimits(_rl) => { + // Upstream emits periodic rate limit snapshots. No-op for now. + } // Note: ReasoningSummaryPartAdded handled above without scratchpad mutation. } } diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index 051012c80170..8e151fec1ddb 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -53,7 +53,7 @@ const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; -#[derive(Debug, Clone)] +#[derive(Clone, Debug)] pub struct ExecParams { pub command: Vec, pub cwd: PathBuf, diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 9cfd20cdb442..2d4305191911 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -11,3 +11,4 @@ codex-core = { path = "../.." } serde_json = "1" tempfile = "3" tokio = { version = "1", features = ["time"] } +wiremock = "0.6" diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 1b379be96b46..4ac700619930 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -7,6 +7,8 @@ use codex_core::config::Config; use codex_core::config::ConfigOverrides; use codex_core::config::ConfigToml; +pub mod responses; + /// Returns a default `Config` whose on-disk state is confined to the provided /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.code` directory (legacy `~/.codex` diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs new file mode 100644 index 000000000000..2f55f17a5229 --- /dev/null +++ b/codex-rs/core/tests/common/responses.rs @@ -0,0 +1,133 @@ +use serde_json::Value; +use wiremock::BodyPrintLimit; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::method; +use wiremock::matchers::path; + +/// Build an SSE stream body from a list of JSON events. +pub fn sse(events: Vec) -> String { + use std::fmt::Write as _; + let mut out = String::new(); + for ev in events { + let kind = ev.get("type").and_then(|v| v.as_str()).unwrap(); + writeln!(&mut out, "event: {kind}").unwrap(); + if !ev.as_object().map(|o| o.len() == 1).unwrap_or(false) { + write!(&mut out, "data: {ev}\n\n").unwrap(); + } else { + out.push('\n'); + } + } + out +} + +/// Convenience: SSE event for a completed response with a specific id. +pub fn ev_completed(id: &str) -> Value { + serde_json::json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} + } + }) +} + +pub fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { + serde_json::json!({ + "type": "response.completed", + "response": { + "id": id, + "usage": { + "input_tokens": total_tokens, + "input_tokens_details": null, + "output_tokens": 0, + "output_tokens_details": null, + "total_tokens": total_tokens + } + } + }) +} + +/// Convenience: SSE event for a single assistant message output item. +pub fn ev_assistant_message(id: &str, text: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "message", + "role": "assistant", + "id": id, + "content": [{"type": "output_text", "text": text}] + } + }) +} + +pub fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "call_id": call_id, + "name": name, + "arguments": arguments + } + }) +} + +/// Convenience: SSE event for an `apply_patch` custom tool call with raw patch +/// text. This mirrors the payload produced by the Responses API when the model +/// invokes `apply_patch` directly (before we convert it to a function call). +pub fn ev_apply_patch_custom_tool_call(call_id: &str, patch: &str) -> Value { + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "custom_tool_call", + "name": "apply_patch", + "input": patch, + "call_id": call_id + } + }) +} + +/// Convenience: SSE event for an `apply_patch` function call. The Responses API +/// wraps the patch content in a JSON string under the `input` key; we recreate +/// the same structure so downstream code exercises the full parsing path. +pub fn ev_apply_patch_function_call(call_id: &str, patch: &str) -> Value { + let arguments = serde_json::json!({ "input": patch }); + let arguments = serde_json::to_string(&arguments).expect("serialize apply_patch arguments"); + + serde_json::json!({ + "type": "response.output_item.done", + "item": { + "type": "function_call", + "name": "apply_patch", + "arguments": arguments, + "call_id": call_id + } + }) +} + +pub fn sse_response(body: String) -> ResponseTemplate { + ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .set_body_raw(body, "text/event-stream") +} + +pub async fn mount_sse_once(server: &MockServer, matcher: M, body: String) +where + M: wiremock::Match + Send + Sync + 'static, +{ + Mock::given(method("POST")) + .and(path("/v1/responses")) + .and(matcher) + .respond_with(sse_response(body)) + .mount(server) + .await; +} + +pub async fn start_mock_server() -> MockServer { + MockServer::builder() + .body_print_limit(BodyPrintLimit::Limited(80_000)) + .start() + .await +} diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 40e6a94ebe68..2fdb2373cd37 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -22,6 +22,7 @@ use codex_protocol::models::ReasoningItemReasoningSummary; use codex_protocol::models::WebSearchAction; use core_test_support::load_default_config_for_test; use core_test_support::load_sse_fixture_with_id; +use core_test_support::responses; use core_test_support::wait_for_event; use futures::StreamExt; use serde_json::json; @@ -875,6 +876,100 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { assert_eq!(body["input"][5]["id"].as_str(), Some("custom-tool-id")); } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn token_count_includes_rate_limits_snapshot() { + let server = MockServer::start().await; + + let sse_body = responses::sse(vec![responses::ev_completed_with_tokens("resp_rate", 123)]); + + let response = ResponseTemplate::new(200) + .insert_header("content-type", "text/event-stream") + .insert_header("x-codex-primary-used-percent", "12.5") + .insert_header("x-codex-protection-used-percent", "40.0") + .insert_header("x-codex-primary-over-protection-limit-percent", "75.0") + .insert_header("x-codex-primary-window-minutes", "10") + .insert_header("x-codex-protection-window-minutes", "60") + .set_body_raw(sse_body, "text/event-stream"); + + Mock::given(method("POST")) + .and(path("/v1/responses")) + .respond_with(response) + .expect(1) + .mount(&server) + .await; + + let mut provider = built_in_model_providers()["openai"].clone(); + provider.base_url = Some(format!("{}/v1", server.uri())); + + let home = TempDir::new().unwrap(); + let mut config = load_default_config_for_test(&home); + config.model_provider = provider; + + let conversation_manager = ConversationManager::with_auth(CodexAuth::from_api_key("test")); + let codex = conversation_manager + .new_conversation(config) + .await + .expect("create conversation") + .conversation; + + codex + .submit(Op::UserInput { + items: vec![InputItem::Text { + text: "hello".into(), + }], + }) + .await + .unwrap(); + + let token_event = wait_for_event(&codex, |msg| matches!(msg, EventMsg::TokenCount(_))).await; + let final_payload = match token_event { + EventMsg::TokenCount(ev) => ev, + _ => unreachable!(), + }; + // Assert full JSON for the final token count event (usage + rate limits) + let final_json = serde_json::to_value(&final_payload).unwrap(); + pretty_assertions::assert_eq!( + final_json, + json!({ + "info": { + "total_token_usage": { + "input_tokens": 123, + "cached_input_tokens": 0, + "output_tokens": 0, + "reasoning_output_tokens": 0, + "total_tokens": 123 + }, + "last_token_usage": { + "input_tokens": 123, + "cached_input_tokens": 0, + "output_tokens": 0, + "reasoning_output_tokens": 0, + "total_tokens": 123 + }, + // Default model is gpt-5 in tests → 272000 context window + "model_context_window": 272000 + }, + "rate_limits": { + "primary_used_percent": 12.5, + "weekly_used_percent": 40.0, + "primary_to_weekly_ratio_percent": 75.0, + "primary_window_minutes": 10, + "weekly_window_minutes": 60 + } + }) + ); + let usage = final_payload + .info + .expect("token usage info should be recorded after completion"); + assert_eq!(usage.total_token_usage.total_tokens, 123); + let final_snapshot = final_payload + .rate_limits + .expect("latest rate limit snapshot should be retained"); + assert_eq!(final_snapshot.primary_used_percent, 12.5); + + wait_for_event(&codex, |msg| matches!(msg, EventMsg::TaskComplete(_))).await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn azure_overrides_assign_properties_used_for_responses_url() { let existing_env_var_with_random_value = if cfg!(windows) { "USERNAME" } else { "USER" }; diff --git a/codex-rs/core/tests/suite/compact.rs b/codex-rs/core/tests/suite/compact.rs index 8db70f355947..a58de304fbad 100644 --- a/codex-rs/core/tests/suite/compact.rs +++ b/codex-rs/core/tests/suite/compact.rs @@ -1,5 +1,3 @@ -#![expect(clippy::unwrap_used)] - use codex_core::CodexAuth; use codex_core::ConversationManager; use codex_core::ModelProviderInfo; @@ -13,12 +11,10 @@ use codex_core::protocol::RolloutItem; use codex_core::protocol::RolloutLine; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; +use core_test_support::responses; use core_test_support::wait_for_event; -use serde_json::Value; use tempfile::TempDir; -use wiremock::BodyPrintLimit; use wiremock::Mock; -use wiremock::MockServer; use wiremock::Request; use wiremock::Respond; use wiremock::ResponseTemplate; @@ -26,106 +22,16 @@ use wiremock::matchers::method; use wiremock::matchers::path; use pretty_assertions::assert_eq; +use responses::ev_assistant_message; +use responses::ev_completed; +use responses::sse; +use responses::start_mock_server; use std::sync::Arc; use std::sync::Mutex; use std::sync::atomic::AtomicUsize; use std::sync::atomic::Ordering; - // --- Test helpers ----------------------------------------------------------- -/// Build an SSE stream body from a list of JSON events. -pub(super) fn sse(events: Vec) -> String { - use std::fmt::Write as _; - let mut out = String::new(); - for ev in events { - let kind = ev.get("type").and_then(|v| v.as_str()).unwrap(); - writeln!(&mut out, "event: {kind}").unwrap(); - if !ev.as_object().map(|o| o.len() == 1).unwrap_or(false) { - write!(&mut out, "data: {ev}\n\n").unwrap(); - } else { - out.push('\n'); - } - } - out -} - -/// Convenience: SSE event for a completed response with a specific id. -pub(super) fn ev_completed(id: &str) -> Value { - serde_json::json!({ - "type": "response.completed", - "response": { - "id": id, - "usage": {"input_tokens":0,"input_tokens_details":null,"output_tokens":0,"output_tokens_details":null,"total_tokens":0} - } - }) -} - -fn ev_completed_with_tokens(id: &str, total_tokens: u64) -> Value { - serde_json::json!({ - "type": "response.completed", - "response": { - "id": id, - "usage": { - "input_tokens": total_tokens, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": total_tokens - } - } - }) -} - -/// Convenience: SSE event for a single assistant message output item. -pub(super) fn ev_assistant_message(id: &str, text: &str) -> Value { - serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "message", - "role": "assistant", - "id": id, - "content": [{"type": "output_text", "text": text}] - } - }) -} - -fn ev_function_call(call_id: &str, name: &str, arguments: &str) -> Value { - serde_json::json!({ - "type": "response.output_item.done", - "item": { - "type": "function_call", - "call_id": call_id, - "name": name, - "arguments": arguments - } - }) -} - -pub(super) fn sse_response(body: String) -> ResponseTemplate { - ResponseTemplate::new(200) - .insert_header("content-type", "text/event-stream") - .set_body_raw(body, "text/event-stream") -} - -pub(super) async fn mount_sse_once(server: &MockServer, matcher: M, body: String) -where - M: wiremock::Match + Send + Sync + 'static, -{ - Mock::given(method("POST")) - .and(path("/v1/responses")) - .and(matcher) - .respond_with(sse_response(body)) - .mount(server) - .await; -} - -async fn start_mock_server() -> MockServer { - MockServer::builder() - .body_print_limit(BodyPrintLimit::Limited(80_000)) - .start() - .await -} - pub(super) const FIRST_REPLY: &str = "FIRST_REPLY"; pub(super) const SUMMARY_TEXT: &str = "SUMMARY_ONLY_CONTEXT"; pub(super) const SUMMARIZE_TRIGGER: &str = "Start Summarization"; @@ -175,19 +81,19 @@ async fn summarize_context_three_requests_and_instructions() { body.contains("\"text\":\"hello world\"") && !body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\"")) }; - mount_sse_once(&server, first_matcher, sse1).await; + responses::mount_sse_once(&server, first_matcher, sse1).await; let second_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{SUMMARIZE_TRIGGER}\"")) }; - mount_sse_once(&server, second_matcher, sse2).await; + responses::mount_sse_once(&server, second_matcher, sse2).await; let third_matcher = |req: &wiremock::Request| { let body = std::str::from_utf8(&req.body).unwrap_or(""); body.contains(&format!("\"text\":\"{THIRD_USER_MSG}\"")) }; - mount_sse_once(&server, third_matcher, sse3).await; + responses::mount_sse_once(&server, third_matcher, sse3).await; // Build config pointing to the mock server and spawn Codex. let model_provider = ModelProviderInfo { @@ -381,17 +287,17 @@ async fn auto_compact_runs_after_token_limit_hit() { let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 70_000), + responses::ev_completed_with_tokens("r1", 70_000), ]); let sse2 = sse(vec![ ev_assistant_message("m2", "SECOND_REPLY"), - ev_completed_with_tokens("r2", 330_000), + responses::ev_completed_with_tokens("r2", 330_000), ]); let sse3 = sse(vec![ ev_assistant_message("m3", AUTO_SUMMARY_TEXT), - ev_completed_with_tokens("r3", 200), + responses::ev_completed_with_tokens("r3", 200), ]); let first_matcher = |req: &wiremock::Request| { @@ -403,7 +309,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(sse_response(sse1)) + .respond_with(responses::sse_response(sse1)) .mount(&server) .await; @@ -416,7 +322,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(sse_response(sse2)) + .respond_with(responses::sse_response(sse2)) .mount(&server) .await; @@ -427,7 +333,7 @@ async fn auto_compact_runs_after_token_limit_hit() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(sse_response(sse3)) + .respond_with(responses::sse_response(sse3)) .mount(&server) .await; @@ -522,17 +428,17 @@ async fn auto_compact_persists_rollout_entries() { let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 70_000), + responses::ev_completed_with_tokens("r1", 70_000), ]); let sse2 = sse(vec![ ev_assistant_message("m2", "SECOND_REPLY"), - ev_completed_with_tokens("r2", 330_000), + responses::ev_completed_with_tokens("r2", 330_000), ]); let sse3 = sse(vec![ ev_assistant_message("m3", AUTO_SUMMARY_TEXT), - ev_completed_with_tokens("r3", 200), + responses::ev_completed_with_tokens("r3", 200), ]); let first_matcher = |req: &wiremock::Request| { @@ -544,7 +450,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(sse_response(sse1)) + .respond_with(responses::sse_response(sse1)) .mount(&server) .await; @@ -557,7 +463,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(sse_response(sse2)) + .respond_with(responses::sse_response(sse2)) .mount(&server) .await; @@ -568,7 +474,7 @@ async fn auto_compact_persists_rollout_entries() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(sse_response(sse3)) + .respond_with(responses::sse_response(sse3)) .mount(&server) .await; @@ -655,17 +561,17 @@ async fn auto_compact_stops_after_failed_attempt() { let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 500), + responses::ev_completed_with_tokens("r1", 500), ]); let sse2 = sse(vec![ ev_assistant_message("m2", SUMMARY_TEXT), - ev_completed_with_tokens("r2", 50), + responses::ev_completed_with_tokens("r2", 50), ]); let sse3 = sse(vec![ ev_assistant_message("m3", STILL_TOO_BIG_REPLY), - ev_completed_with_tokens("r3", 500), + responses::ev_completed_with_tokens("r3", 500), ]); let first_matcher = |req: &wiremock::Request| { @@ -676,7 +582,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(first_matcher) - .respond_with(sse_response(sse1.clone())) + .respond_with(responses::sse_response(sse1.clone())) .mount(&server) .await; @@ -687,7 +593,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(second_matcher) - .respond_with(sse_response(sse2.clone())) + .respond_with(responses::sse_response(sse2.clone())) .mount(&server) .await; @@ -699,7 +605,7 @@ async fn auto_compact_stops_after_failed_attempt() { Mock::given(method("POST")) .and(path("/v1/responses")) .and(third_matcher) - .respond_with(sse_response(sse3.clone())) + .respond_with(responses::sse_response(sse3.clone())) .mount(&server) .await; @@ -769,27 +675,27 @@ async fn auto_compact_allows_multiple_attempts_when_interleaved_with_other_turn_ let sse1 = sse(vec![ ev_assistant_message("m1", FIRST_REPLY), - ev_completed_with_tokens("r1", 500), + responses::ev_completed_with_tokens("r1", 500), ]); let sse2 = sse(vec![ ev_assistant_message("m2", FIRST_AUTO_SUMMARY), - ev_completed_with_tokens("r2", 50), + responses::ev_completed_with_tokens("r2", 50), ]); let sse3 = sse(vec![ - ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), - ev_completed_with_tokens("r3", 150), + responses::ev_function_call(DUMMY_CALL_ID, DUMMY_FUNCTION_NAME, "{}"), + responses::ev_completed_with_tokens("r3", 150), ]); let sse4 = sse(vec![ ev_assistant_message("m4", SECOND_LARGE_REPLY), - ev_completed_with_tokens("r4", 450), + responses::ev_completed_with_tokens("r4", 450), ]); let sse5 = sse(vec![ ev_assistant_message("m5", SECOND_AUTO_SUMMARY), - ev_completed_with_tokens("r5", 60), + responses::ev_completed_with_tokens("r5", 60), ]); let sse6 = sse(vec![ ev_assistant_message("m6", FINAL_REPLY), - ev_completed_with_tokens("r6", 120), + responses::ev_completed_with_tokens("r6", 120), ]); #[derive(Clone)] diff --git a/codex-rs/core/tests/suite/compact_resume_fork.rs b/codex-rs/core/tests/suite/compact_resume_fork.rs index 1bedbcab083d..1e752826bb9f 100644 --- a/codex-rs/core/tests/suite/compact_resume_fork.rs +++ b/codex-rs/core/tests/suite/compact_resume_fork.rs @@ -10,10 +10,6 @@ use super::compact::FIRST_REPLY; use super::compact::SUMMARIZE_TRIGGER; use super::compact::SUMMARY_TEXT; -use super::compact::ev_assistant_message; -use super::compact::ev_completed; -use super::compact::mount_sse_once; -use super::compact::sse; use codex_core::CodexAuth; use codex_core::CodexConversation; use codex_core::ConversationManager; @@ -27,6 +23,10 @@ use codex_core::protocol::InputItem; use codex_core::protocol::Op; use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR; use core_test_support::load_default_config_for_test; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::mount_sse_once; +use core_test_support::responses::sse; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; use serde_json::Value; diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_add.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_add.json deleted file mode 100644 index 8d2bf261af64..000000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_add.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "name": "apply_patch", - "input": "*** Begin Patch\n*** Add File: test.md\n+Hello world\n*** End Patch", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json deleted file mode 100644 index ce05e7d48272..000000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_add.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "name": "apply_patch", - "input": "*** Begin Patch\n*** Add File: app.py\n+class BaseClass:\n+ def method():\n+ return False\n*** End Patch", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json deleted file mode 100644 index 8329d9628c9e..000000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_freeform_update.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "custom_tool_call", - "name": "apply_patch", - "input": "*** Begin Patch\n*** Update File: app.py\n@@ def method():\n- return False\n+\n+ return True\n*** End Patch", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_apply_patch_update.json b/codex-rs/exec/tests/fixtures/sse_apply_patch_update.json deleted file mode 100644 index 79689bece35e..000000000000 --- a/codex-rs/exec/tests/fixtures/sse_apply_patch_update.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "type": "response.output_item.done", - "item": { - "type": "function_call", - "name": "apply_patch", - "arguments": "{\n \"input\": \"*** Begin Patch\\n*** Update File: test.md\\n@@\\n-Hello world\\n+Final text\\n*** End Patch\"\n}", - "call_id": "__ID__" - } - }, - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/fixtures/sse_response_completed.json b/codex-rs/exec/tests/fixtures/sse_response_completed.json deleted file mode 100644 index 1774dc5e8452..000000000000 --- a/codex-rs/exec/tests/fixtures/sse_response_completed.json +++ /dev/null @@ -1,16 +0,0 @@ -[ - { - "type": "response.completed", - "response": { - "id": "__ID__", - "usage": { - "input_tokens": 0, - "input_tokens_details": null, - "output_tokens": 0, - "output_tokens_details": null, - "total_tokens": 0 - }, - "output": [] - } - } -] diff --git a/codex-rs/exec/tests/suite/apply_patch.rs b/codex-rs/exec/tests/suite/apply_patch.rs index 5537853b0221..489f34f9ce8f 100644 --- a/codex-rs/exec/tests/suite/apply_patch.rs +++ b/codex-rs/exec/tests/suite/apply_patch.rs @@ -1,8 +1,12 @@ -#![allow(clippy::expect_used, clippy::unwrap_used)] +#![allow(clippy::expect_used, clippy::unwrap_used, unused_imports)] use anyhow::Context; use assert_cmd::prelude::*; use codex_core::CODEX_APPLY_PATCH_ARG1; +use core_test_support::responses::ev_apply_patch_custom_tool_call; +use core_test_support::responses::ev_apply_patch_function_call; +use core_test_support::responses::ev_completed; +use core_test_support::responses::sse; use std::fs; use std::process::Command; use tempfile::tempdir; @@ -55,15 +59,28 @@ async fn test_apply_patch_tool() -> anyhow::Result<()> { let tmp_cwd = tempdir().expect("failed to create temp dir"); let tmp_path = tmp_cwd.path().to_path_buf(); - run_e2e_exec_test( - tmp_cwd.path(), - vec![ - include_str!("../fixtures/sse_apply_patch_add.json").to_string(), - include_str!("../fixtures/sse_apply_patch_update.json").to_string(), - include_str!("../fixtures/sse_response_completed.json").to_string(), - ], - ) - .await; + let add_patch = r#"*** Begin Patch +*** Add File: test.md ++Hello world +*** End Patch"#; + let update_patch = r#"*** Begin Patch +*** Update File: test.md +@@ +-Hello world ++Final text +*** End Patch"#; + let response_streams = vec![ + sse(vec![ + ev_apply_patch_custom_tool_call("request_0", add_patch), + ev_completed("request_0"), + ]), + sse(vec![ + ev_apply_patch_function_call("request_1", update_patch), + ev_completed("request_1"), + ]), + sse(vec![ev_completed("request_2")]), + ]; + run_e2e_exec_test(tmp_cwd.path(), response_streams).await; let final_path = tmp_path.join("test.md"); let contents = std::fs::read_to_string(&final_path) @@ -86,15 +103,31 @@ async fn test_apply_patch_freeform_tool() -> anyhow::Result<()> { } let tmp_cwd = tempdir().expect("failed to create temp dir"); - run_e2e_exec_test( - tmp_cwd.path(), - vec![ - include_str!("../fixtures/sse_apply_patch_freeform_add.json").to_string(), - include_str!("../fixtures/sse_apply_patch_freeform_update.json").to_string(), - include_str!("../fixtures/sse_response_completed.json").to_string(), - ], - ) - .await; + let freeform_add_patch = r#"*** Begin Patch +*** Add File: app.py ++class BaseClass: ++ def method(): ++ return False +*** End Patch"#; + let freeform_update_patch = r#"*** Begin Patch +*** Update File: app.py +@@ def method(): +- return False ++ ++ return True +*** End Patch"#; + let response_streams = vec![ + sse(vec![ + ev_apply_patch_custom_tool_call("request_0", freeform_add_patch), + ev_completed("request_0"), + ]), + sse(vec![ + ev_apply_patch_custom_tool_call("request_1", freeform_update_patch), + ev_completed("request_1"), + ]), + sse(vec![ev_completed("request_2")]), + ]; + run_e2e_exec_test(tmp_cwd.path(), response_streams).await; // Verify final file contents let final_path = tmp_cwd.path().join("app.py"); diff --git a/codex-rs/exec/tests/suite/common.rs b/codex-rs/exec/tests/suite/common.rs index 8c57e7afcb26..69c09b768a1b 100644 --- a/codex-rs/exec/tests/suite/common.rs +++ b/codex-rs/exec/tests/suite/common.rs @@ -4,7 +4,6 @@ use anyhow::Context; use assert_cmd::prelude::*; -use core_test_support::load_sse_fixture_with_id_from_str; use std::path::Path; use std::process::Command; use std::sync::atomic::AtomicUsize; @@ -27,10 +26,7 @@ impl Respond for SeqResponder { match self.responses.get(call_num) { Some(body) => wiremock::ResponseTemplate::new(200) .insert_header("content-type", "text/event-stream") - .set_body_raw( - load_sse_fixture_with_id_from_str(body, &format!("request_{call_num}")), - "text/event-stream", - ), + .set_body_string(body.clone()), None => panic!("no response for {call_num}"), } } diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 89daee8272ae..e9d77890c318 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -640,6 +640,21 @@ impl TokenUsageInfo { #[derive(Debug, Clone, Deserialize, Serialize, TS)] pub struct TokenCountEvent { pub info: Option, + pub rate_limits: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize, TS)] +pub struct RateLimitSnapshotEvent { + /// Percentage (0-100) of the primary window that has been consumed. + pub primary_used_percent: f64, + /// Percentage (0-100) of the protection window that has been consumed. + pub weekly_used_percent: f64, + /// Size of the primary window relative to weekly (0-100). + pub primary_to_weekly_ratio_percent: f64, + /// Rolling window duration for the primary limit, in minutes. + pub primary_window_minutes: u64, + /// Rolling window duration for the weekly limit, in minutes. + pub weekly_window_minutes: u64, } /// Payload for `ReplayHistory` containing prior `ResponseItem`s and optional events. diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap new file mode 100644 index 000000000000..fa37b2201ff7 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_placeholder.snap @@ -0,0 +1,10 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + Real usage data is not available yet. +[dim] Send a message to Codex, then run /limits again.[/] diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap new file mode 100644 index 000000000000..ced446683758 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_basic.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]30.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]60.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap new file mode 100644 index 000000000000..defc5f213c3b --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_hourly_remaining.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]0.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/][dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap new file mode 100644 index 000000000000..86c82d9247bf --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_mixed_usage.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]20.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]20.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/] [dark-gray](>_)[/][dim]│[/] + [dim]│[/][green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] [green](>_)[/] (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap new file mode 100644 index 000000000000..a1650545b61e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__limits_snapshot_weekly_heavy.snap @@ -0,0 +1,29 @@ +--- +source: tui/src/chatwidget/tests.rs +expression: visual +--- +[magenta]/limits[/] + +[bold]Rate limit usage snapshot[/] +[dim] Tip: run `/limits` right after Codex replies for freshest numbers.[/] + • Hourly limit[dim] (≈5 hours window)[/]: [dark-gray+bold]98.0% used[/] + • Weekly limit[dim] (≈1 week window)[/]: [dark-gray+bold]0.0% used[/] +[green] Within current limits[/] + +[dim] ╭─────────────────────────────────────────────────╮[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] + [dim]│[/](>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_) (>_)[dim]│[/] +[dim] ╰─────────────────────────────────────────────────╯[/] + +[bold]Legend[/] + • [dark-gray+bold]Dark gray[/] = weekly usage so far + • [green+bold]Green[/] = hourly capacity still available + • [bold]Default[/] = weekly capacity beyond the hourly window