| """Mock-free tests for the Fishbowl Lab composer (Unit 8). |
| |
| Cover both surfaces: ``build_lab`` returns the expected handles inside a Blocks, and |
| ``collect_world_config`` assembles a real scenario's data into a validated WorldConfig |
| (round-tripping through ``validate_world`` / ``validate_scenario``) without mutating the |
| shared registry. Model selection is constrained to the Modal catalogue and pins each |
| agent's ``model_endpoint`` (ADR-0022). |
| """ |
|
|
| from __future__ import annotations |
|
|
| import gradio as gr |
| import pytest |
|
|
| from src.core.config import WorldConfig |
| from src.core.registry import default_registry |
| from src.models import inference, modal_catalogue |
| from src.ui.fishbowl import lab |
|
|
| EXPECTED_HANDLE_KEYS = { |
| "inference_backend", |
| "scenario", |
| "premise", |
| "seed", |
| "world", |
| "narrator", |
| "cast_models", |
| "cast_tools", |
| "cast_personas", |
| "cast_schedules", |
| "cast_roster", |
| "judge_policy", |
| "judge_model", |
| "judge_strictness", |
| "tools", |
| "max_turns", |
| "max_calls_per_turn", |
| "max_total_tokens", |
| "hourly_budget_usd", |
| "summon_btn", |
| "surprise_btn", |
| } |
|
|
| |
| _CATALOGUE_KEYS = [e["key"] for e in modal_catalogue.entries()] |
|
|
|
|
| def test_build_lab_returns_expected_handles(): |
| with gr.Blocks(): |
| with gr.Tab("The Lab"): |
| handles = lab.build_lab() |
|
|
| assert set(handles) == EXPECTED_HANDLE_KEYS |
| |
| assert isinstance(handles["inference_backend"], gr.Radio) |
| backend_values = {c[1] for c in handles["inference_backend"].choices} |
| assert backend_values == {b.key for b in inference.backends()} |
| assert handles["inference_backend"].value == inference.DEFAULT_BACKEND |
| assert isinstance(handles["scenario"], gr.Radio) |
| |
| assert isinstance(handles["seed"], gr.Textbox) |
| assert isinstance(handles["narrator"], gr.Dropdown) |
| |
| assert isinstance(handles["cast_models"], gr.State) |
| assert isinstance(handles["cast_tools"], gr.State) |
| assert isinstance(handles["cast_personas"], gr.State) |
| assert isinstance(handles["cast_schedules"], gr.State) |
| |
| assert isinstance(handles["tools"], gr.State) |
| |
| assert isinstance(handles["cast_roster"], gr.CheckboxGroup) |
| assert isinstance(handles["judge_strictness"], gr.Slider) |
| assert isinstance(handles["summon_btn"], gr.Button) |
| assert isinstance(handles["surprise_btn"], gr.Button) |
|
|
|
|
| def test_judge_model_dropdown_offers_only_catalogue_models(): |
| with gr.Blocks(): |
| handles = lab.build_lab() |
| assert isinstance(handles["judge_model"], gr.Dropdown) |
| values = {c[1] for c in handles["judge_model"].choices} |
| assert values <= set(_CATALOGUE_KEYS) |
| assert values, "judge model dropdown should list the catalogue" |
|
|
|
|
| def test_model_choices_are_catalogue_keys_minus_ui_disabled(): |
| choices = lab.model_choices() |
| keys = {key for _label, key in choices} |
| |
| assert keys == set(_CATALOGUE_KEYS) - set(lab._DISABLED_MODELS) |
| |
| |
| assert keys.isdisjoint(lab._DISABLED_MODELS) |
| |
| assert all(" · " in label for label, _ in choices) |
|
|
|
|
| def test_model_choices_hf_backend_offers_qualified_hf_keys(): |
| choices = lab.model_choices("hf") |
| keys = {key for _label, key in choices} |
| assert keys, "the HF catalogue should offer at least one model" |
| |
| assert all(key.startswith("hf:") for key in keys) |
| assert all(inference.split_key(key)[0] == "hf" for key in keys) |
| assert keys.isdisjoint(set(_CATALOGUE_KEYS)) |
|
|
|
|
| def test_collect_world_config_pins_hf_models_as_endpoints(): |
| registry = default_registry() |
| scenario = registry.scenarios["thousand-token-wood"] |
| worker = next(n for n in scenario.cast if registry.agents[n].role != "judge") |
| judge = next(n for n in scenario.cast if registry.agents[n].role == "judge") |
| hf_choices = lab.model_choices("hf") |
| worker_key, judge_key = hf_choices[0][1], hf_choices[-1][1] |
|
|
| world = lab.collect_world_config( |
| scenario=scenario.title, |
| premise="A new whimsical premise for the wood.", |
| seed=scenario.default_seed, |
| cast_models={worker: worker_key}, |
| judge_policy="Majority Vote", |
| judge_model=judge_key, |
| judge_strictness=60, |
| tools=[], |
| tokens=120_000, |
| max_rounds=25, |
| backend="hf", |
| ) |
|
|
| by_name = {a.name: a for a in world.agents} |
| |
| assert by_name[worker].model_endpoint == worker_key |
| assert by_name[judge].model_endpoint == judge_key |
| assert inference.split_key(worker_key)[0] == "hf" |
|
|
|
|
| def test_cast_defaults_cover_non_judge_cast_with_catalogue_keys(): |
| registry = default_registry() |
| scenario = registry.scenarios["thousand-token-wood"] |
| defaults = lab._cast_defaults(scenario) |
| judge_names = {n for n in scenario.cast if (registry.agents.get(n) and registry.agents[n].role == "judge")} |
| non_judge = [n for n in scenario.cast if n not in judge_names] |
| assert set(defaults) == set(non_judge) |
| assert all(v in _CATALOGUE_KEYS for v in defaults.values()) |
|
|
|
|
| def test_merge_roster_model_defaults_seeds_added_agent_and_keeps_picks(): |
| """A mind imported from another scenario gets a default model; prior picks survive.""" |
| registry = default_registry() |
| scenario = registry.scenarios["thousand-token-wood"] |
| |
| outsider = next(n for n, m in sorted(registry.agents.items()) if n not in scenario.cast and m.role != "judge") |
| existing_worker = next(n for n in scenario.cast if registry.agents[n].role != "judge") |
| roster = list(scenario.cast) + [outsider] |
| pinned = {existing_worker: _CATALOGUE_KEYS[-1]} |
|
|
| merged = lab._merge_roster_model_defaults(scenario, roster, pinned) |
|
|
| |
| assert outsider in merged |
| assert merged[outsider] in _CATALOGUE_KEYS |
| |
| assert merged[existing_worker] == _CATALOGUE_KEYS[-1] |
| |
| judge = next((n for n in roster if registry.agents.get(n) and registry.agents[n].role == "judge"), None) |
| assert judge not in merged |
|
|
|
|
| def test_added_agent_from_another_scenario_runs_with_its_model(): |
| """End-to-end: an imported mind, once seeded, is pinned in the assembled run.""" |
| registry = default_registry() |
| scenario = registry.scenarios["thousand-token-wood"] |
| outsider = next(n for n, m in sorted(registry.agents.items()) if n not in scenario.cast and m.role != "judge") |
| roster = list(scenario.cast) + [outsider] |
| |
| cast_models = lab._merge_roster_model_defaults(scenario, roster, {}) |
|
|
| world = lab.collect_world_config( |
| scenario=scenario.title, |
| premise=scenario.goal, |
| seed=scenario.default_seed, |
| cast_models=cast_models, |
| judge_policy="Majority Vote", |
| judge_model=_CATALOGUE_KEYS[-1], |
| judge_strictness=50, |
| tools=[], |
| tokens=120_000, |
| max_rounds=25, |
| cast_roster=roster, |
| ) |
|
|
| by_name = {a.name: a for a in world.agents} |
| assert outsider in by_name |
| assert by_name[outsider].model_endpoint in _CATALOGUE_KEYS |
| assert registry.agents[outsider].model_endpoint is None |
|
|
|
|
| def test_collect_world_config_pins_selected_models_as_endpoints(): |
| registry = default_registry() |
| scenario = registry.scenarios["thousand-token-wood"] |
| worker = next(n for n in scenario.cast if registry.agents[n].role != "judge") |
| judge = next(n for n in scenario.cast if registry.agents[n].role == "judge") |
| worker_key, judge_key = _CATALOGUE_KEYS[0], _CATALOGUE_KEYS[-1] |
|
|
| world = lab.collect_world_config( |
| scenario=scenario.title, |
| premise="A new whimsical premise for the wood.", |
| seed=scenario.default_seed, |
| cast_models={worker: worker_key}, |
| judge_policy="Majority Vote", |
| judge_model=judge_key, |
| judge_strictness=60, |
| tools=["dice.roll", "vote.tally"], |
| tokens=120_000, |
| max_rounds=25, |
| ) |
|
|
| assert isinstance(world, WorldConfig) |
| by_name = {a.name: a for a in world.agents} |
| assert by_name[worker].model_endpoint == worker_key |
| assert by_name[judge].model_endpoint == judge_key |
| |
| assert registry.agents[worker].model_endpoint is None |
| assert registry.agents[judge].model_endpoint is None |
|
|
| out = world.scenarios[0] |
| assert out.name == scenario.name |
| assert out.goal == "A new whimsical premise for the wood." |
| assert out.cast == list(scenario.cast) |
| assert out.governor is not None |
| assert out.governor.max_turns == 25 |
| assert out.governor.max_total_tokens == 120_000 |
|
|
|
|
| def test_collect_world_config_ignores_unknown_or_blank_model_keys(): |
| registry = default_registry() |
| scenario = registry.scenarios["thousand-token-wood"] |
| worker = next(n for n in scenario.cast if registry.agents[n].role != "judge") |
|
|
| world = lab.collect_world_config( |
| scenario=scenario.name, |
| premise="", |
| seed="", |
| cast_models={worker: "not-a-real-endpoint"}, |
| judge_policy="Judge's Whim", |
| judge_model="", |
| judge_strictness=10, |
| tools=[], |
| tokens=None, |
| max_rounds=None, |
| ) |
|
|
| by_name = {a.name: a for a in world.agents} |
| assert by_name[worker].model_endpoint is None |
| |
| assert world.scenarios[0].goal == scenario.goal |
| assert world.scenarios[0].default_seed == scenario.default_seed |
|
|
|
|
| def test_collect_world_config_unknown_scenario_raises(): |
| with pytest.raises(ValueError, match="unknown scenario"): |
| lab.collect_world_config( |
| scenario="not-a-real-world", |
| premise="", |
| seed="", |
| cast_models={}, |
| judge_policy="Majority Vote", |
| judge_model="", |
| judge_strictness=50, |
| tools=[], |
| tokens=None, |
| max_rounds=None, |
| ) |
|
|
|
|
| |
|
|
|
|
| def test_collect_world_config_pins_tool_persona_schedule_onto_the_right_agent(): |
| registry = default_registry() |
| scenario = registry.scenarios["oracle-grove"] |
| |
| world = lab.collect_world_config( |
| scenario=scenario.name, |
| premise="", |
| seed="", |
| cast_models={}, |
| judge_policy="Majority Vote", |
| judge_model="", |
| judge_strictness=50, |
| tools=[], |
| tokens=None, |
| max_rounds=None, |
| cast_tools={"fortune-teller": ["oracle"], "scene-whisperer": []}, |
| cast_personas={"scene-whisperer": "A brand-new whispered identity for the test."}, |
| cast_schedules={"fortune-teller": {"tick_every": 3, "max_consecutive": 2}}, |
| ) |
|
|
| by_name = {a.name: a for a in world.agents} |
| assert by_name["fortune-teller"].tools == ["oracle"] |
| assert by_name["scene-whisperer"].tools == [] |
| assert by_name["scene-whisperer"].persona == "A brand-new whispered identity for the test." |
| assert by_name["fortune-teller"].schedule.tick_every == 3 |
| assert by_name["fortune-teller"].schedule.max_consecutive == 2 |
| |
| assert registry.agents["scene-whisperer"].persona != "A brand-new whispered identity for the test." |
| assert registry.agents["fortune-teller"].schedule.tick_every == 1 |
|
|
|
|
| def test_collect_world_config_drops_ungranted_or_unknown_tools(): |
| registry = default_registry() |
| scenario = registry.scenarios["oracle-grove"] |
| world = lab.collect_world_config( |
| scenario=scenario.name, |
| premise="", |
| seed="", |
| cast_models={}, |
| judge_policy="Majority Vote", |
| judge_model="", |
| judge_strictness=50, |
| tools=[], |
| tokens=None, |
| max_rounds=None, |
| |
| cast_tools={"fortune-teller": ["oracle", "tts.speak", "dice.roll"]}, |
| ) |
| by_name = {a.name: a for a in world.agents} |
| assert by_name["fortune-teller"].tools == ["oracle"] |
|
|
|
|
| def test_collect_world_config_never_escalates_a_non_tool_agent(): |
| |
| |
| registry = default_registry() |
| scenario = registry.scenarios["oracle-grove"] |
| assert registry.agents["scene-whisperer"].tools == [] |
| world = lab.collect_world_config( |
| scenario=scenario.name, |
| premise="", |
| seed="", |
| cast_models={}, |
| judge_policy="Majority Vote", |
| judge_model="", |
| judge_strictness=50, |
| tools=[], |
| tokens=None, |
| max_rounds=None, |
| cast_tools={"scene-whisperer": ["oracle"]}, |
| ) |
| by_name = {a.name: a for a in world.agents} |
| assert by_name["scene-whisperer"].tools == [] |
|
|
|
|
| def test_collect_world_config_honours_roster_genesis_and_governor(): |
| registry = default_registry() |
| scenario = registry.scenarios["thousand-token-wood"] |
| judge = next(n for n in scenario.cast if registry.agents[n].role == "judge") |
| trimmed = [n for n in scenario.cast if n != judge] |
|
|
| world = lab.collect_world_config( |
| scenario=scenario.name, |
| premise="", |
| seed="", |
| cast_models={}, |
| judge_policy="Majority Vote", |
| judge_model="", |
| judge_strictness=50, |
| tools=[], |
| tokens=None, |
| max_rounds=None, |
| cast_roster=trimmed, |
| genesis="A custom genesis the test pins in.", |
| max_turns=17, |
| max_calls_per_turn=5, |
| max_total_tokens=44_000, |
| hourly_budget_usd=2.5, |
| ) |
|
|
| out = world.scenarios[0] |
| |
| assert out.cast == trimmed |
| assert judge not in {a.name for a in world.agents} |
| assert out.genesis_text == "A custom genesis the test pins in." |
| assert out.governor is not None |
| assert out.governor.max_turns == 17 |
| assert out.governor.max_calls_per_turn == 5 |
| assert out.governor.max_total_tokens == 44_000 |
| assert out.governor.hourly_budget_usd == 2.5 |
|
|
|
|
| def test_collect_world_config_judgeless_world_is_valid_without_judge_knobs(): |
| |
| |
| registry = default_registry() |
| scenario = registry.scenarios["oracle-grove"] |
| world = lab.collect_world_config( |
| scenario=scenario.name, |
| premise="", |
| seed="", |
| cast_models={}, |
| judge_policy="Majority Vote", |
| judge_model="", |
| judge_strictness=50, |
| tools=[], |
| tokens=None, |
| max_rounds=None, |
| ) |
| assert isinstance(world, WorldConfig) |
| assert all(a.role != "judge" for a in world.agents) |
| assert world.scenarios[0].cast == list(scenario.cast) |
|
|
|
|
| def test_collect_world_config_legacy_token_and_round_knobs_still_apply(): |
| |
| registry = default_registry() |
| scenario = registry.scenarios["thousand-token-wood"] |
| world = lab.collect_world_config( |
| scenario=scenario.name, |
| premise="", |
| seed="", |
| cast_models={}, |
| judge_policy="Majority Vote", |
| judge_model="", |
| judge_strictness=50, |
| tools=[], |
| tokens=99_000, |
| max_rounds=33, |
| ) |
| gov = world.scenarios[0].governor |
| assert gov is not None |
| assert gov.max_turns == 33 |
| assert gov.max_total_tokens == 99_000 |
|
|