use super::*;
fn parse(text: &str) -> TextToolParseResult {
parse_fenced_json_tool_calls(text)
}
fn arg<'a>(call: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
call.get("arguments")?.get(key)
}
#[test]
fn parses_a_single_clean_call() {
let out = parse("```tool\n{\"name\": \"read_file\", \"args\": {\"path\": \"a.rs\"}}\n```");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "read_file");
assert_eq!(arg(&out.calls[0], "path").unwrap(), "a.rs");
}
#[test]
fn parses_tool_arguments_dialect_aliases() {
let out = parse("```tool\n{\"tool\": \"read_file\", \"arguments\": {\"path\": \"a.rs\"}}\n```");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "read_file");
assert_eq!(arg(&out.calls[0], "path").unwrap(), "a.rs");
}
#[test]
fn unwraps_generic_tool_wrapper_envelope() {
let out = parse(
"```tool\n{\"name\":\"tool\",\"args\":{\"name\":\"look\",\"args\":{\"intent\":\"read\",\"file\":\"src/lib.rs\"}}}\n```",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "look");
assert_eq!(arg(&out.calls[0], "intent").unwrap(), "read");
assert_eq!(arg(&out.calls[0], "file").unwrap(), "src/lib.rs");
}
#[test]
fn strips_harmony_channel_suffix_from_tool_name() {
let out = parse(
"```tool\n{\"name\":\"run<|channel|>commentary\",\"args\":{\"command\":\"cargo test\"}}\n```",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "run");
assert_eq!(arg(&out.calls[0], "command").unwrap(), "cargo test");
}
#[test]
fn canonical_keys_win_over_aliases() {
let out = parse(
"```tool\n{\"name\": \"canonical\", \"tool\": \"alias\", \"args\": {\"k\": 1}, \"arguments\": {\"k\": 2}}\n```",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "canonical");
assert_eq!(arg(&out.calls[0], "k").unwrap(), 1);
}
#[test]
fn content_with_backticks_heredoc_brace_and_tag_survives() {
let content = "```\nx := `raw`\n<<EOF\n}\n</tool>\n```";
let json_content = serde_json::to_string(content).unwrap();
let src = format!(
"```tool\n{{\"name\": \"write_file\", \"args\": {{\"path\": \"f.go\", \"content\": {json_content}}}}}\n```"
);
let out = parse(&src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(arg(&out.calls[0], "content").unwrap(), content);
}
#[test]
fn multiple_fences_yield_multiple_calls() {
let src = "```tool\n{\"name\": \"a\", \"args\": {}}\n```\nsome prose\n```tool\n{\"name\": \"b\", \"args\": {\"k\": 1}}\n```";
let out = parse(src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 2);
assert_eq!(out.calls[0]["name"], "a");
assert_eq!(out.calls[1]["name"], "b");
assert!(out.prose.contains("some prose"));
}
#[test]
fn content_starting_with_heredoc_opener_is_just_a_string() {
let content = "<<EOF\npackage main\n";
let json_content = serde_json::to_string(content).unwrap();
let src = format!(
"```tool\n{{\"name\": \"write_file\", \"args\": {{\"content\": {json_content}}}}}\n```"
);
let out = parse(&src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(arg(&out.calls[0], "content").unwrap(), content);
}
#[test]
fn array_body_is_expected_single_object() {
let out = parse("```tool\n[{\"name\": \"a\", \"args\": {}}]\n```");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(
out.errors[0].contains("exactly one JSON object"),
"got: {}",
out.errors[0]
);
}
#[test]
fn trailing_bytes_after_object_rejected() {
let out = parse("```tool\n{\"name\": \"a\", \"args\": {}} trailing\n```");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("exactly one JSON object"));
}
#[test]
fn missing_name_rejected() {
let out = parse("```tool\n{\"args\": {\"path\": \"a\"}}\n```");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("missing a non-empty string `name`"));
}
#[test]
fn empty_name_rejected() {
let out = parse("```tool\n{\"name\": \" \", \"args\": {}}\n```");
assert!(out.calls.is_empty());
assert!(out.errors[0].contains("`name`"));
}
#[test]
fn args_not_object_rejected() {
let out = parse("```tool\n{\"name\": \"a\", \"args\": \"oops\"}\n```");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("must be a JSON object"));
}
#[test]
fn absent_args_is_empty_object() {
let out = parse("```tool\n{\"name\": \"list_dir\"}\n```");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert!(out.calls[0]["arguments"].is_object());
assert_eq!(out.calls[0]["arguments"].as_object().unwrap().len(), 0);
}
#[test]
fn truncated_string_is_unterminated_not_half_applied() {
let out = parse("```tool\n{\"name\": \"write_file\", \"args\": {\"content\": \"half a str");
assert!(out.calls.is_empty(), "must not dispatch a truncated call");
assert_eq!(out.errors.len(), 1);
assert!(
out.errors[0].contains("Unterminated"),
"got: {}",
out.errors[0]
);
}
#[test]
fn complete_object_without_close_fence_is_accepted() {
let out = parse("```tool\n{\"name\": \"a\", \"args\": {\"k\": 1}}");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "a");
}
#[test]
fn json_fence_accepts_with_protocol_violation() {
let out = parse("```json\n{\"name\": \"a\", \"args\": {}}\n```");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "a");
assert!(
out.violations
.iter()
.any(|v| v.contains("protocol_violation")),
"violations: {:?}",
out.violations
);
}
#[test]
fn tool_like_fence_drift_accepts_with_protocol_violation() {
for (src, opener) in [
(
"```tool_code\n{\"name\": \"a\", \"args\": {\"k\": 1}}\n```",
"```tool_code",
),
(
"```tool python\n{\"name\": \"a\", \"args\": {\"k\": 1}}\n```",
"```tool python",
),
(
"```function_call\n{\"name\": \"a\", \"args\": {\"k\": 1}}\n```",
"```function_call",
),
(
"~~~tool\n{\"name\": \"a\", \"args\": {\"k\": 1}}\n~~~",
"~~~tool",
),
] {
let out = parse(src);
assert!(
out.errors.is_empty(),
"errors for {opener}: {:?}",
out.errors
);
assert_eq!(out.calls.len(), 1, "calls for {opener}: {:?}", out.calls);
assert_eq!(out.calls[0]["name"], "a");
assert_eq!(arg(&out.calls[0], "k").unwrap(), 1);
assert!(
out.violations.iter().any(|v| v.contains(opener)),
"violations for {opener}: {:?}",
out.violations
);
}
}
#[test]
fn invalid_tool_like_fence_drift_reports_error_and_violation() {
let out = parse("```tool_code\nnot json\n```");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("not valid JSON"));
assert!(
out.violations.iter().any(|v| v.contains("```tool_code")),
"violations: {:?}",
out.violations
);
}
#[test]
fn bare_json_tool_call_accepts_with_protocol_violation() {
let out = parse("{\"name\": \"a\", \"args\": {\"k\": 1}}");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert!(out.prose.is_empty(), "prose: {:?}", out.prose);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "a");
assert_eq!(arg(&out.calls[0], "k").unwrap(), 1);
assert!(
out.violations
.iter()
.any(|v| v.contains("bare JSON object")),
"violations: {:?}",
out.violations
);
}
#[test]
fn chat_template_envelope_recovers_multiple_inline_argument_calls() {
let out = parse(
"<tool_calls>\n<tool>\n{\"name\":\"look\",\"file\":\"src/writer.zig\"}\n{\"name\":\"look\",\"file\":\"src/parser.zig\"}\n</tool_calls>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert!(out.prose.is_empty(), "prose: {:?}", out.prose);
assert_eq!(out.calls.len(), 2);
assert_eq!(out.calls[0]["name"], "look");
assert_eq!(
arg(&out.calls[0], "file"),
Some(&serde_json::json!("src/writer.zig"))
);
assert_eq!(out.calls[1]["name"], "look");
assert_eq!(
arg(&out.calls[1], "file"),
Some(&serde_json::json!("src/parser.zig"))
);
assert!(
out.violations
.iter()
.any(|violation| violation.contains("chat-template")),
"violations: {:?}",
out.violations
);
}
#[test]
fn chat_template_envelope_accepts_optional_per_call_close_markers() {
let out = parse(
"<tool_calls><tool>{\"name\":\"a\",\"args\":{\"k\":1}}</tool><tool>{\"tool\":\"b\",\"arguments\":{\"v\":2}}</tool></tool_calls>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 2);
assert_eq!(out.calls[0]["name"], "a");
assert_eq!(arg(&out.calls[0], "k"), Some(&serde_json::json!(1)));
assert_eq!(out.calls[1]["name"], "b");
assert_eq!(arg(&out.calls[1], "v"), Some(&serde_json::json!(2)));
}
#[test]
fn malformed_chat_template_envelope_never_dispatches_partial_calls() {
let out = parse("<tool_calls>\n<tool>\n{\"name\":\"look\",\"file\":\"src/writer.zig\"");
assert!(
out.calls.is_empty(),
"partial calls must not dispatch: {:?}",
out.calls
);
assert_eq!(out.errors.len(), 1);
assert!(
out.errors[0].contains("<tool_calls>") && out.errors[0].contains("not executed"),
"error: {}",
out.errors[0]
);
}
#[test]
fn truncated_chat_template_second_marker_never_dispatches_first_call() {
let out =
parse("<tool_calls><tool>{\"name\":\"look\",\"file\":\"src/lib.rs\"}<tool></tool_calls>");
assert!(
out.calls.is_empty(),
"partial calls must not dispatch: {:?}",
out.calls
);
assert_eq!(out.errors.len(), 1);
assert!(
out.errors[0].contains("ended without a complete JSON object")
&& out.errors[0].contains("not executed"),
"error: {}",
out.errors[0]
);
}
#[test]
fn unmatched_chat_template_tool_close_is_rejected() {
let out = parse("<tool_calls></tool></tool_calls>");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("unmatched `</tool>`"));
}
#[test]
fn chat_template_without_tool_marker_is_rejected() {
let out = parse("<tool_calls>{\"name\":\"look\",\"file\":\"src/writer.zig\"}</tool_calls>");
assert!(out.calls.is_empty());
assert_eq!(out.errors.len(), 1);
assert!(out.errors[0].contains("no `<tool>` marker"));
}
#[test]
fn legacy_tagged_markup_under_json_reports_protocol_violation() {
let out = parse("<tool_call>\na({})\n</tool_call>");
assert!(out.calls.is_empty());
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert!(
out.violations.iter().any(|v| v.contains("<tool_call>")),
"violations: {:?}",
out.violations
);
}
#[test]
fn unrelated_fence_stays_in_prose() {
let out = parse("```python\nprint('hi')\n```");
assert!(out.calls.is_empty());
assert!(out.errors.is_empty());
assert!(out.prose.contains("print('hi')"));
}
#[test]
fn unrelated_tilde_fence_stays_in_prose() {
let out = parse("~~~python\nprint('hi')\n~~~");
assert!(out.calls.is_empty());
assert!(out.errors.is_empty());
assert!(out.prose.contains("print('hi')"));
}
#[test]
fn embedded_backtick_fence_does_not_close_early() {
let content = "before\n```\nafter";
let json_content = serde_json::to_string(content).unwrap();
let src = format!("```tool\n{{\"name\": \"w\", \"args\": {{\"c\": {json_content}}}}}\n```");
let out = parse(&src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(arg(&out.calls[0], "c").unwrap(), content);
}
#[test]
fn content_with_close_tool_tag_survives() {
let content = "x </tool> y";
let json_content = serde_json::to_string(content).unwrap();
let src = format!("```tool\n{{\"name\": \"w\", \"args\": {{\"c\": {json_content}}}}}\n```");
let out = parse(&src);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(arg(&out.calls[0], "c").unwrap(), content);
}
#[test]
fn xml_envelope_three_calls_without_close_parses() {
let out = parse(
"<tool_calls>\n<look>\n<file>\nsrc/writer.zig\n</file>\n</look>\n\
<look>\n<file>\nsrc/parser.zig\n</file>\n</look>\n\
<look>\n<file>\nsrc/root.zig\n</file>\n</look>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert!(out.prose.is_empty(), "prose: {:?}", out.prose);
assert_eq!(out.calls.len(), 3);
for (call, file) in out
.calls
.iter()
.zip(["src/writer.zig", "src/parser.zig", "src/root.zig"])
{
assert_eq!(call["name"], "look");
assert_eq!(arg(call, "file").unwrap(), file);
}
assert!(
out.violations.iter().any(|v| v.contains("chat-template")),
"violations: {:?}",
out.violations
);
}
#[test]
fn xml_envelope_single_multi_arg_call_without_close_parses() {
let out = parse(
"<tool_calls>\n<look>\n<file>\nsrc/writer.zig\n</file>\n<intent>\nread\n</intent>\n</look>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "look");
assert_eq!(arg(&out.calls[0], "file").unwrap(), "src/writer.zig");
assert_eq!(arg(&out.calls[0], "intent").unwrap(), "read");
}
#[test]
fn xml_envelope_multiline_arg_value_kept_verbatim() {
let out = parse(
"<tool_calls>\n<write>\n<content>\nline one\nline two\n</content>\n</write>\n</tool_calls>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "write");
assert_eq!(arg(&out.calls[0], "content").unwrap(), "line one\nline two");
}
#[test]
fn tool_code_tag_with_inline_json_args_parses() {
let out = parse(
"<tool_code>\n{ \"name\": \"look\", \"file\": \"src/writer.zig\", \"intent\": \"read\" }",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "look");
assert_eq!(arg(&out.calls[0], "file").unwrap(), "src/writer.zig");
assert_eq!(arg(&out.calls[0], "intent").unwrap(), "read");
assert!(
out.violations.iter().any(|v| v.contains("chat-template")),
"violations: {:?}",
out.violations
);
}
#[test]
fn tool_code_tag_with_multiple_json_objects_parses() {
let out = parse(
"<tool_code>\n{\"name\":\"a\",\"args\":{\"k\":1}}\n{\"name\":\"b\",\"args\":{\"v\":2}}\n</tool_code>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 2);
assert_eq!(out.calls[0]["name"], "a");
assert_eq!(arg(&out.calls[0], "k").unwrap(), 1);
assert_eq!(out.calls[1]["name"], "b");
assert_eq!(arg(&out.calls[1], "v").unwrap(), 2);
}
#[test]
fn bare_tool_call_tag_with_json_parses() {
let out = parse("<tool_call>\n{\"name\":\"look\",\"args\":{\"file\":\"a.rs\"}}\n</tool_call>");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "look");
assert_eq!(arg(&out.calls[0], "file").unwrap(), "a.rs");
}
#[test]
fn prose_before_envelope_parses_and_keeps_prose() {
let out = parse(
"I will read the writer next.\n<tool_calls>\n<tool>\n{\"name\":\"look\",\"file\":\"src/writer.zig\"}\n</tool_calls>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "look");
assert_eq!(arg(&out.calls[0], "file").unwrap(), "src/writer.zig");
assert!(
out.prose.contains("I will read the writer next."),
"prose: {:?}",
out.prose
);
}
#[test]
fn prose_after_envelope_close_survives() {
let out = parse(
"<tool_calls>\n<tool>\n{\"name\":\"look\",\"file\":\"a.rs\"}\n</tool_calls>\nDone reading.",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert!(
out.prose.contains("Done reading."),
"prose: {:?}",
out.prose
);
}
#[test]
fn truncated_inner_xml_tag_is_violation_zero_calls() {
let out = parse("<tool_calls>\n<look>\n<file>\nsrc/writer.zig");
assert!(out.calls.is_empty(), "calls: {:?}", out.calls);
assert_eq!(out.errors.len(), 1);
assert!(
out.errors[0].contains("<tool_calls>") && out.errors[0].contains("not executed"),
"error: {}",
out.errors[0]
);
}
#[test]
fn envelope_with_garbage_body_is_violation_not_none() {
let out = parse("<tool_code>\nlol this is not json at all\n</tool_code>");
assert!(out.calls.is_empty(), "calls: {:?}", out.calls);
assert_eq!(out.errors.len(), 1, "errors: {:?}", out.errors);
assert!(
out.errors[0].contains("not executed"),
"error: {}",
out.errors[0]
);
}
#[test]
fn prose_before_canonical_tool_fence_still_parses() {
let out = parse("Here is the call:\n```tool\n{\"name\": \"a\", \"args\": {\"k\": 1}}\n```");
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert!(
out.violations.is_empty(),
"violations: {:?}",
out.violations
);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "a");
assert_eq!(arg(&out.calls[0], "k").unwrap(), 1);
assert!(
out.prose.contains("Here is the call:"),
"prose: {:?}",
out.prose
);
}
#[test]
fn xml_envelope_duplicate_arg_is_violation_zero_calls() {
let out = parse(
"<tool_calls>\n<look>\n<file>\na.rs\n</file>\n<file>\nb.rs\n</file>\n</look>\n</tool_calls>",
);
assert!(out.calls.is_empty(), "calls: {:?}", out.calls);
assert_eq!(out.errors.len(), 1, "errors: {:?}", out.errors);
assert!(
out.errors[0].contains("<file>") && out.errors[0].contains("not executed"),
"error: {}",
out.errors[0]
);
}
#[test]
fn tool_code_json_string_with_literal_close_tag_survives() {
let out = parse(
"<tool_code>\n{ \"name\": \"write\", \"args\": { \"content\": \"</tool_code>\" } }\n</tool_code>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "write");
assert_eq!(arg(&out.calls[0], "content").unwrap(), "</tool_code>");
}
#[test]
fn tool_marker_json_string_with_literal_envelope_close_survives() {
let out = parse(
"<tool_calls>\n<tool>\n{ \"name\": \"write\", \"content\": \"</tool_calls>\" }\n</tool_calls>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "write");
assert_eq!(arg(&out.calls[0], "content").unwrap(), "</tool_calls>");
}
#[test]
fn xml_arg_value_with_literal_envelope_close_survives() {
let out = parse(
"<tool_calls>\n<write>\n<content>\n</tool_calls>\n</content>\n</write>\n</tool_calls>",
);
assert!(out.errors.is_empty(), "errors: {:?}", out.errors);
assert_eq!(out.calls.len(), 1);
assert_eq!(out.calls[0]["name"], "write");
assert_eq!(arg(&out.calls[0], "content").unwrap(), "</tool_calls>");
}