Revert "Option to Notify Workspace Owner When Usage Limit is Reached" (#17391)

Reverts openai/codex#16969

#sev3-2026-04-10-accountscheckversion-500s-for-openai-workspace-7300
This commit is contained in:
Shijie Rao
2026-04-10 23:33:13 +00:00
committed by GitHub
parent a3be74143a
commit 930e5adb7e
82 changed files with 60 additions and 3233 deletions
-63
View File
@@ -538,9 +538,6 @@ impl ThreadEventStore {
ThreadBufferedEvent::Request(_)
| ThreadBufferedEvent::Notification(ServerNotification::HookStarted(_))
| ThreadBufferedEvent::Notification(ServerNotification::HookCompleted(_))
| ThreadBufferedEvent::Notification(
ServerNotification::AddCreditsNudgeEmailCompleted(_),
)
| ThreadBufferedEvent::FeedbackSubmission(_)
)
}
@@ -1100,8 +1097,6 @@ impl App {
feedback: self.feedback.clone(),
is_first_run: false,
status_account_display: self.chat_widget.status_account_display().cloned(),
initial_workspace_role: self.chat_widget.current_workspace_role(),
initial_is_workspace_owner: self.chat_widget.current_is_workspace_owner(),
initial_plan_type: self.chat_widget.current_plan_type(),
model: Some(self.chat_widget.current_model().to_string()),
startup_tooltip_override: None,
@@ -2432,10 +2427,6 @@ impl App {
.await?;
Ok(true)
}
AppCommandView::SendAddCreditsNudgeEmail => {
app_server.thread_add_credits_nudge_email(thread_id).await?;
Ok(true)
}
AppCommandView::ReloadUserConfig => {
app_server.reload_user_config().await?;
Ok(true)
@@ -3663,8 +3654,6 @@ impl App {
let has_chatgpt_account = bootstrap.has_chatgpt_account;
let requires_openai_auth = bootstrap.requires_openai_auth;
let status_account_display = bootstrap.status_account_display.clone();
let initial_workspace_role = bootstrap.workspace_role;
let initial_is_workspace_owner = bootstrap.is_workspace_owner;
let initial_plan_type = bootstrap.plan_type;
let session_telemetry = SessionTelemetry::new(
ThreadId::new(),
@@ -3714,8 +3703,6 @@ impl App {
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
initial_workspace_role,
initial_is_workspace_owner,
initial_plan_type,
model: Some(model.clone()),
startup_tooltip_override,
@@ -3750,8 +3737,6 @@ impl App {
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
initial_workspace_role,
initial_is_workspace_owner,
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
@@ -3791,8 +3776,6 @@ impl App {
feedback: feedback.clone(),
is_first_run,
status_account_display: status_account_display.clone(),
initial_workspace_role,
initial_is_workspace_owner,
initial_plan_type,
model: config.model.clone(),
startup_tooltip_override: None,
@@ -4577,23 +4560,6 @@ impl App {
}
}
},
AppEvent::NotifyWorkspaceOwner => {
if self.active_thread_id.is_some() {
self.chat_widget.start_notify_workspace_owner();
if let Err(err) = self
.submit_active_thread_op(app_server, Op::SendAddCreditsNudgeEmail.into())
.await
{
self.chat_widget
.finish_notify_workspace_owner(Err(err.to_string()));
return Err(err);
}
} else {
self.chat_widget.finish_notify_workspace_owner(Err(
"no active thread is available".to_string(),
));
}
}
AppEvent::ConnectorsLoaded { result, is_final } => {
self.chat_widget.on_connectors_loaded(result, is_final);
}
@@ -6374,8 +6340,6 @@ mod tests {
use crate::multi_agents::AgentPickerThreadEntry;
use assert_matches::assert_matches;
use codex_app_server_protocol::AddCreditsNudgeEmailNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailResult;
use codex_app_server_protocol::AdditionalFileSystemPermissions;
use codex_app_server_protocol::AdditionalNetworkPermissions;
use codex_app_server_protocol::AdditionalPermissionProfile;
@@ -6763,8 +6727,6 @@ mod tests {
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: false,
status_account_display: None,
initial_workspace_role: None,
initial_is_workspace_owner: None,
initial_plan_type: None,
model: Some(model),
startup_tooltip_override: None,
@@ -9668,29 +9630,6 @@ guardian_approval = true
);
}
#[test]
fn thread_event_store_rebase_preserves_add_credits_nudge_email_completion() {
let thread_id = ThreadId::new();
let mut store = ThreadEventStore::new(/*capacity*/ 8);
let notification =
ServerNotification::AddCreditsNudgeEmailCompleted(AddCreditsNudgeEmailNotification {
thread_id: thread_id.to_string(),
result: AddCreditsNudgeEmailResult::Sent,
});
store.push_notification(notification.clone());
store.rebase_buffer_after_session_refresh();
let snapshot = store.snapshot();
let [ThreadBufferedEvent::Notification(actual)] = snapshot.events.as_slice() else {
panic!("expected add-credits nudge email completion notification");
};
assert_eq!(
serde_json::to_value(actual).expect("notification should serialize"),
serde_json::to_value(notification).expect("notification should serialize"),
);
}
#[test]
fn build_feedback_upload_params_includes_thread_id_and_rollout_path() {
let thread_id = ThreadId::new();
@@ -10752,8 +10691,6 @@ guardian_approval = true
feedback: app.feedback.clone(),
is_first_run: false,
status_account_display: app.chat_widget.status_account_display().cloned(),
initial_workspace_role: app.chat_widget.current_workspace_role(),
initial_is_workspace_owner: app.chat_widget.current_is_workspace_owner(),
initial_plan_type: app.chat_widget.current_plan_type(),
model: Some(app.chat_widget.current_model().to_string()),
startup_tooltip_override: None,
@@ -176,8 +176,6 @@ impl App {
notification.auth_mode,
notification.plan_type,
),
notification.workspace_role,
notification.is_workspace_owner,
notification.plan_type,
matches!(
notification.auth_mode,
@@ -402,9 +400,6 @@ fn server_notification_thread_target(
ServerNotification::ThreadRealtimeClosed(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::AddCreditsNudgeEmailCompleted(notification) => {
Some(notification.thread_id.as_str())
}
ServerNotification::SkillsChanged(_)
| ServerNotification::McpServerStatusUpdated(_)
| ServerNotification::McpServerOauthLoginCompleted(_)
@@ -1032,14 +1027,10 @@ fn app_server_codex_error_info_to_core(
#[cfg(test)]
mod tests {
use super::ServerNotificationThreadTarget;
use super::command_execution_started_event;
use super::server_notification_thread_events;
use super::server_notification_thread_target;
use super::thread_snapshot_events;
use super::turn_snapshot_events;
use codex_app_server_protocol::AddCreditsNudgeEmailNotification;
use codex_app_server_protocol::AddCreditsNudgeEmailResult;
use codex_app_server_protocol::AgentMessageDeltaNotification;
use codex_app_server_protocol::CodexErrorInfo;
use codex_app_server_protocol::CommandAction;
@@ -1070,19 +1061,6 @@ mod tests {
use pretty_assertions::assert_eq;
use std::path::PathBuf;
#[test]
fn add_credits_nudge_email_completion_targets_thread() {
let thread_id = ThreadId::new();
let target = server_notification_thread_target(
&ServerNotification::AddCreditsNudgeEmailCompleted(AddCreditsNudgeEmailNotification {
thread_id: thread_id.to_string(),
result: AddCreditsNudgeEmailResult::Sent,
}),
);
assert_eq!(target, ServerNotificationThreadTarget::Thread(thread_id));
}
#[test]
fn bridges_completed_agent_messages_from_server_notifications() {
let thread_id = "019cee8c-b993-7e33-88c0-014d4e62612d".to_string();
-2
View File
@@ -98,7 +98,6 @@ pub(crate) enum AppCommandView<'a> {
SetThreadName {
name: &'a str,
},
SendAddCreditsNudgeEmail,
Shutdown,
ThreadRollback {
num_turns: u32,
@@ -392,7 +391,6 @@ impl AppCommand {
num_turns: *num_turns,
},
Op::Review { review_request } => AppCommandView::Review { review_request },
Op::SendAddCreditsNudgeEmail => AppCommandView::SendAddCreditsNudgeEmail,
op => AppCommandView::Other(op),
}
}
-4
View File
@@ -170,10 +170,6 @@ pub(crate) enum AppEvent {
result: Result<Vec<RateLimitSnapshot>, String>,
},
/// Ask Codex to notify the current workspace owner that credits need to be
/// added.
NotifyWorkspaceOwner,
/// Result of prefetching connectors.
ConnectorsLoaded {
result: Result<ConnectorsSnapshot, String>,
+1 -51
View File
@@ -24,8 +24,6 @@ use codex_app_server_protocol::ReviewStartResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailParams;
use codex_app_server_protocol::ThreadAddCreditsNudgeEmailResponse;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadCompactStartParams;
@@ -105,8 +103,6 @@ pub(crate) struct AppServerBootstrap {
pub(crate) account_email: Option<String>,
pub(crate) auth_mode: Option<TelemetryAuthMode>,
pub(crate) status_account_display: Option<StatusAccountDisplay>,
pub(crate) workspace_role: Option<codex_app_server_protocol::WorkspaceRole>,
pub(crate) is_workspace_owner: Option<bool>,
pub(crate) plan_type: Option<codex_protocol::account::PlanType>,
/// Whether the configured model provider needs OpenAI-style auth. Combined
/// with `has_chatgpt_account` to decide if a startup rate-limit prefetch
@@ -221,8 +217,6 @@ impl AppServerSession {
account_email,
auth_mode,
status_account_display,
workspace_role,
is_workspace_owner,
plan_type,
feedback_audience,
has_chatgpt_account,
@@ -232,8 +226,6 @@ impl AppServerSession {
Some(TelemetryAuthMode::ApiKey),
Some(StatusAccountDisplay::ApiKey),
None,
None,
None,
FeedbackAudience::External,
false,
),
@@ -250,30 +242,17 @@ impl AppServerSession {
email: Some(email),
plan: Some(plan_type_display_name(plan_type)),
}),
account.workspace_role,
account.is_workspace_owner,
Some(plan_type),
feedback_audience,
true,
)
}
None => (
None,
None,
None,
None,
None,
None,
FeedbackAudience::External,
false,
),
None => (None, None, None, None, FeedbackAudience::External, false),
};
Ok(AppServerBootstrap {
account_email,
auth_mode,
status_account_display,
workspace_role,
is_workspace_owner,
plan_type,
requires_openai_auth: account.requires_openai_auth,
default_model,
@@ -581,24 +560,6 @@ impl AppServerSession {
Ok(())
}
pub(crate) async fn thread_add_credits_nudge_email(
&mut self,
thread_id: ThreadId,
) -> Result<()> {
let request_id = self.next_request_id();
let _: ThreadAddCreditsNudgeEmailResponse = self
.client
.request_typed(ClientRequest::ThreadAddCreditsNudgeEmail {
request_id,
params: ThreadAddCreditsNudgeEmailParams {
thread_id: thread_id.to_string(),
},
})
.await
.wrap_err("thread/addCreditsNudgeEmail failed in TUI")?;
Ok(())
}
pub(crate) async fn thread_background_terminals_clean(
&mut self,
thread_id: ThreadId,
@@ -1173,9 +1134,6 @@ pub(crate) fn app_server_rate_limit_snapshot_to_core(
primary: snapshot.primary.map(app_server_rate_limit_window_to_core),
secondary: snapshot.secondary.map(app_server_rate_limit_window_to_core),
credits: snapshot.credits.map(app_server_credits_snapshot_to_core),
spend_control: snapshot
.spend_control
.map(app_server_spend_control_snapshot_to_core),
plan_type: snapshot.plan_type,
}
}
@@ -1200,14 +1158,6 @@ fn app_server_credits_snapshot_to_core(
}
}
fn app_server_spend_control_snapshot_to_core(
snapshot: codex_app_server_protocol::SpendControlSnapshot,
) -> codex_protocol::protocol::SpendControlSnapshot {
codex_protocol::protocol::SpendControlSnapshot {
reached: snapshot.reached,
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -648,23 +648,7 @@ impl BottomPaneView for ListSelectionView {
&& !modifiers.contains(KeyModifiers::CONTROL)
&& !modifiers.contains(KeyModifiers::ALT) =>
{
if let Some((visible_idx, _)) = self
.filtered_indices
.iter()
.enumerate()
.find(|(_, actual_idx)| {
self.items.get(**actual_idx).is_some_and(|item| {
item.display_shortcut
.as_ref()
.is_some_and(|shortcut| shortcut.is_press(key_event))
&& item.disabled_reason.is_none()
&& !item.is_disabled
})
})
{
self.state.selected_idx = Some(visible_idx);
self.accept();
} else if let Some(idx) = c
if let Some(idx) = c
.to_digit(10)
.map(|d| d as usize)
.and_then(|d| d.checked_sub(1))
+6 -339
View File
@@ -67,7 +67,6 @@ use crate::terminal_title::clear_terminal_title;
use crate::terminal_title::set_terminal_title;
use crate::text_formatting::proper_join;
use crate::version::CODEX_CLI_VERSION;
use codex_app_server_protocol::AddCreditsNudgeEmailResult as AppServerAddCreditsNudgeEmailResult;
use codex_app_server_protocol::AppSummary;
use codex_app_server_protocol::CodexErrorInfo as AppServerCodexErrorInfo;
use codex_app_server_protocol::CollabAgentState as AppServerCollabAgentState;
@@ -92,7 +91,6 @@ use codex_app_server_protocol::Turn;
use codex_app_server_protocol::TurnCompletedNotification;
use codex_app_server_protocol::TurnPlanStepStatus;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::WorkspaceRole as AppServerWorkspaceRole;
use codex_chatgpt::connectors;
use codex_config::types::ApprovalsReviewer;
use codex_config::types::Notifications;
@@ -135,7 +133,6 @@ use codex_protocol::models::local_image_label_text;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::plan_tool::PlanItemArg as UpdatePlanItemArg;
use codex_protocol::plan_tool::StepStatus as UpdatePlanItemStatus;
use codex_protocol::protocol::AddCreditsNudgeEmailStatus;
#[cfg(test)]
use codex_protocol::protocol::AgentMessageDeltaEvent;
#[cfg(test)]
@@ -195,7 +192,6 @@ use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget;
use codex_protocol::protocol::SkillMetadata as ProtocolSkillMetadata;
use codex_protocol::protocol::SpendControlSnapshot;
#[cfg(test)]
use codex_protocol::protocol::StreamErrorEvent;
use codex_protocol::protocol::TerminalInteractionEvent;
@@ -561,8 +557,6 @@ pub(crate) struct ChatWidgetInit {
pub(crate) feedback: codex_feedback::CodexFeedback,
pub(crate) is_first_run: bool,
pub(crate) status_account_display: Option<StatusAccountDisplay>,
pub(crate) initial_workspace_role: Option<AppServerWorkspaceRole>,
pub(crate) initial_is_workspace_owner: Option<bool>,
pub(crate) initial_plan_type: Option<PlanType>,
pub(crate) model: Option<String>,
pub(crate) startup_tooltip_override: Option<String>,
@@ -609,32 +603,6 @@ enum RateLimitErrorKind {
Generic,
}
pub(crate) const CODEX_USAGE_SETTINGS_URL: &str = "https://chatgpt.com/codex/settings/usage";
const WORKSPACE_OWNER_NOTIFICATION_PROMPT: &str =
"Your workspace is out of credits. Request more from your workspace owner? [y/N]";
const WORKSPACE_OWNER_NOTIFICATION_TITLE: &str = "Request more credits from your workspace owner?";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UsageBasedWorkspaceRateLimitState {
OwnerCreditsDepleted,
OwnerSpendCapReached,
MemberCreditsDepleted,
MemberSpendCapReached,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum UsageBasedWorkspaceBlockKind {
CreditsDepleted,
SpendCapReached,
}
fn is_usage_based_workspace_plan(plan_type: PlanType) -> bool {
matches!(
plan_type,
PlanType::SelfServeBusinessUsageBased | PlanType::EnterpriseCbpUsageBased
)
}
#[cfg(test)]
fn core_rate_limit_error_kind(info: &CoreCodexErrorInfo) -> Option<RateLimitErrorKind> {
match info {
@@ -790,8 +758,6 @@ pub(crate) struct ChatWidget {
/// The currently active collaboration mask, if any.
active_collaboration_mask: Option<CollaborationModeMask>,
has_chatgpt_account: bool,
workspace_role: Option<AppServerWorkspaceRole>,
is_workspace_owner: Option<bool>,
model_catalog: Arc<ModelCatalog>,
session_telemetry: SessionTelemetry,
session_header: SessionHeader,
@@ -802,8 +768,6 @@ pub(crate) struct ChatWidget {
refreshing_status_outputs: Vec<(u64, StatusHistoryHandle)>,
next_status_refresh_request_id: u64,
plan_type: Option<PlanType>,
notify_workspace_owner_in_flight: bool,
pending_workspace_owner_notification_prompt: bool,
rate_limit_warnings: RateLimitWarningState,
rate_limit_switch_prompt: RateLimitSwitchPromptState,
adaptive_chunking: AdaptiveChunkingPolicy,
@@ -2721,15 +2685,6 @@ impl ChatWidget {
balance: credits.balance.clone(),
});
}
if snapshot.spend_control.is_none() {
snapshot.spend_control = self
.rate_limit_snapshots_by_limit_id
.get(&limit_id)
.and_then(|display| display.spend_control.as_ref())
.map(|spend_control| SpendControlSnapshot {
reached: spend_control.reached,
});
}
self.plan_type = snapshot.plan_type.or(self.plan_type);
@@ -2798,7 +2753,6 @@ impl ChatWidget {
} else {
self.rate_limit_snapshots_by_limit_id.clear();
}
self.maybe_show_pending_workspace_owner_notification_prompt();
self.refresh_status_line();
}
/// Finalize any active exec as failed and stop/clear agent-turn UI state.
@@ -2841,34 +2795,15 @@ impl ChatWidget {
}
fn on_error(&mut self, message: String) {
self.on_error_with_hint(message, /*hint*/ None);
}
fn on_error_with_hint(&mut self, message: String, hint: Option<String>) {
self.submit_pending_steers_after_interrupt = false;
self.finalize_turn();
self.add_to_history(history_cell::new_error_event_with_hint(message, hint));
self.add_to_history(history_cell::new_error_event(message));
self.request_redraw();
// After an error ends the turn, try sending the next queued input.
self.maybe_send_next_queued_input();
}
fn on_rate_limit_error(&mut self, message: String) {
if self.should_prefetch_rate_limits() {
self.request_rate_limit_refresh();
}
let guidance = self.usage_based_workspace_rate_limit_error_guidance();
let should_prompt_workspace_owner = self.should_prompt_workspace_owner_notification();
let hint = guidance.as_ref().and_then(|(_, hint)| hint.clone());
let message = guidance.map(|(message, _)| message).unwrap_or(message);
self.on_error_with_hint(message, hint);
if should_prompt_workspace_owner {
self.pending_workspace_owner_notification_prompt = true;
self.maybe_show_pending_workspace_owner_notification_prompt();
}
}
fn handle_non_retry_error(
&mut self,
message: String,
@@ -2885,7 +2820,7 @@ impl ChatWidget {
match info {
RateLimitErrorKind::ServerOverloaded => self.on_server_overloaded_error(message),
RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => {
self.on_rate_limit_error(message);
self.on_error(message)
}
}
} else {
@@ -4778,8 +4713,6 @@ impl ChatWidget {
feedback,
is_first_run,
status_account_display,
initial_workspace_role,
initial_is_workspace_owner,
initial_plan_type,
model,
startup_tooltip_override,
@@ -4841,8 +4774,6 @@ impl ChatWidget {
current_collaboration_mode,
active_collaboration_mask,
has_chatgpt_account,
workspace_role: initial_workspace_role,
is_workspace_owner: initial_is_workspace_owner,
model_catalog,
session_telemetry,
session_header: SessionHeader::new(header_model),
@@ -4853,8 +4784,6 @@ impl ChatWidget {
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
plan_type: initial_plan_type,
notify_workspace_owner_in_flight: false,
pending_workspace_owner_notification_prompt: false,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
adaptive_chunking: AdaptiveChunkingPolicy::default(),
@@ -5495,7 +5424,9 @@ impl ChatWidget {
}
SlashCommand::Status => {
if self.should_prefetch_rate_limits() {
let request_id = self.next_rate_limit_refresh_request_id();
let request_id = self.next_status_refresh_request_id;
self.next_status_refresh_request_id =
self.next_status_refresh_request_id.wrapping_add(1);
self.add_status_output(/*refreshing_rate_limits*/ true, Some(request_id));
self.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
@@ -6756,18 +6687,6 @@ impl ChatWidget {
);
}
}
ServerNotification::AddCreditsNudgeEmailCompleted(notification) => {
let result = match notification.result {
AppServerAddCreditsNudgeEmailResult::Sent => {
Ok(AddCreditsNudgeEmailStatus::Sent)
}
AppServerAddCreditsNudgeEmailResult::CooldownActive => {
Ok(AddCreditsNudgeEmailStatus::CooldownActive)
}
AppServerAddCreditsNudgeEmailResult::Failed { message } => Err(message),
};
self.finish_notify_workspace_owner(result);
}
ServerNotification::ThreadRealtimeSdp(notification) => {
if !from_replay {
self.on_realtime_conversation_sdp(notification.sdp);
@@ -7183,7 +7102,7 @@ impl ChatWidget {
self.on_server_overloaded_error(message)
}
RateLimitErrorKind::UsageLimit | RateLimitErrorKind::Generic => {
self.on_rate_limit_error(message)
self.on_error(message)
}
}
} else {
@@ -7239,9 +7158,6 @@ impl ChatWidget {
EventMsg::GetHistoryEntryResponse(ev) => self.handle_history_entry_response(ev),
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
EventMsg::AddCreditsNudgeEmailResponse(ev) => {
self.finish_notify_workspace_owner(ev.result);
}
EventMsg::SkillsUpdateAvailable => {
self.submit_op(AppCommand::list_skills(
Vec::new(),
@@ -9781,268 +9697,19 @@ impl ChatWidget {
self.plan_type
}
pub(crate) fn current_is_workspace_owner(&self) -> Option<bool> {
self.workspace_role
.map(|role| {
matches!(
role,
AppServerWorkspaceRole::AccountOwner | AppServerWorkspaceRole::AccountAdmin
)
})
.or(self.is_workspace_owner)
}
pub(crate) fn current_workspace_role(&self) -> Option<AppServerWorkspaceRole> {
self.workspace_role
}
pub(crate) fn has_chatgpt_account(&self) -> bool {
self.has_chatgpt_account
}
fn usage_based_workspace_rate_limit_state(&self) -> Option<UsageBasedWorkspaceRateLimitState> {
let block_kind = self.usage_based_workspace_block_kind()?;
let is_workspace_owner = self.current_is_workspace_owner()?;
Some(match (is_workspace_owner, block_kind) {
(true, UsageBasedWorkspaceBlockKind::CreditsDepleted) => {
UsageBasedWorkspaceRateLimitState::OwnerCreditsDepleted
}
(true, UsageBasedWorkspaceBlockKind::SpendCapReached) => {
UsageBasedWorkspaceRateLimitState::OwnerSpendCapReached
}
(false, UsageBasedWorkspaceBlockKind::CreditsDepleted) => {
UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted
}
(false, UsageBasedWorkspaceBlockKind::SpendCapReached) => {
UsageBasedWorkspaceRateLimitState::MemberSpendCapReached
}
})
}
fn usage_based_workspace_block_kind(&self) -> Option<UsageBasedWorkspaceBlockKind> {
let plan_type = self.plan_type?;
if !is_usage_based_workspace_plan(plan_type) {
return None;
}
let codex_snapshot = self.rate_limit_snapshots_by_limit_id.get("codex")?;
let credits_depleted = codex_snapshot
.credits
.as_ref()
.is_some_and(|credits| !credits.has_credits);
let spend_control_reached = codex_snapshot
.spend_control
.as_ref()
.map(|spend_control| spend_control.reached);
let blocked_state = match (credits_depleted, spend_control_reached) {
(true, _) => Some(true),
(false, Some(true)) => Some(false),
(false, Some(false)) => None,
// Older snapshots do not carry spend-control state, so preserve the
// historical interpretation for compatibility.
(false, None) => Some(false),
}?;
Some(match blocked_state {
true => UsageBasedWorkspaceBlockKind::CreditsDepleted,
false => UsageBasedWorkspaceBlockKind::SpendCapReached,
})
}
fn usage_based_workspace_rate_limit_error_guidance(&self) -> Option<(String, Option<String>)> {
if let Some(state) = self.usage_based_workspace_rate_limit_state() {
return match state {
UsageBasedWorkspaceRateLimitState::OwnerCreditsDepleted => Some((
"Your workspace is out of credits.".to_string(),
Some(format!(
"Visit {CODEX_USAGE_SETTINGS_URL} to add workspace credits and continue using Codex."
)),
)),
UsageBasedWorkspaceRateLimitState::OwnerSpendCapReached => Some((
"Your workspace has reached its spend cap.".to_string(),
Some(format!(
"Visit {CODEX_USAGE_SETTINGS_URL} to increase your workspace spend cap and continue using Codex."
)),
)),
UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted => {
Some((WORKSPACE_OWNER_NOTIFICATION_PROMPT.to_string(), None))
}
UsageBasedWorkspaceRateLimitState::MemberSpendCapReached => Some((
"Your workspace has reached its spend cap.".to_string(),
Some(format!(
"Ask an admin to increase your workspace spend cap. Visit {CODEX_USAGE_SETTINGS_URL} for usage settings."
)),
)),
};
}
match self.usage_based_workspace_block_kind()? {
UsageBasedWorkspaceBlockKind::CreditsDepleted => Some((
"Your workspace is out of credits.".to_string(),
Some(format!(
"If you're the workspace owner, visit {CODEX_USAGE_SETTINGS_URL} to add credits. Otherwise wait a moment while Codex refreshes your workspace status."
)),
)),
UsageBasedWorkspaceBlockKind::SpendCapReached => Some((
"Your workspace has reached its spend cap.".to_string(),
Some(format!(
"Ask an admin to increase the workspace spend cap, or visit {CODEX_USAGE_SETTINGS_URL} for usage settings."
)),
)),
}
}
fn next_rate_limit_refresh_request_id(&mut self) -> u64 {
let request_id = self.next_status_refresh_request_id;
self.next_status_refresh_request_id = self.next_status_refresh_request_id.wrapping_add(1);
request_id
}
fn send_rate_limit_refresh_request(&self, request_id: u64) {
self.app_event_tx.send(AppEvent::RefreshRateLimits {
origin: RateLimitRefreshOrigin::StatusCommand { request_id },
});
}
fn request_rate_limit_refresh(&mut self) -> u64 {
let request_id = self.next_rate_limit_refresh_request_id();
self.send_rate_limit_refresh_request(request_id);
request_id
}
fn missing_usage_based_workspace_rate_limit_snapshot(&self) -> bool {
self.should_prefetch_rate_limits()
&& self.plan_type.is_some()
&& !self.rate_limit_snapshots_by_limit_id.contains_key("codex")
}
fn should_prompt_workspace_owner_notification(&self) -> bool {
if self.notify_workspace_owner_in_flight {
return false;
}
if matches!(
self.usage_based_workspace_rate_limit_state(),
Some(UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted)
) {
return true;
}
let is_workspace_owner = self.current_is_workspace_owner();
if !self.plan_type.is_some_and(is_usage_based_workspace_plan)
|| is_workspace_owner == Some(true)
{
return false;
}
let block_kind = self.usage_based_workspace_block_kind();
block_kind != Some(UsageBasedWorkspaceBlockKind::SpendCapReached)
}
fn maybe_show_pending_workspace_owner_notification_prompt(&mut self) {
if !self.pending_workspace_owner_notification_prompt {
return;
}
if self.notify_workspace_owner_in_flight {
self.pending_workspace_owner_notification_prompt = false;
return;
}
if !self.bottom_pane.no_modal_or_popup_active() {
return;
}
match self.usage_based_workspace_rate_limit_state() {
Some(UsageBasedWorkspaceRateLimitState::MemberCreditsDepleted) => {
self.pending_workspace_owner_notification_prompt = false;
self.open_workspace_owner_notification_prompt();
}
Some(_) => {
self.pending_workspace_owner_notification_prompt = false;
}
None => {
if self.usage_based_workspace_block_kind()
== Some(UsageBasedWorkspaceBlockKind::CreditsDepleted)
&& self.current_is_workspace_owner().is_none()
{
return;
}
if !self.missing_usage_based_workspace_rate_limit_snapshot() {
self.pending_workspace_owner_notification_prompt = false;
}
}
}
}
fn open_workspace_owner_notification_prompt(&mut self) {
let items = vec![
SelectionItem {
name: "Yes".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('y'))),
actions: vec![Box::new(|tx| tx.send(AppEvent::NotifyWorkspaceOwner))],
dismiss_on_select: true,
..Default::default()
},
SelectionItem {
name: "No".to_string(),
display_shortcut: Some(key_hint::plain(KeyCode::Char('n'))),
dismiss_on_select: true,
..Default::default()
},
];
self.bottom_pane.show_selection_view(SelectionViewParams {
title: Some(WORKSPACE_OWNER_NOTIFICATION_TITLE.to_string()),
footer_hint: Some(standard_popup_hint_line()),
items,
initial_selected_idx: Some(1),
..Default::default()
});
}
pub(crate) fn start_notify_workspace_owner(&mut self) {
self.notify_workspace_owner_in_flight = true;
self.pending_workspace_owner_notification_prompt = false;
}
pub(crate) fn finish_notify_workspace_owner(
&mut self,
result: Result<AddCreditsNudgeEmailStatus, String>,
) {
self.notify_workspace_owner_in_flight = false;
self.pending_workspace_owner_notification_prompt = false;
match result {
Ok(AddCreditsNudgeEmailStatus::Sent) => {
self.add_info_message("Workspace owner notified.".to_string(), /*hint*/ None);
}
Ok(AddCreditsNudgeEmailStatus::CooldownActive) => {
self.add_info_message(
"Workspace owner was already notified recently.".to_string(),
/*hint*/ None,
);
}
Err(_) => {
self.add_error_message(
"Could not notify your workspace owner. Please try again.".to_string(),
);
}
}
}
pub(crate) fn update_account_state(
&mut self,
status_account_display: Option<StatusAccountDisplay>,
workspace_role: Option<AppServerWorkspaceRole>,
is_workspace_owner: Option<bool>,
plan_type: Option<PlanType>,
has_chatgpt_account: bool,
) {
self.status_account_display = status_account_display;
self.workspace_role = workspace_role;
self.is_workspace_owner = is_workspace_owner;
self.plan_type = plan_type;
self.has_chatgpt_account = has_chatgpt_account;
self.maybe_show_pending_workspace_owner_notification_prompt();
self.bottom_pane
.set_connectors_enabled(self.connectors_enabled());
}
@@ -1,6 +0,0 @@
---
source: tui/src/chatwidget/tests/app_server.rs
assertion_line: 608
expression: rendered
---
■ Your workspace is out of credits. Request more from your workspace owner? [y/N]
-1
View File
@@ -128,7 +128,6 @@ pub(super) use codex_protocol::parse_command::ParsedCommand;
pub(super) use codex_protocol::plan_tool::PlanItemArg;
pub(super) use codex_protocol::plan_tool::StepStatus;
pub(super) use codex_protocol::plan_tool::UpdatePlanArgs;
pub(super) use codex_protocol::protocol::AddCreditsNudgeEmailStatus;
pub(super) use codex_protocol::protocol::AgentMessageDeltaEvent;
pub(super) use codex_protocol::protocol::AgentMessageEvent;
pub(super) use codex_protocol::protocol::AgentReasoningDeltaEvent;
@@ -569,117 +569,6 @@ async fn live_app_server_server_overloaded_error_renders_warning() {
assert!(!chat.bottom_pane.is_task_running());
}
#[tokio::test]
async fn live_app_server_usage_limit_error_shows_notify_owner_hint() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: false,
unlimited: false,
balance: None,
}),
spend_control: None,
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
chat.handle_server_notification(
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded.into()),
additional_details: None,
},
will_retry: false,
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
}),
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Your workspace is out of credits."),
"expected usage-limit error, got {rendered:?}"
);
assert!(
rendered.contains("Request more from your workspace owner? [y/N]"),
"expected workspace-owner prompt, got {rendered:?}"
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Request more credits from your workspace owner?"),
"expected workspace-owner confirmation popup, got {popup:?}"
);
assert_chatwidget_snapshot!(
"live_app_server_usage_limit_error_shows_notify_owner_hint",
rendered
);
}
#[tokio::test]
async fn live_app_server_usage_limit_error_shows_spend_cap_hint() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
}),
spend_control: Some(codex_protocol::protocol::SpendControlSnapshot { reached: true }),
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
chat.handle_server_notification(
ServerNotification::Error(ErrorNotification {
error: AppServerTurnError {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded.into()),
additional_details: None,
},
will_retry: false,
thread_id: "thread-1".to_string(),
turn_id: "turn-1".to_string(),
}),
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Your workspace has reached its spend cap."),
"expected spend-cap error, got {rendered:?}"
);
assert!(
!rendered.contains("Request more from your workspace owner? [y/N]"),
"expected spend-cap guidance instead of workspace-owner prompt, got {rendered:?}"
);
}
#[tokio::test]
async fn live_app_server_invalid_thread_name_update_is_ignored() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
@@ -104,7 +104,6 @@ pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
}
}
@@ -190,8 +189,6 @@ pub(super) async fn make_chatwidget_manual(
current_collaboration_mode,
active_collaboration_mask,
has_chatgpt_account: false,
workspace_role: None,
is_workspace_owner: None,
model_catalog,
session_telemetry,
session_header: SessionHeader::new(resolved_model.clone()),
@@ -202,8 +199,6 @@ pub(super) async fn make_chatwidget_manual(
refreshing_status_outputs: Vec::new(),
next_status_refresh_request_id: 0,
plan_type: None,
notify_workspace_owner_in_flight: false,
pending_workspace_owner_notification_prompt: false,
rate_limit_warnings: RateLimitWarningState::default(),
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(),
@@ -1245,8 +1245,6 @@ async fn collaboration_modes_defaults_to_code_on_startup() {
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
status_account_display: None,
initial_workspace_role: None,
initial_is_workspace_owner: None,
initial_plan_type: None,
model: Some(resolved_model.clone()),
startup_tooltip_override: None,
@@ -71,8 +71,6 @@ async fn experimental_mode_plan_is_ignored_on_startup() {
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
status_account_display: None,
initial_workspace_role: None,
initial_is_workspace_owner: None,
initial_plan_type: None,
model: Some(resolved_model.clone()),
startup_tooltip_override: None,
@@ -130,8 +130,6 @@ async fn helpers_are_available_and_do_not_panic() {
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
status_account_display: None,
initial_workspace_role: None,
initial_is_workspace_owner: None,
initial_plan_type: None,
model: Some(resolved_model),
startup_tooltip_override: None,
@@ -236,7 +234,6 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
unlimited: false,
balance: Some("17.5".to_string()),
}),
spend_control: None,
plan_type: None,
}));
let initial_balance = chat
@@ -256,7 +253,6 @@ async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
}));
@@ -295,7 +291,6 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: None,
}),
credits: None,
spend_control: None,
plan_type: Some(PlanType::Plus),
}));
assert_eq!(chat.plan_type, Some(PlanType::Plus));
@@ -314,7 +309,6 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: Some(234),
}),
credits: None,
spend_control: None,
plan_type: Some(PlanType::Pro),
}));
assert_eq!(chat.plan_type, Some(PlanType::Pro));
@@ -333,7 +327,6 @@ async fn rate_limit_snapshot_updates_and_retains_plan_type() {
resets_at: Some(567),
}),
credits: None,
spend_control: None,
plan_type: None,
}));
assert_eq!(chat.plan_type, Some(PlanType::Pro));
@@ -357,7 +350,6 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
unlimited: false,
balance: Some("5.00".to_string()),
}),
spend_control: None,
plan_type: Some(PlanType::Pro),
}));
@@ -371,7 +363,6 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: Some(PlanType::Pro),
}));
@@ -396,101 +387,6 @@ async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
assert!(other.credits.is_none());
}
#[tokio::test]
async fn core_usage_limit_error_shows_notify_owner_hint() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: false,
unlimited: false,
balance: None,
}),
spend_control: None,
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Your workspace is out of credits."),
"expected usage-limit error, got {rendered:?}"
);
assert!(
rendered.contains("Request more from your workspace owner? [y/N]"),
"expected workspace-owner prompt, got {rendered:?}"
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Request more credits from your workspace owner?"),
"expected workspace-owner confirmation popup, got {popup:?}"
);
}
#[tokio::test]
async fn core_usage_limit_error_shows_spend_cap_hint_when_reached() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
}),
spend_control: Some(codex_protocol::protocol::SpendControlSnapshot { reached: true }),
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Your workspace has reached its spend cap."),
"expected spend-cap hint, got {rendered:?}"
);
assert!(
!rendered.contains("Request more from your workspace owner? [y/N]"),
"expected spend-cap hint instead of workspace-owner prompt, got {rendered:?}"
);
}
#[tokio::test]
async fn rate_limit_switch_prompt_skips_when_on_lower_cost_model() {
let (mut chat, _, _) = make_chatwidget_manual(Some(NUDGE_MODEL_SLUG)).await;
@@ -519,7 +415,6 @@ async fn rate_limit_switch_prompt_skips_non_codex_limit() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
}));
@@ -120,212 +120,3 @@ async fn status_command_overlapping_refreshes_update_matching_cells_only() {
chat.finish_status_rate_limit_refresh(second_request_id);
assert!(chat.refreshing_status_outputs.is_empty());
}
#[tokio::test]
async fn usage_limit_error_requests_background_rate_limit_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })),
"expected usage-limit errors to trigger a background rate-limit refresh; events: {events:?}"
);
}
#[tokio::test]
async fn usage_limit_error_opens_workspace_owner_prompt_after_rate_limits_refresh() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
Some(false),
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })),
"expected usage-limit errors to request a refresh when rate limits are missing; events: {events:?}"
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: false,
unlimited: false,
balance: None,
}),
spend_control: None,
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Request more credits from your workspace owner?"),
"expected workspace-owner prompt after refresh, got: {popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::NotifyWorkspaceOwner));
}
#[tokio::test]
async fn usage_limit_error_opens_workspace_owner_prompt_after_async_workspace_role() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
set_chatgpt_auth(&mut chat);
chat.update_account_state(
/*status_account_display*/ None,
/*workspace_role*/ None,
/*is_workspace_owner*/ None,
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
chat.handle_codex_event(Event {
id: "usage-limit".to_string(),
msg: EventMsg::Error(ErrorEvent {
message: "The usage limit has been reached".to_string(),
codex_error_info: Some(CodexErrorInfo::UsageLimitExceeded),
}),
});
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
assert!(
events
.iter()
.any(|event| matches!(event, AppEvent::RefreshRateLimits { .. })),
"expected usage-limit errors to request a refresh when rate limits are missing; events: {events:?}"
);
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: false,
unlimited: false,
balance: None,
}),
spend_control: None,
plan_type: Some(PlanType::SelfServeBusinessUsageBased),
}));
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
!popup.contains("Request more credits from your workspace owner?"),
"expected no prompt before the async workspace role update, got: {popup}"
);
chat.update_account_state(
/*status_account_display*/ None,
Some(AppServerWorkspaceRole::StandardUser),
/*is_workspace_owner*/ None,
Some(PlanType::SelfServeBusinessUsageBased),
/*has_chatgpt_account*/ true,
);
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert!(
popup.contains("Request more credits from your workspace owner?"),
"expected workspace-owner prompt after async workspace role, got: {popup}"
);
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
assert_matches!(rx.try_recv(), Ok(AppEvent::NotifyWorkspaceOwner));
}
#[tokio::test]
async fn notify_workspace_owner_success_adds_confirmation_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.start_notify_workspace_owner();
chat.finish_notify_workspace_owner(Ok(AddCreditsNudgeEmailStatus::Sent));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one confirmation message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Workspace owner notified."),
"expected success message, got {rendered:?}"
);
assert!(
!chat.notify_workspace_owner_in_flight,
"notify-owner state should clear after success"
);
}
#[tokio::test]
async fn notify_workspace_owner_cooldown_adds_info_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.start_notify_workspace_owner();
chat.finish_notify_workspace_owner(Ok(AddCreditsNudgeEmailStatus::CooldownActive));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one cooldown message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Workspace owner was already notified recently."),
"expected cooldown message, got {rendered:?}"
);
assert!(
!chat.notify_workspace_owner_in_flight,
"notify-owner state should clear after cooldown"
);
}
#[tokio::test]
async fn notify_workspace_owner_error_adds_retry_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.start_notify_workspace_owner();
chat.finish_notify_workspace_owner(Err("backend failed".to_string()));
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one error message");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("Could not notify your workspace owner. Please try again."),
"expected retry message, got {rendered:?}"
);
assert!(
!chat.notify_workspace_owner_in_flight,
"notify-owner state should clear after errors"
);
}
+1 -8
View File
@@ -2166,17 +2166,10 @@ pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHist
}
pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
new_error_event_with_hint(message, /*hint*/ None)
}
pub(crate) fn new_error_event_with_hint(message: String, hint: Option<String>) -> PlainHistoryCell {
// Use a hair space (U+200A) to create a subtle, near-invisible separation
// before the text. VS16 is intentionally omitted to keep spacing tighter
// in terminals like Ghostty.
let mut lines: Vec<Line<'static>> = vec![vec![format!("{message}").red()].into()];
if let Some(hint) = hint {
lines.push(vec![" ".into(), hint.dark_gray()].into());
}
let lines: Vec<Line<'static>> = vec![vec![format!("{message}").red()].into()];
PlainHistoryCell { lines }
}
+1 -3
View File
@@ -891,9 +891,7 @@ pub async fn run_main(
// use RUST_LOG env var, default to info for codex crates.
let env_filter = || {
EnvFilter::try_from_default_env().unwrap_or_else(|_| {
EnvFilter::new(
"codex_core=info,codex_tui=info,codex_rmcp_client=info,codex_app_server=info",
)
EnvFilter::new("codex_core=info,codex_tui=info,codex_rmcp_client=info")
})
};
+1 -66
View File
@@ -16,7 +16,6 @@ use chrono::Utc;
use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot;
use codex_protocol::protocol::RateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow;
use codex_protocol::protocol::SpendControlSnapshot as CoreSpendControlSnapshot;
const STATUS_LIMIT_BAR_SEGMENTS: usize = 20;
const STATUS_LIMIT_BAR_FILLED: &str = "";
@@ -99,8 +98,6 @@ pub(crate) struct RateLimitSnapshotDisplay {
pub secondary: Option<RateLimitWindowDisplay>,
/// Optional credits metadata when available.
pub credits: Option<CreditsSnapshotDisplay>,
/// Optional spend-control metadata for usage-based workspace plans.
pub spend_control: Option<SpendControlSnapshotDisplay>,
}
/// Display-ready credits state extracted from protocol snapshots.
@@ -114,13 +111,6 @@ pub(crate) struct CreditsSnapshotDisplay {
pub balance: Option<String>,
}
/// Display-ready spend-control state extracted from protocol snapshots.
#[derive(Debug, Clone)]
pub(crate) struct SpendControlSnapshotDisplay {
/// Whether a workspace spend cap is currently blocking usage.
pub reached: bool,
}
/// Converts a protocol snapshot into UI-friendly display data.
///
/// Pass the timestamp from the same observation point as `snapshot`; supplying a significantly
@@ -150,10 +140,6 @@ pub(crate) fn rate_limit_snapshot_display_for_limit(
.as_ref()
.map(|window| RateLimitWindowDisplay::from_window(window, captured_at)),
credits: snapshot.credits.as_ref().map(CreditsSnapshotDisplay::from),
spend_control: snapshot
.spend_control
.as_ref()
.map(SpendControlSnapshotDisplay::from),
}
}
@@ -167,14 +153,6 @@ impl From<&CoreCreditsSnapshot> for CreditsSnapshotDisplay {
}
}
impl From<&CoreSpendControlSnapshot> for SpendControlSnapshotDisplay {
fn from(value: &CoreSpendControlSnapshot) -> Self {
Self {
reached: value.reached,
}
}
}
/// Builds display rows from a snapshot and marks stale data by capture age.
///
/// Callers should pass `Local::now()` for `now` at render time; using a cached timestamp can make
@@ -197,7 +175,7 @@ pub(crate) fn compose_rate_limit_data_many(
return StatusRateLimitData::Missing;
}
let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(4));
let mut rows = Vec::with_capacity(snapshots.len().saturating_mul(3));
let mut stale = false;
for snapshot in snapshots {
@@ -285,12 +263,6 @@ pub(crate) fn compose_rate_limit_data_many(
});
}
if let Some(spend_control) = snapshot.spend_control.as_ref()
&& let Some(row) = spend_control_status_row(spend_control)
{
rows.push(row);
}
if let Some(credits) = snapshot.credits.as_ref()
&& let Some(row) = credit_status_row(credits)
{
@@ -350,15 +322,6 @@ fn credit_status_row(credits: &CreditsSnapshotDisplay) -> Option<StatusRateLimit
})
}
fn spend_control_status_row(
spend_control: &SpendControlSnapshotDisplay,
) -> Option<StatusRateLimitRow> {
spend_control.reached.then(|| StatusRateLimitRow {
label: "Spend cap".to_string(),
value: StatusRateLimitValue::Text("Reached".to_string()),
})
}
fn format_credit_balance(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
@@ -386,7 +349,6 @@ mod tests {
use super::CreditsSnapshotDisplay;
use super::RateLimitSnapshotDisplay;
use super::RateLimitWindowDisplay;
use super::SpendControlSnapshotDisplay;
use super::StatusRateLimitData;
use super::compose_rate_limit_data_many;
use chrono::Local;
@@ -413,7 +375,6 @@ mod tests {
unlimited: false,
balance: Some("25".to_string()),
}),
spend_control: None,
};
let other = RateLimitSnapshotDisplay {
limit_name: "codex-other".to_string(),
@@ -425,7 +386,6 @@ mod tests {
unlimited: false,
balance: Some("99".to_string()),
}),
spend_control: None,
};
let rows = match compose_rate_limit_data_many(&[codex, other], now) {
@@ -463,7 +423,6 @@ mod tests {
window_minutes: None,
}),
credits: None,
spend_control: None,
};
let rows = match compose_rate_limit_data_many(&[other], now) {
@@ -480,28 +439,4 @@ mod tests {
]
);
}
#[test]
fn spend_cap_reached_renders_status_row_without_windows() {
let now = Local::now();
let snapshot = RateLimitSnapshotDisplay {
limit_name: "codex".to_string(),
captured_at: now,
primary: None,
secondary: None,
credits: Some(CreditsSnapshotDisplay {
has_credits: true,
unlimited: false,
balance: None,
}),
spend_control: Some(SpendControlSnapshotDisplay { reached: true }),
};
let rows = match compose_rate_limit_data_many(&[snapshot], now) {
StatusRateLimitData::Available(rows) => rows,
other => panic!("unexpected status: {other:?}"),
};
let labels: Vec<String> = rows.iter().map(|row| row.label.clone()).collect();
assert_eq!(labels, vec!["Spend cap".to_string()]);
}
}
@@ -1,22 +0,0 @@
---
source: tui/src/status/tests.rs
assertion_line: 963
expression: sanitized
---
/status
╭───────────────────────────────────────────────────────────────────────╮
│ >_ OpenAI Codex (v0.0.0) │
│ │
│ Visit https://chatgpt.com/codex/settings/usage for up-to-date │
│ information on rate limits and credits │
│ │
│ Model: gpt-5.1-codex-max (reasoning none, summaries auto) │
│ Directory: [[workspace]] │
│ Permissions: Custom (read-only, on-request) │
│ Agents.md: <none> │
│ │
│ Token usage: 750 total (500 input + 250 output) │
│ Context window: 100% left (750 used / 272K) │
│ Spend cap: Reached │
╰───────────────────────────────────────────────────────────────────────╯
-75
View File
@@ -136,7 +136,6 @@ async fn status_snapshot_includes_reasoning_details() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_200)),
}),
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -320,7 +319,6 @@ async fn status_snapshot_includes_monthly_limit() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -372,7 +370,6 @@ async fn status_snapshot_shows_unlimited_credits() {
unlimited: true,
balance: None,
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -422,7 +419,6 @@ async fn status_snapshot_shows_positive_credits() {
unlimited: false,
balance: Some("12.5".to_string()),
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -472,7 +468,6 @@ async fn status_snapshot_hides_zero_credits() {
unlimited: false,
balance: Some("0".to_string()),
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -520,7 +515,6 @@ async fn status_snapshot_hides_when_has_no_credits_flag() {
unlimited: true,
balance: None,
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -626,7 +620,6 @@ async fn status_snapshot_truncates_in_narrow_terminal() {
}),
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -740,7 +733,6 @@ async fn status_snapshot_shows_refreshing_limits_notice() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 2_700)),
}),
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -811,7 +803,6 @@ async fn status_snapshot_includes_credits_and_limits() {
unlimited: false,
balance: Some("37.5".to_string()),
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -865,69 +856,6 @@ async fn status_snapshot_shows_unavailable_limits_message() {
primary: None,
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
};
let captured_at = chrono::Local
.with_ymd_and_hms(2024, 6, 7, 8, 9, 10)
.single()
.expect("timestamp");
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
let model_slug = codex_core::test_support::get_model_offline(config.model.as_deref());
let token_info = token_info_for(&model_slug, &config, &usage);
let composite = new_status_output(
&config,
account_display.as_ref(),
Some(&token_info),
&usage,
&None,
/*thread_name*/ None,
/*forked_from*/ None,
Some(&rate_display),
None,
captured_at,
&model_slug,
/*collaboration_mode*/ None,
/*reasoning_effort_override*/ None,
);
let mut rendered_lines = render_lines(&composite.display_lines(/*width*/ 80));
if cfg!(windows) {
for line in &mut rendered_lines {
*line = line.replace('\\', "/");
}
}
let sanitized = sanitize_directory(rendered_lines).join("\n");
assert_snapshot!(sanitized);
}
#[tokio::test]
async fn status_snapshot_shows_spend_cap_reached_message() {
let temp_home = TempDir::new().expect("temp home");
let mut config = test_config(&temp_home).await;
config.model = Some("gpt-5.1-codex-max".to_string());
config.cwd = PathBuf::from("/workspace/tests").abs();
let account_display = test_status_account_display();
let usage = TokenUsage {
input_tokens: 500,
cached_input_tokens: 0,
output_tokens: 250,
reasoning_output_tokens: 0,
total_tokens: 750,
};
let snapshot = RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: None,
}),
spend_control: Some(codex_protocol::protocol::SpendControlSnapshot { reached: true }),
plan_type: None,
};
let captured_at = chrono::Local
@@ -984,7 +912,6 @@ async fn status_snapshot_treats_refreshing_empty_limits_as_unavailable() {
primary: None,
secondary: None,
credits: None,
spend_control: None,
plan_type: None,
};
let captured_at = chrono::Local
@@ -1055,7 +982,6 @@ async fn status_snapshot_shows_stale_limits_message() {
resets_at: Some(reset_at_from(&captured_at, /*seconds*/ 1_800)),
}),
credits: None,
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);
@@ -1126,7 +1052,6 @@ async fn status_snapshot_cached_limits_hide_credits_without_flag() {
unlimited: false,
balance: Some("80".to_string()),
}),
spend_control: None,
plan_type: None,
};
let rate_display = rate_limit_snapshot_display(&snapshot, captured_at);