"""Regression tests for Hardening Sprint 2 — bridge layer.""" from __future__ import annotations import json import sys import time from pathlib import Path from unittest.mock import patch import pytest sys.path.insert(0, str(Path(__file__).resolve().parents[1])) # --------------------------------------------------------------------------- # 1. Dynamic Ollama availability — fallback / recovery # --------------------------------------------------------------------------- class TestOllamaAvailability: def setup_method(self): import controller.decision_model as dm dm._OLLAMA_CACHE = None # always start clean def test_unavailable_returns_ollama_unavailable_mode(self): import controller.decision_model as dm # Force unavailable via cache dm._OLLAMA_CACHE = (time.monotonic(), False) d = dm.decide("inspect project", 0, None, 3) assert d.mode == "ollama-unavailable", f"Expected ollama-unavailable, got {d.mode}" assert d.action in ("run_agent", "stop") def test_available_uses_ollama_path(self): import controller.decision_model as dm # Force available via cache — real Ollama call will follow dm._OLLAMA_CACHE = (time.monotonic(), True) # Patch _decide_ollama to avoid real HTTP, just confirm it was called with patch.object(dm, "_decide_ollama", wraps=dm._decide_ollama) as mock_ollama: # Patch ollama_adapter.execute to return valid JSON without real call from adapters import ollama_adapter from adapters.base import AdapterResult with patch.object(ollama_adapter, "execute", return_value=AdapterResult( response='{"action":"run_agent","goal":"inspect project","reason":"test"}', error=None, exit_code=0, mode="real", metadata={}, )): d = dm.decide("inspect project", 0, None, 3) mock_ollama.assert_called_once() assert d.mode == "ollama" assert d.parsed_success is True def test_cache_expiry_triggers_recheck(self): """After cache TTL, _ollama_available() re-checks; no restart needed.""" import controller.decision_model as dm # Set cache as unavailable but expired (ts far in the past) dm._OLLAMA_CACHE = (time.monotonic() - dm._OLLAMA_CACHE_TTL - 1, False) # Patch the HTTP call to return True (Ollama "came back up") with patch("urllib.request.urlopen", return_value=None): result = dm._ollama_available() assert result is True, "Cache expired → should re-check and see Ollama available" assert dm._OLLAMA_CACHE[1] is True, "Cache must be updated to True after re-check" def test_cache_is_reused_within_ttl(self): """Within TTL, no HTTP call is made.""" import controller.decision_model as dm dm._OLLAMA_CACHE = (time.monotonic(), False) # unavailable, fresh cache with patch("urllib.request.urlopen") as mock_urlopen: result = dm._ollama_available() mock_urlopen.assert_not_called() assert result is False # --------------------------------------------------------------------------- # 2. bridge_run_error audit event — emission and order # --------------------------------------------------------------------------- class TestBridgeRunErrorAudit: def test_exception_emits_bridge_run_error(self, tmp_path): """Force exception in execute_run → audit must contain bridge_run_error.""" import controller.decision_model as dm from controller.controller_loop import RUNS_DIR, run_controller from core.run_manager import RunManager # Override RUNS_DIR to tmp_path so we don't pollute real runs with patch("controller.controller_loop.RUNS_DIR", tmp_path), \ patch("controller.decision_model._OLLAMA_CACHE", (time.monotonic(), False)), \ patch.object(RunManager, "execute_run", side_effect=RuntimeError("injected")): state = run_controller("inspect project", run_id="test-audit-gap") audit_path = tmp_path / "test-audit-gap" / "controller_audit.jsonl" assert audit_path.exists() events = [json.loads(l) for l in audit_path.read_text().splitlines()] event_types = [e["event_type"] for e in events] assert "bridge_run_error" in event_types def test_audit_event_order_on_exception(self, tmp_path): """Order guarantee: bridge_run_started → bridge_run_error → bridge_run_finished.""" import controller.decision_model as dm from controller.controller_loop import run_controller from core.run_manager import RunManager with patch("controller.controller_loop.RUNS_DIR", tmp_path), \ patch("controller.decision_model._OLLAMA_CACHE", (time.monotonic(), False)), \ patch.object(RunManager, "execute_run", side_effect=RuntimeError("injected")): run_controller("inspect project", run_id="test-audit-order") events = [ json.loads(l) for l in (tmp_path / "test-audit-order" / "controller_audit.jsonl").read_text().splitlines() ] types = [e["event_type"] for e in events] # Find positions of the three events for iteration 0 idx_started = next(i for i, t in enumerate(types) if t == "bridge_run_started") idx_error = next(i for i, t in enumerate(types) if t == "bridge_run_error") idx_finished = next(i for i, t in enumerate(types) if t == "bridge_run_finished") assert idx_started < idx_error < idx_finished, ( f"Expected started < error < finished, got positions {idx_started} {idx_error} {idx_finished}" ) def test_exception_result_has_failed_status(self, tmp_path): """Synthetic result on exception must have status=failed, agent_final_status=failed.""" import controller.decision_model as dm from controller.controller_loop import run_controller from core.run_manager import RunManager with patch("controller.controller_loop.RUNS_DIR", tmp_path), \ patch("controller.decision_model._OLLAMA_CACHE", (time.monotonic(), False)), \ patch.object(RunManager, "execute_run", side_effect=RuntimeError("injected")): state = run_controller("inspect project", run_id="test-audit-result") assert len(state.bridge_run_results) > 0 br = state.bridge_run_results[0] assert br["status"] == "failed" assert br["agent_final_status"] == "failed"