use super::*;use crate::app_event::ConnectorsSnapshot;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;use std::collections::HashMap;use std::collections::VecDeque;#[tokio::test]async fn submission_preserves_text_elements_and_local_images() { let (mut chat, mut rx, mut op_rx) = 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); drain_insert_history(&mut rx); let placeholder = "[Image #1]"; let text = format!("{placeholder} submit"); let text_elements = vec![TextElement::new( (0..placeholder.len()).into(), Some(placeholder.to_string()), )]; let local_images = vec![PathBuf::from("/tmp/submitted.png")]; chat.bottom_pane .set_composer_text(text.clone(), text_elements.clone(), local_images.clone()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let items = match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => items, other => panic!("expected Op::UserTurn, got {other:?}"), }; assert_eq!(items.len(), 2); assert_eq!( items[0], UserInput::LocalImage { path: local_images[0].clone(), detail: None, } ); assert_eq!( items[1], UserInput::Text { text: text.clone(), text_elements: text_elements.clone().into_iter().map(Into::into).collect(), } ); 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 submitted user history cell"); assert_eq!(stored_message, text); assert_eq!(stored_elements, text_elements); assert_eq!(stored_images, local_images); assert!(stored_remote_image_urls.is_empty());}#[tokio::test]async fn submission_includes_configured_active_permission_profile() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let expected_permission_profile: PermissionProfile = 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: "/home/user/project/secrets/**".to_string(), }, access: FileSystemAccessMode::Deny, }, ], glob_scan_max_depth: None, }, }; let expected_active_permission_profile = ActivePermissionProfile::new("custom"); 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: expected_permission_profile, active_permission_profile: Some(expected_active_permission_profile.clone()), 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); drain_insert_history(&mut rx); chat.bottom_pane.set_composer_text( "submit with configured permissions".to_string(), Vec::new(), Vec::new(), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let active_permission_profile = match next_submit_op(&mut op_rx) { Op::UserTurn { active_permission_profile, .. } => active_permission_profile, other => panic!("expected Op::UserTurn, got {other:?}"), }; assert_eq!( active_permission_profile, Some(expected_active_permission_profile) );}#[tokio::test]async fn submission_omits_active_permission_profile_for_legacy_snapshot() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let thread_id = ThreadId::new(); let rollout_file = NamedTempFile::new().unwrap(); let expected_permission_profile: PermissionProfile = PermissionProfile::Managed { network: NetworkSandboxPolicy::Restricted, file_system: ManagedFileSystemPermissions::Unrestricted, }; 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: expected_permission_profile, 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); drain_insert_history(&mut rx); chat.bottom_pane .set_composer_text("submit".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let active_permission_profile = match next_submit_op(&mut op_rx) { Op::UserTurn { active_permission_profile, .. } => active_permission_profile, other => panic!("expected Op::UserTurn, got {other:?}"), }; assert_eq!(active_permission_profile, None);}#[tokio::test]async fn submission_with_remote_and_local_images_keeps_local_placeholder_numbering() { let (mut chat, mut rx, mut op_rx) = 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); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote.png".to_string(); chat.set_remote_image_urls(vec![remote_url.clone()]); let placeholder = "[Image #2]"; let text = format!("{placeholder} submit mixed"); let text_elements = vec![TextElement::new( (0..placeholder.len()).into(), Some(placeholder.to_string()), )]; let local_images = vec![PathBuf::from("/tmp/submitted-mixed.png")]; chat.bottom_pane .set_composer_text(text.clone(), text_elements.clone(), local_images.clone()); assert_eq!(chat.bottom_pane.composer_text(), "[Image #2] submit mixed"); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let items = match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => items, other => panic!("expected Op::UserTurn, got {other:?}"), }; assert_eq!(items.len(), 3); assert_eq!( items[0], UserInput::Image { url: remote_url.clone(), detail: None, } ); assert_eq!( items[1], UserInput::LocalImage { path: local_images[0].clone(), detail: None, } ); assert_eq!( items[2], UserInput::Text { text: text.clone(), text_elements: text_elements.clone().into_iter().map(Into::into).collect(), } ); assert_eq!(text_elements[0].placeholder(&text), Some("[Image #2]")); 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 submitted user history cell"); assert_eq!(stored_message, text); assert_eq!(stored_elements, text_elements); assert_eq!(stored_images, local_images); assert_eq!(stored_remote_image_urls, vec![remote_url]);}#[tokio::test]async fn enter_with_only_remote_images_submits_user_turn() { let (mut chat, mut rx, mut op_rx) = 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); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote-only.png".to_string(); chat.set_remote_image_urls(vec![remote_url.clone()]); assert_eq!(chat.bottom_pane.composer_text(), ""); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let (items, summary) = match next_submit_op(&mut op_rx) { Op::UserTurn { items, summary, .. } => (items, summary), other => panic!("expected Op::UserTurn, got {other:?}"), }; assert_eq!( items, vec![UserInput::Image { url: remote_url.clone(), detail: None, }] ); assert_eq!(summary, None); assert!(chat.remote_image_urls().is_empty()); 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 submitted user history cell"); assert_eq!(stored_message, String::new()); assert_eq!(stored_remote_image_urls, vec![remote_url]);}#[tokio::test]async fn shift_enter_with_only_remote_images_does_not_submit_user_turn() { let (mut chat, mut rx, mut op_rx) = 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); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote-only.png".to_string(); chat.set_remote_image_urls(vec![remote_url.clone()]); assert_eq!(chat.bottom_pane.composer_text(), ""); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::SHIFT)); assert_no_submit_op(&mut op_rx); assert_eq!(chat.remote_image_urls(), vec![remote_url]);}#[tokio::test]async fn enter_with_only_remote_images_does_not_submit_when_modal_is_active() { let (mut chat, mut rx, mut op_rx) = 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); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote-only.png".to_string(); chat.set_remote_image_urls(vec![remote_url.clone()]); chat.open_review_popup(); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(chat.remote_image_urls(), vec![remote_url]); assert_no_submit_op(&mut op_rx);}#[tokio::test]async fn enter_with_only_remote_images_does_not_submit_when_input_disabled() { let (mut chat, mut rx, mut op_rx) = 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); drain_insert_history(&mut rx); let remote_url = "https://example.com/remote-only.png".to_string(); chat.set_remote_image_urls(vec![remote_url.clone()]); chat.bottom_pane.set_composer_input_enabled( /*enabled*/ false, Some("Input disabled for test.".to_string()), ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); assert_eq!(chat.remote_image_urls(), vec![remote_url]); assert_no_submit_op(&mut op_rx);}#[tokio::test]async fn submission_prefers_selected_duplicate_skill_path() { let (mut chat, mut rx, mut op_rx) = 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); drain_insert_history(&mut rx); let repo_skill_path = test_path_buf("/tmp/repo/figma/SKILL.md").abs(); let user_skill_path = test_path_buf("/tmp/user/figma/SKILL.md").abs(); chat.set_skills(Some(vec![ SkillMetadata { name: "figma".to_string(), description: "Repo skill".to_string(), short_description: None, interface: None, dependencies: None, policy: None, path_to_skills_md: repo_skill_path, scope: crate::test_support::skill_scope_repo(), plugin_id: None, }, SkillMetadata { name: "figma".to_string(), description: "User skill".to_string(), short_description: None, interface: None, dependencies: None, policy: None, path_to_skills_md: user_skill_path.clone(), scope: crate::test_support::skill_scope_user(), plugin_id: None, }, ])); chat.bottom_pane.set_composer_text_with_mention_bindings( "please use $figma now".to_string(), Vec::new(), Vec::new(), vec![MentionBinding { sigil: '$', mention: "figma".to_string(), path: user_skill_path.to_string_lossy().into_owned(), }], ); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); let items = match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => items, other => panic!("expected Op::UserTurn, got {other:?}"), }; let selected_skill_paths = items .iter() .filter_map(|item| match item { UserInput::Skill { path, .. } => Some(path.clone()), _ => None, }) .collect::<Vec<_>>(); assert_eq!(selected_skill_paths, vec![user_skill_path.to_path_buf()]);}#[tokio::test]async fn blocked_image_restore_preserves_mention_bindings() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let placeholder = "[Image #1]"; let text = format!("{placeholder} check $file"); let text_elements = vec![TextElement::new( (0..placeholder.len()).into(), Some(placeholder.to_string()), )]; let local_images = vec![LocalImageAttachment { placeholder: placeholder.to_string(), path: PathBuf::from("/tmp/blocked.png"), }]; let mention_bindings = vec![MentionBinding { sigil: '$', mention: "file".to_string(), path: "/tmp/skills/file/SKILL.md".to_string(), }]; chat.restore_blocked_image_submission( text.clone(), text_elements, local_images.clone(), mention_bindings.clone(), Vec::new(), ); let mention_start = text.find("$file").expect("mention token exists"); let expected_elements = vec![ TextElement::new((0..placeholder.len()).into(), Some(placeholder.to_string())), TextElement::new( (mention_start..mention_start + "$file".len()).into(), Some("$file".to_string()), ), ]; assert_eq!(chat.bottom_pane.composer_text(), text); assert_eq!(chat.bottom_pane.composer_text_elements(), expected_elements); assert_eq!( chat.bottom_pane.composer_local_image_paths(), vec![local_images[0].path.clone()], ); assert_eq!(chat.bottom_pane.take_mention_bindings(), mention_bindings); let cells = drain_insert_history(&mut rx); let warning = cells .last() .map(|lines| lines_to_single_string(lines)) .expect("expected warning cell"); assert!( warning.contains("does not support image inputs"), "expected image warning, got: {warning:?}" );}#[tokio::test]async fn blocked_image_restore_with_remote_images_keeps_local_placeholder_mapping() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let first_placeholder = "[Image #2]"; let second_placeholder = "[Image #3]"; let text = format!("{first_placeholder} first\n{second_placeholder} second"); let second_start = text.find(second_placeholder).expect("second placeholder"); let text_elements = vec![ TextElement::new( (0..first_placeholder.len()).into(), Some(first_placeholder.to_string()), ), TextElement::new( (second_start..second_start + second_placeholder.len()).into(), Some(second_placeholder.to_string()), ), ]; let local_images = vec![ LocalImageAttachment { placeholder: first_placeholder.to_string(), path: PathBuf::from("/tmp/blocked-first.png"), }, LocalImageAttachment { placeholder: second_placeholder.to_string(), path: PathBuf::from("/tmp/blocked-second.png"), }, ]; let remote_image_urls = vec!["https://example.com/blocked-remote.png".to_string()]; chat.restore_blocked_image_submission( text.clone(), text_elements.clone(), local_images.clone(), Vec::new(), remote_image_urls.clone(), ); assert_eq!(chat.bottom_pane.composer_text(), text); assert_eq!(chat.bottom_pane.composer_text_elements(), text_elements); assert_eq!(chat.bottom_pane.composer_local_images(), local_images); assert_eq!(chat.remote_image_urls(), remote_image_urls);}#[tokio::test]async fn queued_restore_with_remote_images_keeps_local_placeholder_mapping() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let first_placeholder = "[Image #2]"; let second_placeholder = "[Image #3]"; let text = format!("{first_placeholder} first\n{second_placeholder} second"); let second_start = text.find(second_placeholder).expect("second placeholder"); let text_elements = vec![ TextElement::new( (0..first_placeholder.len()).into(), Some(first_placeholder.to_string()), ), TextElement::new( (second_start..second_start + second_placeholder.len()).into(), Some(second_placeholder.to_string()), ), ]; let local_images = vec![ LocalImageAttachment { placeholder: first_placeholder.to_string(), path: PathBuf::from("/tmp/queued-first.png"), }, LocalImageAttachment { placeholder: second_placeholder.to_string(), path: PathBuf::from("/tmp/queued-second.png"), }, ]; let remote_image_urls = vec!["https://example.com/queued-remote.png".to_string()]; chat.restore_user_message_to_composer(UserMessage { text: text.clone(), local_images: local_images.clone(), remote_image_urls: remote_image_urls.clone(), text_elements: text_elements.clone(), mention_bindings: Vec::new(), }); assert_eq!(chat.bottom_pane.composer_text(), text); assert_eq!(chat.bottom_pane.composer_cursor(), text.len()); assert_eq!(chat.bottom_pane.composer_text_elements(), text_elements); assert_eq!(chat.bottom_pane.composer_local_images(), local_images); assert_eq!(chat.remote_image_urls(), remote_image_urls);}#[tokio::test]async fn interrupted_turn_restore_keeps_active_mode_for_resubmission() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(Some("gpt-5")).await; chat.thread_id = Some(ThreadId::new()); chat.set_feature_enabled(Feature::CollaborationModes, /*enabled*/ true); let plan_mask = collaboration_modes::plan_mask(chat.model_catalog.as_ref()) .expect("expected plan collaboration mode"); let expected_mode = plan_mask .mode .expect("expected mode kind on plan collaboration mode"); chat.set_collaboration_mask(plan_mask); chat.on_task_started(); chat.input_queue.queued_user_messages.push_back( UserMessage { text: "Implement the plan.".to_string(), local_images: Vec::new(), remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_bindings: Vec::new(), } .into(), ); chat.refresh_pending_input_preview(); handle_turn_interrupted(&mut chat, "turn-1"); assert_eq!(chat.bottom_pane.composer_text(), "Implement the plan."); assert!(chat.input_queue.queued_user_messages.is_empty()); assert_eq!(chat.active_collaboration_mode_kind(), expected_mode); chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); match next_submit_op(&mut op_rx) { Op::UserTurn { collaboration_mode: Some(CollaborationMode { mode, .. }), personality: None, .. } => assert_eq!(mode, expected_mode), other => { panic!("expected Op::UserTurn with active mode, got {other:?}") } } assert_eq!(chat.active_collaboration_mode_kind(), expected_mode);}#[tokio::test]async fn remap_placeholders_uses_attachment_labels() { let placeholder_one = "[Image #1]"; let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ TextElement::new( (0..placeholder_two.len()).into(), Some(placeholder_two.to_string()), ), TextElement::new( ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), Some(placeholder_one.to_string()), ), ]; let attachments = vec![ LocalImageAttachment { placeholder: placeholder_one.to_string(), path: PathBuf::from("/tmp/one.png"), }, LocalImageAttachment { placeholder: placeholder_two.to_string(), path: PathBuf::from("/tmp/two.png"), }, ]; let message = UserMessage { text, text_elements: elements, local_images: attachments, remote_image_urls: vec!["https://example.com/a.png".to_string()], mention_bindings: Vec::new(), }; let mut next_label = 3usize; let remapped = remap_placeholders_for_message(message, &mut next_label); assert_eq!(remapped.text, "[Image #4] before [Image #3]"); assert_eq!( remapped.text_elements, vec![ TextElement::new( (0.."[Image #4]".len()).into(), Some("[Image #4]".to_string()), ), TextElement::new( ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), Some("[Image #3]".to_string()), ), ] ); assert_eq!( remapped.local_images, vec![ LocalImageAttachment { placeholder: "[Image #3]".to_string(), path: PathBuf::from("/tmp/one.png"), }, LocalImageAttachment { placeholder: "[Image #4]".to_string(), path: PathBuf::from("/tmp/two.png"), }, ] ); assert_eq!( remapped.remote_image_urls, vec!["https://example.com/a.png".to_string()] );}#[tokio::test]async fn remap_placeholders_uses_byte_ranges_when_placeholder_missing() { let placeholder_one = "[Image #1]"; let placeholder_two = "[Image #2]"; let text = format!("{placeholder_two} before {placeholder_one}"); let elements = vec![ TextElement::new((0..placeholder_two.len()).into(), /*placeholder*/ None), TextElement::new( ("[Image #2] before ".len().."[Image #2] before [Image #1]".len()).into(), /*placeholder*/ None, ), ]; let attachments = vec![ LocalImageAttachment { placeholder: placeholder_one.to_string(), path: PathBuf::from("/tmp/one.png"), }, LocalImageAttachment { placeholder: placeholder_two.to_string(), path: PathBuf::from("/tmp/two.png"), }, ]; let message = UserMessage { text, text_elements: elements, local_images: attachments, remote_image_urls: Vec::new(), mention_bindings: Vec::new(), }; let mut next_label = 3usize; let remapped = remap_placeholders_for_message(message, &mut next_label); assert_eq!(remapped.text, "[Image #4] before [Image #3]"); assert_eq!( remapped.text_elements, vec![ TextElement::new( (0.."[Image #4]".len()).into(), Some("[Image #4]".to_string()), ), TextElement::new( ("[Image #4] before ".len().."[Image #4] before [Image #3]".len()).into(), Some("[Image #3]".to_string()), ), ] ); assert_eq!( remapped.local_images, vec![ LocalImageAttachment { placeholder: "[Image #3]".to_string(), path: PathBuf::from("/tmp/one.png"), }, LocalImageAttachment { placeholder: "[Image #4]".to_string(), path: PathBuf::from("/tmp/two.png"), }, ] );}#[tokio::test]async fn empty_enter_during_task_does_not_queue() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // Simulate running task so submissions would normally be queued. chat.bottom_pane.set_task_running(/*running*/ true); // Press Enter with an empty composer. chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // Ensure nothing was queued. assert!(chat.input_queue.queued_user_messages.is_empty());}#[tokio::test]async fn output_free_interrupted_turn_requests_prompt_restore() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let prompt = UserMessage::from("revise this prompt"); chat.record_cancel_edit_candidate(prompt.clone()); handle_turn_started(&mut chat, "turn-1"); chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); assert_matches!( op_rx.try_recv(), Ok(Op::Interrupt { behavior: crate::app_command::InterruptBehavior::RestorePromptIfNoOutput, }) ); handle_turn_interrupted(&mut chat, "turn-1"); assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt);}#[tokio::test]async fn visible_output_prevents_cancelled_turn_prompt_restore() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt")); handle_turn_started(&mut chat, "turn-1"); chat.on_agent_message_delta("visible output".to_string()); chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); handle_turn_interrupted(&mut chat, "turn-1"); while let Ok(event) = rx.try_recv() { assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_))); }}#[tokio::test]async fn thinking_status_keeps_cancelled_turn_prompt_restore_eligible() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let prompt = UserMessage::from("revise this prompt"); chat.record_cancel_edit_candidate(prompt.clone()); handle_turn_started(&mut chat, "turn-1"); chat.on_agent_reasoning_delta("**Thinking**".to_string()); chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); handle_turn_interrupted(&mut chat, "turn-1"); assert_matches!(rx.try_recv(), Ok(AppEvent::RestoreCancelledTurn(restored)) if restored == prompt);}#[tokio::test]async fn patch_activity_prevents_cancelled_turn_prompt_restore() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.record_cancel_edit_candidate(UserMessage::from("revise this prompt")); handle_turn_started(&mut chat, "turn-1"); chat.on_patch_apply_begin(HashMap::new()); chat.submit_op(AppCommand::interrupt_and_restore_prompt_if_no_output()); handle_turn_interrupted(&mut chat, "turn-1"); while let Ok(event) = rx.try_recv() { assert!(!matches!(event, AppEvent::RestoreCancelledTurn(_))); }}#[tokio::test]async fn pending_steer_esc_does_not_steal_vim_insert_escape() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE); chat.bottom_pane.set_task_running(/*running*/ true); chat.input_queue .pending_steers .push_back(pending_steer("queued steer")); chat.toggle_vim_mode_and_notify(); chat.handle_key_event(KeyEvent::new(KeyCode::Char('i'), KeyModifiers::NONE)); assert!(chat.should_handle_vim_insert_escape(esc)); chat.handle_key_event(esc); assert!(!chat.should_handle_vim_insert_escape(esc)); assert_eq!(chat.input_queue.pending_steers.len(), 1); assert!(!chat.input_queue.submit_pending_steers_after_interrupt); assert!(op_rx.try_recv().is_err()); chat.handle_key_event(esc); match op_rx.try_recv() { Ok(Op::Interrupt { .. }) => {} other => panic!("expected Op::Interrupt, got {other:?}"), } assert!(chat.input_queue.submit_pending_steers_after_interrupt);}#[tokio::test]async fn pending_steer_interrupt_uses_remapped_binding() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let mut keymap = crate::keymap::RuntimeKeymap::defaults(); keymap.chat.interrupt_turn = vec![crate::key_hint::plain(KeyCode::F(12))]; chat.chat_keymap = keymap.chat.clone(); chat.bottom_pane.set_keymap_bindings(&keymap); chat.bottom_pane.set_task_running(/*running*/ true); chat.input_queue .pending_steers .push_back(pending_steer("queued steer")); chat.handle_key_event(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)); assert!(!chat.input_queue.submit_pending_steers_after_interrupt); assert!(op_rx.try_recv().is_err()); chat.handle_key_event(KeyEvent::new(KeyCode::F(12), KeyModifiers::NONE)); match op_rx.try_recv() { Ok(Op::Interrupt { .. }) => {} other => panic!("expected Op::Interrupt, got {other:?}"), } assert!(chat.input_queue.submit_pending_steers_after_interrupt);}#[tokio::test]async fn restore_thread_input_state_syncs_sleep_inhibitor_state() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.set_feature_enabled(Feature::PreventIdleSleep, /*enabled*/ true); chat.restore_thread_input_state(Some(ThreadInputState { composer: None, pending_steers: VecDeque::new(), pending_steer_history_records: VecDeque::new(), pending_steer_compare_keys: VecDeque::new(), rejected_steers_queue: VecDeque::new(), rejected_steer_history_records: VecDeque::new(), queued_user_messages: VecDeque::new(), queued_user_message_history_records: VecDeque::new(), user_turn_pending_start: false, current_collaboration_mode: chat.current_collaboration_mode.clone(), active_collaboration_mask: chat.active_collaboration_mask.clone(), task_running: true, agent_turn_running: true, })); assert!(chat.turn_lifecycle.agent_turn_running); assert!(chat.turn_lifecycle.sleep_inhibitor.is_turn_running()); assert!(chat.bottom_pane.is_task_running()); chat.restore_thread_input_state(/*input_state*/ None); assert!(!chat.turn_lifecycle.agent_turn_running); assert!(!chat.turn_lifecycle.sleep_inhibitor.is_turn_running()); assert!(!chat.bottom_pane.is_task_running());}#[tokio::test]async fn alt_up_edits_most_recent_queued_message() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.chat_keymap.edit_queued_message = vec![crate::key_hint::alt(KeyCode::Up)]; chat.queued_message_edit_hint_binding = Some(crate::key_hint::alt(KeyCode::Up)); chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding); // Simulate a running task so messages would normally be queued. chat.bottom_pane.set_task_running(/*running*/ true); // Seed two queued messages. chat.input_queue .queued_user_messages .push_back(UserMessage::from("first queued".to_string()).into()); chat.input_queue .queued_user_messages .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); // Press Alt+Up to edit the most recent (last) queued message. chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); // Composer should now contain the last queued message. assert_eq!( chat.bottom_pane.composer_text(), "second queued".to_string() ); // And the queue should now contain only the remaining (older) item. assert_eq!(chat.input_queue.queued_user_messages.len(), 1); assert_eq!( chat.input_queue.queued_user_messages.front().unwrap().text, "first queued" );}#[tokio::test]async fn unbound_queued_message_edit_does_not_fall_back_to_alt_up() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.chat_keymap.edit_queued_message = Vec::new(); chat.queued_message_edit_hint_binding = None; chat.bottom_pane .set_queued_message_edit_binding(chat.queued_message_edit_hint_binding); chat.bottom_pane.set_task_running(/*running*/ true); chat.input_queue .queued_user_messages .push_back(UserMessage::from("queued".to_string()).into()); chat.refresh_pending_input_preview(); chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::ALT)); assert!(chat.bottom_pane.composer_text().is_empty()); assert_eq!(chat.input_queue.queued_user_messages.len(), 1);}#[tokio::test]async fn shift_left_edits_most_recent_queued_message_in_apple_terminal() { assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { name: TerminalName::AppleTerminal, term_program: None, version: None, term: None, multiplexer: None, }) .await;}#[tokio::test]async fn shift_left_edits_most_recent_queued_message_in_warp_terminal() { assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { name: TerminalName::WarpTerminal, term_program: None, version: None, term: None, multiplexer: None, }) .await;}#[tokio::test]async fn shift_left_edits_most_recent_queued_message_in_vscode_terminal() { assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { name: TerminalName::VsCode, term_program: None, version: None, term: None, multiplexer: None, }) .await;}#[tokio::test]async fn shift_left_edits_most_recent_queued_message_in_tmux() { assert_shift_left_edits_most_recent_queued_message_for_terminal(TerminalInfo { name: TerminalName::Iterm2, term_program: None, version: None, term: None, multiplexer: Some(Multiplexer::Tmux { version: None }), }) .await;}#[test]fn queued_message_edit_binding_mapping_covers_special_terminals_and_tmux() { assert_eq!( queued_message_edit_binding_for_terminal(TerminalInfo { name: TerminalName::AppleTerminal, term_program: None, version: None, term: None, multiplexer: None, }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( queued_message_edit_binding_for_terminal(TerminalInfo { name: TerminalName::WarpTerminal, term_program: None, version: None, term: None, multiplexer: None, }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( queued_message_edit_binding_for_terminal(TerminalInfo { name: TerminalName::VsCode, term_program: None, version: None, term: None, multiplexer: None, }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( queued_message_edit_binding_for_terminal(TerminalInfo { name: TerminalName::Iterm2, term_program: None, version: None, term: None, multiplexer: Some(Multiplexer::Tmux { version: None }), }), crate::key_hint::shift(KeyCode::Left) ); assert_eq!( queued_message_edit_binding_for_terminal(TerminalInfo { name: TerminalName::Iterm2, term_program: None, version: None, term: None, multiplexer: None, }), crate::key_hint::alt(KeyCode::Up) );}/// Pressing Up to recall the most recent history entry and immediately queuing/// it while a task is running should always enqueue the same text, even when it/// is queued repeatedly.#[tokio::test]async fn enqueueing_history_prompt_multiple_times_is_stable() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); // Submit an initial prompt to seed history. chat.bottom_pane .set_composer_text("repeat me".to_string(), Vec::new(), Vec::new()); chat.handle_key_event(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)); // Simulate an active task so further submissions are queued. chat.bottom_pane.set_task_running(/*running*/ true); for _ in 0..3 { // Recall the prompt from history and ensure it is what we expect. chat.handle_key_event(KeyEvent::new(KeyCode::Up, KeyModifiers::NONE)); assert_eq!(chat.bottom_pane.composer_text(), "repeat me"); // Queue the prompt while the task is running. chat.handle_key_event(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)); } assert_eq!(chat.input_queue.queued_user_messages.len(), 3); for message in chat.input_queue.queued_user_messages.iter() { assert_eq!(message.text, "repeat me"); }}#[tokio::test]async fn submit_user_message_ignores_inaccessible_app_mentions_from_bindings() { let (mut chat, _rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.thread_id = Some(ThreadId::new()); set_chatgpt_auth(&mut chat); chat.config .features .enable(Feature::Apps) .expect("test config should allow feature update"); chat.on_connectors_loaded( Ok(ConnectorsSnapshot { connectors: vec![AppInfo { id: "arabica_uae".to_string(), name: "% Arabica UAE".to_string(), description: Some("Directory-only app".to_string()), logo_url: None, logo_url_dark: None, distribution_channel: None, branding: None, app_metadata: None, labels: None, install_url: Some("https://example.test/arabica".to_string()), is_accessible: false, is_enabled: true, plugin_display_names: Vec::new(), }], }), /*is_final*/ false, ); chat.submit_user_message(UserMessage { text: "$arabica-uae".to_string(), local_images: Vec::new(), remote_image_urls: Vec::new(), text_elements: Vec::new(), mention_bindings: vec![MentionBinding { sigil: '$', mention: "arabica-uae".to_string(), path: "app://arabica_uae".to_string(), }], }); let items = match next_submit_op(&mut op_rx) { Op::UserTurn { items, .. } => items, other => panic!("expected Op::UserTurn, got {other:?}"), }; assert_eq!( items, vec![UserInput::Text { text: "$arabica-uae".to_string(), text_elements: Vec::new(), }] );}#[test]fn user_message_display_from_inputs_matches_flattened_user_message_shape() { let local_image = PathBuf::from("/tmp/local.png"); let rendered = ChatWidget::user_message_display_from_inputs(&[ UserInput::Text { text: "hello ".to_string(), text_elements: vec![TextElement::new((0..5).into(), /*placeholder*/ None).into()], }, UserInput::Image { url: "https://example.com/remote.png".to_string(), detail: None, }, UserInput::LocalImage { path: local_image.clone(), detail: None, }, UserInput::Skill { name: "demo".to_string(), path: PathBuf::from("/tmp/skill/SKILL.md"), }, UserInput::Mention { name: "repo".to_string(), path: "app://repo".to_string(), }, UserInput::Text { text: "world".to_string(), text_elements: vec![TextElement::new((0..5).into(), Some("planet".to_string())).into()], }, ]); assert_eq!( rendered, ChatWidget::user_message_display_from_parts( "hello world".to_string(), vec![ TextElement::new((0..5).into(), Some("hello".to_string())), TextElement::new((6..11).into(), Some("planet".to_string())), ], vec![local_image], vec!["https://example.com/remote.png".to_string()], ) );}#[test]fn user_message_display_from_inputs_hides_prompt_context() { let raw_message = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\nAsk $figma"; let mention_start = raw_message.find("$figma").expect("mention in raw message"); let rendered = ChatWidget::user_message_display_from_inputs(&[UserInput::Text { text: raw_message.to_string(), text_elements: vec![ TextElement::new( (mention_start..mention_start + "$figma".len()).into(), Some("$figma".to_string()), ) .into(), ], }]); assert_eq!( rendered, ChatWidget::user_message_display_from_parts( "Ask $figma".to_string(), vec![TextElement::new((4..10).into(), Some("$figma".to_string()))], Vec::new(), Vec::new(), ) );}#[tokio::test]async fn committed_user_message_with_hidden_prompt_context_renders_local_images() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; let local_image = PathBuf::from("/tmp/context-image.png"); let raw_message = "# Context from my IDE setup:\n\n## Active file: src/lib.rs\n\n## My request for Codex:\n"; complete_user_message_for_inputs( &mut chat, "user-1", vec![ UserInput::Text { text: raw_message.to_string(), text_elements: Vec::new(), }, UserInput::LocalImage { path: local_image.clone(), detail: None, }, ], ); let mut user_cell = None; while let Ok(event) = rx.try_recv() { if let AppEvent::InsertHistoryCell(cell) = event && let Some(cell) = cell.as_any().downcast_ref::<UserHistoryCell>() { user_cell = Some((cell.message.clone(), cell.local_image_paths.clone())); break; } } let (message, local_images) = user_cell.expect("expected user history cell"); assert_eq!(message, ""); assert_eq!(local_images, vec![local_image]);}#[tokio::test]async fn interrupt_restores_queued_messages_into_composer() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; // Simulate a running task to enable queuing of user inputs. chat.bottom_pane.set_task_running(/*running*/ true); // Queue two user messages while the task is running. chat.input_queue .queued_user_messages .push_back(UserMessage::from("first queued".to_string()).into()); chat.input_queue .queued_user_messages .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); // Deliver an interrupted turn notification as if Esc was pressed. handle_turn_interrupted(&mut chat, "turn-1"); // Composer should now contain the queued messages joined by newlines, in order. assert_eq!( chat.bottom_pane.composer_text(), "first queued\nsecond queued" ); // Queue should be cleared and no new user input should have been auto-submitted. assert!(chat.input_queue.queued_user_messages.is_empty()); assert!( op_rx.try_recv().is_err(), "unexpected outbound op after interrupt" ); // Drain rx to avoid unused warnings. let _ = drain_insert_history(&mut rx);}#[tokio::test]async fn interrupt_prepends_queued_messages_before_existing_composer_text() { let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; chat.bottom_pane.set_task_running(/*running*/ true); chat.bottom_pane .set_composer_text("current draft".to_string(), Vec::new(), Vec::new()); chat.input_queue .queued_user_messages .push_back(UserMessage::from("first queued".to_string()).into()); chat.input_queue .queued_user_messages .push_back(UserMessage::from("second queued".to_string()).into()); chat.refresh_pending_input_preview(); handle_turn_interrupted(&mut chat, "turn-1"); assert_eq!( chat.bottom_pane.composer_text(), "first queued\nsecond queued\ncurrent draft" ); assert!(chat.input_queue.queued_user_messages.is_empty()); assert!( op_rx.try_recv().is_err(), "unexpected outbound op after interrupt" ); let _ = drain_insert_history(&mut rx);}