|
9 | 9 |
|
10 | 10 | from hooks import ( |
11 | 11 | _reset_blocker_reason_for_tests, |
| 12 | + _stuck_guard_between_turns_hook, |
12 | 13 | build_hook_matchers, |
13 | 14 | detect_egress_denial, |
14 | 15 | last_blocker_reason, |
| 16 | + last_stuck_summary, |
15 | 17 | post_tool_use_hook, |
16 | 18 | pre_tool_use_hook, |
| 19 | + reset_stuck_summary, |
17 | 20 | ) |
18 | 21 | from policy import PolicyEngine |
19 | 22 |
|
@@ -1721,6 +1724,53 @@ def test_post_tool_use_records_failures_into_the_guard(self): |
1721 | 1724 | # the guard now has enough failures to steer |
1722 | 1725 | assert guard.evaluate().kind == "steer" |
1723 | 1726 |
|
| 1727 | + def test_between_turns_hook_latches_stuck_summary_from_the_guard(self): |
| 1728 | + # #599 N2: pin the PRODUCTION write path for the _LAST_STUCK_SUMMARY latch |
| 1729 | + # (hooks.py:1464-1465). The enrichment tests monkeypatch the getter, so |
| 1730 | + # without this test deleting those two lines would leave the latch |
| 1731 | + # permanently None and every test would still pass. Drive the real hook |
| 1732 | + # with a failure-dominated guard and assert the module latch is populated. |
| 1733 | + from stuck_guard import WINDOW, StuckGuard |
| 1734 | + |
| 1735 | + reset_stuck_summary() |
| 1736 | + assert last_stuck_summary() is None |
| 1737 | + guard = StuckGuard() |
| 1738 | + cmd = {"command": "mise //cdk:test"} |
| 1739 | + # recent_failure_summary needs a FULL window (>= WINDOW) of byte-identical |
| 1740 | + # failures (WINDOW_FAIL_THRESHOLD of them) — fill it past WINDOW. |
| 1741 | + for _ in range(WINDOW + 2): |
| 1742 | + guard.record_tool_result("Bash", cmd, self._oom()) |
| 1743 | + # Precondition: the guard itself considers the window failure-dominated. |
| 1744 | + assert guard.recent_failure_summary() is not None |
| 1745 | + |
| 1746 | + result = _stuck_guard_between_turns_hook({"stuck_guard": guard}) |
| 1747 | + # advisory steer text returned… |
| 1748 | + assert isinstance(result, list) |
| 1749 | + # …and, crucially, the terminal-reason latch was written by the hook. |
| 1750 | + summary = last_stuck_summary() |
| 1751 | + assert summary is not None |
| 1752 | + assert "last tool calls repeated" in summary |
| 1753 | + reset_stuck_summary() |
| 1754 | + |
| 1755 | + def test_between_turns_hook_clears_stuck_summary_when_not_failure_dominated(self): |
| 1756 | + # Symmetric guard: a recovered task (clean window) must CLEAR the latch, |
| 1757 | + # not leave a stale "stuck" summary that a later max_turns cap would echo. |
| 1758 | + # Pre-seed a stale latch via a failure-dominated guard. |
| 1759 | + from stuck_guard import WINDOW, StuckGuard |
| 1760 | + |
| 1761 | + stuck = StuckGuard() |
| 1762 | + for _ in range(WINDOW + 2): |
| 1763 | + stuck.record_tool_result("Bash", {"command": "mise //cdk:test"}, self._oom()) |
| 1764 | + _stuck_guard_between_turns_hook({"stuck_guard": stuck}) |
| 1765 | + assert last_stuck_summary() is not None |
| 1766 | + |
| 1767 | + # A fresh, healthy guard on the next turn clears it (recent_failure_summary None). |
| 1768 | + healthy = StuckGuard() |
| 1769 | + healthy.record_tool_result("Read", {"file": "a.py"}, "def hello(): return 1") |
| 1770 | + _stuck_guard_between_turns_hook({"stuck_guard": healthy}) |
| 1771 | + assert last_stuck_summary() is None |
| 1772 | + reset_stuck_summary() |
| 1773 | + |
1724 | 1774 | def test_post_tool_use_record_error_never_blocks_screening(self): |
1725 | 1775 | # A guard that raises on record must not break the PASS_THROUGH path. |
1726 | 1776 | from stuck_guard import StuckGuard |
|
0 commit comments