use super::*;use codex_app_server_protocol::PluginAvailability;use pretty_assertions::assert_eq;pub(super) async fn test_config() -> Config { // Start from the built-in defaults so tests do not inherit host/system config. let codex_home = tempfile::Builder::new() .prefix("chatwidget-tests-") .tempdir() .expect("tempdir") .keep(); let mut config = Config::load_default_with_cli_overrides_for_codex_home(codex_home.clone(), Vec::new()) .await .expect("config"); config.codex_home = codex_home.abs(); config.sqlite_home = codex_home.clone(); config.log_dir = codex_home.join("log"); config.cwd = PathBuf::from(test_path_display("/tmp/project")).abs(); config.config_layer_stack = ConfigLayerStack::default(); config.startup_warnings.clear(); config}pub(super) fn test_project_path() -> PathBuf { PathBuf::from(test_path_display("/tmp/project"))}pub(super) fn truncated_path_variants(path: &str) -> Vec<String> { let chars: Vec<char> = path.chars().collect(); (1..chars.len()) .map(|len| chars[..len].iter().collect::<String>()) .collect()}pub(super) fn normalize_snapshot_paths(text: impl Into<String>) -> String { let mut text = text.into(); for unix_path in ["/tmp/project", "/tmp/hooks.json"] { let platform_path = test_path_display(unix_path); if platform_path != unix_path { text = text.replace(&platform_path, unix_path); } } let platform_test_cwd = test_path_display("/tmp/project"); if platform_test_cwd == "/tmp/project" { text } else { for platform_prefix in truncated_path_variants(&platform_test_cwd) .into_iter() .rev() { let unix_prefix: String = "/tmp/project" .chars() .take(platform_prefix.chars().count()) .collect(); text = text.replace(&format!("{platform_prefix}…"), &format!("{unix_prefix}…")); } text }}pub(super) fn normalized_backend_snapshot<T: std::fmt::Display>(value: &T) -> String { let platform_test_cwd = test_path_display("/tmp/project"); let rendered = format!("{value}"); if platform_test_cwd == "/tmp/project" { return rendered; } rendered .lines() .map(|line| { if let Some(content) = line .strip_prefix('"') .and_then(|line| line.strip_suffix('"')) { let width = content.chars().count(); let normalized = normalize_snapshot_paths(content); format!("\"{normalized:width$}\"") } else { normalize_snapshot_paths(line) } }) .collect::<Vec<_>>() .join("\n")}pub(super) fn invalid_value( candidate: impl Into<String>, allowed: impl Into<String>,) -> ConstraintError { ConstraintError::InvalidValue { field_name: "<unknown>", candidate: candidate.into(), allowed: allowed.into(), requirement_source: RequirementSource::Unknown, }}pub(super) fn snapshot(percent: f64) -> RateLimitSnapshot { RateLimitSnapshot { limit_id: None, limit_name: None, primary: Some(RateLimitWindow { used_percent: percent.round() as i32, window_duration_mins: Some(60), resets_at: None, }), secondary: None, credits: None, individual_limit: None, plan_type: None, rate_limit_reached_type: None, }}pub(super) fn test_session_telemetry(config: &Config, model: &str) -> SessionTelemetry { let model_info = construct_model_info_offline_for_tests(model, &config.to_models_manager_config()); SessionTelemetry::new( ThreadId::new(), model, model_info.slug.as_str(), /*account_id*/ None, /*account_email*/ None, /*auth_mode*/ None, "test_originator".to_string(), /*log_user_prompts*/ false, "test".to_string(), crate::test_support::session_source_cli(), )}pub(super) fn test_model_catalog(_config: &Config) -> Arc<ModelCatalog> { Arc::new(ModelCatalog::new( crate::test_support::TEST_MODEL_PRESETS.clone(), ))}// --- Helpers for tests that need direct construction and event draining ---pub(super) async fn make_chatwidget_manual( model_override: Option<&str>,) -> ( ChatWidget, tokio::sync::mpsc::UnboundedReceiver<AppEvent>, tokio::sync::mpsc::UnboundedReceiver<Op>,) { make_chatwidget_manual_with_auth( model_override, /*has_chatgpt_account*/ false, /*has_codex_backend_auth*/ false, ) .await}pub(super) async fn make_chatwidget_manual_with_auth( model_override: Option<&str>, has_chatgpt_account: bool, has_codex_backend_auth: bool,) -> ( ChatWidget, tokio::sync::mpsc::UnboundedReceiver<AppEvent>, tokio::sync::mpsc::UnboundedReceiver<Op>,) { let (tx_raw, rx) = unbounded_channel::<AppEvent>(); let app_event_tx = AppEventSender::new(tx_raw); let (op_tx, op_rx) = unbounded_channel::<Op>(); let mut cfg = test_config().await; let resolved_model = model_override .map(str::to_owned) .unwrap_or_else(|| get_model_offline_for_tests(cfg.model.as_deref())); if let Some(model) = model_override { cfg.model = Some(model.to_string()); } let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let model_catalog = test_model_catalog(&cfg); let common = ChatWidgetInit { config: cfg, frame_requester: FrameRequester::test_dummy(), app_event_tx, workspace_command_runner: None, initial_user_message: None, enhanced_keys_supported: false, has_chatgpt_account, has_codex_backend_auth, model_catalog, 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.clone()), 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 mut widget = ChatWidget::new_with_op_target(common, super::CodexOpTarget::Direct(op_tx)); widget.transcript.active_cell = None; widget.transcript.active_cell_revision = 0; widget.normal_placeholder_text = "Ask Codex to do anything".to_string(); widget.side_placeholder_text = "Check recently modified functions for compatibility".to_string(); widget .bottom_pane .set_placeholder_text(widget.normal_placeholder_text.clone()); widget.set_model(&resolved_model); (widget, rx, op_rx)}// ChatWidget may emit other `Op`s (e.g. history/logging updates) on the same channel; this helper// filters until we see a submission op.pub(super) fn next_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) -> Op { loop { match op_rx.try_recv() { Ok(op @ Op::UserTurn { .. }) => return op, Ok(_) => continue, Err(TryRecvError::Empty) => panic!("expected a submit op but queue was empty"), Err(TryRecvError::Disconnected) => panic!("expected submit op but channel closed"), } }}pub(super) fn next_interrupt_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) { loop { match op_rx.try_recv() { Ok(Op::Interrupt { .. }) => return, Ok(_) => continue, Err(TryRecvError::Empty) => panic!("expected interrupt op but queue was empty"), Err(TryRecvError::Disconnected) => panic!("expected interrupt op but channel closed"), } }}pub(super) fn assert_no_submit_op(op_rx: &mut tokio::sync::mpsc::UnboundedReceiver<Op>) { while let Ok(op) = op_rx.try_recv() { assert!( !matches!(op, Op::UserTurn { .. }), "unexpected submit op: {op:?}" ); }}pub(crate) fn set_chatgpt_auth(chat: &mut ChatWidget) { chat.has_chatgpt_account = true; chat.has_codex_backend_auth = true; chat.model_catalog = test_model_catalog(&chat.config);}fn test_model_info(slug: &str, priority: i32, supports_fast_mode: bool) -> ModelInfo { let mut service_tiers = Vec::new(); if supports_fast_mode { service_tiers.push(json!({ "id": ServiceTier::Fast.request_value(), "name": "fast", "description": "Fastest inference with increased plan usage" })); } serde_json::from_value(json!({ "slug": slug, "display_name": slug, "description": format!("{slug} description"), "default_reasoning_level": "medium", "supported_reasoning_levels": [{"effort": "medium", "description": "medium"}], "shell_type": "shell_command", "visibility": "list", "supported_in_api": true, "priority": priority, "additional_speed_tiers": [], "service_tiers": service_tiers, "default_service_tier": null, "availability_nux": null, "upgrade": null, "base_instructions": "base instructions", "supports_reasoning_summaries": false, "default_reasoning_summary": "none", "support_verbosity": false, "default_verbosity": null, "apply_patch_tool_type": null, "truncation_policy": {"mode": "bytes", "limit": 10_000}, "supports_parallel_tool_calls": false, "supports_image_detail_original": false, "context_window": 272_000, "experimental_supported_tools": [], })) .expect("valid model info")}pub(crate) fn set_fast_mode_test_catalog(chat: &mut ChatWidget) { let models: Vec<ModelPreset> = ModelsResponse { models: vec![ test_model_info( "gpt-5.4", /*priority*/ 0, /*supports_fast_mode*/ true, ), test_model_info( "gpt-5.3-codex", /*priority*/ 1, /*supports_fast_mode*/ false, ), ], } .models .into_iter() .map(Into::into) .collect(); chat.model_catalog = Arc::new(ModelCatalog::new(models));}pub(crate) async fn make_chatwidget_manual_with_sender() -> ( ChatWidget, AppEventSender, tokio::sync::mpsc::UnboundedReceiver<AppEvent>, tokio::sync::mpsc::UnboundedReceiver<Op>,) { let (widget, rx, op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let app_event_tx = widget.app_event_tx.clone(); (widget, app_event_tx, rx, op_rx)}pub(super) fn drain_insert_history( rx: &mut tokio::sync::mpsc::UnboundedReceiver<AppEvent>,) -> Vec<Vec<ratatui::text::Line<'static>>> { let mut out = Vec::new(); while let Ok(ev) = rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = ev { let mut lines = cell.display_lines(/*width*/ 80); if !cell.is_stream_continuation() && !out.is_empty() && !lines.is_empty() { lines.insert(0, "".into()); } out.push(lines) } } out}pub(super) fn lines_to_single_string(lines: &[ratatui::text::Line<'static>]) -> String { let mut s = String::new(); for line in lines { for span in &line.spans { s.push_str(&span.content); } s.push('\n'); } s}pub(super) fn status_line_text(chat: &ChatWidget) -> Option<String> { chat.status_line_text()}pub(super) fn make_token_info(total_tokens: i64, context_window: i64) -> TokenUsageInfo { fn usage(total_tokens: i64) -> TokenUsage { TokenUsage { total_tokens, ..TokenUsage::default() } } TokenUsageInfo { total_token_usage: usage(total_tokens), last_token_usage: usage(total_tokens), model_context_window: Some(context_window), }}fn thread_id(chat: &ChatWidget) -> String { chat.thread_id.map(|id| id.to_string()).unwrap_or_default()}fn token_usage_breakdown(usage: TokenUsage) -> codex_app_server_protocol::TokenUsageBreakdown { codex_app_server_protocol::TokenUsageBreakdown { total_tokens: usage.total_tokens, input_tokens: usage.input_tokens, cached_input_tokens: usage.cached_input_tokens, output_tokens: usage.output_tokens, reasoning_output_tokens: usage.reasoning_output_tokens, }}pub(super) fn handle_token_count(chat: &mut ChatWidget, info: Option<TokenUsageInfo>) { match info { Some(info) => { chat.handle_server_notification( ServerNotification::ThreadTokenUsageUpdated( codex_app_server_protocol::ThreadTokenUsageUpdatedNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), token_usage: codex_app_server_protocol::ThreadTokenUsage { total: token_usage_breakdown(info.total_token_usage), last: token_usage_breakdown(info.last_token_usage), model_context_window: info.model_context_window, }, }, ), /*replay_kind*/ None, ); } None => chat.set_token_info(/*info*/ None), }}pub(super) fn handle_error( chat: &mut ChatWidget, message: impl Into<String>, codex_error_info: Option<CodexErrorInfo>,) { chat.handle_server_notification( ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: message.into(), codex_error_info, additional_details: None, }, will_retry: false, thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), }), /*replay_kind*/ None, );}pub(super) fn handle_stream_error( chat: &mut ChatWidget, message: impl Into<String>, additional_details: Option<String>,) { handle_stream_error_with_replay(chat, message, additional_details, /*replay_kind*/ None);}pub(super) fn handle_stream_error_with_replay( chat: &mut ChatWidget, message: impl Into<String>, additional_details: Option<String>, replay_kind: Option<ReplayKind>,) { chat.handle_server_notification( ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: message.into(), codex_error_info: None, additional_details, }, will_retry: true, thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), }), replay_kind, );}pub(super) fn handle_warning(chat: &mut ChatWidget, message: impl Into<String>) { chat.handle_server_notification( ServerNotification::Warning(WarningNotification { thread_id: Some(thread_id(chat)), message: message.into(), }), /*replay_kind*/ None, );}pub(super) fn handle_model_verification( chat: &mut ChatWidget, verifications: Vec<AppServerModelVerification>,) { chat.handle_server_notification( ServerNotification::ModelVerification(ModelVerificationNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), verifications, }), /*replay_kind*/ None, );}pub(super) fn handle_agent_message_delta(chat: &mut ChatWidget, delta: impl Into<String>) { chat.handle_server_notification( ServerNotification::AgentMessageDelta( codex_app_server_protocol::AgentMessageDeltaNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), item_id: "msg-1".to_string(), delta: delta.into(), }, ), /*replay_kind*/ None, );}pub(super) fn handle_agent_reasoning_delta(chat: &mut ChatWidget, delta: impl Into<String>) { chat.handle_server_notification( ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), item_id: "reasoning-1".to_string(), delta: delta.into(), summary_index: 0, }), /*replay_kind*/ None, );}pub(super) fn handle_agent_reasoning_final(chat: &mut ChatWidget) { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), completed_at_ms: 0, item: AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), summary: Vec::new(), content: Vec::new(), }, }), /*replay_kind*/ None, );}pub(super) fn handle_entered_review_mode(chat: &mut ChatWidget, review: impl Into<String>) { chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), started_at_ms: 0, item: AppServerThreadItem::EnteredReviewMode { id: "review-start".to_string(), review: review.into(), }, }), /*replay_kind*/ None, );}pub(super) fn replay_entered_review_mode(chat: &mut ChatWidget, review: impl Into<String>) { chat.replay_thread_item( AppServerThreadItem::EnteredReviewMode { id: "review-start".to_string(), review: review.into(), }, "turn-1".to_string(), ReplayKind::ThreadSnapshot, );}pub(super) fn handle_exited_review_mode(chat: &mut ChatWidget) { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), completed_at_ms: 0, item: AppServerThreadItem::ExitedReviewMode { id: "review-end".to_string(), review: String::new(), }, }), /*replay_kind*/ None, );}pub(super) fn handle_exec_approval_request( chat: &mut ChatWidget, id: impl Into<String>, event: ExecApprovalRequestEvent,) { chat.on_exec_approval_request(id.into(), event);}pub(super) fn handle_apply_patch_approval_request( chat: &mut ChatWidget, id: impl Into<String>, event: ApplyPatchApprovalRequestEvent,) { chat.on_apply_patch_approval_request(id.into(), event);}fn file_update_changes_from_tui(changes: HashMap<PathBuf, FileChange>) -> Vec<FileUpdateChange> { changes .into_iter() .map(|(path, change)| { let (kind, diff) = match change { FileChange::Add { content } => (PatchChangeKind::Add, content), FileChange::Delete { content } => (PatchChangeKind::Delete, content), FileChange::Update { unified_diff, move_path, } => (PatchChangeKind::Update { move_path }, unified_diff), }; FileUpdateChange { path: path.display().to_string(), kind, diff, } }) .collect()}pub(super) fn handle_patch_apply_begin( chat: &mut ChatWidget, call_id: impl Into<String>, turn_id: impl Into<String>, changes: HashMap<PathBuf, FileChange>,) { chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: thread_id(chat), turn_id: turn_id.into(), started_at_ms: 0, item: AppServerThreadItem::FileChange { id: call_id.into(), changes: file_update_changes_from_tui(changes), status: AppServerPatchApplyStatus::InProgress, }, }), /*replay_kind*/ None, );}pub(super) fn handle_patch_apply_end( chat: &mut ChatWidget, call_id: impl Into<String>, turn_id: impl Into<String>, changes: HashMap<PathBuf, FileChange>, status: AppServerPatchApplyStatus,) { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: turn_id.into(), completed_at_ms: 0, item: AppServerThreadItem::FileChange { id: call_id.into(), changes: file_update_changes_from_tui(changes), status, }, }), /*replay_kind*/ None, );}pub(super) fn handle_view_image_tool_call( chat: &mut ChatWidget, call_id: impl Into<String>, path: AbsolutePathBuf,) { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::ImageView { id: call_id.into(), path, }, }), /*replay_kind*/ None, );}pub(super) fn handle_image_generation_end( chat: &mut ChatWidget, call_id: impl Into<String>, status: impl Into<String>, revised_prompt: Option<String>, saved_path: Option<AbsolutePathBuf>,) { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::ImageGeneration { id: call_id.into(), status: status.into(), revised_prompt, result: String::new(), saved_path, }, }), /*replay_kind*/ None, );}pub(super) fn replay_user_message_inputs( chat: &mut ChatWidget, item_id: &str, content: Vec<AppServerUserInput>, replay_kind: ReplayKind,) { chat.replay_thread_item( AppServerThreadItem::UserMessage { id: item_id.to_string(), client_id: None, content, }, "turn-1".to_string(), replay_kind, );}pub(super) fn replay_user_message_text( chat: &mut ChatWidget, item_id: &str, text: impl Into<String>, replay_kind: ReplayKind,) { replay_user_message_inputs( chat, item_id, vec![AppServerUserInput::Text { text: text.into(), text_elements: Vec::new(), }], replay_kind, );}pub(super) fn replay_agent_message( chat: &mut ChatWidget, item_id: &str, text: impl Into<String>, replay_kind: ReplayKind,) { chat.replay_thread_item( AppServerThreadItem::AgentMessage { id: item_id.to_string(), text: text.into(), phase: Some(MessagePhase::FinalAnswer), memory_citation: None, }, "turn-1".to_string(), replay_kind, );}pub(super) fn replay_turn_started(chat: &mut ChatWidget, replay_kind: ReplayKind) { chat.handle_server_notification( ServerNotification::TurnStarted(TurnStartedNotification { thread_id: thread_id(chat), turn: app_server_turn( "turn-1", AppServerTurnStatus::InProgress, /*duration_ms*/ None, /*error*/ None, ), }), Some(replay_kind), );}pub(super) fn replay_agent_message_delta( chat: &mut ChatWidget, delta: impl Into<String>, replay_kind: ReplayKind,) { chat.handle_server_notification( ServerNotification::AgentMessageDelta( codex_app_server_protocol::AgentMessageDeltaNotification { thread_id: thread_id(chat), turn_id: "turn-1".to_string(), item_id: "msg-1".to_string(), delta: delta.into(), }, ), Some(replay_kind), );}// --- Small helpers to tersely drive exec begin/end and snapshot active cell ---pub(super) fn begin_exec_with_source( chat: &mut ChatWidget, call_id: &str, raw_cmd: &str, source: ExecCommandSource,) -> AppServerThreadItem { // Build the full command vec and parse it using core's parser, // then convert to protocol variants for the event payload. let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; let command_actions = codex_shell_command::parse_command::parse_command(&command) .into_iter() .map(|parsed| AppServerCommandAction::from_core_with_cwd(parsed, &chat.config.cwd)) .collect(); let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: chat.config.cwd.clone(), process_id: None, source, status: AppServerCommandExecutionStatus::InProgress, command_actions, aggregated_output: None, exit_code: None, duration_ms: None, }; handle_exec_begin(chat, item.clone()); item}pub(super) fn begin_unified_exec_startup( chat: &mut ChatWidget, call_id: &str, process_id: &str, raw_cmd: &str,) -> AppServerThreadItem { let command = vec!["bash".to_string(), "-lc".to_string(), raw_cmd.to_string()]; let item = AppServerThreadItem::CommandExecution { id: call_id.to_string(), command: codex_shell_command::parse_command::shlex_join(&command), cwd: chat.config.cwd.clone(), process_id: Some(process_id.to_string()), source: ExecCommandSource::UnifiedExecStartup, status: AppServerCommandExecutionStatus::InProgress, command_actions: Vec::new(), aggregated_output: None, exit_code: None, duration_ms: None, }; handle_exec_begin(chat, item.clone()); item}pub(super) fn handle_exec_begin(chat: &mut ChatWidget, item: AppServerThreadItem) { chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), started_at_ms: 0, item, }), /*replay_kind*/ None, );}pub(super) fn terminal_interaction( chat: &mut ChatWidget, call_id: &str, process_id: &str, stdin: &str,) { chat.handle_server_notification( ServerNotification::TerminalInteraction( codex_app_server_protocol::TerminalInteractionNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), item_id: call_id.to_string(), process_id: process_id.to_string(), stdin: stdin.to_string(), }, ), /*replay_kind*/ None, );}pub(super) fn complete_assistant_message( chat: &mut ChatWidget, item_id: &str, text: &str, phase: Option<MessagePhase>,) { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::AgentMessage { id: item_id.to_string(), text: text.to_string(), phase, memory_citation: None, }, }), /*replay_kind*/ None, );}pub(super) fn pending_steer(text: &str) -> PendingSteer { PendingSteer { user_message: UserMessage::from(text), history_record: UserMessageHistoryRecord::UserMessageText, compare_key: PendingSteerCompareKey { message: text.to_string(), image_count: 0, }, }}pub(super) fn complete_user_message(chat: &mut ChatWidget, item_id: &str, text: &str) { complete_user_message_for_inputs( chat, item_id, vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), }], );}pub(super) fn complete_user_message_for_inputs( chat: &mut ChatWidget, item_id: &str, content: Vec<UserInput>,) { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::UserMessage { id: item_id.to_string(), client_id: None, content, }, }), /*replay_kind*/ None, );}pub(super) fn app_server_turn( turn_id: &str, status: AppServerTurnStatus, duration_ms: Option<i64>, error: Option<AppServerTurnError>,) -> AppServerTurn { AppServerTurn { id: turn_id.to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status, error, started_at: None, completed_at: None, duration_ms, }}pub(super) fn handle_turn_started(chat: &mut ChatWidget, turn_id: &str) { chat.handle_server_notification( ServerNotification::TurnStarted(TurnStartedNotification { thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn: app_server_turn( turn_id, AppServerTurnStatus::InProgress, /*duration_ms*/ None, /*error*/ None, ), }), /*replay_kind*/ None, );}pub(super) fn handle_turn_completed( chat: &mut ChatWidget, turn_id: &str, duration_ms: Option<i64>,) { chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn: app_server_turn( turn_id, AppServerTurnStatus::Completed, duration_ms, /*error*/ None, ), }), /*replay_kind*/ None, );}pub(super) fn handle_turn_interrupted(chat: &mut ChatWidget, turn_id: &str) { chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: chat.thread_id.map(|id| id.to_string()).unwrap_or_default(), turn: app_server_turn( turn_id, AppServerTurnStatus::Interrupted, /*duration_ms*/ None, /*error*/ None, ), }), /*replay_kind*/ None, );}pub(super) fn handle_budget_limited_turn(chat: &mut ChatWidget, turn_id: &str) { chat.turn_lifecycle.mark_budget_limited(turn_id.to_string()); handle_turn_interrupted(chat, turn_id);}pub(super) fn begin_exec( chat: &mut ChatWidget, call_id: &str, raw_cmd: &str,) -> AppServerThreadItem { begin_exec_with_source(chat, call_id, raw_cmd, ExecCommandSource::Agent)}pub(super) fn end_exec( chat: &mut ChatWidget, begin_item: AppServerThreadItem, stdout: &str, stderr: &str, exit_code: i32,) { let aggregated = if stderr.is_empty() { stdout.to_string() } else { format!("{stdout}{stderr}") }; let AppServerThreadItem::CommandExecution { id, command, cwd, process_id, source, command_actions, .. } = begin_item else { panic!("expected command execution item"); }; handle_exec_end( chat, AppServerThreadItem::CommandExecution { id, command, cwd, process_id, source, status: if exit_code == 0 { AppServerCommandExecutionStatus::Completed } else { AppServerCommandExecutionStatus::Failed }, command_actions, aggregated_output: (!aggregated.is_empty()).then_some(aggregated), exit_code: Some(exit_code), duration_ms: Some(5), }, );}pub(super) fn handle_exec_end(chat: &mut ChatWidget, item: AppServerThreadItem) { chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: thread_id(chat), turn_id: chat .turn_lifecycle .last_turn_id .clone() .unwrap_or_else(|| "turn-1".to_string()), completed_at_ms: 0, item, }), /*replay_kind*/ None, );}pub(super) fn active_blob(chat: &ChatWidget) -> String { let lines = chat .transcript .active_cell .as_ref() .expect("active cell present") .display_lines(/*width*/ 80); lines_to_single_string(&lines)}pub(super) fn active_hook_blob(chat: &ChatWidget) -> String { let Some(cell) = chat.active_hook_cell.as_ref() else { return "<empty>\n".to_string(); }; let lines = cell.display_lines(/*width*/ 80); lines_to_single_string(&lines)}pub(super) fn expire_quiet_hook_linger(chat: &mut ChatWidget) { if let Some(cell) = chat.active_hook_cell.as_mut() { cell.expire_quiet_runs_now_for_test(); } chat.pre_draw_tick();}pub(super) fn reveal_running_hooks(chat: &mut ChatWidget) { if let Some(cell) = chat.active_hook_cell.as_mut() { cell.reveal_running_runs_now_for_test(); } chat.pre_draw_tick();}pub(super) fn reveal_running_hooks_after_delayed_redraw(chat: &mut ChatWidget) { if let Some(cell) = chat.active_hook_cell.as_mut() { cell.reveal_running_runs_after_delayed_redraw_for_test(); } chat.pre_draw_tick();}pub(super) fn get_available_model(chat: &ChatWidget, model: &str) -> ModelPreset { let models = chat .model_catalog .try_list_models() .expect("models lock available"); models .iter() .find(|&preset| preset.model == model) .cloned() .unwrap_or_else(|| panic!("{model} preset not found"))}pub(super) async fn assert_shift_left_edits_most_recent_queued_message_for_terminal( terminal_info: TerminalInfo,) { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.queued_message_edit_hint_binding = Some(queued_message_edit_binding_for_terminal(terminal_info)); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(/*running*/ true); // Seed two queued messages. chat.input_queue .queued_user_messages .push_back(UserMessage::from("first queued".to_string()).into()); chat.input_queue .queued_user_messages .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); // Press Shift+Left to edit the most recent (last) queued message. chat.handle_key_event(KeyEvent::new(KeyCode::Left, KeyModifiers::SHIFT)); // Composer should now contain the last queued message. assert_eq!( chat.bottom_pane.composer_text(), "second queued".to_string() ); // And the queue should now contain only the remaining (older) item. assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( chat.input_queue.queued_user_messages.front().unwrap().text, "first queued" );}pub(super) fn render_bottom_first_row(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); chat.render(area, &mut buf); for y in 0..area.height { let mut row = String::new(); for x in 0..area.width { let s = buf[(x, y)].symbol(); if s.is_empty() { row.push(' '); } else { row.push_str(s); } } if !row.trim().is_empty() { return row; } } String::new()}pub(crate) fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String { let height = chat.desired_height(width); let area = Rect::new(0, 0, width, height); let mut buf = Buffer::empty(area); chat.render(area, &mut buf); let mut lines: Vec<String> = (0..area.height) .map(|row| { let mut line = String::new(); for col in 0..area.width { let symbol = buf[(area.x + col, area.y + row)].symbol(); if symbol.is_empty() { line.push(' '); } else { line.push_str(symbol); } } line.trim_end().to_string() }) .collect(); while lines.first().is_some_and(|line| line.trim().is_empty()) { lines.remove(0); } while lines.last().is_some_and(|line| line.trim().is_empty()) { lines.pop(); } lines.join("\n")}pub(super) fn strip_osc8_for_snapshot(text: &str) -> String { // Snapshots should assert the visible popup text, not terminal hyperlink escapes. let bytes = text.as_bytes(); let mut stripped = String::with_capacity(text.len()); let mut i = 0; while i < bytes.len() { if bytes[i..].starts_with(b"\x1B]8;;") { i += 5; while i < bytes.len() { if bytes[i] == b'\x07' { i += 1; break; } if i + 1 < bytes.len() && bytes[i] == b'\x1B' && bytes[i + 1] == b'\\' { i += 2; break; } i += 1; } continue; } let ch = text[i..] .chars() .next() .expect("slice should always contain a char"); stripped.push(ch); i += ch.len_utf8(); } stripped}pub(super) fn plugins_test_absolute_path(path: &str) -> AbsolutePathBuf { std::env::temp_dir() .join("codex-plugin-menu-tests") .join(path) .abs()}pub(super) fn plugins_test_interface( display_name: Option<&str>, short_description: Option<&str>, long_description: Option<&str>,) -> PluginInterface { PluginInterface { display_name: display_name.map(str::to_string), short_description: short_description.map(str::to_string), long_description: long_description.map(str::to_string), developer_name: None, category: None, capabilities: Vec::new(), website_url: None, privacy_policy_url: None, terms_of_service_url: None, default_prompt: None, brand_color: None, composer_icon: None, composer_icon_url: None, logo: None, logo_url: None, screenshots: Vec::new(), screenshot_urls: Vec::new(), }}pub(super) fn plugins_test_summary( id: &str, name: &str, display_name: Option<&str>, description: Option<&str>, installed: bool, enabled: bool, install_policy: PluginInstallPolicy,) -> PluginSummary { PluginSummary { id: id.to_string(), remote_plugin_id: None, local_version: None, name: name.to_string(), share_context: None, source: PluginSource::Local { path: plugins_test_absolute_path(&format!("plugins/{name}")), }, installed, enabled, install_policy, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, interface: Some(plugins_test_interface( display_name, description, /*long_description*/ None, )), keywords: Vec::new(), }}pub(super) fn plugins_test_remote_summary( remote_plugin_id: &str, name: &str, display_name: Option<&str>, description: Option<&str>, installed: bool,) -> PluginSummary { PluginSummary { id: remote_plugin_id.to_string(), remote_plugin_id: Some(remote_plugin_id.to_string()), local_version: None, name: name.to_string(), share_context: None, source: PluginSource::Remote, installed, enabled: true, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, interface: Some(plugins_test_interface( display_name, description, /*long_description*/ None, )), keywords: Vec::new(), }}pub(super) fn plugins_test_curated_marketplace( plugins: Vec<PluginSummary>,) -> PluginMarketplaceEntry { PluginMarketplaceEntry { name: OPENAI_CURATED_MARKETPLACE_NAME.to_string(), path: Some(plugins_test_absolute_path("marketplaces/chatgpt")), interface: Some(MarketplaceInterface { display_name: Some("ChatGPT Marketplace".to_string()), }), plugins, }}pub(super) fn plugins_test_repo_marketplace(plugins: Vec<PluginSummary>) -> PluginMarketplaceEntry { PluginMarketplaceEntry { name: "repo".to_string(), path: Some(plugins_test_absolute_path("marketplaces/repo")), interface: Some(MarketplaceInterface { display_name: Some("Repo Marketplace".to_string()), }), plugins, }}pub(super) fn plugins_test_response( marketplaces: Vec<PluginMarketplaceEntry>,) -> PluginListResponse { PluginListResponse { marketplaces, marketplace_load_errors: Vec::new(), featured_plugin_ids: Vec::new(), }}pub(super) fn render_loaded_plugins_popup( chat: &mut ChatWidget, response: PluginListResponse,) -> String { let cwd = chat.config.cwd.clone(); chat.on_plugins_loaded(cwd.to_path_buf(), Ok(response)); chat.add_plugins_output(); render_bottom_popup(chat, /*width*/ 100)}pub(super) fn plugins_test_detail( summary: PluginSummary, description: Option<&str>, skills: &[&str], hooks: &[(codex_app_server_protocol::HookEventName, usize)], apps: &[&str], mcp_servers: &[&str],) -> PluginDetail { PluginDetail { marketplace_name: "ChatGPT Marketplace".to_string(), marketplace_path: Some(plugins_test_absolute_path("marketplaces/chatgpt")), summary, share_url: None, description: description.map(str::to_string), skills: skills .iter() .map(|name| SkillSummary { name: (*name).to_string(), description: format!("{name} description"), short_description: None, interface: None, path: Some(plugins_test_absolute_path(&format!( "skills/{name}/SKILL.md" ))), enabled: true, }) .collect(), hooks: hooks .iter() .enumerate() .flat_map(|(event_index, (event_name, handler_count))| { (0..*handler_count).map(move |handler_index| { codex_app_server_protocol::PluginHookSummary { key: format!("plugin:{event_index}:{handler_index}"), event_name: *event_name, } }) }) .collect(), apps: apps .iter() .map(|name| AppSummary { id: format!("{name}-id"), name: (*name).to_string(), description: Some(format!("{name} app")), install_url: Some(format!("https://example.test/{name}")), category: None, }) .collect(), app_templates: Vec::new(), mcp_servers: mcp_servers.iter().map(|name| (*name).to_string()).collect(), }}pub(super) fn plugins_test_popup_row_position(popup: &str, needle: &str) -> usize { popup .find(needle) .unwrap_or_else(|| panic!("expected popup to contain {needle}: {popup}"))}pub(super) fn type_plugins_search_query(chat: &mut ChatWidget, query: &str) { for ch in query.chars() { chat.handle_key_event(KeyEvent::from(KeyCode::Char(ch))); }}pub(super) fn handle_hook_started(chat: &mut ChatWidget, run: AppServerHookRunSummary) { chat.handle_server_notification( ServerNotification::HookStarted(AppServerHookStartedNotification { thread_id: thread_id(chat), turn_id: None, run, }), /*replay_kind*/ None, );}pub(super) fn handle_hook_completed(chat: &mut ChatWidget, run: AppServerHookRunSummary) { chat.handle_server_notification( ServerNotification::HookCompleted(AppServerHookCompletedNotification { thread_id: thread_id(chat), turn_id: None, run, }), /*replay_kind*/ None, );}pub(super) fn hook_run( run_id: &str, event_name: codex_app_server_protocol::HookEventName, status: codex_app_server_protocol::HookRunStatus, status_message: &str, entries: Vec<codex_app_server_protocol::HookOutputEntry>,) -> codex_app_server_protocol::HookRunSummary { codex_app_server_protocol::HookRunSummary { id: run_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: Some(status_message.to_string()), started_at: 1, completed_at: matches!( status, codex_app_server_protocol::HookRunStatus::Completed | codex_app_server_protocol::HookRunStatus::Failed | codex_app_server_protocol::HookRunStatus::Blocked | codex_app_server_protocol::HookRunStatus::Stopped ) .then_some(11), duration_ms: matches!( status, codex_app_server_protocol::HookRunStatus::Completed | codex_app_server_protocol::HookRunStatus::Failed | codex_app_server_protocol::HookRunStatus::Blocked | codex_app_server_protocol::HookRunStatus::Stopped ) .then_some(10), entries, }}pub(super) async fn assert_hook_events_snapshot( event_name: codex_app_server_protocol::HookEventName, run_id: &str, status_message: &str, snapshot_name: &str,) { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; handle_hook_started( &mut chat, hook_run( run_id, event_name, codex_app_server_protocol::HookRunStatus::Running, status_message, Vec::new(), ), ); assert!( drain_insert_history(&mut rx).is_empty(), "hook start should update the live hook cell instead of writing history" ); reveal_running_hooks(&mut chat); assert!( active_hook_blob(&chat).contains(&format!( "Running {} hook: {status_message}", hook_event_label(event_name) )), "hook start should render in the live hook cell" ); handle_hook_completed( &mut chat, hook_run( run_id, event_name, codex_app_server_protocol::HookRunStatus::Completed, status_message, vec![ codex_app_server_protocol::HookOutputEntry { kind: codex_app_server_protocol::HookOutputEntryKind::Warning, text: "Heads up from the hook".to_string(), }, codex_app_server_protocol::HookOutputEntry { kind: codex_app_server_protocol::HookOutputEntryKind::Context, text: "Remember the startup checklist.".to_string(), }, ], ), ); let cells = drain_insert_history(&mut rx); let combined = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::<String>(); assert_chatwidget_snapshot!(snapshot_name, combined);}fn hook_event_label(event_name: codex_app_server_protocol::HookEventName) -> &'static str { match event_name { codex_app_server_protocol::HookEventName::PreToolUse => "PreToolUse", codex_app_server_protocol::HookEventName::PermissionRequest => "PermissionRequest", codex_app_server_protocol::HookEventName::PostToolUse => "PostToolUse", codex_app_server_protocol::HookEventName::PreCompact => "PreCompact", codex_app_server_protocol::HookEventName::PostCompact => "PostCompact", codex_app_server_protocol::HookEventName::SessionStart => "SessionStart", codex_app_server_protocol::HookEventName::UserPromptSubmit => "UserPromptSubmit", codex_app_server_protocol::HookEventName::SubagentStart => "SubagentStart", codex_app_server_protocol::HookEventName::SubagentStop => "SubagentStop", codex_app_server_protocol::HookEventName::Stop => "Stop", }}