From 9bd2c1b30dbe593eb1408875b0e09613354e0f41 Mon Sep 17 00:00:00 2001 From: Retloldin Date: Thu, 10 Sep 2026 12:40:35 +0200 Subject: [PATCH 1/4] fix(qwen3_coder): resolve JSON Schema refs and union types in tool arguments --- .../freetoken/server/function_call_parser.py | 85 +++++++++++++++++-- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/python/freetoken/server/function_call_parser.py b/python/freetoken/server/function_call_parser.py index 0804dcc3f..fee959639 100644 --- a/python/freetoken/server/function_call_parser.py +++ b/python/freetoken/server/function_call_parser.py @@ -347,15 +347,84 @@ def _ends_with_partial_token(self, buffer: str, bot_token: str) -> int: return i return 0 + def _resolve_local_ref(self, schema: Dict, root_schema: Dict) -> Dict: + """Resolve local refs such as #/$defs/Foo.""" + if not isinstance(schema, dict): + return schema + ref = schema.get("$ref") + if not isinstance(ref, str) or not ref.startswith("#/"): + return schema + node = root_schema + try: + for part in ref[2:].split("/"): + part = part.replace("~1", "/").replace("~0", "~") + node = node[part] + except (KeyError, TypeError): + return schema + if not isinstance(node, dict): + return schema + merged = dict(node) + merged.update({k: v for k, v in schema.items() if k != "$ref"}) + if "$ref" in merged: + return self._resolve_local_ref(merged, root_schema) + return merged + + def _normalize_param_schema(self, schema: Dict, root_schema: Dict) -> Dict: + """Resolve the schema parts needed for tool argument typing.""" + if not isinstance(schema, dict): + return {"type": "string"} + schema = self._resolve_local_ref(schema, root_schema) + param_type = schema.get("type") + if isinstance(param_type, list): + non_null = [t for t in param_type if t != "null"] + result = dict(schema) + result["type"] = non_null[0] if len(non_null) == 1 else "loose" + return result + if isinstance(param_type, str): + return schema + for keyword in ("oneOf", "anyOf"): + branches = schema.get(keyword) + if not isinstance(branches, list): + continue + types = [] + for branch in branches: + if not isinstance(branch, dict): + continue + branch = self._resolve_local_ref(branch, root_schema) + branch_type = branch.get("type") + if isinstance(branch_type, str) and branch_type != "null": + types.append(branch_type) + types = list(dict.fromkeys(types)) + result = dict(schema) + result["type"] = types[0] if len(types) == 1 else "loose" + return result + if isinstance(schema.get("properties"), dict): + result = dict(schema) + result["type"] = "object" + return result + if isinstance(schema.get("items"), (dict, list)): + result = dict(schema) + result["type"] = "array" + return result + result = dict(schema) + result["type"] = "loose" + return result + def _get_param_config(self, func_name: str, tools: List[Tool]) -> Dict: - """Extract the parameter properties (JSON schema) for one tool.""" + """Extract and normalize parameter properties for one tool.""" for tool in tools: - if tool.function.name == func_name and tool.function.parameters: - params = tool.function.parameters - if isinstance(params, dict) and "properties" in params: - return params["properties"] - elif isinstance(params, dict): - return params + if tool.function.name != func_name or not tool.function.parameters: + continue + params = tool.function.parameters + if not isinstance(params, dict): + return {} + properties = params.get("properties") + if not isinstance(properties, dict): + return params + return { + name: self._normalize_param_schema(prop, params) + for name, prop in properties.items() + } return {} def _convert_param_value(self, value: str, param_name: str, param_config: Dict, func_name: str) -> Any: @@ -392,6 +461,8 @@ def _convert_param_value(self, value: str, param_name: str, param_config: Dict, return ast.literal_eval(value) except (ValueError, SyntaxError, TypeError): return value + elif param_type == "loose": + return _parse_loose_json_value(value) return value def _schema_param_type(self, param_name: str, param_config: Dict, missing: str = "string") -> str: From 5af1e400e65c9527a102bf706f69d54116ea9c39 Mon Sep 17 00:00:00 2001 From: Retloldin Date: Fri, 11 Sep 2026 13:06:29 +0200 Subject: [PATCH 2/4] fix(server): guard against cyclic JSON Schema refs --- python/freetoken/server/function_call_parser.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/python/freetoken/server/function_call_parser.py b/python/freetoken/server/function_call_parser.py index fee959639..23730a0c4 100644 --- a/python/freetoken/server/function_call_parser.py +++ b/python/freetoken/server/function_call_parser.py @@ -347,13 +347,24 @@ def _ends_with_partial_token(self, buffer: str, bot_token: str) -> int: return i return 0 - def _resolve_local_ref(self, schema: Dict, root_schema: Dict) -> Dict: - """Resolve local refs such as #/$defs/Foo.""" + def _resolve_local_ref(self, schema: Dict, root_schema: Dict, _seen: Optional[set[str]] = None,) -> Dict: + """ + Resolve local JSON Schema references. + + Unresolvable or cyclic references are returned unresolved. Their values + therefore follow the parser's existing loose/untyped conversion behavior. + """ if not isinstance(schema, dict): return schema ref = schema.get("$ref") if not isinstance(ref, str) or not ref.startswith("#/"): return schema + if _seen is None: + _seen = set() + if ref in _seen: + # Cyclic ref: stop resolution and preserve the unresolved schema. + return schema + _seen.add(ref) node = root_schema try: for part in ref[2:].split("/"): @@ -366,7 +377,7 @@ def _resolve_local_ref(self, schema: Dict, root_schema: Dict) -> Dict: merged = dict(node) merged.update({k: v for k, v in schema.items() if k != "$ref"}) if "$ref" in merged: - return self._resolve_local_ref(merged, root_schema) + return self._resolve_local_ref(merged, root_schema, _seen) return merged def _normalize_param_schema(self, schema: Dict, root_schema: Dict) -> Dict: From 6266a5666b30e9d1a922f12caf73e8cc924b262d Mon Sep 17 00:00:00 2001 From: Retloldin Date: Fri, 11 Sep 2026 13:09:16 +0200 Subject: [PATCH 3/4] fix(server): resolve nested JSON Schema refs in MiniMax M3 --- .../freetoken/server/function_call_parser.py | 51 ++++++++++++------- 1 file changed, 33 insertions(+), 18 deletions(-) diff --git a/python/freetoken/server/function_call_parser.py b/python/freetoken/server/function_call_parser.py index 23730a0c4..e54362400 100644 --- a/python/freetoken/server/function_call_parser.py +++ b/python/freetoken/server/function_call_parser.py @@ -421,23 +421,28 @@ def _normalize_param_schema(self, schema: Dict, root_schema: Dict) -> Dict: result["type"] = "loose" return result - def _get_param_config(self, func_name: str, tools: List[Tool]) -> Dict: - """Extract and normalize parameter properties for one tool.""" + def _get_tool_schema(self, func_name: str, tools: List[Tool]) -> Dict: + """Return the root JSON schema for one tool.""" for tool in tools: if tool.function.name != func_name or not tool.function.parameters: continue params = tool.function.parameters - if not isinstance(params, dict): - return {} - properties = params.get("properties") - if not isinstance(properties, dict): - return params - return { - name: self._normalize_param_schema(prop, params) - for name, prop in properties.items() - } + return params if isinstance(params, dict) else {} return {} + def _get_param_config(self, func_name: str, tools: List[Tool]) -> Dict: + """Extract and normalize parameter properties for one tool.""" + params = self._get_tool_schema(func_name, tools) + if not params: + return {} + properties = params.get("properties") + if not isinstance(properties, dict): + return params + return { + name: self._normalize_param_schema(prop, params) + for name, prop in properties.items() + } + def _convert_param_value(self, value: str, param_name: str, param_config: Dict, func_name: str) -> Any: """Convert parameter value based on schema type. Safe alternative to eval().""" if value.lower() == "null": @@ -2673,18 +2678,23 @@ def _union_leaf(self, raw: str, subs: list) -> Any: return v return raw - def _nested_value(self, raw: str, schema: Any = None) -> Any: + def _nested_value(self, raw: str, schema: Any = None, root_schema: Any = None) -> Any: + if isinstance(schema, dict) and isinstance(root_schema, dict): + schema = self._normalize_param_schema(schema, root_schema) items, stray = self._scan_elements(raw) if not items: return self._typed_leaf(raw, schema) - return self._structure(items, schema, stray=stray) + return self._structure(items, schema, root_schema=root_schema, stray=stray) - def _structure(self, items: List[tuple], schema: Any = None, stray: str = "") -> Any: + def _structure(self, items: List[tuple], schema: Any = None, root_schema: Any = None, stray: str = "") -> Any: """Composite value from scanned elements, threading the JSON schema down (``properties`` / ``items`` / ``additionalProperties``). Only ```` children render as a bare array -- the template's array convention; repeated siblings under any other tag stay an object with an array-valued key. Mixed text+children keeps the text under ``"$text"``.""" + if isinstance(schema, dict) and isinstance(root_schema, dict): + schema = self._normalize_param_schema(schema, root_schema) + props = schema.get("properties") if isinstance(schema, dict) else None item_schema = schema.get("items") if isinstance(schema, dict) else None @@ -2694,11 +2704,13 @@ def _sub_schema(key: str) -> Any: ap = schema.get("additionalProperties") if isinstance(ap, dict): sub = ap + if isinstance(sub, dict) and isinstance(root_schema, dict): + sub = self._normalize_param_schema(sub, root_schema) return sub names = {k for k, _ in items} if names == {"item"}: - return [self._nested_value(raw, item_schema) for _, raw in items] + return [self._nested_value(raw, item_schema, root_schema=root_schema) for _, raw in items] counts: Dict[str, int] = {} for k, _ in items: counts[k] = counts.get(k, 0) + 1 @@ -2714,7 +2726,7 @@ def _sub_schema(key: str) -> Any: and str(sub.get("type", "")).lower() == "array" ): sub = sub.get("items") - value = self._nested_value(raw, sub) + value = self._nested_value(raw, sub, root_schema=root_schema) if key in out: prev = out[key] out[key] = (prev if isinstance(prev, list) else [prev]) + [value] @@ -2725,16 +2737,19 @@ def _sub_schema(key: str) -> Any: return out def _args_from_items( - self, func_name: str, items: List[tuple], tools: List[Tool] + self, func_name: str, items: List[tuple], tools: List[Tool], ) -> Dict: + root_schema = self._get_tool_schema(func_name, tools) config = self._get_param_config(func_name, tools) args: Dict[str, Any] = {} for key, raw in items: prop = config.get(key) if isinstance(config, dict) and key in config else None nested, stray = self._scan_elements(raw) if nested: - value: Any = self._structure(nested, prop, stray=stray) + value: Any = self._structure(nested, prop, root_schema=root_schema, stray=stray) else: + if isinstance(prop, dict) and isinstance(root_schema, dict): + prop = self._normalize_param_schema(prop, root_schema) value = self._typed_leaf(raw, prop) if key in args: prev = args[key] From 2ab3673ac205455c7bb24f605733035a43bcadb8 Mon Sep 17 00:00:00 2001 From: Retloldin Date: Fri, 11 Sep 2026 14:37:43 +0200 Subject: [PATCH 4/4] test(server): cover JSON Schema ref resolution in tool argument parsing --- ..._function_call_parser_schema_resolution.py | 703 ++++++++++++++++++ 1 file changed, 703 insertions(+) create mode 100644 tests/server/test_function_call_parser_schema_resolution.py diff --git a/tests/server/test_function_call_parser_schema_resolution.py b/tests/server/test_function_call_parser_schema_resolution.py new file mode 100644 index 000000000..fbc0fe270 --- /dev/null +++ b/tests/server/test_function_call_parser_schema_resolution.py @@ -0,0 +1,703 @@ +"""JSON Schema resolution in schema-aware tool argument parsing +(server/function_call_parser.py): $ref chains, unions and implicit containers, +plus MiniMax-M3's recursive nesting -- one-shot and streaming. +""" + +from __future__ import annotations + +import json + +import pytest + +from freetoken.server.function_call_parser import Function, FunctionCallParser, Tool + + +# Formats that route parameter values through _convert_param_value(); the rest +# parse arguments from their own JSON grammar and ignore the declared schema. +SCHEMA_AWARE_PARSERS = [ + "glm47", + "qwen3_coder", + "minimax", + "minimax_m3", + "muse_glimmer", +] + +MINIMAX_M3_NS = "]<]minimax[>[" + + +def _tool(prop_schema: dict, root_extra: dict | None = None, *, param_name: str = "limit") -> list[Tool]: + parameters = { + "type": "object", + "properties": { + param_name: prop_schema, + }, + } + + if root_extra: + parameters.update(root_extra) + + return [ + Tool( + function=Function( + name="schema_test", + parameters=parameters, + ) + ) + ] + + +def _wire(parser_name: str, raw: str, *, param_name: str = "limit") -> str: + if parser_name == "qwen3_coder": + return ( + "" + "" + f"{raw}" + "" + "" + ) + + if parser_name == "glm47": + return ( + "schema_test" + f"{param_name}" + f"{raw}" + "" + ) + + if parser_name == "minimax": + return ( + "" + '' + f'{raw}' + "" + "" + ) + + if parser_name == "minimax_m3": + ns = MINIMAX_M3_NS + return ( + f"{ns}\n" + f'{ns}' + f"{ns}<{param_name}>{raw}{ns}" + f"{ns}\n" + f"{ns}" + ) + + if parser_name == "muse_glimmer": + return ( + "<|start|>assistant to=schema_test<|message|>" + "\n" + '\n' + f'{raw}\n' + "\n" + "" + "<|eot|>" + ) + + raise AssertionError(f"Missing wire fixture for parser {parser_name!r}") + + +def _assemble_streamed_calls(calls) -> list[tuple[str, dict]]: + """Reassemble streamed calls the way a client does: a fragment naming a tool + opens a call, the argument fragments after it concatenate into its JSON.""" + assembled: list[list] = [] + + for call in calls: + if call.name is not None: + assembled.append([call.name, []]) + + if call.parameters: + assert assembled, f"argument fragment before any call name: {call!r}" + assembled[-1][1].append(call.parameters) + + return [(name, json.loads("".join(parts) or "{}")) for name, parts in assembled] + + +def _parse_args(parser_name: str, prop_schema: dict, root_extra: dict | None, raw: str, *, streaming: bool, param_name: str = "limit") -> dict: + tools = _tool(prop_schema, root_extra, param_name=param_name) + text = _wire(parser_name, raw, param_name=param_name) + + parser = FunctionCallParser(tools, tool_call_parser=parser_name) + + if not streaming: + result = parser.parse_non_stream(text) + + assert len(result.calls) == 1, result + assert result.calls[0].name == "schema_test" + + return json.loads(result.calls[0].parameters) + + calls = [] + + # Small chunks deliberately split XML/control markers and parameter values. + for i in range(0, len(text), 7): + _, emitted = parser.parse_stream_chunk(text[i : i + 7]) + calls.extend(emitted) + + # Drain any wire-order-deferred output. + for _ in range(4): + normal, emitted = parser.parse_stream_chunk("") + calls.extend(emitted) + + if not normal and not emitted: + break + + # End-of-stream hooks in the serving layer's order (generation.py): finalize + # closes a call cut off mid-arguments, finish releases held-back text. + calls.extend(parser.finalize_stream()) + parser.finish_stream() + + assembled = _assemble_streamed_calls(calls) + + assert len(assembled) == 1, (parser_name, calls) + assert assembled[0][0] == "schema_test" + + return assembled[0][1] + + +SCHEMA_CASES = [ + # Existing behaviour: direct concrete types. + pytest.param( + {"type": "integer"}, + None, + "5", + 5, + id="direct-integer", + ), + pytest.param( + {"type": "string"}, + None, + "5", + "5", + id="direct-string-preserved", + ), + + # Single local $ref. + pytest.param( + {"$ref": "#/$defs/Limit"}, + { + "$defs": { + "Limit": {"type": "integer"}, + } + }, + "5", + 5, + id="ref-integer", + ), + + # Chained $ref. + pytest.param( + {"$ref": "#/$defs/A"}, + { + "$defs": { + "A": {"$ref": "#/$defs/B"}, + "B": {"type": "integer"}, + } + }, + "5", + 5, + id="ref-chain", + ), + + # Draft-07 style definitions should work too: the resolver accepts generic + # local JSON pointers, not only $defs. + pytest.param( + {"$ref": "#/definitions/Limit"}, + { + "definitions": { + "Limit": {"type": "integer"}, + } + }, + "5", + 5, + id="legacy-definitions-ref", + ), + + # JSON Pointer escaping: ~1 => / and ~0 => ~. + pytest.param( + {"$ref": "#/$defs/A~1B~0C"}, + { + "$defs": { + "A/B~C": {"type": "integer"}, + } + }, + "5", + 5, + id="json-pointer-escaping", + ), + + # oneOf / anyOf concrete type. + pytest.param( + { + "oneOf": [ + {"type": "integer"}, + ] + }, + None, + "5", + 5, + id="oneof-integer", + ), + pytest.param( + { + "anyOf": [ + {"type": "integer"}, + ] + }, + None, + "5", + 5, + id="anyof-integer", + ), + + # Nullable unions. + pytest.param( + { + "oneOf": [ + {"type": "integer"}, + {"type": "null"}, + ] + }, + None, + "5", + 5, + id="oneof-nullable-integer", + ), + pytest.param( + { + "anyOf": [ + {"type": "integer"}, + {"type": "null"}, + ] + }, + None, + "5", + 5, + id="anyof-nullable-integer", + ), + pytest.param( + { + "type": ["integer", "null"], + }, + None, + "5", + 5, + id="type-array-nullable-integer", + ), + + # A union member may itself be a $ref. + pytest.param( + { + "oneOf": [ + {"$ref": "#/$defs/Limit"}, + {"type": "null"}, + ] + }, + { + "$defs": { + "Limit": {"type": "integer"}, + } + }, + "5", + 5, + id="oneof-ref-nullable", + ), + + # Referenced structured values must remain JSON structures rather than + # escaped strings. + pytest.param( + {"$ref": "#/$defs/Ids"}, + { + "$defs": { + "Ids": { + "type": "array", + "items": {"type": "integer"}, + } + } + }, + "[1, 2, 3]", + [1, 2, 3], + id="ref-array", + ), + pytest.param( + {"$ref": "#/$defs/Options"}, + { + "$defs": { + "Options": { + "type": "object", + "properties": { + "limit": {"type": "integer"}, + }, + } + } + }, + '{"limit": 5}', + {"limit": 5}, + id="ref-object", + ), + + # JSON Schema does not require an explicit "type" if structure already + # establishes the effective container kind. + pytest.param( + { + "properties": { + "limit": {"type": "integer"}, + } + }, + None, + '{"limit": 5}', + {"limit": 5}, + id="implicit-object", + ), + pytest.param( + { + "items": { + "type": "integer", + } + }, + None, + "[1, 2]", + [1, 2], + id="implicit-array", + ), + + # The PR intentionally uses loose JSON parsing when no single concrete type + # can be inferred. + pytest.param( + { + "oneOf": [ + {"type": "integer"}, + {"type": "string"}, + ] + }, + None, + "5", + 5, + id="ambiguous-union-loose-json", + ), +] + + +@pytest.mark.parametrize("parser_name", SCHEMA_AWARE_PARSERS) +@pytest.mark.parametrize("streaming", [False, True], ids=["one-shot", "streaming"]) +@pytest.mark.parametrize( + ("prop_schema", "root_extra", "raw", "expected"), + SCHEMA_CASES, +) +def test_schema_aware_parsers_resolve_indirect_schema_types( + parser_name, + streaming, + prop_schema, + root_extra, + raw, + expected, +): + args = _parse_args( + parser_name, + prop_schema, + root_extra, + raw, + streaming=streaming, + ) + + assert args == {"limit": expected} + + +# --------------------------------------------------------------------------- +# Broken / recursive references must never take the request path down. +# --------------------------------------------------------------------------- +REF_EDGE_CASES = [ + pytest.param( + {"$ref": "#/$defs/Missing"}, + None, + id="dangling-local-ref", + ), + pytest.param( + {"$ref": "https://example.invalid/schema.json#/Limit"}, + None, + id="external-ref", + ), + pytest.param( + {"$ref": "#/$defs/A"}, + { + "$defs": { + "A": "not-a-schema", + } + }, + id="ref-to-non-object-def", + ), + pytest.param( + {"$ref": "#/$defs/A/name"}, + { + "$defs": { + "A": "not-a-container", + } + }, + id="ref-through-non-object-def", + ), + pytest.param( + {"$ref": "#/$defs/A"}, + { + "$defs": { + "A": {"$ref": "#/$defs/A"}, + } + }, + id="self-referential-ref", + ), + pytest.param( + {"$ref": "#/$defs/A"}, + { + "$defs": { + "A": {"$ref": "#/$defs/B"}, + "B": {"$ref": "#/$defs/A"}, + } + }, + id="mutually-recursive-ref", + ), +] + + +@pytest.mark.parametrize("parser_name", SCHEMA_AWARE_PARSERS) +@pytest.mark.parametrize("streaming", [False, True], ids=["one-shot", "streaming"]) +@pytest.mark.parametrize( + ("prop_schema", "root_extra"), + REF_EDGE_CASES, +) +def test_schema_ref_edge_cases_terminate_without_crashing(parser_name, streaming, prop_schema, root_extra): + args = _parse_args( + parser_name, + prop_schema, + root_extra, + "5", + streaming=streaming, + ) + + # An unresolvable ref leaves the parameter untyped: the same loose JSON + # fallback as an ambiguous union, so "5" -> 5 and never a dropped parameter. + assert args == {"limit": 5} + + +# --------------------------------------------------------------------------- +# Compatibility guards +# --------------------------------------------------------------------------- +@pytest.mark.parametrize("parser_name", SCHEMA_AWARE_PARSERS) +@pytest.mark.parametrize("streaming", [False, True], ids=["one-shot", "streaming"]) +def test_explicit_string_schema_never_loose_parses_json_literals(parser_name, streaming): + args = _parse_args( + parser_name, + {"type": "string"}, + None, + "123", + streaming=streaming, + ) + + assert args == {"limit": "123"} + + +@pytest.mark.parametrize("parser_name", SCHEMA_AWARE_PARSERS) +@pytest.mark.parametrize("streaming", [False, True], ids=["one-shot", "streaming"]) +def test_nullable_string_schema_preserves_numeric_looking_string(parser_name, streaming): + args = _parse_args( + parser_name, + { + "anyOf": [ + {"type": "string"}, + {"type": "null"}, + ] + }, + None, + "123", + streaming=streaming, + ) + + assert args == {"limit": "123"} + + +# --------------------------------------------------------------------------- +# MiniMax-M3 threads the schema through nested XML +# --------------------------------------------------------------------------- +def _m3_tools(properties: dict, **root_extra) -> list[Tool]: + """One ``schema_test`` tool: ``properties`` plus any extra root keywords.""" + return [ + Tool( + function=Function( + name="schema_test", + parameters={"type": "object", "properties": properties, **root_extra}, + ) + ) + ] + + +def _m3_call(body: str) -> str: + """Wrap an invoke body in MiniMax-M3's namespace-delimited wire form.""" + ns = MINIMAX_M3_NS + + return ( + f"{ns}\n" + f'{ns}' + f"{body}" + f"{ns}\n" + f"{ns}" + ) + + +_M3_NODE_DEFS = { + "$defs": { + "Node": { + "type": "object", + "properties": { + "code": {"type": "string"}, + "count": {"type": "integer"}, + "child": {"$ref": "#/$defs/Node"}, + }, + } + } +} + + +def _minimax_m3_recursive_tools() -> list[Tool]: + return _m3_tools({"node": {"$ref": "#/$defs/Node"}}, **_M3_NODE_DEFS) + + +def _minimax_m3_recursive_wire() -> str: + ns = MINIMAX_M3_NS + + return _m3_call( + f"{ns}" + f"{ns}1{ns}" + f"{ns}2{ns}" + f"{ns}" + f"{ns}5{ns}" + f"{ns}6{ns}" + f"{ns}" + f"{ns}" + ) + + +def _parse_minimax_m3(tools: list[Tool], text: str, *, streaming: bool) -> dict: + """Parse one MiniMax-M3 call through whichever path is under test.""" + parser = FunctionCallParser(tools, tool_call_parser="minimax_m3") + + if not streaming: + result = parser.parse_non_stream(text) + + assert len(result.calls) == 1, result + assert result.calls[0].name == "schema_test" + + return json.loads(result.calls[0].parameters) + + calls = [] + + # Small chunks deliberately split NS markers and element boundaries. M3 emits + # each call in one piece at its closing marker, so no extra drain is needed. + for i in range(0, len(text), 7): + _, emitted = parser.parse_stream_chunk(text[i : i + 7]) + calls.extend(emitted) + + calls.extend(parser.finalize_stream()) + parser.finish_stream() + + assembled = _assemble_streamed_calls(calls) + + assert len(assembled) == 1, (calls,) + assert assembled[0][0] == "schema_test" + + return assembled[0][1] + + +@pytest.mark.parametrize("streaming", [False, True], ids=["one-shot", "streaming"]) +def test_minimax_m3_nested_ref_keeps_nested_schema_typing(streaming): + args = _parse_minimax_m3( + _minimax_m3_recursive_tools(), + _minimax_m3_recursive_wire(), + streaming=streaming, + ) + + assert args == { + "node": { + "code": "1", + "count": 2, + "child": { + "code": "5", + "count": 6, + }, + } + } + + +# Below the top level: an array's "items" schema and an implicitly-array nested +# property both used to lose typing and arrive as the verbatim string. +MINIMAX_M3_NESTED_CASES = [ + pytest.param( + _m3_tools( + { + "ids": { + "type": "array", + "items": {"$ref": "#/$defs/Id"}, + } + }, + **{"$defs": {"Id": {"type": "integer"}}}, + ), + _m3_call( + f"{MINIMAX_M3_NS}" + f"{MINIMAX_M3_NS}1{MINIMAX_M3_NS}" + f"{MINIMAX_M3_NS}2{MINIMAX_M3_NS}" + f"{MINIMAX_M3_NS}" + ), + {"ids": [1, 2]}, + id="array-items-ref", + ), + pytest.param( + _m3_tools( + { + "payload": { + "type": "object", + "properties": { + # No explicit "type": only the structure says array. + "cell": {"items": {"type": "integer"}}, + }, + } + } + ), + _m3_call( + f"{MINIMAX_M3_NS}" + f"{MINIMAX_M3_NS}1{MINIMAX_M3_NS}" + f"{MINIMAX_M3_NS}2{MINIMAX_M3_NS}" + f"{MINIMAX_M3_NS}" + ), + {"payload": {"cell": [1, 2]}}, + id="repeated-key-implicit-array", + ), +] + + +@pytest.mark.parametrize("streaming", [False, True], ids=["one-shot", "streaming"]) +@pytest.mark.parametrize(("tools", "text", "expected"), MINIMAX_M3_NESTED_CASES) +def test_minimax_m3_nested_schema_typing_below_the_top_level(tools, text, expected, streaming): + assert _parse_minimax_m3(tools, text, streaming=streaming) == expected + + +# --------------------------------------------------------------------------- +# Frontier: only MiniMax-M3 recurses into nested markup +# --------------------------------------------------------------------------- +# The other formats read a parameter value as opaque text; pinning that makes +# teaching them to recurse a deliberate change. +FLAT_SCHEMA_AWARE_PARSERS = [name for name in SCHEMA_AWARE_PARSERS if name != "minimax_m3"] + + +@pytest.mark.parametrize("parser_name", FLAT_SCHEMA_AWARE_PARSERS) +@pytest.mark.parametrize("streaming", [False, True], ids=["one-shot", "streaming"]) +def test_nested_markup_in_parameter_value_stays_opaque_outside_minimax_m3(parser_name, streaming): + args = _parse_args( + parser_name, + { + "type": "object", + "properties": {"code": {"type": "string"}}, + }, + None, + "1", + streaming=streaming, + param_name="node", + ) + + assert args == {"node": "1"} \ No newline at end of file