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:
Owen Lin
2026-06-26 19:35:54 +00:00
committed by GitHub
parent 812cd2bb57
commit f72976a5f1
14 changed files with 352 additions and 6 deletions
+1
View File
@@ -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 = [