import pytest from arena.codebase_archive import LocalCodebaseArchiveStore from arena.event_bus import EventBus from arena.models import Criteria from arena.run_flow import ( RunArchiveNotFound, RunFlow, ) from arena.run_models import CodebaseArchive, RunEvent, RunRequest, RunTask from arena.run_store import InMemoryRunStore from arena.swarm_runtime import SwarmRuntime, SwarmRunResult from arena.validator_graph import DeterministicValidatorExecutor, RubricReview, ValidatorGraph from arena.validator_plan import validation_checks_from_user_tests from conftest import make_local_session def make_flow(archive_store=None, validator_graph=None): store = archive_store or LocalCodebaseArchiveStore() event_bus = EventBus() return RunFlow( run_store=InMemoryRunStore(), swarm_runtime=SwarmRuntime( session_factory=make_local_session, archive_store=store, event_bus=event_bus, ), validator_graph=validator_graph or ValidatorGraph( executor=DeterministicValidatorExecutor(), rubric_enabled=False, ), archive_store=store, event_bus=event_bus, ) def test_run_flow_creates_run_and_saves_artifacts() -> None: archive_store = LocalCodebaseArchiveStore() flow = make_flow(archive_store) access = flow.create_run(RunRequest(prompt="Build a CLI", user_tests=["echo ok"])) view = flow.get_run(access.run.id) assert access.run.status == "completed" assert view.status == "completed" assert view.tasks[0].status == "completed" assert "Built the app" in view.summary assert view.codebase_archive is not None assert view.codebase_archive.pointer.startswith("local-snapshot://") assert view.validation_checks == validation_checks_from_user_tests(["echo ok"]) assert view.validation_report is not None def test_run_flow_validates_run_and_marks_completed() -> None: archive_store = LocalCodebaseArchiveStore() flow = make_flow(archive_store) access = flow.create_run(RunRequest(prompt="Build a CLI", user_tests=["echo ok"])) report = flow.validate_run(access.run.id) view = flow.get_run(access.run.id) assert report.passed is True assert report.run_id == access.run.id assert view.status == "completed" assert view.validation_report is not None def test_run_flow_zip_download_contains_workspace_files() -> None: flow = make_flow() access = flow.create_run(RunRequest(prompt="Build a CLI", user_tests=[])) payload = flow.get_codebase_zip(access.run.id) assert payload.filename == f"{access.run.id}.zip" assert payload.content_type == "application/zip" assert payload.data.startswith(b"PK") def test_run_flow_requires_archive_before_download_or_validation() -> None: flow = make_flow() access = flow.run_store.create_run(RunRequest(prompt="Build")) with pytest.raises(RunArchiveNotFound): flow.get_codebase_zip(access.run.id) with pytest.raises(RunArchiveNotFound): flow.validate_run(access.run.id) def test_run_flow_rejoin_restores_access() -> None: flow = make_flow() access = flow.create_run(RunRequest(prompt="Build a CLI", user_tests=[])) rejoined = flow.rejoin(access.token) assert rejoined.run.id == access.run.id assert rejoined.token == access.token def test_run_flow_cancel_run_is_noop_for_completed_runs() -> None: flow = make_flow() access = flow.create_run(RunRequest(prompt="Build a CLI", user_tests=[])) cancelled = flow.cancel_run(access.run.id) assert cancelled.status == "completed" assert flow.get_run(access.run.id).status == "completed" def test_run_flow_cancel_run_closes_swarm_runtime() -> None: flow = make_flow() access = flow.run_store.create_run(RunRequest(prompt="Build a CLI", user_tests=[])) closed: list[str] = [] flow.swarm_runtime.close_run = lambda run_id: closed.append(run_id) cancelled = flow.cancel_run(access.run.id) assert closed == [access.run.id] assert cancelled.status == "interrupted" def test_run_flow_add_run_message_touches_runtime_and_records_event() -> None: flow = make_flow() access = flow.create_run(RunRequest(prompt="Build a CLI", user_tests=[])) touched: list[str] = [] flow.swarm_runtime.touch_run = lambda run_id: touched.append(run_id) or True view = flow.add_run_message(access.run.id, "More context") assert touched == [access.run.id] assert view.events[-1].message == "User: More context" def test_run_flow_start_run_streams_late_lifecycle_events() -> None: import time flow = make_flow() access = flow.start_run(RunRequest(prompt="Build a CLI", user_tests=["echo ok"])) queue = flow.subscribe_events(access.run.id) deadline = time.time() + 5.0 while time.time() < deadline and flow.get_run(access.run.id).status != "completed": time.sleep(0.05) events = _drain(queue, timeout=1.0) kinds = [event["kind"] for event in events] assert "validating" in kinds assert "validated" in kinds def test_run_flow_persists_runtime_events_while_swarm_is_running() -> None: store = InMemoryRunStore() archive_store = LocalCodebaseArchiveStore() event_bus = EventBus() runtime = PublishingRuntime(event_bus=event_bus, run_store=store) flow = RunFlow( run_store=store, swarm_runtime=runtime, validator_graph=FakeValidatorGraph(), archive_store=archive_store, event_bus=event_bus, ) access = flow.create_run(RunRequest(prompt="Build a CLI", user_tests=[])) assert runtime.live_view is not None assert runtime.live_view.tasks[0].status == "active" assert runtime.live_view.events[0].message == "Coordinator started work" view = flow.get_run(access.run.id) assert [event.id for event in view.events] == ["event-live"] def test_run_flow_subscribe_events_before_start_run_misses_early_events() -> None: """Behavior contract: events fire-and-forget; subscribing after a run finishes gets nothing.""" flow = make_flow() access = flow.create_run(RunRequest(prompt="Build a CLI", user_tests=["echo ok"])) queue = flow.subscribe_events(access.run.id) events = _drain(queue, timeout=0.2) assert events == [] def test_run_flow_validate_run_returns_cached_report_on_second_call() -> None: flow = make_flow() access = flow.create_run(RunRequest(prompt="Build a CLI", user_tests=["echo ok"])) first = flow.validate_run(access.run.id) second = flow.validate_run(access.run.id) assert first is second def test_run_flow_passes_prompt_and_criteria_to_rubric_reviewer() -> None: captured: dict = {} class CapturingReviewer: def review(self, *, prompt, criteria, check_results): captured["prompt"] = prompt captured["criteria"] = list(criteria) return RubricReview(score=1.0, feedback="ok", risks=[]) flow = make_flow( validator_graph=ValidatorGraph( executor=DeterministicValidatorExecutor(), rubric_reviewer=CapturingReviewer(), rubric_enabled=True, ) ) flow.create_run( RunRequest( prompt="Build a CLI", criteria=[Criteria(text="pytest must pass")], user_tests=["echo ok"], ) ) assert captured["prompt"] == "Build a CLI" assert len(captured["criteria"]) == 1 assert captured["criteria"][0].text == "pytest must pass" def test_run_flow_skips_rubric_when_validator_graph_has_it_disabled() -> None: captured: dict = {"calls": 0} class CountingReviewer: def review(self, *, prompt, criteria, check_results): captured["calls"] += 1 return RubricReview(score=1.0, feedback="ok", risks=[]) flow = make_flow( validator_graph=ValidatorGraph( executor=DeterministicValidatorExecutor(), rubric_reviewer=CountingReviewer(), rubric_enabled=False, ) ) report = flow.create_run( RunRequest(prompt="Build a CLI", user_tests=["echo ok"]) ).run.validation_report assert captured["calls"] == 0 assert report is not None assert report.rubric_score is None def _drain(queue, *, timeout: float) -> list[dict]: import queue as queue_module events: list[dict] = [] while True: try: event = queue.get(timeout=timeout) except queue_module.Empty: break if event is None: break events.append(event) return events class FakeValidatorGraph: def validate(self, **kwargs): from arena.validation_models import ValidationReport return ValidationReport(run_id=kwargs["run_id"], passed=True, summary="ok") class PublishingRuntime: def __init__(self, *, event_bus: EventBus, run_store: InMemoryRunStore) -> None: self.event_bus = event_bus self.run_store = run_store self.live_view = None def start_run(self, run): return object() def execute_run(self, run_id: str, **kwargs) -> SwarmRunResult: task = RunTask(id="task-live", title="Build codebase", status="active") event = RunEvent(id="event-live", message="Coordinator started work") self.event_bus.publish(run_id, {"kind": "task_created", "task": task.model_dump()}) self.event_bus.publish(run_id, {"kind": "event", "event": event.model_dump(mode="json")}) self.live_view = self.run_store.get_run_view(run_id) return SwarmRunResult( summary="Created the app.", tasks=[task.model_copy(update={"status": "completed"})], events=[event], codebase_archive=CodebaseArchive( pointer="local-snapshot://run-1", file_count=1, size_bytes=10, ), ) def close_run(self, run_id: str) -> None: pass def touch_run(self, run_id: str) -> bool: return True