mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
## What changed - Add the experimental `thread/revert` request, which replaces a loaded paginated thread's durable history with the prefix before `beforeTurnId` while preserving the thread ID. - Interrupt any active turn, reload the replacement history without unloading the thread, preserve mutable thread settings, and emit `thread/reverted`. - Return backward pagination cursors for the retained turns and items, reject stale rollout paths after a revert, and document that local file changes are unaffected. ## Testing - Cover history replacement, pagination, stale paths, missing turns, subsequent turns, active-turn interruption, and preserved thread settings. GitOrigin-RevId: 435a8163ad4f310cc47219b436f6bdde21f2b83a
380 lines
13 KiB
Rust
380 lines
13 KiB
Rust
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::<serde_json::Value>()?["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<String>,
|
|
sort_direction: Option<SortDirection>,
|
|
) -> Result<Vec<String>> {
|
|
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(())
|
|
}
|