mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +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:
@@ -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
|
||||
");
|
||||
}
|
||||
+9
@@ -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
|
||||
+34
@@ -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
|
||||
+52
@@ -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
|
||||
+21
@@ -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
|
||||
@@ -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"));
|
||||
}
|
||||
Reference in New Issue
Block a user