diff --git a/codex-rs/tui/src/app/side.rs b/codex-rs/tui/src/app/side.rs index 11ce6cb9e5..67e9340559 100644 --- a/codex-rs/tui/src/app/side.rs +++ b/codex-rs/tui/src/app/side.rs @@ -574,7 +574,10 @@ impl App { .await; let fork_config = self.side_fork_config(); - match app_server.fork_thread(fork_config, parent_thread_id).await { + match app_server + .fork_side_thread(fork_config, parent_thread_id) + .await + { Ok(forked) => { let child_thread_id = forked.session.thread_id; let channel = self.ensure_thread_channel(child_thread_id); diff --git a/codex-rs/tui/src/app_server_session.rs b/codex-rs/tui/src/app_server_session.rs index 948aa9bd58..50d59397cd 100644 --- a/codex-rs/tui/src/app_server_session.rs +++ b/codex-rs/tui/src/app_server_session.rs @@ -145,6 +145,12 @@ pub(crate) enum ForkGoalContinuation { DeferUntilNextTurn, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum ForkPresentation { + Regular, + SideConversation, +} + fn bootstrap_request_error(context: &'static str, err: TypedRequestError) -> color_eyre::Report { color_eyre::eyre::eyre!("{context}: {err}") } @@ -574,6 +580,42 @@ impl AppServerSession { last_turn_id: Option, before_turn_id: Option, goal_continuation: ForkGoalContinuation, + ) -> Result { + self.fork_thread_at_with_presentation( + config, + thread_id, + last_turn_id, + before_turn_id, + goal_continuation, + ForkPresentation::Regular, + ) + .await + } + + pub(crate) async fn fork_side_thread( + &mut self, + config: Config, + thread_id: ThreadId, + ) -> Result { + self.fork_thread_at_with_presentation( + config, + thread_id, + /*last_turn_id*/ None, + /*before_turn_id*/ None, + ForkGoalContinuation::StartIfIdle, + ForkPresentation::SideConversation, + ) + .await + } + + async fn fork_thread_at_with_presentation( + &mut self, + config: Config, + thread_id: ThreadId, + last_turn_id: Option, + before_turn_id: Option, + goal_continuation: ForkGoalContinuation, + presentation: ForkPresentation, ) -> Result { let request_id = self.next_request_id(); let session_config = self.session_config_with_effective_service_tier(&config); @@ -586,6 +628,7 @@ impl AppServerSession { before_turn_id, defer_goal_continuation: goal_continuation == ForkGoalContinuation::DeferUntilNextTurn, + exclude_turns: presentation == ForkPresentation::SideConversation, ..thread_fork_params_from_config( session_config, thread_id, @@ -2444,6 +2487,41 @@ mod tests { ); } + #[tokio::test] + async fn side_fork_excludes_turns_without_clearing_regular_ephemeral_fork() -> Result<()> { + let codex_home = tempfile::tempdir().expect("tempdir"); + let mut config = build_config(&codex_home).await; + config.ephemeral = true; + let thread_id = ThreadId::from_string( + &create_fake_rollout( + codex_home.path(), + "2025-01-05T12-00-00", + "2025-01-05T12:00:00Z", + "Saved user message", + Some(config.model_provider_id.as_str()), + /*git_info*/ None, + ) + .expect("create rollout"), + )?; + let mut app_server = crate::start_embedded_app_server_for_picker(&config).await?; + + let regular = app_server.fork_thread(config.clone(), thread_id).await?; + let side = app_server.fork_side_thread(config, thread_id).await?; + + assert_eq!(regular.turns.len(), 1); + assert!(matches!( + regular.turns[0].items.as_slice(), + [codex_app_server_protocol::ThreadItem::UserMessage { content, .. }] + if content == &[UserInput::Text { + text: "Saved user message".to_string(), + text_elements: Vec::new(), + }] + )); + assert_eq!(side.turns, Vec::::new()); + app_server.shutdown().await?; + Ok(()) + } + #[tokio::test] async fn terminal_visualization_instructions_are_gated_for_all_tui_thread_flows() { let temp_dir = tempfile::tempdir().expect("tempdir");