use crate::{
providers::ToolCall,
tool_display::{
pending_tool_status, pending_tool_summary_metadata, should_show_pending_tool,
tool_summary_metadata,
},
tools::ToolResult,
};
use crate::output::{
ActivityStatus, ToolDisplaySummary, ToolStatus, redact_sensitive_text, tool_display_label,
};
fn unicode_mark(status: ToolStatus) -> &'static str {
match status {
ToolStatus::Running | ToolStatus::Writing => "⟳",
ToolStatus::Success => "✓",
ToolStatus::Failure => "✗",
}
}
fn ascii_mark(status: ToolStatus) -> &'static str {
match status {
ToolStatus::Running => "RUNNING",
ToolStatus::Writing => "WRITING",
ToolStatus::Success => "OK",
ToolStatus::Failure => "FAIL",
}
}
pub(crate) fn pending_tool_display_summary(call: &ToolCall) -> Option<ToolDisplaySummary> {
if !should_show_pending_tool(call) {
return None;
}
let status = pending_tool_status(call);
let metadata = pending_tool_summary_metadata(call)
.into_iter()
.map(|(key, value)| (key, redact_sensitive_text(&value)))
.collect();
Some(ToolDisplaySummary {
tool_name: call.name.clone(),
status,
unicode_mark: unicode_mark(status),
ascii_mark: ascii_mark(status),
label: tool_display_label(call),
metadata,
})
}
pub(crate) fn tool_status_label(status: ToolStatus) -> &'static str {
match status {
ToolStatus::Running => "running",
ToolStatus::Writing => "writing",
ToolStatus::Success => "success",
ToolStatus::Failure => "failure",
}
}
pub(crate) fn pending_activity_status(call: &ToolCall) -> ActivityStatus {
match pending_tool_status(call) {
ToolStatus::Writing => ActivityStatus::Writing,
_ => ActivityStatus::Running,
}
}
pub(crate) fn tool_display_summary(call: &ToolCall, result: &ToolResult) -> ToolDisplaySummary {
let tool_name = if result.tool_name.is_empty() {
call.name.clone()
} else {
result.tool_name.clone()
};
let status = if result.success {
ToolStatus::Success
} else {
ToolStatus::Failure
};
let label = tool_display_label(call);
let metadata = tool_summary_metadata(call, result)
.into_iter()
.map(|(key, value)| (key, redact_sensitive_text(&value)))
.collect();
ToolDisplaySummary {
tool_name,
status,
unicode_mark: unicode_mark(status),
ascii_mark: ascii_mark(status),
label,
metadata,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{providers::ToolCall, tools::ToolResult};
use serde_json::{Value, json};
fn call(name: &str, arguments: Value) -> ToolCall {
ToolCall {
id: "call_1".to_string(),
name: name.to_string(),
arguments,
}
}
fn result(name: &str, success: bool, content: &str, metadata: Value) -> ToolResult {
ToolResult {
tool_name: name.to_string(),
success,
content: content.to_string(),
metadata,
display: crate::tools::ToolResultDisplay::default(),
}
}
#[test]
fn bash_summary_is_deterministic() {
let summary = tool_display_summary(
&call(
"bash",
json!({"command":"cargo test parser", "timeout": 10}),
),
&result(
"bash",
false,
"one\ntwo\n",
json!({"exit_code":101,"timed_out":false}),
),
);
assert_eq!(summary.tool_name, "bash");
assert_eq!(summary.status, ToolStatus::Failure);
assert_eq!(summary.unicode_mark, "✗");
assert_eq!(summary.ascii_mark, "FAIL");
assert_eq!(summary.label, "bash cargo test parser");
assert_eq!(
summary.metadata,
vec![
("timeout".to_string(), "10".to_string()),
("exit_code".to_string(), "101".to_string()),
("timed_out".to_string(), "false".to_string()),
("output_lines".to_string(), "2".to_string()),
]
);
}
#[test]
fn hash_edit_summary_uses_input_bytes_without_payloads() {
let summary = tool_display_summary(
&call(
"hash_edit",
json!({"input":"[src/lib.rs#ABCD]\nSWAP 1.=1:\n+other"}),
),
&result("hash_edit", true, "applied", json!({})),
);
assert_eq!(summary.status, ToolStatus::Success);
assert_eq!(summary.unicode_mark, "✓");
assert_eq!(summary.ascii_mark, "OK");
assert_eq!(summary.label, "hash_edit");
assert!(summary.metadata.is_empty());
}
#[test]
fn read_summary_uses_path_and_range_metadata() {
let summary = tool_display_summary(
&call("read", json!({"path":"src/main.rs","offset":2,"limit":4})),
&result("read", true, "a\nb\nc", json!({})),
);
assert_eq!(summary.label, "read src/main.rs");
assert_eq!(
summary.metadata,
vec![
("offset".to_string(), "2".to_string()),
("limit".to_string(), "4".to_string()),
("output_lines".to_string(), "3".to_string()),
]
);
}
#[test]
fn read_summary_uses_file_count_for_multi_file_args() {
let summary = tool_display_summary(
&call("read", json!({"paths":["a.txt", "b.txt"]})),
&result(
"read",
true,
"--- FILE: a.txt ---\na\n\n--- FILE: b.txt ---\nb",
json!({}),
),
);
assert_eq!(summary.label, "read 2 files");
assert_eq!(
summary.metadata,
vec![
("files".to_string(), "2".to_string()),
("output_lines".to_string(), "5".to_string()),
]
);
}
#[test]
fn write_and_grep_summaries_use_safe_metadata() {
let write = tool_display_summary(
&call("write", json!({"path":"src/lib.rs","content":"secret"})),
&result("write", true, "wrote", json!({"bytes":6})),
);
assert_eq!(write.label, "write src/lib.rs");
assert_eq!(write.metadata, vec![("bytes".to_string(), "6".to_string())]);
let grep = tool_display_summary(
&call("grep", json!({"patterns":["TODO"],"path":"src","limit":5})),
&result(
"grep",
true,
"src/lib.rs:1:TODO\n",
json!({"matches_returned":1}),
),
);
assert_eq!(grep.label, "grep TODO");
assert_eq!(
grep.metadata,
vec![
("path".to_string(), "src".to_string()),
("limit".to_string(), "5".to_string()),
("matches_returned".to_string(), "1".to_string()),
("output_lines".to_string(), "1".to_string()),
]
);
let find = tool_display_summary(
&call(
"find",
json!({"query":"sk-testSecret123456","path":"src","limit":5}),
),
&result("find", true, "src/lib.rs\n", json!({"matches_returned":1})),
);
assert_eq!(find.label, "find <redacted>");
assert_eq!(
find.metadata,
vec![
("path".to_string(), "src".to_string()),
("limit".to_string(), "5".to_string()),
("matches_returned".to_string(), "1".to_string()),
("output_lines".to_string(), "1".to_string()),
]
);
}
#[test]
fn subagents_summary_uses_aggregate_safe_metadata() {
let summary = tool_display_summary(
&call(
"subagents",
json!({"tasks":[{"intent":"inspect"},{"intent":"test"}],"concurrency":4}),
),
&result(
"subagents",
true,
"{\"summary\":{}}",
json!({"summary":{"total":2,"completed":2,"failed":0,"total_tokens":12345}}),
),
);
assert_eq!(summary.label, "subagents · 2 tasks");
assert_eq!(
summary.metadata,
vec![
("tasks".to_string(), "2".to_string()),
("total_tokens".to_string(), "12345".to_string()),
("total".to_string(), "2".to_string()),
("completed".to_string(), "2".to_string()),
("failed".to_string(), "0".to_string()),
]
);
}
#[test]
fn subagents_summary_includes_selected_task_identities() {
let summary = tool_display_summary(
&call(
"subagents",
json!({"tasks":[{"intent":"inspect","identity":"frontend-dev"},{"intent":"test"}],"concurrency":2}),
),
&result(
"subagents",
true,
"{}",
json!({"summary":{"total":2,"completed":2,"failed":0}}),
),
);
assert_eq!(summary.label, "subagents · 2 tasks");
assert_eq!(
summary.metadata,
vec![
("tasks".to_string(), "2".to_string()),
("identities".to_string(), "g1=frontend-dev".to_string()),
("total".to_string(), "2".to_string()),
("completed".to_string(), "2".to_string()),
("failed".to_string(), "0".to_string()),
]
);
}
#[test]
fn subagents_summary_omits_identity_metadata_when_no_identity_selected() {
let summary = tool_display_summary(
&call(
"subagents",
json!({"tasks":[{"intent":"inspect"},{"intent":"test"}],"concurrency":2}),
),
&result(
"subagents",
true,
"{}",
json!({"summary":{"total":2,"completed":2,"failed":0}}),
),
);
assert!(!summary.metadata.iter().any(|(key, _)| key == "identities"));
}
#[test]
fn subagents_rendering_does_not_leak_child_secret_payloads() {
let secret = format!("sk-{}", "x".repeat(24));
let summary = tool_display_summary(
&call(
"subagents",
json!({"tasks":[{"intent":"inspect"}],"concurrency":1}),
),
&result(
"subagents",
false,
&format!(
r#"{{"results":[{{"id":"g1","output":"{secret}","error":"token={secret}"}}]}}"#
),
json!({"summary":{"total":1,"completed":0,"failed":1}}),
),
);
assert_eq!(summary.label, "subagents · 1 task");
assert_eq!(
summary.metadata,
vec![
("tasks".to_string(), "1".to_string()),
("total".to_string(), "1".to_string()),
("completed".to_string(), "0".to_string()),
("failed".to_string(), "1".to_string()),
]
);
assert!(!format!("{summary:?}").contains(&secret));
}
#[test]
fn activity_renderer_redacts_secret_values() {
let secret = format!("sk-{}", "x".repeat(24));
let summary = tool_display_summary(
&call("bash", json!({"command": format!("echo {secret}")})),
&result("bash", true, "ok", json!({})),
);
assert!(summary.label.contains("<redacted>"));
assert!(!summary.label.contains(&secret));
}
#[test]
fn search_summaries_show_queries_results_and_pending_state() {
let pending = pending_tool_display_summary(&call(
"web",
json!({"operation":"search", "query":"rust"}),
))
.unwrap();
assert_eq!(pending.label, "web rust");
assert!(pending.metadata.is_empty());
let web = tool_display_summary(
&call("web", json!({"operation":"search", "query":"rust"})),
&result(
"web",
true,
"ok",
json!({"results":2,"truncated":false,"timeout":false}),
),
);
assert_eq!(web.label, "web rust");
assert_eq!(
web.metadata,
vec![
("results".to_string(), "2".to_string()),
("truncated".to_string(), "false".to_string()),
("timeout".to_string(), "false".to_string()),
]
);
}
#[test]
fn unknown_summary_keeps_minimal_tool_identity() {
let summary = tool_display_summary(
&call("custom", json!({"path":"safe.txt","token":"secret"})),
&result("custom", true, "ok", json!({"exit_code":0})),
);
assert_eq!(summary.tool_name, "custom");
assert_eq!(summary.label, "custom");
assert!(summary.metadata.is_empty());
}
#[test]
fn pending_write_summary_uses_path_and_content_bytes_without_content() {
let secret = "secret payload";
let summary = pending_tool_display_summary(&call(
"write",
json!({"path":"src/lib.rs","content":secret}),
))
.unwrap();
assert_eq!(summary.status, ToolStatus::Writing);
assert_eq!(summary.unicode_mark, "⟳");
assert_eq!(summary.ascii_mark, "WRITING");
assert_eq!(summary.label, "write src/lib.rs");
assert_eq!(
summary.metadata,
vec![("bytes".to_string(), "14".to_string())]
);
assert!(!format!("{summary:?}").contains(secret));
}
#[test]
fn pending_hash_edit_summary_uses_input_bytes_without_payloads() {
let summary = pending_tool_display_summary(&call(
"hash_edit",
json!({"input":"[src/lib.rs#ABCD]\nSWAP 1.=1:\n+other"}),
))
.unwrap();
assert_eq!(summary.status, ToolStatus::Running);
assert_eq!(summary.label, "hash_edit");
assert_eq!(
summary.metadata,
vec![("bytes".to_string(), "35".to_string())]
);
let debug = format!("{summary:?}");
assert!(!debug.contains("SWAP"));
assert!(!debug.contains("secret"));
}
#[test]
fn non_mutating_tool_has_no_pending_transcript_summary() {
assert!(
pending_tool_display_summary(&call("read", json!({"path":"src/lib.rs"}))).is_none()
);
}
}