use crate::{
output::{
ActivityId, ActivityMetadata, pending_tool_display_summary, tool_display_label,
tool_display_summary,
},
providers::ToolCall,
tools::ToolResult,
};
use serde_json::json;
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
pub(super) struct ToolCallFingerprint {
id: String,
name: String,
arguments: String,
}
impl From<&ToolCall> for ToolCallFingerprint {
fn from(call: &ToolCall) -> Self {
Self {
id: call.id.clone(),
name: call.name.clone(),
arguments: call.arguments.to_string(),
}
}
}
pub(super) fn function_call_item(call: &ToolCall) -> serde_json::Value {
json!({
"type": "function_call",
"call_id": call.id.clone(),
"name": call.name.clone(),
"arguments": arguments_json_text(&call.arguments),
"status": "completed",
})
}
pub(super) fn arguments_json_text(arguments: &serde_json::Value) -> String {
match arguments {
serde_json::Value::String(text) => text.clone(),
value => value.to_string(),
}
}
pub(super) fn activity_id_for_call(
iteration: usize,
tool_index: usize,
call: &ToolCall,
) -> ActivityId {
if call.id.trim().is_empty() {
ActivityId::new(format!("turn-{iteration}/tool-{tool_index}-{}", call.name))
} else {
ActivityId::new(call.id.clone())
}
}
pub(super) fn tool_activity_metadata(
call: &ToolCall,
result: Option<&ToolResult>,
) -> ActivityMetadata {
let summary = result
.map(|result| tool_display_summary(call, result))
.or_else(|| pending_tool_display_summary(call));
let mut metadata = ActivityMetadata::new(
summary
.as_ref()
.map(|summary| summary.label.clone())
.unwrap_or_else(|| tool_display_label(call)),
);
if let Some(summary) = summary {
metadata.fields = summary.metadata;
}
metadata
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fallback_activity_ids_include_per_call_index() {
let first = ToolCall {
id: String::new(),
name: "read".to_string(),
arguments: json!({}),
};
let second = ToolCall {
id: String::new(),
name: "read".to_string(),
arguments: json!({}),
};
assert_eq!(
activity_id_for_call(2, 0, &first).as_str(),
"turn-2/tool-0-read"
);
assert_eq!(
activity_id_for_call(2, 1, &second).as_str(),
"turn-2/tool-1-read"
);
}
#[test]
fn running_write_activity_metadata_includes_content_bytes_without_content() {
let call = ToolCall {
id: "call_write".to_string(),
name: "write".to_string(),
arguments: json!({"path":"file.txt","content":"secret payload"}),
};
let metadata = tool_activity_metadata(&call, None);
assert_eq!(metadata.label, "write file.txt");
assert_eq!(
metadata.fields,
vec![("bytes".to_string(), "14".to_string())]
);
assert!(!format!("{metadata:?}").contains("secret payload"));
}
#[test]
fn running_hash_edit_activity_metadata_includes_bytes_without_payloads() {
let call = ToolCall {
id: "call_hash_edit".to_string(),
name: "hash_edit".to_string(),
arguments: json!({"input":"[file.txt#ABCD]\nSWAP 1.=1:\n+other"}),
};
let metadata = tool_activity_metadata(&call, None);
assert_eq!(metadata.label, "hash_edit");
assert_eq!(
metadata.fields,
vec![("bytes".to_string(), "33".to_string())]
);
let debug = format!("{metadata:?}");
assert!(!debug.contains("SWAP"));
assert!(!debug.contains("secret"));
}
}