import json import unittest from sglang.srt.entrypoints.openai.protocol import Function, Tool from sglang.srt.function_call.core_types import StreamingParseResult from sglang.srt.function_call.glm4_moe_detector import Glm4MoeDetector from sglang.srt.function_call.glm47_moe_detector import ( Glm47MoeDetector, get_argument_type, ) from sglang.test.ci.ci_register import register_cpu_ci register_cpu_ci(1.0, "default") class TestGlm47MoeDetector(unittest.TestCase): def setUp(self): self.tools = [ Tool( type="function", function=Function( name="get_weather", description="Get weather information", parameters={ "type": "object", "properties": { "city": {"type": "string", "description": "City name"}, "date": {"type": "string", "description": "Date"}, }, "required": ["city", "date"], }, ), ), ] self.detector = Glm47MoeDetector() # ==================== Basic Parsing Tests (5) ==================== def test_single_tool_call(self): """ Test basic single tool call parsing. Scenario: Parse a complete tool call with two string parameters in a single text block. Purpose: Verify the detector can correctly identify and extract function name and parameters from a simple, well-formed tool call. """ text = ( "get_weather" "cityBeijing" "date2024-06-27" "" ) result = self.detector.detect_and_parse(text, self.tools) self.assertEqual(len(result.calls), 1) self.assertEqual(result.calls[0].name, "get_weather") self.assertEqual( result.calls[0].parameters, '{"city": "Beijing", "date": "2024-06-27"}' ) self.assertEqual(result.normal_text, "") def test_multiple_tool_calls(self): """ Test parsing multiple consecutive tool calls. Scenario: Parse two complete tool calls back-to-back without any text in between. Purpose: Verify the detector correctly handles multiple tool calls and resets state between calls to avoid parameter leakage or ID conflicts. """ text = ( "get_weather" "cityBeijing" "date2024-06-27" "" "get_weather" "cityShanghai" "date2024-06-28" "" ) result = self.detector.detect_and_parse(text, self.tools) self.assertEqual(len(result.calls), 2) self.assertEqual(result.calls[0].name, "get_weather") self.assertEqual( result.calls[0].parameters, '{"city": "Beijing", "date": "2024-06-27"}' ) self.assertEqual(result.calls[1].name, "get_weather") self.assertEqual( result.calls[1].parameters, '{"city": "Shanghai", "date": "2024-06-28"}' ) self.assertEqual(result.normal_text, "") def test_no_arg_function_non_streaming(self): """ Test no-argument function call without streaming. Scenario: Parse a tool call for a function that has no parameters (empty properties). Purpose: Verify the detector generates a single empty object "{}" for no-argument functions and does not duplicate empty parameter objects. """ tools_with_no_args = [ Tool( type="function", function=Function( name="list_filenames", description="List filenames", parameters={ "type": "object", "properties": {}, }, ), ), ] text = "list_filenames" result = self.detector.detect_and_parse(text, tools_with_no_args) self.assertEqual(len(result.calls), 1) self.assertEqual(result.calls[0].name, "list_filenames") params = json.loads(result.calls[0].parameters) self.assertEqual(params, {}) def test_invalid_tool_call(self): """ Test handling of invalid tool calls. Scenario: Attempt to parse a tool call with a function name that doesn't exist in the tool list. Purpose: Verify the detector gracefully rejects invalid function calls and returns no calls rather than throwing an error or accepting invalid input. """ text = "invalid_funccityBeijing" result = self.detector.detect_and_parse(text, self.tools) self.assertEqual(len(result.calls), 0) def test_array_argument_with_escaped_json(self): """ Test array arguments containing escaped JSON strings. Scenario: Parse tool calls with array parameters containing nested JSON objects with escaped quotes (both backslash-escaped and raw escaped strings). Purpose: Verify the detector properly handles JSON escaping without double-escaping, preserving special characters like backslashes in paths and newline sequences. """ tools_with_array = [ Tool( type="function", function=Function( name="todo_write", description="Write todos", parameters={ "type": "object", "properties": { "todos": { "type": "array", "description": "The updated todo list", } }, "required": ["todos"], }, ), ), ] def check_params(result): self.assertEqual(1, len(result.calls)) self.assertEqual("todo_write", result.calls[0].name) params = json.loads(result.calls[0].parameters) self.assertIsInstance(params["todos"], list) self.assertEqual(4, len(params["todos"])) self.assertEqual("1", params["todos"][0]["id"]) self.assertEqual( "Check for hard-coded issues in the backend code", params["todos"][0]["task"], ) self.assertEqual("in_progress", params["todos"][0]["status"]) self.assertEqual("2", params["todos"][1]["id"]) self.assertEqual( "Check for hard-coded issues in the frontend code", params["todos"][1]["task"], ) self.assertEqual("pending", params["todos"][1]["status"]) self.assertEqual("3", params["todos"][2]["id"]) self.assertEqual( "Check for code violating the Single Responsibility Principle", params["todos"][2]["task"], ) self.assertEqual("pending", params["todos"][2]["status"]) self.assertEqual("4", params["todos"][3]["id"]) self.assertEqual( "Generate a rectification proposal report", params["todos"][3]["task"] ) self.assertEqual("pending", params["todos"][3]["status"]) # Test with normal escaped JSON in XML result = self.detector.detect_and_parse( """todo_writetodos[{\"id\": \"1\", \"task\": \"Check for hard-coded issues in the backend code\", \"status\": \"in_progress\"}, {\"id\": \"2\", \"task\": \"Check for hard-coded issues in the frontend code\", \"status\": \"pending\"}, {\"id\": \"3\", \"task\": \"Check for code violating the Single Responsibility Principle\", \"status\": \"pending\"}, {\"id\": \"4\", \"task\": \"Generate a rectification proposal report\", \"status\": \"pending\"}] """, tools_with_array, ) check_params(result) # Test with raw string escaped JSON result = self.detector.detect_and_parse( r"""todo_writetodos[{\"id\": \"1\", \"task\": \"Check for hard-coded issues in the backend code\", \"status\": \"in_progress\"}, {\"id\": \"2\", \"task\": \"Check for hard-coded issues in the frontend code\", \"status\": \"pending\"}, {\"id\": \"3\", \"task\": \"Check for code violating the Single Responsibility Principle\", \"status\": \"pending\"}, {\"id\": \"4\", \"task\": \"Generate a rectification proposal report\", \"status\": \"pending\"}] """, tools_with_array, ) check_params(result) def check_single_todos(tool_result, expected): self.assertEqual(1, len(tool_result.calls)) self.assertEqual("todo_write", tool_result.calls[0].name) params = json.loads(tool_result.calls[0].parameters) self.assertIsInstance(params["todos"], list) self.assertEqual(1, len(params["todos"])) self.assertEqual("1", params["todos"][0]["id"]) self.assertEqual(expected, params["todos"][0]["task"]) self.assertEqual("pending", params["todos"][0]["status"]) # Test with escaped backslashes (Windows paths) expected_path = r"Check file at C:\Users\test.txt" result = self.detector.detect_and_parse( """todo_writetodos[{\"id\": \"1\", \"task\": \"Check file at C:\\\\Users\\\\test.txt\", \"status\": \"pending\"}]""", tools_with_array, ) check_single_todos(result, expected_path) # Test with literal backslash-n (not newline) expected_output = r"Print \n to see newline" result = self.detector.detect_and_parse( """todo_writetodos[{\"id\": \"1\", \"task\": \"Print \\\\n to see newline\",\"status\": \"pending\"}]""", tools_with_array, ) check_single_todos(result, expected_output) # ==================== MTP Core Scenarios (3) ==================== def test_mtp_func_and_string_split(self): """ Test MTP-style function name and string parameter value splitting across chunks. Scenario: Simulate Model Token Provider (MTP) behavior where function names and string parameter values are split mid-word across multiple chunks. Purpose: This is the MOST CRITICAL test - verify the detector correctly reassembles: - Function name split as "create_ta" + "sk" - String values split as "Go to Bei" + "jing" and "San Fran" + "cisco" These splits mimic real MTP output where tokenization breaks words arbitrarily. """ tools = [ Tool( type="function", function=Function( name="create_task", parameters={ "type": "object", "properties": { "title": {"type": "string"}, "location": {"type": "string"}, }, }, ), ), ] chunks = [ "I'll create a task.", # normal text before tool call "create_ta", # function name split mid-word "sktitleGo to Bei", # function name completes, param value splits "jing", # first parameter value completes "locationSan Fran", # second parameter value splits "cisco", # second parameter and tool call complete ] detector = Glm47MoeDetector() all_calls = [] all_normal_text = "" for chunk in chunks: result = detector.parse_streaming_increment(chunk, tools) all_calls.extend(result.calls) all_normal_text += result.normal_text # Verify normal text is preserved self.assertEqual(all_normal_text, "I'll create a task.") # Verify function call func_calls = [c for c in all_calls if c.name] self.assertEqual(len(func_calls), 1) self.assertEqual( func_calls[0].name, "create_task" ) # "create_ta" + "sk" reassembled # Verify parameter reassembly full_params = "".join([c.parameters for c in all_calls if c.parameters]) params = json.loads(full_params) self.assertEqual( params["title"], "Go to Beijing" ) # "Go to Bei" + "jing" reassembled self.assertEqual( params["location"], "San Francisco" ) # "San Fran" + "cisco" reassembled def test_mtp_noarg_and_multiple_calls(self): """ Test MTP-style no-argument function and multiple tool calls with state reset. Scenario: Stream a no-argument function call followed by a regular function call, simulating MTP's output pattern where function completion triggers state reset. Purpose: Verify: - No-argument functions emit exactly ONE empty object "{}", not duplicates - State properly resets between consecutive tool calls (tool_index increments) - Second tool call doesn't inherit parameters from first call """ tools = [ Tool( type="function", function=Function( name="list_files", parameters={ "type": "object", "properties": {}, }, ), ), Tool( type="function", function=Function( name="get_weather", parameters={ "type": "object", "properties": { "city": {"type": "string"}, }, }, ), ), ] chunks = [ "list_files", # no-arg function, complete in one chunk "get_weathercityBeijing", ] detector = Glm47MoeDetector() all_calls = [] for chunk in chunks: result = detector.parse_streaming_increment(chunk, tools) all_calls.extend(result.calls) # Verify two distinct tool calls func_calls = [c for c in all_calls if c.name] self.assertEqual(len(func_calls), 2) self.assertEqual(func_calls[0].name, "list_files") self.assertEqual(func_calls[1].name, "get_weather") # Verify no duplicate empty objects for no-arg function empty_object_calls = [c for c in all_calls if c.parameters == "{}"] self.assertLessEqual( len(empty_object_calls), 1, "No-argument function should emit at most one empty object", ) # Verify second call has correct parameters weather_params = [ c.parameters for c in all_calls if c.parameters and c.parameters != "{}" ] if weather_params: full_params = "".join(weather_params) params = json.loads(full_params) self.assertEqual(params["city"], "Beijing") def test_mtp_number_and_complex_json(self): """ Test MTP-style number parameters and complex JSON array splitting. Scenario: Parse tool calls with number parameters (int and float) and JSON arrays split across chunks, including splits within JSON structure. Purpose: Verify: - Number types (5.5, 10) are preserved as numbers, not strings - JSON array content split as "description" + ": \"" maintains validity - Nested JSON objects in arrays are correctly reconstructed """ tools = [ Tool( type="function", function=Function( name="create_todos", parameters={ "type": "object", "properties": { "priority": {"type": "number"}, "count": {"type": "integer"}, "items": {"type": "array"}, }, }, ), ), ] chunks = [ "create_todos", "priority5.5", # float number "count10", # integer number 'items[{"description', # JSON array splits mid-key '": "Test', # key completes, value starts 'Todo 1"}, {"description": "TestTodo 2"}]', ] detector = Glm47MoeDetector() all_calls = [] for chunk in chunks: result = detector.parse_streaming_increment(chunk, tools) all_calls.extend(result.calls) # Verify function name func_calls = [c for c in all_calls if c.name] self.assertEqual(len(func_calls), 1) self.assertEqual(func_calls[0].name, "create_todos") # Verify parameters - numbers and JSON array full_params = "".join([c.parameters for c in all_calls if c.parameters]) params = json.loads(full_params) # Number types should be preserved self.assertIsInstance(params["priority"], (int, float)) self.assertEqual(params["priority"], 5.5) self.assertIsInstance(params["count"], int) self.assertEqual(params["count"], 10) # JSON array should be correctly reconstructed self.assertIsInstance(params["items"], list) self.assertEqual(len(params["items"]), 2) self.assertEqual(params["items"][0]["description"], "TestTodo 1") self.assertEqual(params["items"][1]["description"], "TestTodo 2") # ==================== Streaming Basics (3) ==================== def test_streaming_tool_call(self): """ Test basic streaming incremental parsing of a single tool call. Scenario: Parse a tool call split across 4 chunks with natural boundaries (function name, first param, second param, closing tag). Purpose: Verify basic streaming functionality works correctly and accumulates parameters progressively across chunks. """ chunks = [ "get_weather", "cityBeijing", "date2024-06-27", "", ] tool_calls = [] for chunk in chunks: result = self.detector.parse_streaming_increment(chunk, self.tools) for tool_call_chunk in result.calls: if ( hasattr(tool_call_chunk, "tool_index") and tool_call_chunk.tool_index is not None ): while len(tool_calls) <= tool_call_chunk.tool_index: tool_calls.append({"name": "", "parameters": ""}) tc = tool_calls[tool_call_chunk.tool_index] if tool_call_chunk.name: tc["name"] = tool_call_chunk.name if tool_call_chunk.parameters: tc["parameters"] += tool_call_chunk.parameters self.assertEqual(len(tool_calls), 1) self.assertEqual(tool_calls[0]["name"], "get_weather") self.assertEqual( tool_calls[0]["parameters"], '{"city": "Beijing", "date": "2024-06-27"}' ) def test_streaming_multiple_tool_calls(self): """ Test streaming incremental parsing of multiple consecutive tool calls. Scenario: Stream two complete tool calls with the transition "" occurring within a single chunk. Purpose: Verify streaming correctly handles multiple tool calls and properly increments tool_index for each new call. """ chunks = [ "get_weather", "cityBeijing", "date2024-06-27", "get_weather", # two tool calls transition in same chunk "cityShanghai", "date2024-06-28", "", ] tool_calls = [] for chunk in chunks: result = self.detector.parse_streaming_increment(chunk, self.tools) for tool_call_chunk in result.calls: if ( hasattr(tool_call_chunk, "tool_index") and tool_call_chunk.tool_index is not None ): while len(tool_calls) <= tool_call_chunk.tool_index: tool_calls.append({"name": "", "parameters": ""}) tc = tool_calls[tool_call_chunk.tool_index] if tool_call_chunk.name: tc["name"] = tool_call_chunk.name if tool_call_chunk.parameters: tc["parameters"] += tool_call_chunk.parameters self.assertEqual(len(tool_calls), 2) self.assertEqual(tool_calls[0]["name"], "get_weather") self.assertEqual( tool_calls[0]["parameters"], '{"city": "Beijing", "date": "2024-06-27"}' ) self.assertEqual(tool_calls[1]["name"], "get_weather") self.assertEqual( tool_calls[1]["parameters"], '{"city": "Shanghai", "date": "2024-06-28"}' ) def test_normal_text_before_tool_call(self): """ Test preservation of normal text (including punctuation) before tool calls. Scenario: Parse chunks containing normal text with various punctuation marks (English and Chinese) immediately followed by tool call tags. Purpose: Verify normal text is preserved in result.normal_text and not lost when tool call parsing begins. This consolidates 6 previous Chinese punctuation tests. """ tools = [ Tool( type="function", function=Function( name="list_dir", parameters={ "type": "object", "properties": { "path": {"type": "string"}, }, }, ), ), ] test_cases = [ ("Sure, let me help.list_dir", "English with period"), ("结构:list_dir", "Chinese colon"), ("问题。list_dir", "Chinese period"), ("Complete!list_dir", "English exclamation"), ("说明;list_dir", "Chinese semicolon"), ] for text, description in test_cases: with self.subTest(description=description): detector = Glm47MoeDetector() result = detector.parse_streaming_increment(text, tools) before_token = text.split("")[0] self.assertIn( before_token, result.normal_text, f"Should preserve '{before_token}' in '{description}'", ) # ==================== Boundary Cases (9) ==================== def test_boundary_empty_param_value(self): """ Test handling of empty parameter values. Scenario: Parse a tool call where a parameter value is an empty string. Purpose: Verify the detector correctly handles empty strings as valid parameter values and doesn't skip or error on them. """ tools = [ Tool( type="function", function=Function( name="create_note", parameters={ "type": "object", "properties": { "title": {"type": "string"}, "content": {"type": "string"}, }, }, ), ), ] text = "create_notetitleTestcontent" result = self.detector.detect_and_parse(text, tools) self.assertEqual(len(result.calls), 1) params = json.loads(result.calls[0].parameters) self.assertEqual(params["title"], "Test") self.assertEqual(params["content"], "") # empty string should be preserved def test_boundary_param_value_extreme_split(self): """ Test extreme parameter value splitting - one character per chunk. Scenario: Stream a parameter value where each character arrives in a separate chunk, representing worst-case MTP tokenization. Purpose: Stress test the buffer reassembly mechanism to ensure it can handle extremely granular chunk boundaries without data loss or corruption. """ tools = [ Tool( type="function", function=Function( name="search", parameters={ "type": "object", "properties": { "query": {"type": "string"}, }, }, ), ), ] chunks = [ "searchqueryN", "e", "w ", "Y", "o", "rk", ] detector = Glm47MoeDetector() all_calls = [] for chunk in chunks: result = detector.parse_streaming_increment(chunk, tools) all_calls.extend(result.calls) full_params = "".join([c.parameters for c in all_calls if c.parameters]) params = json.loads(full_params) self.assertEqual( params["query"], "New York" ) # all characters correctly reassembled def test_boundary_param_value_with_special_chars(self): """ Test parameter values containing special characters and escape sequences. Scenario: Parse parameter values with quotes, backslashes, newlines, and other special characters that require JSON escaping. Purpose: Verify special characters are properly escaped/unescaped and preserved through the parsing pipeline without corruption. """ tools = [ Tool( type="function", function=Function( name="execute_command", parameters={ "type": "object", "properties": { "command": {"type": "string"}, }, }, ), ), ] # Test with single quotes (no escaping needed) text = "execute_commandcommandecho 'Hello World'" result = self.detector.detect_and_parse(text, tools) params = json.loads(result.calls[0].parameters) self.assertEqual(params["command"], "echo 'Hello World'") # Test with spaces and special chars that don't need escaping text = "execute_commandcommandecho Hello & World" result = self.detector.detect_and_parse(text, tools) params = json.loads(result.calls[0].parameters) self.assertEqual(params["command"], "echo Hello & World") def test_boundary_json_deeply_nested(self): """ Test deeply nested JSON structures in parameter values. Scenario: Parse a parameter containing a deeply nested JSON object with multiple levels. Purpose: Verify the detector can handle complex nested structures without stack overflow or parsing errors. """ tools = [ Tool( type="function", function=Function( name="process_data", parameters={ "type": "object", "properties": { "data": {"type": "object"}, }, }, ), ), ] nested_json = ( '{"level1": {"level2": {"level3": {"level4": {"value": "deep"}}}}}' ) text = f"process_datadata{nested_json}" result = self.detector.detect_and_parse(text, tools) params = json.loads(result.calls[0].parameters) # Navigate through nested structure self.assertEqual( params["data"]["level1"]["level2"]["level3"]["level4"]["value"], "deep" ) def test_boundary_json_empty_structures(self): """ Test empty JSON structures (empty objects and arrays) in parameters. Scenario: Parse parameters containing empty objects {} and empty arrays []. Purpose: Verify empty structures are preserved and not confused with no-argument function empty parameter generation. """ tools = [ Tool( type="function", function=Function( name="create_structure", parameters={ "type": "object", "properties": { "empty_obj": {"type": "object"}, "empty_arr": {"type": "array"}, }, }, ), ), ] text = "create_structureempty_obj{}empty_arr[]" result = self.detector.detect_and_parse(text, tools) params = json.loads(result.calls[0].parameters) self.assertEqual(params["empty_obj"], {}) self.assertEqual(params["empty_arr"], []) def test_boundary_multi_tags_one_chunk(self): """ Test multiple XML tags appearing in a single chunk. Scenario: Parse chunks where multiple complete tags (arg_key, arg_value, etc.) appear together without any chunk boundaries between them. Purpose: Verify the regex-based tag extraction correctly handles multiple tags in one chunk and processes them in the correct order. """ tools = [ Tool( type="function", function=Function( name="multi_param", parameters={ "type": "object", "properties": { "a": {"type": "string"}, "b": {"type": "string"}, "c": {"type": "string"}, }, }, ), ), ] # All three parameters in one chunk text = "multi_parama1b2c3" result = self.detector.detect_and_parse(text, tools) params = json.loads(result.calls[0].parameters) self.assertEqual(params["a"], "1") self.assertEqual(params["b"], "2") self.assertEqual(params["c"], "3") def test_boundary_normal_text_mixed_with_tool(self): """ Test normal text interleaved with tool calls. Scenario: Parse text with normal text before and after tool calls. Purpose: Verify normal text segments are correctly separated from tool call parsing and preserved in the normal_text output. """ tools = [ Tool( type="function", function=Function( name="action", parameters={ "type": "object", "properties": {}, }, ), ), ] text = "First I'll do this.actionThen I'll do that." result = self.detector.detect_and_parse(text, tools) self.assertEqual(len(result.calls), 1) self.assertEqual(result.calls[0].name, "action") # Verify both text before and after tool calls are preserved self.assertIn("First I'll do this.", result.normal_text) self.assertIn("Then I'll do that.", result.normal_text) def test_boundary_number_edge_values(self): """ Test edge-case number values (zero, negative, scientific notation). Scenario: Parse parameters with various numeric edge cases to ensure proper type handling. Purpose: Verify the detector correctly preserves number types for edge values and doesn't convert them to strings or lose precision. """ tools = [ Tool( type="function", function=Function( name="calculate", parameters={ "type": "object", "properties": { "zero": {"type": "number"}, "negative": {"type": "number"}, "large": {"type": "number"}, }, }, ), ), ] text = "calculatezero0negative-42.5large1e10" result = self.detector.detect_and_parse(text, tools) params = json.loads(result.calls[0].parameters) self.assertEqual(params["zero"], 0) self.assertEqual(params["negative"], -42.5) self.assertEqual(params["large"], 1e10) def test_boundary_type_string_with_numeric_content(self): """ Test string parameters that contain numeric-looking content. Scenario: Parse string parameters with values like "123" or "45.67" that look like numbers but should remain strings based on parameter schema. Purpose: Verify type preservation based on schema definition, not content appearance. """ tools = [ Tool( type="function", function=Function( name="store_data", parameters={ "type": "object", "properties": { "id": { "type": "string" }, # string type despite numeric content "code": {"type": "string"}, }, }, ), ), ] text = "store_dataid12345code67.89" result = self.detector.detect_and_parse(text, tools) params = json.loads(result.calls[0].parameters) # Should be strings, not numbers self.assertIsInstance(params["id"], str) self.assertIsInstance(params["code"], str) self.assertEqual(params["id"], "12345") self.assertEqual(params["code"], "67.89") # ==================== Error Handling (2) ==================== def test_error_undefined_tool(self): """ Test error handling for undefined tool names. Scenario: Attempt to call a function that doesn't exist in the provided tools list. Purpose: Verify the detector gracefully handles undefined tools by returning an empty call list rather than crashing or producing malformed output. """ text = "nonexistent_functionparamvalue" result = self.detector.detect_and_parse(text, self.tools) # Should not crash, should return empty calls self.assertEqual(len(result.calls), 0) def test_error_incomplete_buffer_at_end(self): """ Test handling of incomplete tool calls at end of stream. Scenario: Streaming ends with an incomplete tool call (e.g., missing closing tag). Purpose: Verify the detector handles incomplete buffers gracefully without throwing exceptions, as streaming may end mid-parse in real scenarios. """ chunks = [ "get_weathercityBeijing", # Stream ends here, no closing tags ] detector = Glm47MoeDetector() for chunk in chunks: result = detector.parse_streaming_increment(chunk, self.tools) # Should not crash self.assertIsInstance(result, StreamingParseResult) # Incomplete call should not be in results # (or may be partially present - main thing is no exception) # ==================== Streamed Raw Length Bug Tests (3) ==================== def test_streamed_raw_length_incomplete_xml_tag(self): """ Test that _streamed_raw_length is updated even when json_increment is empty. Scenario: Stream XML content that is split at an incomplete tag boundary, causing the state machine to buffer without producing JSON output. Purpose: Verify that _streamed_raw_length is updated regardless of whether json_increment is empty, preventing reprocessing of the same input. This tests the bug where: 1. raw_increment is extracted from func_args_raw[self._streamed_raw_length:] 2. _process_xml_to_json_streaming() returns empty string (buffering state) 3. If _streamed_raw_length is NOT updated before the early return, the next call will reprocess the same raw_increment """ tools = [ Tool( type="function", function=Function( name="get_weather", parameters={ "type": "object", "properties": { "city": {"type": "string"}, "temperature": {"type": "number"}, }, }, ), ), ] # Simulate streaming chunks where XML tags are split chunks = [ "get_weather", "cityBei", # Split in middle of value "jing", # Complete the value "temperature2", # Split numeric value "5", ] detector = Glm47MoeDetector() all_calls = [] collected_params = "" for i, chunk in enumerate(chunks): result = detector.parse_streaming_increment(chunk, tools) all_calls.extend(result.calls) # Collect parameters for call in result.calls: if call.parameters: collected_params += call.parameters # Verify complete parameters were collected without duplication if collected_params: params = json.loads(collected_params) self.assertEqual(params["city"], "Beijing") self.assertEqual(params["temperature"], 25) # Critical: Verify no duplicate JSON output due to reprocessing # Count occurrences of "city" key - should appear exactly once city_count = collected_params.count('"city"') self.assertEqual( city_count, 1, f"'city' key appears {city_count} times, expected 1. " f"This indicates input reprocessing bug.", ) def test_streamed_raw_length_tag_split_across_chunks(self): """ Test _streamed_raw_length update when tag is split across chunk boundaries. Scenario: XML tags themselves are split across chunks (e.g., ""). Purpose: Verify that even when the state machine is buffering partial tags, _streamed_raw_length is correctly updated to prevent reprocessing. """ tools = [ Tool( type="function", function=Function( name="search", parameters={ "type": "object", "properties": { "query": {"type": "string"}, "limit": {"type": "integer"}, }, }, ), ), ] # Split tags in extreme positions chunks = [ "searchqueryPython progra", # Complete tag, split value "mminglimit10", ] detector = Glm47MoeDetector() all_params = "" for chunk in chunks: result = detector.parse_streaming_increment(chunk, tools) for call in result.calls: if call.parameters: all_params += call.parameters # Verify correct reassembly params = json.loads(all_params) self.assertEqual(params["query"], "Python programming") self.assertEqual(params["limit"], 10) # Verify no duplication in output query_count = all_params.count('"query"') limit_count = all_params.count('"limit"') self.assertEqual(query_count, 1, "query key duplicated - reprocessing bug") self.assertEqual(limit_count, 1, "limit key duplicated - reprocessing bug") def test_streamed_raw_length_buffer_only_partial_tag(self): """ Test that _streamed_raw_length updates even when state machine returns empty. Scenario: Send increment that is ONLY a partial opening tag that state machine must buffer completely without producing any JSON output. Purpose: Force json_increment to be empty string to expose the bug where _streamed_raw_length is not updated before early return. """ tools = [ Tool( type="function", function=Function( name="test_func", parameters={ "type": "object", "properties": { "key1": {"type": "string"}, }, }, ), ), ] # Manually call _process_arguments_streaming to have precise control detector = Glm47MoeDetector() detector.current_tool_id = 0 detector.current_tool_name_sent = True detector._reset_streaming_state() detector.streamed_args_for_tool = [""] detector._streamed_raw_length = 0 # First call: Complete tag that produces JSON output func_args_1 = "key1va" result_1 = detector._process_arguments_streaming( "test_func", func_args_1, tools ) # Should produce JSON output: {"key1": "va (partial) self.assertIsNotNone(result_1) self.assertGreater(len(result_1.parameters), 0) initial_length = detector._streamed_raw_length self.assertEqual(initial_length, len(func_args_1)) # Second call: Add just partial closing tag - state machine will buffer this # without producing JSON (it's waiting to see if is complete) func_args_2 = func_args_1 + "<" # Add partial tag result_2 = detector._process_arguments_streaming( "test_func", func_args_2, tools ) # This is the critical test: if _streamed_raw_length is NOT updated when # json_increment is empty, then detector._streamed_raw_length will still be # at initial_length, and the next call will reprocess the "<" character # Check if length was updated (bug test) updated_length = detector._streamed_raw_length # BUG: If code has bug, updated_length will equal initial_length # FIXED: If code is correct, updated_length should equal len(func_args_2) self.assertEqual( updated_length, len(func_args_2), "Bug detected: _streamed_raw_length not updated when json_increment is empty. " f"Expected {len(func_args_2)}, got {updated_length}", ) def test_streamed_raw_length_multiple_empty_returns(self): """ Test consecutive chunks that produce empty json_increment. Scenario: Multiple consecutive chunks that all result in empty json_increment as the state machine buffers complex nested structures. Purpose: Verify _streamed_raw_length advances correctly through multiple empty-return cycles without getting stuck or reprocessing. """ tools = [ Tool( type="function", function=Function( name="update_settings", parameters={ "type": "object", "properties": { "name": {"type": "string"}, "value": {"type": "string"}, }, }, ), ), ] # Split XML at positions that may cause state machine buffering chunks = [ "update_settingsna", # Split in tag name "meco", # Complete tag start, split value # codespell:ignore ue "nf", # Continue value "ig_v1val", # Complete value, split next key "ueena", # Complete key name, split value # codespell:ignore ue "bled", # Complete everything ] detector = Glm47MoeDetector() all_params = "" for i, chunk in enumerate(chunks): result = detector.parse_streaming_increment(chunk, tools) for call in result.calls: if call.parameters: all_params += call.parameters # Verify final output is correct self.assertGreater(len(all_params), 0, "Should have generated some parameters") params = json.loads(all_params) self.assertEqual(params["name"], "config_v1") self.assertEqual(params["value"], "enabled") # Verify no duplicate keys due to reprocessing name_count = all_params.count('"name"') value_count = all_params.count('"value"') self.assertEqual( name_count, 1, f"'name' appears {name_count} times - indicates reprocessing bug", ) self.assertEqual( value_count, 1, f"'value' appears {value_count} times - indicates reprocessing bug", ) class TestGlm4ComplexJsonSchema(unittest.TestCase): """Test complex JSON Schema type inference for GLM function call parsers.""" def setUp(self): """Set up test tools with complex JSON schemas.""" self.tools_with_complex_schema = [ Tool( type="function", function=Function( name="search", description="Search for information", parameters={ "type": "object", "properties": { "query": { "description": "Search query, can be a string or a complex object", "anyOf": [ {"type": "string"}, { "type": "object", "properties": { "text": {"type": "string"}, "filters": {"type": "object"}, }, }, ], }, "priority": {"enum": ["low", "medium", "high"]}, "options": { "oneOf": [{"type": "string"}, {"type": "number"}] }, "config": { "allOf": [ {"type": "object"}, {"properties": {"timeout": {"type": "number"}}}, ] }, "tags": {"type": ["string", "null"]}, "data": { "type": "object", "properties": { "nested": { "anyOf": [ {"type": "string"}, { "type": "object", "properties": { "value": {"type": "string"} }, }, ] } }, }, }, "required": ["query"], }, ), ), Tool( type="function", function=Function( name="get_weather", description="Get weather information", parameters={ "type": "object", "properties": { "location": { "type": "string", "description": "Location to get weather for", }, "unit": { "type": "string", "description": "Temperature unit", "enum": ["celsius", "fahrenheit"], }, }, "required": ["location"], }, ), ), ] self.glm4_detector = Glm4MoeDetector() self.glm47_detector = Glm47MoeDetector() def test_get_argument_type_simple_type(self): """Test that get_argument_type correctly handles simple type fields.""" result = get_argument_type( "get_weather", "location", self.tools_with_complex_schema ) self.assertEqual(result, "string") def test_get_argument_type_enum_type(self): """Test that get_argument_type correctly identifies enum as string type.""" result = get_argument_type( "get_weather", "unit", self.tools_with_complex_schema ) # Current implementation returns the direct type field, which is "string" for the enum parameter # But it doesn't handle enum-only schemas properly (without type field) self.assertEqual(result, "string") def test_get_argument_type_anyof_type(self): """Test that get_argument_type correctly handles anyOf type fields.""" result = get_argument_type("search", "query", self.tools_with_complex_schema) # anyOf with [{"type": "string"}, {"type": "object", ...}] should return "string" self.assertEqual(result, "string") # Returns first common type def test_get_argument_type_oneof_type(self): """Test that get_argument_type correctly handles oneOf type fields.""" result = get_argument_type("search", "options", self.tools_with_complex_schema) # oneOf with [{"type": "string"}, {"type": "number"}] should return "string" (prioritizes string) self.assertEqual(result, "string") def test_get_argument_type_allof_type(self): """Test that get_argument_type correctly handles allOf type fields.""" result = get_argument_type("search", "config", self.tools_with_complex_schema) # allOf with [{"type": "object"}, ...] should return "object" self.assertEqual(result, "object") def test_get_argument_type_type_array(self): """Test that get_argument_type correctly handles type arrays.""" result = get_argument_type("search", "tags", self.tools_with_complex_schema) # Type arrays should return the first non-null type self.assertEqual( result, "string" ) # ["string", "null"] -> "string" (non-null type) def test_glm4_detector_with_complex_schema_anyof(self): """Test GLM4 detector with anyOf schema - should demonstrate current issues.""" # This test shows the current behavior with complex schemas text = ( "search\n" "query\nHello world\n" "priority\nmedium\n" "" ) result = self.glm4_detector.detect_and_parse( text, self.tools_with_complex_schema ) self.assertEqual(len(result.calls), 1) self.assertEqual(result.calls[0].name, "search") # Parse parameters to check if they are correctly handled params = json.loads(result.calls[0].parameters) self.assertEqual(params["query"], "Hello world") self.assertEqual(params["priority"], "medium") def test_glm47_detector_with_complex_schema_anyof(self): """Test GLM47 detector with anyOf schema - should demonstrate current issues.""" # This test shows the current behavior with complex schemas text = ( "search" "queryHello world" "prioritymedium" "" ) result = self.glm47_detector.detect_and_parse( text, self.tools_with_complex_schema ) self.assertEqual(len(result.calls), 1) self.assertEqual(result.calls[0].name, "search") # Parse parameters to check if they are correctly handled params = json.loads(result.calls[0].parameters) self.assertEqual(params["query"], "Hello world") self.assertEqual(params["priority"], "medium") def test_glm4_detector_with_enum_values(self): """Test GLM4 detector with enum values in complex schema.""" text = ( "search\n" "query\ntest query\n" "priority\nhigh\n" "" ) result = self.glm4_detector.detect_and_parse( text, self.tools_with_complex_schema ) self.assertEqual(len(result.calls), 1) self.assertEqual(result.calls[0].name, "search") params = json.loads(result.calls[0].parameters) self.assertEqual(params["query"], "test query") self.assertEqual(params["priority"], "high") def test_glm47_detector_with_enum_values(self): """Test GLM47 detector with enum values in complex schema.""" text = ( "search" "querytest query" "priorityhigh" "" ) result = self.glm47_detector.detect_and_parse( text, self.tools_with_complex_schema ) self.assertEqual(len(result.calls), 1) self.assertEqual(result.calls[0].name, "search") params = json.loads(result.calls[0].parameters) self.assertEqual(params["query"], "test query") self.assertEqual(params["priority"], "high") def test_glm4_detector_streaming_with_complex_schema(self): """Test GLM4 detector streaming with complex schema.""" chunks = [ "search\n", "query\nnested object\n", "priority\nlow\n", "", ] tool_calls = [] for chunk in chunks: result = self.glm4_detector.parse_streaming_increment( chunk, self.tools_with_complex_schema ) for tool_call_chunk in result.calls: if ( hasattr(tool_call_chunk, "tool_index") and tool_call_chunk.tool_index is not None ): while len(tool_calls) <= tool_call_chunk.tool_index: tool_calls.append({"name": "", "parameters": ""}) tc = tool_calls[tool_call_chunk.tool_index] if tool_call_chunk.name: tc["name"] = tool_call_chunk.name if tool_call_chunk.parameters: tc["parameters"] += tool_call_chunk.parameters self.assertEqual(len(tool_calls), 1) self.assertEqual(tool_calls[0]["name"], "search") params = json.loads(tool_calls[0]["parameters"]) self.assertEqual(params["query"], "nested object") self.assertEqual(params["priority"], "low") def test_glm47_detector_streaming_with_complex_schema(self): """Test GLM47 detector streaming with complex schema.""" chunks = [ "search", "querynested object", "prioritylow", "", ] tool_calls = [] for chunk in chunks: result = self.glm47_detector.parse_streaming_increment( chunk, self.tools_with_complex_schema ) for tool_call_chunk in result.calls: if ( hasattr(tool_call_chunk, "tool_index") and tool_call_chunk.tool_index is not None ): while len(tool_calls) <= tool_call_chunk.tool_index: tool_calls.append({"name": "", "parameters": ""}) tc = tool_calls[tool_call_chunk.tool_index] if tool_call_chunk.name: tc["name"] = tool_call_chunk.name if tool_call_chunk.parameters: tc["parameters"] += tool_call_chunk.parameters self.assertEqual(len(tool_calls), 1) self.assertEqual(tool_calls[0]["name"], "search") params = json.loads(tool_calls[0]["parameters"]) self.assertEqual(params["query"], "nested object") self.assertEqual(params["priority"], "low") def test_type_inference_issue_reproduction(self): """Reproduce the issue where complex JSON schemas are not properly handled.""" # This test demonstrates the current limitations complex_tools = [ Tool( type="function", function=Function( name="complex_function", parameters={ "type": "object", "properties": { "complex_param": { "anyOf": [ {"type": "string"}, { "type": "object", "properties": {"value": {"type": "string"}}, }, ] }, "enum_param": {"enum": ["option1", "option2", "option3"]}, }, }, ), ) ] # Test that get_argument_type returns appropriate types for complex schemas anyof_result = get_argument_type( "complex_function", "complex_param", complex_tools ) enum_result = get_argument_type("complex_function", "enum_param", complex_tools) # Verify complex schema types are correctly inferred self.assertEqual(anyof_result, "string") # anyOf prioritizes string type self.assertEqual(enum_result, "string") # enum values are strings def test_expected_behavior_for_complex_schemas(self): """Test cases that should work but currently fail - demonstrating the issue.""" # This test shows what the behavior SHOULD be after the fix complex_tools = [ Tool( type="function", function=Function( name="complex_function", parameters={ "type": "object", "properties": { "complex_param": { "anyOf": [ {"type": "string"}, { "type": "object", "properties": {"value": {"type": "string"}}, }, ] }, "enum_param": {"enum": ["option1", "option2", "option3"]}, "oneof_param": { "oneOf": [{"type": "string"}, {"type": "number"}] }, "allof_param": { "allOf": [ {"type": "object"}, {"properties": {"timeout": {"type": "number"}}}, ] }, }, }, ), ) ] # These assertions represent the EXPECTED behavior after implementing RFC improvements # Currently they will fail, demonstrating the issue anyof_result = get_argument_type( "complex_function", "complex_param", complex_tools ) enum_result = get_argument_type("complex_function", "enum_param", complex_tools) oneof_result = get_argument_type( "complex_function", "oneof_param", complex_tools ) allof_result = get_argument_type( "complex_function", "allof_param", complex_tools ) # These should pass after implementing the RFC improvements, but will currently fail # This demonstrates the issue exists self.assertIsNotNone( anyof_result, "anyOf should return a type after RFC implementation" ) self.assertEqual( enum_result, "string", "enum should return 'string' type after RFC implementation", ) self.assertIsNotNone( oneof_result, "oneOf should return a type after RFC implementation" ) self.assertIsNotNone( allof_result, "allOf should return a type after RFC implementation" ) def test_complex_schema_type_inference_scenarios(self): """Test various complex schema scenarios mentioned in the RFC.""" # Create tools with different complex schema structures complex_schema_tools = [ Tool( type="function", function=Function( name="search_complex", parameters={ "type": "object", "properties": { # anyOf example - parameter can be string or object "query": { "description": "Search query, can be a string or a complex object", "anyOf": [ {"type": "string"}, { "type": "object", "properties": { "text": {"type": "string"}, "filters": {"type": "object"}, }, }, ], }, # oneOf example - parameter must be one of the specified types "priority": { "oneOf": [{"type": "string"}, {"type": "integer"}] }, # enum example - parameter must be one of the enum values "category": {"enum": ["news", "sports", "tech"]}, # allOf example - parameter must satisfy all schemas "config": { "allOf": [ {"type": "object"}, {"properties": {"timeout": {"type": "number"}}}, ] }, # Type array example "tags": {"type": ["string", "null"]}, }, }, ), ), Tool( type="function", function=Function( name="get_data", parameters={ "type": "object", "properties": { # Complex nested anyOf "input": { "anyOf": [ {"type": "string"}, {"type": "number"}, { "type": "object", "properties": { "type": {"type": "string"}, "value": {}, }, }, ] } }, }, ), ), ] # Test each complex type scenario query_type = get_argument_type("search_complex", "query", complex_schema_tools) priority_type = get_argument_type( "search_complex", "priority", complex_schema_tools ) category_type = get_argument_type( "search_complex", "category", complex_schema_tools ) config_type = get_argument_type( "search_complex", "config", complex_schema_tools ) tags_type = get_argument_type("search_complex", "tags", complex_schema_tools) input_type = get_argument_type("get_data", "input", complex_schema_tools) # All of these should return appropriate types according to RFC self.assertEqual(query_type, "string") # anyOf: string | object -> string self.assertEqual(priority_type, "string") # oneOf: string | integer -> string self.assertEqual( category_type, "string" ) # enum: ["news", "sports", "tech"] -> string self.assertEqual(config_type, "object") # allOf with object -> object self.assertEqual( tags_type, "string" ) # type array: ["string", "null"] -> string self.assertEqual( input_type, "string" ) # nested anyOf: string | number | object -> string def test_glm4_detector_type_handling_with_complex_schema(self): """Test how GLM4 detector handles type inference for complex schemas in practice.""" complex_tools = [ Tool( type="function", function=Function( name="complex_search", parameters={ "type": "object", "properties": { "query": { "anyOf": [ {"type": "string"}, { "type": "object", "properties": {"text": {"type": "string"}}, }, ] }, "category": {"enum": ["tech", "news", "sports"]}, }, }, ), ) ] # Test with string value for anyOf parameter text = ( "complex_search\n" "query\ntest search\n" "category\ntech\n" "" ) result = self.glm4_detector.detect_and_parse(text, complex_tools) self.assertEqual(len(result.calls), 1) self.assertEqual(result.calls[0].name, "complex_search") params = json.loads(result.calls[0].parameters) self.assertEqual(params["query"], "test search") self.assertEqual(params["category"], "tech") def test_glm47_detector_type_handling_with_complex_schema(self): """Test how GLM47 detector handles type inference for complex schemas in practice.""" complex_tools = [ Tool( type="function", function=Function( name="complex_search", parameters={ "type": "object", "properties": { "query": { "anyOf": [ {"type": "string"}, { "type": "object", "properties": {"text": {"type": "string"}}, }, ] }, "category": {"enum": ["tech", "news", "sports"]}, }, }, ), ) ] # Test with string value for anyOf parameter text = ( "complex_search" "querytest search" "categorytech" "" ) result = self.glm47_detector.detect_and_parse(text, complex_tools) self.assertEqual(len(result.calls), 1) self.assertEqual(result.calls[0].name, "complex_search") params = json.loads(result.calls[0].parameters) self.assertEqual(params["query"], "test search") self.assertEqual(params["category"], "tech") def test_streaming_with_complex_schema_type_inference(self): """Test streaming behavior with complex schema type inference.""" complex_tools = [ Tool( type="function", function=Function( name="stream_test", parameters={ "type": "object", "properties": { "data": { "anyOf": [ {"type": "string"}, { "type": "object", "properties": {"value": {"type": "string"}}, }, ] }, "status": {"enum": ["active", "inactive"]}, }, }, ), ) ] # Test GLM4 detector streaming chunks = [ "stream_test\n", "data\nnested data\n", "status\nactive\n", "", ] tool_calls = [] for chunk in chunks: result = self.glm4_detector.parse_streaming_increment(chunk, complex_tools) for tool_call_chunk in result.calls: if ( hasattr(tool_call_chunk, "tool_index") and tool_call_chunk.tool_index is not None ): while len(tool_calls) <= tool_call_chunk.tool_index: tool_calls.append({"name": "", "parameters": ""}) tc = tool_calls[tool_call_chunk.tool_index] if tool_call_chunk.name: tc["name"] = tool_call_chunk.name if tool_call_chunk.parameters: tc["parameters"] += tool_call_chunk.parameters self.assertEqual(len(tool_calls), 1) self.assertEqual(tool_calls[0]["name"], "stream_test") params = json.loads(tool_calls[0]["parameters"]) self.assertEqual(params["data"], "nested data") self.assertEqual(params["status"], "active") def test_streaming_with_complex_schema_type_inference_glm47(self): """Test GLM47 streaming behavior with complex schema type inference.""" complex_tools = [ Tool( type="function", function=Function( name="stream_test", parameters={ "type": "object", "properties": { "data": { "anyOf": [ {"type": "string"}, { "type": "object", "properties": {"value": {"type": "string"}}, }, ] }, "status": {"enum": ["active", "inactive"]}, }, }, ), ) ] # Test GLM47 detector streaming chunks = [ "stream_test", "datanested data", "statusactive", "", ] tool_calls = [] for chunk in chunks: result = self.glm47_detector.parse_streaming_increment(chunk, complex_tools) for tool_call_chunk in result.calls: if ( hasattr(tool_call_chunk, "tool_index") and tool_call_chunk.tool_index is not None ): while len(tool_calls) <= tool_call_chunk.tool_index: tool_calls.append({"name": "", "parameters": ""}) tc = tool_calls[tool_call_chunk.tool_index] if tool_call_chunk.name: tc["name"] = tool_call_chunk.name if tool_call_chunk.parameters: tc["parameters"] += tool_call_chunk.parameters self.assertEqual(len(tool_calls), 1) self.assertEqual(tool_calls[0]["name"], "stream_test") params = json.loads(tool_calls[0]["parameters"]) self.assertEqual(params["data"], "nested data") self.assertEqual(params["status"], "active") if __name__ == "__main__": unittest.main()