mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
[codex] Add workspace owner usage nudge UI (#18221)
## Summary Third PR in the split from #17956. Stacked on #18220. - shows workspace-owner/member-specific rate-limit messages behind `workspace_owner_usage_nudge` - prompts workspace members to notify the owner or request a usage-limit increase - sends the confirmed nudge through the app-server API and renders completion feedback - adds focused TUI snapshot coverage for prompts and completion states - feature gate ## Validation - `cargo test -p codex-backend-client` - `cargo test -p codex-app-server-protocol` - `cargo test -p codex-app-server rate_limits` - `cargo test -p codex-tui workspace_` - `cargo test -p codex-tui status_` - `just fmt` - `just fix -p codex-backend-client` - `just fix -p codex-app-server-protocol` - `just fix -p codex-app-server` - `just fix -p codex-tui`
This commit is contained in:
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: popup
|
||||
---
|
||||
You've reached your workspace credit limit
|
||||
Your workspace is out of credits. Ask your workspace owner to add more. Notify owner?
|
||||
|
||||
1. Yes (y)
|
||||
› 2. No (default) (n)
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: popup
|
||||
---
|
||||
Usage limit reached
|
||||
Request a limit increase from your owner to continue using codex. Request increase?
|
||||
|
||||
1. Yes (y)
|
||||
› 2. No (default) (n)
|
||||
|
||||
Press enter to confirm or esc to go back
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: "rendered_cases.join(\"\\n---\\n\")"
|
||||
---
|
||||
• Workspace owner notified.
|
||||
|
||||
---
|
||||
• Workspace owner was already notified recently.
|
||||
|
||||
---
|
||||
• Could not notify your workspace owner. Please try again.
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: "rendered_cases.join(\"\\n---\\n\")"
|
||||
---
|
||||
■ You're out of credits. Your workspace is out of credits. Add credits to continue using Codex.
|
||||
|
||||
---
|
||||
■ Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex.
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/status_and_layout.rs
|
||||
expression: "rendered_cases.join(\"\\n---\\n\")"
|
||||
---
|
||||
• Limit increase requested.
|
||||
|
||||
---
|
||||
• A limit increase was already requested recently.
|
||||
|
||||
---
|
||||
• Could not request a limit increase. Please try again.
|
||||
@@ -33,6 +33,8 @@ pub(super) use crate::test_support::test_path_buf;
|
||||
pub(super) use crate::test_support::test_path_display;
|
||||
pub(super) use crate::tui::FrameRequester;
|
||||
pub(super) use assert_matches::assert_matches;
|
||||
pub(super) use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
pub(super) use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
|
||||
pub(super) use codex_app_server_protocol::AdditionalFileSystemPermissions as AppServerAdditionalFileSystemPermissions;
|
||||
pub(super) use codex_app_server_protocol::AdditionalNetworkPermissions as AppServerAdditionalNetworkPermissions;
|
||||
pub(super) use codex_app_server_protocol::AdditionalPermissionProfile as AppServerAdditionalPermissionProfile;
|
||||
@@ -170,6 +172,8 @@ pub(super) use codex_protocol::protocol::Op;
|
||||
pub(super) use codex_protocol::protocol::PatchApplyBeginEvent;
|
||||
pub(super) use codex_protocol::protocol::PatchApplyEndEvent;
|
||||
pub(super) use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus;
|
||||
pub(super) use codex_protocol::protocol::RateLimitReachedType;
|
||||
pub(super) use codex_protocol::protocol::RateLimitSnapshot;
|
||||
pub(super) use codex_protocol::protocol::RateLimitWindow;
|
||||
pub(super) use codex_protocol::protocol::ReadOnlyAccess;
|
||||
pub(super) use codex_protocol::protocol::RealtimeConversationClosedEvent;
|
||||
|
||||
@@ -201,8 +201,10 @@ pub(super) async fn make_chatwidget_manual(
|
||||
refreshing_status_outputs: Vec::new(),
|
||||
next_status_refresh_request_id: 0,
|
||||
plan_type: None,
|
||||
codex_rate_limit_reached_type: None,
|
||||
rate_limit_warnings: RateLimitWarningState::default(),
|
||||
rate_limit_switch_prompt: RateLimitSwitchPromptState::default(),
|
||||
add_credits_nudge_email_in_flight: None,
|
||||
adaptive_chunking: crate::streaming::chunking::AdaptiveChunkingPolicy::default(),
|
||||
stream_controller: None,
|
||||
plan_stream_controller: None,
|
||||
|
||||
@@ -501,6 +501,370 @@ async fn rate_limit_switch_prompt_popup_snapshot() {
|
||||
assert_chatwidget_snapshot!("rate_limit_switch_prompt_popup", popup);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_owner_usage_nudge_flag_disabled_keeps_generic_rate_limit_error() {
|
||||
{
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut limits = snapshot(/*percent*/ 100.0);
|
||||
limits.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<String>();
|
||||
assert!(
|
||||
rendered.contains("Usage limit reached."),
|
||||
"rendered: {rendered}"
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
let mut limits = snapshot(/*percent*/ 100.0);
|
||||
limits.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
!popup.contains("Request a limit increase from your owner"),
|
||||
"popup: {popup}"
|
||||
);
|
||||
assert_no_owner_nudge_or_rate_limit_refresh(&mut rx);
|
||||
}
|
||||
}
|
||||
|
||||
fn enable_workspace_owner_usage_nudge(chat: &mut ChatWidget) {
|
||||
chat.set_feature_enabled(Feature::WorkspaceOwnerUsageNudge, /*enabled*/ true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_member_credits_depleted_prompts_and_sends_credits() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
let mut limits = snapshot(/*percent*/ 100.0);
|
||||
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::Generic,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 90);
|
||||
assert_chatwidget_snapshot!("workspace_member_credits_depleted_prompt", popup);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
let event = next_send_add_credits_nudge_email_event(&mut rx);
|
||||
assert_eq!(event, AddCreditsNudgeCreditType::Credits);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_member_usage_limit_prompts_and_sends_usage_limit() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
let mut limits = snapshot(/*percent*/ 100.0);
|
||||
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert_chatwidget_snapshot!("workspace_member_usage_limit_prompt", popup);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
let event = next_send_add_credits_nudge_email_event(&mut rx);
|
||||
assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn header_rate_limit_snapshot_preserves_member_limit_type_for_error_prompt() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
let mut usage_limits = snapshot(/*percent*/ 100.0);
|
||||
usage_limits.rate_limit_reached_type =
|
||||
Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rate_limit_snapshot(Some(usage_limits));
|
||||
|
||||
// Turn-failure snapshots are derived from response headers and do not carry
|
||||
// the backend-classified reached type. They arrive before the Error event.
|
||||
let mut header_limits = snapshot(/*percent*/ 100.0);
|
||||
header_limits.rate_limit_reached_type = None;
|
||||
chat.on_rate_limit_snapshot(Some(header_limits));
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Request a limit increase from your owner"),
|
||||
"popup: {popup}"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
let event = next_send_add_credits_nudge_email_event(&mut rx);
|
||||
assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn usage_limit_error_remaps_stale_member_credits_state_to_usage_limit_prompt() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
let mut limits = snapshot(/*percent*/ 100.0);
|
||||
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Request a limit increase from your owner"),
|
||||
"popup: {popup}"
|
||||
);
|
||||
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Char('y'), KeyModifiers::NONE));
|
||||
let event = next_send_add_credits_nudge_email_event(&mut rx);
|
||||
assert_eq!(event, AddCreditsNudgeCreditType::UsageLimit);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_owner_limit_states_do_not_prompt_for_owner_nudge() {
|
||||
for (limit_type, error_kind) in [
|
||||
(
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
|
||||
RateLimitErrorKind::Generic,
|
||||
),
|
||||
(
|
||||
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
),
|
||||
(
|
||||
RateLimitReachedType::RateLimitReached,
|
||||
RateLimitErrorKind::Generic,
|
||||
),
|
||||
] {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
let mut limits = snapshot(/*percent*/ 100.0);
|
||||
limits.rate_limit_reached_type = Some(limit_type);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
|
||||
chat.on_rate_limit_error(error_kind, "Usage limit reached.".to_string());
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 90);
|
||||
assert!(!popup.contains("workspace owner"));
|
||||
assert_no_owner_nudge_or_rate_limit_refresh(&mut rx);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_owner_limit_states_render_state_specific_messages() {
|
||||
let cases = [
|
||||
(
|
||||
RateLimitReachedType::WorkspaceOwnerCreditsDepleted,
|
||||
RateLimitErrorKind::Generic,
|
||||
"You're out of credits. Your workspace is out of credits. Add credits to continue using Codex.",
|
||||
),
|
||||
(
|
||||
RateLimitReachedType::WorkspaceOwnerUsageLimitReached,
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached. You've reached your usage limit. Increase your limits to continue using codex.",
|
||||
),
|
||||
];
|
||||
|
||||
let mut rendered_cases = Vec::new();
|
||||
for (limit_type, error_kind, expected) in cases {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
let mut limits = snapshot(/*percent*/ 100.0);
|
||||
limits.rate_limit_reached_type = Some(limit_type);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
|
||||
chat.on_rate_limit_error(error_kind, "Usage limit reached.".to_string());
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<String>();
|
||||
assert!(rendered.contains(expected), "rendered: {rendered}");
|
||||
rendered_cases.push(rendered);
|
||||
}
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"workspace_owner_limit_state_messages",
|
||||
rendered_cases.join("\n---\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn missing_rate_limit_reached_type_does_not_prompt_or_refresh() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 100.0)));
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 90);
|
||||
assert!(!popup.contains("workspace owner"));
|
||||
assert_no_owner_nudge_or_rate_limit_refresh(&mut rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_owner_nudge_default_no_dismisses_without_sending() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
let mut limits = snapshot(/*percent*/ 100.0);
|
||||
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberCreditsDepleted);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::Generic,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
|
||||
assert_no_owner_nudge_or_rate_limit_refresh(&mut rx);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_owner_nudge_reappears_after_dismissing_no() {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
let mut limits = snapshot(/*percent*/ 100.0);
|
||||
limits.rate_limit_reached_type = Some(RateLimitReachedType::WorkspaceMemberUsageLimitReached);
|
||||
chat.on_rate_limit_snapshot(Some(limits));
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
|
||||
assert_no_owner_nudge_or_rate_limit_refresh(&mut rx);
|
||||
|
||||
chat.on_rate_limit_error(
|
||||
RateLimitErrorKind::UsageLimit,
|
||||
"Usage limit reached.".to_string(),
|
||||
);
|
||||
let popup = render_bottom_popup(&chat, /*width*/ 100);
|
||||
assert!(
|
||||
popup.contains("Request a limit increase from your owner"),
|
||||
"popup: {popup}"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_owner_credits_nudge_completion_renders_feedback() {
|
||||
let cases = [
|
||||
(
|
||||
Ok(AddCreditsNudgeEmailStatus::Sent),
|
||||
"Workspace owner notified.",
|
||||
),
|
||||
(
|
||||
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
|
||||
"Workspace owner was already notified recently.",
|
||||
),
|
||||
(
|
||||
Err("request failed".to_string()),
|
||||
"Could not notify your workspace owner. Please try again.",
|
||||
),
|
||||
];
|
||||
|
||||
let mut rendered_cases = Vec::new();
|
||||
for (result, expected) in cases {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::Credits);
|
||||
chat.finish_add_credits_nudge_email_request(result);
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<String>();
|
||||
assert!(rendered.contains(expected), "rendered: {rendered}");
|
||||
rendered_cases.push(rendered);
|
||||
}
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"workspace_owner_credits_nudge_completion_feedback",
|
||||
rendered_cases.join("\n---\n")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_owner_usage_limit_nudge_completion_renders_feedback() {
|
||||
let cases = [
|
||||
(
|
||||
Ok(AddCreditsNudgeEmailStatus::Sent),
|
||||
"Limit increase requested.",
|
||||
),
|
||||
(
|
||||
Ok(AddCreditsNudgeEmailStatus::CooldownActive),
|
||||
"A limit increase was already requested recently.",
|
||||
),
|
||||
(
|
||||
Err("request failed".to_string()),
|
||||
"Could not request a limit increase. Please try again.",
|
||||
),
|
||||
];
|
||||
|
||||
let mut rendered_cases = Vec::new();
|
||||
for (result, expected) in cases {
|
||||
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
enable_workspace_owner_usage_nudge(&mut chat);
|
||||
chat.start_add_credits_nudge_email_request(AddCreditsNudgeCreditType::UsageLimit);
|
||||
chat.finish_add_credits_nudge_email_request(result);
|
||||
let rendered = drain_insert_history(&mut rx)
|
||||
.into_iter()
|
||||
.map(|lines| lines_to_single_string(&lines))
|
||||
.collect::<String>();
|
||||
assert!(rendered.contains(expected), "rendered: {rendered}");
|
||||
rendered_cases.push(rendered);
|
||||
}
|
||||
|
||||
assert_chatwidget_snapshot!(
|
||||
"workspace_owner_usage_limit_nudge_completion_feedback",
|
||||
rendered_cases.join("\n---\n")
|
||||
);
|
||||
}
|
||||
|
||||
fn next_send_add_credits_nudge_email_event(
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) -> AddCreditsNudgeCreditType {
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
if let AppEvent::SendAddCreditsNudgeEmail { credit_type } = event {
|
||||
return credit_type;
|
||||
}
|
||||
}
|
||||
panic!("expected SendAddCreditsNudgeEmail app event");
|
||||
}
|
||||
|
||||
fn assert_no_owner_nudge_or_rate_limit_refresh(
|
||||
rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,
|
||||
) {
|
||||
while let Ok(event) = rx.try_recv() {
|
||||
assert!(
|
||||
!matches!(
|
||||
event,
|
||||
AppEvent::SendAddCreditsNudgeEmail { .. } | AppEvent::RefreshRateLimits { .. }
|
||||
),
|
||||
"unexpected event: {event:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn streaming_final_answer_keeps_task_running_state() {
|
||||
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
|
||||
Reference in New Issue
Block a user