{"repo":"NousResearch/hermes-agent","public_id":"52491","display_label":"Fuzzy match deletes boundary space in replacements","summary":"A bug where whitespace-normalized fuzzy matching silently drops the boundary space after a match during str_replace or patch operations. Multiple fix attempts address preserving that boundary space in fuzzy replacements.","subsystem":null,"counts":{"members":5,"open_issues":0,"open_issues_distinct":0,"open_prs":0,"closed_issues":1,"merged_prs":1,"attempt_prs":3},"landed_fix":{"status":"merged_to_main","basis":"structural","pr":52678},"resolution":{"state":"resolved","by":"pr-52678"},"freshness":{"state":"verified","last_verified_at":"2026-06-26T05:29:15+00:00","is_stale":false},"grouping":{"backbone":"STRUCTURAL_SPANNING","link_density":0.4},"fix_edges":[{"issue_number":52491,"pr_number":52678,"tier":"verified","basis":"closing_ref","confidence":0.95,"recoverable_from_timeline":true}],"members":[{"kind":"issue","number":52491,"title":"[Bug]: Fuzzy whitespace-normalized edit silently deletes the boundary space after a match (str_replace / patch corrupts file)","url":null,"state":"closed"},{"kind":"pr","number":52502,"title":"fix(fuzzy-match): preserve boundary space after whitespace-normalized match (#52491)","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52678","number":52678}],"category":"uncategorized","implication":"needs_llm"},"merged":false,"is_fix_pr":true,"fix_tier":"suggested","fix_detail_withheld":true},{"kind":"pr","number":52513,"title":"fix(tools): preserve word-boundary space in whitespace-normalized edits","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52678","number":52678},{"kind":"pr","value":"#52502","number":52502}],"category":"superseded_by_pr","implication":"superseded"},"merged":false},{"kind":"pr","number":52678,"title":"fix(fuzzy-match): preserve boundary space after whitespace-normalized match (#52491)","url":null,"state":"merged","close_reason":{"category":"merged","implication":"superseded"},"merged":true,"is_fix_pr":true,"fix_tier":"verified","files_changed":["tests/tools/test_fuzzy_match.py","tools/fuzzy_match.py"],"fix_hunks":[{"file":"tests/tools/test_fuzzy_match.py","hunks":["@@ -43,6 +43,47 @@ def test_extra_spaces_match(self):\n         assert count == 1\n         assert \"bar\" in new\n \n+    def test_boundary_space_preserved_after_match(self):\n+        \"\"\"Regression: whitespace_normalized match ending with a non-space\n+        character must NOT consume the word-boundary space that follows.\n+        https://github.com/NousResearch/hermes-agent/issues/52491\"\"\"\n+        # Case 1 — simple word boundary\n+        new, count, strategy, err = fuzzy_find_and_replace(\n+            \"foo   bar baz\", \"foo bar\", \"XY\",\n+        )\n+        assert err is None\n+        assert count == 1\n+        assert strategy == \"whitespace_normalized\"\n+        assert new == \"XY baz\", f\"Boundary space deleted: {new!r}\"\n+\n+    def test_boundary_space_preserved_in_code_edit(self):\n+        \"\"\"Regression: real-world code-edit scenario where the space before\n+        the next operator must survive a whitespace-normalized match.\"\"\"\n+        content = \"result = compute(a,  b) + tail\"\n+        new, count, strategy, err = fuzzy_find_and_replace(\n+            content, \"compute(a, b)\", \"compute(a, b, c)\",\n+        )\n+        assert err is None\n+        assert count == 1\n+        assert strategy == \"whitespace_normalized\"\n+        assert new == \"result = compute(a, b, c) + tail\", f\"Boundary space deleted: {new!r}\"\n+\n+    def test_trailing_ws_still_consumed_when_match_ends_with_space(self):\n+        \"\"\"When the normalized match itself ends with whitespace (pattern has\n+        trailing space), the expansion must still consume the full whitespace\n+        run in the original.\"\"\"\n+        # Use a pattern with trailing space where the boundary is clear:\n+        # content has \"foo   \" then \"bar\", pattern is \"foo \" — the match\n+        # should cover all 3 original spaces (the trailing ws run).\n+        new, count, strategy, err = fuzzy_find_and_replace(\n+            \"a = foo   + bar\", \"foo +\", \"XY\",\n+        )\n+        assert err is None\n+        assert count == 1\n+        # \"foo   +\" normalized to \"foo +\" matches; trailing spaces consumed\n+        # Result: \"a = XY bar\"\n+        assert \"XY\" in new and \"bar\" in new\n+\n \n class TestIndentDifference:\n     def test_different_indentation(self):"]},{"file":"tools/fuzzy_match.py","hunks":["@@ -768,9 +768,14 @@ def _map_normalized_positions(original: str, normalized: str,\n         else:\n             orig_end = orig_start + (norm_end - norm_start)\n         \n-        # Expand to include trailing whitespace that was normalized\n-        while orig_end < len(original) and original[orig_end] in ' \\t':\n-            orig_end += 1\n+        # Expand to include trailing whitespace that was normalized,\n+        # but only when the normalized match itself ended with whitespace.\n+        # When the match ends with a non-space character, the first\n+        # whitespace in the original is a word boundary and must not be\n+        # consumed.  See https://github.com/NousResearch/hermes-agent/issues/52491\n+        if norm_end < len(normalized) and normalized[norm_end - 1] == ' ':\n+            while orig_end < len(original) and original[orig_end] in ' \\t':\n+                orig_end += 1\n         \n         original_matches.append((orig_start, min(orig_end, len(original))))\n     \n"]}]},{"kind":"pr","number":52861,"title":"fix(tools): preserve boundary spaces in fuzzy replacements","url":null,"state":"closed","close_reason":{"refs":[{"kind":"pr","value":"#52678","number":52678},{"kind":"pr","value":"#52491","number":52491}],"category":"duplicate_of","implication":"duplicate"},"merged":false}],"trust":{"default_tier":"verified","includes_suggested":false}}