Skip to content

Commit b34e906

Browse files
Reland "refactor transcript view to handle HistoryCells" (#3753)
Reland of #3538
1 parent 7103838 commit b34e906

13 files changed

+477
-405
lines changed

codex-rs/core/src/codex.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ use codex_protocol::models::ResponseItem;
130130
use codex_protocol::models::ShellToolCallParams;
131131
use codex_protocol::protocol::InitialHistory;
132132

133-
mod compact;
133+
pub mod compact;
134134
use self::compact::build_compacted_history;
135135
use self::compact::collect_user_messages;
136136

@@ -710,7 +710,7 @@ impl Session {
710710
self.persist_rollout_items(&rollout_items).await;
711711
}
712712

713-
fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
713+
pub(crate) fn build_initial_context(&self, turn_context: &TurnContext) -> Vec<ResponseItem> {
714714
let mut items = Vec::<ResponseItem>::with_capacity(2);
715715
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
716716
items.push(UserInstructions::new(user_instructions.to_string()).into());
@@ -3325,6 +3325,9 @@ async fn exit_review_mode(
33253325
.await;
33263326
}
33273327

3328+
#[cfg(test)]
3329+
pub(crate) use tests::make_session_and_context;
3330+
33283331
#[cfg(test)]
33293332
mod tests {
33303333
use super::*;
@@ -3565,7 +3568,7 @@ mod tests {
35653568
})
35663569
}
35673570

3568-
fn make_session_and_context() -> (Session, TurnContext) {
3571+
pub(crate) fn make_session_and_context() -> (Session, TurnContext) {
35693572
let (tx_event, _rx_event) = async_channel::unbounded();
35703573
let codex_home = tempfile::tempdir().expect("create temp dir");
35713574
let config = Config::load_from_base_config_with_overrides(

codex-rs/core/src/codex/compact.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -203,7 +203,7 @@ async fn run_compact_task_inner(
203203
sess.send_event(event).await;
204204
}
205205

206-
fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
206+
pub fn content_items_to_text(content: &[ContentItem]) -> Option<String> {
207207
let mut pieces = Vec::new();
208208
for item in content {
209209
match item {
@@ -235,7 +235,7 @@ pub(crate) fn collect_user_messages(items: &[ResponseItem]) -> Vec<String> {
235235
.collect()
236236
}
237237

238-
fn is_session_prefix_message(text: &str) -> bool {
238+
pub fn is_session_prefix_message(text: &str) -> bool {
239239
matches!(
240240
InputMessageKind::from(("user", text)),
241241
InputMessageKind::UserInstructions | InputMessageKind::EnvironmentContext

codex-rs/core/src/conversation_manager.rs

Lines changed: 50 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use crate::CodexAuth;
33
use crate::codex::Codex;
44
use crate::codex::CodexSpawnOk;
55
use crate::codex::INITIAL_SUBMIT_ID;
6+
use crate::codex::compact::content_items_to_text;
7+
use crate::codex::compact::is_session_prefix_message;
68
use crate::codex_conversation::CodexConversation;
79
use crate::config::Config;
810
use crate::error::CodexErr;
@@ -134,19 +136,19 @@ impl ConversationManager {
134136
self.conversations.write().await.remove(conversation_id)
135137
}
136138

137-
/// Fork an existing conversation by dropping the last `drop_last_messages`
138-
/// user/assistant messages from its transcript and starting a new
139+
/// Fork an existing conversation by taking messages up to the given position
140+
/// (not including the message at the given position) and starting a new
139141
/// conversation with identical configuration (unless overridden by the
140142
/// caller's `config`). The new conversation will have a fresh id.
141143
pub async fn fork_conversation(
142144
&self,
143-
num_messages_to_drop: usize,
145+
nth_user_message: usize,
144146
config: Config,
145147
path: PathBuf,
146148
) -> CodexResult<NewConversation> {
147149
// Compute the prefix up to the cut point.
148150
let history = RolloutRecorder::get_rollout_history(&path).await?;
149-
let history = truncate_after_dropping_last_messages(history, num_messages_to_drop);
151+
let history = truncate_before_nth_user_message(history, nth_user_message);
150152

151153
// Spawn a new conversation with the computed initial history.
152154
let auth_manager = self.auth_manager.clone();
@@ -159,33 +161,30 @@ impl ConversationManager {
159161
}
160162
}
161163

162-
/// Return a prefix of `items` obtained by dropping the last `n` user messages
163-
/// and all items that follow them.
164-
fn truncate_after_dropping_last_messages(history: InitialHistory, n: usize) -> InitialHistory {
165-
if n == 0 {
166-
return InitialHistory::Forked(history.get_rollout_items());
167-
}
168-
169-
// Work directly on rollout items, and cut the vector at the nth-from-last user message input.
164+
/// Return a prefix of `items` obtained by cutting strictly before the nth user message
165+
/// (0-based) and all items that follow it.
166+
fn truncate_before_nth_user_message(history: InitialHistory, n: usize) -> InitialHistory {
167+
// Work directly on rollout items, and cut the vector at the nth user message input.
170168
let items: Vec<RolloutItem> = history.get_rollout_items();
171169

172170
// Find indices of user message inputs in rollout order.
173171
let mut user_positions: Vec<usize> = Vec::new();
174172
for (idx, item) in items.iter().enumerate() {
175-
if let RolloutItem::ResponseItem(ResponseItem::Message { role, .. }) = item
173+
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = item
176174
&& role == "user"
175+
&& content_items_to_text(content).is_some_and(|text| !is_session_prefix_message(&text))
177176
{
178177
user_positions.push(idx);
179178
}
180179
}
181180

182-
// If fewer than n user messages exist, treat as empty.
183-
if user_positions.len() < n {
181+
// If fewer than or equal to n user messages exist, treat as empty (out of range).
182+
if user_positions.len() <= n {
184183
return InitialHistory::New;
185184
}
186185

187-
// Cut strictly before the nth-from-last user message (do not keep the nth itself).
188-
let cut_idx = user_positions[user_positions.len() - n];
186+
// Cut strictly before the nth user message (do not keep the nth itself).
187+
let cut_idx = user_positions[n];
189188
let rolled: Vec<RolloutItem> = items.into_iter().take(cut_idx).collect();
190189

191190
if rolled.is_empty() {
@@ -198,9 +197,11 @@ fn truncate_after_dropping_last_messages(history: InitialHistory, n: usize) -> I
198197
#[cfg(test)]
199198
mod tests {
200199
use super::*;
200+
use crate::codex::make_session_and_context;
201201
use codex_protocol::models::ContentItem;
202202
use codex_protocol::models::ReasoningItemReasoningSummary;
203203
use codex_protocol::models::ResponseItem;
204+
use pretty_assertions::assert_eq;
204205

205206
fn user_msg(text: &str) -> ResponseItem {
206207
ResponseItem::Message {
@@ -252,7 +253,7 @@ mod tests {
252253
.cloned()
253254
.map(RolloutItem::ResponseItem)
254255
.collect();
255-
let truncated = truncate_after_dropping_last_messages(InitialHistory::Forked(initial), 1);
256+
let truncated = truncate_before_nth_user_message(InitialHistory::Forked(initial), 1);
256257
let got_items = truncated.get_rollout_items();
257258
let expected_items = vec![
258259
RolloutItem::ResponseItem(items[0].clone()),
@@ -269,7 +270,37 @@ mod tests {
269270
.cloned()
270271
.map(RolloutItem::ResponseItem)
271272
.collect();
272-
let truncated2 = truncate_after_dropping_last_messages(InitialHistory::Forked(initial2), 2);
273+
let truncated2 = truncate_before_nth_user_message(InitialHistory::Forked(initial2), 2);
273274
assert!(matches!(truncated2, InitialHistory::New));
274275
}
276+
277+
#[test]
278+
fn ignores_session_prefix_messages_when_truncating() {
279+
let (session, turn_context) = make_session_and_context();
280+
let mut items = session.build_initial_context(&turn_context);
281+
items.push(user_msg("feature request"));
282+
items.push(assistant_msg("ack"));
283+
items.push(user_msg("second question"));
284+
items.push(assistant_msg("answer"));
285+
286+
let rollout_items: Vec<RolloutItem> = items
287+
.iter()
288+
.cloned()
289+
.map(RolloutItem::ResponseItem)
290+
.collect();
291+
292+
let truncated = truncate_before_nth_user_message(InitialHistory::Forked(rollout_items), 1);
293+
let got_items = truncated.get_rollout_items();
294+
295+
let expected: Vec<RolloutItem> = vec![
296+
RolloutItem::ResponseItem(items[0].clone()),
297+
RolloutItem::ResponseItem(items[1].clone()),
298+
RolloutItem::ResponseItem(items[2].clone()),
299+
];
300+
301+
assert_eq!(
302+
serde_json::to_value(&got_items).unwrap(),
303+
serde_json::to_value(&expected).unwrap()
304+
);
305+
}
275306
}

codex-rs/core/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ pub use client_common::Prompt;
9292
pub use client_common::REVIEW_PROMPT;
9393
pub use client_common::ResponseEvent;
9494
pub use client_common::ResponseStream;
95+
pub use codex::compact::content_items_to_text;
96+
pub use codex::compact::is_session_prefix_message;
9597
pub use codex_protocol::models::ContentItem;
9698
pub use codex_protocol::models::LocalShellAction;
9799
pub use codex_protocol::models::LocalShellExecAction;

codex-rs/core/tests/suite/compact_resume_fork.rs

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
7474
"compact+resume test expects resumed path {resumed_path:?} to exist",
7575
);
7676

77-
let forked = fork_conversation(&manager, &config, resumed_path, 1).await;
77+
let forked = fork_conversation(&manager, &config, resumed_path, 2).await;
7878
user_turn(&forked, "AFTER_FORK").await;
7979

8080
// 3. Capture the requests to the model and validate the history slices.
@@ -100,17 +100,15 @@ async fn compact_resume_and_fork_preserve_model_history_view() {
100100
"after-resume input should have at least as many items as after-compact",
101101
);
102102
assert_eq!(compact_arr.as_slice(), &resume_arr[..compact_arr.len()]);
103-
eprint!(
104-
"len of compact: {}, len of fork: {}",
105-
compact_arr.len(),
106-
fork_arr.len()
107-
);
108-
eprintln!("input_after_fork:{}", json!(input_after_fork));
103+
109104
assert!(
110105
compact_arr.len() <= fork_arr.len(),
111106
"after-fork input should have at least as many items as after-compact",
112107
);
113-
assert_eq!(compact_arr.as_slice(), &fork_arr[..compact_arr.len()]);
108+
assert_eq!(
109+
&compact_arr.as_slice()[..compact_arr.len()],
110+
&fork_arr[..compact_arr.len()]
111+
);
114112

115113
let prompt = requests[0]["instructions"]
116114
.as_str()
@@ -824,14 +822,15 @@ async fn resume_conversation(
824822
conversation
825823
}
826824

825+
#[cfg(test)]
827826
async fn fork_conversation(
828827
manager: &ConversationManager,
829828
config: &Config,
830829
path: std::path::PathBuf,
831-
back_steps: usize,
830+
nth_user_message: usize,
832831
) -> Arc<CodexConversation> {
833832
let NewConversation { conversation, .. } = manager
834-
.fork_conversation(back_steps, config.clone(), path)
833+
.fork_conversation(nth_user_message, config.clone(), path)
835834
.await
836835
.expect("fork conversation");
837836
conversation

codex-rs/core/tests/suite/fork_conversation.rs

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ use codex_core::ModelProviderInfo;
55
use codex_core::NewConversation;
66
use codex_core::ResponseItem;
77
use codex_core::built_in_model_providers;
8+
use codex_core::content_items_to_text;
9+
use codex_core::is_session_prefix_message;
810
use codex_core::protocol::ConversationPathResponseEvent;
911
use codex_core::protocol::EventMsg;
1012
use codex_core::protocol::InputItem;
@@ -104,13 +106,16 @@ async fn fork_conversation_twice_drops_to_first_message() {
104106
items
105107
};
106108

107-
// Compute expected prefixes after each fork by truncating base rollout at nth-from-last user input.
109+
// Compute expected prefixes after each fork by truncating base rollout
110+
// strictly before the nth user input (0-based).
108111
let base_items = read_items(&base_path);
109112
let find_user_input_positions = |items: &[RolloutItem]| -> Vec<usize> {
110113
let mut pos = Vec::new();
111114
for (i, it) in items.iter().enumerate() {
112115
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) = it
113116
&& role == "user"
117+
&& content_items_to_text(content)
118+
.is_some_and(|text| !is_session_prefix_message(&text))
114119
{
115120
// Consider any user message as an input boundary; recorder stores both EventMsg and ResponseItem.
116121
// We specifically look for input items, which are represented as ContentItem::InputText.
@@ -126,11 +131,8 @@ async fn fork_conversation_twice_drops_to_first_message() {
126131
};
127132
let user_inputs = find_user_input_positions(&base_items);
128133

129-
// After dropping last user input (n=1), cut strictly before that input if present, else empty.
130-
let cut1 = user_inputs
131-
.get(user_inputs.len().saturating_sub(1))
132-
.copied()
133-
.unwrap_or(0);
134+
// After cutting at nth user input (n=1 → second user message), cut strictly before that input.
135+
let cut1 = user_inputs.get(1).copied().unwrap_or(0);
134136
let expected_after_first: Vec<RolloutItem> = base_items[..cut1].to_vec();
135137

136138
// After dropping again (n=1 on fork1), compute expected relative to fork1's rollout.
@@ -161,12 +163,12 @@ async fn fork_conversation_twice_drops_to_first_message() {
161163
serde_json::to_value(&expected_after_first).unwrap()
162164
);
163165

164-
// Fork again with n=1 → drops the (new) last user message, leaving only the first.
166+
// Fork again with n=0 → drops the (new) last user message, leaving only the first.
165167
let NewConversation {
166168
conversation: codex_fork2,
167169
..
168170
} = conversation_manager
169-
.fork_conversation(1, config_for_fork.clone(), fork1_path.clone())
171+
.fork_conversation(0, config_for_fork.clone(), fork1_path.clone())
170172
.await
171173
.expect("fork 2");
172174

codex-rs/tui/src/app.rs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use crate::app_event::AppEvent;
33
use crate::app_event_sender::AppEventSender;
44
use crate::chatwidget::ChatWidget;
55
use crate::file_search::FileSearchManager;
6+
use crate::history_cell::HistoryCell;
67
use crate::pager_overlay::Overlay;
78
use crate::resume_picker::ResumeSelection;
89
use crate::tui;
@@ -52,7 +53,7 @@ pub(crate) struct App {
5253

5354
pub(crate) file_search: FileSearchManager,
5455

55-
pub(crate) transcript_lines: Vec<Line<'static>>,
56+
pub(crate) transcript_cells: Vec<Arc<dyn HistoryCell>>,
5657

5758
// Pager overlay state (Transcript or Static like Diff)
5859
pub(crate) overlay: Option<Overlay>,
@@ -138,7 +139,7 @@ impl App {
138139
active_profile,
139140
file_search,
140141
enhanced_keys_supported,
141-
transcript_lines: Vec::new(),
142+
transcript_cells: Vec::new(),
142143
overlay: None,
143144
deferred_history_lines: Vec::new(),
144145
has_emitted_history_lines: false,
@@ -225,15 +226,12 @@ impl App {
225226
tui.frame_requester().schedule_frame();
226227
}
227228
AppEvent::InsertHistoryCell(cell) => {
228-
let mut cell_transcript = cell.transcript_lines();
229-
if !cell.is_stream_continuation() && !self.transcript_lines.is_empty() {
230-
cell_transcript.insert(0, Line::from(""));
231-
}
229+
let cell: Arc<dyn HistoryCell> = cell.into();
232230
if let Some(Overlay::Transcript(t)) = &mut self.overlay {
233-
t.insert_lines(cell_transcript.clone());
231+
t.insert_cell(cell.clone());
234232
tui.frame_requester().schedule_frame();
235233
}
236-
self.transcript_lines.extend(cell_transcript.clone());
234+
self.transcript_cells.push(cell.clone());
237235
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
238236
if !display.is_empty() {
239237
// Only insert a separating blank line for new cells that are not
@@ -380,7 +378,7 @@ impl App {
380378
} => {
381379
// Enter alternate screen and set viewport to full size.
382380
let _ = tui.enter_alt_screen();
383-
self.overlay = Some(Overlay::new_transcript(self.transcript_lines.clone()));
381+
self.overlay = Some(Overlay::new_transcript(self.transcript_cells.clone()));
384382
tui.frame_requester().schedule_frame();
385383
}
386384
// Esc primes/advances backtracking only in normal (not working) mode
@@ -405,7 +403,7 @@ impl App {
405403
kind: KeyEventKind::Press,
406404
..
407405
} if self.backtrack.primed
408-
&& self.backtrack.count > 0
406+
&& self.backtrack.nth_user_message != usize::MAX
409407
&& self.chat_widget.composer_is_empty() =>
410408
{
411409
// Delegate to helper for clarity; preserves behavior.
@@ -439,7 +437,6 @@ mod tests {
439437
use codex_core::AuthManager;
440438
use codex_core::CodexAuth;
441439
use codex_core::ConversationManager;
442-
use ratatui::text::Line;
443440
use std::sync::Arc;
444441
use std::sync::atomic::AtomicBool;
445442

@@ -462,7 +459,7 @@ mod tests {
462459
config,
463460
active_profile: None,
464461
file_search,
465-
transcript_lines: Vec::<Line<'static>>::new(),
462+
transcript_cells: Vec::new(),
466463
overlay: None,
467464
deferred_history_lines: Vec::new(),
468465
has_emitted_history_lines: false,

0 commit comments

Comments
 (0)