"""Unit tests for AntiStallMonitor."""
from unittest.mock import AsyncMock, patch
import pytest
from navi.core.anti_stall import AntiStallMonitor
from navi.llm.base import Message, ToolCallRequest
from tests.conftest_factory import make_profile
class TestPreTurn:
@pytest.mark.asyncio
async def test_no_message_when_not_stalled(self):
profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
monitor = AntiStallMonitor(profile)
result = await monitor.pre_turn("s1", iteration=1)
assert result is None
@pytest.mark.asyncio
async def test_anti_stall_no_todo(self):
profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=3)
monitor = AntiStallMonitor(profile)
monitor.stall_no_todo = 3
msg = await monitor.pre_turn("s1", iteration=1)
assert msg is not None
assert msg.role == "system"
assert "no todo progress for 3 iterations" in msg.content
assert "Anti-stall warning" in msg.content
@pytest.mark.asyncio
async def test_anti_stall_repeat_tools(self):
profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=3)
monitor = AntiStallMonitor(profile)
monitor.stall_repeat_tools = 3
msg = await monitor.pre_turn("s1", iteration=1)
assert msg is not None
assert "identical tool calls repeated 3 times" in msg.content
@pytest.mark.asyncio
async def test_anti_stall_skipped_on_first_iteration(self):
profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=1)
monitor = AntiStallMonitor(profile)
monitor.stall_no_todo = 10
msg = await monitor.pre_turn("s1", iteration=0)
assert msg is None
@pytest.mark.asyncio
async def test_adaptive_replan_injected_before_anti_stall(self):
"""When both replan_msg and stall are queued, replan takes precedence."""
profile = make_profile(
anti_stall_enabled=True,
anti_stall_threshold=1,
adaptive_replan_enabled=True,
)
monitor = AntiStallMonitor(profile)
monitor.replan_msg = "Please replan."
monitor.stall_no_todo = 5
msg = await monitor.pre_turn("s1", iteration=1)
assert msg is not None
assert "Please replan." in msg.content
assert monitor.replan_msg is None # consumed
@pytest.mark.asyncio
async def test_disabled_anti_stall_returns_none(self):
profile = make_profile(anti_stall_enabled=False, adaptive_replan_enabled=False)
monitor = AntiStallMonitor(profile)
monitor.stall_no_todo = 100
msg = await monitor.pre_turn("s1", iteration=5)
assert msg is None
class TestPostTurn:
@pytest.mark.asyncio
async def test_todo_progress_resets_stall_counter(self):
profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
monitor = AntiStallMonitor(profile)
monitor.stall_no_todo = 5
monitor._todo_snapshot = frozenset({("task1", "pending")})
with patch(
"navi.tools.todo.get_task_snapshot",
new=AsyncMock(return_value=frozenset({("task1", "done")})),
):
await monitor.post_turn("s1", [])
assert monitor.stall_no_todo == 0
@pytest.mark.asyncio
async def test_no_todo_progress_increments_stall_counter(self):
profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
monitor = AntiStallMonitor(profile)
snapshot = frozenset({("task1", "pending")})
monitor._todo_snapshot = snapshot
with patch(
"navi.tools.todo.get_task_snapshot",
new=AsyncMock(return_value=snapshot),
):
await monitor.post_turn("s1", [])
assert monitor.stall_no_todo == 1
@pytest.mark.asyncio
async def test_repeated_tool_calls_increment_repeat_counter(self):
profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
monitor = AntiStallMonitor(profile)
tc = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"})
# first call sets baseline
with patch(
"navi.tools.todo.get_task_snapshot",
new=AsyncMock(return_value=frozenset()),
):
await monitor.post_turn("s1", [tc])
assert monitor.stall_repeat_tools == 0
# identical call again
with patch(
"navi.tools.todo.get_task_snapshot",
new=AsyncMock(return_value=frozenset()),
):
await monitor.post_turn("s1", [tc])
assert monitor.stall_repeat_tools == 1
@pytest.mark.asyncio
async def test_different_tool_calls_reset_repeat_counter(self):
profile = make_profile(anti_stall_enabled=True, anti_stall_threshold=8)
monitor = AntiStallMonitor(profile)
tc1 = ToolCallRequest(id="1", name="fs", arguments={"path": "/tmp"})
tc2 = ToolCallRequest(id="2", name="fs", arguments={"path": "/other"})
with patch(
"navi.tools.todo.get_task_snapshot",
new=AsyncMock(return_value=frozenset()),
):
await monitor.post_turn("s1", [tc1])
await monitor.post_turn("s1", [tc2])
assert monitor.stall_repeat_tools == 0
@pytest.mark.asyncio
async def test_adaptive_replan_queues_message(self):
profile = make_profile(adaptive_replan_enabled=True)
monitor = AntiStallMonitor(profile)
with patch(
"navi.tools.todo.get_task_snapshot",
new=AsyncMock(return_value=frozenset()),
), patch(
"navi.tools.todo.get_failed_steps",
new=AsyncMock(return_value=frozenset({(1, "step A")})),
):
await monitor.post_turn("s1", [])
assert monitor.replan_msg is not None
assert "step 1" in monitor.replan_msg
assert "step A" in monitor.replan_msg
@pytest.mark.asyncio
async def test_adaptive_replan_no_new_failures(self):
profile = make_profile(adaptive_replan_enabled=True)
monitor = AntiStallMonitor(profile)
monitor.known_failed = frozenset({(1, "step A")})
with patch(
"navi.tools.todo.get_task_snapshot",
new=AsyncMock(return_value=frozenset()),
), patch(
"navi.tools.todo.get_failed_steps",
new=AsyncMock(return_value=frozenset({(1, "step A")})),
):
await monitor.post_turn("s1", [])
assert monitor.replan_msg is None
@pytest.mark.asyncio
async def test_disabled_anti_stall_does_not_fetch_snapshot(self):
profile = make_profile(anti_stall_enabled=False, adaptive_replan_enabled=False)
monitor = AntiStallMonitor(profile)
with patch("navi.tools.todo.get_task_snapshot") as mock_snap:
await monitor.post_turn("s1", [])
mock_snap.assert_not_called()