_SCRIPTED_ROLES = ("proposer", "checker") var det ENESTE rollesettet
_load_scripted_replies validerte OG returnerte. explore()s tre ekstra
roller (manager/navigator/hypothesiser) ble filtrert bort selv når de
fantes i --scripted-replies-JSON-en, og manglet en av dem krasjet CLI-en
med en rå KeyError midt i eksplorasjonssløyfa i stedet for en ren
"run refused:"-linje (MAJOR-2, docs/2026-08-25-syretest-vei-ab.md).
_EXPLORATION_SCRIPTED_ROLES legges nå til kravet KUN når --explore er
satt, slik at en manglende rolle nektes VED NAVN ved døren, før
explore() kalles. En rein debattkjøring skal ikke måtte svare for
roller den aldri bruker.
Målt (ikke bare antatt): med alle fem roller scriptet fullfører CLI-en
uten krasj, men sløyfa er fortsatt delvis vakuøs (1 runde, 0 approaches)
som syretesten forutså — en konstant streng per rolle kan ikke svare
korrekt på magentic-managerens stadiespesifikke former.
1028 passed / 5 skipped (+3 nye tester, RØD→GRØNN). Golden
demo-transcript byte-uendret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YSHNrYvKxDR5uct1QoVZng
147 lines
5.7 KiB
Python
147 lines
5.7 KiB
Python
"""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}"
|