mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Align persisted TUI activity groups and reasoning with live output (#46711)
## Why Persisted transcripts rendered tool calls individually and displayed reasoning differently from live replay. Enabling raw reasoning also replaced the summary instead of retaining it. ## What changed - Group computer activity and compatible exploration commands across intervening reasoning, retaining details for the expanded transcript. Respect visible content, command failure boundaries, and turn boundaries. - Separate paginated history at every turn boundary before filtering hidden items, while adding completion metadata only for successful turns. - Keep persisted reasoning out of the compact display, preserve hyperlinks in detailed rendering, and append enabled raw reasoning to the summary. Preserve standalone reasoning Markdown in transcript exports. - Add helpers to rejoin activity groups and attach reasoning across page boundaries using persisted item identities and turn adjacency. ## Testing Add regression coverage for reasoning replay parity, computer groups split across history pages, turn boundaries, and grouped terminal input. Update export and raw-reasoning tests to verify retained summaries and Markdown. GitOrigin-RevId: a716db571440f891cc2c6894ae93fbe0e149aed4
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
//! Group older history items at successful turn boundaries before projecting transcript cells.
|
||||
//! Separate older history at every turn boundary; only successful turns gain completion metadata.
|
||||
//! A page splitting a turn only adds completion metadata when it contains that turn's last item.
|
||||
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
@@ -10,17 +10,17 @@ pub(super) fn group_completed_turn_items(
|
||||
items: Vec<ThreadItem>,
|
||||
turns: &[Turn],
|
||||
) -> Vec<(Vec<ThreadItem>, Option<&Turn>)> {
|
||||
let completed_boundaries: HashMap<_, _> = turns
|
||||
let boundaries: HashMap<_, _> = turns
|
||||
.iter()
|
||||
.filter(|turn| turn.status == TurnStatus::Completed)
|
||||
.filter_map(|turn| turn.items.last().map(|item| (item.id(), turn)))
|
||||
.collect();
|
||||
let mut groups = Vec::new();
|
||||
let mut pending = Vec::new();
|
||||
for item in items {
|
||||
let completed_turn = completed_boundaries.get(item.id()).copied();
|
||||
let boundary = boundaries.get(item.id()).copied();
|
||||
pending.push(item);
|
||||
if completed_turn.is_some() {
|
||||
if let Some(turn) = boundary {
|
||||
let completed_turn = (turn.status == TurnStatus::Completed).then_some(turn);
|
||||
groups.push((std::mem::take(&mut pending), completed_turn));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,7 @@ fn multiple_turns_keep_item_groups_and_completion_order() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsuccessful_and_running_turns_do_not_create_completion_boundaries() {
|
||||
fn unsuccessful_and_running_turns_separate_groups_without_completion_metadata() {
|
||||
let turns = vec![
|
||||
turn("failed", TurnStatus::Failed, &["a"]),
|
||||
turn("interrupted", TurnStatus::Interrupted, &["b"]),
|
||||
@@ -69,7 +69,10 @@ fn unsuccessful_and_running_turns_do_not_create_completion_boundaries() {
|
||||
.flat_map(|turn| turn.items.clone())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
group_completed_turn_items(items.clone(), &turns),
|
||||
vec![(items, None)],
|
||||
group_completed_turn_items(items, &turns),
|
||||
turns
|
||||
.iter()
|
||||
.map(|turn| (turn.items.clone(), None))
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ impl App {
|
||||
store.turns.clone(),
|
||||
)
|
||||
};
|
||||
let mut items = app_server
|
||||
let items = app_server
|
||||
.apply_older_history_page(thread_id, cursor, page, &mut turns)
|
||||
.await?;
|
||||
let mut hidden_item_ids = HashSet::new();
|
||||
@@ -185,9 +185,9 @@ impl App {
|
||||
}
|
||||
}
|
||||
}
|
||||
items.retain(|item| !hidden_item_ids.contains(item.id()));
|
||||
let mut cells = Vec::new();
|
||||
for (items, completed_turn) in completion::group_completed_turn_items(items, &turns) {
|
||||
for (mut items, completed_turn) in completion::group_completed_turn_items(items, &turns) {
|
||||
items.retain(|item| !hidden_item_ids.contains(item.id()));
|
||||
cells.extend(thread_items_to_transcript_cells(
|
||||
Some(thread_id),
|
||||
&cwd,
|
||||
|
||||
+5
@@ -1,9 +1,14 @@
|
||||
---
|
||||
source: tui/src/app/transcript_export_tests.rs
|
||||
assertion_line: 227
|
||||
expression: "render_markdown_transcript(&cells).expect(\"exported transcript\")"
|
||||
---
|
||||
# Codex conversation
|
||||
|
||||
## Reasoning
|
||||
|
||||
Inspect **the saved result**.
|
||||
|
||||
## Activity
|
||||
|
||||
Opened https://example.com/long/path?query=full#details
|
||||
|
||||
@@ -237,6 +237,8 @@ fn render_markdown_transcript(cells: &[Arc<dyn HistoryCell>]) -> Result<String,
|
||||
}
|
||||
lines.extend(image_labels.into_iter().map(Into::into));
|
||||
lines
|
||||
} else if let Some(reasoning) = cell.as_any().downcast_ref::<ReasoningSummaryCell>() {
|
||||
raw_lines_from_source(reasoning.markdown_source().trim())
|
||||
} else {
|
||||
cell.raw_lines()
|
||||
};
|
||||
|
||||
@@ -201,6 +201,10 @@ fn transcript_file_resolves_relative_paths_and_refuses_to_overwrite() {
|
||||
#[test]
|
||||
fn persisted_web_and_image_activity_preserves_full_details() {
|
||||
let items = [
|
||||
serde_json::json!({
|
||||
"type": "reasoning", "id": "reasoning",
|
||||
"summary": ["Inspect **the saved result**."], "content": ["Raw details stay hidden."]
|
||||
}),
|
||||
serde_json::json!({
|
||||
"type": "webSearch", "id": "open", "query": "",
|
||||
"action": {"type": "openPage", "url": "https://example.com/long/path?query=full#details"}
|
||||
|
||||
@@ -349,7 +349,8 @@ impl ChatWidget {
|
||||
.or(self.reasoning_header.take());
|
||||
if !self.reasoning_summary_parts.is_empty() {
|
||||
let reasoning_parts = std::mem::take(&mut self.reasoning_summary_parts);
|
||||
let cell = history_cell::new_reasoning_summary_block(reasoning_parts, &self.config.cwd);
|
||||
let cell: Box<dyn HistoryCell> =
|
||||
history_cell::new_reasoning_summary_block(reasoning_parts, &self.config.cwd);
|
||||
let result = match self.transcript.active_cell.as_mut() {
|
||||
Some(active) => active.append_reasoning(cell),
|
||||
None => Err(cell),
|
||||
|
||||
@@ -252,6 +252,8 @@ mod copy_export_picker_tests;
|
||||
mod exec_flow;
|
||||
mod goal_menu;
|
||||
mod goal_validation;
|
||||
#[path = "tests/group_projection_tests.rs"]
|
||||
mod group_projection_tests;
|
||||
mod guardian;
|
||||
pub(crate) mod helpers;
|
||||
mod history_replay;
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
//! Persisted reasoning matches live replay in compact and detailed presentations.
|
||||
|
||||
use super::*;
|
||||
use crate::thread_transcript::RawReasoningVisibility;
|
||||
use crate::thread_transcript::thread_items_to_transcript_cells;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[tokio::test]
|
||||
async fn snapshot_formatter_reasoning_matches_legacy_and_detailed_replay() {
|
||||
let mut snapshots = Vec::new();
|
||||
for visibility in [
|
||||
RawReasoningVisibility::Hidden,
|
||||
RawReasoningVisibility::Visible,
|
||||
] {
|
||||
let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await;
|
||||
drain_insert_history(&mut rx);
|
||||
chat.config.show_raw_agent_reasoning = visibility == RawReasoningVisibility::Visible;
|
||||
let item = AppServerThreadItem::Reasoning {
|
||||
id: "reasoning".to_string(),
|
||||
summary: vec.".to_string(),
|
||||
"**Checking tests**\n\n<!-- -->".to_string(),
|
||||
],
|
||||
content: vec!["Raw detail.".to_string()],
|
||||
};
|
||||
let projected = thread_items_to_transcript_cells(
|
||||
chat.thread_id,
|
||||
&chat.config.cwd,
|
||||
[item.clone()],
|
||||
visibility,
|
||||
Some(&chat.config),
|
||||
);
|
||||
chat.replay_thread_item(item, "turn".to_string(), ReplayKind::ThreadSnapshot);
|
||||
let replayed: Vec<Box<dyn HistoryCell>> = std::iter::from_fn(|| rx.try_recv().ok())
|
||||
.filter_map(|event| match event {
|
||||
AppEvent::InsertHistoryCell(cell) => Some(cell),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!((projected.len(), replayed.len()), (1, 1));
|
||||
assert!(
|
||||
projected[0]
|
||||
.display_hyperlink_lines(/*width*/ 80)
|
||||
.is_empty()
|
||||
);
|
||||
assert!(replayed[0].display_hyperlink_lines(/*width*/ 80).is_empty());
|
||||
for width in [28, 80] {
|
||||
assert_eq!(
|
||||
projected[0].transcript_hyperlink_lines(width),
|
||||
replayed[0].transcript_hyperlink_lines(width)
|
||||
);
|
||||
}
|
||||
snapshots.push(format!(
|
||||
"{visibility:?}\nlegacy: {:?}\ndetailed:\n{}",
|
||||
projected[0].display_lines(/*width*/ 80),
|
||||
lines_to_single_string(&projected[0].transcript_lines(/*width*/ 80)),
|
||||
));
|
||||
}
|
||||
insta::assert_snapshot!(snapshots.join("\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_reasoning_keeps_its_own_heading() {
|
||||
let projected = thread_items_to_transcript_cells(
|
||||
/*thread_id*/ None,
|
||||
&codex_utils_absolute_path::AbsolutePathBuf::current_dir().unwrap(),
|
||||
[AppServerThreadItem::Reasoning {
|
||||
id: "raw".into(),
|
||||
summary: Vec::new(),
|
||||
content: vec!["**Raw investigation**\nKeep this heading and its details.".into()],
|
||||
}],
|
||||
RawReasoningVisibility::Visible,
|
||||
/*config*/ None,
|
||||
);
|
||||
insta::assert_snapshot!(lines_to_single_string(&projected[0].transcript_lines(/*width*/ 80)), @"
|
||||
• Raw investigation
|
||||
Keep this heading and its details.
|
||||
");
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
---
|
||||
source: tui/src/chatwidget/tests/group_projection_tests.rs
|
||||
expression: "snapshots.join(\"\\n\")"
|
||||
---
|
||||
Hidden
|
||||
legacy: []
|
||||
detailed:
|
||||
• Read src/main.rs (src/main.rs:3).
|
||||
|
||||
Visible
|
||||
legacy: []
|
||||
detailed:
|
||||
• Read src/main.rs (src/main.rs:3).
|
||||
|
||||
Raw detail.
|
||||
@@ -115,6 +115,20 @@ impl ExecCell {
|
||||
}
|
||||
}
|
||||
|
||||
/// Historical grouping uses the same compatibility and failure boundary as live calls.
|
||||
pub(crate) fn append_completed(&mut self, mut newer: Self) -> Result<(), Self> {
|
||||
if !self.is_exploring_cell() || self.should_flush() || !newer.is_exploring_cell() {
|
||||
return Err(newer);
|
||||
}
|
||||
newer.group.details.prepend(
|
||||
std::mem::take(&mut self.group.details),
|
||||
self.group.calls.len(),
|
||||
);
|
||||
self.group.details = newer.group.details;
|
||||
self.group.calls.append(&mut newer.group.calls);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Preserve live clocks and pending output when a validated older page extends exploration.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn prepend(&mut self, older: Self) {
|
||||
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
---
|
||||
source: tui/src/exec_cell/transcript_tests.rs
|
||||
assertion_line: 73
|
||||
expression: "format!(\"Rich\\n{rich}\\n\\nRaw\\n{}\", raw.join(\"\\n\"))"
|
||||
---
|
||||
Rich
|
||||
$ cat first.txt
|
||||
first output
|
||||
✓ • 5ms
|
||||
|
||||
• Inspecting the next file
|
||||
|
||||
↳ Interacted with background terminal · cat first.txt
|
||||
└ continue
|
||||
|
||||
$ cat second.txt
|
||||
second output
|
||||
✓ • 5ms
|
||||
|
||||
Raw
|
||||
$ cat first.txt
|
||||
first output
|
||||
✓ • 5ms
|
||||
Interacted with background terminal: cat first.txt
|
||||
continue
|
||||
|
||||
$ cat second.txt
|
||||
second output
|
||||
✓ • 5ms
|
||||
@@ -69,3 +69,7 @@ impl ExecCell {
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "transcript_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
//! Grouped command details retain terminal input in raw output without exposing hidden reasoning.
|
||||
|
||||
use super::*;
|
||||
use crate::exec_cell::CommandOutput;
|
||||
use crate::exec_cell::model::ExecCall;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::new_reasoning_summary_block;
|
||||
use crate::history_cell::new_unified_exec_interaction;
|
||||
use codex_app_server_protocol::CommandExecutionSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
fn completed_read(name: &str, output: &str) -> ExecCell {
|
||||
let command = vec!["cat".to_owned(), name.to_owned()];
|
||||
let parsed = codex_shell_command::parse_command::parse_command(&command);
|
||||
ExecCell::new(
|
||||
ExecCall {
|
||||
call_id: name.to_owned(),
|
||||
command,
|
||||
parsed,
|
||||
output: Some(CommandOutput::new(/*exit_code*/ 0, output.to_owned())),
|
||||
source: CommandExecutionSource::UnifiedExecStartup,
|
||||
start_time: None,
|
||||
duration: Some(Duration::from_millis(/*millis*/ 5)),
|
||||
interaction_input: None,
|
||||
},
|
||||
/*animations_enabled*/ false,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn raw_grouped_history_retains_terminal_input_and_omits_reasoning() {
|
||||
let mut group = completed_read("first.txt", "first output");
|
||||
group
|
||||
.group
|
||||
.push_detail(Arc::from(new_reasoning_summary_block(
|
||||
vec!["Inspecting the next file".to_owned()],
|
||||
Path::new("."),
|
||||
) as Box<dyn HistoryCell>));
|
||||
group
|
||||
.group
|
||||
.push_detail(Arc::new(new_unified_exec_interaction(
|
||||
Some("cat first.txt".to_owned()),
|
||||
"continue\n".to_owned(),
|
||||
)));
|
||||
group
|
||||
.append_completed(completed_read("second.txt", "second output"))
|
||||
.expect("adjacent reads stay grouped");
|
||||
|
||||
let raw = group
|
||||
.raw_lines()
|
||||
.iter()
|
||||
.map(Line::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let rich = group
|
||||
.transcript_lines(/*width*/ 80)
|
||||
.iter()
|
||||
.map(Line::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!(format!("Rich\n{rich}\n\nRaw\n{}", raw.join("\n")));
|
||||
|
||||
let mut earlier = completed_read("earlier.txt", "earlier output");
|
||||
earlier
|
||||
.group
|
||||
.push_detail(Arc::from(new_reasoning_summary_block(
|
||||
vec!["Inspect the first file".to_owned()],
|
||||
Path::new("."),
|
||||
) as Box<dyn HistoryCell>));
|
||||
earlier
|
||||
.group
|
||||
.push_detail(Arc::new(new_unified_exec_interaction(
|
||||
Some("cat earlier.txt".to_owned()),
|
||||
"next\n".to_owned(),
|
||||
)));
|
||||
let mut expected_raw = earlier.raw_lines();
|
||||
expected_raw.push(Line::from(""));
|
||||
expected_raw.extend(group.raw_lines());
|
||||
let mut expected_rich = earlier.transcript_hyperlink_lines(/*width*/ 80);
|
||||
expected_rich.push(HyperlinkLine::from(""));
|
||||
expected_rich.extend(group.transcript_hyperlink_lines(/*width*/ 80));
|
||||
group.prepend(earlier);
|
||||
assert_eq!(group.raw_lines(), expected_raw);
|
||||
assert_eq!(
|
||||
group.transcript_hyperlink_lines(/*width*/ 80),
|
||||
expected_rich
|
||||
);
|
||||
}
|
||||
@@ -270,10 +270,10 @@ impl McpToolCallCell {
|
||||
continue;
|
||||
};
|
||||
for segment in text.lines() {
|
||||
let line = Line::from(segment.to_owned().dim());
|
||||
if mode == McpToolCallRenderMode::Display {
|
||||
preview.push_line(line);
|
||||
preview.push_line(Line::from(segment.dim()));
|
||||
} else {
|
||||
let line = Line::from(segment.to_owned().dim());
|
||||
lines.extend(adaptive_wrap_hyperlink_lines(
|
||||
&[HyperlinkLine::new(line)],
|
||||
options.clone(),
|
||||
@@ -285,10 +285,10 @@ impl McpToolCallCell {
|
||||
Some(Err(error)) => {
|
||||
let text = format!("Error: {error}");
|
||||
for segment in text.lines() {
|
||||
let line = Line::from(segment.to_owned().dim());
|
||||
if mode == McpToolCallRenderMode::Display {
|
||||
preview.push_line(line);
|
||||
preview.push_line(Line::from(segment.dim()));
|
||||
} else {
|
||||
let line = Line::from(segment.to_owned().dim());
|
||||
lines.extend(adaptive_wrap_hyperlink_lines(
|
||||
&[HyperlinkLine::new(line)],
|
||||
options.clone(),
|
||||
|
||||
@@ -318,9 +318,30 @@ pub(crate) struct ReasoningSummaryCell {
|
||||
/// Session cwd used to render local file links inside the reasoning body.
|
||||
cwd: PathBuf,
|
||||
transcript_only: bool,
|
||||
/// Persisted identity verifies page folds without comparing rendered reasoning text.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
source_item_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ReasoningSummaryCell {
|
||||
pub(crate) fn markdown_source(&self) -> &str {
|
||||
&self.content
|
||||
}
|
||||
|
||||
pub(crate) fn set_source_item_id(&mut self, id: String) {
|
||||
self.source_item_id = Some(id);
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn source_item_id(&self) -> Option<&str> {
|
||||
self.source_item_id.as_deref()
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn is_transcript_only(&self) -> bool {
|
||||
self.transcript_only
|
||||
}
|
||||
|
||||
/// Create a reasoning summary cell that will render local file links relative to the session
|
||||
/// cwd active when the summary was recorded.
|
||||
pub(crate) fn new(header: String, content: String, cwd: &Path, transcript_only: bool) -> Self {
|
||||
@@ -329,31 +350,34 @@ impl ReasoningSummaryCell {
|
||||
content,
|
||||
cwd: cwd.to_path_buf(),
|
||||
transcript_only,
|
||||
source_item_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
append_markdown(
|
||||
fn lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
let lines = crate::markdown_render::render_markdown_lines_with_width_and_cwd(
|
||||
&self.content,
|
||||
crate::width::usable_content_width_u16(width, /*reserved_cols*/ 2),
|
||||
Some(self.cwd.as_path()),
|
||||
&mut lines,
|
||||
);
|
||||
let summary_style = Style::default().dim().italic();
|
||||
let summary_lines = lines
|
||||
.into_iter()
|
||||
.map(|mut line| {
|
||||
line.spans = line
|
||||
line.line.spans = line
|
||||
.line
|
||||
.spans
|
||||
.into_iter()
|
||||
.map(|span| span.patch_style(summary_style))
|
||||
.collect();
|
||||
if let Some(source) = &mut line.source {
|
||||
source.span_style = source.span_style.patch(summary_style);
|
||||
}
|
||||
line
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
adaptive_wrap_lines(
|
||||
crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(
|
||||
&summary_lines,
|
||||
RtOptions::new(width as usize)
|
||||
.initial_indent("• ".dim().into())
|
||||
@@ -364,6 +388,10 @@ impl ReasoningSummaryCell {
|
||||
|
||||
impl HistoryCell for ReasoningSummaryCell {
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.display_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
if self.transcript_only {
|
||||
Vec::new()
|
||||
} else {
|
||||
@@ -372,6 +400,10 @@ impl HistoryCell for ReasoningSummaryCell {
|
||||
}
|
||||
|
||||
fn transcript_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.transcript_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
self.lines(width)
|
||||
}
|
||||
|
||||
@@ -379,7 +411,7 @@ impl HistoryCell for ReasoningSummaryCell {
|
||||
if self.transcript_only {
|
||||
Vec::new()
|
||||
} else {
|
||||
raw_lines_from_source(self.content.trim())
|
||||
raw_lines_from_source(self.markdown_source().trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -686,7 +718,7 @@ pub(crate) fn new_spoken_user_prompt(message: String) -> UserHistoryCell {
|
||||
pub(crate) fn new_reasoning_summary_block(
|
||||
reasoning_parts: Vec<String>,
|
||||
cwd: &Path,
|
||||
) -> Box<dyn HistoryCell> {
|
||||
) -> Box<ReasoningSummaryCell> {
|
||||
let (header, content) = split_reasoning_summary_parts(&reasoning_parts);
|
||||
Box::new(ReasoningSummaryCell::new(
|
||||
header, content, cwd, /*transcript_only*/ true,
|
||||
|
||||
@@ -6756,7 +6756,7 @@ session_picker_view = "dense"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thread_to_transcript_cells_shows_raw_reasoning_over_summary_when_enabled() {
|
||||
fn thread_to_transcript_cells_retains_summary_and_raw_reasoning_when_enabled() {
|
||||
use crate::thread_transcript::thread_to_transcript_cells;
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
@@ -6819,8 +6819,7 @@ session_picker_view = "dense"
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
assert!(rendered.contains("raw reasoning content"));
|
||||
assert!(!rendered.contains("public summary"));
|
||||
assert_eq!(rendered, "• public summary\n \n raw reasoning content");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
//! Project individual persisted items into rich history cells without changing live state.
|
||||
//! Project persisted items into the same rich cells as completed live output.
|
||||
//! Tool grouping spans hidden reasoning but stops at visible content and turn boundaries.
|
||||
//! Only one computer or exploration group can be pending at a time.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -22,11 +24,61 @@ use codex_protocol::items::UserMessageItem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use ratatui::style::Stylize as _;
|
||||
|
||||
mod activity_pages;
|
||||
mod computer_groups;
|
||||
mod exploration_groups;
|
||||
mod other_items;
|
||||
pub(crate) mod tools;
|
||||
|
||||
#[allow(
|
||||
unused_imports,
|
||||
reason = "Used by later layers of the TUI refresh stack."
|
||||
)]
|
||||
pub(crate) use activity_pages::fold_trailing_activity_details;
|
||||
#[allow(
|
||||
unused_imports,
|
||||
reason = "Used by later layers of the TUI refresh stack."
|
||||
)]
|
||||
pub(crate) use activity_pages::is_hidden_activity_detail;
|
||||
#[allow(
|
||||
unused_imports,
|
||||
reason = "Used by later layers of the TUI refresh stack."
|
||||
)]
|
||||
pub(crate) use computer_groups::join_computer_groups;
|
||||
#[allow(
|
||||
unused_imports,
|
||||
reason = "Used by later layers of the TUI refresh stack."
|
||||
)]
|
||||
pub(crate) use computer_groups::older_computer_group;
|
||||
#[allow(
|
||||
unused_imports,
|
||||
reason = "Used by later layers of the TUI refresh stack."
|
||||
)]
|
||||
pub(crate) use exploration_groups::join_exploration_groups;
|
||||
#[allow(
|
||||
unused_imports,
|
||||
reason = "Used by later layers of the TUI refresh stack."
|
||||
)]
|
||||
pub(crate) use exploration_groups::older_exploration_group;
|
||||
|
||||
pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;
|
||||
|
||||
enum PendingActivity {
|
||||
Computer(crate::history_cell::ComputerActivityCell),
|
||||
Exploration(crate::exec_cell::ExecCell),
|
||||
}
|
||||
|
||||
impl PendingActivity {
|
||||
fn flush(pending: &mut Option<Self>, cells: &mut TranscriptCells) {
|
||||
let cell: Arc<dyn HistoryCell> = match pending.take() {
|
||||
Some(Self::Computer(group)) => Arc::new(group),
|
||||
Some(Self::Exploration(group)) => Arc::new(group),
|
||||
None => return,
|
||||
};
|
||||
cells.push(cell);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub(crate) enum RawReasoningVisibility {
|
||||
Hidden,
|
||||
@@ -68,13 +120,19 @@ pub(crate) fn thread_to_transcript_cells(
|
||||
) -> TranscriptCells {
|
||||
let cwd = thread.cwd;
|
||||
let thread_id = ThreadId::from_string(&thread.id).ok();
|
||||
let mut cells = thread_items_to_transcript_cells(
|
||||
thread_id,
|
||||
&cwd,
|
||||
thread.turns.into_iter().flat_map(|turn| turn.items),
|
||||
raw_reasoning_visibility,
|
||||
config,
|
||||
);
|
||||
let mut cells = thread
|
||||
.turns
|
||||
.into_iter()
|
||||
.flat_map(|turn| {
|
||||
thread_items_to_transcript_cells(
|
||||
thread_id,
|
||||
&cwd,
|
||||
turn.items,
|
||||
raw_reasoning_visibility,
|
||||
config,
|
||||
)
|
||||
})
|
||||
.collect::<TranscriptCells>();
|
||||
if cells.is_empty() {
|
||||
cells.push(Arc::new(PlainHistoryCell::new(vec![
|
||||
"No transcript content available".italic().dim().into(),
|
||||
@@ -94,18 +152,89 @@ pub(crate) fn thread_items_to_transcript_cells(
|
||||
thread_id.and_then(|thread_id| InlineVisualizationContext::from_config(config, thread_id))
|
||||
});
|
||||
let mut cells: TranscriptCells = Vec::new();
|
||||
let mut pending = None;
|
||||
for item in items {
|
||||
if let Some(cell) = tools::historical_tool_fallback(&item) {
|
||||
cells.push(Arc::new(cell));
|
||||
} else {
|
||||
cells.extend(item_to_cells(
|
||||
if matches!(item, ThreadItem::Reasoning { .. })
|
||||
&& let Some(group) = &mut pending
|
||||
{
|
||||
for cell in item_to_cells(
|
||||
item,
|
||||
cwd,
|
||||
raw_reasoning_visibility,
|
||||
inline_visualization_context.clone(),
|
||||
));
|
||||
) {
|
||||
match group {
|
||||
PendingActivity::Computer(group) => group.group.push_detail(cell),
|
||||
PendingActivity::Exploration(group) => group.group.push_detail(cell),
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if let Some(cell) = tools::historical_tool_fallback(&item) {
|
||||
PendingActivity::flush(&mut pending, &mut cells);
|
||||
cells.push(Arc::new(cell));
|
||||
continue;
|
||||
}
|
||||
match item {
|
||||
item @ ThreadItem::McpToolCall { .. } => {
|
||||
let Some(call) = tools::McpHistory::from_item(item) else {
|
||||
PendingActivity::flush(&mut pending, &mut cells);
|
||||
continue;
|
||||
};
|
||||
if call.invocation.is_computer_activity() {
|
||||
match &mut pending {
|
||||
Some(PendingActivity::Computer(group)) => {
|
||||
computer_groups::append(group, call);
|
||||
}
|
||||
Some(PendingActivity::Exploration(_)) | None => {
|
||||
PendingActivity::flush(&mut pending, &mut cells);
|
||||
let mut group = crate::history_cell::ComputerActivityCell::default();
|
||||
computer_groups::append(&mut group, call);
|
||||
pending = Some(PendingActivity::Computer(group));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
PendingActivity::flush(&mut pending, &mut cells);
|
||||
let cell = call.into_cell();
|
||||
cells.push(Arc::new(cell));
|
||||
}
|
||||
}
|
||||
item @ ThreadItem::CommandExecution { .. } => {
|
||||
if matches!(pending, Some(PendingActivity::Computer(_))) {
|
||||
PendingActivity::flush(&mut pending, &mut cells);
|
||||
}
|
||||
if let Some(command) = tools::CommandHistory::from_item(item) {
|
||||
let newer = command.into_cell();
|
||||
if let Some(PendingActivity::Exploration(group)) = &mut pending {
|
||||
if let Err(newer) = group.append_completed(newer) {
|
||||
PendingActivity::flush(&mut pending, &mut cells);
|
||||
pending = Some(PendingActivity::Exploration(newer));
|
||||
}
|
||||
} else {
|
||||
pending = Some(PendingActivity::Exploration(newer));
|
||||
}
|
||||
if let Some(PendingActivity::Exploration(group)) = &pending
|
||||
&& group.should_flush()
|
||||
{
|
||||
PendingActivity::flush(&mut pending, &mut cells);
|
||||
}
|
||||
}
|
||||
}
|
||||
item => {
|
||||
let projected = item_to_cells(
|
||||
item,
|
||||
cwd,
|
||||
raw_reasoning_visibility,
|
||||
inline_visualization_context.clone(),
|
||||
);
|
||||
if !projected.is_empty() {
|
||||
PendingActivity::flush(&mut pending, &mut cells);
|
||||
cells.extend(projected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PendingActivity::flush(&mut pending, &mut cells);
|
||||
cells
|
||||
}
|
||||
|
||||
@@ -196,23 +325,28 @@ fn item_to_cells(
|
||||
}
|
||||
}
|
||||
ThreadItem::Reasoning {
|
||||
summary, content, ..
|
||||
id,
|
||||
summary,
|
||||
content,
|
||||
} => {
|
||||
let (header, text) =
|
||||
if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
|
||||
&& !content.is_empty()
|
||||
{
|
||||
("Reasoning".to_string(), content.join("\n\n"))
|
||||
} else {
|
||||
split_reasoning_summary_parts(&summary)
|
||||
};
|
||||
let (header, mut text) = split_reasoning_summary_parts(&summary);
|
||||
if matches!(raw_reasoning_visibility, RawReasoningVisibility::Visible)
|
||||
&& !content.is_empty()
|
||||
{
|
||||
if !text.is_empty() {
|
||||
text.push_str("\n\n");
|
||||
}
|
||||
text.push_str(&content.join("\n\n"));
|
||||
}
|
||||
if !text.trim().is_empty() {
|
||||
cells.push(Arc::new(ReasoningSummaryCell::new(
|
||||
let mut cell = ReasoningSummaryCell::new(
|
||||
header,
|
||||
text,
|
||||
cwd.as_path(),
|
||||
/*transcript_only*/ false,
|
||||
)));
|
||||
/*transcript_only*/ true,
|
||||
);
|
||||
cell.set_source_item_id(id);
|
||||
cells.push(Arc::new(cell));
|
||||
}
|
||||
}
|
||||
item @ ThreadItem::CommandExecution { .. } => {
|
||||
@@ -220,11 +354,6 @@ fn item_to_cells(
|
||||
cells.push(Arc::new(command.into_cell()));
|
||||
}
|
||||
}
|
||||
item @ ThreadItem::McpToolCall { .. } => {
|
||||
if let Some(call) = tools::McpHistory::from_item(item) {
|
||||
cells.push(Arc::new(call.into_cell()));
|
||||
}
|
||||
}
|
||||
other => cells.extend(other_items::cells(other, cwd)),
|
||||
}
|
||||
cells
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Attach transparent page-boundary details to a freshly rebuilt activity group.
|
||||
|
||||
use crate::exec_cell::ExecCell;
|
||||
use crate::history_cell::ComputerActivityCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::ReasoningSummaryCell;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn is_hidden_activity_detail(cell: &Arc<dyn HistoryCell>) -> bool {
|
||||
cell.as_any()
|
||||
.downcast_ref::<ReasoningSummaryCell>()
|
||||
.is_some_and(ReasoningSummaryCell::is_transcript_only)
|
||||
}
|
||||
|
||||
/// Fold standalone reasoning using exact persisted identities from one turn.
|
||||
///
|
||||
/// An older page can arrive after the final reasoning page even with no newer group or live tail.
|
||||
/// Rebuild only known completed calls and share their immutable details; never infer adjacency
|
||||
/// from identical reasoning text or attach a detail from the next turn.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn fold_trailing_activity_details(
|
||||
older: &Arc<dyn HistoryCell>,
|
||||
details: &[Arc<dyn HistoryCell>],
|
||||
turns: &[codex_app_server_protocol::Turn],
|
||||
) -> Option<Arc<dyn HistoryCell>> {
|
||||
let (mut ids, retained) =
|
||||
if let Some(group) = older.as_any().downcast_ref::<ComputerActivityCell>() {
|
||||
(group.call_ids().collect::<Vec<_>>(), &group.group.details)
|
||||
} else {
|
||||
let group = older.as_any().downcast_ref::<ExecCell>()?;
|
||||
if !group.is_exploring_cell() || group.should_flush() {
|
||||
return None;
|
||||
}
|
||||
(
|
||||
group
|
||||
.iter_calls()
|
||||
.map(|call| call.call_id.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
&group.group.details,
|
||||
)
|
||||
};
|
||||
let count = ids.len();
|
||||
for detail in details {
|
||||
let detail = detail.as_any().downcast_ref::<ReasoningSummaryCell>()?;
|
||||
if !detail.is_transcript_only() {
|
||||
return None;
|
||||
}
|
||||
ids.push(detail.source_item_id()?);
|
||||
}
|
||||
let items = super::computer_groups::adjacent_activity_items(&ids, turns)?;
|
||||
let mut retained = retained.clone();
|
||||
for detail in details {
|
||||
retained.push(count, Arc::clone(detail));
|
||||
}
|
||||
if older.as_any().is::<ComputerActivityCell>() {
|
||||
let mut group = super::computer_groups::completed_group(items)?;
|
||||
group.group.details = retained;
|
||||
Some(Arc::new(group))
|
||||
} else {
|
||||
let mut group = super::exploration_groups::completed_group(items)?;
|
||||
group.group.details = retained;
|
||||
Some(Arc::new(group))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Assemble computer activity across hidden reasoning and repair history-page group boundaries.
|
||||
|
||||
use super::tools::McpHistory;
|
||||
use crate::history_cell::ComputerActivityCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::history_cell::McpToolCallCell;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn append(group: &mut ComputerActivityCell, call: McpHistory) {
|
||||
let cell = McpToolCallCell::new(call.id, call.invocation, /*animations_enabled*/ false);
|
||||
group.complete(cell, call.duration, call.result);
|
||||
}
|
||||
|
||||
/// Rebuild only an adjacent computer group, using its persisted IDs to verify the turn boundary.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn join_computer_groups(
|
||||
older: &Arc<dyn HistoryCell>,
|
||||
newer: &Arc<dyn HistoryCell>,
|
||||
turns: &[Turn],
|
||||
) -> Option<Arc<dyn HistoryCell>> {
|
||||
let older = older.as_any().downcast_ref::<ComputerActivityCell>()?;
|
||||
let newer = newer.as_any().downcast_ref::<ComputerActivityCell>()?;
|
||||
let mut group = completed_group(adjacent_items(older, newer, turns)?)?;
|
||||
group.group.details = newer.group.details.clone();
|
||||
group
|
||||
.group
|
||||
.details
|
||||
.prepend(older.group.details.clone(), older.call_ids().count());
|
||||
Some(Arc::new(group))
|
||||
}
|
||||
|
||||
/// Restore only the older half; the caller retains the live half and its pending calls/timers.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn older_computer_group(
|
||||
older: &dyn HistoryCell,
|
||||
newer: &ComputerActivityCell,
|
||||
turns: &[Turn],
|
||||
) -> Option<ComputerActivityCell> {
|
||||
let older = older.as_any().downcast_ref::<ComputerActivityCell>()?;
|
||||
let items = adjacent_items(older, newer, turns)?;
|
||||
let older_items = items
|
||||
.iter()
|
||||
.filter(|item| !matches!(item, ThreadItem::Reasoning { .. }))
|
||||
.take(older.call_ids().count())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut group = completed_group(&older_items)?;
|
||||
group.group.details = older.group.details.clone();
|
||||
Some(group)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
fn adjacent_items<'a>(
|
||||
older: &ComputerActivityCell,
|
||||
newer: &ComputerActivityCell,
|
||||
turns: &'a [Turn],
|
||||
) -> Option<&'a [ThreadItem]> {
|
||||
let ids = older.call_ids().chain(newer.call_ids()).collect::<Vec<_>>();
|
||||
adjacent_activity_items(&ids, turns)
|
||||
}
|
||||
|
||||
/// Validate source adjacency without mistaking invisible reasoning for an activity boundary.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(super) fn adjacent_activity_items<'a>(
|
||||
ids: &[&str],
|
||||
turns: &'a [Turn],
|
||||
) -> Option<&'a [ThreadItem]> {
|
||||
let first = *ids.first()?;
|
||||
let (turn, start) = turns.iter().find_map(|turn| {
|
||||
turn.items
|
||||
.iter()
|
||||
.position(|item| item.id() == first)
|
||||
.map(|start| (turn, start))
|
||||
})?;
|
||||
let mut matched = 0;
|
||||
for (offset, item) in turn.items[start..].iter().enumerate() {
|
||||
if item.id() != ids[matched] {
|
||||
if matches!(item, ThreadItem::Reasoning { .. }) {
|
||||
continue;
|
||||
}
|
||||
return None;
|
||||
}
|
||||
matched += 1;
|
||||
if matched == ids.len() {
|
||||
return Some(&turn.items[start..=start + offset]);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(super) fn completed_group(items: &[ThreadItem]) -> Option<ComputerActivityCell> {
|
||||
let mut group = None;
|
||||
for item in items {
|
||||
if matches!(item, ThreadItem::Reasoning { .. }) {
|
||||
continue;
|
||||
}
|
||||
let call = McpHistory::from_item(item.clone())?;
|
||||
if !call.invocation.is_computer_activity() {
|
||||
return None;
|
||||
}
|
||||
append(group.get_or_insert_default(), call);
|
||||
}
|
||||
group
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "computer_groups_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,173 @@
|
||||
//! Page boundaries must not change the compact or expanded rendering of computer activity.
|
||||
|
||||
use super::*;
|
||||
use crate::thread_transcript::RawReasoningVisibility;
|
||||
use crate::thread_transcript::TranscriptCells;
|
||||
use crate::thread_transcript::thread_items_to_transcript_cells;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::TurnItemsView;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn computer(id: &str) -> ThreadItem {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"type": "mcpToolCall", "id": id, "server": "cua_repl", "tool": "js",
|
||||
"status": "completed", "arguments": {"title": format!("Inspect {id}"), "code": "await cua.getState()"},
|
||||
"appContext": null, "pluginId": null, "readOnlyHint": null,
|
||||
"result": {"content": [{"type": "text", "text": format!("Result {id}")}], "structuredContent": null},
|
||||
"error": null, "durationMs": 10
|
||||
})).unwrap()
|
||||
}
|
||||
|
||||
fn turn(id: &str, items: Vec<ThreadItem>) -> Turn {
|
||||
Turn {
|
||||
id: id.to_string(),
|
||||
items,
|
||||
items_view: TurnItemsView::Full,
|
||||
status: TurnStatus::Completed,
|
||||
error: None,
|
||||
started_at: None,
|
||||
completed_at: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn project(items: &[ThreadItem]) -> TranscriptCells {
|
||||
thread_items_to_transcript_cells(
|
||||
/*thread_id*/ None,
|
||||
&AbsolutePathBuf::current_dir().unwrap(),
|
||||
items.to_vec(),
|
||||
RawReasoningVisibility::Hidden,
|
||||
/*config*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_page_split_retains_the_same_rich_computer_group() {
|
||||
let turns = vec![turn(
|
||||
"turn",
|
||||
vec![
|
||||
computer("first"),
|
||||
ThreadItem::Reasoning {
|
||||
id: "reasoning-first".to_owned(),
|
||||
summary: vec!["Inspect the second screen".to_owned()],
|
||||
content: Vec::new(),
|
||||
},
|
||||
computer("second"),
|
||||
computer("third"),
|
||||
ThreadItem::Reasoning {
|
||||
id: "reasoning-second".to_owned(),
|
||||
summary: vec!["Confirm the final screen".to_owned()],
|
||||
content: Vec::new(),
|
||||
},
|
||||
computer("last"),
|
||||
],
|
||||
)];
|
||||
let items = &turns[0].items;
|
||||
let expected = project(items);
|
||||
for split in 1..items.len() {
|
||||
let older = project(&items[..split]);
|
||||
let newer = project(&items[split..]);
|
||||
let detail_count = newer
|
||||
.iter()
|
||||
.take_while(|cell| {
|
||||
crate::thread_transcript::activity_pages::is_hidden_activity_detail(cell)
|
||||
})
|
||||
.count();
|
||||
let older_group = if detail_count > 0 {
|
||||
crate::thread_transcript::activity_pages::fold_trailing_activity_details(
|
||||
&older[0],
|
||||
&newer[..detail_count],
|
||||
&turns,
|
||||
)
|
||||
.unwrap()
|
||||
} else {
|
||||
Arc::clone(&older[0])
|
||||
};
|
||||
let joined = join_computer_groups(&older_group, &newer[detail_count], &turns).unwrap();
|
||||
assert_eq!(
|
||||
joined.display_hyperlink_lines(/*width*/ 30),
|
||||
expected[0].display_hyperlink_lines(/*width*/ 30)
|
||||
);
|
||||
assert_eq!(
|
||||
joined.transcript_hyperlink_lines(/*width*/ 30),
|
||||
expected[0].transcript_hyperlink_lines(/*width*/ 30)
|
||||
);
|
||||
assert_eq!(joined.raw_lines(), expected[0].raw_lines());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn successive_single_item_pages_reunite_without_duplicating_calls() {
|
||||
let turns = vec![turn(
|
||||
"turn",
|
||||
(0..6).map(|i| computer(&i.to_string())).collect(),
|
||||
)];
|
||||
let items = &turns[0].items;
|
||||
let mut joined = project(&items[5..]).remove(/*index*/ 0);
|
||||
for item in items[..5].iter().rev() {
|
||||
let older = project(std::slice::from_ref(item));
|
||||
joined = join_computer_groups(&older[0], &joined, &turns).unwrap();
|
||||
}
|
||||
let expected = project(items);
|
||||
assert_eq!(
|
||||
joined.transcript_lines(/*width*/ 80),
|
||||
expected[0].transcript_lines(/*width*/ 80)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn joins_respect_actual_turns_and_intervening_items() {
|
||||
let first = computer("first");
|
||||
let last = computer("last");
|
||||
let older = project(std::slice::from_ref(&first));
|
||||
let newer = project(std::slice::from_ref(&last));
|
||||
let turns = vec![
|
||||
turn("first-turn", vec![first.clone()]),
|
||||
turn("last-turn", vec![last.clone()]),
|
||||
];
|
||||
assert!(join_computer_groups(&older[0], &newer[0], &turns).is_none());
|
||||
let grouped = project(&[
|
||||
first.clone(),
|
||||
ThreadItem::Sleep(codex_app_server_protocol::SleepItem {
|
||||
id: "sleep".into(),
|
||||
duration_ms: 10,
|
||||
}),
|
||||
last.clone(),
|
||||
]);
|
||||
let adjacent = project(&[first.clone(), last.clone()]);
|
||||
assert_eq!(grouped.len(), 1);
|
||||
assert_eq!(
|
||||
grouped[0].transcript_lines(/*width*/ 80),
|
||||
adjacent[0].transcript_lines(/*width*/ 80)
|
||||
);
|
||||
let mut turns = vec![turn(
|
||||
"turn",
|
||||
vec![
|
||||
first,
|
||||
ThreadItem::Reasoning {
|
||||
id: "reasoning".to_string(),
|
||||
summary: Vec::new(),
|
||||
content: Vec::new(),
|
||||
},
|
||||
last,
|
||||
],
|
||||
)];
|
||||
let joined = join_computer_groups(&older[0], &newer[0], &turns)
|
||||
.expect("hidden reasoning retains the adjacent computer group");
|
||||
let expected = project(&turns[0].items);
|
||||
assert_eq!(
|
||||
joined.transcript_lines(/*width*/ 80),
|
||||
expected[0].transcript_lines(/*width*/ 80),
|
||||
);
|
||||
turns[0].items[1] = ThreadItem::AgentMessage {
|
||||
id: "message".to_string(),
|
||||
text: "Visible boundary".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
delivery: None,
|
||||
questions: None,
|
||||
};
|
||||
assert!(join_computer_groups(&older[0], &newer[0], &turns).is_none());
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
//! Apply live exploration compatibility to replay and repair groups split by history pages.
|
||||
|
||||
use super::tools::CommandHistory;
|
||||
use crate::exec_cell::ExecCell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::Turn;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn join_exploration_groups(
|
||||
older: &Arc<dyn HistoryCell>,
|
||||
newer: &Arc<dyn HistoryCell>,
|
||||
turns: &[Turn],
|
||||
) -> Option<Arc<dyn HistoryCell>> {
|
||||
let older = older.as_any().downcast_ref::<ExecCell>()?;
|
||||
let newer = newer.as_any().downcast_ref::<ExecCell>()?;
|
||||
let items = adjacent_items(older, newer, turns)?;
|
||||
let mut group = completed_group(items)?;
|
||||
group.group.details = newer.group.details.clone();
|
||||
group
|
||||
.group
|
||||
.details
|
||||
.prepend(older.group.details.clone(), older.group.calls.len());
|
||||
Some(Arc::new(group))
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn older_exploration_group(
|
||||
older: &dyn HistoryCell,
|
||||
newer: &ExecCell,
|
||||
turns: &[Turn],
|
||||
) -> Option<ExecCell> {
|
||||
let older = older.as_any().downcast_ref::<ExecCell>()?;
|
||||
let items = adjacent_items(older, newer, turns)?;
|
||||
let older_items = items
|
||||
.iter()
|
||||
.filter(|item| !matches!(item, ThreadItem::Reasoning { .. }))
|
||||
.take(older.group.calls.len())
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let mut group = completed_group(&older_items)?;
|
||||
group.group.details = older.group.details.clone();
|
||||
Some(group)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
fn adjacent_items<'a>(
|
||||
older: &ExecCell,
|
||||
newer: &ExecCell,
|
||||
turns: &'a [Turn],
|
||||
) -> Option<&'a [ThreadItem]> {
|
||||
if !older.is_exploring_cell() || older.should_flush() || !newer.is_exploring_cell() {
|
||||
return None;
|
||||
}
|
||||
let ids = older
|
||||
.iter_calls()
|
||||
.chain(newer.iter_calls())
|
||||
.map(|call| call.call_id.as_str())
|
||||
.collect::<Vec<_>>();
|
||||
super::computer_groups::adjacent_activity_items(&ids, turns)
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(super) fn completed_group(items: &[ThreadItem]) -> Option<ExecCell> {
|
||||
let mut group: Option<ExecCell> = None;
|
||||
for item in items {
|
||||
if matches!(item, ThreadItem::Reasoning { .. }) {
|
||||
continue;
|
||||
}
|
||||
let call = CommandHistory::from_item(item.clone())?.into_cell();
|
||||
if !call.is_exploring_cell() {
|
||||
return None;
|
||||
}
|
||||
if let Some(group) = &mut group {
|
||||
group.append_completed(call).ok()?;
|
||||
} else {
|
||||
group = Some(call);
|
||||
}
|
||||
}
|
||||
group
|
||||
}
|
||||
Reference in New Issue
Block a user