use rlx_text::{ChatMessage, ChatRenderOptions, ChatTemplate};
const TEMPLATE: &str = r#"
{%- if tools %}<|im_start|>system
# Tools
<tools>
{%- for t in tools %}
{{ t | tojson }}
{%- endfor %}
</tools><|im_end|>
{%- endif %}
{%- for m in messages %}
{%- if m.role == "tool" %}<|im_start|>user
<tool_response>
{{ m.content }}
</tool_response><|im_end|>
{%- else %}<|im_start|>{{ m.role }}
{{ m.content }}
{%- if m.tool_calls %}
{%- for c in m.tool_calls %}
{%- set fn = c.function %}
<tool_call>
{"name": "{{ fn.name }}", "arguments": {{ fn.arguments | tojson }}}
</tool_call>
{%- endfor %}
{%- endif %}<|im_end|>
{%- endif %}
{%- endfor %}
{%- if add_generation_prompt %}<|im_start|>assistant
{%- endif %}
"#;
fn tool(name: &str) -> serde_json::Value {
serde_json::json!({
"type": "function",
"function": {
"name": name,
"description": "Get the current weather for a city",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"],
}
}
})
}
fn render(tools: Vec<serde_json::Value>) -> String {
let t = ChatTemplate::from_source(TEMPLATE).expect("compile");
t.render_with_options(
&[ChatMessage {
role: "user".into(),
content: "weather in Paris?".into(),
}],
ChatRenderOptions {
add_generation_prompt: true,
tools,
..Default::default()
},
)
.expect("render")
}
#[test]
fn a_tool_schema_reaches_the_prompt() {
let out = render(vec![tool("get_weather")]);
assert!(
out.contains("# Tools"),
"the tools branch should fire:\n{out}"
);
assert!(
out.contains("get_weather"),
"the tool's name must survive serialization:\n{out}"
);
assert!(
out.contains("\"city\""),
"the schema must come through:\n{out}"
);
assert!(out.contains("required"), "nested arrays too:\n{out}");
}
#[test]
fn every_tool_is_listed() {
let out = render(vec![tool("a"), tool("b")]);
assert!(
out.contains("\"name\":\"a\"") || out.contains("\"name\": \"a\""),
"{out}"
);
assert!(
out.contains("\"name\":\"b\"") || out.contains("\"name\": \"b\""),
"{out}"
);
}
#[test]
fn no_tools_renders_no_tools_block() {
let out = render(Vec::new());
assert!(
!out.contains("# Tools"),
"empty tools should not open a block:\n{out}"
);
}
#[test]
fn supports_tools_reflects_the_template() {
assert!(
ChatTemplate::from_source(TEMPLATE)
.unwrap()
.supports_tools()
);
let plain = "{% for m in messages %}{{ m.content }}{% endfor %}";
assert!(
!ChatTemplate::from_source(plain).unwrap().supports_tools(),
"a template with no tools branch must say so, so a caller can refuse"
);
}
#[test]
fn tojson_accepts_an_indent_argument() {
let t = ChatTemplate::from_source("{{ tools[0] | tojson(indent=2) }}").expect("compile");
let out = t
.render_with_options(
&[],
ChatRenderOptions {
tools: vec![tool("get_weather")],
..Default::default()
},
)
.expect("render with indent");
assert!(out.contains("get_weather"), "{out}");
assert!(
out.contains('\n'),
"indented json should be multi-line:\n{out}"
);
}
#[test]
fn an_assistant_tool_call_and_its_result_both_render() {
let t = ChatTemplate::from_source(TEMPLATE).expect("compile");
let messages = vec![
serde_json::json!({ "role": "user", "content": "weather in Paris?" }),
serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [{
"type": "function",
"function": { "name": "get_weather", "arguments": { "city": "Paris" } }
}]
}),
serde_json::json!({ "role": "tool", "content": "{\"temp_c\": 14}" }),
];
let out = t
.render_json_with_options(
&messages,
ChatRenderOptions {
add_generation_prompt: true,
tools: vec![tool("get_weather")],
..Default::default()
},
)
.expect("render");
assert!(out.contains("<tool_call>"), "the call must render:\n{out}");
assert!(out.contains("get_weather"), "with its name:\n{out}");
assert!(
out.contains("\"city\":\"Paris\"") || out.contains("\"city\": \"Paris\""),
"and its arguments as json, not a stringified blob:\n{out}"
);
assert!(
out.contains("<tool_response>"),
"the result must render:\n{out}"
);
assert!(out.contains("temp_c"), "with its content:\n{out}");
assert!(
out.find("<tool_call>") < out.find("<tool_response>"),
"the call must precede the result:\n{out}"
);
}
#[test]
fn arguments_keep_their_shape() {
let t = ChatTemplate::from_source(
"{%- for m in messages %}{%- for c in m.tool_calls %} {%- if c.function.arguments is string %}STR:{{ c.function.arguments }} {%- else %}OBJ:{{ c.function.arguments | tojson }}{%- endif %} {%- endfor %}{%- endfor %}",
)
.expect("compile");
let render = |args: serde_json::Value| {
t.render_json_with_options(
&[serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [{ "function": { "name": "f", "arguments": args } }]
})],
ChatRenderOptions::default(),
)
.expect("render")
};
let obj = render(serde_json::json!({ "a": 1 }));
assert!(
obj.starts_with("OBJ:"),
"an object must take the tojson branch: {obj:?}"
);
assert!(obj.contains("\"a\":1"), "and encode once: {obj:?}");
assert!(
!obj.contains("\\\""),
"no escaped quotes from double encoding: {obj:?}"
);
let s = render(serde_json::json!("{\"a\": 1}"));
assert!(
s.starts_with("STR:"),
"a string must take the string branch: {s:?}"
);
assert_eq!(s, "STR:{\"a\": 1}", "and pass through unchanged");
}