- Add ToolParsingHint enum (Detected/Active/Complete) for UI feedback - New UiWriter methods: print_tool_streaming_hint(), print_tool_streaming_active() - Refactor ConsoleUiWriter state to use atomics in ParsingHintState - Add tool_call_streaming field to CompletionChunk for provider hints - Anthropic provider sends streaming hints when tool name detected - New streaming helpers: make_tool_streaming_hint(), make_tool_streaming_active() Parser improvements: - Add is_json_invalidated() to detect false positive tool patterns - Fix tool result poisoning when file contents contain partial JSON - Unescaped newlines in strings or prose after JSON invalidates detection User sees ' ● tool_name |' immediately when tool call starts streaming, with blinking indicator while args are received.
435 lines
16 KiB
Rust
435 lines
16 KiB
Rust
//! Parser Line-Boundary Detection Tests
|
|
//!
|
|
//! CHARACTERIZATION: These tests verify that tool call patterns are only detected
|
|
//! when they appear on their own line (at start of text or after a newline with
|
|
//! only whitespace before the pattern).
|
|
//!
|
|
//! What these tests protect:
|
|
//! - Tool call patterns in various contexts (code blocks, quotes, etc.) are IGNORED
|
|
//! - Tool calls on their own line are DETECTED
|
|
//! - Edge cases at line boundaries
|
|
//! - Unicode handling
|
|
//!
|
|
//! What these tests intentionally do NOT assert:
|
|
//! - Internal parser state
|
|
//! - Exact detection implementation
|
|
//!
|
|
//! Related commits:
|
|
//! - Original: 4c36cc0: fix: prevent parser poisoning from inline tool-call JSON patterns
|
|
//! - Updated: Line-boundary detection instead of sanitization
|
|
|
|
use g3_core::StreamingToolParser;
|
|
|
|
// =============================================================================
|
|
// Test: Code block contexts
|
|
// =============================================================================
|
|
|
|
mod code_block_contexts {
|
|
use super::*;
|
|
|
|
/// Test tool pattern in markdown inline code - should be IGNORED
|
|
#[test]
|
|
fn test_inline_code_backticks() {
|
|
let input = "Use `{\"tool\": \"shell\"}` to run commands";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Should be ignored since it's inline
|
|
assert!(result.is_none(), "Inline code should be ignored");
|
|
}
|
|
|
|
/// Test tool pattern after code fence (should be DETECTED - it's on its own line)
|
|
#[test]
|
|
fn test_after_code_fence_standalone() {
|
|
// Tool call on its own line after a code fence marker
|
|
let input = "```\n{\"tool\": \"shell\", \"args\": {}}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// The tool call is on its own line, should be detected
|
|
assert!(result.is_some(), "Standalone after fence should be detected");
|
|
assert_eq!(result.unwrap(), 4, "Should be at position 4 (after ```\\n)");
|
|
}
|
|
|
|
/// Test tool pattern in prose explanation - should be IGNORED
|
|
#[test]
|
|
fn test_prose_explanation() {
|
|
let input = "The format is {\"tool\": \"name\", \"args\": {...}} where name is the tool";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
assert!(result.is_none(), "Prose should be ignored");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test: Line boundary edge cases
|
|
// =============================================================================
|
|
|
|
mod line_boundary_cases {
|
|
use super::*;
|
|
|
|
/// Test empty lines don't affect detection
|
|
#[test]
|
|
fn test_empty_lines_before_tool_call() {
|
|
let input = "\n\n{\"tool\": \"shell\", \"args\": {}}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Tool call is on its own line (after empty lines), should be detected
|
|
assert!(result.is_some(), "Standalone after empty lines should be detected");
|
|
assert_eq!(result.unwrap(), 2, "Should be at position 2 (after two newlines)");
|
|
}
|
|
|
|
/// Test whitespace-only lines
|
|
#[test]
|
|
fn test_whitespace_only_lines() {
|
|
let input = " \n \n{\"tool\": \"shell\", \"args\": {}}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Tool call is on its own line, should be detected
|
|
assert!(result.is_some(), "Standalone after whitespace lines should be detected");
|
|
}
|
|
|
|
/// Test tool call with leading whitespace (indented)
|
|
#[test]
|
|
fn test_indented_tool_call() {
|
|
let input = " {\"tool\": \"shell\", \"args\": {}}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Indented but on its own line, should be detected
|
|
assert!(result.is_some(), "Indented standalone should be detected");
|
|
assert_eq!(result.unwrap(), 4, "Should be at position 4 (after 4 spaces)");
|
|
}
|
|
|
|
/// Test tool call with tabs
|
|
#[test]
|
|
fn test_tab_indented_tool_call() {
|
|
let input = "\t{\"tool\": \"shell\", \"args\": {}}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Tab-indented but on its own line, should be detected
|
|
assert!(result.is_some(), "Tab-indented standalone should be detected");
|
|
assert_eq!(result.unwrap(), 1, "Should be at position 1 (after tab)");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test: Special characters and Unicode
|
|
// =============================================================================
|
|
|
|
mod unicode_handling {
|
|
use super::*;
|
|
|
|
/// Test tool pattern after emoji - should be IGNORED (inline)
|
|
#[test]
|
|
fn test_after_emoji() {
|
|
let input = "🔧 {\"tool\": \"shell\"}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Emoji before means it's inline, should be ignored
|
|
assert!(result.is_none(), "After emoji should be ignored");
|
|
}
|
|
|
|
/// Test tool pattern after bullet point - should be IGNORED (inline)
|
|
#[test]
|
|
fn test_after_bullet() {
|
|
let input = "• {\"tool\": \"shell\"}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Bullet before means it's inline, should be ignored
|
|
assert!(result.is_none(), "After bullet should be ignored");
|
|
}
|
|
|
|
/// Test tool pattern after CJK text - should be IGNORED (inline)
|
|
#[test]
|
|
fn test_after_cjk() {
|
|
let input = "使用 {\"tool\": \"shell\"} 命令";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// CJK text before means it's inline, should be ignored
|
|
assert!(result.is_none(), "After CJK should be ignored");
|
|
}
|
|
|
|
/// Test tool pattern with Unicode in args on its own line - should be DETECTED
|
|
#[test]
|
|
fn test_unicode_in_args_standalone() {
|
|
let input = "{\"tool\": \"shell\", \"args\": {\"command\": \"echo 你好\"}}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Standalone, should be detected
|
|
assert!(result.is_some(), "Unicode in args standalone should be detected");
|
|
assert_eq!(result.unwrap(), 0, "Should be at position 0");
|
|
}
|
|
|
|
/// Test tool pattern with Unicode in args inline - should be IGNORED
|
|
#[test]
|
|
fn test_unicode_in_args_inline() {
|
|
let input = "Example: {\"tool\": \"shell\", \"args\": {\"command\": \"echo 你好\"}}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Inline, should be ignored
|
|
assert!(result.is_none(), "Unicode in args inline should be ignored");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test: Multiple patterns on same line
|
|
// =============================================================================
|
|
|
|
mod multiple_patterns {
|
|
use super::*;
|
|
|
|
/// Test three tool patterns on one line - all should be IGNORED
|
|
#[test]
|
|
fn test_three_patterns() {
|
|
let input = "Compare {\"tool\": \"a\"} vs {\"tool\": \"b\"} vs {\"tool\": \"c\"}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// All are inline, should be ignored
|
|
assert!(result.is_none(), "All three inline should be ignored");
|
|
}
|
|
|
|
/// Test mixed: one inline (ignored), one standalone (detected)
|
|
#[test]
|
|
fn test_mixed_standalone_and_inline() {
|
|
let input = "Text with {\"tool\": \"inline\"} here\n{\"tool\": \"standalone\", \"args\": {}}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Should find the standalone one, not the inline one
|
|
assert!(result.is_some(), "Should find standalone");
|
|
// The standalone one starts after the newline
|
|
let newline_pos = input.find('\n').unwrap();
|
|
assert_eq!(result.unwrap(), newline_pos + 1, "Should find standalone after newline");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test: Edge cases that should NOT be detected (not tool patterns)
|
|
// =============================================================================
|
|
|
|
mod no_detection_cases {
|
|
use super::*;
|
|
|
|
/// Test similar but not matching patterns
|
|
#[test]
|
|
fn test_similar_but_different() {
|
|
let inputs = [
|
|
"{\"tools\": \"value\"}", // "tools" not "tool"
|
|
"{\"Tool\": \"value\"}", // Capital T
|
|
"{\"TOOL\": \"value\"}", // All caps
|
|
"{'tool': 'value'}", // Single quotes
|
|
];
|
|
|
|
for input in inputs {
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
assert!(result.is_none(), "'{}' should not be detected", input);
|
|
}
|
|
}
|
|
|
|
/// Test partial patterns
|
|
#[test]
|
|
fn test_partial_patterns() {
|
|
let inputs = [
|
|
"{\"tool", // No colon
|
|
"\"tool\":", // No opening brace
|
|
"tool", // Just the word
|
|
];
|
|
|
|
for input in inputs {
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
assert!(result.is_none(), "'{}' should not be detected", input);
|
|
}
|
|
}
|
|
|
|
/// Test JSON that happens to have "tool" as a value
|
|
#[test]
|
|
fn test_tool_as_value() {
|
|
let input = "{\"name\": \"tool\"}";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
assert!(result.is_none(), "'tool' as value should not trigger detection");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test: Real-world scenarios from the bug report
|
|
// =============================================================================
|
|
|
|
mod real_world_scenarios {
|
|
use super::*;
|
|
|
|
/// Test documentation example that caused the original bug
|
|
#[test]
|
|
fn test_documentation_example() {
|
|
let input = r#"To call a tool, use this format: {"tool": "name", "args": {...}}
|
|
|
|
For example:
|
|
{"tool": "shell", "args": {"command": "ls"}}
|
|
|
|
This will execute the command."#;
|
|
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Should find the standalone example, not the inline one
|
|
assert!(result.is_some(), "Should find standalone example");
|
|
|
|
// The standalone example is on line 4 (0-indexed line 3)
|
|
// "To call a tool...\n\nFor example:\n" = 64 + 1 + 13 + 1 = 79 chars before it
|
|
// Actually let's just verify it's NOT at position 33 (the inline one)
|
|
assert!(result.unwrap() > 50, "Should skip inline and find standalone");
|
|
}
|
|
|
|
/// Test code example in prose - should be IGNORED
|
|
#[test]
|
|
fn test_code_in_prose() {
|
|
let input = "The agent responds with {\"tool\": \"read_file\"} when it needs to read files.";
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
assert!(result.is_none(), "Code in prose should be ignored");
|
|
}
|
|
|
|
/// Test the exact scenario from the bug: LLM explaining tool format
|
|
#[test]
|
|
fn test_llm_explanation_scenario() {
|
|
let input = r#"I'll use the shell tool. The format is {"tool": "shell", "args": {...}}.
|
|
|
|
{"tool": "shell", "args": {"command": "ls -la"}}"#;
|
|
|
|
let result = StreamingToolParser::find_first_tool_call_start(input);
|
|
|
|
// Should find the actual tool call, not the explanation
|
|
assert!(result.is_some(), "Should find actual tool call");
|
|
|
|
// The actual tool call is on the last line, after two newlines
|
|
let last_newline = input.rfind('\n').unwrap();
|
|
assert_eq!(result.unwrap(), last_newline + 1, "Should find tool call on last line");
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test: is_on_own_line helper function
|
|
// =============================================================================
|
|
|
|
mod is_on_own_line_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_position_zero() {
|
|
assert!(StreamingToolParser::is_on_own_line("anything", 0));
|
|
}
|
|
|
|
#[test]
|
|
fn test_after_newline_no_whitespace() {
|
|
let text = "line1\nline2";
|
|
assert!(StreamingToolParser::is_on_own_line(text, 6)); // position of 'l' in line2
|
|
}
|
|
|
|
#[test]
|
|
fn test_after_newline_with_whitespace() {
|
|
let text = "line1\n indented";
|
|
assert!(StreamingToolParser::is_on_own_line(text, 8)); // position of 'i' in indented
|
|
}
|
|
|
|
#[test]
|
|
fn test_middle_of_line() {
|
|
let text = "some text here";
|
|
assert!(!StreamingToolParser::is_on_own_line(text, 5)); // position of 't' in text
|
|
}
|
|
|
|
#[test]
|
|
fn test_after_non_whitespace() {
|
|
let text = "prefix{";
|
|
assert!(!StreamingToolParser::is_on_own_line(text, 6)); // position of '{'
|
|
}
|
|
}
|
|
|
|
// =============================================================================
|
|
// Test: End-to-end streaming repro of the parser poisoning bug
|
|
// =============================================================================
|
|
|
|
mod streaming_repro {
|
|
use super::*;
|
|
use g3_providers::CompletionChunk;
|
|
|
|
fn chunk(content: &str, finished: bool) -> CompletionChunk {
|
|
CompletionChunk {
|
|
content: content.to_string(),
|
|
finished,
|
|
tool_calls: None,
|
|
usage: None,
|
|
stop_reason: None,
|
|
tool_call_streaming: None,
|
|
}
|
|
}
|
|
|
|
/// EXACT REPRO: LLM explains tool format inline, then emits real tool call.
|
|
/// Before the fix, the parser would detect the inline pattern and try to
|
|
/// parse it as a tool call, causing premature return of control.
|
|
#[test]
|
|
fn test_inline_explanation_does_not_trigger_tool_detection() {
|
|
let mut parser = StreamingToolParser::new();
|
|
|
|
// Simulate streaming chunks as the LLM explains tool format
|
|
let tools = parser.process_chunk(&chunk(
|
|
"I'll help you with that. The tool call format is ",
|
|
false,
|
|
));
|
|
assert!(tools.is_empty(), "No tool call yet");
|
|
|
|
// THIS IS THE BUG: inline JSON pattern in explanation
|
|
let tools = parser.process_chunk(&chunk(
|
|
r#"{"tool": "shell", "args": {...}}"#,
|
|
false,
|
|
));
|
|
// Before fix: this would incorrectly detect a tool call
|
|
// After fix: this should be ignored (it's inline, not on its own line)
|
|
assert!(tools.is_empty(), "Inline pattern should NOT trigger tool detection");
|
|
|
|
// More explanation
|
|
let tools = parser.process_chunk(&chunk(
|
|
" where you specify the command.\n\n",
|
|
false,
|
|
));
|
|
assert!(tools.is_empty(), "Still no tool call");
|
|
|
|
// NOW the real tool call on its own line
|
|
let tools = parser.process_chunk(&chunk(
|
|
r#"{"tool": "shell", "args": {"command": "ls -la"}}"#,
|
|
true,
|
|
));
|
|
|
|
// Should detect exactly ONE tool call - the real one
|
|
assert_eq!(tools.len(), 1, "Should detect exactly one tool call");
|
|
assert_eq!(tools[0].tool, "shell");
|
|
assert_eq!(tools[0].args["command"], "ls -la");
|
|
}
|
|
|
|
/// Test that multiple inline patterns in a single chunk are all ignored
|
|
#[test]
|
|
fn test_multiple_inline_patterns_in_chunk_ignored() {
|
|
let mut parser = StreamingToolParser::new();
|
|
|
|
let tools = parser.process_chunk(&chunk(
|
|
r#"Compare {"tool": "a"} with {"tool": "b"} and {"tool": "c"}"#,
|
|
true,
|
|
));
|
|
|
|
assert!(tools.is_empty(), "All inline patterns should be ignored");
|
|
}
|
|
|
|
/// Test streaming where tool call arrives across multiple chunks
|
|
#[test]
|
|
fn test_tool_call_split_across_chunks() {
|
|
let mut parser = StreamingToolParser::new();
|
|
|
|
// First chunk: prose then start of tool call on new line
|
|
let tools = parser.process_chunk(&chunk("Here's the command:\n{\"tool\": ", false));
|
|
assert!(tools.is_empty(), "Incomplete tool call");
|
|
|
|
// Second chunk: rest of tool call
|
|
let tools = parser.process_chunk(&chunk(
|
|
r#""shell", "args": {"command": "pwd"}}"#,
|
|
true,
|
|
));
|
|
|
|
assert_eq!(tools.len(), 1, "Should detect the complete tool call");
|
|
assert_eq!(tools[0].tool, "shell");
|
|
}
|
|
}
|