mod common;
use common::{create_test_tools, streaming_helpers};
use tool_parser::{
types::ToolCallItem, CohereParser, JsonParser, LlamaParser, MistralParser, QwenParser,
ToolParser,
};
const NON_TOOL_JSON: &str =
r#"{"type": "function", "function": "get_weather", "parameters": {"city": "Tokyo"}}"#;
const UNDECLARED_TOOL_JSON: &str =
r#"{"name": "get_stock_price", "parameters": {"ticker": "AAPL"}}"#;
type MakeParser = fn() -> Box<dyn ToolParser>;
fn llama() -> Box<dyn ToolParser> {
Box::new(LlamaParser::new())
}
fn json() -> Box<dyn ToolParser> {
Box::new(JsonParser::new())
}
fn mistral() -> Box<dyn ToolParser> {
Box::new(MistralParser::new())
}
fn qwen() -> Box<dyn ToolParser> {
Box::new(QwenParser::new())
}
fn cohere() -> Box<dyn ToolParser> {
Box::new(CohereParser::new())
}
enum Feed {
Chunks(&'static [&'static str]),
Realistic(&'static str),
}
#[expect(
clippy::unwrap_used,
reason = "test helper; allow-unwrap-in-tests only covers #[test] fns"
)]
async fn stream(parser: &mut dyn ToolParser, feed: &Feed) -> (String, Vec<ToolCallItem>) {
let owned_chunks;
let owned_refs;
let chunks: &[&str] = match feed {
Feed::Chunks(chunks) => chunks,
Feed::Realistic(input) => {
owned_chunks = streaming_helpers::create_realistic_chunks(input);
owned_refs = owned_chunks.iter().map(String::as_str).collect::<Vec<_>>();
&owned_refs
}
};
let tools = create_test_tools();
let mut normal_text = String::new();
let mut calls = Vec::new();
for chunk in chunks {
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
normal_text.push_str(&result.normal_text);
calls.extend(result.calls);
}
(normal_text, calls)
}
fn announced(calls: &[ToolCallItem]) -> Option<&str> {
calls.iter().find_map(|c| c.name.as_deref())
}
fn streamed_args(calls: &[ToolCallItem]) -> String {
calls.iter().map(|c| c.parameters.as_str()).collect()
}
struct Case {
label: &'static str,
make: MakeParser,
feed: Feed,
text: &'static str,
call: Option<&'static str>,
args_contain: Option<&'static str>,
flush: &'static str,
}
const BLANK: Case = Case {
label: "",
make: llama,
feed: Feed::Chunks(&[]),
text: "",
call: None,
args_contain: None,
flush: "",
};
#[tokio::test]
async fn streaming_never_swallows_content() {
let cases = [
Case {
label: "llama: no `name` key at all, split mid-token",
feed: Feed::Realistic(NON_TOOL_JSON),
text: NON_TOOL_JSON,
..BLANK
},
Case {
label: "llama: undeclared name split across chunk boundaries",
feed: Feed::Chunks(&[
r#"{"name": "get_st"#,
r#"ock_price", "para"#,
r#"meters": {"ticker": "AAPL"}}"#,
]),
text: UNDECLARED_TOOL_JSON,
..BLANK
},
Case {
label: "json: plain answer that happens to be JSON",
make: json,
feed: Feed::Realistic(r#"{"result": 42, "status": "ok"}"#),
text: r#"{"result": 42, "status": "ok"}"#,
..BLANK
},
Case {
label: "mistral: undeclared name inside [TOOL_CALLS] array",
make: mistral,
feed: Feed::Chunks(&[
"[TOOL_CALLS] ",
r#"[{"name": "frobni"#,
r#"cate", "arguments"#,
r#"": {"x": 1}}]"#,
]),
text: r#"[TOOL_CALLS] [{"name": "frobnicate", "arguments": {"x": 1}}]"#,
..BLANK
},
Case {
label: "qwen: complete JSON inside markers with no `name` key",
make: qwen,
feed: Feed::Chunks(&["<tool_call>\n", r#"{"foo": "#, "1}", "\n</tool_call>"]),
text: "<tool_call>\n{\"foo\": 1}\n</tool_call>",
..BLANK
},
Case {
label: "llama: a real declared call still streams name and args",
feed: Feed::Chunks(&[
r#"{"name": "get_we"#,
r#"ather", "parameters"#, r#"": {"city": "Tokyo"}}"#,
]),
call: Some("get_weather"),
args_contain: Some(r#"{"city":"Tokyo"}"#),
..BLANK
},
Case {
label: "llama: nothing invented after a completed tool call",
feed: Feed::Chunks(&[r#"{"name": "get_weather", "parameters": {"city": "Tokyo"}}"#]),
call: Some("get_weather"),
args_contain: Some(r#"{"city":"Tokyo"}"#),
..BLANK
},
Case {
label: "llama: announced call whose arguments are truncated",
feed: Feed::Chunks(&[r#"{"name": "get_weather", "parameters": {"city": "Par"#]),
call: Some("get_weather"),
..BLANK
},
Case {
label: "json: whole declared call inside one chunk",
make: json,
feed: Feed::Chunks(&[r#"{"name": "get_weather", "arguments": {"city": "Tokyo"}}"#]),
call: Some("get_weather"),
args_contain: Some(r#"{"city":"Tokyo"}"#),
..BLANK
},
Case {
label: "mistral: declared call after an undeclared one streams its args",
make: mistral,
feed: Feed::Chunks(&[
r#"[TOOL_CALLS] [{"name": "bogus", "arguments": {}}, {"name": "get_weather", "arguments": {"city": "Paris"}}"#,
"]",
]),
text: r#"[TOOL_CALLS] [{"name": "bogus", "arguments": {}}, "#,
call: Some("get_weather"),
args_contain: Some(r#"{"city":"Paris"}"#),
..BLANK
},
Case {
label: "cohere: complete action block streams name and arguments",
make: cohere,
feed: Feed::Chunks(&[
r#"<|START_ACTION|>{"tool_name": "search", "parameters": {"query": "rust"}}<|END_ACTION|>"#,
" done",
]),
call: Some("search"),
args_contain: Some(r#"{"query":"rust"}"#),
..BLANK
},
Case {
label: "qwen: partial </tool_call> held in normal_text_buffer",
make: qwen,
feed: Feed::Chunks(&[r#"<tool_call>
{"name": "bogus"</tool"#]),
text: "<tool_call>\n{\"name\": \"bogus\"",
flush: "</tool",
..BLANK
},
Case {
label: "cohere: response markers stripped from the flushed text",
make: cohere,
feed: Feed::Chunks(&["<|START_RESPONSE|>Hello<|START_AC"]),
flush: "Hello<|START_AC",
..BLANK
},
Case {
label: "llama: stream ends mid-name (could still become get_weather)",
feed: Feed::Chunks(&[r#"{"name": "get_wea"#]),
flush: r#"{"name": "get_wea"#,
..BLANK
},
Case {
label: "llama: partial <|python_tag|> held back at end of stream",
feed: Feed::Chunks(&["Sure, let me check <|py"]),
flush: "Sure, let me check <|py",
..BLANK
},
Case {
label: "json: truncated prefix of the declared `search`",
make: json,
feed: Feed::Chunks(&[r#"{"name": "sea"#]),
flush: r#"{"name": "sea"#,
..BLANK
},
Case {
label: "mistral: truncated tool call after the marker",
make: mistral,
feed: Feed::Chunks(&[r#"[TOOL_CALLS] [{"unknown"#]),
flush: r#"[TOOL_CALLS] [{"unknown"#,
..BLANK
},
Case {
label: "qwen: truncated tool call after the marker",
make: qwen,
feed: Feed::Chunks(&["<tool_call>\n{\"a\": 1"]),
flush: "<tool_call>\n{\"a\": 1",
..BLANK
},
Case {
label: "cohere: START_ACTION seen, END_ACTION never arrived",
make: cohere,
feed: Feed::Chunks(&[
r#"<|START_ACTION|>{"tool_name": "search", "parameters": {"query": "x"#,
]),
flush: r#"{"tool_name": "search", "parameters": {"query": "x"#,
..BLANK
},
Case {
label: "json: adjacent non-tool value then a declared call",
make: json,
feed: Feed::Chunks(&[
r#"{"note": "checking"} {"name": "get_weather", "arguments": {"city": "Tokyo"}}"#,
]),
text: r#"{"note": "checking"} "#,
call: Some("get_weather"),
args_contain: Some(r#"{"city":"Tokyo"}"#),
..BLANK
},
Case {
label: "json: adjacent undeclared call then a declared call",
make: json,
feed: Feed::Chunks(&[concat!(
r#"{"name": "bogus_tool", "arguments": {}}"#,
r#"{"name": "get_weather", "arguments": {"city": "Paris"}}"#
)]),
text: r#"{"name": "bogus_tool", "arguments": {}}"#,
call: Some("get_weather"),
args_contain: Some(r#"{"city":"Paris"}"#),
..BLANK
},
];
for case in &cases {
let mut parser = (case.make)();
let (text, calls) = stream(parser.as_mut(), &case.feed).await;
assert_eq!(text, case.text, "[{}] streamed content", case.label);
assert_eq!(announced(&calls), case.call, "[{}] tool call", case.label);
if let Some(fragment) = case.args_contain {
let args = streamed_args(&calls);
assert!(
args.contains(fragment),
"[{}] arguments must be streamed, got {args:?}",
case.label
);
}
assert_eq!(
parser.take_unstreamed_normal_text(),
case.flush,
"[{}] end-of-stream flush",
case.label
);
assert_eq!(
parser.take_unstreamed_normal_text(),
"",
"[{}] flush must drain: a second take returns nothing",
case.label
);
}
}
#[tokio::test]
async fn a_call_without_arguments_streams_an_empty_object() {
let cases: [(&str, MakeParser, &[&str]); 4] = [
("json", json, &[r#"{"name": "get_weather"}"#]),
("llama", llama, &[r#"{"name": "get_weather"}"#]),
(
"mistral",
mistral,
&[r#"[TOOL_CALLS] [{"name": "get_weather"}]"#],
),
(
"qwen",
qwen,
&["<tool_call>\n{\"name\": \"get_weather\"}\n</tool_call>"],
),
];
for (label, make, whole) in cases {
for feed in [Feed::Chunks(whole), Feed::Realistic(whole[0])] {
let mut parser = make();
let (text, calls) = stream(parser.as_mut(), &feed).await;
assert_eq!(text, "", "[{label}] streamed content");
assert_eq!(
announced(&calls),
Some("get_weather"),
"[{label}] tool call"
);
assert_eq!(streamed_args(&calls), "{}", "[{label}] arguments");
assert_eq!(parser.take_unstreamed_normal_text(), "", "[{label}] flush");
}
}
}
#[tokio::test]
async fn partial_declared_name_keeps_buffering() {
let mut parser = LlamaParser::new();
let tools = create_test_tools();
let result = parser
.parse_incremental(r#"{"name": "get_we"#, &tools)
.await
.unwrap();
assert_eq!(result.normal_text, "");
assert!(result.calls.is_empty());
let result = parser
.parse_incremental(r#"ather", "parameters": {"city": "Paris"}}"#, &tools) .await
.unwrap();
assert_eq!(
announced(&result.calls),
Some("get_weather"),
"declared tool call must be announced once the name completes"
);
}
#[tokio::test]
async fn reset_clears_the_streaming_buffers() {
let tools = create_test_tools();
let mut llama = LlamaParser::new();
llama
.parse_incremental(r#"{"name": "get_wea"#, &tools)
.await
.unwrap();
llama.reset();
assert_eq!(
llama.take_unstreamed_normal_text(),
"",
"reset must clear the streaming buffer"
);
let held = r#"<tool_call>
{"name": "bogus"</tool"#;
let mut qwen = QwenParser::new();
qwen.parse_incremental(held, &tools).await.unwrap();
let mut drained = QwenParser::new();
drained.parse_incremental(held, &tools).await.unwrap();
assert_eq!(
drained.take_unstreamed_normal_text(),
"</tool",
"precondition: normal_text_buffer is holding the partial end token"
);
qwen.reset();
assert_eq!(
qwen.take_unstreamed_normal_text(),
"",
"reset must clear normal_text_buffer, not just the main buffer"
);
}