mirror of
https://github.com/openai/codex.git
synced 2026-11-07 08:34:29 +00:00
Add app-server support for reverting paginated threads (#38440)
## 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
This commit is contained in:
1 parent
1992f8c018
commit
4343b2bdc4
24 files changed
+939
-21
No files matched your search
@@ -92,6 +92,7 @@ mod thread_metadata_update;
|
||||
mod thread_name_websocket;
|
||||
mod thread_read;
|
||||
mod thread_resume;
|
||||
mod thread_revert;
|
||||
mod thread_rollback;
|
||||
mod thread_sections;
|
||||
mod thread_settings_update;
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
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(())
|
||||
}
|
||||
Reference in new issue
Block a user