Accept literal patch hunks
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
a3cc97bd60fcd7614619ae4bc8800ead4682ae44- Parents
-
7944195 - Tree
c4bbd4d
a3cc97b
a3cc97bd60fcd7614619ae4bc8800ead4682ae447944195
c4bbd4d| Status | File | + | - |
|---|---|---|---|
| M |
src/loader/tools/fs_safety.py
|
8 | 4 |
| M |
tests/test_bash_operator_surfaces.py
|
11 | 0 |
| M |
tests/test_expanded_tools.py
|
27 | 0 |
| M |
tests/test_safeguard_services.py
|
24 | 0 |
src/loader/tools/fs_safety.pymodified@@ -2,6 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | from __future__ import annotations |
| 4 | 4 | |
| 5 | +import ast | |
| 5 | 6 | import json |
| 6 | 7 | import re |
| 7 | 8 | from dataclasses import asdict, dataclass |
@@ -88,9 +89,9 @@ def coerce_structured_patch_payload( | ||
| 88 | 89 | ) -> list[dict[str, object] | StructuredPatchHunk]: |
| 89 | 90 | """Normalize structured patch payloads from native tool callers. |
| 90 | 91 | |
| 91 | - Some local models serialize the `hunks` array as a JSON string even though | |
| 92 | - the tool schema asks for an array. Treat that as recoverable instead of | |
| 93 | - rejecting an otherwise valid patch call. | |
| 92 | + Some local models serialize the `hunks` array as a JSON or Python-literal | |
| 93 | + string even though the tool schema asks for an array. Treat that as | |
| 94 | + recoverable instead of rejecting an otherwise valid patch call. | |
| 94 | 95 | """ |
| 95 | 96 | |
| 96 | 97 | if isinstance(value, str): |
@@ -99,7 +100,10 @@ def coerce_structured_patch_payload( | ||
| 99 | 100 | try: |
| 100 | 101 | value = json.loads(value) |
| 101 | 102 | except json.JSONDecodeError: |
| 102 | - return [] | |
| 103 | + try: | |
| 104 | + value = ast.literal_eval(value) | |
| 105 | + except (SyntaxError, ValueError): | |
| 106 | + return [] | |
| 103 | 107 | |
| 104 | 108 | if isinstance(value, StructuredPatchHunk): |
| 105 | 109 | return [value] |
tests/test_bash_operator_surfaces.pymodified@@ -288,6 +288,17 @@ def test_build_file_mutation_preview_accepts_json_encoded_hunks() -> None: | ||
| 288 | 288 | assert preview.structured_patch[0].new_lines == 5 |
| 289 | 289 | |
| 290 | 290 | |
| 291 | +def test_build_file_mutation_preview_accepts_python_literal_hunks() -> None: | |
| 292 | + tool_args = _replacement_block_patch_tool_args() | |
| 293 | + tool_args["hunks"] = repr(tool_args["hunks"]) | |
| 294 | + | |
| 295 | + preview = build_file_mutation_preview("patch", tool_args=tool_args) | |
| 296 | + | |
| 297 | + assert preview is not None | |
| 298 | + assert preview.structured_patch[0].old_start == 42 | |
| 299 | + assert preview.structured_patch[0].new_lines == 5 | |
| 300 | + | |
| 301 | + | |
| 291 | 302 | def test_cli_print_tool_call_renders_bash_panel_without_truncating(monkeypatch: pytest.MonkeyPatch) -> None: |
| 292 | 303 | console = Console(record=True, width=120) |
| 293 | 304 | monkeypatch.setattr(cli_main_module, "console", console) |
tests/test_expanded_tools.pymodified@@ -65,6 +65,33 @@ async def test_patch_tool_accepts_json_encoded_structured_hunks( | ||
| 65 | 65 | assert target.read_text() == "alpha\nbeta from json string\ngamma\n" |
| 66 | 66 | |
| 67 | 67 | |
| 68 | +@pytest.mark.asyncio | |
| 69 | +async def test_patch_tool_accepts_python_literal_structured_hunks( | |
| 70 | + temp_dir: Path, | |
| 71 | +) -> None: | |
| 72 | + target = temp_dir / "sample.txt" | |
| 73 | + target.write_text("alpha\nbeta\ngamma\n") | |
| 74 | + tool = PatchTool(workspace_root=temp_dir) | |
| 75 | + | |
| 76 | + result = await tool.execute( | |
| 77 | + file_path=str(target), | |
| 78 | + hunks=repr( | |
| 79 | + [ | |
| 80 | + { | |
| 81 | + "old_start": 2, | |
| 82 | + "old_lines": 1, | |
| 83 | + "new_start": 2, | |
| 84 | + "new_lines": 1, | |
| 85 | + "lines": ["-beta", "+beta from literal string"], | |
| 86 | + } | |
| 87 | + ] | |
| 88 | + ), | |
| 89 | + ) | |
| 90 | + | |
| 91 | + assert result.is_error is False | |
| 92 | + assert target.read_text() == "alpha\nbeta from literal string\ngamma\n" | |
| 93 | + | |
| 94 | + | |
| 68 | 95 | @pytest.mark.asyncio |
| 69 | 96 | async def test_patch_tool_rejects_context_mismatch(temp_dir: Path) -> None: |
| 70 | 97 | target = temp_dir / "sample.txt" |
tests/test_safeguard_services.pymodified@@ -378,6 +378,30 @@ def test_pre_action_validator_allows_json_encoded_patch_hunks() -> None: | ||
| 378 | 378 | assert result == ValidationResult(valid=True) |
| 379 | 379 | |
| 380 | 380 | |
| 381 | +def test_pre_action_validator_allows_python_literal_patch_hunks() -> None: | |
| 382 | + validator = PreActionValidator() | |
| 383 | + | |
| 384 | + result = validator.validate( | |
| 385 | + "patch", | |
| 386 | + { | |
| 387 | + "file_path": "notes.txt", | |
| 388 | + "hunks": repr( | |
| 389 | + [ | |
| 390 | + { | |
| 391 | + "old_start": 1, | |
| 392 | + "old_lines": 1, | |
| 393 | + "new_start": 1, | |
| 394 | + "new_lines": 1, | |
| 395 | + "lines": ["-old", "+new"], | |
| 396 | + } | |
| 397 | + ] | |
| 398 | + ), | |
| 399 | + }, | |
| 400 | + ) | |
| 401 | + | |
| 402 | + assert result == ValidationResult(valid=True) | |
| 403 | + | |
| 404 | + | |
| 381 | 405 | def test_pre_action_validator_blocks_placeholder_html_write(tmp_path: Path) -> None: |
| 382 | 406 | validator = PreActionValidator() |
| 383 | 407 | |