use serde_json::Value;
use crate::localization::{
BackendLocale, backend_strings, format_more_actions, localized_tool_display_name,
resolve_backend_locale,
};
use crate::tool_types::{ToolCall, ToolDefinition};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ToolNarrationPhase {
Started,
Waiting,
Completed,
Failed,
}
fn title_case(name: &str) -> String {
name.split(['_', ' '])
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
match chars.next() {
Some(first) => {
let mut value = String::new();
value.extend(first.to_uppercase());
value.push_str(chars.as_str());
value
}
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn display_name(
tool_def: Option<&ToolDefinition>,
tool_call: &ToolCall,
locale: Option<&str>,
) -> String {
localized_tool_display_name(
&tool_call.name,
tool_def.and_then(|def| def.display_name()),
locale,
)
.unwrap_or_else(|| title_case(&tool_call.name))
}
pub fn arg_str<'a>(arguments: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| arguments.get(*key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
const SECRET_KEY_FRAGMENTS: &[&str] = &[
"token",
"api_key",
"apikey",
"password",
"secret",
"authorization",
];
fn is_secret_key(key: &str) -> bool {
let lower = key.to_ascii_lowercase();
SECRET_KEY_FRAGMENTS
.iter()
.any(|fragment| lower.contains(fragment))
}
pub fn safe_arg_str<'a>(arguments: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.filter(|key| !is_secret_key(key))
.find_map(|key| arguments.get(*key))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
}
pub fn basename(path: &str) -> &str {
let trimmed = path.trim_end_matches('/');
trimmed
.rsplit('/')
.next()
.filter(|part| !part.is_empty())
.unwrap_or(path)
}
pub fn truncate(value: &str, max_len: usize) -> String {
let clean = value.trim();
if clean.chars().count() <= max_len {
return clean.to_string();
}
let truncated: String = clean.chars().take(max_len).collect();
format!("{truncated}...")
}
pub fn url_display(url: &str) -> String {
let trimmed = url.trim();
let parseable = trimmed
.strip_prefix("//")
.map(|rest| format!("https://{rest}"))
.unwrap_or_else(|| trimmed.to_string());
let Ok(parsed) = url::Url::parse(&parseable) else {
return String::new();
};
let Some(host) = parsed.host().map(|host| host.to_string()) else {
return String::new();
};
let authority = match parsed.port() {
Some(port) => format!("{host}:{port}"),
None => host,
};
let cleaned_path = parsed.path().trim_end_matches('/');
let display = if cleaned_path.is_empty() {
authority
} else {
format!("{authority}{cleaned_path}")
};
let cleaned = display.trim_end_matches('/');
truncate(cleaned, 48)
}
fn is_uk(locale: Option<&str>) -> bool {
resolve_backend_locale(locale) == BackendLocale::Uk
}
type Verbs<'a> = (&'a str, &'a str, &'a str);
fn pick<'a>(locale: Option<&str>, en: Verbs<'a>, uk: Verbs<'a>) -> Verbs<'a> {
if is_uk(locale) { uk } else { en }
}
pub fn generic_phrase(
verb_started: &str,
verb_completed: &str,
verb_failed: &str,
target: Option<String>,
phase: ToolNarrationPhase,
) -> String {
let verb = match phase {
ToolNarrationPhase::Started | ToolNarrationPhase::Waiting => verb_started,
ToolNarrationPhase::Completed => verb_completed,
ToolNarrationPhase::Failed => verb_failed,
};
match target {
Some(target) if !target.is_empty() => format!("{verb} {target}"),
_ => verb.to_string(),
}
}
fn phrase3(verbs: Verbs, target: Option<String>, phase: ToolNarrationPhase) -> String {
generic_phrase(verbs.0, verbs.1, verbs.2, target, phase)
}
pub fn labeled_phrase(
verb_started: &str,
verb_completed: &str,
verb_failed: &str,
value: Option<String>,
phase: ToolNarrationPhase,
) -> String {
let verb = match phase {
ToolNarrationPhase::Started | ToolNarrationPhase::Waiting => verb_started,
ToolNarrationPhase::Completed => verb_completed,
ToolNarrationPhase::Failed => verb_failed,
};
match value {
Some(value) if !value.is_empty() => format!("{verb}: {value}"),
_ => verb.to_string(),
}
}
fn location_phrase(arguments: &Value, locale: Option<&str>) -> String {
arg_str(arguments, &["path", "directory", "working_dir"])
.map(|value| {
if value == "." || value == "/workspace" {
backend_strings(locale).current_directory.to_string()
} else {
value.to_string()
}
})
.unwrap_or_else(|| backend_strings(locale).current_directory.to_string())
}
pub fn narrate_shell_exec(
arguments: &Value,
fallback: &str,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let command = arg_str(arguments, &["commands", "command"])
.map(|value| format!("`{}`", truncate(value, 48)))
.unwrap_or_else(|| fallback.to_string());
let verbs = pick(
locale,
("Running", "Ran", "Failed to run"),
("Запускаю", "Запустив", "Не вдалося запустити"),
);
phrase3(verbs, Some(command), phase)
}
fn path_target(arguments: &Value, keys: &[&str]) -> Option<String> {
arg_str(arguments, keys).map(|path| basename(path).to_string())
}
pub fn narrate_read_file(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let target = path_target(arguments, &["path"]);
if is_uk(locale) {
phrase3(
("Читаю", "Прочитав", "Не вдалося прочитати"),
Some(target.unwrap_or_else(|| "файл".to_string())),
phase,
)
} else {
phrase3(("Reading", "Read", "Failed to read"), target, phase)
}
}
pub fn narrate_read_many_files(phase: ToolNarrationPhase, locale: Option<&str>) -> String {
if is_uk(locale) {
phrase3(
(
"Читаю кілька файлів",
"Прочитав кілька файлів",
"Не вдалося прочитати кілька файлів",
),
None,
phase,
)
} else {
phrase3(
("Reading", "Read", "Failed to read"),
Some("multiple files".to_string()),
phase,
)
}
}
pub fn narrate_write_file(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let target = path_target(arguments, &["path"]);
if is_uk(locale) {
phrase3(
("Записую", "Записав", "Не вдалося записати"),
Some(target.unwrap_or_else(|| "файл".to_string())),
phase,
)
} else {
phrase3(("Writing", "Wrote", "Failed to write"), target, phase)
}
}
pub fn narrate_edit_file(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let target = path_target(arguments, &["path"]);
if is_uk(locale) {
phrase3(
("Редагую", "Відредагував", "Не вдалося відредагувати"),
Some(target.unwrap_or_else(|| "файл".to_string())),
phase,
)
} else {
phrase3(("Editing", "Edited", "Failed to edit"), target, phase)
}
}
pub fn narrate_append_file(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let target = path_target(arguments, &["path"]);
if is_uk(locale) {
phrase3(
("Дописую у", "Дописав у", "Не вдалося дописати у"),
Some(target.unwrap_or_else(|| "файл".to_string())),
phase,
)
} else {
phrase3(
("Appending to", "Appended to", "Failed to append to"),
target,
phase,
)
}
}
pub fn narrate_move_file(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let target = path_target(arguments, &["to", "destination", "new_path"])
.or_else(|| path_target(arguments, &["path"]));
if is_uk(locale) {
phrase3(
("Переміщую", "Перемістив", "Не вдалося перемістити"),
Some(target.unwrap_or_else(|| "файл".to_string())),
phase,
)
} else {
phrase3(("Moving", "Moved", "Failed to move"), target, phase)
}
}
pub fn narrate_delete_file(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let target = path_target(arguments, &["path"]);
if is_uk(locale) {
phrase3(
("Видаляю", "Видалив", "Не вдалося видалити"),
Some(target.unwrap_or_else(|| "файл".to_string())),
phase,
)
} else {
phrase3(("Deleting", "Deleted", "Failed to delete"), target, phase)
}
}
pub fn narrate_mkdir(arguments: &Value, phase: ToolNarrationPhase, locale: Option<&str>) -> String {
let target = path_target(arguments, &["path"]);
let verbs = pick(
locale,
(
"Creating directory",
"Created directory",
"Failed to create directory",
),
(
"Створюю директорію",
"Створив директорію",
"Не вдалося створити директорію",
),
);
phrase3(verbs, target, phase)
}
pub fn narrate_stat_file(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let target = path_target(arguments, &["path"]);
if is_uk(locale) {
phrase3(
("Перевіряю", "Перевірив", "Не вдалося перевірити"),
Some(target.unwrap_or_else(|| "файл".to_string())),
phase,
)
} else {
phrase3(("Checking", "Checked", "Failed to check"), target, phase)
}
}
pub fn narrate_list_directory(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let target = location_phrase(arguments, locale);
let verbs = pick(
locale,
(
"Listing files in",
"Listed files in",
"Failed to list files in",
),
(
"Переглядаю файли у",
"Переглянув файли у",
"Не вдалося переглянути файли у",
),
);
phrase3(verbs, Some(target), phase)
}
pub fn narrate_grep_files(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let pattern = arg_str(arguments, &["pattern"]).map(|pattern| truncate(pattern, 36));
if is_uk(locale) {
match pattern {
Some(pattern) => phrase3(
("Шукаю", "Знайшов", "Не вдалося знайти"),
Some(format!("`{pattern}` у файлах")),
phase,
),
None => phrase3(
(
"Шукаю у файлах",
"Завершив пошук у файлах",
"Не вдалося виконати пошук у файлах",
),
None,
phase,
),
}
} else {
let target = pattern
.map(|pattern| format!("files for {pattern}"))
.unwrap_or_else(|| "files".to_string());
phrase3(
("Searching", "Searched", "Failed to search"),
Some(target),
phase,
)
}
}
pub fn narrate_search_web(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let query = arg_str(arguments, &["query", "q", "search"]).map(|query| truncate(query, 48));
if is_uk(locale) {
match query {
Some(query) => labeled_phrase(
"Шукаю у вебі",
"Завершив пошук у вебі",
"Не вдалося знайти у вебі",
Some(query),
phase,
),
None => phrase3(
(
"Шукаю у вебі",
"Завершив пошук у вебі",
"Не вдалося виконати пошук у вебі",
),
None,
phase,
),
}
} else {
let target = query
.map(|query| format!("web for {query}"))
.unwrap_or_else(|| "web".to_string());
phrase3(
("Searching", "Searched", "Failed to search"),
Some(target),
phase,
)
}
}
pub fn narrate_provider_search(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let query = safe_arg_str(arguments, &["query", "q", "search", "pattern"])
.map(|query| truncate(query, 48));
if is_uk(locale) {
labeled_phrase("Шукаю", "Завершив пошук", "Не вдалося знайти", query, phase)
} else {
labeled_phrase("Search", "Searched", "Could not search", query, phase)
}
}
pub fn narrate_secret_store(
arguments: &Value,
fallback: &str,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let operation = arg_str(arguments, &["operation"]).unwrap_or("use");
let target = safe_arg_str(arguments, &["name", "key"])
.map(str::to_string)
.unwrap_or_else(|| fallback.to_string());
if is_uk(locale) {
match phase {
ToolNarrationPhase::Started | ToolNarrationPhase::Waiting => {
format!("Виконую {} {}", title_case(operation), target)
.trim()
.to_string()
}
ToolNarrationPhase::Completed => {
format!("Виконав {} {}", title_case(operation), target)
.trim()
.to_string()
}
ToolNarrationPhase::Failed => format!("Не вдалося виконати {operation} {target}")
.trim()
.to_string(),
}
} else {
let started = format!("{}ing", title_case(operation));
match phase {
ToolNarrationPhase::Started | ToolNarrationPhase::Waiting => {
format!("{started} {target}").trim().to_string()
}
ToolNarrationPhase::Completed => {
if operation.eq_ignore_ascii_case("list") {
format!("Listed {target}").trim().to_string()
} else {
format!("{} {}", title_case(operation), target)
.trim()
.to_string()
}
}
ToolNarrationPhase::Failed => {
format!("Failed to {} {}", operation.to_lowercase(), target)
.trim()
.to_string()
}
}
}
}
pub fn narrate_spawn_subagent(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let name = arg_str(arguments, &["name"]).map(|name| truncate(name, 40));
if is_uk(locale) {
let target = name
.map(|name| format!("субагента {name}"))
.unwrap_or_else(|| "субагента".to_string());
phrase3(
("Запускаю", "Запустив", "Не вдалося запустити"),
Some(target),
phase,
)
} else {
let target = name
.map(|name| format!("{name} subagent"))
.unwrap_or_else(|| "subagent".to_string());
phrase3(
("Launching", "Launched", "Failed to launch"),
Some(target),
phase,
)
}
}
pub fn narrate_write_todos(phase: ToolNarrationPhase, locale: Option<&str>) -> String {
let verbs = pick(
locale,
("Updating", "Updated", "Failed to update"),
(
"Оновлюю список задач",
"Оновив список задач",
"Не вдалося оновити список задач",
),
);
if is_uk(locale) {
phrase3(verbs, None, phase)
} else {
phrase3(verbs, Some("task list".to_string()), phase)
}
}
pub fn narrate_web_fetch(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let value = safe_arg_str(arguments, &["url", "uri"]).map(url_display);
let verbs = pick(
locale,
("Fetch URL", "Fetched URL", "Could not fetch URL"),
(
"Завантажую URL",
"Завантажив URL",
"Не вдалося завантажити URL",
),
);
labeled_phrase(verbs.0, verbs.1, verbs.2, value, phase)
}
pub fn narrate_tool_search(
arguments: &Value,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let value = safe_arg_str(arguments, &["query"]).map(|q| truncate(q, 64));
let verbs = pick(
locale,
("Search tools", "Searched tools", "Could not search tools"),
(
"Шукаю інструменти",
"Знайшов інструменти",
"Не вдалося знайти інструменти",
),
);
labeled_phrase(verbs.0, verbs.1, verbs.2, value, phase)
}
pub fn narrate_skill(
tool_name: &str,
arguments: &Value,
phase: ToolNarrationPhase,
_locale: Option<&str>,
) -> Option<String> {
let value = safe_arg_str(arguments, &["name", "skill", "id"]).map(|v| truncate(v, 48));
let phrase = match tool_name {
"activate_skill" => labeled_phrase(
"Activate skill",
"Activated skill",
"Could not activate skill",
value,
phase,
),
"read_skill" => labeled_phrase(
"Read skill",
"Read skill",
"Could not read skill",
value,
phase,
),
"list_skills" => generic_phrase(
"Listing skills",
"Listed skills",
"Could not list skills",
None,
phase,
),
_ => return None,
};
Some(phrase)
}
fn operation_verbs(operation: &str) -> Verbs<'static> {
match operation {
"create" => ("Creating", "Created", "Failed to create"),
"update" => ("Updating", "Updated", "Failed to update"),
"delete" | "destroy" => ("Deleting", "Deleted", "Failed to delete"),
"copy" | "clone" | "duplicate" => ("Copying", "Copied", "Failed to copy"),
"list" => ("Listing", "Listed", "Failed to list"),
"get" | "read" => ("Reading", "Read", "Failed to read"),
"set" => ("Setting", "Set", "Failed to set"),
"send" => ("Sending", "Sent", "Failed to send"),
"run" | "execute" => ("Running", "Ran", "Failed to run"),
_ => ("Running", "Ran", "Failed to run"),
}
}
fn operation_narration(noun: &str, arguments: &Value, phase: ToolNarrationPhase) -> Option<String> {
let operation = arg_str(arguments, &["operation", "action"])?;
let verbs = operation_verbs(operation);
let name =
arg_str(arguments, &["display_name", "name", "title", "new_name"]).map(|v| truncate(v, 40));
let target = match name {
Some(name) => format!("{noun}: {name}"),
None => noun.to_string(),
};
Some(phrase3(verbs, Some(target), phase))
}
pub fn render_tool_narration_with_locale(
tool_def: Option<&ToolDefinition>,
tool_call: &ToolCall,
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> String {
let fallback_name = display_name(tool_def, tool_call, locale);
if let Some(narration) = tool_def
.and_then(|def| def.hints().narration_noun.as_deref())
.and_then(|noun| operation_narration(noun, &tool_call.arguments, phase))
{
return narration;
}
let verbs = pick(
locale,
("Running", "Ran", "Failed to run"),
("Запускаю", "Запустив", "Не вдалося запустити"),
);
phrase3(verbs, Some(fallback_name), phase)
}
pub fn render_tool_narration(
tool_def: Option<&ToolDefinition>,
tool_call: &ToolCall,
phase: ToolNarrationPhase,
) -> String {
render_tool_narration_with_locale(tool_def, tool_call, phase, None)
}
pub fn render_group_headline(
tool_calls: &[ToolCall],
tool_defs: &[ToolDefinition],
phase: ToolNarrationPhase,
) -> Option<String> {
render_group_headline_with_locale(tool_calls, tool_defs, phase, None)
}
pub fn render_group_headline_with_locale(
tool_calls: &[ToolCall],
tool_defs: &[ToolDefinition],
phase: ToolNarrationPhase,
locale: Option<&str>,
) -> Option<String> {
if tool_calls.is_empty() {
return None;
}
let tool_map: std::collections::HashMap<&str, &ToolDefinition> =
tool_defs.iter().map(|def| (def.name(), def)).collect();
let phrases = tool_calls
.iter()
.map(|tool_call| {
render_tool_narration_with_locale(
tool_map.get(tool_call.name.as_str()).copied(),
tool_call,
phase,
locale,
)
})
.take(3)
.collect::<Vec<_>>();
Some(join_phrases(&phrases, tool_calls.len(), locale))
}
fn join_phrases(phrases: &[String], total_count: usize, locale: Option<&str>) -> String {
let strings = backend_strings(locale);
match phrases {
[] => strings.working.to_string(),
[only] => only.clone(),
[first, second] => match resolve_backend_locale(locale) {
BackendLocale::Uk => format!("{first} і {second}"),
BackendLocale::En => format!("{first} and {second}"),
},
[first, second, ..] => {
let more = format_more_actions(locale, total_count.saturating_sub(2));
format!("{first}, {second}, {more}")
}
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
use crate::tool_types::ToolCall;
fn args(value: serde_json::Value) -> serde_json::Value {
value
}
#[test]
fn shell_exec_helper_en_and_uk() {
let a = args(json!({ "command": "cargo test" }));
assert_eq!(
narrate_shell_exec(&a, "Shell", ToolNarrationPhase::Started, None),
"Running `cargo test`"
);
assert_eq!(
narrate_shell_exec(&a, "Shell", ToolNarrationPhase::Completed, Some("uk")),
"Запустив `cargo test`"
);
}
#[test]
fn read_file_helper_uses_basename() {
let a = args(json!({ "path": "/workspace/AGENTS.md" }));
assert_eq!(
narrate_read_file(&a, ToolNarrationPhase::Started, None),
"Reading AGENTS.md"
);
assert_eq!(
narrate_read_file(&a, ToolNarrationPhase::Completed, Some("uk-UA")),
"Прочитав AGENTS.md"
);
}
#[test]
fn edit_file_helper() {
let a = args(json!({ "path": "/workspace/src/main.rs" }));
assert_eq!(
narrate_edit_file(&a, ToolNarrationPhase::Completed, None),
"Edited main.rs"
);
}
#[test]
fn web_fetch_helper_strips_scheme_and_query() {
let a = args(json!({ "url": "https://example.com/page?token=abc#frag" }));
assert_eq!(
narrate_web_fetch(&a, ToolNarrationPhase::Completed, None),
"Fetched URL: example.com/page"
);
assert_eq!(
narrate_web_fetch(&a, ToolNarrationPhase::Completed, Some("uk")),
"Завантажив URL: example.com/page"
);
}
#[test]
fn url_display_strips_embedded_credentials() {
assert_eq!(
url_display("https://user:pass@example.com/path?token=abc"),
"example.com/path"
);
assert_eq!(url_display("https://user:pass@example.com"), "example.com");
assert_eq!(
url_display("//user:pass@example.com/path?token=abc#frag"),
"example.com/path"
);
assert_eq!(
url_display("https:///user:pass@example.com/path?token=abc#frag"),
"example.com/path"
);
}
#[test]
fn web_fetch_helper_falls_back_to_bare_verb_for_malformed_url() {
for raw in [
"not a url with secret-token inside",
"javascript:alert('user:s3cret@host')",
"user:s3cret-pass@no-scheme/path",
] {
let a = args(json!({ "url": raw }));
let started = narrate_web_fetch(&a, ToolNarrationPhase::Started, None);
let completed = narrate_web_fetch(&a, ToolNarrationPhase::Completed, None);
assert_eq!(started, "Fetch URL", "input: {raw}");
assert_eq!(completed, "Fetched URL", "input: {raw}");
assert!(
!started.contains("secret"),
"leaked secret for input: {raw}"
);
assert!(
!started.contains("s3cret"),
"leaked secret for input: {raw}"
);
assert!(
!completed.contains("s3cret"),
"leaked secret for input: {raw}"
);
}
}
#[test]
fn provider_search_never_leaks_secret() {
let a = args(json!({ "token": "super-secret" }));
assert_eq!(
narrate_provider_search(&a, ToolNarrationPhase::Started, None),
"Search"
);
}
#[test]
fn skill_helper_dispatches_family() {
assert_eq!(
narrate_skill(
"activate_skill",
&json!({ "name": "ship" }),
ToolNarrationPhase::Completed,
None
),
Some("Activated skill: ship".to_string())
);
assert_eq!(
narrate_skill("not_a_skill", &json!({}), ToolNarrationPhase::Started, None),
None
);
}
#[test]
fn fallback_uses_narration_noun_when_present() {
use crate::tool_types::{BuiltinTool, ToolDefinition, ToolHints};
let tool_call = ToolCall {
id: "call_1".to_string(),
name: "manage_agents".to_string(),
arguments: json!({ "operation": "create", "name": "Neon Cartographer" }),
};
let def = ToolDefinition::Builtin(BuiltinTool {
name: "manage_agents".to_string(),
display_name: Some("Manage Agents".to_string()),
description: String::new(),
parameters: json!({}),
policy: Default::default(),
category: None,
deferrable: Default::default(),
hints: ToolHints::default().with_narration_noun("agent"),
full_parameters: None,
});
assert_eq!(
render_tool_narration(Some(&def), &tool_call, ToolNarrationPhase::Started),
"Creating agent: Neon Cartographer"
);
assert_eq!(
render_tool_narration(Some(&def), &tool_call, ToolNarrationPhase::Completed),
"Created agent: Neon Cartographer"
);
assert_eq!(
render_tool_narration(Some(&def), &tool_call, ToolNarrationPhase::Failed),
"Failed to create agent: Neon Cartographer"
);
}
#[test]
fn fallback_uses_display_name_for_unowned_tool() {
let tool_call = ToolCall {
id: "call_1".to_string(),
name: "mystery_tool".to_string(),
arguments: json!({}),
};
assert_eq!(
render_tool_narration(None, &tool_call, ToolNarrationPhase::Completed),
"Ran Mystery Tool"
);
}
}