Option to Notify Workspace Owner When Usage Limit is Reached (#16969)

## Summary
- Replace the manual `/notify-owner` flow with an inline confirmation
prompt when a usage-based workspace member hits a credits-depleted
limit.
- Fetch the current workspace role from the live ChatGPT
`accounts/check/v4-2023-04-27` endpoint so owner/member behavior matches
the desktop and web clients.
- Keep owner, member, and spend-cap messaging distinct so we only offer
the owner nudge when the workspace is actually out of credits.

## What Changed
- `backend-client`
- Added a typed fetch for the current account role from
`accounts/check`.
  - Mapped backend role values into a Rust workspace-role enum.
- `app-server` and protocol
  - Added `workspaceRole` to `account/read` and `account/updated`.
- Derived `isWorkspaceOwner` from the live role, with a fallback to the
cached token claim when the role fetch is unavailable.
- `tui`
  - Removed the explicit `/notify-owner` slash command.
- When a member is blocked because the workspace is out of credits, the
error now prompts:
- `Your workspace is out of credits. Request more from your workspace
owner? [y/N]`
  - Choosing `y` sends the existing owner-notification request.
- Choosing `n`, pressing `Esc`, or accepting the default selection
dismisses the prompt without sending anything.
- Selection popups now honor explicit item shortcuts, which is how the
`y` / `n` interaction is wired.

## Reviewer Notes
- The main behavior change is scoped to usage-based workspace members
whose workspace credits are depleted.
- Spend-cap reached should not show the owner-notification prompt.
- Owners and admins should continue to see `/usage` guidance instead of
the member prompt.
- The live role fetch is best-effort; if it fails, we fall back to the
existing token-derived ownership signal.

## Testing
- Manual verification
  - Workspace owner does not see the member prompt.
- Workspace member with depleted credits sees the confirmation prompt
and can send the nudge with `y`.
- Workspace member with spend cap reached does not see the
owner-notification prompt.

### Workspace member out of usage

https://github.com/user-attachments/assets/341ac396-eff4-4a7f-bf0c-60660becbea1

### Workspace owner
<img width="1728" height="1086" alt="Screenshot 2026-04-09 at 11 48
22 AM"
src="https://github.com/user-attachments/assets/06262a45-e3fc-4cc4-8326-1cbedad46ed6"
/>
This commit is contained in:
richardopenai
2026-04-09 21:15:17 -07:00
committed by GitHub
parent 36712d8546
commit 9f2a585153
82 changed files with 3233 additions and 60 deletions

View File

@@ -136,6 +136,7 @@ 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);
@@ -319,6 +320,7 @@ 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);
@@ -370,6 +372,7 @@ 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);
@@ -419,6 +422,7 @@ 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);
@@ -468,6 +472,7 @@ 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);
@@ -515,6 +520,7 @@ 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);
@@ -620,6 +626,7 @@ 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);
@@ -733,6 +740,7 @@ 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);
@@ -803,6 +811,7 @@ 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);
@@ -856,6 +865,69 @@ 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
@@ -912,6 +984,7 @@ 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
@@ -982,6 +1055,7 @@ 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);
@@ -1052,6 +1126,7 @@ 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);