Files
codex/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs
T

3389 lines
119 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
use crate::bottom_pane::goal_status_indicator_line;
use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG;
use pretty_assertions::assert_eq;
use ratatui::backend::TestBackend;
use serial_test::serial;
fn enable_test_ambient_pet(chat: &mut ChatWidget) {
chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
crate::pets::ImageProtocol::Kitty,
));
chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false);
}
/// Receiving a token usage update without usage clears the context indicator.
#[tokio::test]
async fn token_count_none_resets_context_indicator() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
let context_window = 13_000;
let pre_compact_tokens = 12_700;
handle_token_count(
&mut chat,
Some(make_token_info(pre_compact_tokens, context_window)),
);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(30));
handle_token_count(&mut chat, /*info*/ None);
assert_eq!(chat.bottom_pane.context_window_percent(), None);
}
#[tokio::test]
async fn app_server_cyber_policy_error_renders_dedicated_notice() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
handle_error(
&mut chat,
"server fallback message",
Some(CodexErrorInfo::CyberPolicy),
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("This chat was flagged for possible cybersecurity risk"));
assert!(rendered.contains("Trusted Access for Cyber"));
assert!(!rendered.contains("server fallback message"));
}
#[tokio::test]
async fn app_server_model_verification_renders_warning() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
handle_model_verification(
&mut chat,
vec![AppServerModelVerification::TrustedAccessForCyber],
);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1);
let rendered = lines_to_single_string(&cells[0]);
assert!(rendered.contains("multiple flags for possible cybersecurity risk"));
assert!(rendered.contains("extra safety checks are on"));
assert!(rendered.contains("Trusted Access for Cyber"));
assert!(rendered.contains("https://chatgpt.com/cyber"));
}
#[tokio::test]
async fn context_indicator_shows_used_tokens_when_window_unknown() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await;
chat.config.model_context_window = None;
let auto_compact_limit = 200_000;
chat.config.model_auto_compact_token_limit = Some(auto_compact_limit);
// No model window, so the indicator should fall back to showing tokens used.
let total_tokens = 106_000;
let token_usage = TokenUsage {
total_tokens,
..TokenUsage::default()
};
let token_info = TokenUsageInfo {
total_token_usage: token_usage.clone(),
last_token_usage: token_usage,
model_context_window: None,
};
handle_token_count(&mut chat, Some(token_info));
assert_eq!(chat.bottom_pane.context_window_percent(), None);
assert_eq!(
chat.bottom_pane.context_window_used_tokens(),
Some(total_tokens)
);
}
#[tokio::test]
async fn token_usage_update_uses_runtime_context_window() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.model_context_window = Some(1_000_000);
handle_token_count(
&mut chat,
Some(make_token_info(
/*total_tokens*/ 0, /*context_window*/ 950_000,
)),
);
assert_eq!(
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::ContextWindowSize),
Some("950K window".to_string())
);
assert_eq!(chat.bottom_pane.context_window_percent(), Some(100));
chat.add_status_output(
/*refreshing_rate_limits*/ false, /*request_id*/ None,
);
let cells = drain_insert_history(&mut rx);
let context_line = cells
.last()
.expect("status output inserted")
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.find(|line| line.contains("Context window"))
.expect("context window line");
assert!(
context_line.contains("950K"),
"expected /status to use runtime context window, got: {context_line}"
);
assert!(
!context_line.contains("1M"),
"expected /status to avoid raw config context window, got: {context_line}"
);
}
#[tokio::test]
async fn status_line_git_summary_items_render_values() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_git_summary = Some(StatusLineGitSummary {
pull_request: Some(crate::branch_summary::StatusLinePullRequest {
number: 20_252,
url: "https://github.com/openai/codex/pull/20252".to_string(),
}),
branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats {
additions: 143,
deletions: 22,
}),
});
assert_eq!(
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::PullRequestNumber),
Some("PR #20252".to_string())
);
assert_eq!(
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::BranchChanges),
Some("+143 -22".to_string())
);
}
#[tokio::test]
async fn raw_output_status_line_value_only_shows_when_enabled() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
assert_eq!(
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::RawOutput),
None
);
chat.set_raw_output_mode(/*enabled*/ true);
assert_eq!(
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::RawOutput),
Some("raw output".to_string())
);
}
#[tokio::test]
async fn status_line_branch_changes_render_no_changes() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_git_summary = Some(StatusLineGitSummary {
pull_request: None,
branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats {
additions: 0,
deletions: 0,
}),
});
assert_eq!(
chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::BranchChanges),
Some("No changes".to_string())
);
}
#[tokio::test]
async fn stale_status_line_git_summary_update_is_ignored() {
let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_git_summary_cwd = Some(PathBuf::from("/expected"));
chat.status_line_git_summary_pending = true;
chat.set_status_line_git_summary(
PathBuf::from("/other"),
StatusLineGitSummary {
pull_request: Some(crate::branch_summary::StatusLinePullRequest {
number: 20_252,
url: "https://github.com/openai/codex/pull/20252".to_string(),
}),
branch_change_stats: Some(crate::branch_summary::GitBranchDiffStats {
additions: 143,
deletions: 22,
}),
},
);
assert!(chat.status_line_git_summary.is_none());
assert!(!chat.status_line_git_summary_pending);
}
#[tokio::test]
async fn raw_output_mode_can_change_without_inserting_notice() {
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_raw_output_mode(/*enabled*/ true);
assert!(chat.raw_output_mode());
assert!(drain_insert_history(&mut rx).is_empty());
chat.set_raw_output_mode_and_notify(/*enabled*/ false);
assert!(!chat.raw_output_mode());
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<Vec<_>>()
.join("\n");
assert!(
history.contains("Raw output mode off: rich transcript rendering restored."),
"expected raw output notice, got {history:?}"
);
}
#[tokio::test]
async fn flush_answer_stream_keeps_default_reflow_for_plain_text_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller = crate::streaming::controller::StreamController::new(
Some(80),
cwd.as_path(),
HistoryRenderMode::Rich,
);
assert!(controller.push("plain response line\n"));
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::app_event::ConsolidationScrollbackReflow::IfResizeReflowRan
);
assert!(deferred_history_cell.is_none());
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
saw_insert_history,
"plain text should still insert history before consolidation"
);
}
#[tokio::test]
async fn flush_answer_stream_requests_scrollback_reflow_for_live_table_tail() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller = crate::streaming::controller::StreamController::new(
Some(80),
cwd.as_path(),
HistoryRenderMode::Rich,
);
controller.push("| Name | Notes |\n");
controller.push("| --- | --- |\n");
controller.push("| alpha | tail held until final table render |\n");
assert!(
controller.has_live_tail(),
"expected table holdback to leave a live tail for this regression",
);
chat.stream_controller = Some(controller);
while rx.try_recv().is_ok() {}
chat.flush_answer_stream_with_separator();
let mut saw_consolidate = false;
let mut saw_insert_history = false;
while let Ok(event) = rx.try_recv() {
match event {
AppEvent::InsertHistoryCell(_) => saw_insert_history = true,
AppEvent::ConsolidateAgentMessage {
scrollback_reflow,
deferred_history_cell,
..
} => {
saw_consolidate = true;
assert_eq!(
scrollback_reflow,
crate::app_event::ConsolidationScrollbackReflow::Required
);
assert!(
deferred_history_cell.is_some(),
"live table tail should be staged for consolidation",
);
}
_ => {}
}
}
assert!(
saw_consolidate,
"expected stream finalization to consolidate"
);
assert!(
!saw_insert_history,
"live table tail should not be inserted before canonical reflow"
);
}
#[tokio::test]
async fn completed_plan_table_tail_skips_provisional_history_insert() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let cwd = chat.config.cwd.to_path_buf();
let mut controller = crate::streaming::controller::PlanStreamController::new(
Some(80),
cwd.as_path(),
HistoryRenderMode::Rich,
);
controller.push("| Step | Owner |\n");
controller.push("| --- | --- |\n");
controller.push("| Verify | Codex |\n");
assert!(
controller.has_live_tail(),
"expected plan table holdback to leave a live tail",
);
chat.plan_stream_controller = Some(controller);
chat.transcript.plan_delta_buffer =
"| Step | Owner |\n| --- | --- |\n| Verify | Codex |\n".to_string();
while rx.try_recv().is_ok() {}
chat.on_plan_item_completed(String::new());
let mut saw_source_backed_plan = false;
let mut saw_stream_plan = false;
let mut rendered_plan = String::new();
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
if cell.as_any().is::<history_cell::ProposedPlanCell>() {
saw_source_backed_plan = true;
rendered_plan = lines_to_single_string(&cell.display_lines(/*width*/ 80));
}
saw_stream_plan |= cell.as_any().is::<history_cell::ProposedPlanStreamCell>();
}
}
assert!(saw_source_backed_plan, "expected source-backed plan insert");
assert!(
rendered_plan.contains('│') || rendered_plan.contains('┌'),
"expected completed plan table to render as a boxed table, got: {rendered_plan:?}"
);
assert!(
!saw_stream_plan,
"live plan table tail should not be inserted provisionally"
);
}
#[tokio::test]
#[cfg_attr(target_os = "windows", ignore = "disabled on windows")]
async fn configured_pet_load_is_deferred_until_after_construction() {
let (tx_raw, mut rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut cfg = test_config().await;
cfg.tui_pet = Some(crate::pets::DEFAULT_PET_ID.to_string());
crate::pets::write_test_pack(&cfg.codex_home);
let resolved_model = crate::legacy_core::test_support::get_model_offline(cfg.model.as_deref());
let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str());
let init = ChatWidgetInit {
config: cfg.clone(),
environment_manager: Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
frame_requester: FrameRequester::test_dummy(),
app_event_tx: tx,
workspace_command_runner: None,
initial_user_message: None,
enhanced_keys_supported: false,
has_chatgpt_account: false,
model_catalog: test_model_catalog(&cfg),
feedback: codex_feedback::CodexFeedback::new(),
is_first_run: true,
status_account_display: None,
runtime_model_provider_base_url: None,
initial_plan_type: None,
model: Some(resolved_model),
startup_tooltip_override: None,
status_line_invalid_items_warned: Arc::new(AtomicBool::new(false)),
terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)),
session_telemetry,
};
let chat = ChatWidget::new_with_app_event(init);
assert!(!chat.ambient_pet_image_enabled());
let event = tokio::time::timeout(std::time::Duration::from_secs(/*secs*/ 30), rx.recv())
.await
.unwrap()
.unwrap();
assert_matches!(
event,
AppEvent::ConfiguredPetLoaded { pet_id, result } => {
assert_eq!(pet_id, crate::pets::DEFAULT_PET_ID);
assert!(result.unwrap().is_some());
}
);
}
#[tokio::test]
async fn prefetch_rate_limits_is_gated_on_chatgpt_auth_provider() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
assert!(!chat.should_prefetch_rate_limits());
set_chatgpt_auth(&mut chat);
assert!(chat.should_prefetch_rate_limits());
chat.config.model_provider.requires_openai_auth = false;
assert!(!chat.should_prefetch_rate_limits());
chat.prefetch_rate_limits();
assert!(!chat.should_prefetch_rate_limits());
}
#[tokio::test]
async fn rate_limit_warnings_emit_thresholds() {
let mut state = RateLimitWarningState::default();
let mut warnings: Vec<String> = Vec::new();
warnings.extend(state.take_warnings(Some(10.0), Some(10079), Some(55.0), Some(299)));
warnings.extend(state.take_warnings(Some(55.0), Some(10081), Some(10.0), Some(299)));
warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(80.0), Some(299)));
warnings.extend(state.take_warnings(Some(80.0), Some(10081), Some(10.0), Some(299)));
warnings.extend(state.take_warnings(Some(10.0), Some(10081), Some(95.0), Some(299)));
warnings.extend(state.take_warnings(Some(95.0), Some(10079), Some(10.0), Some(299)));
assert_eq!(
warnings,
vec![
String::from(
"Heads up, you have less than 25% of your 5h limit left. Run /status for a breakdown."
),
String::from(
"Heads up, you have less than 25% of your weekly limit left. Run /status for a breakdown.",
),
String::from(
"Heads up, you have less than 5% of your 5h limit left. Run /status for a breakdown."
),
String::from(
"Heads up, you have less than 5% of your weekly limit left. Run /status for a breakdown.",
),
],
"expected one warning per limit for the highest crossed threshold"
);
}
#[tokio::test]
async fn test_rate_limit_warnings_monthly() {
let mut state = RateLimitWarningState::default();
let mut warnings: Vec<String> = Vec::new();
warnings.extend(state.take_warnings(
Some(75.0),
Some(43199),
/*primary_used_percent*/ None,
/*primary_window_minutes*/ None,
));
assert_eq!(
warnings,
vec![String::from(
"Heads up, you have less than 25% of your monthly limit left. Run /status for a breakdown.",
),],
"expected one warning per limit for the highest crossed threshold"
);
}
#[tokio::test]
async fn rate_limit_snapshot_keeps_prior_credits_when_missing_from_headers() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: None,
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("17.5".to_string()),
}),
plan_type: None,
rate_limit_reached_type: None,
}));
let initial_balance = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.and_then(|snapshot| snapshot.credits.as_ref())
.and_then(|credits| credits.balance.as_deref());
assert_eq!(initial_balance, Some("17.5"));
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 80,
window_duration_mins: Some(60),
resets_at: Some(123),
}),
secondary: None,
credits: None,
plan_type: None,
rate_limit_reached_type: None,
}));
let display = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.expect("rate limits should be cached");
let credits = display
.credits
.as_ref()
.expect("credits should persist when headers omit them");
assert_eq!(credits.balance.as_deref(), Some("17.5"));
assert!(!credits.unlimited);
assert_eq!(
display.primary.as_ref().map(|window| window.used_percent),
Some(80.0)
);
}
#[tokio::test]
async fn rate_limit_snapshot_updates_and_retains_plan_type() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 10,
window_duration_mins: Some(60),
resets_at: None,
}),
secondary: Some(RateLimitWindow {
used_percent: 5,
window_duration_mins: Some(300),
resets_at: None,
}),
credits: None,
plan_type: Some(PlanType::Plus),
rate_limit_reached_type: None,
}));
assert_eq!(chat.plan_type, Some(PlanType::Plus));
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 25,
window_duration_mins: Some(30),
resets_at: Some(123),
}),
secondary: Some(RateLimitWindow {
used_percent: 15,
window_duration_mins: Some(300),
resets_at: Some(234),
}),
credits: None,
plan_type: Some(PlanType::Pro),
rate_limit_reached_type: None,
}));
assert_eq!(chat.plan_type, Some(PlanType::Pro));
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: None,
limit_name: None,
primary: Some(RateLimitWindow {
used_percent: 30,
window_duration_mins: Some(60),
resets_at: Some(456),
}),
secondary: Some(RateLimitWindow {
used_percent: 18,
window_duration_mins: Some(300),
resets_at: Some(567),
}),
credits: None,
plan_type: None,
rate_limit_reached_type: None,
}));
assert_eq!(chat.plan_type, Some(PlanType::Pro));
}
#[tokio::test]
async fn rate_limit_snapshots_keep_separate_entries_per_limit_id() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex".to_string()),
limit_name: Some("codex".to_string()),
primary: Some(RateLimitWindow {
used_percent: 20,
window_duration_mins: Some(300),
resets_at: Some(100),
}),
secondary: None,
credits: Some(CreditsSnapshot {
has_credits: true,
unlimited: false,
balance: Some("5.00".to_string()),
}),
plan_type: Some(PlanType::Pro),
rate_limit_reached_type: None,
}));
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
primary: Some(RateLimitWindow {
used_percent: 90,
window_duration_mins: Some(60),
resets_at: Some(200),
}),
secondary: None,
credits: None,
plan_type: Some(PlanType::Pro),
rate_limit_reached_type: None,
}));
let codex = chat
.rate_limit_snapshots_by_limit_id
.get("codex")
.expect("codex snapshot should exist");
let other = chat
.rate_limit_snapshots_by_limit_id
.get("codex_other")
.expect("codex_other snapshot should exist");
assert_eq!(codex.primary.as_ref().map(|w| w.used_percent), Some(20.0));
assert_eq!(
codex
.credits
.as_ref()
.and_then(|credits| credits.balance.as_deref()),
Some("5.00")
);
assert_eq!(other.primary.as_ref().map(|w| w.used_percent), Some(90.0));
assert!(other.credits.is_none());
}
#[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;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_skips_non_codex_limit() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(RateLimitSnapshot {
limit_id: Some("codex_other".to_string()),
limit_name: Some("codex_other".to_string()),
primary: Some(RateLimitWindow {
used_percent: 95,
window_duration_mins: Some(60),
resets_at: None,
}),
secondary: None,
credits: None,
plan_type: None,
rate_limit_reached_type: None,
}));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_shows_once_per_session() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 90.0)));
assert!(
chat.rate_limit_warnings.primary_index >= 1,
"warnings not emitted"
);
chat.maybe_show_pending_rate_limit_prompt();
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_respects_hidden_notice() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.config.notices.hide_rate_limit_model_nudge = Some(true);
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 95.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Idle
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_defers_until_task_complete() {
let (mut chat, _, _) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.bottom_pane.set_task_running(/*running*/ true);
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 90.0)));
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Pending
));
chat.bottom_pane.set_task_running(/*running*/ false);
chat.maybe_show_pending_rate_limit_prompt();
assert!(matches!(
chat.rate_limit_switch_prompt,
RateLimitSwitchPromptState::Shown
));
}
#[tokio::test]
async fn rate_limit_switch_prompt_popup_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5")).await;
chat.has_chatgpt_account = true;
chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 92.0)));
chat.maybe_show_pending_rate_limit_prompt();
let popup = render_bottom_popup(&chat, /*width*/ 80);
assert_chatwidget_snapshot!("rate_limit_switch_prompt_popup", popup);
}
#[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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
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;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert!(chat.bottom_pane.is_task_running());
assert!(!chat.bottom_pane.status_indicator_visible());
chat.bottom_pane
.set_composer_text("queued submission".to_string(), Vec::new(), Vec::new());
chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE));
assert_eq!(chat.input_queue.queued_user_messages.len(), 1);
assert_eq!(
chat.input_queue.queued_user_messages.front().unwrap().text,
"queued submission"
);
assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty));
chat.handle_key_event(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL));
match op_rx.try_recv() {
Ok(Op::Interrupt) => {}
other => panic!("expected Op::Interrupt, got {other:?}"),
}
assert!(!chat.bottom_pane.quit_shortcut_hint_visible());
}
#[tokio::test]
async fn idle_commit_ticks_do_not_restore_status_without_commentary_completion() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
chat.on_agent_message_delta("Final answer line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
assert_eq!(chat.bottom_pane.is_task_running(), true);
// A second idle tick should not toggle the row back on and cause jitter.
chat.on_commit_tick();
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
}
#[tokio::test]
async fn final_answer_completion_restores_status_indicator_for_pending_steer() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.on_task_started();
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
chat.on_agent_message_delta("Long output line 1\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
chat.on_agent_message_delta("Long output line 2\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
assert_eq!(chat.bottom_pane.is_task_running(), true);
chat.bottom_pane.set_composer_text(
"Please summarize the rest more briefly.".to_string(),
Vec::new(),
Vec::new(),
);
chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
assert_eq!(chat.input_queue.pending_steers.len(), 1);
let items = match next_submit_op(&mut op_rx) {
Op::UserTurn { items, .. } => items,
other => panic!("expected Op::UserTurn, got {other:?}"),
};
assert_eq!(
items,
vec![UserInput::Text {
text: "Please summarize the rest more briefly.".to_string(),
text_elements: Vec::new(),
}]
);
complete_assistant_message(
&mut chat,
"msg-final",
"Long output line 1\nLong output line 2\n",
Some(MessagePhase::FinalAnswer),
);
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
assert_eq!(chat.bottom_pane.is_task_running(), true);
complete_user_message(
&mut chat,
"user-steer",
"Please summarize the rest more briefly.",
);
assert!(chat.input_queue.pending_steers.is_empty());
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
assert_eq!(chat.bottom_pane.is_task_running(), true);
}
#[tokio::test]
async fn commentary_completion_restores_status_indicator_before_exec_begin() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
chat.on_agent_message_delta("Preamble line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert_eq!(chat.bottom_pane.status_indicator_visible(), false);
complete_assistant_message(
&mut chat,
"msg-commentary",
"Preamble line\n",
Some(MessagePhase::Commentary),
);
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
assert_eq!(chat.bottom_pane.is_task_running(), true);
begin_exec(&mut chat, "call-1", "echo hi");
assert_eq!(chat.bottom_pane.status_indicator_visible(), true);
}
#[tokio::test]
async fn fast_status_indicator_requires_chatgpt_auth() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
#[tokio::test]
async fn fast_status_indicator_is_hidden_for_models_without_fast_support() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode());
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(!get_available_model(&chat, "gpt-5.3-codex").supports_fast_mode());
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
#[tokio::test]
async fn fast_status_indicator_is_hidden_when_fast_mode_is_off() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
assert!(!chat.should_show_fast_status(chat.current_model(), chat.current_service_tier(),));
}
// Snapshot test: ChatWidget at very small heights (idle)
// Ensures overall layout behaves when terminal height is extremely constrained.
#[tokio::test]
async fn ui_snapshots_small_heights_idle() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
for h in [1u16, 2, 3] {
let name = format!("chat_small_idle_h{h}");
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw chat idle");
assert_chatwidget_snapshot!(name, normalized_backend_snapshot(terminal.backend()));
}
}
// Snapshot test: ChatWidget at very small heights (task running)
// Validates how status + composer are presented within tight space.
#[tokio::test]
async fn ui_snapshots_small_heights_task_running() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
// Activate status line
handle_turn_started(&mut chat, "turn-1");
handle_agent_reasoning_delta(&mut chat, "**Thinking**");
for h in [1u16, 2, 3] {
let name = format!("chat_small_running_h{h}");
let mut terminal = Terminal::new(TestBackend::new(40, h)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw chat running");
assert_chatwidget_snapshot!(name, normalized_backend_snapshot(terminal.backend()));
}
}
#[tokio::test]
#[serial]
async fn ambient_pet_stays_hidden_until_a_pet_is_selected() {
use ratatui::layout::Rect;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported(
crate::pets::ImageProtocol::Kitty,
));
assert!(chat.ambient_pet.is_none());
crate::pets::write_test_pack(&chat.config.codex_home);
chat.set_tui_pet(Some("codex".to_string()));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 20,
);
let draw = chat
.ambient_pet_draw(area, area.bottom())
.expect("ambient pet draw request");
assert_eq!(draw.x, 51);
assert_eq!(draw.y, 14);
assert_eq!(draw.columns, 9);
assert_eq!(draw.rows, 5);
assert_eq!(
draw.y.saturating_add(draw.rows),
area.bottom().saturating_sub(/*rhs*/ 1)
);
handle_turn_started(&mut chat, "turn-1");
handle_agent_reasoning_delta(&mut chat, "**Thinking**");
let draw_with_status = chat
.ambient_pet_draw(area, area.bottom())
.expect("ambient pet draw request with status");
assert_eq!(draw_with_status.y, draw.y);
assert_eq!(
draw_with_status.y.saturating_add(draw_with_status.rows),
area.bottom().saturating_sub(/*rhs*/ 1)
);
}
#[tokio::test]
#[serial]
async fn ambient_pet_screen_bottom_anchor_uses_terminal_bottom() {
use codex_config::types::TuiPetAnchor;
use ratatui::layout::Rect;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut chat);
let terminal_area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24,
);
let composer_bottom_y = 20;
let default_draw = chat
.ambient_pet_draw(terminal_area, composer_bottom_y)
.expect("composer-anchored pet draw request");
assert_eq!(default_draw.y, 14);
chat.config.tui_pet_anchor = TuiPetAnchor::ScreenBottom;
let screen_bottom_draw = chat
.ambient_pet_draw(terminal_area, composer_bottom_y)
.expect("screen-bottom anchored pet draw request");
assert_eq!(screen_bottom_draw.y, 18);
}
#[tokio::test]
#[serial]
async fn ambient_pet_can_be_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string()));
assert!(chat.ambient_pet.is_none());
}
#[tokio::test]
#[serial]
async fn ambient_pet_reserves_history_wrap_width() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut chat);
assert_eq!(chat.history_wrap_width(/*width*/ 80), 69);
chat.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string()));
assert_eq!(chat.history_wrap_width(/*width*/ 80), 80);
}
#[tokio::test]
#[serial]
async fn ambient_pet_reduces_stream_width_and_composer_text_width() {
use ratatui::Terminal;
let (mut with_pet, _with_pet_rx, _with_pet_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut with_pet);
with_pet.last_rendered_width.set(Some(80));
let stream_width_with_pet = with_pet.current_stream_width(/*reserved_cols*/ 2);
let (mut disabled, _disabled_rx, _disabled_op_rx) =
make_chatwidget_manual(/*model_override*/ None).await;
disabled.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string()));
disabled.last_rendered_width.set(Some(80));
let stream_width_without_pet = disabled.current_stream_width(/*reserved_cols*/ 2);
assert_eq!(
stream_width_with_pet,
crate::width::usable_content_width(/*total_width*/ 69, /*reserved_cols*/ 2)
);
assert_eq!(
stream_width_without_pet,
crate::width::usable_content_width(/*total_width*/ 80, /*reserved_cols*/ 2)
);
assert!(stream_width_with_pet < stream_width_without_pet);
let draft =
"Minim commodo esse elit Lorem exercitation elit ipsum proident labore. Esse culpa aliqua"
.to_string();
with_pet
.bottom_pane
.set_composer_text(draft.clone(), Vec::new(), Vec::new());
disabled
.bottom_pane
.set_composer_text(draft, Vec::new(), Vec::new());
let mut with_pet_terminal =
Terminal::new(TestBackend::new(/*width*/ 80, /*height*/ 6)).expect("create terminal");
with_pet_terminal
.draw(|f| with_pet.render(f.area(), f.buffer_mut()))
.expect("draw pet-enabled chat");
let mut disabled_terminal =
Terminal::new(TestBackend::new(/*width*/ 80, /*height*/ 6)).expect("create terminal");
disabled_terminal
.draw(|f| disabled.render(f.area(), f.buffer_mut()))
.expect("draw disabled-pet chat");
let pet_row = buffer_row_containing(with_pet_terminal.backend().buffer(), "Minim")
.expect("pet-enabled composer row should render draft");
let disabled_row = buffer_row_containing(disabled_terminal.backend().buffer(), "Minim")
.expect("disabled-pet composer row should render draft");
assert!(row_tail_is_blank(&pet_row, /*start_col*/ 69));
assert!(!row_tail_is_blank(&disabled_row, /*start_col*/ 69));
}
fn buffer_row_containing(buffer: &ratatui::buffer::Buffer, text: &str) -> Option<String> {
(0..buffer.area.height)
.map(|y| {
(0..buffer.area.width)
.map(|x| buffer.cell((x, y)).expect("cell should exist").symbol())
.collect::<String>()
})
.find(|row| row.contains(text))
}
fn row_tail_is_blank(row: &str, start_col: usize) -> bool {
row.chars().skip(start_col).all(char::is_whitespace)
}
#[tokio::test]
#[serial]
async fn ambient_pet_draw_uses_terminal_screen_area_not_short_inline_viewport() {
use ratatui::layout::Rect;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut chat);
assert!(
chat.ambient_pet_draw(
Rect::new(
/*x*/ 0, /*y*/ 21, /*width*/ 80, /*height*/ 3,
),
/*composer_bottom_y*/ 24
)
.is_none(),
"a normal short inline viewport cannot fit the ambient pet"
);
let draw = chat
.ambient_pet_draw(
Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24,
),
/*composer_bottom_y*/ 24,
)
.expect("full terminal screen has room for the ambient pet");
assert_eq!(draw.x, 71);
assert_eq!(draw.y, 18);
}
#[tokio::test]
#[serial]
async fn ambient_pet_hides_notification_text_overlay() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
enable_test_ambient_pet(&mut chat);
for (kind, label) in [
(crate::pets::PetNotificationKind::Running, "Running"),
(crate::pets::PetNotificationKind::Waiting, "Needs input"),
(crate::pets::PetNotificationKind::Review, "Ready"),
(crate::pets::PetNotificationKind::Failed, "Blocked"),
] {
chat.set_ambient_pet_notification(kind, /*body*/ None);
let mut terminal = Terminal::new(TestBackend::new(60, 20)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw ambient pet notification");
assert!(
!normalized_backend_snapshot(terminal.backend()).contains(label),
"did not expect {label} notification text to render"
);
}
}
// Snapshot test: status widget + approval modal active together
// The modal takes precedence visually; this captures the layout with a running
// task (status indicator active) while an approval request is shown.
#[tokio::test]
async fn status_widget_and_approval_modal_snapshot() {
use crate::approval_events::ExecApprovalRequestEvent;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
// Begin a running task so the status indicator would be active.
handle_turn_started(&mut chat, "turn-1");
// Provide a deterministic header for the status line.
handle_agent_reasoning_delta(&mut chat, "**Analyzing**");
// Now show an approval modal (e.g. exec approval).
let ev = ExecApprovalRequestEvent {
call_id: "call-approve-exec".into(),
approval_id: Some("call-approve-exec".into()),
turn_id: "turn-approve-exec".into(),
command: vec!["echo".into(), "hello world".into()],
cwd: test_path_buf("/tmp").abs(),
reason: Some(
"this is a test reason such as one that would be produced by the model".into(),
),
network_approval_context: None,
proposed_execpolicy_amendment: Some(ExecPolicyAmendment {
command: vec!["echo".into(), "hello world".into()],
}),
proposed_network_policy_amendments: None,
additional_permissions: None,
available_decisions: None,
};
handle_exec_approval_request(&mut chat, "sub-approve-exec", ev);
// Render at the widget's desired height and snapshot.
let width: u16 = 100;
let height = chat.desired_height(width);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height))
.expect("create terminal");
terminal.set_viewport_area(Rect::new(0, 0, width, height));
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw status + approval modal");
assert_chatwidget_snapshot!(
"status_widget_and_approval_modal",
normalized_backend_snapshot(terminal.backend())
);
}
// Snapshot test: status widget active (StatusIndicatorView)
// Ensures the VT100 rendering of the status indicator is stable when active.
#[tokio::test]
async fn status_widget_active_snapshot() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
// Activate the status indicator by simulating a task start.
handle_turn_started(&mut chat, "turn-1");
// Provide a deterministic header via a bold reasoning chunk.
handle_agent_reasoning_delta(&mut chat, "**Analyzing**");
// Render and snapshot.
let height = chat.desired_height(/*width*/ 80);
let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(80, height))
.expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw status widget");
assert_chatwidget_snapshot!(
"status_widget_active",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn stream_error_updates_status_indicator() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.bottom_pane.set_task_running(/*running*/ true);
let msg = "Reconnecting... 2/5";
let details = "Idle timeout waiting for SSE";
handle_stream_error(&mut chat, msg, Some(details.to_string()));
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
"expected no history cell for StreamError event"
);
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), msg);
assert_eq!(status.details(), Some(details));
}
#[tokio::test]
async fn stream_error_restores_hidden_status_indicator() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.on_task_started();
chat.on_agent_message_delta("Preamble line\n".to_string());
chat.on_commit_tick();
drain_insert_history(&mut rx);
assert!(!chat.bottom_pane.status_indicator_visible());
let msg = "Reconnecting... 2/5";
let details = "Idle timeout waiting for SSE";
handle_stream_error(&mut chat, msg, Some(details.to_string()));
let status = chat
.bottom_pane
.status_widget()
.expect("status indicator should be visible");
assert_eq!(status.header(), msg);
assert_eq!(status.details(), Some(details));
}
#[tokio::test]
async fn warning_event_adds_warning_history_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_warning(&mut chat, "test warning message");
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("test warning message"),
"warning cell missing content: {rendered}"
);
}
#[tokio::test]
async fn repeated_model_metadata_warning_is_hidden_for_same_slug() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let warning = "Model metadata for `unknown-model` not found. Defaulting to fallback metadata; this can degrade performance and cause issues.";
handle_warning(&mut chat, warning);
handle_warning(&mut chat, warning);
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("unknown-model"),
"warning cell missing model slug: {rendered}"
);
}
#[tokio::test]
async fn repeated_generic_warning_is_not_hidden() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_warning(&mut chat, "test warning message");
handle_warning(&mut chat, "test warning message");
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 2, "expected both warning history cells");
}
#[tokio::test]
async fn status_line_invalid_items_warn_once() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec![
"model_name".to_string(),
"bogus_item".to_string(),
"lines_changed".to_string(),
"bogus_item".to_string(),
]);
chat.thread_id = Some(ThreadId::new());
chat.refresh_status_line();
let cells = drain_insert_history(&mut rx);
assert_eq!(cells.len(), 1, "expected one warning history cell");
let rendered = lines_to_single_string(&cells[0]);
assert!(
rendered.contains("bogus_item"),
"warning cell missing invalid item content: {rendered}"
);
chat.refresh_status_line();
let cells = drain_insert_history(&mut rx);
assert!(
cells.is_empty(),
"expected invalid status line warning to emit only once"
);
}
#[tokio::test]
async fn status_line_context_used_renders_labeled_percent() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec!["context-used".to_string()]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Context 0% used".to_string()));
assert!(
drain_insert_history(&mut rx).is_empty(),
"context-used should remain a valid status line item"
);
}
#[tokio::test]
async fn status_line_context_remaining_renders_labeled_percent() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec!["context-remaining".to_string()]);
chat.refresh_status_line();
assert_eq!(
status_line_text(&chat),
Some("Context 100% left".to_string())
);
assert!(
drain_insert_history(&mut rx).is_empty(),
"context-remaining should remain a valid status line item"
);
}
#[tokio::test]
async fn status_line_legacy_context_usage_renders_context_used_percent() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
chat.config.tui_status_line = Some(vec!["context-usage".to_string()]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Context 0% used".to_string()));
assert!(
drain_insert_history(&mut rx).is_empty(),
"legacy context-usage should remain a valid status line item"
);
}
#[tokio::test]
async fn status_line_branch_state_resets_when_git_branch_disabled() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.status_line_branch = Some("main".to_string());
chat.status_line_branch_pending = true;
chat.status_line_branch_lookup_complete = true;
chat.config.tui_status_line = Some(vec!["model_name".to_string()]);
chat.refresh_status_line();
assert_eq!(chat.status_line_branch, None);
assert!(!chat.status_line_branch_pending);
assert!(!chat.status_line_branch_lookup_complete);
}
#[tokio::test]
async fn status_line_branch_refreshes_after_turn_complete() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
install_noop_workspace_command_runner(&mut chat);
chat.config.tui_status_line = Some(vec!["git-branch".to_string()]);
chat.status_line_branch_lookup_complete = true;
chat.status_line_branch_pending = false;
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
assert!(chat.status_line_branch_pending);
}
#[tokio::test]
async fn status_line_branch_refreshes_after_interrupt() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
install_noop_workspace_command_runner(&mut chat);
chat.config.tui_status_line = Some(vec!["git-branch".to_string()]);
chat.status_line_branch_lookup_complete = true;
chat.status_line_branch_pending = false;
handle_turn_interrupted(&mut chat, "turn-1");
assert!(chat.status_line_branch_pending);
}
fn install_noop_workspace_command_runner(chat: &mut ChatWidget) {
chat.workspace_command_runner = Some(std::sync::Arc::new(NoopWorkspaceCommandRunner));
}
struct NoopWorkspaceCommandRunner;
impl crate::workspace_command::WorkspaceCommandExecutor for NoopWorkspaceCommandRunner {
fn run(
&self,
_command: crate::workspace_command::WorkspaceCommand,
) -> std::pin::Pin<
Box<
dyn std::future::Future<
Output = Result<
crate::workspace_command::WorkspaceCommandOutput,
crate::workspace_command::WorkspaceCommandError,
>,
> + Send
+ '_,
>,
> {
Box::pin(async {
Ok(crate::workspace_command::WorkspaceCommandOutput {
exit_code: 1,
stdout: String::new(),
stderr: String::new(),
})
})
}
}
#[tokio::test]
async fn interrupted_turn_clears_visible_running_hook() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"pre-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command policy"),
),
);
reveal_running_hooks(&mut chat);
let before_interrupt = active_hook_blob(&chat);
handle_turn_interrupted(&mut chat, "turn-1");
assert_chatwidget_snapshot!(
"interrupted_turn_clears_visible_running_hook",
format!(
"before interrupt:\n{before_interrupt}after interrupt:\n{}",
active_hook_blob(&chat)
)
);
}
#[tokio::test]
async fn status_line_fast_mode_renders_on_and_off() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast off".to_string()));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
chat.refresh_status_line();
assert_eq!(status_line_text(&chat), Some("Fast on".to_string()));
}
#[tokio::test]
async fn status_line_fast_mode_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["fast-mode".to_string()]);
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
chat.refresh_status_line();
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw fast-mode footer");
assert_chatwidget_snapshot!(
"status_line_fast_mode_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_model_with_reasoning_includes_fast_for_fast_capable_models() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.config.cwd = test_project_path().abs();
chat.config.tui_status_line = Some(vec![
"model-with-reasoning".to_string(),
"context-used".to_string(),
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.refresh_status_line();
let test_cwd = test_path_display("/tmp/project");
assert_eq!(
status_line_text(&chat),
Some(format!("gpt-5.4 xhigh fast · Context 0% used · {test_cwd}"))
);
chat.set_model("gpt-5.3-codex");
chat.refresh_status_line();
assert_eq!(
status_line_text(&chat),
Some(format!(
"gpt-5.3-codex xhigh · Context 0% used · {test_cwd}"
))
);
}
#[tokio::test]
async fn terminal_title_model_updates_on_model_change_without_manual_refresh() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.config.tui_terminal_title = Some(vec!["model".to_string()]);
chat.refresh_terminal_title();
assert_eq!(chat.last_terminal_title, Some("gpt-5.4".to_string()));
chat.set_model("gpt-5.3-codex");
assert_eq!(chat.last_terminal_title, Some("gpt-5.3-codex".to_string()));
}
#[tokio::test]
async fn status_line_model_with_reasoning_updates_on_mode_switch_without_manual_refresh() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
chat.config.tui_status_line = Some(vec!["model-with-reasoning".to_string()]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
assert_eq!(
status_line_text(&chat),
Some("gpt-5.3-codex high".to_string())
);
let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask);
assert_eq!(
status_line_text(&chat),
Some("gpt-5.3-codex medium".to_string())
);
let default_mask = collaboration_modes::default_mask(chat.model_catalog.as_ref())
.expect("expected default collaboration mode");
chat.set_collaboration_mask(default_mask);
assert_eq!(
status_line_text(&chat),
Some("gpt-5.3-codex high".to_string())
);
}
#[tokio::test]
async fn status_line_model_with_reasoning_plan_mode_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.show_welcome_banner = false;
chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true);
chat.config.tui_status_line = Some(vec!["model-with-reasoning".to_string()]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref())
.expect("expected plan collaboration mode");
chat.set_collaboration_mask(plan_mask);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw plan-mode footer");
assert_chatwidget_snapshot!(
"status_line_model_with_reasoning_plan_mode_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn renamed_thread_footer_title_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await;
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec![
"model-with-reasoning".to_string(),
"thread-title".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::High));
chat.refresh_status_line();
let thread_id = ThreadId::new();
chat.thread_id = Some(thread_id);
chat.handle_server_notification(
ServerNotification::ThreadNameUpdated(
codex_app_server_protocol::ThreadNameUpdatedNotification {
thread_id: thread_id.to_string(),
thread_name: Some("Roadmap cleanup".to_string()),
},
),
/*replay_kind*/ None,
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw renamed-thread footer");
assert_chatwidget_snapshot!(
"renamed_thread_footer_title",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_model_with_reasoning_fast_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.show_welcome_banner = false;
chat.config.cwd = test_project_path().abs();
chat.config.tui_status_line = Some(vec![
"model-with-reasoning".to_string(),
"context-used".to_string(),
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.refresh_status_line();
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw model-with-reasoning footer");
assert_chatwidget_snapshot!(
"status_line_model_with_reasoning_fast_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_model_with_reasoning_context_remaining_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.show_welcome_banner = false;
chat.config.cwd = test_project_path().abs();
chat.config.tui_status_line = Some(vec![
"model-with-reasoning".to_string(),
"context-remaining".to_string(),
"current-dir".to_string(),
]);
chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh));
chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string()));
set_chatgpt_auth(&mut chat);
set_fast_mode_test_catalog(&mut chat);
assert!(get_available_model(&chat, "gpt-5.4").supports_fast_mode());
chat.refresh_status_line();
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw model-with-reasoning footer");
assert_chatwidget_snapshot!(
"status_line_model_with_reasoning_context_remaining_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_goal_active_token_budget_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
chat.refresh_status_line();
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Active,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 40_000,
),
},
),
/*replay_kind*/ None,
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw goal status footer");
assert_chatwidget_snapshot!(
"status_line_goal_active_token_budget_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn status_line_goal_complete_elapsed_footer_snapshot() {
use ratatui::Terminal;
use ratatui::backend::TestBackend;
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.show_welcome_banner = false;
chat.config.tui_status_line = Some(vec!["model-name".to_string()]);
chat.refresh_status_line();
let mut goal = test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Complete,
/*token_budget*/ None,
/*tokens_used*/ 40_000,
);
goal.time_used_seconds = 2 * 24 * 60 * 60 + 23 * 60 * 60 + 42 * 60;
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal,
},
),
/*replay_kind*/ None,
);
let width = 80;
let height = chat.desired_height(width);
let mut terminal = Terminal::new(TestBackend::new(width, height)).expect("create terminal");
terminal
.draw(|f| chat.render(f.area(), f.buffer_mut()))
.expect("draw goal status footer");
assert_chatwidget_snapshot!(
"status_line_goal_complete_elapsed_footer",
normalized_backend_snapshot(terminal.backend())
);
}
#[tokio::test]
async fn session_configured_clears_goal_status_footer() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: "thread-1".to_string(),
turn_id: None,
goal: test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Active,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 40_000,
),
},
),
/*replay_kind*/ None,
);
assert_eq!(
chat.current_goal_status_indicator,
Some(GoalStatusIndicator::Active {
usage: Some("40K / 50K".to_string())
})
);
chat.turn_lifecycle
.budget_limited_turn_ids
.insert("turn-1".to_string());
let rollout_file = NamedTempFile::new().unwrap();
chat.handle_thread_session(crate::session_state::ThreadSessionState {
thread_id: ThreadId::new(),
forked_from_id: None,
fork_parent_title: None,
thread_name: None,
model: "gpt-5.4".to_string(),
model_provider_id: "test-provider".to_string(),
service_tier: None,
approval_policy: AskForApproval::Never,
approvals_reviewer: ApprovalsReviewer::User,
permission_profile: PermissionProfile::read_only(),
active_permission_profile: None,
cwd: test_path_buf("/home/user/project").abs(),
runtime_workspace_roots: Vec::new(),
instruction_source_paths: Vec::new(),
reasoning_effort: Some(ReasoningEffortConfig::default()),
message_history: None,
network_proxy: None,
rollout_path: Some(rollout_file.path().to_path_buf()),
});
assert_eq!(chat.current_goal_status_indicator, None);
assert!(chat.turn_lifecycle.budget_limited_turn_ids.is_empty());
}
#[tokio::test]
async fn thread_goal_update_for_other_thread_is_ignored() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await;
chat.set_feature_enabled(Feature::Goals, /*enabled*/ true);
chat.thread_id = Some(ThreadId::new());
let other_thread_id = ThreadId::new().to_string();
let mut goal = test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 50_000,
);
goal.thread_id = other_thread_id.clone();
chat.handle_server_notification(
ServerNotification::ThreadGoalUpdated(
codex_app_server_protocol::ThreadGoalUpdatedNotification {
thread_id: other_thread_id,
turn_id: Some("turn-other".to_string()),
goal,
},
),
/*replay_kind*/ None,
);
assert_eq!(chat.current_goal_status_indicator, None);
assert!(chat.current_goal_status.is_none());
assert!(chat.turn_lifecycle.budget_limited_turn_ids.is_empty());
}
#[test]
fn goal_status_indicator_formats_statuses_and_budgets() {
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Active,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 40_000,
)),
Some(GoalStatusIndicator::Active {
usage: Some("40K / 50K".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Active,
/*token_budget*/ None,
/*tokens_used*/ 0,
)),
Some(GoalStatusIndicator::Active {
usage: Some("30m".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 51_000,
)),
Some(GoalStatusIndicator::BudgetLimited {
usage: Some("51K / 50K tokens".to_string()),
})
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::BudgetLimited,
/*token_budget*/ None,
/*tokens_used*/ 0,
)),
Some(GoalStatusIndicator::BudgetLimited { usage: None })
);
assert_eq!(
goal_status_indicator_from_app_goal(&test_thread_goal(
codex_app_server_protocol::ThreadGoalStatus::Complete,
/*token_budget*/ Some(50_000),
/*tokens_used*/ 40_000,
)),
Some(GoalStatusIndicator::Complete {
usage: Some("40K tokens".to_string()),
})
);
}
#[test]
fn goal_status_indicator_line_formats_goal_text() {
let cases = [
(
GoalStatusIndicator::Active {
usage: Some("4K / 5K".to_string()),
},
"Pursuing goal (4K / 5K)",
),
(
GoalStatusIndicator::BudgetLimited {
usage: Some("4K / 5K tokens".to_string()),
},
"Goal unmet (4K / 5K tokens)",
),
(GoalStatusIndicator::Paused, "Goal paused (/goal resume)"),
(
GoalStatusIndicator::BudgetLimited { usage: None },
"Goal abandoned",
),
(
GoalStatusIndicator::Complete {
usage: Some("10h 12m".to_string()),
},
"Goal achieved (10h 12m)",
),
(
GoalStatusIndicator::Complete { usage: None },
"Goal achieved",
),
];
for (indicator, expected) in cases {
let line =
goal_status_indicator_line(Some(&indicator)).expect("goal indicator should render");
let actual = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(expected, actual);
}
}
fn test_thread_goal(
status: codex_app_server_protocol::ThreadGoalStatus,
token_budget: Option<i64>,
tokens_used: i64,
) -> codex_app_server_protocol::ThreadGoal {
codex_app_server_protocol::ThreadGoal {
thread_id: "thread-1".to_string(),
objective: "Keep improving the benchmark".to_string(),
status,
token_budget,
tokens_used,
time_used_seconds: 30 * 60,
created_at: 0,
updated_at: 0,
}
}
#[tokio::test]
async fn runtime_metrics_websocket_timing_logs_and_final_separator_sums_totals() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_feature_enabled(Feature::RuntimeMetrics, /*enabled*/ true);
chat.on_task_started();
chat.apply_runtime_metrics_delta(RuntimeMetricsSummary {
responses_api_engine_iapi_ttft_ms: 120,
responses_api_engine_service_tbt_ms: 50,
..RuntimeMetricsSummary::default()
});
let first_log = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.find(|line| line.contains("WebSocket timing:"))
.expect("expected websocket timing log");
assert!(first_log.contains("TTFT: 120ms (iapi)"));
assert!(first_log.contains("TBT: 50ms (service)"));
chat.apply_runtime_metrics_delta(RuntimeMetricsSummary {
responses_api_engine_iapi_ttft_ms: 80,
..RuntimeMetricsSummary::default()
});
let second_log = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.find(|line| line.contains("WebSocket timing:"))
.expect("expected websocket timing log");
assert!(second_log.contains("TTFT: 80ms (iapi)"));
chat.on_task_complete(
/*last_agent_message*/ None, /*duration_ms*/ None, /*from_replay*/ false,
);
let mut final_separator = None;
while let Ok(event) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = event {
final_separator = Some(lines_to_single_string(&cell.display_lines(/*width*/ 300)));
}
}
let final_separator = final_separator.expect("expected final separator with runtime metrics");
assert!(final_separator.contains("TTFT: 80ms (iapi)"));
assert!(final_separator.contains("TBT: 50ms (service)"));
}
#[tokio::test]
async fn multiple_agent_messages_in_single_turn_emit_multiple_headers() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
// Begin turn
handle_turn_started(&mut chat, "turn-1");
// First finalized assistant message
complete_assistant_message(&mut chat, "msg-first", "First message", /*phase*/ None);
// Second finalized assistant message in the same turn
complete_assistant_message(
&mut chat,
"msg-second",
"Second message",
/*phase*/ None,
);
// End turn
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
let cells = drain_insert_history(&mut rx);
let combined: String = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect();
assert!(
combined.contains("First message"),
"missing first message: {combined}"
);
assert!(
combined.contains("Second message"),
"missing second message: {combined}"
);
let first_idx = combined.find("First message").unwrap();
let second_idx = combined.find("Second message").unwrap();
assert!(first_idx < second_idx, "messages out of order: {combined}");
}
#[tokio::test]
async fn final_reasoning_then_message_without_deltas_are_rendered() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
// No deltas; only final reasoning followed by final message.
handle_agent_reasoning_final(&mut chat);
complete_assistant_message(
&mut chat,
"msg-result",
"Here is the result.",
/*phase*/ None,
);
// Drain history and snapshot the combined visible content.
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"final_reasoning_then_message_without_deltas_are_rendered",
combined
);
}
#[tokio::test]
async fn deltas_then_same_final_message_are_rendered_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
// Stream some reasoning deltas first.
handle_agent_reasoning_delta(&mut chat, "I will ");
handle_agent_reasoning_delta(&mut chat, "first analyze the ");
handle_agent_reasoning_delta(&mut chat, "request.");
handle_agent_reasoning_final(&mut chat);
// Then stream answer deltas, followed by the exact same final message.
handle_agent_message_delta(&mut chat, "Here is the ");
handle_agent_message_delta(&mut chat, "result.");
complete_assistant_message(
&mut chat,
"msg-result",
"Here is the result.",
/*phase*/ None,
);
// Snapshot the combined visible content to ensure we render as expected
// when deltas are followed by the identical final message.
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"deltas_then_same_final_message_are_rendered_snapshot",
combined
);
}
#[tokio::test]
async fn user_prompt_submit_app_server_hook_notifications_render_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.handle_server_notification(
ServerNotification::HookStarted(AppServerHookStartedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: Some("turn-1".to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status: AppServerHookRunStatus::Running,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: None,
duration_ms: None,
entries: Vec::new(),
},
}),
/*replay_kind*/ None,
);
chat.handle_server_notification(
ServerNotification::HookCompleted(AppServerHookCompletedNotification {
thread_id: ThreadId::new().to_string(),
turn_id: Some("turn-1".to_string()),
run: AppServerHookRunSummary {
id: "user-prompt-submit:0:/tmp/hooks.json".to_string(),
event_name: AppServerHookEventName::UserPromptSubmit,
handler_type: AppServerHookHandlerType::Command,
execution_mode: AppServerHookExecutionMode::Sync,
scope: AppServerHookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status: AppServerHookRunStatus::Stopped,
status_message: Some("checking go-workflow input policy".to_string()),
started_at: 1,
completed_at: Some(11),
duration_ms: Some(10),
entries: vec![
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Warning,
text: "go-workflow must start from PlanMode".to_string(),
},
AppServerHookOutputEntry {
kind: AppServerHookOutputEntryKind::Stop,
text: "prompt blocked".to_string(),
},
],
},
}),
/*replay_kind*/ None,
);
let cells = drain_insert_history(&mut rx);
let combined = cells
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"user_prompt_submit_app_server_hook_notifications_render_snapshot",
combined
);
assert!(!chat.bottom_pane.status_indicator_visible());
}
#[tokio::test]
async fn pre_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_app_server_protocol::HookEventName::PreToolUse,
"pre-tool-use:0:/tmp/hooks.json",
"warming the shell",
"pre_tool_use_hook_events_render_snapshot",
)
.await;
}
#[tokio::test]
async fn post_tool_use_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_app_server_protocol::HookEventName::PostToolUse,
"post-tool-use:0:/tmp/hooks.json",
"warming the shell",
"post_tool_use_hook_events_render_snapshot",
)
.await;
}
#[tokio::test]
async fn completed_hook_with_no_entries_stays_out_of_history() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
/*status_message*/ None,
),
);
assert!(drain_insert_history(&mut rx).is_empty());
reveal_running_hooks(&mut chat);
let running_snapshot = hook_live_and_history_snapshot(&chat, "running", "");
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
let completed_lingering_snapshot =
hook_live_and_history_snapshot(&chat, "completed lingering", "");
expire_quiet_hook_linger(&mut chat);
let completed_snapshot = hook_live_and_history_snapshot(&chat, "completed after linger", "");
assert_chatwidget_snapshot!(
"hook_live_running_then_quiet_completed_snapshot",
format!("{running_snapshot}\n\n{completed_lingering_snapshot}\n\n{completed_snapshot}")
);
}
#[tokio::test]
async fn quiet_hook_linger_starts_when_delayed_redraw_reveals_hook() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
reveal_running_hooks_after_delayed_redraw(&mut chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
assert!(
active_hook_blob(&chat).contains("Running PostToolUse hook"),
"quiet hook should linger after the row becomes visible"
);
expire_quiet_hook_linger(&mut chat);
assert_eq!(active_hook_blob(&chat), "<empty>\n");
}
#[tokio::test]
async fn blocked_and_failed_hooks_render_feedback_and_errors() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_completed(
&mut chat,
hook_completed_run(
"pre-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Blocked,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Feedback,
text: "run tests before touching the fixture".to_string(),
}],
),
);
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:1:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Failed,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Error,
text: "hook exited with code 7".to_string(),
}],
),
);
let rendered = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!("hook_blocked_failed_feedback_history_snapshot", rendered);
assert!(
rendered.contains(
"PreToolUse hook (blocked)\n feedback: run tests before touching the fixture"
),
"expected blocked hook feedback: {rendered:?}"
);
assert!(
rendered.contains("PostToolUse hook (failed)\n error: hook exited with code 7"),
"expected failed hook error: {rendered:?}"
);
}
#[tokio::test]
async fn completed_hook_with_output_flushes_immediately() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command"),
),
);
reveal_running_hooks(&mut chat);
let running_snapshot = hook_live_and_history_snapshot(&chat, "running", "");
handle_hook_completed(
&mut chat,
hook_completed_run(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Blocked,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Feedback,
text: "command blocked by policy".to_string(),
}],
),
);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
let completed_snapshot = hook_live_and_history_snapshot(&chat, "completed", &history);
assert_chatwidget_snapshot!(
"completed_hook_with_output_flushes_immediately_snapshot",
format!("{running_snapshot}\n\n{completed_snapshot}")
);
}
#[tokio::test]
async fn completed_hook_output_precedes_following_assistant_message() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command"),
),
);
reveal_running_hooks(&mut chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
"pre-tool-use:0:/tmp/hooks.json:tool-call-1",
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Blocked,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Feedback,
text: "command blocked by policy".to_string(),
}],
),
);
complete_assistant_message(
&mut chat,
"msg-after-hook",
"The hook feedback was applied.",
/*phase*/ None,
);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"completed_hook_output_precedes_following_assistant_message_snapshot",
format!(
"active hooks:\n{}history:\n{history}",
active_hook_blob(&chat)
)
);
let hook_index = history
.find("PreToolUse hook (blocked)")
.expect("hook feedback should be in history");
let assistant_index = history
.find("The hook feedback was applied.")
.expect("assistant message should be in history");
assert!(
hook_index < assistant_index,
"hook output should precede later assistant text: {history:?}"
);
}
#[tokio::test]
async fn completed_same_id_hook_output_survives_restart() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let hook_id = "stop:0:/tmp/hooks.json";
handle_hook_started(
&mut chat,
hook_started_run(
hook_id,
codex_app_server_protocol::HookEventName::Stop,
Some("checking stop condition"),
),
);
reveal_running_hooks(&mut chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
hook_id,
codex_app_server_protocol::HookEventName::Stop,
codex_app_server_protocol::HookRunStatus::Stopped,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Stop,
text: "continue with more context".to_string(),
}],
),
);
handle_hook_started(
&mut chat,
hook_started_run(
hook_id,
codex_app_server_protocol::HookEventName::Stop,
Some("checking stop condition"),
),
);
reveal_running_hooks(&mut chat);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"completed_same_id_hook_output_survives_restart_snapshot",
format!(
"active hooks:\n{}history:\n{history}",
active_hook_blob(&chat)
)
);
assert!(
history.contains("Stop hook (stopped)\n stop: continue with more context"),
"first hook output should not be overwritten: {history:?}"
);
}
#[tokio::test]
async fn identical_parallel_running_hooks_collapse_to_count() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
for tool_call_id in ["tool-call-1", "tool-call-2", "tool-call-3"] {
handle_hook_started(
&mut chat,
hook_started_run(
&format!("pre-tool-use:0:/tmp/hooks.json:{tool_call_id}"),
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command policy"),
),
);
}
reveal_running_hooks(&mut chat);
assert_chatwidget_snapshot!(
"identical_parallel_running_hooks_collapse_to_count_snapshot",
hook_live_and_history_snapshot(&chat, "running", "")
);
}
#[tokio::test]
async fn overlapping_hook_live_cell_tracks_parallel_quiet_hooks() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.set_status_header("Thinking".to_string());
chat.bottom_pane.ensure_status_indicator();
handle_hook_started(
&mut chat,
hook_started_run(
"pre-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PreToolUse,
Some("checking command policy"),
),
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
reveal_running_hooks(&mut chat);
let first_running_snapshot = hook_live_and_history_snapshot(&chat, "pre running", "");
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:1:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
reveal_running_hooks(&mut chat);
let second_running_snapshot = hook_live_and_history_snapshot(&chat, "post running", "");
handle_hook_completed(
&mut chat,
hook_completed_run(
"pre-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PreToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
let older_completed_snapshot =
hook_live_and_history_snapshot(&chat, "pre completed lingering", "");
expire_quiet_hook_linger(&mut chat);
let older_completed_expired_snapshot =
hook_live_and_history_snapshot(&chat, "pre completed after linger", "");
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:1:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert_eq!(chat.status_state.current_status.header, "Thinking");
assert!(chat.bottom_pane.status_indicator_visible());
assert!(drain_insert_history(&mut rx).is_empty());
let all_completed_lingering_snapshot =
hook_live_and_history_snapshot(&chat, "all completed lingering", "");
expire_quiet_hook_linger(&mut chat);
let all_completed_snapshot = hook_live_and_history_snapshot(&chat, "all completed", "");
assert_chatwidget_snapshot!(
"overlapping_hook_live_cell_snapshot",
format!(
"{first_running_snapshot}\n\n{second_running_snapshot}\n\n{older_completed_snapshot}\n\n{older_completed_expired_snapshot}\n\n{all_completed_lingering_snapshot}\n\n{all_completed_snapshot}"
)
);
}
#[tokio::test]
async fn running_hook_does_not_displace_active_exec_cell() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
let begin = begin_exec(&mut chat, "call-1", "echo done");
let exec_running = active_blob(&chat);
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
reveal_running_hooks(&mut chat);
let exec_and_hook_running = format!(
"active exec:\n{}active hooks:\n{}",
active_blob(&chat),
active_hook_blob(&chat)
);
end_exec(&mut chat, begin, "done", "", /*exit_code*/ 0);
let history_after_exec = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
let hook_running_after_exec = active_hook_blob(&chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
codex_app_server_protocol::HookRunStatus::Completed,
Vec::new(),
),
);
assert!(drain_insert_history(&mut rx).is_empty());
let quiet_hook_completed_lingering = active_hook_blob(&chat);
expire_quiet_hook_linger(&mut chat);
let quiet_hook_completed = active_hook_blob(&chat);
assert_chatwidget_snapshot!(
"hook_runs_while_exec_active_snapshot",
format!(
"exec running:\n{exec_running}\nexec and hook running:\n{exec_and_hook_running}\nhistory after exec:\n{history_after_exec}\nhook running after exec:\n{hook_running_after_exec}\nquiet hook completed lingering:\n{quiet_hook_completed_lingering}\nquiet hook completed:\n{quiet_hook_completed}"
)
);
}
#[tokio::test]
async fn hidden_active_hook_does_not_add_transcript_separator() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
begin_exec(&mut chat, "call-1", "echo done");
let exec_only_line_count = chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("active exec transcript lines")
.len();
handle_hook_started(
&mut chat,
hook_started_run(
"post-tool-use:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::PostToolUse,
Some("checking output policy"),
),
);
let hidden_hook_transcript = chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("active exec transcript lines");
assert_eq!(hidden_hook_transcript.len(), exec_only_line_count);
reveal_running_hooks(&mut chat);
let visible_hook_lines = chat
.active_hook_cell
.as_ref()
.expect("active hook cell")
.transcript_lines(/*width*/ 80);
let visible_hook_transcript = chat
.active_cell_transcript_lines(/*width*/ 80)
.expect("active exec and hook transcript lines");
assert_eq!(
visible_hook_transcript.len(),
exec_only_line_count + 1 + visible_hook_lines.len()
);
assert_eq!(
lines_to_single_string(
&visible_hook_transcript[exec_only_line_count..exec_only_line_count + 1],
),
"\n"
);
}
#[tokio::test]
async fn hook_completed_before_reveal_renders_completed_without_running_flash() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
handle_hook_started(
&mut chat,
hook_started_run(
"session-start:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::SessionStart,
Some("warming the shell"),
),
);
let started_hidden_snapshot = active_hook_blob(&chat);
handle_hook_completed(
&mut chat,
hook_completed_run(
"session-start:0:/tmp/hooks.json",
codex_app_server_protocol::HookEventName::SessionStart,
codex_app_server_protocol::HookRunStatus::Completed,
vec![codex_app_server_protocol::HookOutputEntry {
kind: codex_app_server_protocol::HookOutputEntryKind::Context,
text: "session context".to_string(),
}],
),
);
let history = drain_insert_history(&mut rx)
.iter()
.map(|lines| lines_to_single_string(lines))
.collect::<String>();
assert_chatwidget_snapshot!(
"hook_completed_before_reveal_renders_completed_without_running_flash_snapshot",
format!("started hidden:\n{started_hidden_snapshot}\nhistory:\n{history}")
);
}
#[tokio::test]
async fn session_start_hook_events_render_snapshot() {
assert_hook_events_snapshot(
codex_app_server_protocol::HookEventName::SessionStart,
"session-start:0:/tmp/hooks.json",
"warming the shell",
"session_start_hook_events_render_snapshot",
)
.await;
}
fn hook_started_run(
id: &str,
event_name: codex_app_server_protocol::HookEventName,
status_message: Option<&str>,
) -> codex_app_server_protocol::HookRunSummary {
hook_run_summary(
id,
event_name,
codex_app_server_protocol::HookRunStatus::Running,
status_message,
Vec::new(),
)
}
fn hook_completed_run(
id: &str,
event_name: codex_app_server_protocol::HookEventName,
status: codex_app_server_protocol::HookRunStatus,
entries: Vec<codex_app_server_protocol::HookOutputEntry>,
) -> codex_app_server_protocol::HookRunSummary {
hook_run_summary(
id, event_name, status, /*status_message*/ None, entries,
)
}
fn hook_run_summary(
id: &str,
event_name: codex_app_server_protocol::HookEventName,
status: codex_app_server_protocol::HookRunStatus,
status_message: Option<&str>,
entries: Vec<codex_app_server_protocol::HookOutputEntry>,
) -> codex_app_server_protocol::HookRunSummary {
codex_app_server_protocol::HookRunSummary {
id: id.to_string(),
event_name,
handler_type: codex_app_server_protocol::HookHandlerType::Command,
execution_mode: codex_app_server_protocol::HookExecutionMode::Sync,
scope: codex_app_server_protocol::HookScope::Turn,
source_path: PathBuf::from(test_path_display("/tmp/hooks.json")).abs(),
source: codex_app_server_protocol::HookSource::User,
display_order: 0,
status,
status_message: status_message.map(str::to_string),
started_at: 1,
completed_at: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(2),
duration_ms: (status != codex_app_server_protocol::HookRunStatus::Running).then_some(1),
entries,
}
}
fn hook_live_and_history_snapshot(chat: &ChatWidget, phase: &str, history: &str) -> String {
let history = if history.is_empty() {
"<empty>"
} else {
history
};
format!(
"{phase}\nlive hooks:\n{}history:\n{history}",
active_hook_blob(chat),
)
}
// Combined visual snapshot using vt100 for history + direct buffer overlay for UI.
// This renders the final visual as seen in a terminal: history above, then a blank line,
// then the exec block, another blank line, the status line, a blank line, and the composer.
#[tokio::test]
async fn chatwidget_exec_and_status_layout_vt100_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
complete_assistant_message(
&mut chat,
"msg-search",
"I’m going to search the repo for where “Change Approved” is rendered to update that view.",
/*phase*/ None,
);
let command = vec!["bash".into(), "-lc".into(), "rg \"Change Approved\"".into()];
let parsed_cmd = [
ParsedCommand::Search {
query: Some("Change Approved".into()),
path: None,
cmd: "rg \"Change Approved\"".into(),
},
ParsedCommand::Read {
name: "diff_render.rs".into(),
cmd: "cat diff_render.rs".into(),
path: "diff_render.rs".into(),
},
];
let command_actions = parsed_cmd
.iter()
.cloned()
.map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd))
.collect::<Vec<_>>();
let cwd = chat.config.cwd.clone();
handle_exec_begin(
&mut chat,
AppServerThreadItem::CommandExecution {
id: "c1".into(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd: cwd.clone(),
process_id: None,
source: ExecCommandSource::Agent,
status: AppServerCommandExecutionStatus::InProgress,
command_actions: command_actions.clone(),
aggregated_output: None,
exit_code: None,
duration_ms: None,
},
);
handle_exec_end(
&mut chat,
AppServerThreadItem::CommandExecution {
id: "c1".into(),
command: codex_shell_command::parse_command::shlex_join(&command),
cwd,
process_id: None,
source: ExecCommandSource::Agent,
status: AppServerCommandExecutionStatus::Completed,
command_actions,
aggregated_output: None,
exit_code: Some(0),
duration_ms: Some(16000),
},
);
handle_turn_started(&mut chat, "turn-1");
handle_agent_reasoning_delta(&mut chat, "**Investigating rendering code**");
chat.bottom_pane.set_composer_text(
"Summarize recent commits".to_string(),
Vec::new(),
Vec::new(),
);
let width: u16 = 80;
let ui_height: u16 = chat.desired_height(width);
let vt_height: u16 = 40;
let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height);
let backend = VT100Backend::new(width, vt_height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
term.set_viewport_area(viewport);
for lines in drain_insert_history(&mut rx) {
crate::insert_history::insert_history_lines(&mut term, lines)
.expect("Failed to insert history lines in test");
}
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_chatwidget_snapshot!(
"chatwidget_exec_and_status_layout_vt100_snapshot",
normalize_snapshot_paths(term.backend().vt100().screen().contents())
);
}
// E2E vt100 snapshot for complex markdown with indented and nested fenced code blocks
#[tokio::test]
async fn chatwidget_markdown_code_blocks_vt100_snapshot() {
let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
// Simulate a final agent message via streaming deltas instead of a single message
handle_turn_started(&mut chat, "turn-1");
// Build a vt100 visual from the history insertions only (no UI overlay)
let width: u16 = 80;
let height: u16 = 50;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
// Place viewport at the last line so that history lines insert above it
term.set_viewport_area(Rect::new(0, height - 1, width, 1));
// Simulate streaming via AgentMessageDelta in 2-character chunks (no final AgentMessage).
let source: &str = r#"
-- Indented code block (4 spaces)
SELECT *
FROM "users"
WHERE "email" LIKE '%@example.com';
````markdown
```sh
printf 'fenced within fenced\n'
```
````
```jsonc
{
// comment allowed in jsonc
"path": "C:\\Program Files\\App",
"regex": "^foo.*(bar)?$"
}
```
"#;
let mut it = source.chars();
loop {
let mut delta = String::new();
match it.next() {
Some(c) => delta.push(c),
None => break,
}
if let Some(c2) = it.next() {
delta.push(c2);
}
handle_agent_message_delta(&mut chat, delta);
// Drive commit ticks and drain emitted history lines into the vt100 buffer.
loop {
chat.on_commit_tick();
let mut inserted_any = false;
while let Ok(app_ev) = rx.try_recv() {
if let AppEvent::InsertHistoryCell(cell) = app_ev {
let lines = cell.display_lines(width);
crate::insert_history::insert_history_lines(&mut term, lines)
.expect("Failed to insert history lines in test");
inserted_any = true;
}
}
if !inserted_any {
break;
}
}
}
// Finalize the stream without sending a final AgentMessage, to flush any tail.
handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None);
for lines in drain_insert_history(&mut rx) {
crate::insert_history::insert_history_lines(&mut term, lines)
.expect("Failed to insert history lines in test");
}
assert_chatwidget_snapshot!(
"chatwidget_markdown_code_blocks_vt100_snapshot",
normalize_snapshot_paths(term.backend().vt100().screen().contents())
);
}
#[tokio::test]
async fn chatwidget_tall() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await;
chat.thread_id = Some(ThreadId::new());
handle_turn_started(&mut chat, "turn-1");
for i in 0..30 {
chat.queue_user_message(format!("Hello, world! {i}").into());
}
let width: u16 = 80;
let height: u16 = 24;
let backend = VT100Backend::new(width, height);
let mut term = crate::custom_terminal::Terminal::with_options(backend).expect("terminal");
let desired_height = chat.desired_height(width).min(height);
term.set_viewport_area(Rect::new(0, height - desired_height, width, desired_height));
term.draw(|f| {
chat.render(f.area(), f.buffer_mut());
})
.unwrap();
assert_chatwidget_snapshot!(
"chatwidget_tall",
normalize_snapshot_paths(term.backend().vt100().screen().contents())
);
}