use anyhow::Result; use app_test_support::MockResponsesConfig; use app_test_support::TestAppServer; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::create_mock_responses_server_sequence; use app_test_support::create_request_user_input_sse_response; use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::ClientInfo; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::InitializeCapabilities; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SortDirection; use codex_app_server_protocol::ThreadHistoryMode; use codex_app_server_protocol::ThreadItemsListParams; use codex_app_server_protocol::ThreadItemsListResponse; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; use codex_app_server_protocol::ThreadRevertParams; use codex_app_server_protocol::ThreadRevertResponse; use codex_app_server_protocol::ThreadRevertedNotification; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadTurnsListParams; use codex_app_server_protocol::ThreadTurnsListResponse; use codex_app_server_protocol::TurnCompletedNotification; 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_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ModeKind; use codex_protocol::config_types::Settings; use codex_protocol::openai_models::ReasoningEffort; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); #[tokio::test] async fn thread_revert_replaces_paginated_history_before_turn() -> 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() .await?; initialize_experimental(&mut mcp).await?; let ThreadStartResponse { thread, .. } = mcp .start_thread(ThreadStartParams { history_mode: Some(ThreadHistoryMode::Paginated), ..Default::default() }) .await?; let stale_rollout_path = thread.path.clone().expect("thread rollout path"); let mut turn_ids = Vec::new(); for text in ["first", "second"] { let completed = mcp .start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread.id.clone(), input: vec![UserInput::Text { text: text.to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; turn_ids.push(completed.turn.id); } let ThreadRevertResponse { thread: reverted_thread, turns_backwards_cursor, items_backwards_cursor, } = mcp .request(|request_id| ClientRequest::ThreadRevert { request_id, params: ThreadRevertParams { thread_id: thread.id.clone(), before_turn_id: turn_ids[1].clone(), }, }) .await?; let reverted: ThreadRevertedNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_notification("thread/reverted"), ) .await??; assert_eq!(reverted.thread_id, thread.id); assert_eq!(reverted_thread.id, thread.id); assert!(reverted_thread.turns.is_empty()); assert!(items_backwards_cursor.is_some()); assert_eq!( turn_ids_from_cursor( &mut mcp, &thread.id, turns_backwards_cursor, /*sort_direction*/ None, ) .await?, turn_ids[..1] ); let ThreadItemsListResponse { data: reverted_items, .. } = mcp .request(|request_id| ClientRequest::ThreadItemsList { request_id, params: ThreadItemsListParams { thread_id: thread.id.clone(), turn_id: None, cursor: items_backwards_cursor, limit: None, sort_direction: None, }, }) .await?; assert!(!reverted_items.is_empty()); assert!( reverted_items .iter() .all(|item| item.turn_id == turn_ids[0]) ); mcp.shutdown_gracefully().await?; let mut mcp = TestAppServer::builder() .with_codex_home(codex_home.path()) .build() .await?; initialize_experimental(&mut mcp).await?; let stale_resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread.id.clone(), path: Some(stale_rollout_path), ..Default::default() }) .await?; let stale_resume_error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(stale_resume_id)), ) .await??; assert!( stale_resume_error.error.message.contains("stale path") && stale_resume_error .error .message .contains("omit path and resume by thread id"), "unexpected resume error: {}", stale_resume_error.error.message, ); let resume_id = mcp .send_thread_resume_request(ThreadResumeParams { thread_id: thread.id.clone(), ..Default::default() }) .await?; let _: ThreadResumeResponse = timeout(DEFAULT_READ_TIMEOUT, mcp.read_response(resume_id)).await??; let invalid_revert_id = mcp .send_raw_request( "thread/revert", Some(serde_json::to_value(ThreadRevertParams { thread_id: thread.id.clone(), before_turn_id: "missing-turn".to_string(), })?), ) .await?; let invalid_revert_error: JSONRPCError = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_error_message(RequestId::Integer(invalid_revert_id)), ) .await??; assert_eq!( invalid_revert_error.error.message, "turn not found: missing-turn" ); let third_turn = mcp .start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread.id.clone(), input: vec![UserInput::Text { text: "third".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; let requests = server.received_requests().await.expect("response requests"); let model_input = requests .iter() .rev() .find(|request| request.url.path().ends_with("/responses")) .expect("third turn response request") .body_json::()?["input"] .clone(); let model_input = serde_json::to_string(&model_input)?; assert!(model_input.contains("first")); assert!(!model_input.contains("second")); assert!(model_input.contains("third")); assert_eq!( turn_ids_from_cursor( &mut mcp, &thread.id, /*cursor*/ None, Some(SortDirection::Asc), ) .await?, vec![turn_ids[0].clone(), third_turn.turn.id] ); Ok(()) } #[tokio::test] async fn thread_revert_interrupts_active_turn_and_keeps_thread_loaded() -> Result<()> { let home = TempDir::new()?; let server = create_mock_responses_server_sequence(vec![ create_final_assistant_message_sse_response("first")?, create_request_user_input_sse_response("call_blocked")?, create_final_assistant_message_sse_response("third")?, ]) .await; MockResponsesConfig::new(&server.uri()).write(home.path())?; let mut mcp = TestAppServer::builder() .with_codex_home(home.path()) .build() .await?; initialize_experimental(&mut mcp).await?; let ThreadStartResponse { thread, .. } = mcp .start_thread(ThreadStartParams { history_mode: Some(ThreadHistoryMode::Paginated), ..Default::default() }) .await?; let first_turn = mcp .start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread.id.clone(), input: vec![UserInput::Text { text: "first".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; let TurnStartResponse { turn: active_turn } = mcp .request(|request_id| ClientRequest::TurnStart { request_id, params: TurnStartParams { thread_id: thread.id.clone(), input: vec![UserInput::Text { text: "sleep".to_string(), text_elements: Vec::new(), }], collaboration_mode: Some(CollaborationMode { mode: ModeKind::Plan, settings: Settings { model: "mock-model".to_string(), reasoning_effort: Some(ReasoningEffort::Medium), developer_instructions: None, }, }), approval_policy: Some(AskForApproval::Never), ..Default::default() }, }) .await?; timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_request_message(), ) .await??; let ThreadRevertResponse { thread: reverted_thread, turns_backwards_cursor, items_backwards_cursor, } = mcp .request(|request_id| ClientRequest::ThreadRevert { request_id, params: ThreadRevertParams { thread_id: thread.id.clone(), before_turn_id: active_turn.id.clone(), }, }) .await?; let completed: TurnCompletedNotification = timeout( DEFAULT_READ_TIMEOUT, mcp.read_notification("turn/completed"), ) .await??; assert_eq!(completed.thread_id, thread.id); assert_eq!(completed.turn.status, TurnStatus::Interrupted); assert!(reverted_thread.turns.is_empty()); assert!(items_backwards_cursor.is_some()); assert_eq!( turn_ids_from_cursor( &mut mcp, &thread.id, turns_backwards_cursor, /*sort_direction*/ None, ) .await?, vec![first_turn.turn.id] ); let resumed: ThreadResumeResponse = mcp .request(|request_id| ClientRequest::ThreadResume { request_id, params: ThreadResumeParams { thread_id: thread.id.clone(), ..Default::default() }, }) .await?; assert_eq!(resumed.approval_policy, AskForApproval::Never); mcp.start_turn_and_wait_for_completion(TurnStartParams { thread_id: thread.id, input: vec![UserInput::Text { text: "third".to_string(), text_elements: Vec::new(), }], ..Default::default() }) .await?; Ok(()) } async fn turn_ids_from_cursor( mcp: &mut TestAppServer, thread_id: &str, cursor: Option, sort_direction: Option, ) -> Result> { let ThreadTurnsListResponse { data, .. } = mcp .request(|request_id| ClientRequest::ThreadTurnsList { request_id, params: ThreadTurnsListParams { thread_id: thread_id.to_string(), cursor, limit: None, sort_direction, items_view: None, }, }) .await?; Ok(data.into_iter().map(|turn| turn.id).collect()) } async fn initialize_experimental(mcp: &mut TestAppServer) -> Result<()> { let initialized = timeout( DEFAULT_READ_TIMEOUT, mcp.initialize_with_capabilities( ClientInfo { name: "test-client".to_string(), title: None, version: "0.1.0".to_string(), }, Some(InitializeCapabilities { experimental_api: true, request_attestation: false, opt_out_notification_methods: None, mcp_server_openai_form_elicitation: false, extensions: None, }), ), ) .await??; assert!(matches!(initialized, JSONRPCMessage::Response(_))); Ok(()) }