From 7b08b37da3b3b6f370df282a67912e9a88896af5 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Fri, 26 Jun 2026 00:42:51 +0200 Subject: [PATCH] test(fase2): assert BudgetMiddleware short-circuits a real chat call + the debate --- tests/test_budget.py | 22 +++++++++++++++++++--- tests/test_vertical_slice_e2e.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/tests/test_budget.py b/tests/test_budget.py index 058776e..619d6cd 100644 --- a/tests/test_budget.py +++ b/tests/test_budget.py @@ -33,9 +33,7 @@ async def _noop() -> None: def _resp(total: int | None) -> ChatResponse: usage = UsageDetails(total_token_count=total) if total is not None else None - return ChatResponse( - messages=[Message(role="assistant", contents=["x"])], usage_details=usage - ) + return ChatResponse(messages=[Message(role="assistant", contents=["x"])], usage_details=usage) async def test_meter_reads_total_token_count_and_accumulates() -> None: @@ -69,6 +67,24 @@ async def test_strict_usage_none_hard_fails() -> None: await mw.process(_Ctx(_resp(None)), _noop) # type: ignore[arg-type] +async def test_budget_middleware_fires_on_real_agent_chat(make_client_factory) -> None: + """F8 (real-client half): registering BudgetMiddleware on a real Agent (layered chat client + that carries ChatMiddlewareLayer) and running an actual chat call short-circuits with + BudgetExceeded — the middleware<->client integration the prior suite never exercised (the + minimal-base stand-in silently no-ops the middleware).""" + from agent_framework import Agent + + client = make_client_factory("ok", tokens=8)("proposer") # 8 tokens/reply > cap 5 + meter = TokenMeter(Budget(max_tokens=5, max_rounds=10)) + agent = Agent( + client, "propose a measure", name="proposer", middleware=[BudgetMiddleware(meter)] + ) + with pytest.raises(BudgetExceeded) as exc: + await agent.run("hi") + assert exc.value.kind == "tokens" + assert meter.tokens == 8 # charged from the synthetic UsageDetails via the middleware + + def test_no_word_count_token_proxy_in_src() -> None: # The meter is fed from real UsageDetails, never a len(text.split()) word-count proxy # (research 03 Rec 3 — the Fase 1 _word_tokens anti-pattern is retired). NOTE: a bare diff --git a/tests/test_vertical_slice_e2e.py b/tests/test_vertical_slice_e2e.py index 80af0b9..c903883 100644 --- a/tests/test_vertical_slice_e2e.py +++ b/tests/test_vertical_slice_e2e.py @@ -169,6 +169,36 @@ async def test_g_validated_proposal_derives_from_debate( assert isinstance(result.outcome, ValidatedProposal) +async def test_h_tiny_budget_halts_via_debate_middleware( + docs_dir, make_client_factory, fresh_store, monkeypatch +) -> None: + """F8 (debate-path half): a tiny budget must short-circuit in the DEBATE middleware, not the + later generate loop. Fence generation — if generate_via_llm is reached, the debate did NOT + short-circuit, so fail. Because generate_via_llm independently raises BudgetExceeded, a plain + pytest.raises would pass even with the debate middleware detached; the fence makes this test + load-bearing (detaching the middleware fails it).""" + from portfolio_optimiser import run as run_mod + + async def _forbidden(*args, **kwargs): + raise AssertionError( + "generate_via_llm reached — debate did not short-circuit via middleware" + ) + + monkeypatch.setattr(run_mod, "generate_via_llm", _forbidden) + with pytest.raises(BudgetExceeded): + await run_project( + "FV42-GSV-E1", + "local", + docs_dir=docs_dir, + verdict_input=_VI, + client_factory=make_client_factory( + _VALID, tokens=100 + ), # 100 > cap 10, on the FIRST debate call + store=fresh_store, + max_tokens=10, + ) + + async def test_f_malformed_contract_raises_before_any_chat(docs_dir, make_client_factory) -> None: calls = {"n": 0} base = make_client_factory(_VALID)