052591281d
- Complete agent system with planning and execution - Bridge API with FastAPI server - WhatsApp-style mobile dashboard - Enhanced web search with query improvement - Security guards and policy checks - Comprehensive documentation
135 lines
6.4 KiB
Python
135 lines
6.4 KiB
Python
"""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"
|