Codex Handbook
tui/src/chatwidget/tests/history_replay.rs 1159 lines
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());}