| 1 | # SPDX-License-Identifier: Apache-2.0 |
| 2 | # SPDX-FileCopyrightText: Copyright contributors to the vLLM project |
| 3 | |
| 4 | import ast |
| 5 | import json |
| 6 | |
| 7 | import pytest |
| 8 | |
| 9 | from vllm.tool_parsers.utils import ( |
| 10 | UnexpectedAstError, |
| 11 | coerce_to_schema_type, |
| 12 | contains_broken_string_literal, |
| 13 | escape_ctrl_chars_in_strings, |
| 14 | escape_nested_quotes_in_strings, |
| 15 | extract_types_from_schema, |
| 16 | get_parameter_value, |
| 17 | handle_single_tool, |
| 18 | make_valid_python, |
| 19 | normalize_leading_zero_ints, |
| 20 | rename_reserved_kwargs, |
| 21 | restore_reserved_kwarg_names, |
| 22 | ) |
| 23 | |
| 24 | |
| 25 | class TestCoerceToSchemaType: |
| 26 | class TestNullHandling: |
| 27 | def test_null_converted_when_type_is_null(self): |
| 28 | assert coerce_to_schema_type("null", "null") is None |
| 29 | |
| 30 | def test_null_converted_when_null_in_type_list(self): |
| 31 | assert coerce_to_schema_type("null", ["string", "null"]) is None |
| 32 | |
| 33 | def test_null_preserved_as_string_when_type_is_string(self): |
| 34 | assert coerce_to_schema_type("null", "string") == "null" |
| 35 | |
| 36 | def test_null_case_insensitive(self): |
| 37 | assert coerce_to_schema_type("NULL", "null") is None |
| 38 | assert coerce_to_schema_type("Null", "null") is None |
| 39 | |
| 40 | def test_none_string_never_converted(self): |
| 41 | assert coerce_to_schema_type("none", "null") == "none" |
| 42 | assert coerce_to_schema_type("none", "string") == "none" |
| 43 | assert coerce_to_schema_type("none", ["string", "null"]) == "none" |
| 44 | |
| 45 | def test_nil_string_never_converted(self): |
| 46 | assert coerce_to_schema_type("nil", "string") == "nil" |
| 47 | assert coerce_to_schema_type("nil", ["string", "null"]) == "nil" |
| 48 | |
| 49 | def test_non_null_value_with_null_type(self): |
| 50 | assert coerce_to_schema_type("hello", ["null", "string"]) == "hello" |
| 51 | |
| 52 | class TestStringType: |
| 53 | def test_string_type(self): |
| 54 | assert coerce_to_schema_type("hello", "string") == "hello" |
| 55 | |
| 56 | def test_str_alias(self): |
| 57 | assert coerce_to_schema_type("hello", "str") == "hello" |
| 58 | |
| 59 | def test_text_alias(self): |
| 60 | assert coerce_to_schema_type("hello", "text") == "hello" |
| 61 | |
| 62 | def test_varchar_alias(self): |
| 63 | assert coerce_to_schema_type("hello", "varchar") == "hello" |
| 64 | |
| 65 | def test_char_alias(self): |
| 66 | assert coerce_to_schema_type("x", "char") == "x" |
| 67 | |
| 68 | def test_enum_alias(self): |
| 69 | assert coerce_to_schema_type("option_a", "enum") == "option_a" |
| 70 | |
| 71 | class TestIntegerType: |
| 72 | def test_integer_type(self): |
| 73 | assert coerce_to_schema_type("42", "integer") == 42 |
| 74 | |
| 75 | def test_int_alias(self): |
| 76 | assert coerce_to_schema_type("42", "int") == 42 |
| 77 | |
| 78 | def test_negative_integer(self): |
| 79 | assert coerce_to_schema_type("-7", "integer") == -7 |
| 80 | |
| 81 | def test_invalid_integer_fallback(self): |
| 82 | assert coerce_to_schema_type("not_a_number", "integer") == "not_a_number" |
| 83 | |
| 84 | def test_uint32_alias(self): |
| 85 | assert coerce_to_schema_type("5", "uint32") == 5 |
| 86 | |
| 87 | def test_long_alias(self): |
| 88 | assert coerce_to_schema_type("100", "long") == 100 |
| 89 | |
| 90 | class TestNumberType: |
| 91 | def test_number_type(self): |
| 92 | assert coerce_to_schema_type("3.14", "number") == 3.14 |
| 93 | |
| 94 | def test_float_alias(self): |
| 95 | assert coerce_to_schema_type("2.5", "float") == 2.5 |
| 96 | |
| 97 | def test_double_alias(self): |
| 98 | assert coerce_to_schema_type("2.5", "double") == 2.5 |
| 99 | |
| 100 | def test_whole_float_returns_int(self): |
| 101 | assert coerce_to_schema_type("5.0", "number") == 5 |
| 102 | assert isinstance(coerce_to_schema_type("5.0", "number"), int) |
| 103 | |
| 104 | def test_invalid_number_fallback(self): |
| 105 | assert coerce_to_schema_type("abc", "number") == "abc" |
| 106 | |
| 107 | class TestNonFiniteNumbers: |
| 108 | """Non-finite numeric strings must not crash and must coerce to a |
| 109 | JSON-serializable value. |
| 110 | |
| 111 | Regression: ``int(float("inf"))`` raised an uncaught ``OverflowError`` |
| 112 | (only ``ValueError``/``TypeError`` were handled), and ``"1e999"`` |
| 113 | round-tripped through ``json.loads`` to a float ``inf`` that |
| 114 | ``json.dumps`` renders as invalid JSON ``Infinity``. |
| 115 | """ |
| 116 | |
| 117 | @pytest.mark.parametrize( |
| 118 | "value", ["inf", "-inf", "Infinity", "1e999", "nan", "-nan"] |
| 119 | ) |
| 120 | def test_non_finite_number_does_not_crash(self, value): |
| 121 | # Must not raise (previously OverflowError for inf/1e999/Infinity). |
| 122 | result = coerce_to_schema_type(value, "number") |
| 123 | # Result must serialize to valid, finite JSON and round-trip. |
| 124 | assert json.loads(json.dumps(result)) == result |
| 125 | |
| 126 | @pytest.mark.parametrize("value", ["inf", "-inf", "1e999"]) |
| 127 | def test_non_finite_number_preserved_as_string(self, value): |
| 128 | assert coerce_to_schema_type(value, "number") == value |
| 129 | |
| 130 | @pytest.mark.parametrize("value", ["inf", "1e999", "Infinity"]) |
| 131 | def test_non_finite_integer_not_float_inf(self, value): |
| 132 | result = coerce_to_schema_type(value, "integer") |
| 133 | assert isinstance(result, str) |
| 134 | assert result == value |
| 135 | |
| 136 | class TestNonFiniteContainers: |
| 137 | """Non-finite floats nested in object/array values must not produce |
| 138 | invalid JSON. |
| 139 | |
| 140 | Regression: the ``object``/``array`` branch returned |
| 141 | ``json.loads(value)`` directly, so ``"[1e999]"`` became ``[inf]`` and |
| 142 | ``'{"x": Infinity}'`` became ``{"x": inf}`` -- values that |
| 143 | ``json.dumps`` later renders as invalid JSON (``Infinity``/``NaN``). |
| 144 | """ |
| 145 | |
| 146 | @pytest.mark.parametrize( |
| 147 | "value", ["[1e999]", "[1, 2, 1e999]", "[NaN]", "[-Infinity]"] |
| 148 | ) |
| 149 | def test_array_with_non_finite_preserved_as_string(self, value): |
| 150 | result = coerce_to_schema_type(value, "array") |
| 151 | assert result == value |
| 152 | assert json.loads(json.dumps(result)) == result |
| 153 | |
| 154 | @pytest.mark.parametrize( |
| 155 | "value", ['{"x": 1e999}', '{"x": Infinity}', '{"a": [1e999, 2]}'] |
| 156 | ) |
| 157 | def test_object_with_non_finite_preserved_as_string(self, value): |
| 158 | result = coerce_to_schema_type(value, "object") |
| 159 | assert result == value |
| 160 | assert json.loads(json.dumps(result)) == result |
| 161 | |
| 162 | def test_finite_array_still_coerced(self): |
| 163 | assert coerce_to_schema_type("[1, 2, 3]", "array") == [1, 2, 3] |
| 164 | |
| 165 | def test_finite_object_still_coerced(self): |
| 166 | assert coerce_to_schema_type('{"a": 1}', "object") == {"a": 1} |
| 167 | |
| 168 | def test_unknown_type_non_finite_falls_back_to_string(self): |
| 169 | # Exercises the final json.loads fallback path. |
| 170 | assert coerce_to_schema_type("1e999", "unknown_type") == "1e999" |
| 171 | |
| 172 | class TestBooleanType: |
| 173 | def test_true(self): |
| 174 | assert coerce_to_schema_type("true", "boolean") is True |
| 175 | |
| 176 | def test_false(self): |
| 177 | assert coerce_to_schema_type("false", "boolean") is False |
| 178 | |
| 179 | def test_bool_alias(self): |
| 180 | assert coerce_to_schema_type("true", "bool") is True |
| 181 | |
| 182 | def test_one_is_true(self): |
| 183 | assert coerce_to_schema_type("1", "boolean") is True |
| 184 | |
| 185 | def test_zero_is_false(self): |
| 186 | assert coerce_to_schema_type("0", "boolean") is False |
| 187 | |
| 188 | def test_invalid_boolean_fallback(self): |
| 189 | assert coerce_to_schema_type("maybe", "boolean") == "maybe" |
| 190 | |
| 191 | class TestObjectArrayType: |
| 192 | def test_object_type(self): |
| 193 | assert coerce_to_schema_type('{"a": 1}', "object") == {"a": 1} |
| 194 | |
| 195 | def test_array_type(self): |
| 196 | assert coerce_to_schema_type("[1, 2, 3]", "array") == [1, 2, 3] |
| 197 | |
| 198 | def test_invalid_json_fallback(self): |
| 199 | assert coerce_to_schema_type("not json", "object") == "not json" |
| 200 | |
| 201 | def test_dict_alias(self): |
| 202 | assert coerce_to_schema_type('{"k": "v"}', "dict") == {"k": "v"} |
| 203 | |
| 204 | def test_list_alias(self): |
| 205 | assert coerce_to_schema_type("[1]", "list") == [1] |
| 206 | |
| 207 | class TestMultiType: |
| 208 | def test_null_takes_priority_over_string(self): |
| 209 | assert coerce_to_schema_type("null", ["string", "null"]) is None |
| 210 | |
| 211 | def test_integer_tried_before_string(self): |
| 212 | assert coerce_to_schema_type("42", ["integer", "string"]) == 42 |
| 213 | |
| 214 | def test_falls_through_to_string(self): |
| 215 | assert coerce_to_schema_type("hello", ["integer", "string"]) == "hello" |
| 216 | |
| 217 | class TestFallback: |
| 218 | def test_unknown_type_returns_string(self): |
| 219 | assert coerce_to_schema_type("hello", "unknown_type") == "hello" |
| 220 | |
| 221 | def test_json_fallback_for_unknown_type(self): |
| 222 | assert coerce_to_schema_type('{"a": 1}', "unknown_type") == {"a": 1} |
| 223 | |
| 224 | @pytest.mark.parametrize("schema_type", ["string", "str", "text"]) |
| 225 | def test_string_types_preserve_value(self, schema_type): |
| 226 | assert coerce_to_schema_type("anything", schema_type) == "anything" |
| 227 | |
| 228 | def test_unrecognized_type_falls_back_to_json(self): |
| 229 | assert coerce_to_schema_type("42", "interval") == 42 |
| 230 | |
| 231 | |
| 232 | class TestExtractTypesFromSchema: |
| 233 | def test_direct_type_string(self): |
| 234 | assert extract_types_from_schema({"type": "string"}) == ["string"] |
| 235 | |
| 236 | def test_direct_type_integer(self): |
| 237 | assert extract_types_from_schema({"type": "integer"}) == ["integer"] |
| 238 | |
| 239 | def test_type_array(self): |
| 240 | result = set(extract_types_from_schema({"type": ["string", "null"]})) |
| 241 | assert result == {"string", "null"} |
| 242 | |
| 243 | def test_anyof(self): |
| 244 | schema = {"anyOf": [{"type": "object"}, {"type": "null"}]} |
| 245 | result = set(extract_types_from_schema(schema)) |
| 246 | assert result == {"object", "null"} |
| 247 | |
| 248 | def test_oneof(self): |
| 249 | schema = {"oneOf": [{"type": "integer"}, {"type": "string"}]} |
| 250 | result = set(extract_types_from_schema(schema)) |
| 251 | assert result == {"integer", "string"} |
| 252 | |
| 253 | def test_allof(self): |
| 254 | schema = {"allOf": [{"type": "object"}]} |
| 255 | assert extract_types_from_schema(schema) == ["object"] |
| 256 | |
| 257 | def test_enum_infers_types(self): |
| 258 | schema = {"enum": [1, "a", None]} |
| 259 | result = set(extract_types_from_schema(schema)) |
| 260 | assert result == {"integer", "string", "null"} |
| 261 | |
| 262 | def test_enum_with_bool(self): |
| 263 | schema = {"enum": [True, False]} |
| 264 | assert extract_types_from_schema(schema) == ["boolean"] |
| 265 | |
| 266 | def test_enum_with_float(self): |
| 267 | schema = {"enum": [1.5, 2.5]} |
| 268 | assert extract_types_from_schema(schema) == ["number"] |
| 269 | |
| 270 | def test_enum_with_list_and_dict(self): |
| 271 | schema = {"enum": [[1, 2], {"a": 1}]} |
| 272 | result = set(extract_types_from_schema(schema)) |
| 273 | assert result == {"array", "object"} |
| 274 | |
| 275 | def test_none_schema_defaults_to_string(self): |
| 276 | assert extract_types_from_schema(None) == ["string"] |
| 277 | |
| 278 | def test_non_dict_schema_defaults_to_string(self): |
| 279 | assert extract_types_from_schema("string") == ["string"] |
| 280 | |
| 281 | def test_empty_dict_defaults_to_string(self): |
| 282 | assert extract_types_from_schema({}) == ["string"] |
| 283 | |
| 284 | def test_nested_anyof(self): |
| 285 | schema = { |
| 286 | "anyOf": [ |
| 287 | {"anyOf": [{"type": "integer"}, {"type": "null"}]}, |
| 288 | {"type": "string"}, |
| 289 | ] |
| 290 | } |
| 291 | result = set(extract_types_from_schema(schema)) |
| 292 | assert result == {"integer", "null", "string"} |
| 293 | |
| 294 | |
| 295 | def _value_of(expr: str): |
| 296 | """Parse a single Python expression and run get_parameter_value on it.""" |
| 297 | return get_parameter_value(ast.parse(expr, mode="eval").body) |
| 298 | |
| 299 | |
| 300 | def _first_call(text: str) -> ast.Call: |
| 301 | """Parse ``[foo(...)]`` and return the single ast.Call node.""" |
| 302 | statement = ast.parse(text).body[0] |
| 303 | assert isinstance(statement, ast.Expr) |
| 304 | assert isinstance(statement.value, ast.List) |
| 305 | call = statement.value.elts[0] |
| 306 | assert isinstance(call, ast.Call) |
| 307 | return call |
| 308 | |
| 309 | |
| 310 | def _bare_call(text: str) -> ast.Call: |
| 311 | """Parse ``foo(...)`` (no list wrapper) and return the ast.Call node.""" |
| 312 | statement = ast.parse(text).body[0] |
| 313 | assert isinstance(statement, ast.Expr) |
| 314 | assert isinstance(statement.value, ast.Call) |
| 315 | return statement.value |
| 316 | |
| 317 | |
| 318 | def _kwarg_constant(call: ast.Call, index: int = 0): |
| 319 | """Return the constant value of the call's ``index``-th keyword arg.""" |
| 320 | value = call.keywords[index].value |
| 321 | assert isinstance(value, ast.Constant) |
| 322 | return value.value |
| 323 | |
| 324 | |
| 325 | class TestMakeValidPythonStringLiterals: |
| 326 | def test_bracket_inside_string_is_literal(self): |
| 327 | # A bracket inside a string argument must not be counted as a |
| 328 | # structural bracket. Regression: `]` inside the string popped the |
| 329 | # bracket stack and the whole call raised as mismatched. |
| 330 | text = "[exec(command='grep -F \"]\" log.txt')]" |
| 331 | assert make_valid_python(text) == (text, "") |
| 332 | |
| 333 | def test_open_bracket_inside_string_is_literal(self): |
| 334 | # An unclosed `[` inside a string must not leave a phantom open |
| 335 | # bracket on the stack. |
| 336 | text = "[exec(command='grep [abc log.txt')]" |
| 337 | assert make_valid_python(text) == (text, "") |
| 338 | |
| 339 | def test_partial_string_with_bracket_completes(self): |
| 340 | # Streaming prefix ending mid-string after a literal bracket closes |
| 341 | # with quote + paren + bracket. |
| 342 | result = make_valid_python('[exec(command=\'grep -F "]" lo') |
| 343 | assert result is not None |
| 344 | completed, added = result |
| 345 | assert added == "')]" |
| 346 | assert completed == "[exec(command='grep -F \"]\" lo')]" |
| 347 | |
| 348 | def test_real_mismatched_bracket_still_raises(self): |
| 349 | with pytest.raises(UnexpectedAstError): |
| 350 | make_valid_python("[exec(command=data])") |
| 351 | |
| 352 | def test_multiline_string_argument_recovers_after_escape(self): |
| 353 | # A raw newline inside a string argument is invalid Python, so |
| 354 | # make_valid_python alone returns None; callers pre-escape control |
| 355 | # chars (as the lfm2 parser does) and the escaped value must evaluate |
| 356 | # back to the original. |
| 357 | text = "[exec(command='line1\nline2')]" |
| 358 | assert make_valid_python(text) is None |
| 359 | result = make_valid_python(escape_ctrl_chars_in_strings(text)) |
| 360 | assert result is not None |
| 361 | completed, added = result |
| 362 | assert added == "" |
| 363 | assert _kwarg_constant(_first_call(completed)) == "line1\nline2" |
| 364 | |
| 365 | def test_value_ending_in_backslash_recovers(self): |
| 366 | # A string value ending in a literal backslash: the closing quote follows |
| 367 | # an escaped backslash (an *even* run), so it closes the string. Checking |
| 368 | # only the single preceding char misread it as an escaped quote, left the |
| 369 | # string open, and make_valid_python returned None — dropping calls whose |
| 370 | # last argument ends in a backslash (common in regex like r'\b'). |
| 371 | text = "[write(path='x', content='pattern \\\\')]" |
| 372 | assert make_valid_python(text) == (text, "") |
| 373 | |
| 374 | def test_escaped_quote_odd_backslashes_stays_open(self): |
| 375 | # An escaped quote (an *odd* backslash run) must NOT close the string; |
| 376 | # only the final unescaped quote does. Value round-trips to it's fine. |
| 377 | text = "[say(msg='it\\'s fine')]" |
| 378 | assert make_valid_python(text) == (text, "") |
| 379 | assert _kwarg_constant(_first_call(text)) == "it's fine" |
| 380 | |
| 381 | |
| 382 | class TestEscapeCtrlCharsInStrings: |
| 383 | def test_newline_inside_string_escaped(self): |
| 384 | assert escape_ctrl_chars_in_strings("f(cmd='a\nb')") == "f(cmd='a\\nb')" |
| 385 | |
| 386 | def test_ctrl_chars_outside_strings_untouched(self): |
| 387 | assert escape_ctrl_chars_in_strings("f(a=1,\nb=2)") == "f(a=1,\nb=2)" |
| 388 | |
| 389 | def test_existing_escapes_pass_through(self): |
| 390 | text = "f(cmd='a\\nb')" |
| 391 | assert escape_ctrl_chars_in_strings(text) == text |
| 392 | |
| 393 | def test_escaped_quote_does_not_close_string(self): |
| 394 | assert escape_ctrl_chars_in_strings("f(cmd='a\\'\nb')") == "f(cmd='a\\'\\nb')" |
| 395 | |
| 396 | def test_value_preserved_through_ast(self): |
| 397 | # The escaped text parses and evaluates back to the original value. |
| 398 | raw = "cat > f.py << EOF\nimport csv\nEOF\techo done" |
| 399 | escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')") |
| 400 | assert _kwarg_constant(_bare_call(escaped)) == raw |
| 401 | |
| 402 | def test_nul_byte_inside_string_escaped(self): |
| 403 | # ast.parse raises ValueError (not SyntaxError) on NUL anywhere in |
| 404 | # the source, so an unescaped NUL in a string arg is unrecoverable. |
| 405 | raw = "printf a\x00b" |
| 406 | escaped = escape_ctrl_chars_in_strings(f"f(cmd='{raw}')") |
| 407 | assert "\x00" not in escaped |
| 408 | assert _kwarg_constant(_bare_call(escaped)) == raw |
| 409 | |
| 410 | def test_nul_byte_outside_strings_untouched(self): |
| 411 | text = "f(a=1,\x00b=2)" |
| 412 | assert escape_ctrl_chars_in_strings(text) == text |
| 413 | |
| 414 | |
| 415 | class TestGetParameterValueNegativeNumbers: |
| 416 | # A negative number is parsed by Python as UnaryOp(USub, Constant(n)), not |
| 417 | # a plain Constant. Without explicit handling the entire tool call is |
| 418 | # dropped. Negative longitudes/deltas/offsets are extremely common tool |
| 419 | # arguments (e.g. every Western-hemisphere coordinate). |
| 420 | def test_negative_int(self): |
| 421 | assert _value_of("-1") == -1 |
| 422 | |
| 423 | def test_negative_float(self): |
| 424 | assert _value_of("-3.5") == -3.5 |
| 425 | |
| 426 | def test_explicit_positive_int(self): |
| 427 | assert _value_of("+7") == 7 |
| 428 | |
| 429 | def test_negative_longitude(self): |
| 430 | assert _value_of("-74.0046539") == -74.0046539 |
| 431 | |
| 432 | def test_negative_in_list(self): |
| 433 | assert _value_of("[-1, 2, -3]") == [-1, 2, -3] |
| 434 | |
| 435 | def test_negative_in_dict(self): |
| 436 | assert _value_of('{"min": -5, "max": 5}') == {"min": -5, "max": 5} |
| 437 | |
| 438 | def test_nested_negative(self): |
| 439 | assert _value_of('{"bbox": [-74.0, 40.7, -73.9]}') == { |
| 440 | "bbox": [-74.0, 40.7, -73.9] |
| 441 | } |
| 442 | |
| 443 | def test_non_numeric_unary_still_raises(self): |
| 444 | # ``not x`` / ``~x`` are not literals and must still be rejected. |
| 445 | with pytest.raises(UnexpectedAstError): |
| 446 | _value_of("~5") |
| 447 | with pytest.raises(UnexpectedAstError): |
| 448 | _value_of("not True") |
| 449 | |
| 450 | |
| 451 | class TestHandleSingleToolNegativeNumbers: |
| 452 | def test_negative_arg_end_to_end(self): |
| 453 | call = _first_call("[searchWeather(latitude=40.84, longitude=-74.0046539)]") |
| 454 | tool = handle_single_tool(call) |
| 455 | assert tool.function.name == "searchWeather" |
| 456 | assert json.loads(tool.function.arguments) == { |
| 457 | "latitude": 40.84, |
| 458 | "longitude": -74.0046539, |
| 459 | } |
| 460 | |
| 461 | def test_negative_delta_end_to_end(self): |
| 462 | call = _first_call("[updateInventory(quantity_delta=-20)]") |
| 463 | tool = handle_single_tool(call) |
| 464 | assert json.loads(tool.function.arguments) == {"quantity_delta": -20} |
| 465 | |
| 466 | |
| 467 | class TestGetParameterValueSet: |
| 468 | # JSON has no set type; a set argument is decoded as a list (preserving |
| 469 | # source order) instead of dropping the whole call, mirroring the tuple |
| 470 | # handling. |
| 471 | def test_set_becomes_list(self): |
| 472 | assert _value_of("{'a', 'b'}") == ["a", "b"] |
| 473 | |
| 474 | def test_set_of_numbers(self): |
| 475 | assert _value_of("{1, 2, 3}") == [1, 2, 3] |
| 476 | |
| 477 | def test_set_nested_in_dict(self): |
| 478 | assert _value_of("{'tags': {'x', 'y'}}") == {"tags": ["x", "y"]} |
| 479 | |
| 480 | def test_set_end_to_end(self): |
| 481 | call = _first_call("[label(tags={'urgent', 'bug'})]") |
| 482 | tool = handle_single_tool(call) |
| 483 | assert json.loads(tool.function.arguments) == {"tags": ["urgent", "bug"]} |
| 484 | |
| 485 | |
| 486 | class TestMakeValidPythonSets: |
| 487 | def test_complete_set_in_model_text_accepted(self): |
| 488 | # A set the model wrote and closed itself is a genuine argument. |
| 489 | text = "[label(tags={'urgent', 'bug'})]" |
| 490 | assert make_valid_python(text) == (text, "") |
| 491 | |
| 492 | def test_truncated_dict_completed_to_set_still_rejected(self): |
| 493 | # `{"k` closes to `{"k"}` — a truncated dict, not a set the model |
| 494 | # wrote. The completion added the `}`, so it must keep waiting. |
| 495 | assert make_valid_python('[f(x={"k') is None |
| 496 | |
| 497 | |
| 498 | class TestGetParameterValueFString: |
| 499 | # A placeholder-free f-string is a plain string constant, but ast parses |
| 500 | # it as JoinedStr; it must not drop the call. Real placeholders are not |
| 501 | # literals and must still be rejected. |
| 502 | def test_constant_fstring(self): |
| 503 | assert _value_of("f'hello world'") == "hello world" |
| 504 | |
| 505 | def test_empty_fstring(self): |
| 506 | assert _value_of("f''") == "" |
| 507 | |
| 508 | def test_constant_fstring_in_list(self): |
| 509 | assert _value_of("[f'a', 'b']") == ["a", "b"] |
| 510 | |
| 511 | def test_fstring_with_placeholder_still_raises(self): |
| 512 | with pytest.raises(UnexpectedAstError): |
| 513 | _value_of("f'{x}'") |
| 514 | |
| 515 | def test_constant_fstring_end_to_end(self): |
| 516 | call = _first_call("[send(msg=f'hello')]") |
| 517 | tool = handle_single_tool(call) |
| 518 | assert json.loads(tool.function.arguments) == {"msg": "hello"} |
| 519 | |
| 520 | |
| 521 | class TestGetParameterValueTuple: |
| 522 | # JSON has no tuple type, so a tuple argument is decoded as a list rather |
| 523 | # than dropping the whole call. |
| 524 | def test_tuple_becomes_list(self): |
| 525 | assert _value_of("(800, 600)") == [800, 600] |
| 526 | |
| 527 | def test_nested_tuple(self): |
| 528 | assert _value_of("[(1, 2), (3, 4)]") == [[1, 2], [3, 4]] |
| 529 | |
| 530 | def test_tuple_with_negative(self): |
| 531 | assert _value_of("(-74.0, 40.7)") == [-74.0, 40.7] |
| 532 | |
| 533 | def test_tuple_end_to_end(self): |
| 534 | call = _first_call("[resize(size=(800, 600))]") |
| 535 | tool = handle_single_tool(call) |
| 536 | assert json.loads(tool.function.arguments) == {"size": [800, 600]} |
| 537 | |
| 538 | |
| 539 | class TestEscapeNestedQuotesInStrings: |
| 540 | # Unescaped same-style quotes nested in a string argument (shell |
| 541 | # commands like sed -n '1,9p') are Python juxtaposition errors. When |
| 542 | # exactly one quote can syntactically close the string, close there and |
| 543 | # escape the interior quotes; anything ambiguous is left unchanged. |
| 544 | def test_sed_command_recovers_exact_value(self): |
| 545 | text = "[bash(command='sed -n '360,450p' /testbed/common.py')]" |
| 546 | rewritten, changed = escape_nested_quotes_in_strings(text) |
| 547 | assert changed |
| 548 | assert _kwarg_constant(_first_call(rewritten)) == ( |
| 549 | "sed -n '360,450p' /testbed/common.py" |
| 550 | ) |
| 551 | |
| 552 | def test_double_quote_variant(self): |
| 553 | text = '[bash(command="grep "foo" log.txt")]' |
| 554 | rewritten, changed = escape_nested_quotes_in_strings(text) |
| 555 | assert changed |
| 556 | assert _kwarg_constant(_first_call(rewritten)) == 'grep "foo" log.txt' |
| 557 | |
| 558 | def test_mixed_with_already_escaped_quote(self): |
| 559 | text = "[bash(command='it\\'s 'x' end')]" |
| 560 | rewritten, changed = escape_nested_quotes_in_strings(text) |
| 561 | assert changed |
| 562 | assert _kwarg_constant(_first_call(rewritten)) == "it's 'x' end" |
| 563 | |
| 564 | def test_doubly_nested_python_c_payload(self): |
| 565 | # command='python3 -c "...latex(x, mul_symbol='\,')..."' — two false |
| 566 | # closers both followed by ')', but only the real one yields text |
| 567 | # that parses; ast-validation disambiguates. |
| 568 | text = "[bash(command='python3 -c \"print(latex(x, mul_symbol='\\,'))\"')]" |
| 569 | rewritten, changed = escape_nested_quotes_in_strings(text) |
| 570 | assert changed |
| 571 | assert _kwarg_constant(_first_call(rewritten)) == ( |
| 572 | "python3 -c \"print(latex(x, mul_symbol='\\,'))\"" |
| 573 | ) |
| 574 | |
| 575 | def test_multiline_python_c_with_inner_string(self): |
| 576 | # Raw newlines beyond the phantom close plus a single-quoted inner |
| 577 | # string; recovery must survive both (caller re-escapes ctrl chars). |
| 578 | inner = "python3 -c \"\ntest_str = 'is <b>fine</b>'\nprint(test_str)\n\"" |
| 579 | text = f"[bash(command='{inner}')]" |
| 580 | rewritten, changed = escape_nested_quotes_in_strings(text) |
| 581 | assert changed |
| 582 | recovered = escape_ctrl_chars_in_strings(rewritten) |
| 583 | assert _kwarg_constant(_first_call(recovered)) == inner |
| 584 | |
| 585 | def test_nested_quotes_before_non_string_argument(self): |
| 586 | # A later NON-string argument adds no candidate closer, so the |
| 587 | # nested string is still unambiguous. |
| 588 | text = "[run(cmd='awk '{print}' f', count=2)]" |
| 589 | rewritten, changed = escape_nested_quotes_in_strings(text) |
| 590 | assert changed |
| 591 | call = _first_call(rewritten) |
| 592 | assert _kwarg_constant(call, 0) == "awk '{print}' f" |
| 593 | |
| 594 | @pytest.mark.parametrize( |
| 595 | "text", |
| 596 | [ |
| 597 | "[f(a='x')]", |
| 598 | "[f(a='x', b='y')]", |
| 599 | "[f(a={'k': 'v'})]", |
| 600 | "[f(a='it\\'s fine')]", |
| 601 | "[a(x='p'), b(y='q')]", |
| 602 | ], |
| 603 | ) |
| 604 | def test_valid_text_unchanged(self, text): |
| 605 | rewritten, changed = escape_nested_quotes_in_strings(text) |
| 606 | assert not changed |
| 607 | assert rewritten == text |
| 608 | |
| 609 | @pytest.mark.parametrize( |
| 610 | "text", |
| 611 | [ |
| 612 | "[f(a='echo 'hi', b='x')]", |
| 613 | "[run(cmd='awk '{print}' f', mode='fast')]", |
| 614 | ], |
| 615 | ) |
| 616 | def test_ambiguous_nesting_left_unchanged(self, text): |
| 617 | # A later string argument's closing quote is itself a plausible |
| 618 | # closer, making the nesting formally ambiguous; no rewrite is |
| 619 | # attempted rather than guessing. |
| 620 | rewritten, changed = escape_nested_quotes_in_strings(text) |
| 621 | assert not changed |
| 622 | assert rewritten == text |
| 623 | |
| 624 | |
| 625 | class TestContainsBrokenStringLiteral: |
| 626 | @pytest.mark.parametrize( |
| 627 | "text, broken", |
| 628 | [ |
| 629 | ("[bash(command='sed -n '360,450p' /x')]", True), |
| 630 | ("[f(a='echo 'hi', b='x')]", True), |
| 631 | # Closing quote at the very end of partial text: the follower |
| 632 | # is unknown until the next chunk arrives, so hold. |
| 633 | ("[f(a='x'", True), |
| 634 | ("[f(a='x', b='y')]", False), |
| 635 | # Mid-string (no closing quote yet) is normal streaming. |
| 636 | ('[f(a=\'grep -F "]" log', False), |
| 637 | ("[f(a={'k': 'v'})]", False), |
| 638 | ("[f(a='it\\'s fine')]", False), |
| 639 | ], |
| 640 | ) |
| 641 | def test_detection(self, text, broken): |
| 642 | assert contains_broken_string_literal(text) is broken |
| 643 | |
| 644 | |
| 645 | class TestGetParameterValueNonJsonConstants: |
| 646 | # bytes/Ellipsis/complex are ast.Constant but have no JSON form; they |
| 647 | # must raise UnexpectedAstError (like other unsupported nodes) instead |
| 648 | # of surfacing later as a TypeError inside json.dumps. |
| 649 | @pytest.mark.parametrize("expr", ["b'abc'", "...", "1j"]) |
| 650 | def test_non_json_constant_raises(self, expr): |
| 651 | with pytest.raises(UnexpectedAstError): |
| 652 | _value_of(expr) |
| 653 | |
| 654 | def test_handle_single_tool_raises_ast_error_not_type_error(self): |
| 655 | call = _first_call("[f(x=b'abc')]") |
| 656 | with pytest.raises(UnexpectedAstError): |
| 657 | handle_single_tool(call) |
| 658 | |
| 659 | @pytest.mark.parametrize( |
| 660 | "expr, expected", |
| 661 | [("'s'", "s"), ("1", 1), ("1.5", 1.5), ("True", True), ("None", None)], |
| 662 | ) |
| 663 | def test_json_constants_still_pass(self, expr, expected): |
| 664 | assert _value_of(expr) == expected |
| 665 | |
| 666 | |
| 667 | class TestNormalizeLeadingZeroInts: |
| 668 | # Zero-padded ints (month=07) are a SyntaxError no other recovery path |
| 669 | # handles; the rewrite must strip the padding without touching tokens |
| 670 | # that are already valid Python. |
| 671 | @pytest.mark.parametrize( |
| 672 | "text, expected", |
| 673 | [ |
| 674 | ("[f(month=07)]", "[f(month=7)]"), |
| 675 | ("[f(x=007, y=05)]", "[f(x=7, y=5)]"), |
| 676 | ("[f(x=0_7)]", "[f(x=7)]"), |
| 677 | ("[f(x=-07)]", "[f(x=-7)]"), |
| 678 | ], |
| 679 | ) |
| 680 | def test_leading_zeros_stripped(self, text, expected): |
| 681 | assert normalize_leading_zero_ints(text) == expected |
| 682 | assert ast.parse(expected) |
| 683 | |
| 684 | @pytest.mark.parametrize( |
| 685 | "text", |
| 686 | [ |
| 687 | "[f(x=0)]", |
| 688 | "[f(x=00)]", |
| 689 | "[f(x=0.5)]", |
| 690 | "[f(x=07.5)]", |
| 691 | "[f(x=1.07)]", |
| 692 | "[f(x=1e07)]", |
| 693 | "[f(x=0x1F)]", |
| 694 | "[f(x=0o17)]", |
| 695 | "[f(x=0b101)]", |
| 696 | "[f(s='id 007')]", |
| 697 | '[f(s="v0.07")]', |
| 698 | ], |
| 699 | ) |
| 700 | def test_valid_tokens_and_strings_untouched(self, text): |
| 701 | assert normalize_leading_zero_ints(text) == text |
| 702 | |
| 703 | def test_end_to_end(self): |
| 704 | normalized = normalize_leading_zero_ints("[set_date(month=07, day=05)]") |
| 705 | tool = handle_single_tool(_first_call(normalized)) |
| 706 | assert json.loads(tool.function.arguments) == {"month": 7, "day": 5} |
| 707 | |
| 708 | |
| 709 | class TestRenameReservedKwargs: |
| 710 | # A parameter named after a Python keyword (`from=1`) is a SyntaxError |
| 711 | # that no escape/retry can recover; rename_reserved_kwargs rewrites it to |
| 712 | # a parseable name and restore_reserved_kwarg_names is its exact inverse. |
| 713 | def test_reserved_kwarg_renamed(self): |
| 714 | text, changed = rename_reserved_kwargs("[memory_get(from=1)]") |
| 715 | assert changed |
| 716 | assert text == "[memory_get(from_pyreservedkw_=1)]" |
| 717 | assert ast.parse(text) |
| 718 | |
| 719 | def test_round_trip_restores_original_name(self): |
| 720 | renamed, _ = rename_reserved_kwargs("[memory_get(path='M.md', from=1)]") |
| 721 | tool = handle_single_tool(_first_call(renamed)) |
| 722 | restored = restore_reserved_kwarg_names(json.loads(tool.function.arguments)) |
| 723 | assert restored == {"path": "M.md", "from": 1} |
| 724 | |
| 725 | def test_multiple_reserved_kwargs(self): |
| 726 | text, changed = rename_reserved_kwargs('[search(in="docs/", from=0)]') |
| 727 | assert changed |
| 728 | args = json.loads(handle_single_tool(_first_call(text)).function.arguments) |
| 729 | assert restore_reserved_kwarg_names(args) == {"in": "docs/", "from": 0} |
| 730 | |
| 731 | def test_keyword_inside_string_untouched(self): |
| 732 | text, changed = rename_reserved_kwargs('[f(cmd="import x from y")]') |
| 733 | assert not changed |
| 734 | assert text == '[f(cmd="import x from y")]' |
| 735 | |
| 736 | def test_keyword_with_from_eq_inside_string_untouched(self): |
| 737 | text, changed = rename_reserved_kwargs('[f(cmd="SELECT from=1")]') |
| 738 | assert not changed |
| 739 | |
| 740 | def test_keyword_value_untouched(self): |
| 741 | # `x=True` has a keyword as *value*, not parameter name. |
| 742 | text, changed = rename_reserved_kwargs("[f(x=True, y=None)]") |
| 743 | assert not changed |
| 744 | |
| 745 | def test_double_equals_untouched(self): |
| 746 | text, changed = rename_reserved_kwargs('[f(expr="a", cond=1)]') |
| 747 | assert not changed |
| 748 | # `in ==` comparison-like text is not kwarg position anyway, but the |
| 749 | # `==` guard also protects string-free edge text. |
| 750 | text, changed = rename_reserved_kwargs("[f(x=1)]") |
| 751 | assert not changed |
| 752 | |
| 753 | def test_non_keyword_names_untouched(self): |
| 754 | text, changed = rename_reserved_kwargs("[f(fromage=1, classic=2)]") |
| 755 | assert not changed |
| 756 | |
| 757 | def test_spaces_around_equals(self): |
| 758 | text, changed = rename_reserved_kwargs("[f( from = 1 )]") |
| 759 | assert changed |
| 760 | assert ast.parse(text) |
| 761 | |
| 762 | def test_restore_leaves_normal_names_alone(self): |
| 763 | assert restore_reserved_kwarg_names({"path": "x", "from": 1}) == { |
| 764 | "path": "x", |
| 765 | "from": 1, |
| 766 | } |