mirror of
https://github.com/openai/codex.git
synced 2026-09-20 12:47:38 +00:00
Restore rich tool details in persisted TUI transcripts (#46710)
## Why Loaded transcripts reduce several tool calls and file changes to basic status summaries, losing details available in live history cells. ## What changed - Reuse rich history cells for completed commands, MCP calls, patches, agent activity, image generation, and notices. - Add dynamic tool cells with bounded output previews while retaining arguments and full output in detailed and raw views. - Preserve last-known statuses for incomplete calls, unavailable results, and declined or failed operations. Keep legacy command text and terminal interaction output when rich reconstruction is unavailable. - Remove redundant move annotations from patch diffs and retain patch activity IDs. - Filter untrusted control characters before emitting terminal scrollback, while preserving semantic hyperlinks. ## Testing Add unit and snapshot coverage for compact and detailed tool rendering, status fallbacks, retained output, patch restoration, and control-character filtering with and without hyperlinks. GitOrigin-RevId: 73d754262768cf6d91f3ea4040116bf93cf87784
This commit is contained in:
@@ -30,7 +30,7 @@ pub(crate) fn file_update_changes_to_display(
|
||||
) -> HashMap<PathBuf, FileChange> {
|
||||
changes
|
||||
.into_iter()
|
||||
.map(|change| {
|
||||
.map(|mut change| {
|
||||
let path = PathBuf::from(change.path);
|
||||
let file_change = match change.kind {
|
||||
PatchChangeKind::Add => FileChange::Add {
|
||||
@@ -39,10 +39,19 @@ pub(crate) fn file_update_changes_to_display(
|
||||
PatchChangeKind::Delete => FileChange::Delete {
|
||||
content: change.diff,
|
||||
},
|
||||
PatchChangeKind::Update { move_path } => FileChange::Update {
|
||||
unified_diff: change.diff,
|
||||
move_path,
|
||||
},
|
||||
PatchChangeKind::Update { move_path } => {
|
||||
if let Some(path) = &move_path
|
||||
&& let Some(diff) = change
|
||||
.diff
|
||||
.strip_suffix(&format!("\n\nMoved to: {}", path.display()))
|
||||
{
|
||||
change.diff.truncate(diff.len());
|
||||
}
|
||||
FileChange::Update {
|
||||
unified_diff: change.diff,
|
||||
move_path,
|
||||
}
|
||||
}
|
||||
};
|
||||
(path, file_change)
|
||||
})
|
||||
|
||||
275
codex-rs/tui/src/history_cell/dynamic.rs
Normal file
275
codex-rs/tui/src/history_cell/dynamic.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
//! Dynamic tool activity with retained arguments and output across live and loaded transcripts.
|
||||
|
||||
use super::HistoryCell;
|
||||
use super::activity_preview::DETAIL_PREVIEW_LINES;
|
||||
use super::activity_preview::clipped_line;
|
||||
use super::raw_lines_from_source;
|
||||
use crate::style::accent_color;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines;
|
||||
use crate::terminal_hyperlinks::plain_hyperlink_lines;
|
||||
use crate::terminal_hyperlinks::visible_lines;
|
||||
use crate::wrapping::RtOptions;
|
||||
use codex_app_server_protocol::DynamicToolCallOutputContentItem;
|
||||
use codex_app_server_protocol::DynamicToolCallStatus;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use serde_json::Value;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct DynamicToolCallCell {
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
call_id: String,
|
||||
data: Arc<RwLock<DynamicToolCallData>>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct DynamicToolCallData {
|
||||
name: String,
|
||||
arguments: Value,
|
||||
status: DynamicToolCallStatus,
|
||||
interrupted: bool,
|
||||
output: Option<Vec<String>>,
|
||||
duration_ms: Option<i64>,
|
||||
}
|
||||
|
||||
impl DynamicToolCallCell {
|
||||
pub(crate) fn from_item(item: ThreadItem) -> Option<Self> {
|
||||
let (call_id, data) = DynamicToolCallData::from_item(item)?;
|
||||
Some(Self {
|
||||
call_id,
|
||||
data: Arc::new(RwLock::new(data)),
|
||||
})
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn call_id(&self) -> &str {
|
||||
&self.call_id
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn is_active(&self) -> bool {
|
||||
self.data
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.is_active()
|
||||
}
|
||||
|
||||
/// Updates the original retained row when concurrent calls complete in a different order.
|
||||
/// Callers revising an already-terminal cell must rebuild its cached renderables.
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn update_from_item(&self, item: ThreadItem) -> bool {
|
||||
let Some((call_id, data)) = DynamicToolCallData::from_item(item) else {
|
||||
return false;
|
||||
};
|
||||
if call_id != self.call_id {
|
||||
return false;
|
||||
}
|
||||
*self
|
||||
.data
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = data;
|
||||
true
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
pub(crate) fn mark_interrupted(&self) {
|
||||
let mut data = self
|
||||
.data
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if !data.is_active() {
|
||||
return;
|
||||
}
|
||||
data.status = DynamicToolCallStatus::Failed;
|
||||
data.interrupted = true;
|
||||
data.output
|
||||
.get_or_insert_with(Vec::new)
|
||||
.push("Interrupted before this tool returned a result.".to_owned());
|
||||
}
|
||||
}
|
||||
|
||||
impl DynamicToolCallData {
|
||||
fn from_item(item: ThreadItem) -> Option<(String, Self)> {
|
||||
let ThreadItem::DynamicToolCall {
|
||||
id,
|
||||
namespace,
|
||||
tool,
|
||||
arguments,
|
||||
status,
|
||||
content_items,
|
||||
success,
|
||||
duration_ms,
|
||||
} = item
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
Some((
|
||||
id,
|
||||
Self {
|
||||
name: namespace
|
||||
.map_or_else(|| tool.clone(), |namespace| format!("{namespace}.{tool}")),
|
||||
arguments,
|
||||
status: if success == Some(false) {
|
||||
DynamicToolCallStatus::Failed
|
||||
} else {
|
||||
status
|
||||
},
|
||||
interrupted: false,
|
||||
output: content_items.map(|items| {
|
||||
items
|
||||
.into_iter()
|
||||
.map(|item| match item {
|
||||
DynamicToolCallOutputContentItem::InputText { text } => text,
|
||||
DynamicToolCallOutputContentItem::InputImage { .. } => {
|
||||
"<image content>".to_owned()
|
||||
}
|
||||
DynamicToolCallOutputContentItem::InputAudio { .. } => {
|
||||
"<audio content>".to_owned()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}),
|
||||
duration_ms,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
|
||||
fn is_active(&self) -> bool {
|
||||
matches!(self.status, DynamicToolCallStatus::InProgress)
|
||||
}
|
||||
|
||||
fn header(&self) -> Line<'static> {
|
||||
let (marker, verb) = if self.interrupted {
|
||||
("•".red().bold(), "Interrupted")
|
||||
} else {
|
||||
match self.status {
|
||||
DynamicToolCallStatus::InProgress => ("•".dim(), "Calling"),
|
||||
DynamicToolCallStatus::Completed => ("•".green(), "Called"),
|
||||
DynamicToolCallStatus::Failed => ("•".red().bold(), "Failed"),
|
||||
}
|
||||
};
|
||||
let mut line = Line::from(vec![
|
||||
marker,
|
||||
" ".into(),
|
||||
verb.bold(),
|
||||
" ".into(),
|
||||
self.name.clone().fg(accent_color()),
|
||||
]);
|
||||
if let Some(duration_ms) = self.duration_ms.filter(|duration| *duration >= 0) {
|
||||
line.push_span(format!(" · {duration_ms}ms").dim());
|
||||
}
|
||||
line
|
||||
}
|
||||
|
||||
fn output_lines(&self) -> Vec<Line<'static>> {
|
||||
match &self.output {
|
||||
Some(output) if output.is_empty() => vec![Line::from("(no output)".dim())],
|
||||
Some(output) => output
|
||||
.iter()
|
||||
.flat_map(|text| raw_lines_from_source(text))
|
||||
.collect(),
|
||||
None if self.is_active() => Vec::new(),
|
||||
None => vec![Line::from("(output unavailable)".dim())],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for DynamicToolCallCell {
|
||||
fn has_stable_transcript_height(&self) -> bool {
|
||||
!self.is_active()
|
||||
}
|
||||
|
||||
fn activity_ids(&self) -> Vec<String> {
|
||||
vec![format!("dynamic:{}", self.call_id)]
|
||||
}
|
||||
|
||||
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
visible_lines(self.compact_hyperlink_lines(width))
|
||||
}
|
||||
|
||||
fn compact_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
let data = self
|
||||
.data
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut lines = vec![clipped_line(data.header(), width)];
|
||||
let (skip, output) = match &data.output {
|
||||
Some(output) if !output.is_empty() => {
|
||||
let output = output.iter().flat_map(|text| text.split_terminator('\n'));
|
||||
let skip = output.clone().count().saturating_sub(DETAIL_PREVIEW_LINES);
|
||||
let tail = output
|
||||
.skip(skip)
|
||||
.map(|text| {
|
||||
// Bound allocation and grapheme scanning before clipping to display width.
|
||||
let end = text.floor_char_boundary(16 * 1024);
|
||||
let mut text_preview = text[..end].to_owned();
|
||||
if end < text.len() {
|
||||
text_preview.push('…');
|
||||
}
|
||||
Line::from(text_preview)
|
||||
})
|
||||
.collect();
|
||||
(skip, tail)
|
||||
}
|
||||
_ => (0, data.output_lines()),
|
||||
};
|
||||
if skip > 0 {
|
||||
lines.push(clipped_line(
|
||||
format!(" … {skip} earlier lines hidden").dim().into(),
|
||||
width,
|
||||
));
|
||||
}
|
||||
for (index, mut line) in output.into_iter().enumerate() {
|
||||
line.spans.insert(
|
||||
/*index*/ 0,
|
||||
if index == 0 { " └ " } else { " " }.dim(),
|
||||
);
|
||||
lines.push(clipped_line(line.dim(), width));
|
||||
}
|
||||
lines
|
||||
}
|
||||
|
||||
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
|
||||
let data = self
|
||||
.data
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut lines = vec![data.header().into()];
|
||||
let args = serde_json::to_string_pretty(&data.arguments)
|
||||
.unwrap_or_else(|_| data.arguments.to_string());
|
||||
let details = raw_lines_from_source(&format!("Arguments: {args}"))
|
||||
.into_iter()
|
||||
.chain(data.output_lines())
|
||||
.collect();
|
||||
lines.extend(adaptive_wrap_hyperlink_lines(
|
||||
&plain_hyperlink_lines(details),
|
||||
RtOptions::new(usize::from(width).max(/*other*/ 1))
|
||||
.initial_indent(" └ ".dim().into())
|
||||
.subsequent_indent(" ".into()),
|
||||
));
|
||||
lines
|
||||
}
|
||||
|
||||
fn raw_lines(&self) -> Vec<Line<'static>> {
|
||||
let data = self
|
||||
.data
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut lines = vec![data.header()];
|
||||
lines.extend(raw_lines_from_source(&format!(
|
||||
"Arguments: {}",
|
||||
data.arguments
|
||||
)));
|
||||
lines.extend(data.output_lines());
|
||||
super::plain_lines(lines)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "dynamic_tests.rs"]
|
||||
mod tests;
|
||||
116
codex-rs/tui/src/history_cell/dynamic_tests.rs
Normal file
116
codex-rs/tui/src/history_cell/dynamic_tests.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
//! Dynamic tool projections keep truthful status and bounded previews without losing detail.
|
||||
|
||||
use super::*;
|
||||
use crate::test_support::PathBufExt;
|
||||
use crate::test_support::test_path_buf;
|
||||
use crate::thread_transcript::RawReasoningVisibility;
|
||||
use crate::thread_transcript::thread_items_to_transcript_cells;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
fn item(status: DynamicToolCallStatus, output: Option<&str>) -> ThreadItem {
|
||||
ThreadItem::DynamicToolCall {
|
||||
id: "dynamic-1".to_string(),
|
||||
namespace: Some("example".to_string()),
|
||||
tool: "inspect".to_string(),
|
||||
arguments: json!({"path": "src/main.rs"}),
|
||||
status,
|
||||
content_items: output.map(|text| {
|
||||
vec![DynamicToolCallOutputContentItem::InputText {
|
||||
text: text.to_string(),
|
||||
}]
|
||||
}),
|
||||
success: None,
|
||||
duration_ms: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_status_and_output_match_persisted_presentations() {
|
||||
let cwd = test_path_buf("/workspace").abs();
|
||||
let mut snapshots = Vec::new();
|
||||
for (label, status, output) in [
|
||||
("pending", DynamicToolCallStatus::InProgress, None),
|
||||
(
|
||||
"success",
|
||||
DynamicToolCallStatus::Completed,
|
||||
Some("Found the definition"),
|
||||
),
|
||||
(
|
||||
"failure",
|
||||
DynamicToolCallStatus::Failed,
|
||||
Some("Permission denied"),
|
||||
),
|
||||
("unavailable", DynamicToolCallStatus::Completed, None),
|
||||
] {
|
||||
let replayed = thread_items_to_transcript_cells(
|
||||
/*thread_id*/ None,
|
||||
&cwd,
|
||||
[item(status, output)],
|
||||
RawReasoningVisibility::Hidden,
|
||||
/*config*/ None,
|
||||
);
|
||||
assert_eq!(replayed.len(), 1);
|
||||
let cell = &replayed[0];
|
||||
for (mode, lines) in [
|
||||
("compact", cell.display_lines(/*width*/ 80)),
|
||||
(
|
||||
"full",
|
||||
visible_lines(cell.transcript_hyperlink_lines(/*width*/ 80)),
|
||||
),
|
||||
("raw", cell.raw_lines()),
|
||||
] {
|
||||
let text = lines
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if mode == "compact" || label == "success" {
|
||||
snapshots.push(format!("{label}, {mode}\n{text}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
insta::assert_snapshot!(snapshots.join("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dynamic_preview_reports_hidden_lines_and_retains_full_output() {
|
||||
let mut snapshots = Vec::new();
|
||||
for count in [3, 4] {
|
||||
let output = (1..=count)
|
||||
.map(|index| format!("Result line {index}: retained content"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let cell =
|
||||
DynamicToolCallCell::from_item(item(DynamicToolCallStatus::Completed, Some(&output)))
|
||||
.unwrap();
|
||||
for width in [20, 80] {
|
||||
let compact = cell.display_lines(width);
|
||||
assert!(
|
||||
compact
|
||||
.iter()
|
||||
.all(|line| line.width() <= usize::from(width))
|
||||
);
|
||||
let text = compact
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
snapshots.push(format!("lines={count}, width={width}\n{text}"));
|
||||
}
|
||||
let raw = cell
|
||||
.raw_lines()
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(raw.ends_with(&output));
|
||||
let detailed = visible_lines(cell.transcript_hyperlink_lines(/*width*/ 80))
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(output.lines().all(|line| detailed.contains(line)));
|
||||
}
|
||||
insta::assert_snapshot!(snapshots.join("\n\n"));
|
||||
}
|
||||
@@ -106,6 +106,7 @@ mod activity_details;
|
||||
pub(crate) mod activity_preview;
|
||||
mod approvals;
|
||||
mod base;
|
||||
mod dynamic;
|
||||
mod exec;
|
||||
mod hook_cell;
|
||||
mod markdown_render_cache;
|
||||
@@ -125,6 +126,7 @@ mod warnings;
|
||||
pub(crate) use activity_details::ActivityDetails;
|
||||
pub(crate) use approvals::*;
|
||||
pub(crate) use base::*;
|
||||
pub(crate) use dynamic::DynamicToolCallCell;
|
||||
pub(crate) use exec::*;
|
||||
pub(crate) use hook_cell::HookCell;
|
||||
pub(crate) use hook_cell::new_active_hook_cell;
|
||||
|
||||
@@ -17,6 +17,13 @@ pub(crate) struct PatchHistoryCell {
|
||||
cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl PatchHistoryCell {
|
||||
pub(crate) fn with_activity_id(mut self, id: String) -> Self {
|
||||
self.activity_id = format!("patch:{id}");
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl HistoryCell for PatchHistoryCell {
|
||||
fn activity_ids(&self) -> Vec<String> {
|
||||
vec![self.activity_id.clone()]
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
---
|
||||
source: tui/src/history_cell/dynamic_tests.rs
|
||||
assertion_line: 95
|
||||
expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
lines=3, width=20
|
||||
• Called example.in…
|
||||
└ Result line 1: …
|
||||
Result line 2: …
|
||||
Result line 3: …
|
||||
|
||||
lines=3, width=80
|
||||
• Called example.inspect
|
||||
└ Result line 1: retained content
|
||||
Result line 2: retained content
|
||||
Result line 3: retained content
|
||||
|
||||
lines=4, width=20
|
||||
• Called example.in…
|
||||
… 1 earlier lines…
|
||||
└ Result line 2: …
|
||||
Result line 3: …
|
||||
Result line 4: …
|
||||
|
||||
lines=4, width=80
|
||||
• Called example.inspect
|
||||
… 1 earlier lines hidden
|
||||
└ Result line 2: retained content
|
||||
Result line 3: retained content
|
||||
Result line 4: retained content
|
||||
@@ -0,0 +1,31 @@
|
||||
---
|
||||
source: tui/src/history_cell/dynamic_tests.rs
|
||||
assertion_line: 81
|
||||
expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
pending, compact
|
||||
• Calling example.inspect
|
||||
|
||||
success, compact
|
||||
• Called example.inspect
|
||||
└ Found the definition
|
||||
|
||||
success, full
|
||||
• Called example.inspect
|
||||
└ Arguments: {
|
||||
"path": "src/main.rs"
|
||||
}
|
||||
Found the definition
|
||||
|
||||
success, raw
|
||||
• Called example.inspect
|
||||
Arguments: {"path":"src/main.rs"}
|
||||
Found the definition
|
||||
|
||||
failure, compact
|
||||
• Failed example.inspect
|
||||
└ Permission denied
|
||||
|
||||
unavailable, compact
|
||||
• Called example.inspect
|
||||
└ (output unavailable)
|
||||
@@ -1,7 +1,8 @@
|
||||
//! Inserts finalized history rows into terminal scrollback.
|
||||
//!
|
||||
//! Codex uses the terminal scrollback itself for finalized chat history, so inserting a history
|
||||
//! cell is an escape-sequence operation rather than a normal ratatui render.
|
||||
//! cell is an escape-sequence operation rather than a normal ratatui render. Untrusted content
|
||||
//! follows ratatui’s control-character filtering before semantic hyperlinks add trusted escapes.
|
||||
|
||||
use std::fmt;
|
||||
use std::io;
|
||||
@@ -528,16 +529,38 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writes_semantic_web_link_without_changing_visible_text() {
|
||||
fn writes_semantic_web_link_without_emitting_untrusted_controls() {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
let destination = "https://example.com/long/path";
|
||||
let line = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(destination));
|
||||
let mut actual = Vec::new();
|
||||
|
||||
write_history_line(&mut actual, &line, /*wrap_width*/ 80).expect("write history line");
|
||||
|
||||
let output = String::from_utf8(actual).expect("UTF-8 terminal output");
|
||||
assert!(output.contains("\x1b]8;;https://example.com/long/path\x07"));
|
||||
assert_eq!(line.line.spans[0].content, destination);
|
||||
for linked in [false, true] {
|
||||
let mut line = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(vec![
|
||||
"\x1b[2J\x1b]52;c;Y2xpcA==\x07\u{009d}hidden\u{009c}\r\n\t ".into(),
|
||||
destination.into(),
|
||||
]));
|
||||
let mut safe = crate::terminal_hyperlinks::annotate_web_urls_in_line(Line::from(vec![
|
||||
"[2J]52;c;Y2xpcA==hidden ".into(),
|
||||
destination.into(),
|
||||
]));
|
||||
if !linked {
|
||||
line.hyperlinks.clear();
|
||||
safe.hyperlinks.clear();
|
||||
}
|
||||
let original = line.clone();
|
||||
let mut actual = Vec::new();
|
||||
let mut expected = Vec::new();
|
||||
write_history_line(&mut actual, &line, /*wrap_width*/ 80).unwrap();
|
||||
write_history_line(&mut expected, &safe, /*wrap_width*/ 80).unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
assert_eq!(line, original);
|
||||
let output = String::from_utf8(actual).unwrap();
|
||||
assert_eq!(
|
||||
output.contains(&format!(
|
||||
"\x1b]8;;{destination}\x07{destination}\x1b]8;;\x07"
|
||||
)),
|
||||
linked,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -649,7 +649,23 @@ pub(crate) fn strip_osc8(text: &str) -> String {
|
||||
|
||||
pub(crate) fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
|
||||
if line.hyperlinks.is_empty() {
|
||||
return line.line.spans.clone();
|
||||
return line
|
||||
.line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| Span {
|
||||
style: span.style,
|
||||
content: if span.content.contains(char::is_control) {
|
||||
span.content
|
||||
.graphemes(/*is_extended*/ true)
|
||||
.filter(|grapheme| !grapheme.contains(char::is_control))
|
||||
.collect::<String>()
|
||||
.into()
|
||||
} else {
|
||||
span.content.clone()
|
||||
},
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut out = Vec::new();
|
||||
@@ -660,6 +676,12 @@ pub(crate) fn decorate_spans(line: &HyperlinkLine) -> Vec<Span<'static>> {
|
||||
for span in &line.line.spans {
|
||||
for grapheme in span.content.graphemes(/*is_extended*/ true) {
|
||||
let width = display_width(grapheme);
|
||||
// Match ratatui's filtering, retaining source columns for semantic links.
|
||||
// Only the hyperlink metadata below may introduce terminal escapes.
|
||||
if grapheme.contains(char::is_control) {
|
||||
column += width;
|
||||
continue;
|
||||
}
|
||||
while line
|
||||
.hyperlinks
|
||||
.get(link_index)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Render persisted thread turns into history-cell building blocks.
|
||||
//! Project individual persisted items into rich history cells without changing live state.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -14,7 +14,6 @@ use crate::history_cell::UserHistoryCell;
|
||||
use crate::history_cell::split_reasoning_summary_parts;
|
||||
use crate::inline_visualization::InlineVisualizationContext;
|
||||
use crate::legacy_core::config::Config;
|
||||
use crate::multi_agents::sub_agent_activity_summary;
|
||||
use codex_app_server_protocol::Thread;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::UserInput;
|
||||
@@ -22,7 +21,9 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::items::UserMessageItem;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
|
||||
mod other_items;
|
||||
pub(crate) mod tools;
|
||||
|
||||
pub(crate) type TranscriptCells = Vec<Arc<dyn HistoryCell>>;
|
||||
|
||||
@@ -94,235 +95,137 @@ pub(crate) fn thread_items_to_transcript_cells(
|
||||
});
|
||||
let mut cells: TranscriptCells = Vec::new();
|
||||
for item in items {
|
||||
match item {
|
||||
ThreadItem::UserMessage {
|
||||
id,
|
||||
client_id,
|
||||
content,
|
||||
} => {
|
||||
if content.iter().any(|input| {
|
||||
matches!(
|
||||
input,
|
||||
UserInput::Audio { .. } | UserInput::LocalAudio { .. }
|
||||
)
|
||||
}) {
|
||||
tracing::warn!(
|
||||
user_message_id = id,
|
||||
"audio user inputs are not supported by the TUI and will be omitted"
|
||||
);
|
||||
}
|
||||
let item = UserMessageItem {
|
||||
id,
|
||||
client_id,
|
||||
content: content
|
||||
.into_iter()
|
||||
.map(codex_app_server_protocol::UserInput::into_core)
|
||||
.collect(),
|
||||
};
|
||||
let message = item.message();
|
||||
let reply_text = crate::async_question_reply::display_text(&message);
|
||||
let text_elements = if reply_text.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
item.text_elements()
|
||||
};
|
||||
cells.push(Arc::new(UserHistoryCell {
|
||||
spoken: false,
|
||||
message: reply_text.unwrap_or(message),
|
||||
text_elements,
|
||||
local_image_paths: item.local_image_paths(),
|
||||
remote_image_urls: item.image_urls(),
|
||||
}));
|
||||
}
|
||||
ThreadItem::AgentMessage { text, .. } => {
|
||||
let parsed = parse_assistant_markdown(&text, cwd.as_path());
|
||||
if !parsed.visible_markdown.trim().is_empty() {
|
||||
cells.push(Arc::new(AgentMarkdownCell::new_with_inline_visualizations(
|
||||
parsed.visible_markdown,
|
||||
cwd.as_path(),
|
||||
inline_visualization_context.clone(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
ThreadItem::FunctionCallOutput {
|
||||
name,
|
||||
namespace,
|
||||
output,
|
||||
..
|
||||
} => {
|
||||
if let Some((source_thread_id, prompt)) =
|
||||
crate::dynamic_tools::parse_delegated_tool_output(
|
||||
&name,
|
||||
namespace.as_deref(),
|
||||
&output,
|
||||
)
|
||||
{
|
||||
cells.push(Arc::new(PrefixedWrappedHistoryCell::new(
|
||||
format!("Sent by Codex from task {source_thread_id}\n{prompt}"),
|
||||
"• ".dim(),
|
||||
" ",
|
||||
)));
|
||||
}
|
||||
}
|
||||
ThreadItem::Plan { text, .. } => {
|
||||
if !text.trim().is_empty() {
|
||||
cells.push(Arc::new(crate::history_cell::new_proposed_plan(
|
||||
text,
|
||||
cwd.as_path(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
ThreadItem::Reasoning {
|
||||
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)
|
||||
};
|
||||
if !text.trim().is_empty() {
|
||||
cells.push(Arc::new(ReasoningSummaryCell::new(
|
||||
header,
|
||||
text,
|
||||
cwd.as_path(),
|
||||
/*transcript_only*/ false,
|
||||
)));
|
||||
}
|
||||
}
|
||||
ThreadItem::WebSearch(item) => {
|
||||
cells.push(Arc::new(crate::history_cell::new_web_search_call(
|
||||
item.id,
|
||||
item.query,
|
||||
item.action
|
||||
.unwrap_or(codex_app_server_protocol::WebSearchAction::Other),
|
||||
)));
|
||||
}
|
||||
ThreadItem::ImageView { path, .. } => {
|
||||
cells.push(Arc::new(crate::history_cell::new_view_image_tool_call(
|
||||
path,
|
||||
)));
|
||||
}
|
||||
other => {
|
||||
if let Some(cell) = fallback_transcript_cell(&other) {
|
||||
cells.push(Arc::new(cell));
|
||||
}
|
||||
}
|
||||
if let Some(cell) = tools::historical_tool_fallback(&item) {
|
||||
cells.push(Arc::new(cell));
|
||||
} else {
|
||||
cells.extend(item_to_cells(
|
||||
item,
|
||||
cwd,
|
||||
raw_reasoning_visibility,
|
||||
inline_visualization_context.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
fn fallback_transcript_cell(item: &ThreadItem) -> Option<PlainHistoryCell> {
|
||||
let lines = match item {
|
||||
ThreadItem::HookPrompt { fragments, .. } => fragments
|
||||
.iter()
|
||||
.map(|fragment| {
|
||||
vec![
|
||||
"hook prompt: ".dim(),
|
||||
fragment.text.trim().to_string().into(),
|
||||
]
|
||||
.into()
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
ThreadItem::CommandExecution {
|
||||
command,
|
||||
status,
|
||||
aggregated_output,
|
||||
exit_code,
|
||||
..
|
||||
/// Project one item without changing the active widget or its turn lifecycle.
|
||||
fn item_to_cells(
|
||||
item: ThreadItem,
|
||||
cwd: &AbsolutePathBuf,
|
||||
raw_reasoning_visibility: RawReasoningVisibility,
|
||||
inline_visualization_context: Option<InlineVisualizationContext>,
|
||||
) -> TranscriptCells {
|
||||
let mut cells: TranscriptCells = Vec::new();
|
||||
match item {
|
||||
ThreadItem::UserMessage {
|
||||
id,
|
||||
client_id,
|
||||
content,
|
||||
} => {
|
||||
let mut lines: Vec<Line<'static>> =
|
||||
vec![vec!["$ ".dim(), command.clone().into()].into()];
|
||||
lines.push(
|
||||
format!(
|
||||
"status: {status:?}{}",
|
||||
exit_code
|
||||
.map(|code| format!(" · exit {code}"))
|
||||
.unwrap_or_default()
|
||||
if content.iter().any(|input| {
|
||||
matches!(
|
||||
input,
|
||||
UserInput::Audio { .. } | UserInput::LocalAudio { .. }
|
||||
)
|
||||
.dim()
|
||||
.into(),
|
||||
);
|
||||
if let Some(output) = aggregated_output.as_deref()
|
||||
&& !output.trim().is_empty()
|
||||
{
|
||||
lines.extend(
|
||||
output
|
||||
.lines()
|
||||
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
|
||||
}) {
|
||||
tracing::warn!(
|
||||
user_message_id = id,
|
||||
"audio user inputs are not supported by the TUI and will be omitted"
|
||||
);
|
||||
}
|
||||
lines
|
||||
let item = UserMessageItem {
|
||||
id,
|
||||
client_id,
|
||||
content: content
|
||||
.into_iter()
|
||||
.map(codex_app_server_protocol::UserInput::into_core)
|
||||
.collect(),
|
||||
};
|
||||
let message = item.message();
|
||||
let reply_text = crate::async_question_reply::display_text(&message);
|
||||
let text_elements = if reply_text.is_some() {
|
||||
Vec::new()
|
||||
} else {
|
||||
item.text_elements()
|
||||
};
|
||||
cells.push(Arc::new(UserHistoryCell {
|
||||
spoken: false,
|
||||
message: reply_text.unwrap_or(message),
|
||||
text_elements,
|
||||
local_image_paths: item.local_image_paths(),
|
||||
remote_image_urls: item.image_urls(),
|
||||
}));
|
||||
}
|
||||
ThreadItem::FileChange {
|
||||
changes, status, ..
|
||||
} => vec![
|
||||
format!("file changes: {status:?} · {} changes", changes.len())
|
||||
.dim()
|
||||
.into(),
|
||||
],
|
||||
ThreadItem::McpToolCall {
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
..
|
||||
} => vec![
|
||||
format!("mcp tool: {server}/{tool} · {status:?}")
|
||||
.dim()
|
||||
.into(),
|
||||
],
|
||||
ThreadItem::DynamicToolCall {
|
||||
ThreadItem::AgentMessage { text, .. } => {
|
||||
let parsed = parse_assistant_markdown(&text, cwd.as_path());
|
||||
if !parsed.visible_markdown.trim().is_empty() {
|
||||
cells.push(Arc::new(AgentMarkdownCell::new_with_inline_visualizations(
|
||||
parsed.visible_markdown,
|
||||
cwd.as_path(),
|
||||
inline_visualization_context,
|
||||
)));
|
||||
}
|
||||
}
|
||||
ThreadItem::FunctionCallOutput {
|
||||
name,
|
||||
namespace,
|
||||
tool,
|
||||
status,
|
||||
output,
|
||||
..
|
||||
} => {
|
||||
let name = namespace
|
||||
.as_ref()
|
||||
.map(|namespace| format!("{namespace}/{tool}"))
|
||||
.unwrap_or_else(|| tool.clone());
|
||||
vec![format!("tool: {name} · {status:?}").dim().into()]
|
||||
if let Some((source_thread_id, prompt)) =
|
||||
crate::dynamic_tools::parse_delegated_tool_output(
|
||||
&name,
|
||||
namespace.as_deref(),
|
||||
&output,
|
||||
)
|
||||
{
|
||||
cells.push(Arc::new(PrefixedWrappedHistoryCell::new(
|
||||
format!("Sent by Codex from task {source_thread_id}\n{prompt}"),
|
||||
"• ".dim(),
|
||||
" ",
|
||||
)));
|
||||
}
|
||||
}
|
||||
ThreadItem::CollabAgentToolCall { tool, status, .. } => {
|
||||
vec![format!("agent tool: {tool:?} · {status:?}").dim().into()]
|
||||
ThreadItem::Plan { text, .. } => {
|
||||
if !text.trim().is_empty() {
|
||||
cells.push(Arc::new(crate::history_cell::new_proposed_plan(
|
||||
text,
|
||||
cwd.as_path(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
ThreadItem::SubAgentActivity {
|
||||
kind, agent_path, ..
|
||||
ThreadItem::Reasoning {
|
||||
summary, content, ..
|
||||
} => {
|
||||
vec![sub_agent_activity_summary(*kind, agent_path).dim().into()]
|
||||
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)
|
||||
};
|
||||
if !text.trim().is_empty() {
|
||||
cells.push(Arc::new(ReasoningSummaryCell::new(
|
||||
header,
|
||||
text,
|
||||
cwd.as_path(),
|
||||
/*transcript_only*/ false,
|
||||
)));
|
||||
}
|
||||
}
|
||||
ThreadItem::ImageGeneration(item) => {
|
||||
let saved = item
|
||||
.saved_path
|
||||
.as_ref()
|
||||
.map(|path| format!(" · {}", path.as_path().display()))
|
||||
.unwrap_or_default();
|
||||
vec![
|
||||
format!("image generation: {}{saved}", item.status)
|
||||
.dim()
|
||||
.into(),
|
||||
]
|
||||
item @ ThreadItem::CommandExecution { .. } => {
|
||||
if let Some(command) = tools::CommandHistory::from_item(item) {
|
||||
cells.push(Arc::new(command.into_cell()));
|
||||
}
|
||||
}
|
||||
ThreadItem::EnteredReviewMode { review, .. } => {
|
||||
vec![vec!["review started: ".dim(), review.clone().into()].into()]
|
||||
item @ ThreadItem::McpToolCall { .. } => {
|
||||
if let Some(call) = tools::McpHistory::from_item(item) {
|
||||
cells.push(Arc::new(call.into_cell()));
|
||||
}
|
||||
}
|
||||
ThreadItem::ExitedReviewMode { review, .. } => {
|
||||
vec![vec!["review finished: ".dim(), review.clone().into()].into()]
|
||||
}
|
||||
ThreadItem::ContextCompaction { .. } => {
|
||||
vec!["context compacted".dim().into()]
|
||||
}
|
||||
ThreadItem::UserMessage { .. }
|
||||
| ThreadItem::AgentMessage { .. }
|
||||
| ThreadItem::FunctionCallOutput { .. }
|
||||
| ThreadItem::Plan { .. }
|
||||
| ThreadItem::Reasoning { .. }
|
||||
| ThreadItem::WebSearch(_)
|
||||
| ThreadItem::ImageView { .. }
|
||||
| ThreadItem::Sleep(_) => return None,
|
||||
};
|
||||
(!lines.is_empty()).then(|| PlainHistoryCell::new(lines))
|
||||
other => cells.extend(other_items::cells(other, cwd)),
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
148
codex-rs/tui/src/thread_transcript/other_items.rs
Normal file
148
codex-rs/tui/src/thread_transcript/other_items.rs
Normal file
@@ -0,0 +1,148 @@
|
||||
//! Restore historical tool and notice cells without changing live application state.
|
||||
//!
|
||||
//! Formatting stays in the same history cells used by live events. Only completed
|
||||
//! patches render as applied changes; other statuses retain their actual outcome.
|
||||
|
||||
use super::TranscriptCells;
|
||||
use crate::app_server_approval_conversions::file_update_changes_to_display;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::multi_agents;
|
||||
use codex_app_server_protocol::PatchApplyStatus;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::WebSearchAction;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use ratatui::style::Stylize as _;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(super) fn cells(item: ThreadItem, cwd: &AbsolutePathBuf) -> TranscriptCells {
|
||||
let mut cells: TranscriptCells = Vec::new();
|
||||
match item {
|
||||
ThreadItem::FileChange {
|
||||
id,
|
||||
changes,
|
||||
status,
|
||||
} => match status {
|
||||
PatchApplyStatus::Completed if !changes.is_empty() => {
|
||||
cells.push(Arc::new(
|
||||
history_cell::new_patch_event(
|
||||
file_update_changes_to_display(changes),
|
||||
cwd.as_path(),
|
||||
)
|
||||
.with_activity_id(id),
|
||||
));
|
||||
}
|
||||
PatchApplyStatus::Completed => {}
|
||||
PatchApplyStatus::Failed => {
|
||||
cells.push(Arc::new(history_cell::new_patch_apply_failure(
|
||||
String::new(),
|
||||
)));
|
||||
}
|
||||
PatchApplyStatus::InProgress | PatchApplyStatus::Declined => {
|
||||
let message = if status == PatchApplyStatus::Declined {
|
||||
"Patch application declined"
|
||||
} else {
|
||||
"Patch application in progress"
|
||||
};
|
||||
cells.push(Arc::new(history_cell::new_info_event(
|
||||
message.to_string(),
|
||||
/*hint*/ None,
|
||||
)));
|
||||
}
|
||||
},
|
||||
ThreadItem::WebSearch(item) => cells.push(Arc::new(history_cell::new_web_search_call(
|
||||
item.id,
|
||||
item.query,
|
||||
item.action.unwrap_or(WebSearchAction::Other),
|
||||
))),
|
||||
ThreadItem::ImageView { path, .. } => {
|
||||
cells.push(Arc::new(history_cell::new_view_image_tool_call(path)))
|
||||
}
|
||||
ThreadItem::ImageGeneration(item)
|
||||
if !matches!(item.status.as_str(), "completed" | "failed") =>
|
||||
{
|
||||
let status = if item.status.is_empty() {
|
||||
"status unavailable"
|
||||
} else {
|
||||
&item.status
|
||||
};
|
||||
cells.push(Arc::new(history_cell::new_info_event(
|
||||
format!("Image generation · {status}"),
|
||||
/*hint*/ None,
|
||||
)));
|
||||
}
|
||||
ThreadItem::ImageGeneration(item) => {
|
||||
cells.push(Arc::new(history_cell::new_image_generation_call(
|
||||
item.id,
|
||||
&item.status,
|
||||
item.revised_prompt,
|
||||
item.saved_path,
|
||||
)));
|
||||
}
|
||||
item @ ThreadItem::CollabAgentToolCall { .. } => {
|
||||
if let Some(cell) = multi_agents::tool_call_history_cell(
|
||||
&item,
|
||||
/*cached_spawn_request*/ None,
|
||||
|_| multi_agents::AgentMetadata::default(),
|
||||
) {
|
||||
cells.push(Arc::new(cell));
|
||||
}
|
||||
}
|
||||
item @ ThreadItem::SubAgentActivity { .. } => {
|
||||
if let Some(cell) = multi_agents::sub_agent_activity_history_cell(&item) {
|
||||
cells.push(Arc::new(cell));
|
||||
}
|
||||
}
|
||||
ThreadItem::EnteredReviewMode { review, .. } => {
|
||||
cells.push(Arc::new(history_cell::new_review_status_line(format!(
|
||||
">> Code review started: {review} <<"
|
||||
))));
|
||||
}
|
||||
ThreadItem::ExitedReviewMode { review, .. } => {
|
||||
cells.push(Arc::new(history_cell::new_review_status_line(format!(
|
||||
"<< Code review finished: {review} >>"
|
||||
))));
|
||||
}
|
||||
ThreadItem::ContextCompaction { .. } => {
|
||||
cells.push(Arc::new(history_cell::new_info_event(
|
||||
"Context compacted".to_string(),
|
||||
/*hint*/ None,
|
||||
)));
|
||||
}
|
||||
// These items do not have richer history-cell presentations yet.
|
||||
ThreadItem::HookPrompt { fragments, .. } => {
|
||||
if !fragments.is_empty() {
|
||||
cells.push(Arc::new(PlainHistoryCell::new(
|
||||
fragments
|
||||
.into_iter()
|
||||
.map(|fragment| {
|
||||
vec![
|
||||
"hook prompt: ".dim(),
|
||||
fragment.text.trim().to_string().into(),
|
||||
]
|
||||
.into()
|
||||
})
|
||||
.collect(),
|
||||
)));
|
||||
}
|
||||
}
|
||||
item @ ThreadItem::DynamicToolCall { .. } => {
|
||||
if let Some(cell) = history_cell::DynamicToolCallCell::from_item(item) {
|
||||
cells.push(Arc::new(cell));
|
||||
}
|
||||
}
|
||||
ThreadItem::UserMessage { .. }
|
||||
| ThreadItem::AgentMessage { .. }
|
||||
| ThreadItem::FunctionCallOutput { .. }
|
||||
| ThreadItem::Plan { .. }
|
||||
| ThreadItem::Reasoning { .. }
|
||||
| ThreadItem::CommandExecution { .. }
|
||||
| ThreadItem::McpToolCall { .. }
|
||||
| ThreadItem::Sleep(_) => {}
|
||||
}
|
||||
cells
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "other_items_tests.rs"]
|
||||
mod tests;
|
||||
190
codex-rs/tui/src/thread_transcript/other_items_tests.rs
Normal file
190
codex-rs/tui/src/thread_transcript/other_items_tests.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! Persisted tool projections retain rich detail, styles, and truthful outcomes.
|
||||
|
||||
use super::cells;
|
||||
use crate::diff_model::FileChange;
|
||||
use crate::history_cell;
|
||||
use crate::history_cell::HistoryCell;
|
||||
use crate::test_support::PathBufExt;
|
||||
use crate::test_support::test_path_buf;
|
||||
use codex_app_server_protocol::FileUpdateChange;
|
||||
use codex_app_server_protocol::ImageGenerationItem;
|
||||
use codex_app_server_protocol::PatchApplyStatus;
|
||||
use codex_app_server_protocol::PatchChangeKind;
|
||||
use codex_app_server_protocol::SubAgentActivityKind;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_app_server_protocol::WebSearchAction;
|
||||
use codex_app_server_protocol::WebSearchItem;
|
||||
use codex_utils_path_uri::LegacyAppPathString;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn completed_patch_restores_rich_diff_and_styles() {
|
||||
let cwd = test_path_buf("/workspace").abs();
|
||||
let mut changes = vec![
|
||||
FileUpdateChange {
|
||||
path: "new.rs".to_string(),
|
||||
kind: PatchChangeKind::Add,
|
||||
diff: "fn greet() {\n println!(\"hello\");\n}\n".to_string(),
|
||||
},
|
||||
FileUpdateChange {
|
||||
path: "old.txt".to_string(),
|
||||
kind: PatchChangeKind::Delete,
|
||||
diff: "outdated\n".to_string(),
|
||||
},
|
||||
FileUpdateChange {
|
||||
path: "src/before.rs".to_string(),
|
||||
kind: PatchChangeKind::Update {
|
||||
move_path: Some(PathBuf::from("src/after.rs")),
|
||||
},
|
||||
diff: "@@ -1 +1 @@\n-let before = 1;\n+let after = 2;\n".to_string(),
|
||||
},
|
||||
];
|
||||
let expected = history_cell::new_patch_event(
|
||||
HashMap::from([
|
||||
(
|
||||
PathBuf::from("new.rs"),
|
||||
FileChange::Add {
|
||||
content: changes[0].diff.clone(),
|
||||
},
|
||||
),
|
||||
(
|
||||
PathBuf::from("old.txt"),
|
||||
FileChange::Delete {
|
||||
content: changes[1].diff.clone(),
|
||||
},
|
||||
),
|
||||
(
|
||||
PathBuf::from("src/before.rs"),
|
||||
FileChange::Update {
|
||||
unified_diff: changes[2].diff.clone(),
|
||||
move_path: Some(PathBuf::from("src/after.rs")),
|
||||
},
|
||||
),
|
||||
]),
|
||||
cwd.as_path(),
|
||||
);
|
||||
changes[2].diff.push_str("\n\nMoved to: src/after.rs");
|
||||
let actual = cells(
|
||||
ThreadItem::FileChange {
|
||||
id: "patch-1".to_string(),
|
||||
changes,
|
||||
status: PatchApplyStatus::Completed,
|
||||
},
|
||||
&cwd,
|
||||
);
|
||||
|
||||
assert_eq!(actual.len(), 1);
|
||||
assert_eq!(
|
||||
actual[0].transcript_hyperlink_lines(/*width*/ 80),
|
||||
expected.transcript_hyperlink_lines(/*width*/ 80),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfinished_and_rejected_patches_keep_their_outcome() {
|
||||
let cwd = test_path_buf("/workspace").abs();
|
||||
let rendered = [
|
||||
PatchApplyStatus::InProgress,
|
||||
PatchApplyStatus::Declined,
|
||||
PatchApplyStatus::Failed,
|
||||
]
|
||||
.into_iter()
|
||||
.flat_map(|status| {
|
||||
cells(
|
||||
ThreadItem::FileChange {
|
||||
id: "patch-1".to_string(),
|
||||
changes: vec![FileUpdateChange {
|
||||
path: "main.rs".to_string(),
|
||||
kind: PatchChangeKind::Add,
|
||||
diff: "fn main() {}\n".to_string(),
|
||||
}],
|
||||
status,
|
||||
},
|
||||
&cwd,
|
||||
)
|
||||
})
|
||||
.flat_map(|cell| cell.display_lines(/*width*/ 80))
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
insta::assert_snapshot!(rendered, @"
|
||||
• Patch application in progress
|
||||
• Patch application declined
|
||||
✘ Failed to apply patch
|
||||
");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_and_notice_projection_uses_normal_transcript_presentation() {
|
||||
let cwd = test_path_buf("/workspace").abs();
|
||||
let image = ImageGenerationItem {
|
||||
id: "image-1".to_string(),
|
||||
status: "completed".to_string(),
|
||||
revised_prompt: Some("A diagram of the history pages".to_string()),
|
||||
result: String::new(),
|
||||
transparent_background: None,
|
||||
failure: None,
|
||||
saved_path: None,
|
||||
imagegen_request_id: None,
|
||||
generation_id: None,
|
||||
};
|
||||
let items = vec![
|
||||
ThreadItem::EnteredReviewMode {
|
||||
id: "review-start".to_string(),
|
||||
review: "current changes".to_string(),
|
||||
},
|
||||
ThreadItem::WebSearch(WebSearchItem {
|
||||
id: "search-1".to_string(),
|
||||
query: "fallback query".to_string(),
|
||||
action: Some(WebSearchAction::FindInPage {
|
||||
url: Some("https://example.com".to_string()),
|
||||
pattern: Some("pagination".to_string()),
|
||||
}),
|
||||
results: None,
|
||||
}),
|
||||
ThreadItem::ImageView {
|
||||
id: "view-1".to_string(),
|
||||
path: LegacyAppPathString::from_string("diagram.png".to_string()),
|
||||
},
|
||||
ThreadItem::ImageGeneration(image.clone()),
|
||||
ThreadItem::ImageGeneration(ImageGenerationItem {
|
||||
status: "in_progress".into(),
|
||||
..image
|
||||
}),
|
||||
ThreadItem::SubAgentActivity {
|
||||
id: "agent-1".to_string(),
|
||||
kind: SubAgentActivityKind::Completed,
|
||||
agent_thread_id: "01912345-1234-7123-8123-123456789abc".to_string(),
|
||||
agent_path: "/root/reviewer".to_string(),
|
||||
},
|
||||
ThreadItem::ExitedReviewMode {
|
||||
id: "review-end".to_string(),
|
||||
review: "No findings".to_string(),
|
||||
},
|
||||
ThreadItem::ContextCompaction {
|
||||
id: "compact-1".to_string(),
|
||||
},
|
||||
];
|
||||
let rendered = items
|
||||
.into_iter()
|
||||
.flat_map(|item| cells(item, &cwd))
|
||||
.flat_map(|cell| cell.display_lines(/*width*/ 80))
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
insta::assert_snapshot!(rendered, @"
|
||||
>> Code review started: current changes <<
|
||||
• Searched for 'pagination' in https://example.com
|
||||
• Viewed image diagram.png
|
||||
• Generated Image:
|
||||
└ A diagram of the history pages
|
||||
• Image generation · in_progress
|
||||
• Completed `/root/reviewer`
|
||||
<< Code review finished: No findings >>
|
||||
• Context compacted
|
||||
");
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
source: tui/src/thread_transcript/tools_tests.rs
|
||||
expression: "snapshots.join(\"\\n\")"
|
||||
---
|
||||
agent tool: SpawnAgent · In progress at last update
|
||||
agent tool: SendInput · Failed
|
||||
agent tool: CloseAgent · Interrupted
|
||||
agent tool: ResumeAgent · Failed
|
||||
agent tool: Wait · Interrupted
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
source: tui/src/thread_transcript/tools_tests.rs
|
||||
expression: rendered
|
||||
---
|
||||
Command: compact
|
||||
• Ran cargo check
|
||||
└ output line 1
|
||||
output line 2
|
||||
output line 3
|
||||
+9 lines (ctrl+t to view transcript)
|
||||
|
||||
Command: detailed
|
||||
$ cargo check
|
||||
output line 1
|
||||
output line 2
|
||||
output line 3
|
||||
output line 4
|
||||
output line 5
|
||||
output line 6
|
||||
output line 7
|
||||
output line 8
|
||||
output line 9
|
||||
output line 10
|
||||
output line 11
|
||||
output line 12
|
||||
✓ • 0ms
|
||||
|
||||
MCP: compact
|
||||
• Called example.js({"title":"Inspect page mcp","code":"await cua.getState()"})
|
||||
└ Full result for mcp
|
||||
|
||||
MCP: detailed
|
||||
• Called example.js({"title":"Inspect page mcp","code":"await cua.getState()"})
|
||||
└ Full result for mcp
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
source: tui/src/thread_transcript/tools_tests.rs
|
||||
expression: "snapshots.join(\"\\n\\n\")"
|
||||
---
|
||||
declined
|
||||
$ cargo check
|
||||
Declined
|
||||
first line
|
||||
indented output
|
||||
last line
|
||||
|
||||
opaque command
|
||||
$ C:\Program Files\Git\bin\bash.exe -lc "echo hi"
|
||||
Completed · exit 0
|
||||
first line
|
||||
indented output
|
||||
last line
|
||||
|
||||
UNC command
|
||||
$ \\server\share\tool.exe -arg
|
||||
Completed · exit 0
|
||||
first line
|
||||
indented output
|
||||
last line
|
||||
|
||||
completed interaction
|
||||
$ cargo check
|
||||
Terminal interaction · Completed · exit 0
|
||||
first line
|
||||
indented output
|
||||
last line
|
||||
|
||||
failed interaction
|
||||
$ cargo check
|
||||
Terminal interaction · Failed · exit 7
|
||||
first line
|
||||
indented output
|
||||
last line
|
||||
|
||||
failed interaction without exit code
|
||||
$ cargo check
|
||||
Terminal interaction · Failed
|
||||
first line
|
||||
indented output
|
||||
last line
|
||||
|
||||
pending interaction
|
||||
$ cargo check
|
||||
Terminal interaction · In progress at last update
|
||||
first line
|
||||
indented output
|
||||
last line
|
||||
@@ -0,0 +1,21 @@
|
||||
---
|
||||
source: tui/src/thread_transcript/tools_tests.rs
|
||||
assertion_line: 135
|
||||
expression: rendered
|
||||
---
|
||||
$ cargo check
|
||||
In progress at last update
|
||||
output line 1
|
||||
output line 2
|
||||
output line 3
|
||||
output line 4
|
||||
output line 5
|
||||
output line 6
|
||||
output line 7
|
||||
output line 8
|
||||
output line 9
|
||||
output line 10
|
||||
output line 11
|
||||
output line 12
|
||||
mcp tool: example/js · In progress at last update
|
||||
mcp tool: example/js · Completed · result unavailable
|
||||
248
codex-rs/tui/src/thread_transcript/tools.rs
Normal file
248
codex-rs/tui/src/thread_transcript/tools.rs
Normal file
@@ -0,0 +1,248 @@
|
||||
//! Share tool history conversion between live handlers and persisted transcript pages.
|
||||
//!
|
||||
//! These values contain presentation data only. Creating historical cells never starts a task,
|
||||
//! changes composer state, or leaves an animation running; pending items retain their last known
|
||||
//! status. Callers own chronological grouping.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::exec_cell::CommandOutput;
|
||||
use crate::exec_cell::ExecCell;
|
||||
use crate::exec_cell::new_active_exec_command;
|
||||
use crate::history_cell::McpInvocation;
|
||||
use crate::history_cell::McpToolCallCell;
|
||||
use crate::history_cell::PlainHistoryCell;
|
||||
use crate::history_cell::new_active_mcp_tool_call;
|
||||
use codex_app_server_protocol::CollabAgentTool;
|
||||
use codex_app_server_protocol::CollabAgentToolCallStatus;
|
||||
use codex_app_server_protocol::CommandExecutionSource;
|
||||
use codex_app_server_protocol::CommandExecutionStatus;
|
||||
use codex_app_server_protocol::McpToolCallStatus;
|
||||
use codex_app_server_protocol::ThreadItem;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::parse_command::ParsedCommand;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Preserve status and output when replay cannot reconstruct a rich completed tool cell.
|
||||
/// Declined commands never ran, and historical terminal interactions do not carry stdin details.
|
||||
/// Pending items retain their last known state without starting a running clock.
|
||||
pub(crate) fn historical_tool_fallback(item: &ThreadItem) -> Option<PlainHistoryCell> {
|
||||
let lines = match item {
|
||||
ThreadItem::CommandExecution {
|
||||
command,
|
||||
source,
|
||||
status,
|
||||
aggregated_output,
|
||||
exit_code,
|
||||
..
|
||||
} if matches!(
|
||||
status,
|
||||
CommandExecutionStatus::InProgress | CommandExecutionStatus::Declined
|
||||
) || *source == CommandExecutionSource::UnifiedExecInteraction
|
||||
|| replay_command_args(command).is_none() =>
|
||||
{
|
||||
let status_label = match status {
|
||||
CommandExecutionStatus::InProgress => "In progress at last update",
|
||||
CommandExecutionStatus::Completed => "Completed",
|
||||
CommandExecutionStatus::Failed => "Failed",
|
||||
CommandExecutionStatus::Declined => "Declined",
|
||||
};
|
||||
let mut status_line = if *source == CommandExecutionSource::UnifiedExecInteraction {
|
||||
format!("Terminal interaction · {status_label}")
|
||||
} else {
|
||||
status_label.to_string()
|
||||
};
|
||||
if matches!(
|
||||
status,
|
||||
CommandExecutionStatus::Completed | CommandExecutionStatus::Failed
|
||||
) && let Some(code) = exit_code
|
||||
{
|
||||
status_line.push_str(&format!(" · exit {code}"));
|
||||
}
|
||||
let mut lines: Vec<Line<'static>> = vec![
|
||||
vec!["$ ".dim(), command.clone().into()].into(),
|
||||
status_line.dim().into(),
|
||||
];
|
||||
if let Some(output) = aggregated_output {
|
||||
lines.extend(
|
||||
output
|
||||
.lines()
|
||||
.map(|line| vec![" ".dim(), line.trim_end().to_string().dim()].into()),
|
||||
);
|
||||
}
|
||||
lines
|
||||
}
|
||||
ThreadItem::McpToolCall {
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
result,
|
||||
error,
|
||||
..
|
||||
} if *status == McpToolCallStatus::InProgress || (result.is_none() && error.is_none()) => {
|
||||
let status = match status {
|
||||
McpToolCallStatus::InProgress => "In progress at last update",
|
||||
McpToolCallStatus::Completed => "Completed · result unavailable",
|
||||
McpToolCallStatus::Failed => "Failed · result unavailable",
|
||||
};
|
||||
vec![format!("mcp tool: {server}/{tool} · {status}").dim().into()]
|
||||
}
|
||||
ThreadItem::CollabAgentToolCall {
|
||||
tool:
|
||||
tool @ (CollabAgentTool::SpawnAgent
|
||||
| CollabAgentTool::SendInput
|
||||
| CollabAgentTool::CloseAgent
|
||||
| CollabAgentTool::ResumeAgent
|
||||
| CollabAgentTool::Wait),
|
||||
status,
|
||||
..
|
||||
} if *status != CollabAgentToolCallStatus::Completed => {
|
||||
let status = match status {
|
||||
CollabAgentToolCallStatus::InProgress => "In progress at last update",
|
||||
CollabAgentToolCallStatus::Failed => "Failed",
|
||||
CollabAgentToolCallStatus::Interrupted => "Interrupted",
|
||||
CollabAgentToolCallStatus::Completed => "Completed",
|
||||
};
|
||||
vec![format!("agent tool: {tool:?} · {status}").dim().into()]
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
Some(PlainHistoryCell::new(lines))
|
||||
}
|
||||
|
||||
// The protocol joins argv with shlex. Keep noncanonical legacy commands verbatim.
|
||||
fn replay_command_args(command: &str) -> Option<Vec<String>> {
|
||||
let args = shlex::split(command)?;
|
||||
(shlex::try_join(args.iter().map(String::as_str)).as_deref() == Ok(command)).then_some(args)
|
||||
}
|
||||
|
||||
pub(crate) struct CommandHistory {
|
||||
pub(crate) id: String,
|
||||
pub(crate) command: Vec<String>,
|
||||
pub(crate) parsed: Vec<ParsedCommand>,
|
||||
pub(crate) source: CommandExecutionSource,
|
||||
pub(crate) aggregated_output: String,
|
||||
pub(crate) exit_code: i32,
|
||||
pub(crate) duration: Duration,
|
||||
}
|
||||
|
||||
impl CommandHistory {
|
||||
pub(crate) fn from_item(item: ThreadItem) -> Option<Self> {
|
||||
let ThreadItem::CommandExecution {
|
||||
id,
|
||||
command,
|
||||
source,
|
||||
status,
|
||||
command_actions,
|
||||
aggregated_output,
|
||||
exit_code,
|
||||
duration_ms,
|
||||
..
|
||||
} = item
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
// Thread snapshots omit stdin, so `None` would falsely classify these as waits.
|
||||
if source == CommandExecutionSource::UnifiedExecInteraction {
|
||||
return None;
|
||||
}
|
||||
let exit_code = match status {
|
||||
CommandExecutionStatus::InProgress | CommandExecutionStatus::Declined => return None,
|
||||
CommandExecutionStatus::Completed => exit_code.unwrap_or_default(),
|
||||
CommandExecutionStatus::Failed => {
|
||||
exit_code.filter(|code| *code != 0).unwrap_or(/*default*/ 1)
|
||||
}
|
||||
};
|
||||
Some(Self {
|
||||
id,
|
||||
command: replay_command_args(&command)?,
|
||||
parsed: command_actions
|
||||
.into_iter()
|
||||
.map(codex_app_server_protocol::CommandAction::into_core)
|
||||
.collect(),
|
||||
source,
|
||||
aggregated_output: aggregated_output.unwrap_or_default(),
|
||||
exit_code,
|
||||
duration: Duration::from_millis(duration_ms.unwrap_or_default().max(/*other*/ 0) as u64),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn into_cell(self) -> ExecCell {
|
||||
let output = CommandOutput::new(self.exit_code, self.aggregated_output);
|
||||
let mut cell = new_active_exec_command(
|
||||
self.id.clone(),
|
||||
self.command,
|
||||
self.parsed,
|
||||
self.source,
|
||||
/*interaction_input*/ None,
|
||||
/*animations_enabled*/ false,
|
||||
);
|
||||
let completed = cell.complete_call(&self.id, output, self.duration);
|
||||
debug_assert!(completed, "new exec cell should contain {}", self.id);
|
||||
cell
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct McpHistory {
|
||||
pub(crate) id: String,
|
||||
pub(crate) invocation: McpInvocation,
|
||||
pub(crate) duration: Duration,
|
||||
pub(crate) result: Result<CallToolResult, String>,
|
||||
}
|
||||
|
||||
impl McpHistory {
|
||||
pub(crate) fn from_item(item: ThreadItem) -> Option<Self> {
|
||||
let ThreadItem::McpToolCall {
|
||||
id,
|
||||
server,
|
||||
tool,
|
||||
status,
|
||||
arguments,
|
||||
result,
|
||||
error,
|
||||
duration_ms,
|
||||
..
|
||||
} = item
|
||||
else {
|
||||
return None;
|
||||
};
|
||||
if status == McpToolCallStatus::InProgress {
|
||||
return None;
|
||||
}
|
||||
let result = match (result, error) {
|
||||
(_, Some(error)) => Err(error.message),
|
||||
(Some(result), None) => {
|
||||
let result = *result;
|
||||
Ok(CallToolResult {
|
||||
content: result.content,
|
||||
structured_content: result.structured_content,
|
||||
is_error: Some(status == McpToolCallStatus::Failed),
|
||||
meta: None,
|
||||
})
|
||||
}
|
||||
(None, None) => return None,
|
||||
};
|
||||
Some(Self {
|
||||
id,
|
||||
invocation: McpInvocation {
|
||||
server,
|
||||
tool,
|
||||
arguments: Some(arguments),
|
||||
},
|
||||
duration: Duration::from_millis(duration_ms.unwrap_or_default().max(/*other*/ 0) as u64),
|
||||
result,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn into_cell(self) -> McpToolCallCell {
|
||||
let mut cell =
|
||||
new_active_mcp_tool_call(self.id, self.invocation, /*animations_enabled*/ false);
|
||||
cell.complete(self.duration, self.result);
|
||||
cell
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "tools_tests.rs"]
|
||||
mod tests;
|
||||
338
codex-rs/tui/src/thread_transcript/tools_tests.rs
Normal file
338
codex-rs/tui/src/thread_transcript/tools_tests.rs
Normal file
@@ -0,0 +1,338 @@
|
||||
use super::*;
|
||||
use crate::test_support::PathBufExt;
|
||||
use crate::test_support::test_path_buf;
|
||||
use crate::thread_transcript::RawReasoningVisibility;
|
||||
use crate::thread_transcript::thread_items_to_transcript_cells;
|
||||
use codex_app_server_protocol::CommandAction;
|
||||
use codex_app_server_protocol::McpToolCallResult;
|
||||
use codex_utils_path_uri::LegacyAppPathString;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
|
||||
fn command_item(status: CommandExecutionStatus) -> ThreadItem {
|
||||
ThreadItem::CommandExecution {
|
||||
id: "command".to_string(),
|
||||
plugin_id: None,
|
||||
script_path: None,
|
||||
model_context: None,
|
||||
command: "cargo check".to_string(),
|
||||
cwd: LegacyAppPathString::from_string("/tmp/project"),
|
||||
process_id: None,
|
||||
source: CommandExecutionSource::Agent,
|
||||
status,
|
||||
command_actions: vec![CommandAction::Unknown {
|
||||
command: "cargo check".to_string(),
|
||||
}],
|
||||
aggregated_output: Some(
|
||||
(1..=12)
|
||||
.map(|line| format!("output line {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
),
|
||||
exit_code: Some(0),
|
||||
duration_ms: Some(-5),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_item(server: &str, id: &str) -> ThreadItem {
|
||||
ThreadItem::McpToolCall {
|
||||
id: id.to_string(),
|
||||
server: server.to_string(),
|
||||
tool: "js".to_string(),
|
||||
status: McpToolCallStatus::Completed,
|
||||
arguments: json!({"title": format!("Inspect page {id}"), "code": "await cua.getState()"}),
|
||||
app_context: None,
|
||||
mcp_app_resource_uri: None,
|
||||
plugin_id: None,
|
||||
read_only_hint: None,
|
||||
mcp_app_ui: None,
|
||||
result: Some(Box::new(McpToolCallResult {
|
||||
content: vec![json!({"type": "text", "text": format!("Full result for {id}")})],
|
||||
structured_content: None,
|
||||
meta: None,
|
||||
})),
|
||||
error: None,
|
||||
duration_ms: Some(5),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completed_tools_keep_compact_and_detailed_presentations() {
|
||||
let cwd = test_path_buf("/workspace").abs();
|
||||
let items = [
|
||||
command_item(CommandExecutionStatus::Completed),
|
||||
mcp_item("example", "mcp"),
|
||||
];
|
||||
let cells = thread_items_to_transcript_cells(
|
||||
/*thread_id*/ None,
|
||||
&cwd,
|
||||
items,
|
||||
RawReasoningVisibility::Hidden,
|
||||
/*config*/ None,
|
||||
);
|
||||
assert_eq!(cells.len(), 2);
|
||||
let rendered = ["Command", "MCP"]
|
||||
.into_iter()
|
||||
.zip(cells)
|
||||
.map(|(label, cell)| {
|
||||
assert_eq!(cell.transcript_animation_tick(), None);
|
||||
let display = cell
|
||||
.display_lines(/*width*/ 80)
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
let transcript = cell
|
||||
.transcript_lines(/*width*/ 80)
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("{label}: compact\n{display}\n\n{label}: detailed\n{transcript}")
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n\n");
|
||||
insta::assert_snapshot!("completed_tool_presentations", rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_commands_keep_failure_when_exit_code_is_missing_or_zero() {
|
||||
for exit in [None, Some(0)] {
|
||||
let mut item = command_item(CommandExecutionStatus::Failed);
|
||||
if let ThreadItem::CommandExecution { exit_code, .. } = &mut item {
|
||||
*exit_code = exit;
|
||||
}
|
||||
let command = CommandHistory::from_item(item).unwrap();
|
||||
assert_eq!((command.exit_code, command.duration), (1, Duration::ZERO));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn incomplete_tool_payloads_preserve_last_known_status_and_output() {
|
||||
let command = command_item(CommandExecutionStatus::InProgress);
|
||||
let mut mcp = mcp_item("example", "pending");
|
||||
if let ThreadItem::McpToolCall { status, result, .. } = &mut mcp {
|
||||
*status = McpToolCallStatus::InProgress;
|
||||
*result = None;
|
||||
}
|
||||
let mut missing_result = mcp_item("example", "completed");
|
||||
if let ThreadItem::McpToolCall { result, .. } = &mut missing_result {
|
||||
*result = None;
|
||||
}
|
||||
let cwd = test_path_buf("/workspace").abs();
|
||||
let rendered = thread_items_to_transcript_cells(
|
||||
/*thread_id*/ None,
|
||||
&cwd,
|
||||
[command, mcp, missing_result],
|
||||
RawReasoningVisibility::Hidden,
|
||||
/*config*/ None,
|
||||
)
|
||||
.into_iter()
|
||||
.flat_map(|cell| cell.display_lines(/*width*/ 80))
|
||||
.map(|line| line.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
insta::assert_snapshot!("pending_tool_presentations", rendered);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn historical_command_fallbacks_preserve_status_output_and_group_boundaries() {
|
||||
let cwd = test_path_buf("/workspace").abs();
|
||||
let exploration = |call_id: &str| {
|
||||
let mut item = command_item(CommandExecutionStatus::Completed);
|
||||
if let ThreadItem::CommandExecution {
|
||||
id,
|
||||
command,
|
||||
command_actions,
|
||||
..
|
||||
} = &mut item
|
||||
{
|
||||
*id = call_id.to_owned();
|
||||
*command = "cat README.md".to_owned();
|
||||
*command_actions = vec![CommandAction::Read {
|
||||
command: command.clone(),
|
||||
name: "README.md".to_owned(),
|
||||
path: LegacyAppPathString::from_string("/workspace/README.md"),
|
||||
}];
|
||||
}
|
||||
item
|
||||
};
|
||||
let mut snapshots = Vec::new();
|
||||
for (source, status, recorded_exit, label) in [
|
||||
(
|
||||
CommandExecutionSource::Agent,
|
||||
CommandExecutionStatus::Declined,
|
||||
None,
|
||||
"declined",
|
||||
),
|
||||
(
|
||||
CommandExecutionSource::Agent,
|
||||
CommandExecutionStatus::Completed,
|
||||
Some(0),
|
||||
"opaque command",
|
||||
),
|
||||
(
|
||||
CommandExecutionSource::Agent,
|
||||
CommandExecutionStatus::Completed,
|
||||
Some(0),
|
||||
"UNC command",
|
||||
),
|
||||
(
|
||||
CommandExecutionSource::UnifiedExecInteraction,
|
||||
CommandExecutionStatus::Completed,
|
||||
Some(0),
|
||||
"completed interaction",
|
||||
),
|
||||
(
|
||||
CommandExecutionSource::UnifiedExecInteraction,
|
||||
CommandExecutionStatus::Failed,
|
||||
Some(7),
|
||||
"failed interaction",
|
||||
),
|
||||
(
|
||||
CommandExecutionSource::UnifiedExecInteraction,
|
||||
CommandExecutionStatus::Failed,
|
||||
None,
|
||||
"failed interaction without exit code",
|
||||
),
|
||||
(
|
||||
CommandExecutionSource::UnifiedExecInteraction,
|
||||
CommandExecutionStatus::InProgress,
|
||||
None,
|
||||
"pending interaction",
|
||||
),
|
||||
] {
|
||||
let mut item = command_item(status);
|
||||
if let ThreadItem::CommandExecution {
|
||||
source: item_source,
|
||||
command,
|
||||
aggregated_output,
|
||||
exit_code,
|
||||
..
|
||||
} = &mut item
|
||||
{
|
||||
*item_source = source;
|
||||
if label == "opaque command" {
|
||||
*command = r#"C:\Program Files\Git\bin\bash.exe -lc "echo hi""#.to_owned();
|
||||
} else if label == "UNC command" {
|
||||
*command = r"\\server\share\tool.exe -arg".to_owned();
|
||||
}
|
||||
*aggregated_output = Some("first line\n indented output\nlast line".to_owned());
|
||||
*exit_code = recorded_exit;
|
||||
}
|
||||
let cells = thread_items_to_transcript_cells(
|
||||
/*thread_id*/ None,
|
||||
&cwd,
|
||||
[exploration("before"), item, exploration("after")],
|
||||
RawReasoningVisibility::Hidden,
|
||||
/*config*/ None,
|
||||
);
|
||||
assert_eq!(cells.len(), 3);
|
||||
for cell in [&cells[0], &cells[2]] {
|
||||
assert!(
|
||||
cell.as_any()
|
||||
.downcast_ref::<ExecCell>()
|
||||
.unwrap()
|
||||
.is_exploring_cell()
|
||||
);
|
||||
}
|
||||
let [compact, detailed, raw] = [
|
||||
cells[1].display_lines(/*width*/ 80),
|
||||
cells[1].transcript_lines(/*width*/ 80),
|
||||
cells[1].raw_lines(),
|
||||
]
|
||||
.map(|lines| {
|
||||
lines
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
});
|
||||
assert_eq!((&detailed, &raw), (&compact, &compact));
|
||||
snapshots.push(format!("{label}\n{compact}"));
|
||||
}
|
||||
insta::assert_snapshot!(snapshots.join("\n\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_tool_fallbacks_preserve_status_without_duplicating_v2_activity() {
|
||||
let cwd = test_path_buf("/workspace").abs();
|
||||
let mut snapshots = Vec::new();
|
||||
for (tool, status) in [
|
||||
(
|
||||
CollabAgentTool::SpawnAgent,
|
||||
CollabAgentToolCallStatus::InProgress,
|
||||
),
|
||||
(
|
||||
CollabAgentTool::SendInput,
|
||||
CollabAgentToolCallStatus::Failed,
|
||||
),
|
||||
(
|
||||
CollabAgentTool::CloseAgent,
|
||||
CollabAgentToolCallStatus::Interrupted,
|
||||
),
|
||||
(
|
||||
CollabAgentTool::ResumeAgent,
|
||||
CollabAgentToolCallStatus::Failed,
|
||||
),
|
||||
(
|
||||
CollabAgentTool::Wait,
|
||||
CollabAgentToolCallStatus::Interrupted,
|
||||
),
|
||||
(
|
||||
CollabAgentTool::SendMessage,
|
||||
CollabAgentToolCallStatus::InProgress,
|
||||
),
|
||||
(
|
||||
CollabAgentTool::FollowupTask,
|
||||
CollabAgentToolCallStatus::InProgress,
|
||||
),
|
||||
(
|
||||
CollabAgentTool::InterruptAgent,
|
||||
CollabAgentToolCallStatus::InProgress,
|
||||
),
|
||||
(
|
||||
CollabAgentTool::ListAgents,
|
||||
CollabAgentToolCallStatus::InProgress,
|
||||
),
|
||||
] {
|
||||
let visible = matches!(
|
||||
tool,
|
||||
CollabAgentTool::SpawnAgent
|
||||
| CollabAgentTool::SendInput
|
||||
| CollabAgentTool::CloseAgent
|
||||
| CollabAgentTool::ResumeAgent
|
||||
| CollabAgentTool::Wait
|
||||
);
|
||||
let item = ThreadItem::CollabAgentToolCall {
|
||||
id: "pending-agent-call".to_string(),
|
||||
tool,
|
||||
status,
|
||||
sender_thread_id: "00000000-0000-0000-0000-000000000001".to_string(),
|
||||
receiver_thread_ids: vec!["00000000-0000-0000-0000-000000000002".to_string()],
|
||||
prompt: Some("Inspect the parser".to_string()),
|
||||
model: None,
|
||||
reasoning_effort: None,
|
||||
agents_states: Default::default(),
|
||||
};
|
||||
let cells = thread_items_to_transcript_cells(
|
||||
/*thread_id*/ None,
|
||||
&cwd,
|
||||
[item],
|
||||
RawReasoningVisibility::Hidden,
|
||||
/*config*/ None,
|
||||
);
|
||||
assert_eq!(cells.len(), usize::from(visible));
|
||||
for cell in cells {
|
||||
assert_eq!(cell.transcript_animation_tick(), None);
|
||||
let text = cell
|
||||
.transcript_lines(/*width*/ 80)
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
snapshots.push(text);
|
||||
}
|
||||
}
|
||||
insta::assert_snapshot!(snapshots.join("\n"));
|
||||
}
|
||||
Reference in New Issue
Block a user