import asyncio import json from dataclasses import replace import pytest from viano_bug_analysis.trial import drive, main, preflight, TrialError from viano_bug_analysis.llm import ProviderUnavailable from viano_bug_analysis.package import PackageReader, PackageValidator from viano_bug_analysis.budget import BudgetManager, BudgetConfig from viano_bug_analysis.lifecycle import State from test_case_builder import case_setup from test_investigation_tools import tool_case from test_case_interpreter import gateway, FixtureAdapter from test_hypothesis_builder import ProposalAdapter from test_investigator import LoopAdapter, CASE from test_diagnosis_builder import FinalAdapter from test_change_request_builder import RequestAdapter class PipelineAdapter: def __init__(self, confirmed=True): self.calls = 0 self.adapters = {"case": FixtureAdapter(), "hypotheses": ProposalAdapter(), "loop": LoopAdapter(), "diagnosis": FinalAdapter(confirmed), "request": RequestAdapter()} async def generate(self, config, request, **kwargs): self.calls += 1 phase = {"case_interpretation_v1": "case", "hypotheses_v1": "hypotheses", "hypotheses_v2": "hypotheses", "hypotheses_v3": "hypotheses", "hypotheses_v4": "hypotheses", "investigation_v1": "loop", "investigation_v2": "loop", "investigation_v3": "loop", "diagnosis_v1": "diagnosis", "diagnosis_v2": "diagnosis", "diagnosis_v3": "diagnosis", "diagnosis_v4": "diagnosis", "diagnosis_v5": "diagnosis", "change_request_v1": "request", "change_request_v2": "request", "change_request_v3": "request", "change_request_v4": "request", "change_request_v5": "request", "change_request_v6": "request"}[request["prompt_version"]] adapter = self.adapters[phase] return await adapter.generate(config, request, **kwargs) @pytest.mark.parametrize("confirmed,expected", [(True, "OUTPUT_READY"), (False, "REVIEW")]) def test_trial_full_investigation_delivery_and_resume(tool_case, confirmed, expected): store, original = tool_case adapter = PipelineAdapter(confirmed) g = gateway(adapter) result = asyncio.run(drive(store, g, *CASE)) assert result["status"] == expected exported = result["exported"] assert exported and PackageValidator().validate(exported).valid with PackageReader(original) as old, PackageReader(exported) as new: for name in old.files: if name != "manifest.json": assert old.read_bytes(name) == new.read_bytes(name) assert new.manifest["package_id"] == old.manifest["package_id"] assert bool(new.manifest["artifacts"].get("change_request")) == confirmed budget = BudgetManager(store).snapshot(*CASE).state calls = adapter.calls replay = asyncio.run(drive(store, g, *CASE)) assert replay["exported"] == exported and adapter.calls == calls assert BudgetManager(store).snapshot(*CASE).state == budget def test_initial_builder_interpretation_and_hypotheses(case_setup): store = case_setup[0] adapter = PipelineAdapter() result = asyncio.run(drive(store, gateway(adapter), *CASE, max_dispatches=3)) assert result["status"] == "YIELDED" assert result["state"] == State.GENERATING_HYPOTHESES assert adapter.calls == 2 @pytest.mark.parametrize("phase,dispatches,calls",[("case",1,0),("interpretation",2,1),("interpretation",10,1)]) def test_stop_after_confirmed_phase_and_safe_repeated_command(case_setup,phase,dispatches,calls): store=case_setup[0] adapter=PipelineAdapter();g=gateway(adapter) result=asyncio.run(drive(store,g,*CASE,max_dispatches=dispatches,stop_after=phase)) assert result["status"]=="PHASE_COMPLETE" and adapter.calls==calls with PackageReader(result["package"]) as reader: assert not reader.manifest["artifacts"].get("hypotheses") replay=asyncio.run(drive(store,g,*CASE,max_dispatches=10,stop_after=phase)) assert replay["package"]==result["package"] and adapter.calls==calls def test_retry_returns_without_sleep_and_blocked_budget_kept(tool_case): store = tool_case[0] adapter = LoopAdapter(unavailable=True) g = gateway(adapter) g.config = replace(g.config, max_attempts=1) result = asyncio.run(drive(store, g, *CASE)) assert result["status"] == "BLOCKED" budget = BudgetManager(store).snapshot(*CASE).state calls = len(adapter.contexts) assert asyncio.run(drive(store, g, *CASE))["status"] == "BLOCKED" assert len(adapter.contexts) == calls assert BudgetManager(store).snapshot(*CASE).state == budget def test_retry_pending_and_early_resume_are_nonblocking(case_setup): store = case_setup[0] result = asyncio.run(drive(store, gateway(FixtureAdapter(ProviderUnavailable("LLM_CONNECTION"))), *CASE)) assert result["status"] == "RETRY_PENDING" and result["retry_not_before"] result = asyncio.run(drive(store, gateway(FixtureAdapter()), *CASE)) assert result["status"] == "DEFERRED" def test_preflight_no_network_no_secret_echo(tmp_path, monkeypatch, capsys): config = tmp_path / "llm.toml" config.write_text('[llm]\nprivacy_profile="cloud_allowed"\n[[llm.providers]]\nid="test"\nmodel="test"\nprivacy="cloud"\nbase_url="https://example.invalid/v1"\napi_key_env="TRIAL_TEST_KEY"\n') monkeypatch.delenv("TRIAL_TEST_KEY", raising=False) assert main(["check", "--llm-config", str(config)]) == 2 assert "LLM_CREDENTIALS" in capsys.readouterr().err monkeypatch.setenv("TRIAL_TEST_KEY", "private-value") assert main(["check", "--llm-config", str(config)]) == 0 output = capsys.readouterr().out assert json.loads(output)["network_tested"] is False assert "private-value" not in output def test_cli_rejects_ambiguous_identity(): with pytest.raises(SystemExit): main(["run", "--package", "one.zip", "--case-id", "a", "--package-id", "b"]) def test_cli_interpretation_stop_exits_successfully(case_setup,monkeypatch,capsys): import viano_bug_analysis.trial as trial store=case_setup[0];adapter=PipelineAdapter() monkeypatch.setattr(trial,"preflight",lambda path:gateway(adapter)) command=["run","--workspace",str(store.workspace.root),"--case-id",CASE[0],"--package-id",CASE[1], "--stop-after","interpretation","--max-dispatches","6"] assert main(command)==0 assert json.loads(capsys.readouterr().out)["status"]=="PHASE_COMPLETE" and adapter.calls==1 assert main(command)==0 and adapter.calls==1 def test_cli_run_connects_telemetry_and_publishes(tool_case, monkeypatch, capsys): import viano_bug_analysis.trial as trial store = tool_case[0] monkeypatch.setattr(trial, "preflight", lambda path: gateway(PipelineAdapter())) assert main(["run", "--workspace", str(store.workspace.root), "--case-id", CASE[0], "--package-id", CASE[1]]) == 0 captured = capsys.readouterr() assert json.loads(captured.out)["status"] == "OUTPUT_READY" assert '"state"' in captured.err directory = store.workspace.runtime / "telemetry" assert (directory / "service.jsonl").is_file() assert 'vba_llm_rounds_total' in (directory / "metrics.prom").read_text() assert json.loads((directory / "status.json").read_text())["active_operations"] == [] def test_human_test_returns_blocked_with_pending_budget(tool_case): store = tool_case[0] adapter = LoopAdapter(test=True) result = asyncio.run(drive(store, gateway(adapter), *CASE, policies={"budget": BudgetConfig(action_costs={"testing.request_human": 50})})) assert result["status"] == "BLOCKED" assert result["routing"]["reason"] == "AWAITING_TEST_FEEDBACK" before = BudgetManager(store).snapshot(*CASE).state assert asyncio.run(drive(store, gateway(adapter), *CASE))["status"] == "BLOCKED" assert before == BudgetManager(store).snapshot(*CASE).state def test_dispatch_yield_resumes_without_repeating_first_model_call(tool_case): store = tool_case[0] adapter = PipelineAdapter() g = gateway(adapter) first = asyncio.run(drive(store, g, *CASE, max_dispatches=1)) assert first["status"] == "YIELDED" and adapter.calls == 1 result = asyncio.run(drive(store, g, *CASE)) assert result["status"] == "OUTPUT_READY" and adapter.calls == 8 def test_invalid_early_model_reply_reaches_bounded_review(case_setup): from test_case_interpreter import payload adapter = FixtureAdapter({**payload(), "unexpected": "invalid"}) result = asyncio.run(drive(case_setup[0], gateway(adapter), *CASE)) assert result["status"] == "REVIEW" assert len(adapter.requests) == 3 def test_cli_corrupt_inbox_is_quarantined_without_model_calls(tmp_path, monkeypatch, capsys): import viano_bug_analysis.trial as trial from viano_bug_analysis.lifecycle import Workspace workspace = Workspace(tmp_path / "work") source = workspace.inbox / "arbitrary.bin" source.write_bytes(b"corrupt zip") adapter = PipelineAdapter() monkeypatch.setattr(trial, "preflight", lambda path: gateway(adapter)) assert main(["run", "--workspace", str(workspace.root), "--package", str(source)]) == 2 assert json.loads(capsys.readouterr().out)["status"] == "QUARANTINED" assert adapter.calls == 0 assert list(workspace.quarantine.glob("*/validation_error.json")) def test_missing_case_is_safe_cli_error(tmp_path, monkeypatch, capsys): import viano_bug_analysis.trial as trial monkeypatch.setattr(trial, "preflight", lambda path: gateway(PipelineAdapter())) assert main(["run", "--workspace", str(tmp_path / "work"), "--case-id", "missing", "--package-id", "missing"]) == 2 output = capsys.readouterr().err assert '"CASE_NOT_FOUND"' in output and "Traceback" not in output @pytest.mark.parametrize("limit", [0, -1, True]) def test_dispatch_limit_is_controlled(tool_case, limit): with pytest.raises(TrialError, match="TRIAL_DISPATCH_LIMIT"): asyncio.run(drive(tool_case[0], gateway(LoopAdapter()), *CASE, max_dispatches=limit))