717 lines
20 KiB
Rust
717 lines
20 KiB
Rust
//! JSON Parser Integration Tests
|
|
//!
|
|
//! Tests for the JSON parser which handles OpenAI, Claude, and generic JSON formats
|
|
|
|
use serde_json::json;
|
|
use smg::tool_parser::{JsonParser, ToolParser};
|
|
|
|
use crate::common::{create_test_tools, streaming_helpers::*};
|
|
|
|
#[tokio::test]
|
|
async fn test_simple_json_tool_call() {
|
|
let parser = JsonParser::new();
|
|
let input = r#"{"name": "get_weather", "arguments": {"location": "San Francisco"}}"#;
|
|
|
|
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert_eq!(normal_text, "");
|
|
assert_eq!(tools[0].function.name, "get_weather");
|
|
|
|
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
|
|
assert_eq!(args["location"], "San Francisco");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_array_of_tools() {
|
|
let parser = JsonParser::new();
|
|
let input = r#"Hello, here are the results: [
|
|
{"name": "get_weather", "arguments": {"location": "SF"}},
|
|
{"name": "search", "arguments": {"query": "news"}}
|
|
]"#;
|
|
|
|
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 2);
|
|
assert_eq!(normal_text, "Hello, here are the results: ");
|
|
assert_eq!(tools[0].function.name, "get_weather");
|
|
assert_eq!(tools[1].function.name, "search");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_with_parameters_key() {
|
|
let parser = JsonParser::new();
|
|
let input = r#"{"name": "calculate", "parameters": {"x": 10, "y": 20}}"#;
|
|
|
|
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert_eq!(normal_text, "");
|
|
assert_eq!(tools[0].function.name, "calculate");
|
|
|
|
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
|
|
assert_eq!(args["x"], 10);
|
|
assert_eq!(args["y"], 20);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_extraction_from_text() {
|
|
let parser = JsonParser::new();
|
|
let input = r#"I'll help you with that. {"name": "search", "arguments": {"query": "rust"}} Let me search for that."#;
|
|
|
|
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert_eq!(
|
|
normal_text,
|
|
"I'll help you with that. Let me search for that."
|
|
);
|
|
assert_eq!(tools[0].function.name, "search");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_with_nested_objects() {
|
|
let parser = JsonParser::new();
|
|
let input = r#"{
|
|
"name": "update_config",
|
|
"arguments": {
|
|
"settings": {
|
|
"theme": "dark",
|
|
"language": "en",
|
|
"notifications": {
|
|
"email": true,
|
|
"push": false
|
|
}
|
|
}
|
|
}
|
|
}"#;
|
|
|
|
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert_eq!(normal_text, "");
|
|
assert_eq!(tools[0].function.name, "update_config");
|
|
|
|
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
|
|
assert_eq!(args["settings"]["theme"], "dark");
|
|
assert_eq!(args["settings"]["notifications"]["email"], true);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_with_special_characters() {
|
|
let parser = JsonParser::new();
|
|
let input = r#"{"name": "echo", "arguments": {"text": "Line 1\nLine 2\tTabbed", "path": "C:\\Users\\test"}}"#;
|
|
|
|
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert_eq!(normal_text, "");
|
|
|
|
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
|
|
assert_eq!(args["text"], "Line 1\nLine 2\tTabbed");
|
|
assert_eq!(args["path"], "C:\\Users\\test");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_with_unicode() {
|
|
let parser = JsonParser::new();
|
|
let input = r#"{"name": "translate", "arguments": {"text": "Hello 世界 🌍", "emoji": "😊"}}"#;
|
|
|
|
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert_eq!(normal_text, "");
|
|
|
|
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
|
|
assert_eq!(args["text"], "Hello 世界 🌍");
|
|
assert_eq!(args["emoji"], "😊");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_empty_arguments() {
|
|
let parser = JsonParser::new();
|
|
let input = r#"{"name": "ping", "arguments": {}}"#;
|
|
|
|
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert_eq!(normal_text, "");
|
|
assert_eq!(tools[0].function.name, "ping");
|
|
|
|
let args: serde_json::Value = serde_json::from_str(&tools[0].function.arguments).unwrap();
|
|
assert_eq!(args, json!({}));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_invalid_format() {
|
|
let parser = JsonParser::new();
|
|
|
|
// Missing closing brace
|
|
let input = r#"{"name": "test", "arguments": {"key": "value""#;
|
|
let (normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 0);
|
|
assert_eq!(
|
|
normal_text,
|
|
"{\"name\": \"test\", \"arguments\": {\"key\": \"value\""
|
|
);
|
|
|
|
// Not JSON at all
|
|
let input = "This is just plain text";
|
|
let (_normal_text, tools) = parser.parse_complete(input).await.unwrap();
|
|
assert_eq!(tools.len(), 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_format_detection() {
|
|
let parser = JsonParser::new();
|
|
|
|
assert!(parser.has_tool_markers(r#"{"name": "test", "arguments": {}}"#));
|
|
assert!(parser.has_tool_markers(r#"[{"name": "test"}]"#));
|
|
assert!(!parser.has_tool_markers("plain text"));
|
|
}
|
|
|
|
// Streaming tests for JSON array format
|
|
#[tokio::test]
|
|
async fn test_json_array_streaming_required_mode() {
|
|
use smg::protocols::common::Tool;
|
|
|
|
// Test that simulates the exact streaming pattern from required mode
|
|
let mut parser = JsonParser::new();
|
|
|
|
// Define test tools
|
|
let tools = vec![Tool {
|
|
tool_type: "function".to_string(),
|
|
function: smg::protocols::common::Function {
|
|
name: "get_weather".to_string(),
|
|
description: Some("Get weather".to_string()),
|
|
parameters: serde_json::json!({}),
|
|
strict: None,
|
|
},
|
|
}];
|
|
|
|
// Simulate the EXACT chunks from the debug log
|
|
let chunks = vec![
|
|
"[{",
|
|
" \"",
|
|
"name",
|
|
"\":",
|
|
" \"",
|
|
"get",
|
|
"_weather",
|
|
"\",",
|
|
" \"",
|
|
"parameters",
|
|
"\":",
|
|
" {",
|
|
" \"",
|
|
"city",
|
|
"\":",
|
|
" \"",
|
|
"Paris",
|
|
"\"",
|
|
" }",
|
|
" }]",
|
|
];
|
|
|
|
let mut all_results = Vec::new();
|
|
let mut all_normal_text = String::new();
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
|
|
all_results.extend(result.calls);
|
|
all_normal_text.push_str(&result.normal_text);
|
|
}
|
|
|
|
// We should have gotten tool call chunks
|
|
assert!(
|
|
!all_results.is_empty(),
|
|
"Should have emitted tool call chunks"
|
|
);
|
|
|
|
// Should not have emitted any normal text (including the closing ])
|
|
assert_eq!(
|
|
all_normal_text, "",
|
|
"Should not emit normal text for JSON array format"
|
|
);
|
|
|
|
// Check that we got the function name
|
|
let has_name = all_results
|
|
.iter()
|
|
.any(|item| item.name.as_ref().is_some_and(|n| n == "get_weather"));
|
|
assert!(has_name, "Should have emitted function name");
|
|
|
|
// Check that we got the parameters
|
|
let has_params = all_results.iter().any(|item| !item.parameters.is_empty());
|
|
assert!(has_params, "Should have emitted parameters");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_array_multiple_tools_streaming() {
|
|
use smg::protocols::common::Tool;
|
|
|
|
// Test with multiple tools in array
|
|
let mut parser = JsonParser::new();
|
|
|
|
let tools = vec![
|
|
Tool {
|
|
tool_type: "function".to_string(),
|
|
function: smg::protocols::common::Function {
|
|
name: "get_weather".to_string(),
|
|
description: Some("Get weather".to_string()),
|
|
parameters: serde_json::json!({}),
|
|
strict: None,
|
|
},
|
|
},
|
|
Tool {
|
|
tool_type: "function".to_string(),
|
|
function: smg::protocols::common::Function {
|
|
name: "get_news".to_string(),
|
|
description: Some("Get news".to_string()),
|
|
parameters: serde_json::json!({}),
|
|
strict: None,
|
|
},
|
|
},
|
|
];
|
|
|
|
// Split into smaller, more realistic chunks
|
|
let chunks = vec![
|
|
"[{",
|
|
"\"name\":",
|
|
"\"get_weather\"",
|
|
",\"parameters\":",
|
|
"{\"city\":",
|
|
"\"SF\"}",
|
|
"}",
|
|
",",
|
|
"{\"name\":",
|
|
"\"get_news\"",
|
|
",\"parameters\":",
|
|
"{\"topic\":",
|
|
"\"tech\"}",
|
|
"}]",
|
|
];
|
|
|
|
let mut all_results = Vec::new();
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
|
|
all_results.extend(result.calls);
|
|
}
|
|
|
|
// Should have gotten tool calls for both functions
|
|
let has_weather = all_results
|
|
.iter()
|
|
.any(|item| item.name.as_ref().is_some_and(|n| n == "get_weather"));
|
|
let has_news = all_results
|
|
.iter()
|
|
.any(|item| item.name.as_ref().is_some_and(|n| n == "get_news"));
|
|
|
|
assert!(has_weather, "Should have get_weather tool call");
|
|
assert!(has_news, "Should have get_news tool call");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_array_closing_bracket_separate_chunk() {
|
|
use smg::protocols::common::Tool;
|
|
|
|
// Test case where the closing ] comes as a separate chunk
|
|
let mut parser = JsonParser::new();
|
|
|
|
let tools = vec![Tool {
|
|
tool_type: "function".to_string(),
|
|
function: smg::protocols::common::Function {
|
|
name: "get_weather".to_string(),
|
|
description: Some("Get weather".to_string()),
|
|
parameters: json!({}),
|
|
strict: None,
|
|
},
|
|
}];
|
|
|
|
// Closing ] as separate chunk, followed by normal text
|
|
let chunks = vec![
|
|
"[{",
|
|
"\"",
|
|
"name",
|
|
"\":",
|
|
"\"",
|
|
"get",
|
|
"_weather",
|
|
"\",",
|
|
"\"",
|
|
"parameters",
|
|
"\":",
|
|
"{",
|
|
"\"",
|
|
"city",
|
|
"\":",
|
|
"\"",
|
|
"Paris",
|
|
"\"",
|
|
"}",
|
|
"}",
|
|
"]",
|
|
" Here's",
|
|
" the",
|
|
" weather",
|
|
" info",
|
|
];
|
|
|
|
let mut all_normal_text = String::new();
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
|
|
all_normal_text.push_str(&result.normal_text);
|
|
}
|
|
|
|
// Should emit only the third chunk as normal text, NOT the ]
|
|
assert_eq!(
|
|
all_normal_text, " Here's the weather info",
|
|
"Should emit only normal text without ], got: '{}'",
|
|
all_normal_text
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_single_object_with_trailing_text() {
|
|
use smg::protocols::common::Tool;
|
|
|
|
// Test single object format (no array) with trailing text
|
|
let mut parser = JsonParser::new();
|
|
|
|
let tools = vec![Tool {
|
|
tool_type: "function".to_string(),
|
|
function: smg::protocols::common::Function {
|
|
name: "get_weather".to_string(),
|
|
description: Some("Get weather".to_string()),
|
|
parameters: serde_json::json!({}),
|
|
strict: None,
|
|
},
|
|
}];
|
|
|
|
let chunks = vec![
|
|
"{",
|
|
"\"",
|
|
"name",
|
|
"\":",
|
|
"\"",
|
|
"get_weather",
|
|
"\",",
|
|
"\"",
|
|
"parameters",
|
|
"\":",
|
|
"{",
|
|
"\"city",
|
|
"\":",
|
|
"\"Paris",
|
|
"\"}",
|
|
"}",
|
|
" Here's",
|
|
" the",
|
|
" weather",
|
|
];
|
|
|
|
let mut all_normal_text = String::new();
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
|
|
all_normal_text.push_str(&result.normal_text);
|
|
}
|
|
|
|
// Should emit the trailing text as normal_text (no ] to strip for single object)
|
|
assert_eq!(
|
|
all_normal_text, " Here's the weather",
|
|
"Should emit normal text for single object format, got: '{}'",
|
|
all_normal_text
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_single_object_with_bracket_in_text() {
|
|
use smg::protocols::common::Tool;
|
|
|
|
// Test that ] in normal text is NOT stripped for single object format
|
|
let mut parser = JsonParser::new();
|
|
|
|
let tools = vec![Tool {
|
|
tool_type: "function".to_string(),
|
|
function: smg::protocols::common::Function {
|
|
name: "get_weather".to_string(),
|
|
description: Some("Get weather".to_string()),
|
|
parameters: serde_json::json!({}),
|
|
strict: None,
|
|
},
|
|
}];
|
|
|
|
let chunks = vec![
|
|
"{",
|
|
"\"name",
|
|
"\":",
|
|
"\"get_weather",
|
|
"\",",
|
|
"\"parameters",
|
|
"\":",
|
|
"{",
|
|
"\"city",
|
|
"\":",
|
|
"\"Paris",
|
|
"\"}",
|
|
"}",
|
|
"]",
|
|
" Here's",
|
|
" the",
|
|
" weather",
|
|
];
|
|
|
|
let mut all_normal_text = String::new();
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
|
|
all_normal_text.push_str(&result.normal_text);
|
|
}
|
|
|
|
// For single object format, ] should NOT be stripped (it's part of normal text)
|
|
assert_eq!(
|
|
all_normal_text, "] Here's the weather",
|
|
"Should preserve ] in normal text for single object format, got: '{}'",
|
|
all_normal_text
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_array_bracket_in_text_after_tools() {
|
|
use smg::protocols::common::Tool;
|
|
|
|
// Test that ] in normal text AFTER array tools is preserved
|
|
let mut parser = JsonParser::new();
|
|
|
|
let tools = vec![Tool {
|
|
tool_type: "function".to_string(),
|
|
function: smg::protocols::common::Function {
|
|
name: "get_weather".to_string(),
|
|
description: Some("Get weather".to_string()),
|
|
parameters: serde_json::json!({}),
|
|
strict: None,
|
|
},
|
|
}];
|
|
|
|
let chunks = vec![
|
|
"[",
|
|
"{",
|
|
"\"name",
|
|
"\":",
|
|
"\"get_weather",
|
|
"\",",
|
|
"\"parameters",
|
|
"\":",
|
|
"{",
|
|
"\"city",
|
|
"\":",
|
|
"\"Paris",
|
|
"\"}",
|
|
"}",
|
|
"]",
|
|
" Array",
|
|
" notation:",
|
|
" arr",
|
|
"[",
|
|
"0",
|
|
"]",
|
|
];
|
|
|
|
let mut all_normal_text = String::new();
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
|
|
all_normal_text.push_str(&result.normal_text);
|
|
}
|
|
|
|
// Should preserve ] in normal text after array tools complete
|
|
assert_eq!(
|
|
all_normal_text, " Array notation: arr[0]",
|
|
"Should preserve ] in normal text after array tools, got: '{}'",
|
|
all_normal_text
|
|
);
|
|
}
|
|
// =============================================================================
|
|
// REALISTIC STREAMING TESTS
|
|
// =============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_json_bug_incomplete_tool_name_string() {
|
|
let tools = create_test_tools();
|
|
let mut parser = JsonParser::new();
|
|
|
|
// This exact sequence triggered the bug:
|
|
// Parser receives {"name": " and must NOT parse it as empty name
|
|
let chunks = vec![
|
|
r#"{"#,
|
|
r#"""#,
|
|
r#"name"#,
|
|
r#"""#,
|
|
r#":"#,
|
|
r#" "#,
|
|
r#"""#, // ← Critical moment: parser has {"name": "
|
|
// At this point, partial_json should NOT allow incomplete strings
|
|
// when current_tool_name_sent=false
|
|
r#"search"#, // Use valid tool name from create_test_tools()
|
|
r#"""#,
|
|
r#", "#,
|
|
r#"""#,
|
|
r#"arguments"#,
|
|
r#"""#,
|
|
r#": {"#,
|
|
r#"""#,
|
|
r#"query"#,
|
|
r#"""#,
|
|
r#": "#,
|
|
r#"""#,
|
|
r#"rust programming"#,
|
|
r#"""#,
|
|
r#"}}"#,
|
|
];
|
|
|
|
let mut got_tool_name = false;
|
|
let mut saw_empty_name = false;
|
|
|
|
for chunk in chunks.iter() {
|
|
let result = parser.parse_incremental(chunk, &tools).await.unwrap();
|
|
|
|
for call in result.calls {
|
|
if let Some(name) = &call.name {
|
|
if name.is_empty() {
|
|
saw_empty_name = true;
|
|
}
|
|
if name == "search" {
|
|
got_tool_name = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(
|
|
!saw_empty_name,
|
|
"Parser should NEVER return empty tool name"
|
|
);
|
|
assert!(got_tool_name, "Should have parsed tool name correctly");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_realistic_chunks_simple_tool() {
|
|
let tools = create_test_tools();
|
|
let mut parser = JsonParser::new();
|
|
|
|
let input = r#"{"name": "get_weather", "arguments": {"city": "Paris"}}"#;
|
|
let chunks = create_realistic_chunks(input);
|
|
|
|
assert!(chunks.len() > 10, "Should have many small chunks");
|
|
|
|
let mut got_tool_name = false;
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
|
|
for call in result.calls {
|
|
if let Some(name) = call.name {
|
|
assert_eq!(name, "get_weather");
|
|
got_tool_name = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(got_tool_name, "Should have parsed tool name");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_strategic_chunks_with_quotes() {
|
|
let tools = create_test_tools();
|
|
let mut parser = JsonParser::new();
|
|
|
|
let input = r#"{"name": "search", "arguments": {"query": "rust programming"}}"#;
|
|
let chunks = create_strategic_chunks(input);
|
|
|
|
// Strategic chunks break after quotes and colons
|
|
assert!(chunks.iter().any(|c| c.ends_with('"')));
|
|
|
|
let mut got_tool_name = false;
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
|
|
for call in result.calls {
|
|
if call.name.is_some() {
|
|
got_tool_name = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(got_tool_name, "Should have parsed tool name");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_incremental_arguments_streaming() {
|
|
let tools = create_test_tools();
|
|
let mut parser = JsonParser::new();
|
|
|
|
let input = r#"{"name": "search", "arguments": {"query": "test", "limit": 10}}"#;
|
|
let chunks = create_realistic_chunks(input);
|
|
|
|
let mut tool_name_sent = false;
|
|
let mut got_arguments = false;
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
|
|
for call in result.calls {
|
|
if call.name.is_some() {
|
|
tool_name_sent = true;
|
|
}
|
|
if tool_name_sent && !call.parameters.is_empty() {
|
|
got_arguments = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(tool_name_sent, "Should have sent tool name");
|
|
assert!(got_arguments, "Should have sent arguments");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_very_long_url_in_arguments() {
|
|
let tools = create_test_tools();
|
|
let mut parser = JsonParser::new();
|
|
|
|
// Simulate long URL arriving in many chunks
|
|
let long_url = "https://example.com/very/long/path/".to_string() + &"segment/".repeat(50);
|
|
let input = format!(
|
|
r#"{{"name": "search", "arguments": {{"query": "{}"}}}}"#,
|
|
long_url
|
|
);
|
|
let chunks = create_realistic_chunks(&input);
|
|
|
|
assert!(chunks.len() > 100, "Long URL should create many chunks");
|
|
|
|
let mut got_tool_name = false;
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
|
|
for call in result.calls {
|
|
if call.name.is_some() {
|
|
got_tool_name = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(got_tool_name, "Should have parsed tool name");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_json_unicode() {
|
|
let tools = create_test_tools();
|
|
let mut parser = JsonParser::new();
|
|
|
|
let input = r#"{"name": "search", "arguments": {"query": "Hello 世界 🌍"}}"#;
|
|
let chunks = create_realistic_chunks(input);
|
|
|
|
let mut got_tool_name = false;
|
|
|
|
for chunk in chunks {
|
|
let result = parser.parse_incremental(&chunk, &tools).await.unwrap();
|
|
for call in result.calls {
|
|
if call.name.is_some() {
|
|
got_tool_name = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
assert!(got_tool_name, "Should have parsed with unicode");
|
|
}
|