Start side conversations without replaying inherited turns (#34198)

## What changed

- Fork side conversations with `exclude_turns` so their initial UI starts empty instead of displaying the parent thread's turns.
- Keep regular fork responses unchanged, including for ephemeral threads.

## Testing

- Add coverage that compares regular and side-conversation forks of the same saved thread.

GitOrigin-RevId: 662607e9f588c20a2e06656deefa5b70bde19cd2
This commit is contained in:
Charlie Marsh
2026-07-19 16:54:58 +00:00
committed by copyberry
parent 3dd3c5d08a
commit 78fd2f2b28
2 changed files with 82 additions and 1 deletions
+4 -1
View File
@@ -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);
+78
View File
@@ -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<String>,
before_turn_id: Option<String>,
goal_continuation: ForkGoalContinuation,
) -> Result<AppServerStartedThread> {
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<AppServerStartedThread> {
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<String>,
before_turn_id: Option<String>,
goal_continuation: ForkGoalContinuation,
presentation: ForkPresentation,
) -> Result<AppServerStartedThread> {
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::<Turn>::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");