use serde_json::Value;
const BIG_ARG_CHARS: usize = 100;
const HEADER_MAX_CHARS: usize = 100;
const PROSE_RESULT_TOOLS: &[&str] = &["web_search", "fetch_url", "note_recall", "call_subagent"];
#[derive(Debug, Clone, PartialEq)]
pub enum ToolBlock {
Code { lang: String, text: String },
Console(Console),
Plain(String),
Markdown(String),
}
#[derive(Debug, Clone, PartialEq, Default)]
pub struct Console {
pub command: String,
pub stdout: String,
pub stderr: String,
pub exit: Option<i32>,
pub files: String,
}
pub(crate) fn format_console(
command: Option<&str>,
stdout: &str,
stderr: &str,
success: bool,
code: Option<i32>,
loc: &crate::shared::i18n::Locale,
) -> String {
let mut parts = Vec::new();
if let Some(command) = command.map(str::trim).filter(|c| !c.is_empty()) {
parts.push(counted("command", command));
}
if !stdout.trim().is_empty() {
parts.push(counted("stdout", stdout));
}
if !stderr.trim().is_empty() {
parts.push(counted("stderr", stderr));
}
if !success {
parts.push(format!(
"{} {}",
loc.t("python.console.exit"),
code.unwrap_or(-1)
));
}
if parts.is_empty() {
loc.t("python.console.empty").to_string()
} else {
parts.join("\n\n")
}
}
fn counted(label: &str, body: &str) -> String {
let n = body.lines().count();
let unit = if n == 1 { "line" } else { "lines" };
format!("{label} ({n} {unit}):\n{body}")
}
fn counted_header(line: &str) -> Option<(&str, usize)> {
let (label, rest) = line.split_once(" (")?;
if !matches!(label, "command" | "stdout" | "stderr") {
return None;
}
let count = rest
.strip_suffix(" lines):")
.or_else(|| rest.strip_suffix(" line):"))?;
Some((label, count.parse().ok()?))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArgDetail {
Compact,
Full,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ToolPresentation {
pub header_suffix: Option<String>,
pub args: Vec<ToolBlock>,
pub result: Vec<ToolBlock>,
}
pub fn present(name: &str, arguments: &str, result: &str, detail: ArgDetail) -> ToolPresentation {
let val: Option<Value> = serde_json::from_str(arguments).ok();
let (header_suffix, args) = present_args(name, arguments, val.as_ref(), detail);
let result = present_result(name, val.as_ref(), result);
ToolPresentation {
header_suffix,
args,
result,
}
}
fn present_args(
name: &str,
raw: &str,
val: Option<&Value>,
detail: ArgDetail,
) -> (Option<String>, Vec<ToolBlock>) {
match detail {
ArgDetail::Compact => compact_args(name, raw, val),
ArgDetail::Full => (None, full_args(name, raw, val)),
}
}
fn code_block(name: &str, map: &serde_json::Map<String, Value>) -> Option<(ToolBlock, String)> {
let (field, lang) = code_field(name, map)?;
let Some(Value::String(code)) = map.get(field) else {
return None;
};
if code.trim().is_empty() {
return None;
}
Some((
ToolBlock::Code {
lang,
text: code.clone(),
},
field.to_string(),
))
}
fn big_string_block(
name: &str,
map: &serde_json::Map<String, Value>,
) -> Option<(ToolBlock, String)> {
ordered_fields(name, map)
.into_iter()
.find_map(|(k, v)| match v {
Value::String(s) if is_big(s) => Some((ToolBlock::Plain(s.clone()), k.clone())),
_ => None,
})
}
fn scalar_pairs(
name: &str,
map: &serde_json::Map<String, Value>,
consumed: Option<&str>,
) -> Vec<(String, String)> {
let mut pairs: Vec<(String, String)> = Vec::new();
for (k, v) in ordered_fields(name, map) {
if consumed == Some(k.as_str()) {
continue;
}
if let Some(s) = scalar_str(v)
&& !s.trim().is_empty()
{
pairs.push((k.clone(), s));
}
}
pairs
}
fn compact_args(name: &str, raw: &str, val: Option<&Value>) -> (Option<String>, Vec<ToolBlock>) {
let Some(Value::Object(map)) = val else {
let t = raw.trim();
return (
if t.is_empty() {
None
} else {
Some(truncate_header(t))
},
Vec::new(),
);
};
let (blocks, consumed) = match code_block(name, map).or_else(|| big_string_block(name, map)) {
Some((block, field)) => (vec![block], Some(field)),
None => (Vec::new(), None),
};
(
header_from_pairs(&scalar_pairs(name, map, consumed.as_deref())),
blocks,
)
}
fn full_args(name: &str, raw: &str, val: Option<&Value>) -> Vec<ToolBlock> {
let Some(Value::Object(map)) = val else {
let t = raw.trim();
return if t.is_empty() {
Vec::new()
} else {
vec![ToolBlock::Plain(t.to_string())]
};
};
let code = code_field(name, map);
let mut blocks = Vec::new();
for (k, v) in ordered_fields(name, map) {
match v {
Value::String(s)
if code.as_ref().is_some_and(|(f, _)| *f == k) && !s.trim().is_empty() =>
{
blocks.push(ToolBlock::Plain(format!("{k}:")));
blocks.push(ToolBlock::Code {
lang: code.as_ref().map(|(_, l)| l.clone()).unwrap_or_default(),
text: s.clone(),
});
}
Value::String(s) if is_big(s) => {
blocks.push(ToolBlock::Plain(format!("{k}:")));
blocks.push(ToolBlock::Plain(s.clone()));
}
_ => blocks.push(ToolBlock::Plain(match scalar_str(v) {
Some(s) if !s.trim().is_empty() => format!("{k}: {s}"),
_ => format!("{k}: {v}"),
})),
}
}
blocks
}
const FIELD_ORDER: &[(&str, &[&str])] = &[
("call_subagent", &["name", "system_message", "message"]),
(
"code_edit",
&["path", "old_string", "new_string", "replace_all"],
),
("code_grep", &["pattern", "path", "glob"]),
("code_list", &["path", "depth"]),
("code_read", &["path", "offset", "limit"]),
("code_write", &["path", "content"]),
("fetch_url", &["url", "focus", "summarize"]),
("fs_write", &["path", "content", "append"]),
("note_link", &["from_id", "to_id", "relation"]),
("note_merge", &["ids", "content"]),
("note_recall", &["query", "tags", "limit"]),
("note_revise", &["id", "content"]),
("note_supersede", &["old_id", "content"]),
("rag_add", &["text", "source"]),
(
"update_self_model",
&["summary", "add_goals", "complete_goals", "abandon_goals"],
),
(
"update_user_model",
&[
"add_traits",
"remove_traits",
"add_interests",
"remove_interests",
"relationship_dynamic",
"note",
],
),
("web_search", &["query", "max_results", "fetch_content"]),
(
"youtube_watch",
&["url", "focus", "start", "end", "transcript"],
),
];
fn ordered_fields<'a>(
name: &str,
map: &'a serde_json::Map<String, Value>,
) -> Vec<(&'a String, &'a Value)> {
let Some((_, order)) = FIELD_ORDER.iter().find(|(tool, _)| *tool == name) else {
return map.iter().collect();
};
let mut fields: Vec<(&String, &Value)> = order
.iter()
.filter_map(|field| map.get_key_value(*field))
.collect();
fields.extend(map.iter().filter(|(k, _)| !order.contains(&k.as_str())));
fields
}
fn code_field(name: &str, map: &serde_json::Map<String, Value>) -> Option<(&'static str, String)> {
match name {
"python_exec" => Some(("code", "python".into())),
"fs_write" => Some(("content", ext_lang(map.get("path")))),
_ => None,
}
}
fn present_result(name: &str, args: Option<&Value>, result: &str) -> Vec<ToolBlock> {
if result.trim().is_empty() {
return Vec::new();
}
match name {
"python_exec"
| crate::features::tools::code::CODE_BUILD_ID
| crate::features::tools::code::CODE_RUN_ID
| crate::features::tools::code::CODE_TEST_ID => match parse_console(result) {
Some(c) => vec![ToolBlock::Console(c)],
None => vec![ToolBlock::Plain(result.to_string())],
},
"fs_read"
if !fs_read_failure_prefixes()
.iter()
.any(|p| result.starts_with(p)) =>
{
let lang = ext_lang(args.and_then(|v| v.get("path")));
vec![ToolBlock::Code {
lang,
text: result.to_string(),
}]
}
n if PROSE_RESULT_TOOLS.contains(&n) => vec![ToolBlock::Markdown(result.to_string())],
_ => vec![ToolBlock::Plain(result.to_string())],
}
}
fn exit_labels() -> Vec<&'static str> {
crate::shared::i18n::Lang::all()
.iter()
.map(|&l| crate::shared::i18n::locale(l).t("python.console.exit"))
.collect()
}
fn fs_read_failure_prefixes() -> Vec<&'static str> {
crate::shared::i18n::Lang::all()
.iter()
.filter_map(|&l| {
crate::shared::i18n::locale(l)
.t("tool.fs_read.result.read_failed")
.split('{')
.next()
})
.collect()
}
fn parse_console(result: &str) -> Option<Console> {
#[derive(PartialEq)]
enum Sec {
None,
Command,
Stdout,
Stderr,
Files,
}
let exit_labels = exit_labels();
let mut c = Console::default();
let mut sec = Sec::None;
let mut cmd: Vec<&str> = Vec::new();
let mut out: Vec<&str> = Vec::new();
let mut err: Vec<&str> = Vec::new();
let mut files: Vec<&str> = Vec::new();
let lines: Vec<&str> = result.lines().collect();
let mut at = 0;
while at < lines.len() {
let line = lines[at];
at += 1;
if let Some((label, n)) = counted_header(line) {
let end = at.checked_add(n)?;
let body = lines.get(at..end)?;
match label {
"command" => cmd.extend_from_slice(body),
"stdout" => out.extend_from_slice(body),
_ => err.extend_from_slice(body),
}
at = end;
sec = Sec::None;
continue;
}
if line == "command:" {
sec = Sec::Command;
} else if line == "stdout:" {
sec = Sec::Stdout;
} else if line == "stderr:" {
sec = Sec::Stderr;
} else if line == "files:" {
sec = Sec::Files;
} else if let Some(rest) = exit_labels.iter().find_map(|lbl| line.strip_prefix(lbl)) {
c.exit = rest.trim().parse::<i32>().ok();
sec = Sec::None;
} else {
match sec {
Sec::Command => cmd.push(line),
Sec::Stdout => out.push(line),
Sec::Stderr => err.push(line),
Sec::Files => files.push(line),
Sec::None if line.trim().is_empty() => {}
Sec::None => return None,
}
}
}
if cmd.is_empty() && out.is_empty() && err.is_empty() && files.is_empty() && c.exit.is_none() {
return None;
}
c.command = join_trim(&cmd);
c.stdout = join_trim(&out);
c.stderr = join_trim(&err);
c.files = join_trim(&files);
Some(c)
}
fn join_trim(lines: &[&str]) -> String {
let mut v = lines.to_vec();
while v.last().is_some_and(|l| l.trim().is_empty()) {
v.pop();
}
v.join("\n")
}
fn header_from_pairs(pairs: &[(String, String)]) -> Option<String> {
let raw = match pairs.len() {
0 => return None,
1 => pairs[0].1.clone(),
_ => pairs
.iter()
.map(|(k, v)| format!("{k}={v}"))
.collect::<Vec<_>>()
.join(", "),
};
Some(truncate_header(&raw))
}
fn scalar_str(v: &Value) -> Option<String> {
match v {
Value::String(s) => Some(s.clone()),
Value::Number(n) => Some(n.to_string()),
Value::Bool(b) => Some(b.to_string()),
Value::Null | Value::Array(_) | Value::Object(_) => None,
}
}
fn is_big(s: &str) -> bool {
s.contains('\n') || s.chars().count() > BIG_ARG_CHARS
}
fn ext_lang(path: Option<&Value>) -> String {
path.and_then(Value::as_str)
.and_then(|p| p.rsplit_once('.'))
.map(|(_, ext)| ext.to_ascii_lowercase())
.unwrap_or_default()
}
fn truncate_header(s: &str) -> String {
let flat: String = s.split_whitespace().collect::<Vec<_>>().join(" ");
if flat.chars().count() <= HEADER_MAX_CHARS {
flat
} else {
let cut: String = flat.chars().take(HEADER_MAX_CHARS).collect();
format!("{cut}…")
}
}
#[cfg(test)]
mod tests {
use super::*;
fn present(name: &str, arguments: &str, result: &str) -> ToolPresentation {
super::present(name, arguments, result, ArgDetail::Compact)
}
fn arg_text(p: &ToolPresentation) -> String {
p.args
.iter()
.map(|b| match b {
ToolBlock::Code { text, .. }
| ToolBlock::Plain(text)
| ToolBlock::Markdown(text) => text.clone(),
ToolBlock::Console(_) => String::new(),
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn full_detail_puts_the_name_alone_in_the_header_and_lists_the_arguments() {
let args = r#"{"url":"http://x/y","summarize":true}"#;
let compact = present("fetch_url", args, "ok");
assert_eq!(
compact.header_suffix.as_deref(),
Some("url=http://x/y, summarize=true"),
"collapsed folds them into the header"
);
assert!(compact.args.is_empty());
let full = super::present("fetch_url", args, "ok", ArgDetail::Full);
assert_eq!(full.header_suffix, None, "the name alone");
assert_eq!(
arg_text(&full),
"url: http://x/y\nsummarize: true",
"one line per argument, in the tool's own field order"
);
}
#[test]
fn full_detail_shows_a_value_the_header_would_truncate() {
let url = "http://example.org/a/fairly/long/path/that/still/fits/on/its/own";
let focus = "memory management modes and their pitfalls";
let args = format!(r#"{{"url":"{url}","focus":"{focus}","summarize":true}}"#);
assert!(
present("fetch_url", &args, "")
.header_suffix
.unwrap()
.ends_with('…'),
"the premise: this one truncates"
);
let text = arg_text(&super::present("fetch_url", &args, "", ArgDetail::Full));
assert!(text.contains(&format!("url: {url}")), "whole: {text}");
assert!(text.contains(&format!("focus: {focus}")), "whole: {text}");
}
#[test]
fn full_detail_shows_arguments_the_header_cannot_carry() {
let args = r#"{"temperature":0.8,"samplers":["top_k","min_p"],"nested":{"a":1}}"#;
let compact = present("set_sampling", args, "ok");
assert_eq!(compact.header_suffix.as_deref(), Some("0.8"));
assert!(compact.args.is_empty());
let full = super::present("set_sampling", args, "ok", ArgDetail::Full);
assert_eq!(full.header_suffix, None);
let text = arg_text(&full);
assert!(text.contains(r#"samplers: ["top_k","min_p"]"#), "{text}");
assert!(text.contains(r#"nested: {"a":1}"#), "{text}");
assert!(text.contains("temperature: 0.8"), "{text}");
}
#[test]
fn full_detail_labels_a_code_argument_and_keeps_its_highlighting() {
let full = super::present(
"python_exec",
r#"{"code":"print(1)","timeout":30}"#,
"",
ArgDetail::Full,
);
assert_eq!(
full.args,
vec![
ToolBlock::Plain("code:".into()),
ToolBlock::Code {
lang: "python".into(),
text: "print(1)".into()
},
ToolBlock::Plain("timeout: 30".into()),
]
);
}
#[test]
fn full_detail_labels_a_large_string_argument() {
let long = "текст ".repeat(40);
let args = format!(r#"{{"content":{},"tags":"a"}}"#, serde_json::json!(long));
let full = super::present("note_save", &args, "", ArgDetail::Full);
assert_eq!(
full.args,
vec![
ToolBlock::Plain("content:".into()),
ToolBlock::Plain(long),
ToolBlock::Plain("tags: a".into()),
]
);
}
#[test]
fn full_detail_covers_arguments_that_are_not_a_json_object() {
let long = "x".repeat(HEADER_MAX_CHARS + 40);
assert!(present("t", &long, "").args.is_empty());
let full = super::present("t", &long, "", ArgDetail::Full);
assert_eq!(full.header_suffix, None);
assert_eq!(arg_text(&full), long, "the raw arguments, whole");
assert!(super::present("t", "", "", ArgDetail::Full).args.is_empty());
}
#[test]
fn full_detail_shows_an_empty_value_as_json() {
let full = super::present("t", r#"{"focus":"","n":1}"#, "", ArgDetail::Full);
assert_eq!(arg_text(&full), "focus: \"\"\nn: 1");
}
#[test]
fn python_shows_code_block_and_console() {
let p = present(
"python_exec",
r#"{"code":"print(1)\nx = 2"}"#,
"stdout:\nhello\nworld",
);
assert_eq!(
p.header_suffix, None,
"the code goes into a block, the header is empty"
);
assert_eq!(
p.args,
vec![ToolBlock::Code {
lang: "python".into(),
text: "print(1)\nx = 2".into(),
}]
);
assert_eq!(
p.result,
vec![ToolBlock::Console(Console {
command: String::new(),
stdout: "hello\nworld".into(),
stderr: String::new(),
exit: None,
files: String::new(),
})]
);
}
#[test]
fn python_console_parses_all_sections() {
let out = "stdout:\nok line\n\nstderr:\nTraceback\n\nкод возврата: 1";
let c = parse_console(out).unwrap();
assert_eq!(c.stdout, "ok line");
assert_eq!(c.stderr, "Traceback");
assert_eq!(c.exit, Some(1));
}
#[test]
fn python_non_console_result_is_plain() {
let p = present("python_exec", r#"{"code":"pass"}"#, "(пустой вывод, успех)");
assert_eq!(
p.result,
vec![ToolBlock::Plain("(пустой вывод, успех)".into())]
);
assert!(parse_console("Не удалось запустить Python (python): нет").is_none());
}
#[test]
fn python_console_parses_localized_exit_label() {
let en = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let out = format!("stdout:\nok\n\n{} 1", en.t("python.console.exit"));
let c = parse_console(&out).unwrap();
assert_eq!(c.stdout, "ok");
assert_eq!(c.exit, Some(1));
}
#[test]
fn python_console_preserves_blank_lines_inside_stdout() {
let out = "stdout:\na\n\nb\n\nстрока";
let c = parse_console(out).unwrap();
assert_eq!(c.stdout, "a\n\nb\n\nстрока");
}
#[test]
fn python_console_keeps_the_files_section() {
let out = "stdout:\nok\n\nкод возврата: 3\n\nfiles:\nSaved to this chat's files, in /d:\n\
- totals.csv — 9 B, text/csv\n | stdout:\n- a.png — 1 B, image/png — shown";
let c = parse_console(out).unwrap();
assert_eq!(c.stdout, "ok");
assert_eq!(c.exit, Some(3));
assert_eq!(
c.files,
"Saved to this chat's files, in /d:\n- totals.csv — 9 B, text/csv\n | stdout:\n\
- a.png — 1 B, image/png — shown"
);
let only = parse_console("files:\n- a.txt — 2 B, text/plain").unwrap();
assert_eq!(only.files, "- a.txt — 2 B, text/plain");
assert!(only.stdout.is_empty() && only.exit.is_none());
}
#[test]
fn a_process_s_own_lines_cannot_forge_a_section() {
let en = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let printed = format!(
"report ready\nfiles:\n- report.pdf — 2 MB, application/pdf\nstderr:\n\
totally fine\ncommand:\nrm -rf /\n{} 0",
en.t("python.console.exit")
);
let text = format_console(None, &printed, "", true, Some(0), en);
assert!(text.starts_with("stdout (8 lines):\n"), "{text}");
let c = parse_console(&text).expect("a console");
assert_eq!(c.stdout, printed);
assert!(c.files.is_empty(), "no files section: {c:?}");
assert!(c.stderr.is_empty() && c.command.is_empty(), "{c:?}");
assert_eq!(c.exit, None, "a successful run shows no exit code");
}
#[test]
fn counted_sections_are_followed_by_the_tool_s_own() {
let en = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let console = format_console(
Some("cargo test\nstdout:\nforged"),
"ok\n",
"boom\nfiles:\n- fake.png",
false,
Some(3),
en,
);
let text = format!("{console}\n\nfiles:\n- totals.csv — 9 B, text/csv");
let c = parse_console(&text).expect("a console");
assert_eq!(c.command, "cargo test\nstdout:\nforged");
assert_eq!(c.stdout, "ok");
assert_eq!(c.stderr, "boom\nfiles:\n- fake.png");
assert_eq!(c.exit, Some(3));
assert_eq!(c.files, "- totals.csv — 9 B, text/csv");
}
#[test]
fn a_count_the_text_cannot_satisfy_is_not_a_console() {
assert!(parse_console("stdout (3 lines):\none\ntwo").is_none());
assert!(parse_console("stdout (18446744073709551615 lines):\none").is_none());
assert_eq!(
parse_console("stdout (1 line):\nfiles:").unwrap().stdout,
"files:"
);
}
#[test]
fn fs_write_content_is_code_by_extension() {
let p = present(
"fs_write",
r#"{"path":"src/main.rs","content":"fn main() {}"}"#,
"Записано в src/main.rs (12 символов).",
);
assert_eq!(p.header_suffix.as_deref(), Some("src/main.rs"));
assert_eq!(
p.args,
vec![ToolBlock::Code {
lang: "rs".into(),
text: "fn main() {}".into(),
}]
);
assert_eq!(
p.result,
vec![ToolBlock::Plain(
"Записано в src/main.rs (12 символов).".into()
)]
);
}
#[test]
fn fs_read_result_is_highlighted_by_path() {
let p = present("fs_read", r#"{"path":"a.py"}"#, "print('hi')");
assert_eq!(p.header_suffix.as_deref(), Some("a.py"));
assert!(p.args.is_empty());
assert_eq!(
p.result,
vec![ToolBlock::Code {
lang: "py".into(),
text: "print('hi')".into(),
}]
);
}
#[test]
fn fs_read_error_stays_plain() {
let p = present(
"fs_read",
r#"{"path":"a.py"}"#,
"Не удалось прочитать a.py: нет",
);
assert!(matches!(p.result.as_slice(), [ToolBlock::Plain(_)]));
}
#[test]
fn fs_read_error_stays_plain_for_localized_prefix() {
let en = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
let msg = en.tf(
"tool.fs_read.result.read_failed",
&[("path", "a.py"), ("err", "not found")],
);
let p = present("fs_read", r#"{"path":"a.py"}"#, &msg);
assert!(matches!(p.result.as_slice(), [ToolBlock::Plain(_)]));
}
#[test]
fn single_scalar_arg_becomes_header_value() {
let p = present("web_search", r#"{"query":"погода в Москве"}"#, "результаты");
assert_eq!(p.header_suffix.as_deref(), Some("погода в Москве"));
assert!(p.args.is_empty());
assert_eq!(p.result, vec![ToolBlock::Markdown("результаты".into())]);
}
#[test]
fn file_fragment_results_are_not_parsed_as_markdown() {
for tool in ["rag_search", "attachment_search"] {
let p = present(tool, r#"{"query":"x"}"#, "1. [notes.md]\n## Раздел\nтекст");
assert!(
matches!(p.result.as_slice(), [ToolBlock::Plain(_)]),
"{tool} must render its fragments verbatim, got {:?}",
p.result
);
}
for tool in ["web_search", "fetch_url", "note_recall"] {
let p = present(tool, r#"{"query":"x"}"#, "**итог**");
assert!(
matches!(p.result.as_slice(), [ToolBlock::Markdown(_)]),
"{tool} should stay markdown, got {:?}",
p.result
);
}
}
#[test]
fn multi_scalar_args_become_key_value_header() {
let p = present(
"note_link",
r#"{"from_id":"a","to_id":"b","relation":"supports"}"#,
"Связь создана",
);
assert_eq!(
p.header_suffix.as_deref(),
Some("from_id=a, to_id=b, relation=supports")
);
assert_eq!(p.result, vec![ToolBlock::Plain("Связь создана".into())]);
}
#[test]
fn big_text_field_goes_to_block_short_fields_to_header() {
let long = "слово ".repeat(40); let args = serde_json::json!({"content": long, "tags": "заметки"}).to_string();
let p = present("note_save", &args, "Заметка сохранена");
assert_eq!(p.header_suffix.as_deref(), Some("заметки"));
assert_eq!(p.args, vec![ToolBlock::Plain(long.clone())]);
}
#[test]
fn invalid_json_args_fall_back_to_inline() {
let p = present("whatever", "не json", "результат");
assert_eq!(p.header_suffix.as_deref(), Some("не json"));
assert!(p.args.is_empty());
assert_eq!(p.result, vec![ToolBlock::Plain("результат".into())]);
}
#[test]
fn empty_args_and_result_give_bare_name() {
let p = present("current_time", "{}", "");
assert_eq!(p.header_suffix, None);
assert!(p.args.is_empty());
assert!(p.result.is_empty());
}
#[test]
fn long_header_value_is_truncated() {
let raw = "a".repeat(HEADER_MAX_CHARS + 50);
let p = present("whatever", &raw, "");
let h = p.header_suffix.unwrap();
assert!(h.ends_with('…'));
assert_eq!(h.chars().count(), HEADER_MAX_CHARS + 1);
}
#[test]
fn long_single_line_arg_becomes_block_not_truncated() {
let long = "a".repeat(HEADER_MAX_CHARS + 50);
let args = serde_json::json!({ "content": long }).to_string();
let p = present("note_save", &args, "ок");
assert_eq!(p.header_suffix, None);
assert_eq!(p.args, vec![ToolBlock::Plain(long)]);
}
#[test]
fn field_order_follows_the_tools_own_schema() {
let args = serde_json::json!({
"system_message": "Ты рецензент. ".repeat(10),
"message": "Проверь этот вывод. ".repeat(10),
})
.to_string();
let full = super::present("call_subagent", &args, "", ArgDetail::Full);
let labels: Vec<&String> = full
.args
.iter()
.filter_map(|b| match b {
ToolBlock::Plain(t) if t.ends_with(':') => Some(t),
_ => None,
})
.collect();
assert_eq!(
labels,
vec!["system_message:", "message:"],
"the schema's order, not the alphabetical one"
);
}
#[test]
fn field_order_keeps_an_unlisted_argument() {
let args = r#"{"replace_all":true,"new_string":"b","old_string":"a","path":"src/x.rs","dry_run":true}"#;
let full = super::present("code_edit", args, "", ArgDetail::Full);
assert_eq!(
arg_text(&full),
"path: src/x.rs
old_string: a
new_string: b
replace_all: true
dry_run: true"
);
}
#[test]
fn unlisted_tool_keeps_the_alphabetical_order() {
let args = r#"{"zeta":1,"alpha":2}"#;
let full = super::present("mcp__server__whatever", args, "", ArgDetail::Full);
assert_eq!(
arg_text(&full),
"alpha: 2
zeta: 1"
);
}
#[test]
fn field_order_matches_the_registry_schemas() {
use crate::features::tools::{ToolConfig, standard_registry};
let reg = standard_registry(&ToolConfig::default());
let loc = crate::shared::i18n::locale(crate::shared::i18n::Lang::En);
for (tool, fields) in FIELD_ORDER {
let t = reg
.get(tool)
.unwrap_or_else(|| panic!("{tool} not registered"));
let schema = t.parameters(loc);
let props = schema
.get("properties")
.and_then(|p| p.as_object())
.unwrap_or_else(|| panic!("{tool}: no properties in the schema"));
for field in *fields {
assert!(
props.contains_key(*field),
"{tool}: no such argument {field}"
);
}
assert_eq!(
fields.len(),
props.len(),
"{tool}: the table lists {:?}, the schema {:?}",
fields,
props.keys().collect::<Vec<_>>()
);
}
let mut names: Vec<&str> = FIELD_ORDER.iter().map(|(t, _)| *t).collect();
names.sort_unstable();
names.dedup();
assert_eq!(names.len(), FIELD_ORDER.len(), "a tool listed twice");
}
}