use super::*;use crate::app_event::HistoryLookupResponse;use codex_app_server_protocol::NetworkAccess;use codex_app_server_protocol::SandboxPolicy;use codex_protocol::models::ManagedFileSystemPermissions;use codex_protocol::permissions::FileSystemAccessMode;use codex_protocol::permissions::FileSystemPath;use codex_protocol::permissions::FileSystemSandboxEntry;use codex_protocol::permissions::FileSystemSpecialPath;use codex_protocol::permissions::NetworkSandboxPolicy;use pretty_assertions::assert_eq;#[tokio::test]async fn resumed_initial_messages_render_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".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()), }; chat.handle_thread_session(configured); replay_user_message_text( &mut chat, "user-1", "hello from user", ReplayKind::ResumeInitialMessages, ); replay_agent_message( &mut chat, "assistant-1", "assistant reply", ReplayKind::ResumeInitialMessages, ); let cells = drain_insert_history(&mut rx); let mut merged_lines = Vec::new(); for lines in cells { let text = lines .iter() .flat_map(|line| line.spans.iter()) .map(|span| span.content.clone()) .collect::<String>(); merged_lines.push(text); } let text_blob = merged_lines.join("\n"); assert!( text_blob.contains("hello from user"), "expected replayed user message", ); assert!( text_blob.contains("assistant reply"), "expected replayed agent message", );}#[tokio::test]async fn replayed_user_messages_seed_composer_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_history_metadata( ThreadId::new(), /*log_id*/ 1, /*entry_count*/ 3, ); let mut replay_mention = |id: &str, text: &str, name: &str, path: &str| { replay_user_message_inputs( &mut chat, id, vec![ AppServerUserInput::Text { text: text.to_string(), text_elements: Vec::new(), }, AppServerUserInput::Mention { name: name.to_string(), path: path.to_string(), }, ], ReplayKind::ResumeInitialMessages, ); }; replay_mention( "user-1", "use $sample", "Sample Plugin", "plugin://sample@test", ); replay_mention( "user-2", "use $google-calendar", "Google Calendar", "app://google_calendar", ); drain_insert_history(&mut rx); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(chat.bottom_pane.composer_text(), "use $google-calendar"); assert_eq!( chat.bottom_pane.take_mention_bindings(), vec![MentionBinding { sigil: '$', mention: "google-calendar".to_string(), path: "app://google_calendar".to_string(), }] ); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(chat.bottom_pane.composer_text(), "use $sample"); assert_eq!( chat.bottom_pane.take_mention_bindings(), vec![MentionBinding { sigil: '$', mention: "sample".to_string(), path: "plugin://sample@test".to_string(), }] ); let mut next_lookup_offset = || { let AppEvent::LookupMessageHistoryEntry { offset, .. } = rx.try_recv().expect("expected lookup") else { panic!("unexpected event variant"); }; offset }; let response = |offset, entry: &str| HistoryLookupResponse { offset, log_id: 1, entry: Some(entry.to_string()), }; chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); chat.handle_history_entry_response(response( next_lookup_offset(), "use [$google-calendar](app://google_calendar)", )); assert_eq!(next_lookup_offset(), 1); chat.handle_history_entry_response(response(1, "use [$sample](plugin://sample@test)")); assert_eq!(next_lookup_offset(), 0); chat.handle_history_entry_response(response(0, "/rename smoke-1")); assert_eq!(chat.bottom_pane.composer_text(), "/rename smoke-1");}#[tokio::test]async fn replayed_review_prompt_does_not_seed_composer_history() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; chat.replay_thread_item( AppServerThreadItem::EnteredReviewMode { id: "review-start".to_string(), review: "changes against main".to_string(), }, "turn-1".to_string(), ReplayKind::ResumeInitialMessages, ); replay_user_message_text( &mut chat, "review-prompt", "Review the code changes against the base branch 'main'.", ReplayKind::ResumeInitialMessages, ); drain_insert_history(&mut rx); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(chat.bottom_pane.composer_text(), "");}#[tokio::test]async fn replayed_user_message_preserves_text_elements_and_local_images() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let placeholder = "[Image #1]"; let message = format!("{placeholder} replayed"); let text_elements = vec![TextElement::new( (0..placeholder.len()).into(), Some(placeholder.to_string()), )]; let local_images = vec![PathBuf::from("/tmp/replay.png")]; let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".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()), }; chat.handle_thread_session(configured); replay_user_message_inputs( &mut chat, "user-1", vec![ AppServerUserInput::Text { text: message.clone(), text_elements: text_elements.clone().into_iter().map(Into::into).collect(), }, AppServerUserInput::LocalImage { path: local_images[0].clone(), detail: None, }, ], ReplayKind::ResumeInitialMessages, ); let mut user_cell = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = ev && let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() { user_cell = Some(( cell.message.clone(), cell.text_elements.clone(), cell.local_image_paths.clone(), cell.remote_image_urls.clone(), )); break; } } let (stored_message, stored_elements, stored_images, stored_remote_image_urls) = user_cell.expect("expected a replayed user history cell"); assert_eq!(stored_message, message); assert_eq!(stored_elements, text_elements); assert_eq!(stored_images, local_images); assert!(stored_remote_image_urls.is_empty());}#[tokio::test]async fn replayed_user_message_preserves_remote_image_urls() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let message = "replayed with remote image".to_string(); let remote_image_urls = vec!["https://example.com/image.png".to_string()]; let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".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()), }; chat.handle_thread_session(configured); replay_user_message_inputs( &mut chat, "user-1", vec![ AppServerUserInput::Text { text: message.clone(), text_elements: Vec::new(), }, AppServerUserInput::Image { url: remote_image_urls[0].clone(), detail: None, }, ], ReplayKind::ResumeInitialMessages, ); let mut user_cell = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = ev && let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() { user_cell = Some(( cell.message.clone(), cell.local_image_paths.clone(), cell.remote_image_urls.clone(), )); break; } } let (stored_message, stored_local_images, stored_remote_image_urls) = user_cell.expect("expected a replayed user history cell"); assert_eq!(stored_message, message); assert!(stored_local_images.is_empty()); assert_eq!(stored_remote_image_urls, remote_image_urls);}#[tokio::test]async fn session_configured_syncs_widget_config_permissions_and_cwd() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; chat.config .permissions .approval_policy .set(AskForApproval::OnRequest.to_core()) .expect("set approval policy"); chat.config .permissions .set_permission_profile(PermissionProfile::workspace_write()) .expect("set permission profile"); chat.config.cwd = test_path_buf("/home/user/main").abs(); let expected_cwd = test_path_buf("/home/user/sub-agent").abs(); let expected_app_server_permission_profile = PermissionProfile::Managed { network: NetworkSandboxPolicy::Restricted, file_system: ManagedFileSystemPermissions::Restricted { entries: vec![ FileSystemSandboxEntry { path: FileSystemPath::Special { value: FileSystemSpecialPath::Root, }, access: FileSystemAccessMode::Read, }, FileSystemSandboxEntry { path: FileSystemPath::GlobPattern { pattern: "**/.secret".to_string(), }, access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, }, }; let expected_permission_profile = expected_app_server_permission_profile.clone(); let expected_core_sandbox = expected_permission_profile .to_legacy_sandbox_policy(expected_cwd.as_path()) .expect("permission profile should project to legacy sandbox policy"); let expected_sandbox = SandboxPolicy::from(expected_core_sandbox); let configured = crate::session_state::ThreadSessionState { thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile, active_permission_profile: None, cwd: expected_cwd.clone(), runtime_workspace_roots: vec![expected_cwd.clone()], instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), collaboration_mode: None, personality: None, message_history: None, network_proxy: None, rollout_path: None, }; chat.handle_thread_session(configured); assert_eq!( AskForApproval::from(chat.config_ref().permissions.approval_policy.value()), AskForApproval::Never ); let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( chat.config_ref().permissions.effective_permission_profile(), expected_app_server_permission_profile ); assert_eq!(&chat.config_ref().cwd, &expected_cwd); let updated_profile = PermissionProfile::workspace_write(); chat.set_permission_profile_from_session_snapshot(PermissionProfileSnapshot::legacy( updated_profile.clone(), )) .expect("set permission profile"); assert_eq!( chat.config_ref().permissions.permission_profile(), &updated_profile, "local permission changes should replace SessionConfigured canonical permissions" ); assert_eq!( chat.config_ref().permissions.effective_permission_profile(), updated_profile .materialize_project_roots_with_workspace_roots(std::slice::from_ref(&expected_cwd,)), "effective permissions should still use the current thread runtime workspace roots" );}#[tokio::test]async fn session_configured_preserves_profile_workspace_roots() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let previous_cwd = test_path_buf("/home/user/main").abs(); let profile_root = test_path_buf("/home/user/shared").abs(); chat.config.cwd = previous_cwd.clone(); chat.config.workspace_roots = vec![previous_cwd, profile_root.clone()]; chat.config.workspace_roots_explicit = false; chat.config .permissions .set_workspace_roots(chat.config.workspace_roots.clone()); let session_cwd = test_path_buf("/home/user/sub-agent").abs(); let session_runtime_workspace_roots = vec![session_cwd.clone()]; let session_effective_workspace_roots = vec![session_cwd.clone(), profile_root]; let session_permission_profile = PermissionProfile::workspace_write() .materialize_project_roots_with_workspace_roots(&session_effective_workspace_roots); let configured = crate::session_state::ThreadSessionState { thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: session_permission_profile.clone(), active_permission_profile: None, cwd: session_cwd.clone(), runtime_workspace_roots: session_runtime_workspace_roots.clone(), instruction_source_paths: Vec::new(), reasoning_effort: Some(ReasoningEffortConfig::default()), collaboration_mode: None, personality: None, message_history: None, network_proxy: None, rollout_path: None, }; chat.handle_thread_session(configured); assert_eq!(&chat.config_ref().cwd, &session_cwd); assert_eq!( chat.config_ref().permissions.user_visible_workspace_roots(), session_runtime_workspace_roots.as_slice() ); assert_eq!( chat.config_ref().permissions.effective_permission_profile(), session_permission_profile );}#[tokio::test]async fn session_configured_external_sandbox_keeps_external_runtime_policy() { let (mut chat, _rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let expected_app_server_permission_profile = PermissionProfile::External { network: NetworkSandboxPolicy::Restricted, }; let expected_permission_profile = expected_app_server_permission_profile.clone(); let expected_sandbox = SandboxPolicy::ExternalSandbox { network_access: NetworkAccess::Restricted, }; let configured = crate::session_state::ThreadSessionState { thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".to_string(), model_provider_id: "test-provider".to_string(), service_tier: None, approval_policy: AskForApproval::Never, approvals_reviewer: ApprovalsReviewer::User, permission_profile: expected_permission_profile, active_permission_profile: None, cwd: test_path_buf("/home/user/external").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: None, }; chat.handle_thread_session(configured); let actual_sandbox = SandboxPolicy::from(chat.config_ref().legacy_sandbox_policy()); assert_eq!(&actual_sandbox, &expected_sandbox); assert_eq!( chat.config_ref().permissions.effective_permission_profile(), expected_app_server_permission_profile );}#[tokio::test]async fn replayed_user_message_with_only_remote_images_renders_history_cell() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let remote_image_urls = vec!["https://example.com/remote-only.png".to_string()]; let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".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()), }; chat.handle_thread_session(configured); replay_user_message_inputs( &mut chat, "user-1", vec![AppServerUserInput::Image { url: remote_image_urls[0].clone(), detail: None, }], ReplayKind::ResumeInitialMessages, ); let mut user_cell = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = ev && let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() { user_cell = Some((cell.message.clone(), cell.remote_image_urls.clone())); break; } } let (stored_message, stored_remote_image_urls) = user_cell.expect("expected a replayed remote-image-only user history cell"); assert!(stored_message.is_empty()); assert_eq!(stored_remote_image_urls, remote_image_urls);}#[tokio::test]async fn replayed_user_message_with_only_local_images_renders_history_cell() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; let local_images = [PathBuf::from("/tmp/replay-local-only.png")]; let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let configured = crate::session_state::ThreadSessionState { thread_id, forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".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()), }; chat.handle_thread_session(configured); replay_user_message_inputs( &mut chat, "user-1", vec![AppServerUserInput::LocalImage { path: local_images[0].clone(), detail: None, }], ReplayKind::ResumeInitialMessages, ); let mut user_cell = None; while let Ok(ev) = rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = ev && let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() { user_cell = Some((cell.message.clone(), cell.local_image_paths.clone())); break; } } let (stored_message, stored_local_images) = user_cell.expect("expected a replayed local-image-only user history cell"); assert!(stored_message.is_empty()); assert_eq!(stored_local_images, local_images);}#[tokio::test]async fn forked_thread_history_line_includes_name_and_id_snapshot() { let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut chat = chat; let forked_from_id = ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); chat.emit_forked_thread_event(forked_from_id, Some("named-thread".to_string())); let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async { loop { match rx.recv().await { Some(AppEvent::InsertHistoryCell(cell)) => break cell, Some(_) => continue, None => panic!("app event channel closed before forked thread history was emitted"), } } }) .await .expect("timed out waiting for forked thread history"); let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80)); assert!( combined.contains("Thread forked from"), "expected forked thread message in history" ); assert_chatwidget_snapshot!("forked_thread_history_line", combined);}#[tokio::test]async fn forked_thread_history_line_without_name_shows_id_once_snapshot() { let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut chat = chat; let temp = tempdir().expect("tempdir"); chat.config.codex_home = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(temp.path()) .expect("temp dir is absolute"); let forked_from_id = ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id"); chat.emit_forked_thread_event(forked_from_id, /*fork_parent_title*/ None); let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async { loop { match rx.recv().await { Some(AppEvent::InsertHistoryCell(cell)) => break cell, Some(_) => continue, None => panic!("app event channel closed before forked thread history was emitted"), } } }) .await .expect("timed out waiting for forked thread history"); let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80)); assert_chatwidget_snapshot!("forked_thread_history_line_without_name", combined);}#[tokio::test]async fn app_server_forked_thread_history_line_uses_app_server_title_snapshot() { let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut chat = chat; let temp = tempdir().expect("tempdir"); chat.config.codex_home = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(temp.path()) .expect("temp dir is absolute"); let forked_from_id = ThreadId::from_string("e9f18a88-8081-4e51-9d4e-8af5cde2d8dd").expect("forked id"); let session_index_entry = format!( "{{\"id\":\"{forked_from_id}\",\"thread_name\":\"stale-local-thread\",\"updated_at\":\"2024-01-02T00:00:00Z\"}}\n" ); std::fs::write(temp.path().join("session_index.jsonl"), session_index_entry) .expect("write session index"); chat.emit_forked_thread_event(forked_from_id, Some("app-server-parent-thread".to_string())); let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async { loop { match rx.recv().await { Some(AppEvent::InsertHistoryCell(cell)) => break cell, Some(_) => continue, None => panic!("app event channel closed before forked thread history was emitted"), } } }) .await .expect("timed out waiting for forked thread history"); let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80)); assert!(combined.contains("app-server-parent-thread")); assert!( !combined.contains("stale-local-thread"), "app-server fork title lookup should not read local CODEX_HOME" ); assert_chatwidget_snapshot!("app_server_forked_thread_history_line", combined);}#[tokio::test]async fn app_server_forked_thread_history_line_without_app_server_name_ignores_local_snapshot() { let (chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut chat = chat; let temp = tempdir().expect("tempdir"); chat.config.codex_home = codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(temp.path()) .expect("temp dir is absolute"); let forked_from_id = ThreadId::from_string("019c2d47-4935-7423-a190-05691f566092").expect("forked id"); let session_index_entry = format!( "{{\"id\":\"{forked_from_id}\",\"thread_name\":\"stale-local-thread\",\"updated_at\":\"2024-01-02T00:00:00Z\"}}\n" ); std::fs::write(temp.path().join("session_index.jsonl"), session_index_entry) .expect("write session index"); chat.emit_forked_thread_event(forked_from_id, /*fork_parent_title*/ None); let history_cell = tokio::time::timeout(std::time::Duration::from_secs(2), async { loop { match rx.recv().await { Some(AppEvent::InsertHistoryCell(cell)) => break cell, Some(_) => continue, None => panic!("app event channel closed before forked thread history was emitted"), } } }) .await .expect("timed out waiting for forked thread history"); let combined = lines_to_single_string(&history_cell.display_lines(/*width*/ 80)); assert!( !combined.contains("stale-local-thread"), "app-server fork title lookup should not read local CODEX_HOME" ); assert_chatwidget_snapshot!( "app_server_forked_thread_history_line_without_app_server_name", combined );}#[tokio::test]async fn thread_snapshot_replay_preserves_agent_message_during_review_mode() { let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; replay_entered_review_mode(&mut chat, "current changes"); let _ = drain_insert_history(&mut rx); replay_agent_message( &mut chat, "review-message", "Review progress update", ReplayKind::ThreadSnapshot, ); let inserted = drain_insert_history(&mut rx); assert_eq!(inserted.len(), 1); assert!(lines_to_single_string(&inserted[0]).contains("Review progress update"));}#[tokio::test]async fn replayed_retryable_app_server_error_keeps_turn_running() { 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, }, }), Some(ReplayKind::ThreadSnapshot), ); drain_insert_history(&mut rx); chat.handle_server_notification( ServerNotification::Error(ErrorNotification { error: AppServerTurnError { message: "Reconnecting... 1/5".to_string(), codex_error_info: None, additional_details: Some("Idle timeout waiting for SSE".to_string()), }, will_retry: true, thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), }), Some(ReplayKind::ThreadSnapshot), ); assert!(drain_insert_history(&mut rx).is_empty()); 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"); assert_eq!(status.details(), None);}#[tokio::test]async fn replayed_thread_closed_notification_does_not_exit_tui() { 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(), }), Some(ReplayKind::ThreadSnapshot), ); assert_matches!(rx.try_recv(), Err(TryRecvError::Empty));}#[tokio::test]async fn replayed_reasoning_item_hides_raw_reasoning_when_disabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.show_raw_agent_reasoning = false; chat.handle_thread_session(crate::session_state::ThreadSessionState { thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".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_project_path().abs(), runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, collaboration_mode: None, personality: None, message_history: None, network_proxy: None, rollout_path: None, }); let _ = drain_insert_history(&mut rx); chat.replay_thread_item( AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), summary: vec!["Summary only".to_string()], content: vec!["Raw reasoning".to_string()], }, "turn-1".to_string(), ReplayKind::ThreadSnapshot, ); let rendered = match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => { lines_to_single_string(&cell.transcript_lines(/*width*/ 80)) } other => panic!("expected InsertHistoryCell, got {other:?}"), }; assert!(!rendered.trim().is_empty()); assert!(!rendered.contains("Raw reasoning"));}#[tokio::test]async fn replayed_reasoning_item_shows_raw_reasoning_when_enabled() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.config.show_raw_agent_reasoning = true; chat.handle_thread_session(crate::session_state::ThreadSessionState { thread_id: ThreadId::new(), forked_from_id: None, fork_parent_title: None, thread_name: None, model: "test-model".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_project_path().abs(), runtime_workspace_roots: Vec::new(), instruction_source_paths: Vec::new(), reasoning_effort: None, collaboration_mode: None, personality: None, message_history: None, network_proxy: None, rollout_path: None, }); let _ = drain_insert_history(&mut rx); chat.replay_thread_item( AppServerThreadItem::Reasoning { id: "reasoning-1".to_string(), summary: vec!["Summary only".to_string()], content: vec!["Raw reasoning".to_string()], }, "turn-1".to_string(), ReplayKind::ThreadSnapshot, ); let rendered = match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => { lines_to_single_string(&cell.transcript_lines(/*width*/ 80)) } other => panic!("expected InsertHistoryCell, got {other:?}"), }; assert!(rendered.contains("Raw reasoning"));}#[tokio::test]async fn replayed_in_progress_mcp_tool_call_stays_active() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let _ = drain_insert_history(&mut rx); chat.replay_thread_item( AppServerThreadItem::McpToolCall { id: "mcp-1".to_string(), server: "copilot-bridge".to_string(), tool: "copilot".to_string(), status: codex_app_server_protocol::McpToolCallStatus::InProgress, arguments: json!({"action": "wait"}), mcp_app_resource_uri: None, plugin_id: None, result: None, error: None, duration_ms: None, }, "turn-1".to_string(), ReplayKind::ThreadSnapshot, ); assert!(drain_insert_history(&mut rx).is_empty()); let active = active_blob(&chat); assert!(active.contains("Calling")); assert!(!active.contains("MCP tool call completed without a result"));}#[tokio::test]async fn live_reasoning_summary_is_not_rendered_twice_when_item_completes() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.show_welcome_banner = false; 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, ); let _ = drain_insert_history(&mut rx); chat.handle_server_notification( ServerNotification::ReasoningSummaryTextDelta(ReasoningSummaryTextDeltaNotification { thread_id: "thread-1".to_string(), turn_id: "turn-1".to_string(), item_id: "reasoning-1".to_string(), delta: "Summary only".to_string(), summary_index: 0, }), /*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::Reasoning { id: "reasoning-1".to_string(), summary: vec!["Summary only".to_string()], content: Vec::new(), }, }), /*replay_kind*/ None, ); let rendered = match rx.try_recv() { Ok(AppEvent::InsertHistoryCell(cell)) => { lines_to_single_string(&cell.transcript_lines(/*width*/ 80)) } other => panic!("expected InsertHistoryCell, got {other:?}"), }; assert_eq!(rendered.matches("Summary only").count(), 1);}#[tokio::test]async fn thread_snapshot_replayed_turn_started_marks_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; replay_turn_started(&mut chat, ReplayKind::ThreadSnapshot); drain_insert_history(&mut rx); 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");}#[tokio::test]async fn replayed_in_progress_turn_marks_task_running() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.replay_thread_turns( vec![AppServerTurn { id: "turn-1".to_string(), items_view: codex_app_server_protocol::TurnItemsView::Full, items: Vec::new(), status: AppServerTurnStatus::InProgress, error: None, started_at: None, completed_at: None, duration_ms: None, }], ReplayKind::ResumeInitialMessages, ); assert!(drain_insert_history(&mut rx).is_empty()); 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");}#[tokio::test]async fn replayed_stream_error_does_not_set_retry_status_or_status_indicator() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_status_header("Idle".to_string()); handle_stream_error_with_replay( &mut chat, "Reconnecting... 2/5", Some("Idle timeout waiting for SSE".to_string()), Some(ReplayKind::ResumeInitialMessages), ); let cells = drain_insert_history(&mut rx); assert!( cells.is_empty(), "expected no history cell for replayed StreamError event" ); assert_eq!(chat.status_state.current_status.header, "Idle"); assert!(chat.status_state.retry_status_header.is_none()); assert!(chat.bottom_pane.status_widget().is_none());}#[tokio::test]async fn thread_snapshot_replayed_stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; replay_turn_started(&mut chat, ReplayKind::ThreadSnapshot); drain_insert_history(&mut rx); handle_stream_error_with_replay( &mut chat, "Reconnecting... 1/5", /*additional_details*/ None, Some(ReplayKind::ThreadSnapshot), ); drain_insert_history(&mut rx); replay_agent_message_delta(&mut chat, "hello", ReplayKind::ThreadSnapshot); 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 stream_recovery_restores_previous_status_header() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; handle_turn_started(&mut chat, "turn-1"); drain_insert_history(&mut rx); handle_stream_error( &mut chat, "Reconnecting... 1/5", /*additional_details*/ None, ); drain_insert_history(&mut rx); handle_agent_message_delta(&mut chat, "hello"); 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());}