use cortiq_engine::tokenizer::Tokenizer;
fn tok_with_template() -> Tokenizer {
let mut t = Tokenizer::byte_level();
t.chat_template = Some(include_str!("fixtures/qwen3_chat_template.jinja").to_string());
t
}
fn j(v: serde_json::Value) -> serde_json::Value {
v
}
#[test]
fn tools_render_into_the_system_block() {
let t = tok_with_template();
let tools = vec![j(serde_json::json!({
"type": "function",
"function": {
"name": "get_weather",
"description": "Get current weather for a city",
"parameters": {
"type": "object",
"properties": {"city": {"type": "string"}},
"required": ["city"]
}
}
}))];
let msgs = vec![j(
serde_json::json!({"role": "user", "content": "Weather in Paris?"}),
)];
let text = t.render_chat_json(&msgs, Some(&tools), None).unwrap();
assert!(text.contains("<tools>"), "tool signatures section missing");
assert!(
text.contains("\"get_weather\""),
"the function declaration must reach the prompt"
);
assert!(
text.contains("<tool_call>"),
"the output-grammar instruction must reach the prompt"
);
let bare = t.render_chat_json(&msgs, None, None).unwrap();
assert!(
!bare.contains("<tools>"),
"tools block must be absent without tools"
);
}
#[test]
fn assistant_history_with_tool_calls_renders() {
let t = tok_with_template();
let msgs = vec![
j(serde_json::json!({"role": "user", "content": "Weather in Paris?"})),
j(serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}
}]
})),
j(serde_json::json!({
"role": "tool",
"tool_call_id": "call_1",
"content": "{\"temp_c\": 18, \"sky\": \"clear\"}"
})),
];
let text = t.render_chat_json(&msgs, None, None).unwrap();
assert!(
text.contains(
"<tool_call>\n{\"name\": \"get_weather\", \"arguments\": {\"city\": \"Paris\"}}"
),
"assistant tool_calls must render in the trained grammar, got:\n{text}"
);
assert!(
text.contains("<tool_response>\n{\"temp_c\": 18"),
"role:tool results must render as <tool_response>, got:\n{text}"
);
}
#[test]
fn consecutive_tool_results_share_one_user_turn() {
let t = tok_with_template();
let msgs = vec![
j(serde_json::json!({"role": "user", "content": "Two cities."})),
j(serde_json::json!({
"role": "assistant", "content": "",
"tool_calls": [
{"type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}},
{"type": "function", "function": {"name": "get_weather", "arguments": "{\"city\": \"Oslo\"}"}}
]
})),
j(serde_json::json!({"role": "tool", "content": "18C"})),
j(serde_json::json!({"role": "tool", "content": "7C"})),
];
let text = t.render_chat_json(&msgs, None, None).unwrap();
let user_starts = text.matches("<|im_start|>user").count();
assert_eq!(
user_starts, 2,
"the two tool results must share one user turn:\n{text}"
);
assert_eq!(text.matches("<tool_response>").count(), 2);
}
fn nanbeige_tok() -> Tokenizer {
let mut t = Tokenizer::byte_level();
t.chat_template = Some(include_str!("fixtures/nanbeige42_chat_template.jinja").to_string());
t
}
#[test]
fn nanbeige_tools_render_in_json_grammar() {
let t = nanbeige_tok();
let tools = vec![j(serde_json::json!({
"type": "function",
"function": {"name": "get_weather", "description": "d",
"parameters": {"type": "object", "properties": {}}}
}))];
let msgs = vec![j(
serde_json::json!({"role": "user", "content": "Weather in Paris?"}),
)];
let text = t.render_chat_json(&msgs, Some(&tools), None).unwrap();
assert!(
text.contains("<tools>"),
"tools section missing:\n{}",
&text[..text.len().min(400)]
);
assert!(text.contains("\"get_weather\""));
assert!(
text.contains("\"arguments\": <args-json-object>"),
"the JSON grammar must be selected, not the XML default"
);
assert!(
!text.contains("<function=example_function_name>"),
"the XML branch must not be the one rendered"
);
}
#[test]
fn nanbeige_tool_history_round_trips() {
let t = nanbeige_tok();
let msgs = vec![
j(serde_json::json!({"role": "user", "content": "Weather?"})),
j(serde_json::json!({"role": "assistant", "content": "",
"tool_calls": [{"type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}]})),
j(serde_json::json!({"role": "tool", "content": "18C"})),
];
let text = t.render_chat_json(&msgs, None, None).unwrap();
assert!(text.contains("get_weather"), "call history lost:\n{text}");
assert!(
text.contains("<tool_response>"),
"tool result lost:\n{text}"
);
}
#[test]
fn a_failing_template_is_reported_not_swallowed() {
let mut t = Tokenizer::byte_level();
t.chat_template =
Some("{% for m in messages %}{{ m.content | no_such_filter }}{% endfor %}".to_string());
let msgs = vec![serde_json::json!({"role": "user", "content": "hi"})];
let tools = vec![serde_json::json!({"type": "function", "function": {"name": "f"}})];
let err = t
.try_apply_chat_template_json(&msgs, Some(&tools), None)
.unwrap_err();
assert!(err.contains("no_such_filter"), "{err}");
assert!(!t.apply_chat_template_json(&msgs, None, None).is_empty());
t.chat_template = Some("{{ raise_exception('Conversation roles must alternate') }}".into());
let err = t
.try_apply_chat_template_json(&msgs, None, None)
.unwrap_err();
assert!(err.contains("roles must alternate"), "{err}");
}
#[test]
fn minicpm5_vendor_template_renders_like_transformers() {
let mut t = Tokenizer::byte_level();
t.chat_template = Some(include_str!("fixtures/minicpm5_chat_template.jinja").to_string());
let fx: serde_json::Value =
serde_json::from_str(include_str!("fixtures/minicpm5_template_renders.json")).unwrap();
let cases = fx["cases"].as_array().unwrap();
let mut with_tools = 0;
for (i, case) in cases.iter().enumerate() {
let msgs: Vec<serde_json::Value> = case["messages"].as_array().unwrap().clone();
let tools: Option<Vec<serde_json::Value>> =
case.get("tools").and_then(|t| t.as_array()).cloned();
with_tools += tools.is_some() as usize;
let think = case.get("enable_thinking").and_then(|v| v.as_bool());
if let Err(e) = t.try_apply_chat_template_json(&msgs, tools.as_deref(), think) {
panic!("case {i}: render error {e}");
}
let got = t.render_chat_json(&msgs, tools.as_deref(), think).unwrap();
assert_eq!(got, case["hf_text"].as_str().unwrap(), "case {i}");
}
assert!(
with_tools >= 3,
"the fixture must exercise the tools branch"
);
}