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:
Eric Traut
2026-09-19 20:24:41 +00:00
committed by copyberry
parent 321c50fc2c
commit 4abcb8d1da
18 changed files with 1689 additions and 231 deletions
@@ -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;
@@ -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
View 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;
@@ -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"));
}