mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat(app-server): add optional turn_id to thread/fork (#30277)
## Description This adds stable optional `turnId` support to `thread/fork`. When supplied, the fork copies persisted history through that terminal turn, inclusive, and drops later turns from the new thread. Omitting or passing `null` preserves the existing full-history fork behavior, including the interruption marker when the stored source history ends mid-turn. ## Why We're deprecating `thread/rollback` and this will help certain UX use cases work around it by using `thread/fork` + `turn_id` instead.
This commit is contained in:
@@ -145,6 +145,7 @@ pub(crate) mod state_db_bridge;
|
||||
pub use state_db_bridge::StateDbHandle;
|
||||
pub use state_db_bridge::init_state_db;
|
||||
mod thread_rollout_truncation;
|
||||
pub use thread_rollout_truncation::truncate_rollout_after_turn_id;
|
||||
mod tools;
|
||||
pub(crate) mod turn_diff_tracker;
|
||||
mod turn_metadata;
|
||||
|
||||
@@ -5,6 +5,10 @@
|
||||
|
||||
use crate::context_manager::is_user_turn_boundary;
|
||||
use crate::event_mapping;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::build_turns_from_rollout_items;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::error::Result as CodexResult;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
@@ -149,6 +153,58 @@ pub(crate) fn truncate_rollout_before_nth_user_message_from_start(
|
||||
items[..cut_idx].to_vec()
|
||||
}
|
||||
|
||||
/// Return a rollout prefix ending after the requested persisted terminal turn.
|
||||
///
|
||||
/// The turn must still be present in the effective post-rollback history and
|
||||
/// must have an explicit persisted TurnStarted boundary. Synthetic IDs
|
||||
/// generated while projecting legacy rollouts are intentionally unsupported
|
||||
/// because they do not provide a stable raw rollout boundary for a fork.
|
||||
pub fn truncate_rollout_after_turn_id(
|
||||
items: &[RolloutItem],
|
||||
last_turn_id: &str,
|
||||
) -> CodexResult<Vec<RolloutItem>> {
|
||||
let turns = build_turns_from_rollout_items(items);
|
||||
let turn = turns
|
||||
.iter()
|
||||
.find(|turn| turn.id == last_turn_id)
|
||||
.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest(format!(
|
||||
"lastTurnId '{last_turn_id}' was not found in the source thread"
|
||||
))
|
||||
})?;
|
||||
|
||||
let target_start_index = items
|
||||
.iter()
|
||||
.position(|item| {
|
||||
matches!(
|
||||
item,
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(event))
|
||||
if event.turn_id == last_turn_id
|
||||
)
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
CodexErr::InvalidRequest(format!(
|
||||
"lastTurnId '{last_turn_id}' is not a persisted canonical turn in the source thread"
|
||||
))
|
||||
})?;
|
||||
|
||||
if matches!(turn.status, TurnStatus::InProgress) {
|
||||
return Err(CodexErr::InvalidRequest(format!(
|
||||
"lastTurnId '{last_turn_id}' identifies an in-progress turn"
|
||||
)));
|
||||
}
|
||||
|
||||
let cut_index = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.skip(target_start_index.saturating_add(1))
|
||||
.find_map(|(index, item)| {
|
||||
matches!(item, RolloutItem::EventMsg(EventMsg::TurnStarted(_))).then_some(index)
|
||||
})
|
||||
.unwrap_or(items.len());
|
||||
Ok(items[..cut_index].to_vec())
|
||||
}
|
||||
|
||||
/// Return a suffix of `items` that keeps the last `n_from_end` fork turns.
|
||||
///
|
||||
/// If fewer than or equal to `n_from_end` fork turns exist, this keeps from the first fork-turn
|
||||
|
||||
@@ -6,6 +6,9 @@ use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::ThreadRolledBackEvent;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -66,6 +69,104 @@ fn inter_agent_communication(text: &str, trigger_turn: bool) -> RolloutItem {
|
||||
))
|
||||
}
|
||||
|
||||
fn turn_started(turn_id: &str) -> RolloutItem {
|
||||
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
trace_id: None,
|
||||
started_at: None,
|
||||
model_context_window: None,
|
||||
collaboration_mode_kind: Default::default(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn turn_completed(turn_id: &str) -> RolloutItem {
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(TurnCompleteEvent {
|
||||
turn_id: turn_id.to_string(),
|
||||
last_agent_message: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
time_to_first_token_ms: None,
|
||||
}))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_rollout_after_terminal_canonical_turn_id() {
|
||||
let rollout = vec![
|
||||
turn_started("turn-1"),
|
||||
turn_completed("turn-1"),
|
||||
turn_started("turn-2"),
|
||||
turn_completed("turn-2"),
|
||||
turn_started("turn-3"),
|
||||
turn_completed("turn-3"),
|
||||
];
|
||||
|
||||
let truncated =
|
||||
truncate_rollout_after_turn_id(&rollout, "turn-2").expect("truncate through turn-2");
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(&truncated).unwrap(),
|
||||
serde_json::to_value(&rollout[..4]).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_rollout_after_turn_id_rejects_rolled_back_turn() {
|
||||
let rollout = vec![
|
||||
turn_started("turn-1"),
|
||||
turn_completed("turn-1"),
|
||||
turn_started("turn-2"),
|
||||
turn_completed("turn-2"),
|
||||
RolloutItem::EventMsg(EventMsg::ThreadRolledBack(ThreadRolledBackEvent {
|
||||
num_turns: 1,
|
||||
})),
|
||||
turn_started("turn-3"),
|
||||
turn_completed("turn-3"),
|
||||
];
|
||||
|
||||
let err = truncate_rollout_after_turn_id(&rollout, "turn-2")
|
||||
.expect_err("rolled-back turn should not be a fork anchor");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
CodexErr::InvalidRequest(message)
|
||||
if message == "lastTurnId 'turn-2' was not found in the source thread"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_rollout_after_turn_id_rejects_synthetic_legacy_turn_id() {
|
||||
let rollout = vec![RolloutItem::EventMsg(EventMsg::UserMessage(
|
||||
UserMessageEvent {
|
||||
message: "legacy".to_string(),
|
||||
..Default::default()
|
||||
},
|
||||
))];
|
||||
|
||||
let err = truncate_rollout_after_turn_id(&rollout, "rollout-0")
|
||||
.expect_err("synthetic turn should not be a fork anchor");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
CodexErr::InvalidRequest(message)
|
||||
if message
|
||||
== "lastTurnId 'rollout-0' is not a persisted canonical turn in the source thread"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_rollout_after_turn_id_rejects_in_progress_turn() {
|
||||
let rollout = vec![turn_started("turn-1")];
|
||||
|
||||
let err = truncate_rollout_after_turn_id(&rollout, "turn-1")
|
||||
.expect_err("in-progress turn should not be a fork anchor");
|
||||
|
||||
assert!(matches!(
|
||||
err,
|
||||
CodexErr::InvalidRequest(message)
|
||||
if message == "lastTurnId 'turn-1' identifies an in-progress turn"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_rollout_from_start_before_nth_user_only() {
|
||||
let items = [
|
||||
|
||||
Reference in New Issue
Block a user