"""MAJOR-2 (docs/2026-08-25-syretest-vei-ab.md) — ``--explore --scripted-replies`` must not crash with a raw ``KeyError: 'navigator'``. ``_SCRIPTED_ROLES = ("proposer", "checker")`` (``run.py:1531``) is the debate's two roles. ``explore()`` asks the SAME ``client_factory`` for three more: ``manager``, ``navigator``, ``hypothesiser`` (``explore.py:576``). ``_load_scripted_replies`` was fail-fast for the two roles it knew about — measured (docs/2026-08-25-syretest-vei-ab.md § MAJOR-2) to let the three it did not know about surface exactly the ``KeyError`` deep inside ``scripted_factory``'s lookup its own docstring warns against, mid-run, after the banner had already printed. The fix widens the required-role set to include the exploration's three roles WHEN ``--explore`` is in play, so a missing role is refused BY NAME before any model/agent work starts — the same door, never a second one. """ from __future__ import annotations import json from pathlib import Path from typing import Any from portfolio_optimiser import run _BUNDLE_DIR = Path(__file__).resolve().parents[1] / "shared" / "examples" / "bygg-energi-mikro" _PID = "BYGG-KONTOR-NORD" _PROPOSER_REPLY = json.dumps( { "measure": "LED-retrofit", "affected_items": [{"code": "ENERGI-TOTAL-EL", "quantity": 300000, "unit_cost": 1.0}], "claimed_saving_nok": 30000, } ) def _config_file(tmp_path: Path, **overrides: Any) -> str: path = tmp_path / "exploration.json" path.write_text( json.dumps( { "max_rounds": 4, "max_tokens": 100_000, "max_stall_count": 2, "max_reset_count": 1, "max_plan_revisions": 0, "enable_plan_review": False, **overrides, } ), encoding="utf-8", ) return str(path) def _replies_file(tmp_path: Path, roles: dict[str, str]) -> str: path = tmp_path / "replies.json" path.write_text(json.dumps(roles), encoding="utf-8") return str(path) def _base_argv(tmp_path: Path, replies_path: str) -> list[str]: return [ _PID, "--docs-dir", str(_BUNDLE_DIR), "--bundle-dir", str(_BUNDLE_DIR), "--explore", "Find the cheapest saving.", "--explore-config", _config_file(tmp_path), "--scripted-replies", replies_path, ] def test_explore_with_debate_only_scripted_replies_is_refused_by_name(tmp_path, capsys) -> None: """A ``--scripted-replies`` file that only answers the debate (proposer/checker) — exactly the file MAJOR-2 was measured against — is refused BY NAME, never left to crash mid-run. Detach point: revert ``_SCRIPTED_ROLES`` to the fixed debate-only tuple used for ``--explore`` too → this raises an unhandled ``KeyError`` instead of returning 1 (RED, reproduces MAJOR-2). """ replies = _replies_file(tmp_path, {"proposer": _PROPOSER_REPLY, "checker": "VERDICT: APPROVE"}) rc = run.main(_base_argv(tmp_path, replies)) assert rc == 1, "a role the exploration needs is missing — this must be a clean refusal" err = capsys.readouterr().err assert "run refused" in err for role in ("manager", "navigator", "hypothesiser"): assert role in err, f"the refusal must name the missing role {role!r}" def test_explore_with_debate_only_scripted_replies_never_reaches_a_model_call( tmp_path, capsys, monkeypatch ) -> None: """The refusal fires at the DOOR — before ``explore()`` is even entered. A spy on ``explore`` proves zero exploration work happened, the same way econ 57's outbox/run-id hoist was proved. Detach point: let the flag through and catch the ``KeyError`` further in → this spy would still record a call (RED). """ calls: list[object] = [] monkeypatch.setattr( "portfolio_optimiser.run.explore", lambda *a, **kw: ( calls.append((a, kw)) or (_ for _ in ()).throw(AssertionError("unreached")) ), ) replies = _replies_file(tmp_path, {"proposer": _PROPOSER_REPLY, "checker": "VERDICT: APPROVE"}) rc = run.main(_base_argv(tmp_path, replies)) assert rc == 1 assert calls == [], "explore() must never be entered when a required role is missing" def test_explore_with_the_full_five_role_scripted_replies_does_not_crash(tmp_path, capsys) -> None: """With every role ``explore()`` can ask for supplied as a constant string, the CLI door must run the loop to completion (or a typed budget/refusal outcome) — never a raw traceback. A constant per-role reply cannot answer every stage-specific shape the magentic manager can be prompted with (facts / plan / progress-ledger JSON / final answer all differ) — so this does not assert the exploration finds anything, only that the documented crash is gone. """ replies = _replies_file( tmp_path, { "proposer": _PROPOSER_REPLY, "checker": "VERDICT: APPROVE", "manager": '{"is_request_satisfied": {"reason": "r", "answer": true}, ' '"is_in_loop": {"reason": "r", "answer": false}, ' '"is_progress_being_made": {"reason": "r", "answer": true}, ' '"next_speaker": {"reason": "r", "answer": "hypothesiser"}, ' '"instruction_or_question": {"reason": "r", "answer": "go"}}', "navigator": "NAVIGATOR: read the index.", "hypothesiser": "HYPOTHESIS: " + json.dumps({"label": "x", "rationale": "y"}), }, ) rc = run.main(_base_argv(tmp_path, replies)) err = capsys.readouterr().err assert "KeyError" not in err assert "Traceback" not in err assert rc in (0, 1), f"expected a clean exit, got rc={rc} stderr={err!r}"