use std::io::Write;
use crate::{
providers::ToolCall,
tool_display::{
pending_tool_status, pending_tool_summary_metadata, should_show_pending_tool,
tool_summary_metadata,
},
tools::ToolResult,
};
use super::{ascii_mark, rich::RichSection, unicode_mark};
use crate::output::{
ActivityStatus, OutputEvent, OutputMode, OutputRenderer, PlainOutputRenderer,
RichTranscriptRenderer, ToolDisplaySummary, ToolStatus, redact_sensitive_text,
tool_display_label,
};
pub(crate) enum CliRenderer<'a, W: Write> {
Plain(PlainOutputRenderer<'a, W>),
Rich(RichTranscriptRenderer<'a, W>),
}
impl<'a, W: Write> CliRenderer<'a, W> {
pub(crate) fn new(writer: &'a mut W, mode: OutputMode) -> Self {
match mode {
OutputMode::Plain => Self::Plain(PlainOutputRenderer::new(writer)),
OutputMode::Rich(style) => Self::Rich(RichTranscriptRenderer::new(writer, style)),
}
}
pub(crate) fn render_assistant_delta(&mut self, text: &str) -> anyhow::Result<()> {
match self {
Self::Plain(renderer) => renderer.render_assistant_delta(text),
Self::Rich(renderer) => renderer.render_assistant_delta(text),
}
}
pub(crate) fn render_tool_block(&mut self, block: &str) -> anyhow::Result<()> {
match self {
Self::Plain(renderer) => renderer.render_tool_block(block),
Self::Rich(renderer) => {
renderer.flush_thinking()?;
renderer.flush_assistant()?;
if !renderer.at_line_start {
writeln!(renderer.writer)?;
}
renderer.begin_section(RichSection::Tool, "Tool")?;
for line in block.lines() {
writeln!(renderer.writer, " {line}")?;
}
renderer.writer.flush()?;
Ok(())
}
}
}
}
impl<W: Write> OutputRenderer for CliRenderer<'_, W> {
fn render(&mut self, event: &OutputEvent) -> anyhow::Result<()> {
match self {
Self::Plain(renderer) => renderer.render(event),
Self::Rich(renderer) => renderer.render(event),
}
}
fn finish(&mut self) -> anyhow::Result<()> {
match self {
Self::Plain(renderer) => renderer.finish(),
Self::Rich(renderer) => renderer.finish(),
}
}
}
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 format_pending_tool_block(summary: &ToolDisplaySummary) -> String {
let mut lines = Vec::new();
lines.push(format!("STATUS: {}", tool_status_label(summary.status)));
lines.push(format!("LABEL: {}", summary.label));
for (key, value) in &summary.metadata {
lines.push(format!("{}: {}", key.to_ascii_uppercase(), value));
}
let mut block = lines.join("\n");
block.push('\n');
block
}
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,
}
}
#[test]
fn automatic_prompt_and_compaction_statuses_are_visible() {
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::AutomaticUserPrompt {
text: "continue".to_string(),
})
.unwrap();
renderer.render(&OutputEvent::CompactionStarted).unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("you [automatic]: continue"));
assert!(text.contains("compact: started"));
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{output::*, providers::ToolCall, tools::ToolResult};
use serde_json::{Value, json};
use std::path::PathBuf;
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 redact_sensitive_text_redacts_base64_data_urls() {
let text = redact_sensitive_text("before data:image/png;base64,YWJj after");
assert_eq!(text, "before data:<redacted>;base64,<redacted> after");
assert!(!text.contains("data:image/png;base64"));
assert!(!text.contains("YWJj"));
}
#[test]
fn plain_renderer_redacts_split_assistant_secret_across_deltas() {
let assistant_secret = "sentinelAssistantToken123";
let thinking_secret = "sentinelThinkingToken123";
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::AssistantDelta {
text: "assistant api_".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "key=".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: format!("{assistant_secret}\n"),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: "thinking Bear".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: "er ".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: format!("{thinking_secret}\n"),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryComplete {
text: format!("thinking Bearer {thinking_secret}\n"),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("api_key=<redacted>"), "{text}");
assert!(text.contains("Bearer <redacted>"), "{text}");
assert!(!text.contains(assistant_secret), "{text}");
assert!(!text.contains(thinking_secret), "{text}");
}
#[test]
fn rich_renderer_redacts_assistant_and_user_prompt_secrets() {
let user_secret = "sentinelUserSecret123";
let assistant_secret = "sentinelAssistantBearer123";
let thinking_secret = "sentinelRichThinking123";
let mut output = Vec::new();
let mut renderer = RichTranscriptRenderer::new(
&mut output,
RichStyle {
color: false,
unicode: false,
},
);
renderer
.render(&OutputEvent::UserPrompt {
text: format!("please use api_key={user_secret}"),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "assistant Bear".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "er ".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: format!("{assistant_secret}\n"),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: "api_".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: format!("key={thinking_secret}\n"),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("api_key=<redacted>"), "{text}");
assert!(text.contains("Bearer <redacted>"), "{text}");
assert!(!text.contains(user_secret), "{text}");
assert!(!text.contains(assistant_secret), "{text}");
assert!(!text.contains(thinking_secret), "{text}");
}
#[test]
fn output_mode_uses_rich_only_for_tty() {
assert_eq!(output_mode(false, false, false), OutputMode::Plain);
assert_eq!(
output_mode(false, true, false),
OutputMode::Rich(RichStyle {
color: true,
unicode: false,
})
);
assert_eq!(
output_mode(true, true, true),
OutputMode::Rich(RichStyle {
color: true,
unicode: true,
})
);
assert_eq!(
output_mode(true, false, true),
OutputMode::Rich(RichStyle {
color: false,
unicode: true,
})
);
}
#[test]
fn rich_renderer_outputs_transcript_sections_without_duplicate_assistant() {
let call = call("bash", json!({"command":"cargo test parser"}));
let result = result("bash", false, "error\n", json!({"exit_code":101}));
let summary = tool_display_summary(&call, &result);
let mut output = Vec::new();
let mut renderer = RichTranscriptRenderer::new(
&mut output,
RichStyle {
color: false,
unicode: false,
},
);
renderer
.render(&OutputEvent::SessionHeader {
session_id: Some("abc123".to_string()),
model: "gpt-test".to_string(),
cwd: PathBuf::from("/repo"),
})
.unwrap();
renderer
.render(&OutputEvent::UserPrompt {
text: "Fix test".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "I'll check".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: " it.\nDone".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantComplete {
text: "I'll check it.\nDone".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ToolResult {
call: Box::new(call),
result: Box::new(result),
summary: Box::new(summary),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("magi-code · session abc123 · model gpt-test · cwd /repo\n\nYou\n"));
assert!(text.contains(
"You\n Fix test\n\nAssistant\n I'll check it.\n Done\n\nTool · bash\n FAIL bash cargo test parser\n exit 101 · lines 1\n"
));
assert_eq!(text.matches("Assistant").count(), 1);
assert_eq!(text.matches("I'll check it.").count(), 1);
assert!(!text.contains("\x1b["));
}
#[test]
fn rich_renderer_can_emit_color_and_unicode_for_tty_policy() {
let call = call("read", json!({"path":"src/main.rs"}));
let result = result("read", true, "hello", json!({}));
let summary = tool_display_summary(&call, &result);
let mut output = Vec::new();
let mut renderer = RichTranscriptRenderer::new(
&mut output,
RichStyle {
color: true,
unicode: true,
},
);
renderer
.render(&OutputEvent::SessionHeader {
session_id: None,
model: "gpt-test".to_string(),
cwd: PathBuf::from("/repo"),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "hello".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ToolResult {
call: Box::new(call),
result: Box::new(result),
summary: Box::new(summary),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("\x1b[1mAssistant\x1b[0m"));
assert!(text.contains("\x1b[1mTool · read\x1b[0m"));
assert!(text.contains("\x1b[32m✓\x1b[0m read src/main.rs"));
assert!(!text.contains("OK read src/main.rs"));
}
#[test]
fn rich_renderer_no_color_uses_ascii_status_marks() {
let call = call("read", json!({"path":"src/main.rs"}));
let result = result("read", true, "hello", json!({}));
let summary = tool_display_summary(&call, &result);
let mut output = Vec::new();
let mut renderer = RichTranscriptRenderer::new(
&mut output,
RichStyle {
color: false,
unicode: false,
},
);
renderer
.render(&OutputEvent::ToolResult {
call: Box::new(call),
result: Box::new(result),
summary: Box::new(summary),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Tool · read\n OK read src/main.rs\n"));
assert!(!text.contains('✓'));
assert!(!text.contains("\x1b["));
}
#[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!({"pattern":"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 plain_renderer_outputs_thinking_summary_between_assistant_and_tool() {
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::AssistantDelta {
text: "answer".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryComplete {
text: "provider summary".to_string(),
})
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(
text.contains("answer\n--- THINKING SUMMARY ---\nprovider summary"),
"{text}"
);
}
#[test]
fn plain_renderer_streams_thinking_deltas_as_one_block() {
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: "think".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryDelta {
text: "ing".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ThinkingSummaryComplete {
text: "thinking".to_string(),
})
.unwrap();
let text = String::from_utf8(output).unwrap();
assert_eq!(
text.matches("--- THINKING SUMMARY ---").count(),
1,
"{text}"
);
assert_eq!(
text.matches("--- END THINKING SUMMARY ---").count(),
1,
"{text}"
);
assert!(
text.contains("--- THINKING SUMMARY ---\nthinking\n--- END THINKING SUMMARY ---"),
"{text}"
);
}
#[test]
fn rich_renderer_outputs_distinct_thinking_summary_section() {
let mut output = Vec::new();
let mut renderer = RichTranscriptRenderer::new(
&mut output,
RichStyle {
color: false,
unicode: false,
},
);
renderer
.render(&OutputEvent::ThinkingSummaryComplete {
text: "provider summary".to_string(),
})
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(
text.contains("Thinking summary\n provider summary"),
"{text}"
);
}
#[test]
fn redact_sensitive_text_handles_common_secret_shapes_without_counts() {
let redacted = redact_sensitive_text(
r#"Bearer abcdefghijk password = "open sesame" token=tokensecret {"accountId":"acct-secret-123","access_token":"plain-secret","api_key":"api-secret","input_tokens":42,"token_estimate":9,"status":"error"} cwd=/tmp/project id=call_123"#,
);
assert!(redacted.contains("Bearer <redacted>"));
assert!(redacted.contains("password = <redacted>"));
assert!(redacted.contains("token=<redacted>"));
assert!(redacted.contains(r#""accountId":<redacted>"#));
assert!(redacted.contains(r#""access_token":<redacted>"#));
assert!(redacted.contains(r#""api_key":<redacted>"#));
assert!(redacted.contains(r#""input_tokens":42"#));
assert!(redacted.contains(r#""token_estimate":9"#));
assert!(redacted.contains(r#""status":"error""#));
assert!(redacted.contains("cwd=/tmp/project"));
assert!(!redacted.contains("abcdefghijk"));
assert!(!redacted.contains("open sesame"));
assert!(!redacted.contains("tokensecret"));
assert!(!redacted.contains("acct-secret-123"));
assert!(!redacted.contains("plain-secret"));
assert!(!redacted.contains("api-secret"));
}
#[test]
fn redact_sensitive_text_redacts_bearer_tokens() {
let redacted = redact_sensitive_text("server replied Bearer sentinelBearerToken123");
assert_eq!(redacted, "server replied Bearer <redacted>");
assert!(!redacted.contains("sentinelBearerToken123"));
}
#[test]
fn redact_sensitive_text_redacts_api_key_assignments() {
let redacted = redact_sensitive_text("api_key = sentinel-api-key");
assert_eq!(redacted, "api_key = <redacted>");
assert!(!redacted.contains("sentinel-api-key"));
}
#[test]
fn redact_sensitive_text_redacts_exa_api_key_assignments() {
let redacted = redact_sensitive_text(
"EXA_API_KEY=sk-exaSecret123456 x-api-key: sk-headerSecret123456",
);
assert!(redacted.contains("EXA_API_KEY=<redacted>"));
assert!(redacted.contains("x-api-key: <redacted>"));
assert!(!redacted.contains("sk-exaSecret"));
assert!(!redacted.contains("sk-headerSecret"));
}
#[test]
fn redact_sensitive_text_redacts_oauth_token_keys() {
let redacted = redact_sensitive_text(
r#"access_token="sentinel-access" refresh_token='sentinel-refresh' id_token=sentinel-id"#,
);
assert!(redacted.contains("access_token=<redacted>"));
assert!(redacted.contains("refresh_token=<redacted>"));
assert!(redacted.contains("id_token=<redacted>"));
assert!(!redacted.contains("sentinel-access"));
assert!(!redacted.contains("sentinel-refresh"));
assert!(!redacted.contains("sentinel-id"));
}
#[test]
fn redact_sensitive_text_preserves_non_secret_text() {
let text = "cwd=/tmp/project input_tokens=42 status=ok model=demo";
assert_eq!(redact_sensitive_text(text), text);
}
#[test]
fn credential_like_key_does_not_match_usage_counts() {
assert!(!is_credential_like_key("input_tokens"));
assert!(!is_credential_like_key("output_token_count"));
assert!(!is_credential_like_key("token_estimate"));
assert!(is_credential_like_key("access_token"));
assert!(is_credential_like_key("api_key"));
assert!(!is_credential_like_key("key"));
}
#[test]
fn search_summaries_show_queries_results_and_pending_state() {
let pending =
pending_tool_display_summary(&call("web_search", json!({"queries":["rust", "cargo"]})))
.unwrap();
assert_eq!(pending.label, "web_search 2 queries");
assert_eq!(
pending.metadata,
vec![("query_count".to_string(), "2".to_string())]
);
let web = tool_display_summary(
&call("web_search", json!({"queries":["rust"]})),
&result(
"web_search",
true,
"ok",
json!({"query_count":1,"total_results":2,"truncated":false,"timeout":false}),
),
);
assert_eq!(web.label, "web_search rust");
assert_eq!(
web.metadata,
vec![
("query_count".to_string(), "1".to_string()),
("results".to_string(), "2".to_string()),
("truncated".to_string(), "false".to_string()),
("timeout".to_string(), "false".to_string()),
]
);
let code = tool_display_summary(
&call("code_search", json!({"query":"reqwest timeout"})),
&result(
"code_search",
true,
"ok",
json!({"results":3,"truncated":true}),
),
);
assert_eq!(code.label, "code_search reqwest timeout");
assert_eq!(
code.metadata,
vec![
("results".to_string(), "3".to_string()),
("truncated".to_string(), "true".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()
);
}
#[test]
fn plain_renderer_outputs_pending_write_before_final_result() {
let call = call(
"write",
json!({"path":"file.txt","content":"secret payload"}),
);
let result = result("write", true, "wrote", json!({"bytes":14}));
let summary = tool_display_summary(&call, &result);
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::ToolStarted {
call: Box::new(call.clone()),
label: tool_display_label(&call),
})
.unwrap();
renderer
.render(&OutputEvent::ToolResult {
call: Box::new(call),
result: Box::new(result),
summary: Box::new(summary),
})
.unwrap();
let text = String::from_utf8(output).unwrap();
let running = text.find("STATUS: writing").unwrap();
let success = text.find("STATUS: success").unwrap();
assert!(running < success);
assert!(text.contains("LABEL: write file.txt"));
assert!(text.contains("BYTES: 14"));
assert!(!text.contains("secret payload"));
}
#[test]
fn plain_renderer_outputs_pending_hash_edit_before_final_failure() {
let call = call(
"hash_edit",
json!({"input":"[file.txt#ABCD]\nSWAP 1.=1:\n+other"}),
);
let result = result("hash_edit", false, "missing text", json!({}));
let summary = tool_display_summary(&call, &result);
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::ToolStarted {
call: Box::new(call.clone()),
label: tool_display_label(&call),
})
.unwrap();
renderer
.render(&OutputEvent::ToolResult {
call: Box::new(call),
result: Box::new(result),
summary: Box::new(summary),
})
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.find("STATUS: running").unwrap() < text.find("STATUS: failure").unwrap());
assert!(text.contains("BYTES: 33"));
assert!(!text.contains("SWAP"));
assert!(!text.contains("secret"));
}
#[test]
fn rich_renderer_outputs_pending_write_with_safe_metadata() {
let call = call(
"write",
json!({"path":"file.txt","content":"secret payload"}),
);
let mut output = Vec::new();
let mut renderer = RichTranscriptRenderer::new(
&mut output,
RichStyle {
color: false,
unicode: false,
},
);
renderer
.render(&OutputEvent::ToolStarted {
call: Box::new(call),
label: "write file.txt".to_string(),
})
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("Tool · write\n WRITING write file.txt\n bytes 14\n"));
assert!(!text.contains("secret payload"));
}
#[test]
fn pending_tool_start_after_assistant_delta_preserves_newline_hygiene() {
let call = call("write", json!({"path":"file.txt","content":"hi"}));
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::AssistantDelta {
text: "before".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ToolStarted {
call: Box::new(call),
label: "write file.txt".to_string(),
})
.unwrap();
let text = String::from_utf8(output).unwrap();
assert!(text.contains("before\nSTATUS: writing"));
}
#[test]
fn plain_renderer_keeps_sequential_write_hash_edit_lifecycle_order_readable() {
let write_call = call("write", json!({"path":"a.txt","content":"alpha"}));
let write_result = result("write", true, "wrote 5 bytes", json!({"bytes":5}));
let edit_call = call(
"hash_edit",
json!({"input":"[a.txt#ABCD]\nSWAP 1.=1:\n+beta"}),
);
let edit_result = result("hash_edit", true, "applied 1 edit", json!({}));
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::AssistantDelta {
text: "before".to_string(),
})
.unwrap();
renderer
.render(&OutputEvent::ToolStarted {
call: Box::new(write_call.clone()),
label: tool_display_label(&write_call),
})
.unwrap();
renderer
.render(&OutputEvent::ToolResult {
summary: Box::new(tool_display_summary(&write_call, &write_result)),
call: Box::new(write_call),
result: Box::new(write_result),
})
.unwrap();
renderer
.render(&OutputEvent::ToolStarted {
call: Box::new(edit_call.clone()),
label: tool_display_label(&edit_call),
})
.unwrap();
renderer
.render(&OutputEvent::ToolResult {
summary: Box::new(tool_display_summary(&edit_call, &edit_result)),
call: Box::new(edit_call),
result: Box::new(edit_result),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "after".to_string(),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
let before = text.find("before").unwrap();
let write_running = text.find("LABEL: write a.txt").unwrap();
let write_success = text.find("STATUS: success").unwrap();
let edit_running = text.find("LABEL: hash_edit").unwrap();
let edit_success = text.rfind("STATUS: success").unwrap();
let after = text.find("after").unwrap();
assert!(before < write_running);
assert!(write_running < write_success);
assert!(write_success < edit_running);
assert!(edit_running < edit_success);
assert!(edit_success < after);
assert!(!text.contains("alpha"));
assert!(!text.contains("beta"));
}
#[test]
fn browser_summary_and_pending_display_redact_connect_url() {
let call = call(
"browser",
json!({"command":"snapshot -i","mode":"connect","connectUrl":"http://user:secret@localhost:9222?token=sk-liveSecret123456"}),
);
let pending = pending_tool_display_summary(&call).unwrap();
assert_eq!(pending.label, "browser snapshot -i");
assert_eq!(pending.status, ToolStatus::Running);
assert!(
pending
.metadata
.iter()
.any(|(key, value)| key == "mode" && value == "connect")
);
let summary = tool_display_summary(
&call,
&result(
"browser",
false,
"failed",
json!({"action":"snapshot","mode":"connect","connect_url":"http://<redacted>@localhost:9222?<redacted>","exit_code":7,"timed_out":false,"truncated":false}),
),
);
assert_eq!(summary.label, "browser snapshot -i");
assert!(
summary
.metadata
.iter()
.any(|(key, value)| key == "action" && value == "snapshot")
);
assert!(
summary
.metadata
.iter()
.any(|(key, value)| key == "exit_code" && value == "7")
);
assert!(!format!("{summary:?}").contains("sk-liveSecret"));
}
#[test]
fn plain_renderer_preserves_current_visible_tool_block() {
let call = call("read", json!({"path":"file.txt"}));
let result = result("read", true, "hello", json!({}));
let summary = tool_display_summary(&call, &result);
let mut output = Vec::new();
let mut renderer = PlainOutputRenderer::new(&mut output);
renderer
.render(&OutputEvent::ToolResult {
call: Box::new(call),
result: Box::new(result),
summary: Box::new(summary),
})
.unwrap();
renderer
.render(&OutputEvent::AssistantDelta {
text: "done".to_string(),
})
.unwrap();
renderer.finish().unwrap();
let text = String::from_utf8(output).unwrap();
assert!(!text.contains("TOOL START"));
assert!(text.contains("STATUS: success"));
assert!(text.contains("PATH: file.txt"));
assert!(text.contains("OUTPUT:\nhello"));
assert!(text.contains("OUTPUT:\nhello\ndone\n"));
}
}