Testing a scenario¶
glossogen.testing runs a scenario the way the platform runs it, with the LLM
replaced by a script. It ships in the package, so a scenario in your own
distribution is tested the same way the built-in ones are.
Async tests need this in your pyproject.toml, or every one of them errors with
"async def functions are not natively supported":
The contract check: glossogen validate¶
Run this before writing any test. It reports every failure rather than the first and exits non-zero, so it belongs in your CI whether or not you write tests.
glossogen validate ./reactor-purge # a directory: no install needed
glossogen validate reactor_purge # an installed scenario, by name
A name resolves through installed entry-point metadata, so the package has to be
installed. A directory is read straight from its own pyproject.toml, which is
what to use while editing. The two cannot be confused: a scenario name is a bare
lowercase identifier, so anything holding a dot or a slash is a path.
It builds the scenario from every preset it ships and checks what Python cannot. Each check catches a failure that is silent at the time it happens:
| Check | The silent failure it catches |
|---|---|
Agents claim only channels that exist; get_agent_roles agrees with the agents built |
A roster the web UI and the metrics disagree on |
| Every preset validates; every round's injection renders | A misspelled template variable renders a briefing with a hole in it, and the run looks plausible for fifteen rounds |
Every event declares a unique event_type, and the parser builds over platform events plus yours |
A repeated name shadows one side of the parser: the run writes fine and reads back as the other thing |
events.py imports only glossogen.models.event_base (checked in source, not by importing) |
The event union imports your events.py while mid-import, so a wider import fails the whole platform at startup |
| A probe or explanation config points at files that exist | A missing question bank makes that metric family report nothing, exactly like a run with nothing to measure |
get_judge_models is readable |
The launch check asks it which credentials a run needs |
Directory form only: package-data covers prompts and presets |
Your editable install works and the wheel you hand out renders nothing |
Directory form only: the entry-point group is glossogen.scenarios.v1 |
Declared under another version, the scenario is not refused, it is absent |
Directory form only: the package __init__.py is empty |
Event discovery imports it mid-cycle |
| Directory form only: the name is not already taken | A name held by a built-in stays with the built-in, so validating a second veyru by name reports the healthy built-in |
It needs no API key. Provider credentials are hidden while each preset builds, so a scenario that reaches for one at construction fails here rather than in someone else's environment.
It renders every round's injection rather than only the first, because scenarios swap
templates per round and a template first reached at round 12 would otherwise cost
eleven rounds to discover. What it cannot reach is the branch reading a previous
round's outcome: nothing has been played, so that branch belongs to run_rounds.
Two rounds are enough, because round two has a round one behind it.
The registration check¶
To compare the class the entry point resolves against your own, which needs the class in hand rather than a name:
from glossogen.testing import assert_scenario_is_registered
from my_scenarios.reactor_purge.scenario import ReactorPurgeScenario
def test_the_entry_point_resolves_to_this_class() -> None:
assert_scenario_is_registered(scenario_cls=ReactorPurgeScenario)
It reads installed entry-point metadata, so run it against an installed package.
The prompt linter¶
make lint-server runs linter/check_prompt_templates.py over every template in
the repository. It applies to a repository clone only, for scenarios developed
in-tree; a scenario in its own package has no equivalent. It is not
scenario-aware and needs nothing built, so it catches a different set:
| Catch | Otherwise surfaces |
|---|---|
| A template that does not parse | After the run directory is claimed and the agents connected |
An {% include %} naming a partial outside the search directory |
Names resolve against that directory, not the including template |
| A template nothing renders or includes | Somebody edits a prompt believing it is live |
| A name in shipped code that no template answers to | At render, in someone else's run |
It does not check undeclared variables: scenarios assemble template variables in
helpers, so the set a template renders with is not decidable from the call site.
StrictUndefined answers that exactly, at render. Prompt-sized string literals in
scenario Python are reported as advisory and do not fail the build.
The round loop¶
validate proves a scenario builds. It never starts the game clock. run_rounds
closes that: tool dispatch, runtime, clock, event logger and your world are all
real, and only the model is scripted.
from pathlib import Path
import pytest
from glossogen.testing import assert_no_agent_crashed, assert_round_loop_completed, run_rounds
async def test_the_round_loop_runs(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
result = await run_rounds(
scenario_name="reactor_purge",
preset_name="knobs_default",
round_count=2,
overrides={},
tmp_path=tmp_path,
monkeypatch=monkeypatch,
)
assert_round_loop_completed(result=result, round_count=2)
assert_no_agent_crashed(result=result)
The preset is required for the same reason --config is required on run: the
test exercises a configuration you actually ship. Each agent is routed to a primary channel it
belongs to, read from the scenario, so a two-team scenario sends on both team
channels without the test naming either.
Each agent chats exactly once per round. That is paced, not merely budgeted: every send sits behind a round gate that reads the simulation's own round counter, so which round a message lands in does not depend on how the machine schedules the agents' cycles, and two runs place every message identically.
| Piece | For |
|---|---|
run_rounds |
The whole loop from a preset name, N rounds of scripted chatter |
build_scenario + run_scenario |
The same paced chatter against a scenario you built yourself |
chat_script, ToolTurn, SayTurn |
The script: a ToolTurn calls one of your tools with arguments you choose, driving the world into a state the round verdict depends on |
assert_agents_chatted_every_round |
The paced contract as a check: one primary-channel message per agent per round |
SimulationResult |
The event log as typed events, your scenario's own included; of_type(event_type=RoundResultRecorded), messages_on and failed_tool_calls are what assertions read |
Why it does not wait¶
A live run waits MIN_ROUND_DURATION_SECONDS after the last message to guess
whether the agents have finished. A test wrote the scripts, so the harness
answers that question directly instead of sleeping, and it replaces the
provider-backed token counter that would otherwise post every message to a
provider. A test that races real time passes on a quiet machine and fails under
load, and the failure reads as a bug in the code under test. See "Tests and time"
in CLAUDE.md.
Testing a metric¶
metric_harness scores a finished run through run_scenario_evaluation, the
same function glossogen evaluate calls. A metric is exercised across the JSONL
reader, the transcript builder, the registry, the report merge and the cost
accounting rather than against a hand-built list of events.
Only the judge is replaced. Deterministic metrics never notice. LLM-judge metrics
get a StubLLMProvider whose answers the test chose. The interesting assertion
is usually about what the judge was shown, since the answers were chosen.
SmokeScenario is a two-agent, one-channel scenario to score against when your
metric is not tied to a domain of your own.