"""Batch pending diff for /skills diff (#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()