Files
hermes-agent/tests/tools/test_skill_pending_diff_batch.py
Brooklyn Nicholson 1a142d4e38 fix(skills): preview a staged patch through the approval matcher
_fold_staged()/skill_pending_diff() folded patch ops with plain str.replace(),
ignoring replace_all and the fuzzy-match contract the approve path runs
(_patch_skill -> fuzzy_find_and_replace). A patch whose anchor repeats in the
target content previewed a folded diff ('X X') that /skills approve would then
reject with 'Found 2 matches'.

Fold previews through fuzzy_find_and_replace with the payload's replace_all and
render an explicit '(patch would fail: ...)' note instead of a fabricated
result; in a staged batch a failed patch leaves the later ops' base at the
pre-patch content, since approve aborts the batch at that op. Also drops the
trailing blank line at EOF flagged by git diff --check.
2026-09-30 23:05:43 -05:00

177 lines
7.9 KiB
Python

"""Batch pending diff for /skills diff <id> (#123315).
Writes staged through ``skill_manage`` ``operations[]`` are ``batch`` records;
without a batch case ``skill_pending_diff`` falls through to ``(batch on '')``
and the review-before-approve affordance never shows a diff.
"""
import importlib
import os
import shutil
import sys
import tempfile
import unittest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
SK = (
"---\nname: {n}\ndescription: Probe skill for pending-diff tests.\n---\n"
"# Probe\nStep 1.\n"
)
class TestSkillPendingDiffBatch(unittest.TestCase):
def setUp(self):
self.home = tempfile.mkdtemp(prefix="skdiff_t_")
os.environ["HERMES_HOME"] = self.home
os.makedirs(os.path.join(self.home, "skills", "probe"), exist_ok=True)
with open(os.path.join(self.home, "skills", "probe", "SKILL.md"), "w",
encoding="utf-8") as f:
f.write(SK.format(n="probe"))
# Re-import against the temp home (skill lookup caches SKILLS_DIR).
import tools.skill_manager_tool as smt
importlib.reload(smt)
import tools.write_approval as wa
importlib.reload(wa)
self.wa = wa
def tearDown(self):
shutil.rmtree(self.home, ignore_errors=True)
def _batch_record(self):
return {
"id": "abc123",
"summary": "batch(2 ops: create, patch) on newprobe, probe",
"payload": {
"action": "batch",
"operations": [
{"action": "create", "name": "newprobe",
"content": SK.format(n="newprobe")},
{"action": "patch", "name": "probe",
"old_string": "Step 1.", "new_string": "Step ONE."},
],
},
}
def test_batch_diff_renders_per_op_content(self):
out = self.wa.skill_pending_diff(self._batch_record())
self.assertNotIn("(batch on", out)
# create op shows the full new content
self.assertIn("Step 1.", out)
# patch op shows a unified diff against the on-disk skill
self.assertIn("-Step 1.", out)
self.assertIn("+Step ONE.", out)
def test_single_op_paths_unchanged(self):
create = self.wa.skill_pending_diff(
{"payload": {"action": "create", "name": "newprobe",
"content": SK.format(n="newprobe")}})
self.assertEqual(create, SK.format(n="newprobe"))
patch = self.wa.skill_pending_diff(
{"payload": {"action": "patch", "name": "probe",
"old_string": "Step 1.", "new_string": "Step ONE."}})
self.assertIn("-Step 1.", patch)
self.assertIn("+Step ONE.", patch)
def test_batch_op_diffs_against_earlier_ops_not_disk(self):
"""A batch that creates a skill and then patches it: the patch must diff against the
content the create op staged, not the (absent) on-disk copy — otherwise the review
renders the literal '(patch ...)' fallback for the very case batches are used for."""
rec = {"id": "def456", "payload": {"action": "batch", "operations": [
{"action": "create", "name": "newprobe",
"content": SK.format(n="newprobe")},
{"action": "patch", "name": "newprobe",
"old_string": "Step 1.", "new_string": "Step ONE."},
{"action": "write_file", "name": "newprobe",
"file_path": "ref.md", "file_content": "x\n"},
{"action": "patch", "name": "newprobe", "file_path": "ref.md",
"old_string": "x", "new_string": "y"},
]}}
out = self.wa.skill_pending_diff(rec)
self.assertNotIn("(patch", out)
self.assertIn("-Step 1.", out)
self.assertIn("+Step ONE.", out)
# write_file labels its own file, and the following patch diffs against its content
self.assertIn("a/ref.md", out)
self.assertIn("-x", out)
self.assertIn("+y", out)
def test_batch_numbering_ignores_non_dict_ops(self):
rec = {"id": "ghi789", "payload": {"action": "batch", "operations": [
"not-an-op",
{"action": "create", "name": "newprobe",
"content": SK.format(n="newprobe")},
]}}
out = self.wa.skill_pending_diff(rec)
self.assertIn("## op 1/1:", out)
self.assertNotIn("/2:", out)
def test_patch_preview_uses_the_approval_matcher(self):
"""A patch whose anchor repeats must NOT preview a fake folded result: approve runs
``fuzzy_find_and_replace`` (unique-match unless replace_all), so the preview renders
the matcher's own ambiguous error instead of a diff approval would reject with
'Found 2 matches' (review feedback on the original diff-fold preview)."""
rec = {"id": "jkl012", "payload": {
"action": "patch", "name": "probe",
"old_string": "Step 1.", "new_string": "Step ONE.",
# content on disk: the anchor repeats (SK ends "Step 1.\n"; fold in a second)
"replace_all": False}}
with open(os.path.join(self.home, "skills", "probe", "SKILL.md"), "a",
encoding="utf-8") as f:
f.write("Step 1.\n")
out = self.wa.skill_pending_diff(rec)
self.assertIn("(patch would fail:", out)
self.assertIn("Found 2 matches", out)
# never a folded diff: approving this payload aborts the batch at this op
self.assertNotIn("+Step ONE.", out)
def test_patch_preview_replace_all_folds_every_match(self):
"""With replace_all=True the repeated anchor folds the same way approve would: every
occurrence replaced, so the preview shows the diff approval would actually commit."""
rec = {"id": "mno345", "payload": {
"action": "patch", "name": "probe",
"old_string": "Step 1.", "new_string": "Step ONE.",
"replace_all": True}}
with open(os.path.join(self.home, "skills", "probe", "SKILL.md"), "a",
encoding="utf-8") as f:
f.write("Step 1.\n")
out = self.wa.skill_pending_diff(rec)
self.assertNotIn("(patch would fail", out)
self.assertEqual(out.count("+Step ONE."), 2)
def test_batch_preview_flags_failed_patch_without_folding(self):
"""In a batch, a failed patch op renders the failure and the NEXT op's base stays at
the pre-patch staged content — approve aborts the batch at the failed op, so nothing
after it should preview against a result that will never be committed."""
sk = SK.format(n="probe")
doubled = sk + "Step 1.\n"
rec = {"id": "pqr678", "payload": {"action": "batch", "operations": [
# op 1: patch whose anchor repeats in the on-disk content (fails, no replace_all)
{"action": "patch", "name": "probe",
"old_string": "Step 1.", "new_string": "Step ONE."},
# op 2: write_file whose target must still show a diff against the UNfolded base
{"action": "write_file", "name": "probe", "file_path": "ref.md",
"file_content": "z\n"},
]}}
with open(os.path.join(self.home, "skills", "probe", "SKILL.md"), "w",
encoding="utf-8") as f:
f.write(doubled)
out = self.wa.skill_pending_diff(rec)
self.assertIn("(patch would fail:", out)
self.assertIn("Found 2 matches", out)
def test_diff_subcommand_end_to_end(self):
from hermes_cli.write_approval_commands import handle_pending_subcommand
rec = self.wa.stage_write(
self.wa.SKILLS, self._batch_record()["payload"],
summary="batch(2 ops) on newprobe, probe", origin="foreground")
out = handle_pending_subcommand(self.wa.SKILLS, ["diff", rec["id"]])
self.assertIsNotNone(out)
assert out is not None
self.assertIn(rec["id"], out)
self.assertNotIn("(batch on", out)
self.assertIn("+Step ONE.", out)
if __name__ == "__main__":
unittest.main()