use super::*;use pretty_assertions::assert_eq;fn thread_settings_for_test( model: &str, thread_id: ThreadId,) -> codex_app_server_protocol::ThreadSettingsUpdatedNotification { codex_app_server_protocol::ThreadSettingsUpdatedNotification { thread_id: thread_id.to_string(), thread_settings: codex_app_server_protocol::ThreadSettings { cwd: test_path_buf("/tmp/thread-settings").abs(), approval_policy: AskForApproval::OnRequest, approvals_reviewer: codex_app_server_protocol::ApprovalsReviewer::AutoReview, sandbox_policy: codex_app_server_protocol::SandboxPolicy::ReadOnly { network_access: false, }, active_permission_profile: Some( codex_app_server_protocol::ActivePermissionProfile::read_only(), ), model: model.to_string(), model_provider: "openai".to_string(), service_tier: Some(ServiceTier::Fast.request_value().to_string()), effort: Some(ReasoningEffortConfig::High), summary: None, collaboration_mode: CollaborationMode { mode: ModeKind::Plan, settings: codex_protocol::config_types::Settings { model: model.to_string(), reasoning_effort: Some(ReasoningEffortConfig::High), developer_instructions: None, }, }, personality: Some(Personality::Pragmatic), }, }}fn configured_thread_session(thread_id: ThreadId) -> crate::session_state::ThreadSessionState { crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, model: "gpt-5.3-codex".to_string(), model_provider_id: "openai".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("/tmp/thread-settings").abs(), runtime_workspace_roots: vec![test_path_buf("/tmp/thread-settings").abs()], instruction_source_paths: Vec::new(), reasoning_effort: None, collaboration_mode: None, personality: None, message_history: None, network_proxy: None, rollout_path: None, }}#[tokio::test]async fn invalid_url_elicitation_is_declined() { let (mut chat, _app_event_tx, mut rx, _op_rx) = make_chatwidget_manual_with_sender().await; let visible_thread_id = ThreadId::new(); let request_thread_id = ThreadId::new(); chat.thread_id = Some(visible_thread_id); chat.handle_elicitation_request_now( codex_app_server_protocol::RequestId::Integer(9), codex_app_server_protocol::McpServerElicitationRequestParams { thread_id: request_thread_id.to_string(), turn_id: Some("turn-auth".to_string()), server_name: "payments".to_string(), request: codex_app_server_protocol::McpServerElicitationRequest::Url { meta: None, message: "Review the payment details to continue.".to_string(), url: "http://payments.example/checkout/123".to_string(), elicitation_id: "payment-123".to_string(), }, }, ); assert_matches!( rx.try_recv(), Ok(AppEvent::SubmitThreadOp { thread_id: op_thread_id, op: Op::ResolveElicitation { server_name, request_id: codex_app_server_protocol::RequestId::Integer(9), decision: codex_app_server_protocol::McpServerElicitationAction::Decline, content: None, meta: None, }, }) if op_thread_id == request_thread_id && server_name == "payments" );}#[tokio::test]async fn thread_settings_updated_updates_visible_state_without_transcript() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; set_fast_mode_test_catalog(&mut chat); let thread_id = ThreadId::new(); chat.handle_thread_session(configured_thread_session(thread_id)); let _ = drain_insert_history(&mut rx); chat.handle_server_notification( ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-5.4", thread_id)), /*replay_kind*/ None, ); assert_eq!(chat.current_model(), "gpt-5.4"); assert_eq!( chat.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!( chat.current_service_tier(), Some(ServiceTier::Fast.request_value()) ); assert_eq!( chat.config_ref().permissions.approval_policy.value(), AskForApproval::OnRequest.to_core() ); assert_eq!( chat.config_ref().approvals_reviewer, ApprovalsReviewer::AutoReview ); assert_eq!( chat.config_ref() .permissions .active_permission_profile() .expect("active profile") .id, codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_READ_ONLY ); assert_eq!(chat.config_ref().personality, Some(Personality::Pragmatic)); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); assert!( drain_insert_history(&mut rx).is_empty(), "ThreadSettingsUpdated should not render transcript history" ); chat.handle_server_notification( ServerNotification::ThreadSettingsUpdated(thread_settings_for_test( "gpt-5.2", ThreadId::new(), )), /*replay_kind*/ None, ); assert_eq!(chat.current_model(), "gpt-5.4");}#[tokio::test]async fn thread_settings_updated_preserves_default_settings_for_plan_mode() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(Some("gpt-5.3-codex")).await; let thread_id = ThreadId::new(); let mut session = configured_thread_session(thread_id); session.model = "gpt-default".to_string(); session.reasoning_effort = Some(ReasoningEffortConfig::Low); chat.handle_thread_session(session); let _ = drain_insert_history(&mut rx); let default_mode = chat.current_collaboration_mode().clone(); chat.handle_server_notification( ServerNotification::ThreadSettingsUpdated(thread_settings_for_test("gpt-plan", thread_id)), /*replay_kind*/ None, ); assert_eq!(chat.active_collaboration_mode_kind(), ModeKind::Plan); assert_eq!(chat.current_model(), "gpt-plan"); assert_eq!( chat.current_reasoning_effort(), Some(ReasoningEffortConfig::High) ); assert_eq!(chat.current_collaboration_mode(), &default_mode); 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!(chat.active_collaboration_mode_kind(), ModeKind::Default); assert_eq!(chat.current_model(), "gpt-default"); assert_eq!( chat.current_reasoning_effort(), Some(ReasoningEffortConfig::Low) );}#[tokio::test]async fn collab_spawn_end_shows_requested_model_and_effort() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let sender_thread_id = ThreadId::new(); let spawned_thread_id = ThreadId::new(); chat.set_collab_agent_metadata( spawned_thread_id, Some("Robie".to_string()), Some("explorer".to_string()), ); chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), started_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "call-spawn".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, status: AppServerCollabAgentToolCallStatus::InProgress, sender_thread_id: sender_thread_id.to_string(), receiver_thread_ids: Vec::new(), prompt: Some("Explore the repo".to_string()), model: Some("gpt-5".to_string()), reasoning_effort: Some(ReasoningEffortConfig::High), agents_states: HashMap::new(), }, }), /*replay_kind*/ None, ); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "call-spawn".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, status: AppServerCollabAgentToolCallStatus::Completed, sender_thread_id: sender_thread_id.to_string(), receiver_thread_ids: vec![spawned_thread_id.to_string()], prompt: Some("Explore the repo".to_string()), model: None, reasoning_effort: None, agents_states: HashMap::from([( spawned_thread_id.to_string(), AppServerCollabAgentState { status: AppServerCollabAgentStatus::PendingInit, message: None, }, )]), }, }), /*replay_kind*/ None, ); let cells = drain_insert_history(&mut rx); let rendered = cells .iter() .map(|lines| lines_to_single_string(lines)) .collect::<Vec<_>>() .join("\n"); assert!( rendered.contains("Spawned Robie [explorer] (gpt-5 high)"), "expected spawn line to include agent metadata and requested model, got {rendered:?}" );}#[tokio::test]async fn live_app_server_user_message_item_completed_does_not_duplicate_rendered_prompt() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); chat.bottom_pane .set_composer_text("Hi, are you there?".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); match next_submit_op(&mut op_rx) { Op::UserTurn { .. } => {} other => panic!("expected Op::UserTurn, got {other:?}"), } let inserted = drain_insert_history(&mut rx); assert_eq!(inserted.len(), 1); assert!(lines_to_single_string(&inserted[0]).contains("Hi, are you there?")); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::UserMessage { id: "user-1".to_string(), client_id: None, content: vec![AppServerUserInput::Text { text: "Hi, are you there?".to_string(), text_elements: Vec::new(), }], }, }), /*replay_kind*/ None, ); assert!(drain_insert_history(&mut rx).is_empty());}#[tokio::test]async fn live_app_server_turn_completed_clears_working_status_after_answer_item() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::TurnStarted(TurnStartedNotification { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, started_at: Some(0), completed_at: None, duration_ms: None, }, }), /*replay_kind*/ None, ); assert!(chat.bottom_pane.is_task_running()); let status = chat .bottom_pane .status_widget() .expect("status indicator should be visible"); assert_eq!(status.header(), "Working"); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::AgentMessage { id: "msg-1".to_string(), text: "Yes. What do you need?".to_string(), phase: Some(MessagePhase::FinalAnswer), memory_citation: None, }, }), /*replay_kind*/ None, ); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); assert!(lines_to_single_string(&cells[0]).contains("Yes. What do you need?")); assert!(chat.bottom_pane.is_task_running()); chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::Completed, error: None, started_at: None, completed_at: Some(0), duration_ms: None, }, }), /*replay_kind*/ None, ); assert!(!chat.bottom_pane.is_task_running()); assert!(chat.bottom_pane.status_widget().is_none());}#[tokio::test]async fn live_app_server_turn_started_sets_feedback_turn_id() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::TurnStarted(TurnStartedNotification { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, started_at: Some(0), completed_at: None, duration_ms: None, }, }), /*replay_kind*/ None, ); chat.open_feedback_note( crate::app_event::FeedbackCategory::Bug, /*include_logs*/ false, ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_matches!( rx.try_recv(), Ok(AppEvent::SubmitFeedback { category: crate::app_event::FeedbackCategory::Bug, reason: None, turn_id: Some(turn_id), include_logs: false, }) if turn_id == "turn-1" );}#[tokio::test]async fn live_app_server_warning_notification_renders_message() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::Warning(WarningNotification { thread_id: None, message: "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list.".to_string(), }), /*replay_kind*/ None, ); 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]); let normalized = rendered.split_whitespace().collect::<Vec<_>>().join(" "); assert!( normalized.contains("Exceeded skills context budget of 2%."), "expected warning notification message, got {rendered}" ); assert!( normalized.contains( "All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list." ), "expected warning guidance, got {rendered}" );}#[tokio::test]async fn live_app_server_guardian_warning_notification_renders_message() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::GuardianWarning(GuardianWarningNotification { thread_id: "thread-1".to_string(), message: "Automatic approval review denied the requested action.".to_string(), }), /*replay_kind*/ None, ); 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("Automatic approval review denied the requested action."), "expected guardian warning notification message, got {rendered}" );}#[tokio::test]async fn live_app_server_config_warning_prefixes_summary() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::ConfigWarning(ConfigWarningNotification { summary: "Invalid configuration; using defaults.".to_string(), details: None, path: None, range: None, }), /*replay_kind*/ None, ); 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("Invalid configuration; using defaults."), "expected config warning summary, got {rendered}" );}#[tokio::test]async fn live_app_server_file_change_item_started_preserves_changes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), started_at_ms: 0, item: AppServerThreadItem::FileChange { id: "patch-1".to_string(), changes: vec![FileUpdateChange { path: "foo.txt".to_string(), kind: PatchChangeKind::Add, diff: "hello\n".to_string(), }], status: AppServerPatchApplyStatus::InProgress, }, }), /*replay_kind*/ None, ); let cells = drain_insert_history(&mut rx); assert!(!cells.is_empty(), "expected patch history to be rendered"); let transcript = lines_to_single_string(cells.last().expect("patch cell")); assert!( transcript.contains("Added foo.txt") || transcript.contains("Edited foo.txt"), "expected patch summary to include foo.txt, got: {transcript}" );}#[tokio::test]async fn live_app_server_command_execution_strips_shell_wrapper() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let script = r#"python3 -c 'print("Hello, world!")'"#; let command = shlex::try_join(["/bin/zsh", "-lc", script]).expect("round-trippable shell wrapper"); chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), started_at_ms: 0, item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command: command.clone(), cwd: test_path_buf("/tmp").abs(), process_id: None, source: AppServerCommandExecutionSource::UserShell, status: AppServerCommandExecutionStatus::InProgress, command_actions: vec![AppServerCommandAction::Unknown { command: script.to_string(), }], aggregated_output: None, exit_code: None, duration_ms: None, }, }), /*replay_kind*/ None, ); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::CommandExecution { id: "cmd-1".to_string(), command, cwd: test_path_buf("/tmp").abs(), process_id: None, source: AppServerCommandExecutionSource::UserShell, status: AppServerCommandExecutionStatus::Completed, command_actions: vec![AppServerCommandAction::Unknown { command: script.to_string(), }], aggregated_output: Some("Hello, world!\n".to_string()), exit_code: Some(0), duration_ms: Some(5), }, }), /*replay_kind*/ None, ); let cells = drain_insert_history(&mut rx); assert_eq!( cells.len(), 1, "expected one completed command history cell" ); let blob = lines_to_single_string(cells.first().expect("command cell")); assert_chatwidget_snapshot!( "live_app_server_command_execution_strips_shell_wrapper", blob );}#[tokio::test]async fn live_app_server_collab_wait_items_render_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let sender_thread_id = ThreadId::from_string("019cff70-2599-75e2-af72-b90000000001").expect("valid thread id"); let receiver_thread_id = ThreadId::from_string("019cff70-2599-75e2-af72-b958ce5dc1cc").expect("valid thread id"); let other_receiver_thread_id = ThreadId::from_string("019cff70-2599-75e2-af72-b96db334332d").expect("valid thread id"); chat.set_collab_agent_metadata( receiver_thread_id, Some("Robie".to_string()), Some("explorer".to_string()), ); chat.set_collab_agent_metadata( other_receiver_thread_id, Some("Ada".to_string()), Some("reviewer".to_string()), ); chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), started_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "wait-1".to_string(), tool: AppServerCollabAgentTool::Wait, status: AppServerCollabAgentToolCallStatus::InProgress, sender_thread_id: sender_thread_id.to_string(), receiver_thread_ids: vec![ receiver_thread_id.to_string(), other_receiver_thread_id.to_string(), ], prompt: None, model: None, reasoning_effort: None, agents_states: HashMap::new(), }, }), /*replay_kind*/ None, ); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "wait-1".to_string(), tool: AppServerCollabAgentTool::Wait, status: AppServerCollabAgentToolCallStatus::Completed, sender_thread_id: sender_thread_id.to_string(), receiver_thread_ids: vec![ receiver_thread_id.to_string(), other_receiver_thread_id.to_string(), ], prompt: None, model: None, reasoning_effort: None, agents_states: HashMap::from([ ( receiver_thread_id.to_string(), AppServerCollabAgentState { status: AppServerCollabAgentStatus::Completed, message: Some("Done".to_string()), }, ), ( other_receiver_thread_id.to_string(), AppServerCollabAgentState { status: AppServerCollabAgentStatus::Running, message: None, }, ), ]), }, }), /*replay_kind*/ None, ); let combined = drain_insert_history(&mut rx) .into_iter() .map(|lines| lines_to_single_string(&lines)) .collect::<Vec<_>>() .join("\n"); assert_chatwidget_snapshot!("app_server_collab_wait_items_render_history", combined);}#[tokio::test]async fn live_app_server_collab_spawn_completed_renders_requested_model_and_effort() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let sender_thread_id = ThreadId::from_string("019cff70-2599-75e2-af72-b90000000002").expect("valid thread id"); let spawned_thread_id = ThreadId::from_string("019cff70-2599-75e2-af72-b91781b41a8e").expect("valid thread id"); chat.handle_server_notification( ServerNotification::ItemStarted(ItemStartedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), started_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "spawn-1".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, status: AppServerCollabAgentToolCallStatus::InProgress, sender_thread_id: sender_thread_id.to_string(), receiver_thread_ids: Vec::new(), prompt: Some("Explore the repo".to_string()), model: Some("gpt-5".to_string()), reasoning_effort: Some(ReasoningEffortConfig::High), agents_states: HashMap::new(), }, }), /*replay_kind*/ None, ); chat.handle_server_notification( ServerNotification::ItemCompleted(ItemCompletedNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), completed_at_ms: 0, item: AppServerThreadItem::CollabAgentToolCall { id: "spawn-1".to_string(), tool: AppServerCollabAgentTool::SpawnAgent, status: AppServerCollabAgentToolCallStatus::Completed, sender_thread_id: sender_thread_id.to_string(), receiver_thread_ids: vec![spawned_thread_id.to_string()], prompt: Some("Explore the repo".to_string()), model: Some("gpt-5".to_string()), reasoning_effort: Some(ReasoningEffortConfig::High), agents_states: HashMap::from([( spawned_thread_id.to_string(), AppServerCollabAgentState { status: AppServerCollabAgentStatus::PendingInit, message: None, }, )]), }, }), /*replay_kind*/ None, ); let combined = drain_insert_history(&mut rx) .into_iter() .map(|lines| lines_to_single_string(&lines)) .collect::<Vec<_>>() .join("\n"); assert_chatwidget_snapshot!( "app_server_collab_spawn_completed_renders_requested_model_and_effort", combined );}#[tokio::test]async fn live_app_server_failed_turn_does_not_duplicate_error_history() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::TurnStarted(TurnStartedNotification { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, started_at: Some(0), completed_at: None, duration_ms: None, }, }), /*replay_kind*/ None, ); chat.handle_server_notification( ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: "permission denied".to_string(), codex_error_info: None, additional_details: None, }, will_retry: false, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), }), /*replay_kind*/ None, ); let first_cells = drain_insert_history(&mut rx); assert_eq!(first_cells.len(), 1); assert!(lines_to_single_string(&first_cells[0]).contains("permission denied")); chat.handle_server_notification( ServerNotification::TurnCompleted(TurnCompletedNotification { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::Failed, error: Some(AppServerTurnError { message: "permission denied".to_string(), codex_error_info: None, additional_details: None, }), started_at: None, completed_at: Some(0), duration_ms: None, }, }), /*replay_kind*/ None, ); assert!(drain_insert_history(&mut rx).is_empty()); assert!(!chat.bottom_pane.is_task_running());}#[tokio::test]async fn live_app_server_failed_turn_consolidates_streamed_answer() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; handle_turn_started(&mut chat, "turn-1"); while rx.try_recv().is_ok() {} handle_agent_message_delta(&mut chat, "```diff\n+ streamed patch\n```\n"); chat.run_commit_tick(); while rx.try_recv().is_ok() {} handle_error( &mut chat, "stream disconnected before completion", /*codex_error_info*/ None, ); let mut saw_consolidate = false; while let Ok(event) = rx.try_recv() { if let AppEvent::ConsolidateAgentMessage { source, .. } = event { saw_consolidate = true; assert!( source.contains("streamed patch"), "expected partial stream source to be consolidated, got {source:?}" ); } } assert!( saw_consolidate, "failed turn should consolidate streamed cells before clearing the stream controller" );}#[tokio::test]async fn live_app_server_stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::TurnStarted(TurnStartedNotification { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, started_at: Some(0), completed_at: None, duration_ms: None, }, }), /*replay_kind*/ None, ); drain_insert_history(&mut rx); chat.handle_server_notification( ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: "Reconnecting... 1/5".to_string(), codex_error_info: Some(CodexErrorInfo::Other), additional_details: None, }, will_retry: true, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), }), /*replay_kind*/ None, ); drain_insert_history(&mut rx); chat.handle_server_notification( ServerNotification::AgentMessageDelta( codex_app_server_protocol::AgentMessageDeltaNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "item-1".to_string(), delta: "hello".to_string(), }, ), /*replay_kind*/ None, ); let status = chat .bottom_pane .status_widget() .expect("status indicator should be visible"); assert_eq!(status.header(), "Working"); assert_eq!(status.details(), None); assert!(chat.status_state.retry_status_header.is_none());}#[tokio::test]async fn live_app_server_server_overloaded_error_renders_warning() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::TurnStarted(TurnStartedNotification { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, started_at: Some(0), completed_at: None, duration_ms: None, }, }), /*replay_kind*/ None, ); drain_insert_history(&mut rx); chat.handle_server_notification( ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: "server overloaded".to_string(), codex_error_info: Some(CodexErrorInfo::ServerOverloaded), additional_details: None, }, will_retry: false, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), }), /*replay_kind*/ None, ); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); assert_eq!(lines_to_single_string(&cells[0]), "⚠ server overloaded\n"); assert!(!chat.bottom_pane.is_task_running());}#[tokio::test]async fn live_app_server_cyber_policy_error_renders_dedicated_notice() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::TurnStarted(TurnStartedNotification { thread_id: "thread-1".to_string(), turn: AppServerTurn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, started_at: Some(0), completed_at: None, duration_ms: None, }, }), /*replay_kind*/ None, ); drain_insert_history(&mut rx); chat.handle_server_notification( ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: "server fallback message".to_string(), codex_error_info: Some(CodexErrorInfo::CyberPolicy), additional_details: None, }, will_retry: false, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), }), /*replay_kind*/ None, ); 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")); assert!(!chat.bottom_pane.is_task_running());}#[tokio::test]async fn live_app_server_model_verification_renders_warning() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::ModelVerification(ModelVerificationNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), verifications: vec![AppServerModelVerification::TrustedAccessForCyber], }), /*replay_kind*/ None, ); 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 live_app_server_invalid_thread_name_update_is_ignored() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); chat.thread_id = Some(thread_id); chat.thread_name = Some("original name".to_string()); chat.handle_server_notification( ServerNotification::ThreadNameUpdated( codex_app_server_protocol::ThreadNameUpdatedNotification { thread_id: "not-a-thread-id".to_string(), thread_name: Some("bad update".to_string()), }, ), /*replay_kind*/ None, ); assert_eq!(chat.thread_id, Some(thread_id)); assert_eq!(chat.thread_name, Some("original name".to_string()));}#[tokio::test]async fn live_app_server_thread_name_update_shows_resume_hint() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::from_string("123e4567-e89b-12d3-a456-426614174000").expect("thread id"); 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("review-fix".to_string()), }, ), /*replay_kind*/ None, ); assert_eq!(chat.thread_name, Some("review-fix".to_string())); let cells = drain_insert_history(&mut rx); assert_eq!(cells.len(), 1); let rendered = lines_to_single_string(&cells[0]); assert_chatwidget_snapshot!("thread_name_update_resume_hint", rendered);}#[tokio::test]async fn live_app_server_thread_closed_requests_immediate_exit() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.handle_server_notification( ServerNotification::ThreadClosed(ThreadClosedNotification { thread_id: "thread-1".to_string(), }), /*replay_kind*/ None, ); assert_matches!(rx.try_recv(), Ok(AppEvent::Exit(ExitMode::Immediate)));}