Preserve inherited previews for paginated thread forks (#60559)

## Why

Reference-backed forks can lack a local opening user message, causing them to disappear from thread listings or lose their inherited preview when a new turn is written. Compaction can also remove the opening message from the model context used to prepare a fork.

## What changed

- Persist inherited previews and preserve them when recording new turns and merging thread-list metadata.
- Include reference-backed forks with inherited history in thread listings before their first local message.
- Recover opening previews from transcript history within the requested fork boundary when compaction omits them from model context.
- Use the shared `user_message_preview` helper when deriving previews from rollout items.

## Testing

Extend thread-fork integration tests to check previews in listings before and after a new turn, after reopening, and when forking at a boundary after compaction.

GitOrigin-RevId: 5962cf71ea7104f07c9013cc77259b105e4c60d7
This commit is contained in:
Eric Traut
2026-11-10 08:52:02 +00:00
committed by copyberry
parent ae5c75dbfc
commit f988a5f0da
11 changed files with 184 additions and 26 deletions
@@ -5702,12 +5702,19 @@ impl ThreadRequestProcessor {
}
}
let ephemeral_preview = if ephemeral {
if paginated_source && last_turn_id.is_none() && before_turn_id.is_none() {
source_thread.preview.clone()
} else {
preview_from_rollout_items(&history_items)
}
let inherited_preview = if paginated_source
&& last_turn_id.is_none()
&& before_turn_id.is_none()
&& !source_thread.preview.is_empty()
{
source_thread.preview.clone()
} else if let Some(preview) = prepared_fork
.as_mut()
.and_then(|fork| fork.preview_before_compaction.take())
{
preview
} else if paginated_source || ephemeral {
preview_from_rollout_items(&history_items)
} else {
String::new()
};
@@ -5727,6 +5734,7 @@ impl ThreadRequestProcessor {
self.thread_manager.as_ref(),
self.thread_store.as_ref(),
StoreThreadMetadataPatch {
preview: (!inherited_preview.is_empty()).then(|| inherited_preview.clone()),
project_id: inherited_project_id.clone().map(Some),
daybreak_enabled: source_thread.daybreak_enabled,
..Default::default()
@@ -5891,7 +5899,7 @@ impl ThreadRequestProcessor {
&config_snapshot,
/*path*/ None,
);
thread.preview = ephemeral_preview;
thread.preview = inherited_preview;
thread.forked_from_id = Some(source_thread_id.to_string());
thread.turns = ephemeral_turns;
(thread, ephemeral_token_usage_turn_id)
@@ -6912,12 +6920,15 @@ fn preview_from_rollout_items(items: &[RolloutItem]) -> String {
.iter()
.find_map(|item| match item {
RolloutItem::ResponseItem(item) => match codex_core::parse_turn_item(&item.item) {
Some(codex_protocol::items::TurnItem::UserMessage(user)) => Some(user.message()),
Some(codex_protocol::items::TurnItem::UserMessage(user)) => {
codex_protocol::protocol::user_message_preview(
&user.as_legacy_user_message_event(),
)
}
_ => None,
},
_ => None,
})
.map(|preview| strip_user_message_prefix(preview.as_str()).to_string())
.unwrap_or_default()
}