use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::create_fake_paginated_rollout; use app_test_support::create_fake_rollout_with_text_elements; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::rollout_path; use app_test_support::test_absolute_path; use app_test_support::to_response; use codex_app_server::in_process; use codex_app_server::in_process::InProcessStartArgs; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::CodexErrorInfo; use codex_app_server_protocol::DeprecationNoticeNotification; use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::ImageReference; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::InitializeParams; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadForkParams; use codex_app_server_protocol::ThreadForkResponse; use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadItemEntry; use codex_app_server_protocol::ThreadItemPayload; use codex_app_server_protocol::ThreadItemsListAnchor; use codex_app_server_protocol::ThreadItemsListCursor; use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; use codex_app_server_protocol::ThreadItemsReadParams; use codex_app_server_protocol::ThreadItemsReadResponse; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; use codex_app_server_protocol::ThreadNameUpdatedNotification; use codex_app_server_protocol::ThreadReadParams; use codex_app_server_protocol::ThreadReadResponse; use codex_app_server_protocol::ThreadResumeInitialTurnsPageParams; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadSearchOccurrencesParams; use codex_app_server_protocol::ThreadSearchOccurrencesResponse; use codex_app_server_protocol::ThreadSetNameParams; use codex_app_server_protocol::ThreadSetNameResponse; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadTurnsListResponse; use codex_app_server_protocol::Turn; use codex_app_server_protocol::TurnItemsView; use codex_app_server_protocol::TurnStartParams; use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_arg0::Arg0DispatchPaths; use codex_config::CloudConfigBundleLoader; use codex_config::LoaderOverrides; use codex_core::ARCHIVED_SESSIONS_SUBDIR; use codex_core::config::ConfigBuilder; use codex_exec_server::EnvironmentManager; use codex_feedback::CodexFeedback; use codex_protocol::ContextWindowId; use codex_protocol::items::AgentMessageContent; use codex_protocol::items::AgentMessageItem; use codex_protocol::items::CommandExecutionItem; use codex_protocol::items::TurnItem as CoreTurnItem; use codex_protocol::items::UserMessageItem; use codex_protocol::models::BaseInstructions; use codex_protocol::models::MessagePhase; use codex_protocol::protocol::AgentMessageEvent; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ExecCommandSource; use codex_protocol::protocol::ItemCompletedEvent; use codex_protocol::protocol::SessionSource as ProtocolSessionSource; use codex_protocol::protocol::ThreadMemoryMode; use codex_protocol::protocol::TurnCompleteEvent; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; use codex_protocol::user_input::ByteRange; use codex_protocol::user_input::TextElement; use codex_rollout::RolloutItem; use codex_thread_store::AppendThreadItemsParams; use codex_thread_store::CreateThreadParams; use codex_thread_store::InMemoryThreadStore; use codex_thread_store::LocalThreadStore; use codex_thread_store::LocalThreadStoreConfig; use codex_thread_store::PersistContext; use codex_thread_store::ThreadMetadataPatch; use codex_thread_store::ThreadPersistenceMetadata; use codex_thread_store::ThreadStore; use codex_thread_store::UpdateThreadMetadataParams; use codex_utils_absolute_path::test_support::PathExt; use core_test_support::responses; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; use std::io::Write; use std::path::Path; use std::sync::Arc; use tempfile::TempDir; use tokio::time::timeout; use uuid::Uuid; #[cfg(windows)] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(25); #[cfg(not(windows))] const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] async fn thread_read_returns_summary_without_turns() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let preview = "Saved user message"; let text_elements = [TextElement::new( ByteRange { start: 0, end: 5 }, Some("".into()), )]; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", preview, text_elements .iter() .map(|elem| serde_json::to_value(elem).expect("serialize text element")) .collect(), Some("mock_provider"), /*git_info*/ None, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: conversation_id.clone(), include_turns: false, }) .await?; let response: Value = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(response["thread"].get("model"), Some(&Value::Null)); assert_eq!( response["thread"].get("reasoningEffort"), Some(&Value::Null) ); let ThreadReadResponse { thread, .. } = serde_json::from_value(response)?; assert_eq!(thread.id, conversation_id); assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); assert!(!thread.ephemeral, "stored rollouts should not be ephemeral"); assert!(thread.path.as_ref().expect("thread path").is_absolute()); assert_eq!(thread.cwd, test_absolute_path("/")); assert_eq!(thread.cli_version, "0.0.0"); assert_eq!(thread.source, SessionSource::Cli); assert_eq!(thread.git_info, None); assert_eq!(thread.turns.len(), 0); assert_eq!(thread.status, ThreadStatus::NotLoaded); let list_id = mcp.send_raw_request("thread/list", Some(json!({}))).await?; let response: Value = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(list_id)).await??; let listed = response["data"] .as_array() .expect("thread list") .iter() .find(|listed| listed["id"] == conversation_id) .expect("stored thread should be listed"); assert_eq!(listed.get("model"), Some(&Value::Null)); assert_eq!(listed.get("reasoningEffort"), Some(&Value::Null)); Ok(()) } #[tokio::test] async fn thread_read_can_include_turns() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let preview = "Saved user message"; let text_elements = vec![TextElement::new( ByteRange { start: 0, end: 5 }, Some("".into()), )]; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", preview, text_elements .iter() .map(|elem| serde_json::to_value(elem).expect("serialize text element")) .collect(), Some("mock_provider"), /*git_info*/ None, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: conversation_id.clone(), include_turns: true, }) .await?; let ThreadReadResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(thread.turns.len(), 1); let turn = &thread.turns[0]; assert_eq!(turn.status, TurnStatus::Completed); assert_eq!(turn.root_turn_id, None); assert_eq!(turn.items_view, TurnItemsView::Full); assert_eq!(turn.items.len(), 1, "expected user message item"); match &turn.items[0].payload { ThreadItemPayload::UserMessage { content, .. } => { assert_eq!( content, &vec![UserInput::Text { text: preview.to_string(), text_elements: text_elements.clone().into_iter().map(Into::into).collect(), }] ); } other => panic!("expected user message item, got {other:?}"), } assert_eq!(thread.status, ThreadStatus::NotLoaded); assert!( !mcp.pending_notification_methods() .contains(&"deprecationNotice".to_string()) ); Ok(()) } /// Preserves a file-backed image and causal root across completion and a cold history read. #[test_case::test_case(ThreadHistoryMode::Legacy; "legacy")] #[test_case::test_case(ThreadHistoryMode::Paginated; "paginated")] #[tokio::test] async fn thread_read_preserves_file_id_and_root_from_completed_turn( history_mode: ThreadHistoryMode, ) -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized() .await?; let start_id = mcp .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), history_mode: Some(history_mode), ..Default::default() }) .await?; let ThreadStartResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??; let image = UserInput::Image { image: ImageReference::File { file_id: "file_123".to_string(), }, detail: None, }; let completed = timeout( DEFAULT_READ_TIMEOUT, mcp.start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread.id.clone(), input: vec![image.clone()], root_turn_id: Some("causal-root".to_string()), ..Default::default() }), ) .await??; let expected_root = "causal-root"; assert_eq!(completed.turn.root_turn_id.as_deref(), Some(expected_root)); let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread.id.clone(), include_turns: true, }) .await?; let ThreadReadResponse { thread: loaded_thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; let ThreadItemPayload::UserMessage { content, .. } = &loaded_thread.turns[0].items[0].payload else { panic!("expected completed turn to start with a user message"); }; assert_eq!(content, &vec![image.clone()]); assert_eq!( loaded_thread.turns[0].root_turn_id.as_deref(), Some(expected_root) ); timeout(DEFAULT_READ_TIMEOUT, mcp.shutdown_gracefully()).await??; drop(mcp); let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized() .await?; let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread.id.clone(), include_turns: true, }) .await?; let ThreadReadResponse { thread: restarted_thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; let ThreadItemPayload::UserMessage { content, .. } = &restarted_thread.turns[0].items[0].payload else { panic!("expected persisted turn to start with a user message"); }; assert_eq!(content, &vec![image]); assert_eq!( restarted_thread.turns[0].root_turn_id.as_deref(), Some(expected_root) ); let page_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: thread.id, cursor: None, limit: Some(1), sort_direction: Some(SortDirection::Asc), items_view: None, }) .await?; let page: ThreadTurnsListResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(page_id)).await??; assert_eq!(page.data.len(), 1); assert_eq!(page.data[0].id, completed.turn.id); assert_eq!(page.data[0].root_turn_id.as_deref(), Some(expected_root)); Ok(()) } #[tokio::test] async fn paginated_stored_thread_routes_projected_turns() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let conversation_id = create_fake_paginated_rollout( codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", "Saved user message", Some("mock_provider"), /*git_info*/ None, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: conversation_id.clone(), include_turns: false, }) .await?; let ThreadReadResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated); assert!(thread.turns.is_empty()); assert!( !mcp.pending_notification_methods() .contains(&"deprecationNotice".to_string()) ); let full_read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: conversation_id.clone(), include_turns: true, }) .await?; let notice: DeprecationNoticeNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_notification("deprecationNotice"), ) .await??; assert_eq!( notice, DeprecationNoticeNotification { summary: "Full-history hydration is deprecated for paginated threads; omit `includeTurns` or set it to `false`, then page with `thread/turns/list` and `thread/items/list`.".to_string(), details: None, } ); let _: ThreadReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(full_read_id)).await??; let list_id = mcp .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, cursor: None, limit: Some(50), sort_key: None, sort_direction: None, model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: false, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }) .await?; let ThreadListResponse { data, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(list_id)).await??; let listed = data .iter() .find(|thread| thread.id == conversation_id) .expect("thread/list should include paginated thread"); assert_eq!(listed.history_mode, ThreadHistoryMode::Paginated); let turns_list_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: conversation_id.clone(), cursor: None, limit: None, sort_direction: None, items_view: None, }) .await?; let turns_list_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)), ) .await??; assert_eq!( to_response::(turns_list_resp)?, ThreadTurnsListResponse { data: Vec::new(), next_cursor: None, backwards_cursor: None, } ); Ok(()) } #[tokio::test] async fn thread_turns_list_can_page_backward_and_forward() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let filename_ts = "2025-01-05T12-00-00"; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), filename_ts, "2025-01-05T12:00:00Z", "first", vec![], Some("mock_provider"), /*git_info*/ None, )?; let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let read_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: conversation_id.clone(), cursor: None, limit: Some(2), sort_direction: Some(SortDirection::Desc), items_view: None, }) .await?; let ThreadTurnsListResponse { data, next_cursor, backwards_cursor, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(turn_user_texts(&data), vec!["third", "second"]); assert!( data.iter() .all(|turn| turn.items_view == TurnItemsView::Summary) ); let next_cursor = next_cursor.expect("expected nextCursor for older turns"); let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn"); let read_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: conversation_id.clone(), cursor: Some(next_cursor), limit: Some(10), sort_direction: Some(SortDirection::Desc), items_view: None, }) .await?; let ThreadTurnsListResponse { data, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(turn_user_texts(&data), vec!["first"]); append_user_message(rollout_path.as_path(), "2025-01-05T12:03:00Z", "fourth")?; let read_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: conversation_id, cursor: Some(backwards_cursor), limit: Some(10), sort_direction: Some(SortDirection::Asc), items_view: None, }) .await?; let ThreadTurnsListResponse { data, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(turn_user_texts(&data), vec!["third", "fourth"]); Ok(()) } #[tokio::test] async fn thread_turns_list_supports_requested_items_view() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let filename_ts = "2025-01-05T12-00-00"; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), filename_ts, "2025-01-05T12:00:00Z", "first", vec![], Some("mock_provider"), /*git_info*/ None, )?; let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); append_agent_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "draft")?; append_agent_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "final")?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let full = read_single_turn_items_view( &mut mcp, conversation_id.as_str(), Some(TurnItemsView::Full), ) .await?; assert_eq!(full.items_view, TurnItemsView::Full); assert_eq!( turn_agent_texts(std::slice::from_ref(&full)), vec!["draft", "final"] ); let summary = read_single_turn_items_view( &mut mcp, conversation_id.as_str(), Some(TurnItemsView::Summary), ) .await?; assert_eq!(summary.items_view, TurnItemsView::Summary); assert_eq!( turn_user_texts(std::slice::from_ref(&summary)), vec!["first"] ); assert_eq!( turn_agent_texts(std::slice::from_ref(&summary)), vec!["final"] ); let not_loaded = read_single_turn_items_view( &mut mcp, conversation_id.as_str(), Some(TurnItemsView::NotLoaded), ) .await?; assert_eq!(not_loaded.items_view, TurnItemsView::NotLoaded); assert!(not_loaded.items.is_empty()); assert_eq!(not_loaded.id, full.id); assert_eq!(not_loaded.status, full.status); assert_eq!(not_loaded.started_at, full.started_at); assert_eq!(not_loaded.completed_at, full.completed_at); assert_eq!(not_loaded.duration_ms, full.duration_ms); Ok(()) } #[tokio::test] async fn thread_search_occurrences_reads_paginated_projection() -> Result<()> { let partial_answer = "😀 ΟΣ ß partial ꟒"; let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let thread_id = codex_protocol::ThreadId::default(); let sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.path().abs()); let state_db = codex_state::StateRuntime::init(sqlite.clone(), "mock_provider".to_string()).await?; let store = LocalThreadStore::new( LocalThreadStoreConfig { codex_home: codex_home.path().to_path_buf(), sqlite, default_model_provider_id: "mock_provider".to_string(), }, Some(state_db), ); store .create_thread(CreateThreadParams { attached_thread: false, creator_user_id: None, creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, parent_thread_id: None, source: ProtocolSessionSource::Cli, thread_source: None, originator: "test_originator".to_string(), base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), multi_agent_version: None, history_mode: codex_protocol::protocol::ThreadHistoryMode::Paginated, history_base: None, subagent_history_start_ordinal: None, initial_window_id: ContextWindowId::new_uuid_v7(), runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(codex_home.path().to_path_buf()), model_provider: "mock_provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, }, }) .await?; store .persist_thread(thread_id, PersistContext::Standard) .await?; store .append_items(AppendThreadItemsParams { thread_id, items: vec![ paginated_turn_started("turn-1"), paginated_completed_item( thread_id, "turn-1", CoreTurnItem::UserMessage(UserMessageItem { id: "user-1".to_string(), client_id: None, content: vec![ codex_protocol::user_input::UserInput::Text { text: "Nee".to_string(), text_elements: Vec::new(), }, codex_protocol::user_input::UserInput::Text { text: "dle needle needle needle".to_string(), text_elements: Vec::new(), }, ], }), ), paginated_completed_item( thread_id, "turn-1", CoreTurnItem::UserMessage(UserMessageItem { id: "steer-1".to_string(), client_id: None, content: vec![codex_protocol::user_input::UserInput::Text { text: "steer toward needle".to_string(), text_elements: Vec::new(), }], }), ), paginated_completed_item( thread_id, "turn-1", CoreTurnItem::AgentMessage(AgentMessageItem { moderation: None, id: "commentary-1".to_string(), content: vec![AgentMessageContent::Text { text: "commentary needle".to_string(), }], phase: Some(MessagePhase::Commentary), memory_citation: None, delivery: None, questions: None, }), ), paginated_completed_item( thread_id, "turn-1", CoreTurnItem::AgentMessage(AgentMessageItem { moderation: None, id: "partial-1".to_string(), content: vec![AgentMessageContent::Text { text: partial_answer.to_string(), }], phase: Some(MessagePhase::PartialAnswer), memory_citation: None, delivery: None, questions: None, }), ), paginated_completed_item( thread_id, "turn-1", CoreTurnItem::AgentMessage(AgentMessageItem { moderation: None, id: "final-1".to_string(), content: vec![AgentMessageContent::Text { text: "😀 **Final** \nneedle".to_string(), }], phase: Some(MessagePhase::FinalAnswer), memory_citation: None, delivery: None, questions: None, }), ), paginated_turn_completed("turn-1"), ], }) .await?; store.shutdown_thread(thread_id).await?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let request_id = mcp .send_thread_search_occurrences_request(ThreadSearchOccurrencesParams { thread_id: thread_id.to_string(), search_term: "needle".to_string(), cursor: None, limit: Some(3), }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let ThreadSearchOccurrencesResponse { data, next_cursor } = to_response(response)?; assert_eq!( data.iter() .map(|occurrence| occurrence.item_id.as_str()) .collect::>(), vec!["user-1", "user-1", "user-1"] ); assert_eq!( data.iter() .map(|occurrence| occurrence.turn_id.as_str()) .collect::>(), vec!["turn-1", "turn-1", "turn-1"] ); assert_eq!( data.iter() .map(|occurrence| occurrence.snippet_match_range.start) .collect::>(), vec![0, 7, 14] ); let next_cursor = next_cursor.expect("first page should have another occurrence"); let request_id = mcp .send_thread_search_occurrences_request(ThreadSearchOccurrencesParams { thread_id: thread_id.to_string(), search_term: "needle".to_string(), cursor: Some(next_cursor), limit: Some(3), }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; let ThreadSearchOccurrencesResponse { data, next_cursor } = to_response(response)?; assert_eq!( data.iter() .map(|occurrence| occurrence.item_id.as_str()) .collect::>(), vec!["user-1", "steer-1", "final-1"] ); assert_eq!( data.iter() .map(|occurrence| occurrence.turn_id.as_str()) .collect::>(), vec!["turn-1", "turn-1", "turn-1"] ); assert_eq!(data[2].snippet, "😀 Final needle"); assert_eq!(data[2].snippet_match_range.start, 9); assert_eq!(data[2].snippet_match_range.end, 15); assert_eq!(next_cursor, None); for (term, expected_range) in [ ("partial", 8..15), ("Σ", 4..5), ("σ", 4..5), ("ς", 4..5), ("ΟΣ", 3..5), ("ss", 6..7), ("ꟓ", 16..17), ] { let request_id = mcp .send_thread_search_occurrences_request(ThreadSearchOccurrencesParams { thread_id: thread_id.to_string(), search_term: term.to_string(), cursor: None, limit: Some(3), }) .await?; let ThreadSearchOccurrencesResponse { data, next_cursor } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; let [hit] = data.as_slice() else { panic!("expected one match for {term}, got {data:?}"); }; assert_eq!(hit.item_id, "partial-1"); assert_eq!(hit.snippet, partial_answer); let range = &hit.snippet_match_range; assert_eq!(range.start..range.end, expected_range, "search term {term}"); assert_eq!(next_cursor, None); } let fork_request_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: thread_id.to_string(), ..Default::default() }) .await?; let ThreadForkResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_request_id)).await??; let forked_thread_id = thread.id; let source_resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread_id.to_string(), ..Default::default() }) .await?; let _: ThreadResumeResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(source_resume_id)).await??; for (target_thread_id, text) in [ (thread_id.to_string(), "excluded parent needle"), (forked_thread_id.clone(), "child needle"), ] { let turn_id = mcp .send_turn_start_request(TurnStartParams { thread_id: target_thread_id, input: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; let _: TurnStartResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_id)).await??; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; } let request_id = mcp .send_thread_search_occurrences_request(ThreadSearchOccurrencesParams { thread_id: forked_thread_id.clone(), search_term: "needle".to_string(), cursor: None, limit: Some(6), }) .await?; let ThreadSearchOccurrencesResponse { data, next_cursor } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(data.len(), 6); assert!( data.iter() .all(|occurrence| !occurrence.snippet.contains("excluded parent needle")) ); let next_cursor = next_cursor.expect("search should continue into child history"); let request_id = mcp .send_thread_search_occurrences_request(ThreadSearchOccurrencesParams { thread_id: forked_thread_id, search_term: "needle".to_string(), cursor: Some(next_cursor), limit: Some(6), }) .await?; let ThreadSearchOccurrencesResponse { data, next_cursor } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request_id)).await??; assert_eq!(data.len(), 1); assert!(data[0].snippet.contains("child needle")); assert_eq!(next_cursor, None); Ok(()) } #[tokio::test] async fn thread_turns_list_reads_store_history_without_rollout_path() -> Result<()> { let codex_home = TempDir::new()?; let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000123")?; let store_id = Uuid::new_v4().to_string(); MockResponsesConfig::new("http://127.0.0.1:1") .with_root_config(&format!( r#"experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }}"# )) .write(codex_home.path())?; let store = InMemoryThreadStore::for_id(store_id.clone()); let _in_memory_store = InMemoryThreadStoreId { store_id }; seed_pathless_store_thread(&store, thread_id).await?; let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .loader_overrides(loader_overrides.clone()) .build() .await?; let client = in_process::start(InProcessStartArgs { arg0_paths: Arg0DispatchPaths::default(), config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), embedded_network_policy: Default::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli.into(), enable_codex_api_key_env: false, initialize: InitializeParams { client_info: ClientInfo { name: "codex-app-server-tests".to_string(), title: None, version: "0.1.0".to_string(), }, capabilities: Some(InitializeCapabilities { experimental_api: true, ..Default::default() }), }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) .await?; let result = client .request(ClientRequest::ThreadTurnsList { request_id: RequestId::Integer(1), params: ThreadTurnsListParams { thread_id: thread_id.to_string(), cursor: None, limit: Some(10), sort_direction: Some(SortDirection::Asc), items_view: None, }, }) .await? .expect("thread/turns/list should succeed"); let ThreadTurnsListResponse { data, .. } = serde_json::from_value(result)?; assert_eq!(turn_user_texts(&data), vec!["history from store"]); client.shutdown().await?; Ok(()) } #[tokio::test] async fn thread_read_loaded_include_turns_reads_store_history_without_rollout_path() -> Result<()> { let codex_home = TempDir::new()?; let store_id = Uuid::new_v4().to_string(); MockResponsesConfig::new("http://127.0.0.1:1") .with_root_config(&format!( r#"experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }}"# )) .write(codex_home.path())?; let store = InMemoryThreadStore::for_id(store_id.clone()); let _in_memory_store = InMemoryThreadStoreId { store_id }; let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .loader_overrides(loader_overrides.clone()) .build() .await?; let client = in_process::start(InProcessStartArgs { arg0_paths: Arg0DispatchPaths::default(), config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), embedded_network_policy: Default::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli.into(), enable_codex_api_key_env: false, initialize: InitializeParams { client_info: ClientInfo { name: "codex-app-server-tests".to_string(), title: None, version: "0.1.0".to_string(), }, capabilities: Some(InitializeCapabilities { experimental_api: true, ..Default::default() }), }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) .await?; let result = client .request(ClientRequest::ThreadStart { request_id: RequestId::Integer(1), params: ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }, }) .await? .expect("thread/start should succeed"); let ThreadStartResponse { thread, .. } = serde_json::from_value(result)?; assert_eq!(thread.path, None); let thread_id = codex_protocol::ThreadId::from_string(&thread.id)?; store .append_items(AppendThreadItemsParams { thread_id, items: store_history_items(), }) .await?; store .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { daybreak_enabled: Some(false), ..Default::default() }, include_archived: false, }) .await?; let result = client .request(ClientRequest::ThreadRead { request_id: RequestId::Integer(2), params: ThreadReadParams { thread_id: thread.id, include_turns: true, }, }) .await? .expect("thread/read should succeed"); let ThreadReadResponse { thread, .. } = serde_json::from_value(result)?; assert_eq!(thread.daybreak_enabled, Some(false)); assert_eq!(thread.status, ThreadStatus::Idle); assert_eq!(turn_user_texts(&thread.turns), vec!["history from store"]); let [ codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::UserMessage { content, .. }, .. }, ] = thread.turns[0].items.as_slice() else { panic!("expected one user message item"); }; assert_eq!( content, &vec![ UserInput::Text { text: "history from store".to_string(), text_elements: Vec::new(), }, UserInput::Audio { audio: codex_app_server_protocol::AudioReference::Inline { url: "https://example.com/recording.mp3".to_string() } }, UserInput::LocalAudio { path: "recording.wav".into(), }, ] ); client.shutdown().await?; Ok(()) } #[tokio::test] async fn thread_list_includes_store_thread_without_rollout_path() -> Result<()> { let codex_home = TempDir::new()?; let thread_id = codex_protocol::ThreadId::from_string("00000000-0000-4000-8000-000000000124")?; let store_id = Uuid::new_v4().to_string(); MockResponsesConfig::new("http://127.0.0.1:1") .with_root_config(&format!( r#"experimental_thread_store = {{ type = "in_memory", id = "{store_id}" }}"# )) .write(codex_home.path())?; let store = InMemoryThreadStore::for_id(store_id.clone()); let _in_memory_store = InMemoryThreadStoreId { store_id }; seed_pathless_store_thread(&store, thread_id).await?; let loader_overrides = LoaderOverrides::without_managed_config_for_tests(); let config = ConfigBuilder::default() .codex_home(codex_home.path().to_path_buf()) .fallback_cwd(Some(codex_home.path().to_path_buf())) .loader_overrides(loader_overrides.clone()) .build() .await?; let client = in_process::start(InProcessStartArgs { arg0_paths: Arg0DispatchPaths::default(), config: Arc::new(config), cli_overrides: Vec::new(), loader_overrides, strict_config: false, cloud_config_bundle: CloudConfigBundleLoader::default(), embedded_network_policy: Default::default(), thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader), feedback: CodexFeedback::new(), log_db: None, state_db: None, environment_manager: Arc::new(EnvironmentManager::default_for_tests()), config_warnings: Vec::new(), session_source: SessionSource::Cli.into(), enable_codex_api_key_env: false, initialize: InitializeParams { client_info: ClientInfo { name: "codex-app-server-tests".to_string(), title: None, version: "0.1.0".to_string(), }, capabilities: Some(InitializeCapabilities { experimental_api: true, ..Default::default() }), }, channel_capacity: in_process::DEFAULT_IN_PROCESS_CHANNEL_CAPACITY, }) .await?; let result = client .request(ClientRequest::ThreadList { request_id: RequestId::Integer(1), params: ThreadListParams { include_attached_threads: false, current_account_only: false, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, cursor: None, limit: Some(10), sort_key: None, sort_direction: None, model_providers: Some(Vec::new()), source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: false, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }, }) .await? .expect("thread/list should succeed"); let ThreadListResponse { data, .. } = serde_json::from_value(result)?; assert_eq!(data.len(), 1); let thread = &data[0]; assert_eq!(thread.id, thread_id.to_string()); assert_eq!(thread.path, None); assert_eq!(thread.preview, ""); assert_eq!(thread.name.as_deref(), Some("named pathless thread")); client.shutdown().await?; Ok(()) } #[tokio::test] async fn thread_read_can_return_archived_threads_by_id() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let filename_ts = "2025-01-05T12-00-00"; let preview = "Archived saved user message"; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), filename_ts, "2025-01-05T12:00:00Z", preview, vec![], Some("mock_provider"), /*git_info*/ None, )?; let active_rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); let archived_dir = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); std::fs::create_dir_all(&archived_dir)?; let archived_rollout_path = archived_dir.join(active_rollout_path.file_name().expect("rollout file name")); std::fs::rename(&active_rollout_path, &archived_rollout_path)?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: conversation_id.clone(), include_turns: false, }) .await?; let ThreadReadResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(thread.id, conversation_id); assert_eq!(thread.preview, preview); let path = thread.path.expect("thread path"); assert_eq!(path.canonicalize()?, archived_rollout_path.canonicalize()?); Ok(()) } #[tokio::test] async fn cold_resume_pages_inherited_history_when_active_ancestor_is_short() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let filename_ts = "2025-01-05T12-00-00"; let parent_id = create_fake_paginated_rollout( codex_home.path(), filename_ts, "2025-01-05T12:00:00Z", "Saved user message", Some("mock_provider"), /*git_info*/ None, )?; let active = rollout_path(codex_home.path(), filename_ts, &parent_id); for item in [ paginated_turn_started("turn-1"), paginated_completed_item( codex_protocol::ThreadId::from_string(&parent_id)?, "turn-1", CoreTurnItem::UserMessage(UserMessageItem { id: "user-1".to_string(), client_id: None, content: Vec::new(), }), ), paginated_turn_completed("turn-1"), ] { codex_rollout::append_rollout_item_to_path(&active, &item).await?; } let mut app = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let fork: ThreadForkResponse = app .request(|request_id| ClientRequest::ThreadFork { request_id, params: ThreadForkParams { thread_id: parent_id, ..Default::default() }, }) .await?; timeout(DEFAULT_READ_TIMEOUT, app.shutdown_gracefully()).await??; // Leave the preferred copy readable, but shorter than the child's inherited bookmark. let archive = codex_home.path().join(ARCHIVED_SESSIONS_SUBDIR); std::fs::create_dir_all(&archive)?; let filename = active.file_name().expect("rollout filename"); std::fs::copy(&active, archive.join(filename))?; let contents = std::fs::read_to_string(&active)?; let (header, _) = contents.split_once('\n').expect("rollout body"); std::fs::write(&active, format!("{header}\n"))?; let mut app = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let child_id = codex_protocol::ThreadId::from_string(&fork.thread.id)?; let resumed: ThreadResumeResponse = app .request(|request_id| ClientRequest::ThreadResume { request_id, params: ThreadResumeParams { thread_id: fork.thread.id, exclude_turns: true, ..Default::default() }, }) .await?; assert!(resumed.thread.turns.is_empty()); let turn_cursor = resumed.turns_backwards_cursor.expect("turn cursor"); let item_cursor = resumed.items_backwards_cursor.expect("item cursor"); let turns = read_turns_page( &mut app, child_id, Some(turn_cursor), Some(1), SortDirection::Desc, Some(TurnItemsView::NotLoaded), ) .await?; let items = read_items_page( &mut app, child_id, /*turn_id*/ None, Some(item_cursor), Some(1), SortDirection::Desc, ) .await?; assert_eq!(turns.data[0].id, "turn-1"); assert_eq!(items.data[0].turn_id, "turn-1"); assert_eq!(items.data[0].item.id(), "user-1"); Ok(()) } #[tokio::test] async fn thread_resume_initial_turns_page_matches_requested_turns_list_page() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let filename_ts = "2025-01-05T12-00-00"; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), filename_ts, "2025-01-05T12:00:00Z", "first", vec![], Some("mock_provider"), /*git_info*/ None, )?; let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let turns_list_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: conversation_id.clone(), cursor: None, limit: Some(2), sort_direction: Some(SortDirection::Asc), items_view: Some(TurnItemsView::NotLoaded), }) .await?; let turns_list_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(turns_list_id)), ) .await??; let expected_page = to_response::(turns_list_resp)?; let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: conversation_id, exclude_turns: true, initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { limit: Some(2), sort_direction: Some(SortDirection::Asc), items_view: Some(TurnItemsView::NotLoaded), }), ..Default::default() }) .await?; let ThreadResumeResponse { thread, initial_turns_page, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; assert!(thread.turns.is_empty()); assert_eq!( initial_turns_page, Some(codex_app_server_protocol::TurnsPage::from(expected_page)) ); Ok(()) } #[tokio::test] async fn thread_turns_list_rejects_cursor_when_anchor_turn_is_rolled_back() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let filename_ts = "2025-01-05T12-00-00"; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), filename_ts, "2025-01-05T12:00:00Z", "first", vec![], Some("mock_provider"), /*git_info*/ None, )?; let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id); append_user_message(rollout_path.as_path(), "2025-01-05T12:01:00Z", "second")?; append_user_message(rollout_path.as_path(), "2025-01-05T12:02:00Z", "third")?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let read_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: conversation_id.clone(), cursor: None, limit: Some(2), sort_direction: Some(SortDirection::Desc), items_view: None, }) .await?; let ThreadTurnsListResponse { backwards_cursor, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; let backwards_cursor = backwards_cursor.expect("expected backwardsCursor for newest turn"); append_thread_rollback( rollout_path.as_path(), "2025-01-05T12:03:00Z", /*num_turns*/ 1, )?; let read_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: conversation_id, cursor: Some(backwards_cursor), limit: Some(10), sort_direction: Some(SortDirection::Asc), items_view: None, }) .await?; let read_err: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(read_id)), ) .await??; assert_eq!( read_err.error.message, "invalid cursor: anchor turn is no longer present" ); Ok(()) } #[tokio::test] async fn thread_read_returns_forked_from_id_for_forked_threads() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let conversation_id = create_fake_rollout_with_text_elements( codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", "Saved user message", vec![], Some("mock_provider"), /*git_info*/ None, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let fork_id = mcp .send_thread_fork_request(ThreadForkParams { thread_id: conversation_id.clone(), ..Default::default() }) .await?; let ThreadForkResponse { thread: forked, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(fork_id)).await??; let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: forked.id, include_turns: false, }) .await?; let ThreadReadResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(thread.forked_from_id, Some(conversation_id)); Ok(()) } #[tokio::test] async fn thread_read_loaded_thread_returns_precomputed_path_before_materialization() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized() .await?; let start_id = mcp .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) .await?; let ThreadStartResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??; let thread_path = thread.path.clone().expect("thread path"); assert!( !thread_path.exists(), "fresh thread rollout should not be materialized yet" ); let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread.id.clone(), include_turns: false, }) .await?; let ThreadReadResponse { thread: read, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(read.id, thread.id); assert_eq!(read.path, Some(thread_path)); assert!(read.preview.is_empty()); assert_eq!(read.turns.len(), 0); assert_eq!(read.status, ThreadStatus::Idle); Ok(()) } #[tokio::test] async fn paginated_thread_name_set_is_reflected_in_read_list_and_metadata_resume() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let conversation_id = create_fake_paginated_rollout( codex_home.path(), "2025-01-05T12-00-00", "2025-01-05T12:00:00Z", "Saved user message", Some("mock_provider"), /*git_info*/ None, )?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; // Set a user-facing thread title. let new_name = "Custom saved name"; let set_id = mcp .send_thread_set_name_request(ThreadSetNameParams { thread_id: conversation_id.clone(), name: new_name.to_string(), }) .await?; let _: ThreadSetNameResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(set_id)).await??; let notification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("thread/name/updated"), ) .await??; let notification: ThreadNameUpdatedNotification = serde_json::from_value(notification.params.expect("thread/name/updated params"))?; assert_eq!(notification.thread_id, conversation_id); assert_eq!(notification.thread_name.as_deref(), Some(new_name)); // Read should now surface `thread.name`, and the wire payload must include `name`. let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: conversation_id.clone(), include_turns: false, }) .await?; let read_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; let read_result = read_resp.result.clone(); let ThreadReadResponse { thread, .. } = to_response::(read_resp)?; assert_eq!(thread.id, conversation_id); assert_eq!(thread.name.as_deref(), Some(new_name)); assert_eq!(thread.history_mode, ThreadHistoryMode::Paginated); let thread_json = read_result .get("thread") .and_then(Value::as_object) .expect("thread/read result.thread must be an object"); assert_eq!( thread_json.get("name").and_then(Value::as_str), Some(new_name), "thread/read must serialize `thread.name` on the wire" ); assert_eq!( thread_json.get("ephemeral").and_then(Value::as_bool), Some(false), "thread/read must serialize `thread.ephemeral` on the wire" ); // List should also surface the name. let list_id = mcp .send_thread_list_request(ThreadListParams { include_attached_threads: false, current_account_only: false, excluded_thread_ids: None, attached_to_thread_id: None, originators: None, cursor: None, limit: Some(50), sort_key: None, sort_direction: None, model_providers: Some(vec!["mock_provider".to_string()]), source_kinds: None, archived: None, section_id: None, project_id: None, cwd: None, use_state_db_only: true, search_term: None, parent_thread_id: None, ancestor_thread_id: None, }) .await?; let list_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(list_id)), ) .await??; let list_result = list_resp.result.clone(); let ThreadListResponse { data, .. } = to_response::(list_resp)?; let listed = data .iter() .find(|t| t.id == conversation_id) .expect("thread/list should include the created thread"); assert_eq!(listed.name.as_deref(), Some(new_name)); let listed_json = list_result .get("data") .and_then(Value::as_array) .expect("thread/list result.data must be an array") .iter() .find(|t| t.get("id").and_then(Value::as_str) == Some(&conversation_id)) .and_then(Value::as_object) .expect("thread/list should include the created thread as an object"); assert_eq!( listed_json.get("name").and_then(Value::as_str), Some(new_name), "thread/list must serialize `thread.name` on the wire" ); assert_eq!( listed_json.get("ephemeral").and_then(Value::as_bool), Some(false), "thread/list must serialize `thread.ephemeral` on the wire" ); // Resume should also surface the name. let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: conversation_id.clone(), exclude_turns: true, ..Default::default() }) .await?; let resume_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(resume_id)), ) .await??; let resume_result = resume_resp.result.clone(); let ThreadResumeResponse { thread: resumed, .. } = to_response::(resume_resp)?; assert_eq!(resumed.id, conversation_id); assert_eq!(resumed.name.as_deref(), Some(new_name)); let resumed_json = resume_result .get("thread") .and_then(Value::as_object) .expect("thread/resume result.thread must be an object"); assert_eq!( resumed_json.get("name").and_then(Value::as_str), Some(new_name), "thread/resume must serialize `thread.name` on the wire" ); assert_eq!( resumed_json.get("ephemeral").and_then(Value::as_bool), Some(false), "thread/resume must serialize `thread.ephemeral` on the wire" ); Ok(()) } #[tokio::test] async fn thread_read_include_turns_rejects_unmaterialized_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized() .await?; let start_id = mcp .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), history_mode: Some(ThreadHistoryMode::Legacy), ..Default::default() }) .await?; let ThreadStartResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??; let thread_path = thread.path.clone().expect("thread path"); assert!( !thread_path.exists(), "fresh thread rollout should not be materialized yet" ); let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread.id.clone(), include_turns: true, }) .await?; let read_err: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(read_id)), ) .await??; assert!( read_err .error .message .contains("includeTurns is unavailable before first user message"), "unexpected error: {}", read_err.error.message ); Ok(()) } #[tokio::test] async fn thread_turns_list_rejects_unmaterialized_loaded_thread() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized() .await?; let start_id = mcp .send_thread_start_request_with_auto_env(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) .await?; let ThreadStartResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(start_id)).await??; let thread_path = thread.path.clone().expect("thread path"); assert!( !thread_path.exists(), "fresh thread rollout should not be materialized yet" ); let read_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: thread.id, cursor: None, limit: None, sort_direction: None, items_view: None, }) .await?; let read_err: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(read_id)), ) .await??; assert!( read_err .error .message .contains("thread/turns/list is unavailable before first user message"), "unexpected error: {}", read_err.error.message ); Ok(()) } #[tokio::test] async fn thread_items_list_defers_requested_outputs() -> Result<()> { use codex_protocol::items::CommandExecutionStatus::Completed; use codex_protocol::items::CommandExecutionStatus::Declined; use codex_protocol::items::CommandExecutionStatus::Failed; use codex_protocol::items::CommandExecutionStatus::InProgress; async fn list(server: &mut TestAppServer, mut params: Value) -> Result { // Compare all fixture items on one page. params["limit"] = json!(100); let id = server .send_request("thread/items/list", Some(params)) .await?; timeout(DEFAULT_READ_TIMEOUT, server.read_response(id)).await? } let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let thread_id = codex_protocol::ThreadId::default(); let store = create_paginated_thread(codex_home.path(), thread_id).await?; let large = "é".repeat(2048); let small = "x".to_owned(); let empty = String::new(); let commands = [ ("large", Completed, Some(0), Some(&large)), ("small", Completed, Some(0), Some(&small)), ("empty", Completed, Some(0), Some(&empty)), ("missing-output", Completed, Some(0), None), ("missing-exit", Completed, None, Some(&large)), ("nonzero", Completed, Some(1), Some(&large)), ("failed", Failed, Some(0), Some(&large)), ("declined", Declined, Some(0), Some(&large)), ("running", InProgress, Some(0), Some(&large)), ]; let mut items = vec![paginated_turn_started("turn")]; for (id, status, exit_code, output) in commands { items.push(paginated_completed_item( thread_id, "turn", CoreTurnItem::CommandExecution(CommandExecutionItem { id: id.to_string(), command: vec!["ls".to_string()], cwd: codex_home.path().abs().into(), parsed_cmd: Vec::new(), source: ExecCommandSource::Agent, status, aggregated_output: output.cloned(), exit_code, sandbox_type: None, model_context: None, plugin_id: None, script_path: None, process_id: None, interaction_input: None, duration: None, }), )); } let mcp_items = mcp_output_deferral_items(thread_id)?; let expected_count = commands.len() + mcp_items.len(); items.extend(mcp_items); store .append_items(AppendThreadItemsParams { thread_id, items }) .await?; store.shutdown_thread(thread_id).await?; let mut stable = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build() .await?; stable .initialize_with_capabilities( ClientInfo { name: "stable-history-client".to_string(), title: None, version: "1".to_string(), }, /*capabilities*/ None, ) .await?; let mut params = json!({"threadId": thread_id, "turnId": "turn"}); let full = list(&mut stable, params.clone()).await?; let entries = full["data"].as_array().expect("list entries"); assert_eq!(entries.len(), expected_count); let mcp_index = commands.len(); assert_eq!(entries[mcp_index]["item"]["id"], "large-mcp"); let saved = &entries[mcp_index]["item"]["result"]; let preview = saved["content"][0]["text"].as_str().expect("saved preview"); assert!(preview.contains("truncated")); assert!(preview.contains("end of plain output")); assert!(preview.len() < 64 * 1024); let connector = entries .iter() .find(|entry| entry["item"]["id"] == "connector-mcp") .expect("hosted connector"); assert_eq!( connector["item"]["appContext"]["connectorId"], "fixture-connector" ); let empty = entries .iter() .find(|entry| entry["item"]["id"] == "empty-result") .expect("empty MCP result"); assert_eq!(empty["item"]["result"]["content"], json!([])); assert!(empty.get("outputDeferred").is_none()); for (entry, (id, _, _, output)) in entries.iter().zip(commands) { assert_eq!(entry["item"]["id"], id); assert_eq!( entry["item"]["aggregatedOutput"], json!(output.filter(|text| !text.is_empty())) ); assert!(entry.get("outputDeferred").is_none()); } for options in [ Value::Null, json!({}), json!({"commandExecution": false, "mcpToolCall": false}), json!({"unknownItemKind": true}), ] { params["deferOutput"] = options; assert_eq!(list(&mut stable, params.clone()).await?, full); } for option in ["commandExecution", "mcpToolCall"] { params["deferOutput"] = json!({(option): true}); let id = stable .send_request("thread/items/list", Some(params.clone())) .await?; let error = timeout( DEFAULT_READ_TIMEOUT, stable.read_stream_until_error_message(RequestId::Integer(id)), ) .await??; assert_eq!(error.error.code, -32600); } let mut experimental = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; assert_eq!( list( &mut experimental, json!({"threadId": thread_id, "turnId": "turn", "deferLargeCommandOutput": true}), ) .await?, full ); for (commands, mcp) in [(true, false), (false, true), (true, true)] { params["deferOutput"] = json!({"commandExecution": commands, "mcpToolCall": mcp, "unknownItemKind": true}); let mut expected = full.clone(); let mut originals = Vec::new(); if commands { for (i, original) in entries.iter().take(2).enumerate() { originals.push(original); expected["data"][i]["outputDeferred"] = json!(true); expected["data"][i]["item"]["aggregatedOutput"] = Value::Null; } } if mcp { for (entry, original) in expected["data"] .as_array_mut() .expect("list entries") .iter_mut() .zip(entries) { if matches!( entry["item"]["id"].as_str(), Some("large-mcp" | "connector-mcp" | "small-mcp") ) { entry["outputDeferred"] = json!(true); entry["item"]["result"]["content"] = json!([]); originals.push(original); } } } let response = list(&mut experimental, params.clone()).await?; assert_eq!(response, expected); let page: ThreadItemsListResponse = serde_json::from_value(response)?; let deferred: Vec<_> = page .data .into_iter() .filter_map(|entry| ThreadItem::try_from(entry).err()) .collect(); let exact: Value = experimental .request(|request_id| ClientRequest::ThreadItemsRead { request_id, params: ThreadItemsReadParams { thread_id: thread_id.to_string(), turn_id: deferred .first() .expect("each opt-in defers an item") .turn_id .clone(), item_ids: deferred.into_iter().map(|item| item.item_id).collect(), }, }) .await?; assert_eq!(exact, json!({"data": originals})); } let across_turns = list( &mut experimental, json!({"threadId": thread_id, "deferOutput": {"commandExecution": true, "mcpToolCall": true}}), ) .await?; assert_eq!(across_turns["data"], full["data"]); Ok(()) } fn mcp_output_deferral_items(thread_id: codex_protocol::ThreadId) -> Result> { let text = |text: &str| json!({"type": "text", "text": text}); let content = |blocks: Vec| json!({"result": {"content": blocks}}); let oversized = "é".repeat(40 * 1024); let large = "x".repeat(4096); let plain = || text(&large); let notion = json!({"results": [{"object": "page", "url": "https://www.notion.so/page", "title": large}]}).to_string(); let cases = [ ( "large-mcp", content(vec![text(&oversized), text("end of plain output")]), ), ( "connector-mcp", json!({"server": "codex_apps", "connectorId": "fixture-connector", "linkId": "link-1"}), ), ( "builtin-terminal", json!({"server": "codex_app", "tool": "read_thread_terminal"}), ), ("small-mcp", content(vec![text("x"), text("")])), ("empty-text", content(vec![text("")])), ("empty-result", content(vec![])), ( "failed-mcp", json!({"status": "failed", "error": {"message": "tool failed"}}), ), ("running-mcp", json!({"status": "inProgress"})), ("missing-result", json!({"result": null})), ( "annotated", content(vec![ plain(), json!({"type": "text", "text": "caption", "annotations": {"audience": ["user"]}}), ]), ), ( "image-result", content(vec![ plain(), json!({"type": "image", "mimeType": "image/png", "data": "YQ=="}), ]), ), ( "structured-result", json!({"result": {"content": [plain()], "structuredContent": {"title": "Source"}}}), ), ( "metadata-result", json!({"result": {"content": [plain()], "_meta": {"uri": "ui://result"}}}), ), ( "app-resource", json!({"server": "codex_apps", "connectorId": "fixture-connector", "mcpAppResourceUri": "ui://panel"}), ), ("legacy-app-ui", json!({"mcpAppResourceUri": "ui://panel"})), ( "app-ui", json!({"mcpAppUi": {"resourceUri": "ui://panel", "preferredModelDisplayMode": "inline"}}), ), ( "json-result", content(vec![text(&format!("\u{feff} {notion}"))]), ), ( "mixed-json", content(vec![text(&oversized), text(" \n []")]), ), ( "fenced-result", content(vec![plain(), text(" \n ```json\n{}\n```")]), ), ( "tilde-fence", content(vec![plain(), text("~~~json\n{}\n~~~")]), ), ]; let mut items = Vec::new(); for (id, overrides) in cases { let mut call = json!({ "id": id, "server": "fixture", "tool": "lookup", "status": "completed", "arguments": {"query": "saved"}, "pluginId": "fixture@local", "readOnlyHint": true, "result": {"content": [plain()]}, }); for (field, value) in overrides.as_object().expect("fixture overrides") { call[field] = value.clone(); } items.push(paginated_completed_item( thread_id, "turn", CoreTurnItem::McpToolCall(serde_json::from_value(call)?), )); } Ok(items) } #[tokio::test] async fn paginated_history_lists_and_legacy_reads_use_projected_turns_and_items() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let thread_id = codex_protocol::ThreadId::default(); let store = create_paginated_thread(codex_home.path(), thread_id).await?; store .append_items(AppendThreadItemsParams { thread_id, items: vec![ paginated_turn_started("turn-1"), paginated_completed_item( thread_id, "turn-1", CoreTurnItem::UserMessage(UserMessageItem { id: "user-1".to_string(), client_id: None, content: Vec::new(), }), ), paginated_completed_item( thread_id, "turn-1", CoreTurnItem::UserMessage(UserMessageItem { id: "steer-1".to_string(), client_id: None, content: Vec::new(), }), ), paginated_completed_item( thread_id, "turn-1", CoreTurnItem::AgentMessage(AgentMessageItem { moderation: None, id: "agent-1".to_string(), content: vec![AgentMessageContent::Text { text: "first".to_string(), }], phase: None, memory_citation: None, delivery: None, questions: None, }), ), paginated_completed_item( thread_id, "turn-1", CoreTurnItem::UserMessage(UserMessageItem { id: "steer-1".to_string(), client_id: Some("updated-steer".to_string()), content: Vec::new(), }), ), paginated_turn_completed("turn-1"), paginated_turn_started("turn-2"), paginated_completed_item( thread_id, "turn-2", CoreTurnItem::UserMessage(UserMessageItem { id: "user-2".to_string(), client_id: None, content: Vec::new(), }), ), ], }) .await?; store.shutdown_thread(thread_id).await?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let expected_turn_1_full = Turn { id: "turn-1".to_string(), root_turn_id: None, items: vec![ ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: Vec::new(), }, ThreadItemPayload::UserMessage { id: "steer-1".to_string(), client_id: Some("updated-steer".to_string()), content: Vec::new(), }, ThreadItemPayload::AgentMessage { moderation: None, id: "agent-1".to_string(), text: "first".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, }, ] .into_iter() .map(|payload| codex_app_server_protocol::ThreadItem { payload, started_at_ms: Some(0), completed_at_ms: Some(1), }) .collect(), items_view: TurnItemsView::Full, status: TurnStatus::Completed, error: None, started_at: Some(10), completed_at: Some(20), duration_ms: Some(10_000), }; let expected_turn_2_full = Turn { id: "turn-2".to_string(), root_turn_id: None, items: vec![ThreadItemPayload::UserMessage { id: "user-2".to_string(), client_id: None, content: Vec::new(), }] .into_iter() .map(|payload| codex_app_server_protocol::ThreadItem { payload, started_at_ms: Some(0), completed_at_ms: Some(1), }) .collect(), items_view: TurnItemsView::Full, status: TurnStatus::Interrupted, error: None, started_at: Some(10), completed_at: None, duration_ms: None, }; let expected_full_turns = vec![expected_turn_1_full.clone(), expected_turn_2_full.clone()]; let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread_id.to_string(), include_turns: true, }) .await?; let ThreadReadResponse { thread: unloaded_thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(unloaded_thread.turns, expected_full_turns); let legacy_resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread_id.to_string(), ..Default::default() }) .await?; let ThreadResumeResponse { thread: legacy_thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(legacy_resume_id)).await??; assert_eq!(legacy_thread.turns, expected_full_turns); let initial_page_resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread_id.to_string(), exclude_turns: true, initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { limit: Some(1), sort_direction: Some(SortDirection::Desc), items_view: Some(TurnItemsView::Full), }), ..Default::default() }) .await?; let ThreadResumeResponse { thread: initial_page_thread, initial_turns_page, .. } = timeout( DEFAULT_READ_TIMEOUT, mcp.read_response(initial_page_resume_id), ) .await??; assert!(initial_page_thread.turns.is_empty()); assert_eq!( initial_turns_page.expect("initial turns page").data, vec![expected_turn_2_full] ); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread_id.to_string(), exclude_turns: true, ..Default::default() }) .await?; let ThreadResumeResponse { thread, turns_backwards_cursor, items_backwards_cursor, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; assert!(thread.turns.is_empty()); let turns_backwards_cursor = turns_backwards_cursor.expect("resume should return a turn head cursor"); let items_backwards_cursor = items_backwards_cursor.expect("resume should return an item head cursor"); let rejoin_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread_id.to_string(), exclude_turns: true, ..Default::default() }) .await?; let ThreadResumeResponse { turns_backwards_cursor: rejoin_turns_backwards_cursor, items_backwards_cursor: rejoin_items_backwards_cursor, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(rejoin_id)).await??; assert_eq!( rejoin_turns_backwards_cursor.as_deref(), Some(turns_backwards_cursor.as_str()) ); assert_eq!( rejoin_items_backwards_cursor.as_deref(), Some(items_backwards_cursor.as_str()) ); let ThreadTurnsListResponse { data, .. } = read_turns_page( &mut mcp, thread_id, Some(turns_backwards_cursor), Some(2), SortDirection::Desc, Some(TurnItemsView::NotLoaded), ) .await?; assert_eq!( data.into_iter().map(|turn| turn.id).collect::>(), vec!["turn-2", "turn-1"] ); let ThreadItemsListResponse { data, .. } = read_items_page( &mut mcp, thread_id, /*turn_id*/ None, Some(items_backwards_cursor.clone()), Some(3), SortDirection::Desc, ) .await?; assert_eq!( data.into_iter() .map(|entry| entry.item.id().to_string()) .collect::>(), vec!["user-2", "agent-1", "steer-1"] ); let ThreadItemsListResponse { data, .. } = read_items_page( &mut mcp, thread_id, Some("turn-1"), Some(items_backwards_cursor), Some(2), SortDirection::Desc, ) .await?; assert_eq!( data.into_iter() .map(|entry| entry.item.id().to_string()) .collect::>(), vec!["agent-1", "steer-1"] ); let first_page = read_turns_page( &mut mcp, thread_id, /*cursor*/ None, Some(1), SortDirection::Asc, Some(TurnItemsView::Summary), ) .await?; assert_eq!( first_page.data, vec![Turn { id: "turn-1".to_string(), root_turn_id: None, items: vec![ ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: Vec::new(), }, ThreadItemPayload::AgentMessage { moderation: None, id: "agent-1".to_string(), text: "first".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, }, ] .into_iter() .map(|payload| codex_app_server_protocol::ThreadItem { payload, started_at_ms: Some(0), completed_at_ms: Some(1) }) .collect(), items_view: TurnItemsView::Summary, status: TurnStatus::Completed, error: None, started_at: Some(10), completed_at: Some(20), duration_ms: Some(10_000), }] ); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread_id.to_string(), exclude_turns: true, initial_turns_page: Some(ThreadResumeInitialTurnsPageParams { limit: Some(1), sort_direction: Some(SortDirection::Asc), items_view: Some(TurnItemsView::Summary), }), ..Default::default() }) .await?; let resumed: ThreadResumeResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; assert_eq!( resumed.initial_turns_page.expect("summary page").data, first_page.data ); let next_cursor = first_page.next_cursor.expect("next turn cursor"); let second_page = read_turns_page( &mut mcp, thread_id, Some(next_cursor), Some(1), SortDirection::Asc, Some(TurnItemsView::NotLoaded), ) .await?; assert_eq!( second_page.data, vec![Turn { id: "turn-2".to_string(), root_turn_id: None, items: Vec::new(), items_view: TurnItemsView::NotLoaded, status: TurnStatus::Interrupted, error: None, started_at: Some(10), completed_at: None, duration_ms: None, }] ); let full_page = read_turns_page( &mut mcp, thread_id, /*cursor*/ None, Some(1), SortDirection::Asc, Some(TurnItemsView::Full), ) .await?; assert_eq!(full_page.data, vec![expected_turn_1_full]); let first_items_page = read_items_page( &mut mcp, thread_id, /*turn_id*/ None, /*cursor*/ None, Some(1), SortDirection::Asc, ) .await?; assert_eq!( first_items_page.data, vec![ThreadItemEntry { turn_id: "turn-1".to_string(), output_deferred: false, item: codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::UserMessage { id: "user-1".to_string(), client_id: None, content: Vec::new(), }, started_at_ms: Some(0), completed_at_ms: Some(1) }, started_at_ms: Some(0), completed_at_ms: Some(1), }] ); let second_items_page = read_items_page( &mut mcp, thread_id, /*turn_id*/ None, Some(first_items_page.next_cursor.expect("next item cursor")), Some(1), SortDirection::Asc, ) .await?; assert_eq!(second_items_page.data.len(), 1); assert_eq!(second_items_page.data[0].turn_id, "turn-1"); assert_eq!(second_items_page.data[0].item.id(), "steer-1"); let third_items_page = read_items_page( &mut mcp, thread_id, /*turn_id*/ None, Some(second_items_page.next_cursor.expect("next item cursor")), Some(2), SortDirection::Asc, ) .await?; assert_eq!(third_items_page.data.len(), 2); assert_eq!(third_items_page.data[0].turn_id, "turn-1"); assert_eq!(third_items_page.data[0].item.id(), "agent-1"); assert_eq!(third_items_page.data[1].turn_id, "turn-2"); assert_eq!(third_items_page.data[1].item.id(), "user-2"); let anchor_params = ThreadItemsListParams { thread_id: thread_id.to_string(), turn_id: Some("turn-1".to_string()), cursor: None, limit: Some(1), sort_direction: None, defer_output: None, }; for direction in [None, Some(SortDirection::Desc)] { let request = mcp .send_thread_items_list_request(ThreadItemsListParams { limit: Some(10), sort_direction: direction, ..anchor_params.clone() }) .await?; let ordinary: ThreadItemsListResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request)).await??; assert_eq!(ordinary.data.len(), 3); let request = mcp .send_thread_items_list_request(ThreadItemsListParams { cursor: Some(ThreadItemsListCursor::Anchor(ThreadItemsListAnchor::Item { item_id: ordinary.data[0].item.id().to_string(), })), sort_direction: direction, ..anchor_params.clone() }) .await?; let anchored: ThreadItemsListResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request)).await??; assert_eq!(anchored.data, ordinary.data[1..2]); let request = mcp .send_thread_items_list_request(ThreadItemsListParams { cursor: Some(ThreadItemsListCursor::Opaque( anchored .next_cursor .expect("anchored page has a continuation"), )), sort_direction: direction, ..anchor_params.clone() }) .await?; let continued: ThreadItemsListResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(request)).await??; assert_eq!(continued.data, ordinary.data[2..]); assert_eq!(continued.next_cursor, None); } for (turn_id, anchor, message) in [ ( None, "steer-1", "turnId is required when cursor is an item anchor", ), ( Some("turn-1"), "missing", "cursor.itemId does not identify an item in the requested history scope", ), ] { let request = mcp .send_thread_items_list_request(ThreadItemsListParams { turn_id: turn_id.map(str::to_string), cursor: Some(ThreadItemsListCursor::Anchor(ThreadItemsListAnchor::Item { item_id: anchor.to_string(), })), ..anchor_params.clone() }) .await?; let error = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(request)), ) .await??; assert_eq!( (error.error.code, error.error.message.as_str()), (-32602, message) ); } let read_id = mcp .send_thread_items_read_request(ThreadItemsReadParams { thread_id: thread_id.to_string(), item_ids: vec![ "agent-1".to_string(), "steer-1".to_string(), "user-2".to_string(), "missing".to_string(), ], turn_id: "turn-1".to_string(), }) .await?; let read: ThreadItemsReadResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!( read.data, vec![ ThreadItemEntry { turn_id: "turn-1".to_string(), output_deferred: false, item: codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::UserMessage { id: "steer-1".to_string(), client_id: Some("updated-steer".to_string()), content: Vec::new(), }, started_at_ms: Some(0), completed_at_ms: Some(1) }, started_at_ms: Some(0), completed_at_ms: Some(1), }, ThreadItemEntry { turn_id: "turn-1".to_string(), output_deferred: false, item: codex_app_server_protocol::ThreadItem { payload: ThreadItemPayload::AgentMessage { moderation: None, id: "agent-1".to_string(), text: "first".to_string(), phase: None, memory_citation: None, delivery: None, questions: None, }, started_at_ms: Some(0), completed_at_ms: Some(1) }, started_at_ms: Some(0), completed_at_ms: Some(1), }, ] ); let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread_id.to_string(), client_user_message_id: None, input: vec![UserInput::Text { text: "continue after legacy resume".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; let _: TurnStartResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_start_id)).await??; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread_id.to_string(), include_turns: true, }) .await?; let ThreadReadResponse { thread: loaded_thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(&loaded_thread.turns[..2], expected_full_turns); assert_eq!( turn_user_texts(&loaded_thread.turns), vec!["continue after legacy resume"] ); Ok(()) } #[tokio::test] async fn thread_items_read_validates_ids_before_history_access() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .without_auto_env() .build_initialized() .await?; let read_id = mcp .send_thread_items_list_request(ThreadItemsListParams { thread_id: "00000000-0000-4000-8000-000000000123".to_string(), turn_id: None, cursor: None, limit: None, sort_direction: None, defer_output: None, }) .await?; let read_err: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(read_id)), ) .await??; assert_eq!(read_err.error.code, -32601); assert_eq!( read_err.error.message, "thread/items/list is not supported yet" ); let read = ThreadItemsReadParams { thread_id: "00000000-0000-4000-8000-000000000123".to_string(), item_ids: vec!["item".to_string()], turn_id: "turn".to_string(), }; for item_ids in [ Vec::new(), (0..101).map(|index| index.to_string()).collect(), ] { let id = mcp .send_thread_items_read_request(ThreadItemsReadParams { item_ids, ..read.clone() }) .await?; let error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(id)), ) .await??; assert_eq!(error.error.code, -32600); assert_eq!(error.error.message, "itemIds must contain 1 to 100 IDs"); } let max_read_id = mcp .send_thread_items_read_request(ThreadItemsReadParams { item_ids: (0..100).map(|index| index.to_string()).collect(), ..read }) .await?; let max_read_error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(max_read_id)), ) .await??; assert_eq!(max_read_error.error.code, -32601); Ok(()) } #[test_case::test_case("model_not_found", CodexErrorInfo::ModelNotFound, "Access to this deployment ended."; "model_not_found")] #[test_case::test_case("inference_hook_denied", CodexErrorInfo::InferenceHookDenied, "Blocked by the workspace hook."; "inference_hook_denied")] #[tokio::test] async fn thread_read_preserves_provider_error_after_failed_turn( code: &str, expected: CodexErrorInfo, message: &str, ) -> Result<()> { let server = responses::start_mock_server().await; let response_mock = responses::mount_sse_once(&server, responses::sse_failed("resp-1", code, message)).await; let codex_home = TempDir::new()?; MockResponsesConfig::new(&server.uri()).write(codex_home.path())?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build_initialized() .await?; let ThreadStartResponse { thread, .. } = mcp .start_thread(ThreadStartParams { model: Some("mock-model".to_string()), ..Default::default() }) .await?; let turn_start_id = mcp .send_turn_start_request(TurnStartParams { thread_id: thread.id.clone(), client_user_message_id: None, input: vec![UserInput::Text { text: "fail this turn".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; let _: TurnStartResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(turn_start_id)).await??; let live: ErrorNotification = timeout(DEFAULT_READ_TIMEOUT, mcp.read_notification("error")).await??; assert!(!live.will_retry); assert_eq!(live.error.codex_error_info, Some(expected)); assert_eq!(live.error.message, message); timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), ) .await??; let read_id = mcp .send_thread_read_request(ThreadReadParams { thread_id: thread.id, include_turns: true, }) .await?; let ThreadReadResponse { thread, .. } = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(read_id)).await??; assert_eq!(thread.status, ThreadStatus::SystemError); assert_eq!( thread.turns.last().and_then(|turn| turn.error.as_ref()), Some(&live.error) ); assert_eq!(response_mock.requests().len(), 1); Ok(()) } fn append_user_message(path: &Path, timestamp: &str, text: &str) -> std::io::Result<()> { let mut file = std::fs::OpenOptions::new().append(true).open(path)?; writeln!( file, "{}", json!({ "timestamp": timestamp, "type":"event_msg", "payload": { "type":"user_message", "message": text, "text_elements": [], "local_images": [] } }) ) } fn append_agent_message(path: &Path, timestamp: &str, text: &str) -> anyhow::Result<()> { let mut file = std::fs::OpenOptions::new().append(true).open(path)?; writeln!( file, "{}", json!({ "timestamp": timestamp, "type": "event_msg", "payload": serde_json::to_value(EventMsg::AgentMessage(AgentMessageEvent { moderation: None, message: text.to_string(), phase: None, memory_citation: None, delivery: None, questions: None, }))?, }) )?; Ok(()) } fn append_thread_rollback(path: &Path, timestamp: &str, num_turns: u32) -> std::io::Result<()> { let mut file = std::fs::OpenOptions::new().append(true).open(path)?; writeln!( file, "{}", json!({ "timestamp": timestamp, "type":"event_msg", "payload": { "type":"thread_rolled_back", "num_turns": num_turns } }) ) } async fn read_single_turn_items_view( mcp: &mut TestAppServer, thread_id: &str, items_view: Option, ) -> anyhow::Result { let read_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: thread_id.to_string(), cursor: None, limit: Some(10), sort_direction: Some(SortDirection::Asc), items_view, }) .await?; let read_resp: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(read_id)), ) .await??; let ThreadTurnsListResponse { mut data, .. } = to_response::(read_resp)?; assert_eq!(data.len(), 1); Ok(data.remove(0)) } async fn read_turns_page( mcp: &mut TestAppServer, thread_id: codex_protocol::ThreadId, cursor: Option, limit: Option, sort_direction: SortDirection, items_view: Option, ) -> Result { let request_id = mcp .send_thread_turns_list_request(ThreadTurnsListParams { thread_id: thread_id.to_string(), cursor, limit, sort_direction: Some(sort_direction), items_view, }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; to_response(response) } async fn read_items_page( mcp: &mut TestAppServer, thread_id: codex_protocol::ThreadId, turn_id: Option<&str>, cursor: Option, limit: Option, sort_direction: SortDirection, ) -> Result { let request_id = mcp .send_thread_items_list_request(ThreadItemsListParams { thread_id: thread_id.to_string(), turn_id: turn_id.map(str::to_string), cursor: cursor.map(ThreadItemsListCursor::Opaque), limit, sort_direction: Some(sort_direction), defer_output: None, }) .await?; let response: JSONRPCResponse = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; to_response(response) } async fn create_paginated_thread( codex_home: &Path, thread_id: codex_protocol::ThreadId, ) -> Result { let sqlite = codex_state::SqliteConfig::new_for_testing(codex_home.abs()); let state_db = codex_state::StateRuntime::init(sqlite.clone(), "mock_provider".to_string()).await?; let store = LocalThreadStore::new( LocalThreadStoreConfig { codex_home: codex_home.to_path_buf(), sqlite, default_model_provider_id: "mock_provider".to_string(), }, Some(state_db), ); store .create_thread(CreateThreadParams { attached_thread: false, creator_user_id: None, creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, parent_thread_id: None, source: ProtocolSessionSource::Cli, thread_source: None, originator: "test_originator".to_string(), base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), multi_agent_version: None, history_mode: codex_protocol::protocol::ThreadHistoryMode::Paginated, history_base: None, subagent_history_start_ordinal: None, initial_window_id: ContextWindowId::new_uuid_v7(), runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: Some(codex_home.to_path_buf()), model_provider: "mock_provider".to_string(), memory_mode: ThreadMemoryMode::Enabled, }, }) .await?; store .persist_thread(thread_id, PersistContext::Standard) .await?; Ok(store) } fn paginated_turn_started(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent { turn_attribution: None, turn_id: turn_id.to_string(), root_turn_id: None, trace_id: None, started_at: Some(10), model_context_window: None, collaboration_mode_kind: Default::default(), })) } fn paginated_turn_completed(turn_id: &str) -> RolloutItem { RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent { root_turn_id: None, turn_id: turn_id.to_string(), last_agent_message: None, error: None, started_at: Some(10), completed_at: Some(20), duration_ms: Some(10_000), time_to_first_token_ms: None, })) } fn paginated_completed_item( thread_id: codex_protocol::ThreadId, turn_id: &str, item: CoreTurnItem, ) -> RolloutItem { RolloutItem::EventMsg(EventMsg::ItemCompleted(ItemCompletedEvent { content_token_count: None, thread_id, turn_id: turn_id.to_string(), item, started_at_ms: Some(0), completed_at_ms: 1, })) } fn turn_user_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { turns .iter() .filter_map(|turn| match &turn.items.first()?.payload { ThreadItemPayload::UserMessage { content, .. } => match content.first()? { UserInput::Text { text, .. } => Some(text.as_str()), UserInput::Image { .. } | UserInput::LocalImage { .. } | UserInput::Audio { .. } | UserInput::LocalAudio { .. } | UserInput::Skill { .. } | UserInput::Mention { .. } => None, }, _ => None, }) .collect() } fn turn_agent_texts(turns: &[codex_app_server_protocol::Turn]) -> Vec<&str> { turns .iter() .flat_map(Turn::item_payloads) .filter_map(|item| match item { ThreadItemPayload::AgentMessage { text, .. } => Some(text.as_str()), _ => None, }) .collect() } struct InMemoryThreadStoreId { store_id: String, } impl Drop for InMemoryThreadStoreId { fn drop(&mut self) { InMemoryThreadStore::remove_id(&self.store_id); } } async fn seed_pathless_store_thread( store: &InMemoryThreadStore, thread_id: codex_protocol::ThreadId, ) -> Result<()> { store .create_thread(CreateThreadParams { attached_thread: false, creator_user_id: None, creator_account_id: None, session_id: thread_id.into(), thread_id, extra_config: None, forked_from_id: None, parent_thread_id: None, source: ProtocolSessionSource::Cli, thread_source: None, originator: "test_originator".to_string(), base_instructions: BaseInstructions::default(), dynamic_tools: Vec::new(), selected_capability_roots: Vec::new(), multi_agent_version: None, history_mode: Default::default(), history_base: None, subagent_history_start_ordinal: None, initial_window_id: ContextWindowId::new_uuid_v7(), runtime_workspace_roots: None, metadata: ThreadPersistenceMetadata { cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, }, }) .await?; store .append_items(AppendThreadItemsParams { thread_id, items: store_history_items(), }) .await?; store .update_thread_metadata(UpdateThreadMetadataParams { thread_id, patch: ThreadMetadataPatch { name: Some(Some("named pathless thread".to_string())), ..Default::default() }, include_archived: true, }) .await?; Ok(()) } fn store_history_items() -> Vec { vec![RolloutItem::EventMsg(EventMsg::UserMessage( UserMessageEvent { client_id: None, message: "history from store".to_string(), images: None, local_images: Vec::new(), audio: Some(vec!["https://example.com/recording.mp3".to_string()]), local_audio: vec!["recording.wav".into()], text_elements: Vec::new(), ..Default::default() }, ))] }