use super::*;use crate::bottom_pane::goal_status_indicator_line;use crate::chatwidget::rate_limits::NUDGE_MODEL_SLUG;use crate::chatwidget::rate_limits::get_limits_duration;use codex_app_server_protocol::SpendControlLimitSnapshot;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('━'), "expected completed plan table to render with separators, 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 = get_model_offline_for_tests(cfg.model.as_deref()); let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { config: cfg.clone(), 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, has_codex_backend_auth: 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" );}#[test]fn rate_limit_duration_labels_only_render_supported_windows() { assert_eq!(get_limits_duration(2 * 60), None); assert_eq!(get_limits_duration(24 * 60).as_deref(), Some("daily")); assert_eq!( get_limits_duration(365 * 24 * 60).as_deref(), Some("annual") );}#[tokio::test]async fn test_rate_limit_warnings_use_generic_fallback_labels() { let mut state = RateLimitWarningState::default(); assert_eq!( state.take_warnings( /*secondary_used_percent*/ Some(75.0), /*secondary_window_minutes*/ None, /*primary_used_percent*/ Some(75.0), /*primary_window_minutes*/ None, ), vec![ String::from( "Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.", ), String::from( "Heads up, you have less than 25% of your usage limit left. Run /status for a breakdown.", ), ], );}#[tokio::test]async fn test_rate_limit_warnings_use_secondary_fallback_for_unsupported_window() { let mut state = RateLimitWarningState::default(); assert_eq!( state.take_warnings( /*secondary_used_percent*/ Some(75.0), /*secondary_window_minutes*/ Some(2 * 60), /*primary_used_percent*/ None, /*primary_window_minutes*/ None, ), vec![String::from( "Heads up, you have less than 25% of your secondary usage limit left. Run /status for a breakdown.", )], );}#[tokio::test]async fn status_line_uses_secondary_fallback_for_unsupported_window() { 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: Some(RateLimitWindow { used_percent: 50, window_duration_mins: Some(2 * 60), resets_at: None, }), credits: None, individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), Some("secondary usage 50% left".to_string()) );}#[tokio::test]async fn status_line_legacy_limit_items_prefer_matching_windows() { 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: 94, window_duration_mins: Some(7 * 24 * 60), resets_at: None, }), secondary: Some(RateLimitWindow { used_percent: 40, window_duration_mins: Some(5 * 60), resets_at: None, }), credits: None, individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), Some("5h 60% left".to_string()) ); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), Some("weekly 6% left".to_string()) );}#[tokio::test]async fn status_line_shows_secondary_non_weekly_when_primary_is_weekly() { 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: 94, window_duration_mins: Some(7 * 24 * 60), resets_at: None, }), secondary: Some(RateLimitWindow { used_percent: 35, window_duration_mins: Some(30 * 24 * 60), resets_at: None, }), credits: None, individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), Some("monthly 65% left".to_string()) ); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), Some("weekly 6% left".to_string()) );}#[tokio::test]async fn status_line_five_hour_item_omits_weekly_only_limit() { 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: 9, window_duration_mins: Some(7 * 24 * 60), resets_at: None, }), secondary: None, credits: None, individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), None ); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), Some("weekly 91% left".to_string()) );}#[tokio::test]async fn status_line_single_monthly_primary_omits_weekly_limit_item() { 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: 35, window_duration_mins: Some(30 * 24 * 60), resets_at: None, }), secondary: None, credits: None, individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), Some("monthly 65% left".to_string()) ); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), None );}#[tokio::test]async fn status_line_secondary_only_non_weekly_limit_omits_primary_limit_item() { 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: Some(RateLimitWindow { used_percent: 35, window_duration_mins: Some(30 * 24 * 60), resets_at: None, }), credits: None, individual_limit: None, plan_type: None, rate_limit_reached_type: None, })); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::FiveHourLimit), None ); assert_eq!( chat.status_line_value_for_item(crate::bottom_pane::StatusLineItem::WeeklyLimit), Some("monthly 65% left".to_string()) );}#[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()), }), individual_limit: None, 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, individual_limit: 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 rolling_rate_limit_snapshot_preserves_prior_individual_limit() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut usage_limits = snapshot(/*percent*/ 10.0); usage_limits.individual_limit = Some(SpendControlLimitSnapshot { limit: "25000".to_string(), used: "8000".to_string(), remaining_percent: 68, resets_at: 1_800_000_000, }); chat.on_rate_limit_snapshot(Some(usage_limits)); chat.on_rolling_rate_limit_snapshot(snapshot(/*percent*/ 20.0)); let display = chat .rate_limit_snapshots_by_limit_id .get("codex") .expect("rate limits should be cached"); let individual_limit = display .individual_limit .as_ref() .expect("rolling updates should preserve monthly limits"); assert_eq!(individual_limit.used, "8,000"); assert_eq!(individual_limit.limit, "25,000"); assert_eq!(individual_limit.percent_remaining, 68.0); chat.on_rate_limit_snapshot(Some(snapshot(/*percent*/ 30.0))); let display = chat .rate_limit_snapshots_by_limit_id .get("codex") .expect("rate limits should be cached"); assert!(display.individual_limit.is_none());}#[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, individual_limit: 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, individual_limit: 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, individual_limit: 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()), }), individual_limit: None, 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, individual_limit: 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, individual_limit: 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 ctrl_c_interrupt_pauses_active_goal_turn() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.set_feature_enabled(Feature::Goals, /*enabled*/ true); chat.thread_id = Some(thread_id); let mut goal = test_thread_goal( codex_app_server_protocol::ThreadGoalStatus::Active, /*token_budget*/ Some(50_000), /*tokens_used*/ 40_000, ); goal.thread_id = thread_id.to_string(); chat.handle_server_notification( ServerNotification::ThreadGoalUpdated( codex_app_server_protocol::ThreadGoalUpdatedNotification { thread_id: thread_id.to_string(), turn_id: None, goal, }, ), /*replay_kind*/ None, ); chat.on_task_started(); 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_matches!( rx.try_recv(), Ok(AppEvent::SetThreadGoalStatus { thread_id: event_thread_id, status: AppThreadGoalStatus::Paused, }) if event_thread_id == thread_id );}#[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 completed_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( "post-tool-use:0:/tmp/hooks.json", codex_app_server_protocol::HookEventName::PostToolUse, /*status_message*/ None, ), ); reveal_running_hooks(&mut chat); let before_completion = active_hook_blob(&chat); handle_turn_completed(&mut chat, "turn-1", /*duration_ms*/ None); assert_chatwidget_snapshot!( "completed_turn_clears_visible_running_hook", format!( "before completion:\n{before_completion}after completion:\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_and_terminal_title_reasoning_render_only_effort() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.4")).await; chat.config.tui_status_line = Some(vec!["reasoning".to_string()]); chat.config.tui_terminal_title = Some(vec!["reasoning".to_string()]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::XHigh)); chat.set_service_tier(Some(ServiceTier::Fast.request_value().to_string())); chat.refresh_status_line(); chat.refresh_terminal_title(); assert_eq!(status_line_text(&chat), Some("xhigh".to_string())); assert_eq!(chat.last_terminal_title, Some("xhigh".to_string()));}#[tokio::test]async fn status_line_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!["reasoning".to_string()]); chat.set_reasoning_effort(Some(ReasoningEffortConfig::High)); assert_eq!(status_line_text(&chat), Some("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("medium".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()), collaboration_mode: None, personality: None, 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::Blocked, /*token_budget*/ None, /*tokens_used*/ 0, )), Some(GoalStatusIndicator::Blocked) ); assert_eq!( goal_status_indicator_from_app_goal(&test_thread_goal( codex_app_server_protocol::ThreadGoalStatus::UsageLimited, /*token_budget*/ None, /*tokens_used*/ 0, )), Some(GoalStatusIndicator::UsageLimited) ); 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::Blocked, "Goal blocked (/goal resume)"), ( GoalStatusIndicator::UsageLimited, "Goal hit usage limits (/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\nsecond line".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```shprintf '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()) );}