diff --git a/crates/g3-core/tests/streaming_parser_test.rs b/crates/g3-core/tests/streaming_parser_test.rs new file mode 100644 index 0000000..2d33777 --- /dev/null +++ b/crates/g3-core/tests/streaming_parser_test.rs @@ -0,0 +1,545 @@ +//! Comprehensive tests for StreamingToolParser +//! +//! Tests cover: +//! - Multiple tool calls in one response +//! - Tool call followed by text +//! - Incomplete tool calls at various truncation points +//! - Parser reset behavior +//! - Buffer management + +use g3_core::StreamingToolParser; +use g3_providers::CompletionChunk; + +// Helper to create a chunk +fn chunk(content: &str, finished: bool) -> CompletionChunk { + CompletionChunk { + content: content.to_string(), + finished, + tool_calls: None, + usage: None, + } +} + +// ============================================================================= +// Test: Multiple tool calls in one response +// ============================================================================= + +#[test] +fn test_multiple_tool_calls_in_single_chunk() { + let mut parser = StreamingToolParser::new(); + + // Two complete tool calls in one chunk + let content = r#"Let me do two things: +{"tool": "read_file", "args": {"file_path": "a.txt"}} +Now the second: +{"tool": "shell", "args": {"command": "ls"}}"#; + + let tools = parser.process_chunk(&chunk(content, false)); + + // Should detect at least one tool call + // Note: Current implementation may only return the first one found + assert!(!tools.is_empty(), "Should detect at least one tool call"); +} + +#[test] +fn test_multiple_tool_calls_across_chunks() { + let mut parser = StreamingToolParser::new(); + + // First tool call + let tools1 = parser.process_chunk(&chunk( + r#"{"tool": "read_file", "args": {"file_path": "a.txt"}}"#, + false + )); + assert_eq!(tools1.len(), 1, "First tool call should be detected"); + assert_eq!(tools1[0].tool, "read_file"); + + // Reset parser (simulating what happens after tool execution) + parser.reset(); + + // Second tool call + let tools2 = parser.process_chunk(&chunk( + r#"{"tool": "shell", "args": {"command": "ls"}}"#, + false + )); + assert_eq!(tools2.len(), 1, "Second tool call should be detected"); + assert_eq!(tools2[0].tool, "shell"); +} + +#[test] +fn test_first_complete_second_incomplete() { + let mut parser = StreamingToolParser::new(); + + // First complete, second incomplete + let content = r#"{"tool": "read_file", "args": {"file_path": "a.txt"}} +{"tool": "shell", "args": {"command": "ls"#; + + let tools = parser.process_chunk(&chunk(content, false)); + + // Should detect the first complete tool call + // The incomplete one should be detected by has_incomplete_tool_call + assert!(parser.has_incomplete_tool_call(), "Should detect incomplete tool call"); +} + +// ============================================================================= +// Test: Tool call followed by text +// ============================================================================= + +#[test] +fn test_tool_call_with_trailing_text() { + let mut parser = StreamingToolParser::new(); + + let content = r#"{"tool": "read_file", "args": {"file_path": "test.txt"}} + +Here is the content of the file..."#; + + let tools = parser.process_chunk(&chunk(content, false)); + + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].tool, "read_file"); + + // The trailing text should be in the buffer + let text = parser.get_text_content(); + assert!(text.contains("Here is the content"), "Trailing text should be preserved"); +} + +#[test] +fn test_text_before_tool_call() { + let mut parser = StreamingToolParser::new(); + + let content = r#"Let me read that file for you. + +{"tool": "read_file", "args": {"file_path": "test.txt"}}"#; + + let tools = parser.process_chunk(&chunk(content, false)); + + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].tool, "read_file"); + + // The leading text should be in the buffer + let text = parser.get_text_content(); + assert!(text.contains("Let me read"), "Leading text should be preserved"); +} + +#[test] +fn test_text_before_and_after_tool_call() { + let mut parser = StreamingToolParser::new(); + + let content = r#"I'll check the file. + +{"tool": "read_file", "args": {"file_path": "test.txt"}} + +Done checking."#; + + let tools = parser.process_chunk(&chunk(content, false)); + + assert_eq!(tools.len(), 1); + + let text = parser.get_text_content(); + assert!(text.contains("I'll check"), "Leading text should be preserved"); + assert!(text.contains("Done checking"), "Trailing text should be preserved"); +} + +// ============================================================================= +// Test: Incomplete tool calls at various truncation points +// ============================================================================= + +#[test] +fn test_incomplete_after_tool_key() { + let mut parser = StreamingToolParser::new(); + parser.process_chunk(&chunk(r#"{"tool":"#, false)); + assert!(parser.has_incomplete_tool_call()); +} + +#[test] +fn test_incomplete_after_tool_name() { + let mut parser = StreamingToolParser::new(); + parser.process_chunk(&chunk(r#"{"tool": "read_file""#, false)); + assert!(parser.has_incomplete_tool_call()); +} + +#[test] +fn test_incomplete_after_args_key() { + let mut parser = StreamingToolParser::new(); + parser.process_chunk(&chunk(r#"{"tool": "read_file", "args":"#, false)); + assert!(parser.has_incomplete_tool_call()); +} + +#[test] +fn test_incomplete_mid_args_object() { + let mut parser = StreamingToolParser::new(); + parser.process_chunk(&chunk(r#"{"tool": "read_file", "args": {"file_path":"#, false)); + assert!(parser.has_incomplete_tool_call()); +} + +#[test] +fn test_incomplete_mid_string_value() { + let mut parser = StreamingToolParser::new(); + parser.process_chunk(&chunk(r#"{"tool": "shell", "args": {"command": "ls -la /very/long/path"#, false)); + assert!(parser.has_incomplete_tool_call()); +} + +#[test] +fn test_incomplete_missing_final_brace() { + let mut parser = StreamingToolParser::new(); + parser.process_chunk(&chunk(r#"{"tool": "read_file", "args": {"file_path": "test.txt"}"#, false)); + assert!(parser.has_incomplete_tool_call()); +} + +#[test] +fn test_complete_tool_call_not_incomplete() { + let mut parser = StreamingToolParser::new(); + parser.process_chunk(&chunk(r#"{"tool": "read_file", "args": {"file_path": "test.txt"}}"#, false)); + assert!(!parser.has_incomplete_tool_call(), "Complete tool call should not be marked incomplete"); +} + +// ============================================================================= +// Test: Parser reset behavior +// ============================================================================= + +#[test] +fn test_reset_clears_buffer() { + let mut parser = StreamingToolParser::new(); + + parser.process_chunk(&chunk("Some content here", false)); + assert!(!parser.get_text_content().is_empty()); + + parser.reset(); + + assert!(parser.get_text_content().is_empty(), "Buffer should be empty after reset"); +} + +#[test] +fn test_reset_clears_incomplete_state() { + let mut parser = StreamingToolParser::new(); + + // Create incomplete tool call + parser.process_chunk(&chunk(r#"{"tool": "read_file", "args": {"#, false)); + assert!(parser.has_incomplete_tool_call()); + + parser.reset(); + + assert!(!parser.has_incomplete_tool_call(), "Incomplete state should be cleared after reset"); +} + +#[test] +fn test_reset_clears_unexecuted_state() { + let mut parser = StreamingToolParser::new(); + + // Create complete but "unexecuted" tool call + parser.process_chunk(&chunk(r#"{"tool": "read_file", "args": {"file_path": "test.txt"}}"#, false)); + assert!(parser.has_unexecuted_tool_call()); + + parser.reset(); + + assert!(!parser.has_unexecuted_tool_call(), "Unexecuted state should be cleared after reset"); +} + +#[test] +fn test_reset_allows_new_tool_calls() { + let mut parser = StreamingToolParser::new(); + + // First tool call + let tools1 = parser.process_chunk(&chunk( + r#"{"tool": "read_file", "args": {"file_path": "a.txt"}}"#, + false + )); + assert_eq!(tools1.len(), 1); + + parser.reset(); + + // Second tool call after reset + let tools2 = parser.process_chunk(&chunk( + r#"{"tool": "shell", "args": {"command": "ls"}}"#, + false + )); + assert_eq!(tools2.len(), 1); + assert_eq!(tools2[0].tool, "shell"); +} + +// ============================================================================= +// Test: Buffer management and edge cases +// ============================================================================= + +#[test] +fn test_streaming_chunks_accumulate() { + let mut parser = StreamingToolParser::new(); + + // Stream in chunks + parser.process_chunk(&chunk(r#"{"tool": "#, false)); + parser.process_chunk(&chunk(r#""read_file", "#, false)); + parser.process_chunk(&chunk(r#""args": {"file_path": "#, false)); + parser.process_chunk(&chunk(r#""test.txt"}}"#, false)); + + // Should have accumulated the complete tool call + let text = parser.get_text_content(); + assert!(text.contains(r#""tool""#)); + assert!(text.contains(r#""read_file""#)); +} + +#[test] +fn test_finished_chunk_triggers_final_parse() { + let mut parser = StreamingToolParser::new(); + + // Incomplete chunks + parser.process_chunk(&chunk(r#"{"tool": "read_file", "#, false)); + let tools1 = parser.process_chunk(&chunk(r#""args": {"file_path": "test.txt"}}"#, false)); + + // Tool should be detected before finished + assert!(!tools1.is_empty() || !parser.has_unexecuted_tool_call(), + "Tool should be detected during streaming or marked as unexecuted"); +} + +#[test] +fn test_empty_chunks_ignored() { + let mut parser = StreamingToolParser::new(); + + parser.process_chunk(&chunk("", false)); + parser.process_chunk(&chunk("", false)); + + assert!(parser.get_text_content().is_empty()); + assert!(!parser.has_incomplete_tool_call()); + assert!(!parser.has_unexecuted_tool_call()); +} + +#[test] +fn test_whitespace_only_chunks() { + let mut parser = StreamingToolParser::new(); + + parser.process_chunk(&chunk(" \n\t ", false)); + + assert!(!parser.has_incomplete_tool_call()); + assert!(!parser.has_unexecuted_tool_call()); +} + +#[test] +fn test_json_with_escaped_quotes() { + let mut parser = StreamingToolParser::new(); + + let content = r#"{"tool": "shell", "args": {"command": "echo \"hello\""}}"#; + let tools = parser.process_chunk(&chunk(content, false)); + + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].tool, "shell"); +} + +#[test] +fn test_json_with_escaped_backslashes() { + let mut parser = StreamingToolParser::new(); + + let content = r#"{"tool": "write_file", "args": {"file_path": "C:\\Users\\test.txt", "content": "data"}}"#; + let tools = parser.process_chunk(&chunk(content, false)); + + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].tool, "write_file"); +} + +#[test] +fn test_json_with_nested_braces_in_string() { + let mut parser = StreamingToolParser::new(); + + let content = r#"{"tool": "write_file", "args": {"content": "{\"nested\": {\"json\": true}}"}}"#; + let tools = parser.process_chunk(&chunk(content, false)); + + assert_eq!(tools.len(), 1); + assert_eq!(tools[0].tool, "write_file"); +} + +#[test] +fn test_text_buffer_length_tracking() { + let mut parser = StreamingToolParser::new(); + + parser.process_chunk(&chunk("Hello", false)); + assert_eq!(parser.text_buffer_len(), 5); + + parser.process_chunk(&chunk(" World", false)); + assert_eq!(parser.text_buffer_len(), 11); + + parser.reset(); + assert_eq!(parser.text_buffer_len(), 0); +} + +#[test] +fn test_message_stopped_flag() { + let mut parser = StreamingToolParser::new(); + + parser.process_chunk(&chunk("Hello", false)); + assert!(!parser.is_message_stopped()); + + parser.process_chunk(&chunk(" World", true)); + assert!(parser.is_message_stopped()); + + parser.reset(); + assert!(!parser.is_message_stopped()); +} + +// ============================================================================= +// Test: Tool call pattern variations +// ============================================================================= + +#[test] +fn test_tool_pattern_no_spaces() { + let mut parser = StreamingToolParser::new(); + let tools = parser.process_chunk(&chunk( + r#"{"tool":"read_file","args":{"file_path":"test.txt"}}"#, + false + )); + assert_eq!(tools.len(), 1); +} + +// ============================================================================= +// Test: mark_tool_calls_consumed functionality +// ============================================================================= + +#[test] +fn test_mark_consumed_clears_unexecuted_state() { + let mut parser = StreamingToolParser::new(); + + // Add a complete tool call + parser.process_chunk(&chunk( + r#"{"tool": "read_file", "args": {"file_path": "test.txt"}}"#, + false + )); + + // Should be detected as unexecuted + assert!(parser.has_unexecuted_tool_call()); + + // Mark as consumed + parser.mark_tool_calls_consumed(); + + // Should no longer be detected as unexecuted + assert!(!parser.has_unexecuted_tool_call(), + "After marking consumed, has_unexecuted_tool_call should return false"); +} + +#[test] +fn test_mark_consumed_allows_new_tool_detection() { + let mut parser = StreamingToolParser::new(); + + // First tool call + parser.process_chunk(&chunk( + r#"{"tool": "read_file", "args": {"file_path": "a.txt"}}"#, + false + )); + parser.mark_tool_calls_consumed(); + + // Second tool call (without reset) + parser.process_chunk(&chunk( + r#"{"tool": "shell", "args": {"command": "ls"}}"#, + false + )); + + // Should detect the new unexecuted tool call + assert!(parser.has_unexecuted_tool_call(), + "New tool call after consumed position should be detected"); +} + +#[test] +fn test_bare_brace_not_incomplete() { + let mut parser = StreamingToolParser::new(); + + // Just a bare opening brace - not a tool call pattern + parser.process_chunk(&chunk(r#"{""#, false)); + + // Should NOT be detected as incomplete because it doesn't match tool patterns + assert!(!parser.has_incomplete_tool_call(), + "Bare {{ should not be detected as incomplete tool call"); +} + +#[test] +fn test_duplicate_tool_call_pattern() { + let mut parser = StreamingToolParser::new(); + + // Simulate the problematic pattern: tool call, garbage, duplicate tool call + let content = concat!( + r#"{"tool": "str_replace", "args": {"file_path": "test.rs", "diff": "test"}}"#, + "\n\n{\"\n\n", + r#"{"tool": "str_replace", "args": {"file_path": "test.rs", "diff": "test"}}"# + ); + let tools = parser.process_chunk(&chunk(content, false)); + + // Should detect at least one tool call + assert!(!tools.is_empty(), "Should detect at least one tool call"); + + // After processing, there should be an unexecuted tool call (the duplicate) + // because the parser only returns the first one it finds during streaming + assert!(parser.has_unexecuted_tool_call(), + "Should detect the duplicate as unexecuted"); +} + +#[test] +fn test_multiple_tool_calls_returned_on_finish() { + let mut parser = StreamingToolParser::new(); + + // Two complete tool calls in one chunk, with finished=true + let content = concat!( + r#"{"tool": "read_file", "args": {"file_path": "a.txt"}}"#, + "\nSome text\n", + r#"{"tool": "shell", "args": {"command": "ls"}}"# + ); + + // First, add content without finishing + parser.process_chunk(&chunk(content, false)); + + // Now finish the stream - should return ALL tool calls + let tools = parser.process_chunk(&chunk("", true)); + + // Should return both tool calls + assert_eq!(tools.len(), 2, "Should return both tool calls when stream finishes"); + assert_eq!(tools[0].tool, "read_file"); + assert_eq!(tools[1].tool, "shell"); +} + +#[test] +fn test_tool_pattern_extra_spaces() { + let mut parser = StreamingToolParser::new(); + let tools = parser.process_chunk(&chunk( + r#"{ "tool" : "read_file" , "args" : { "file_path" : "test.txt" } }"#, + false + )); + assert_eq!(tools.len(), 1); +} + +#[test] +fn test_tool_pattern_with_newlines() { + let mut parser = StreamingToolParser::new(); + // Note: The parser looks for specific patterns like {"tool": or { "tool": + // Multi-line JSON with newlines between { and "tool" won't match + // This is expected behavior - the pattern matching is intentionally strict + let _tools = parser.process_chunk(&chunk( + r#"{ + "tool": "read_file", + "args": { + "file_path": "test.txt" + } +}"#, + false + )); + // This won't be detected as a tool call due to newline after { + // The has_unexecuted_tool_call check also won't find it + // This is a known limitation of the pattern-based detection +} + +// ============================================================================= +// Test: Edge cases for has_message_like_keys validation +// ============================================================================= + +#[test] +fn test_normal_args_accepted() { + let mut parser = StreamingToolParser::new(); + let tools = parser.process_chunk(&chunk( + r#"{"tool": "read_file", "args": {"file_path": "test.txt", "start": 0, "end": 100}}"#, + false + )); + assert_eq!(tools.len(), 1); +} + +#[test] +fn test_content_with_phrases_in_value_accepted() { + let mut parser = StreamingToolParser::new(); + // Phrases like "I'll" in VALUES should be fine (only keys are checked) + let tools = parser.process_chunk(&chunk( + r#"{"tool": "write_file", "args": {"file_path": "test.txt", "content": "I'll help you with that. Let me explain."}}"#, + false + )); + assert_eq!(tools.len(), 1); +}