"""Integration coverage for the §19.2 "per-RFC model availability — UX half" candidate folded into §6.6. The meta-repo entry's optional `models:` frontmatter narrows what AI models contributors can pick from on a given RFC. The resolution rule is uniform across every AI surface: - Absent (`models:` key omitted) → inherit the operator universe. - Populated list → intersection with the operator's provisioned providers, preserving the entry's order. First entry is the RFC default. - Empty list (`models: []`) → AI surfaces refuse honestly. The tests below prove each branch through the API surface and verify the cache round-trip from meta-repo frontmatter to picker option list. """ from __future__ import annotations import asyncio import json from test_propose_vertical import ( # noqa: F401 FakeGitea, app_with_fake_gitea, provision_user_row, sign_in_as, tmp_env, ) from test_rfc_view_vertical import FakeProvider, seed_active_rfc # noqa: F401 from test_super_draft_vertical import seed_super_draft # noqa: F401 SEED_BODY = ( "Open Human Model is a framework for representing humans.\n\n" "It defines consent, trait, and agency in compatible terms." ) def _set_models_json(slug: str, value: str | None) -> None: """Write the resolved cached_rfcs.models_json directly. Mirrors what `_upsert_cached_rfc` would do on the next reconciler sweep, without requiring a full meta-repo refresh in tests focused on the resolver.""" from app import db db.conn().execute( "UPDATE cached_rfcs SET models_json = ? WHERE slug = ?", (value, slug), ) def _install_two_providers(app) -> None: """Swap two fake providers — `claude` and `gemini` — into the shared providers dict. The dict is held by reference inside the router closures (see Slice 2's pattern in test_rfc_view_vertical), so the mutation propagates.""" app.state.providers.clear() app.state.providers["claude"] = FakeProvider("TITLE: A\nDESCRIPTION: B") app.state.providers["gemini"] = FakeProvider("TITLE: G\nDESCRIPTION: H") # --------------------------------------------------------------------------- # /api/rfcs//models — the picker option-list surface # --------------------------------------------------------------------------- def test_absent_frontmatter_inherits_operator_universe(app_with_fake_gitea): """When the meta-repo entry has no `models:` field, the picker's option list is the operator's full provisioned universe.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") _install_two_providers(app) # models_json is NULL — set_models_json not called. r = client.get("/api/rfcs/ohm/models") assert r.status_code == 200, r.text body = r.json() ids = [m["id"] for m in body["models"]] assert ids == ["claude", "gemini"] assert body["default"] == "claude" def test_populated_frontmatter_narrows_picker_to_intersection(app_with_fake_gitea): """A populated `models:` list narrows the picker. Operator order does not matter — the entry's stated order is preserved.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") _install_two_providers(app) _set_models_json("ohm", json.dumps(["gemini"])) r = client.get("/api/rfcs/ohm/models") body = r.json() assert [m["id"] for m in body["models"]] == ["gemini"] assert body["default"] == "gemini" def test_empty_frontmatter_disables_ai_surfaces(app_with_fake_gitea): """`models: []` — the RFC opts out of AI per §6.6. The picker surface returns an empty list and an empty default.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") _install_two_providers(app) _set_models_json("ohm", json.dumps([])) r = client.get("/api/rfcs/ohm/models") body = r.json() assert body["models"] == [] assert body["default"] == "" def test_intersection_with_provisioned_providers(app_with_fake_gitea): """An entry that lists models the operator no longer provisions — they're silently hidden from the picker. The frontmatter list is preserved so a later operator change can restore them; the cache row is untouched by this test.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") _install_two_providers(app) # Frontmatter names a model the operator doesn't have plus one # it does — only the intersection comes out. _set_models_json("ohm", json.dumps(["llama-3", "claude"])) r = client.get("/api/rfcs/ohm/models") body = r.json() assert [m["id"] for m in body["models"]] == ["claude"] assert body["default"] == "claude" def test_intersection_empty_same_as_opt_out(app_with_fake_gitea): """When the frontmatter names only models the operator doesn't provision, the resolved list is empty and the surface refuses cleanly — same shape as the explicit `models: []` opt-out.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner", email="ben@test") _install_two_providers(app) _set_models_json("ohm", json.dumps(["llama-3", "gpt-5"])) r = client.get("/api/rfcs/ohm/models") assert r.json()["models"] == [] # --------------------------------------------------------------------------- # §10.2 PR draft: model selection follows the RFC default # --------------------------------------------------------------------------- def test_pr_draft_uses_rfc_default_model(app_with_fake_gitea): """The §10.2 draft picks the RFC's default — the first entry of the resolved list. We rig the providers so `claude` returns one title and `gemini` returns a different one, then prove the entry's pinned model is the one we get back.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") provision_user_row(user_id=2, login="alice", role="contributor") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) # Two fakes, distinct outputs. app.state.providers.clear() app.state.providers["claude"] = FakeProvider( "TITLE: from claude\nDESCRIPTION: claude wrote this." ) app.state.providers["gemini"] = FakeProvider( "TITLE: from gemini\nDESCRIPTION: gemini wrote this." ) _set_models_json("ohm", json.dumps(["gemini"])) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.json()["branch_name"] # Make the branch differ from main so the draft endpoint passes # its "commits ahead" precondition. from app import db from test_rfc_view_vertical import FakeGitea # noqa: F401 # Promote-to-branch starts a branch off main with main's content; # the simplest way to give it a commit-ahead is via a manual edit # flush. But for this test, an inline commit on the fake is # cleaner. repo_full = "wiggleverse/rfc-0001-ohm" owner, repo = repo_full.split("/", 1) new_body = SEED_BODY + "\n\nA further sentence." fake.files[(owner, repo, branch, "RFC.md")] = { "content": new_body, "sha": fake._next_sha(), } fake.branches[(owner, repo)][branch]["sha"] = fake.files[ (owner, repo, branch, "RFC.md") ]["sha"] db.conn().execute( """ UPDATE cached_branches SET head_sha = ? WHERE rfc_slug = 'ohm' AND branch_name = ? """, (fake.branches[(owner, repo)][branch]["sha"], branch), ) r = client.post(f"/api/rfcs/ohm/branches/{branch}/pr-draft") assert r.status_code == 200, r.text # `gemini` is the resolved default — its title comes back. assert r.json()["title"] == "from gemini" def test_pr_draft_falls_back_to_stub_on_opt_out(app_with_fake_gitea): """When the RFC opts out of AI (`models: []`), §10.2 falls back to its deterministic stub — the title is the stub format, not an LLM-generated string.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") provision_user_row(user_id=2, login="alice", role="contributor") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) _install_two_providers(app) _set_models_json("ohm", json.dumps([])) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.json()["branch_name"] from app import db repo_full = "wiggleverse/rfc-0001-ohm" owner, repo = repo_full.split("/", 1) new_body = SEED_BODY + "\n\nA further sentence." fake.files[(owner, repo, branch, "RFC.md")] = { "content": new_body, "sha": fake._next_sha(), } fake.branches[(owner, repo)][branch]["sha"] = fake.files[ (owner, repo, branch, "RFC.md") ]["sha"] db.conn().execute( """ UPDATE cached_branches SET head_sha = ? WHERE rfc_slug = 'ohm' AND branch_name = ? """, (fake.branches[(owner, repo)][branch]["sha"], branch), ) r = client.post(f"/api/rfcs/ohm/branches/{branch}/pr-draft") assert r.status_code == 200, r.text body = r.json() # The stub uses "Edits to " as the title (per Slice 3). assert body["title"] == "Edits to OHM" # --------------------------------------------------------------------------- # Chat surface refuses cleanly when AI is unavailable for the RFC # --------------------------------------------------------------------------- def test_chat_refuses_when_rfc_opted_out_of_ai(app_with_fake_gitea): """A chat turn on a branch of an RFC with `models: []` refuses with 503 — same shape as "no providers configured." The refusal surfaces as a property of the RFC, not a server failure.""" from fastapi.testclient import TestClient app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=2, login="alice", role="contributor") seed_active_rfc(fake, slug="ohm", title="OHM", body=SEED_BODY) _install_two_providers(app) _set_models_json("ohm", json.dumps([])) sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", role="contributor") r = client.post("/api/rfcs/ohm/branches/main/promote-to-branch", json={}) branch = r.json()["branch_name"] view = client.get(f"/api/rfcs/ohm/branches/{branch}").json() thread_id = view["main_thread_id"] r = client.post( f"/api/rfcs/ohm/branches/{branch}/threads/{thread_id}/chat", json={"text": "tighten this", "model": "claude"}, ) assert r.status_code == 503 # --------------------------------------------------------------------------- # Cache round-trip: frontmatter `models:` flows into models_json # --------------------------------------------------------------------------- def test_meta_repo_frontmatter_models_round_trips_through_cache(app_with_fake_gitea): """The production path: a meta-repo entry whose frontmatter carries `models: [claude]` lands in `cached_rfcs.models_json` after the next `refresh_meta_repo` sweep.""" from fastapi.testclient import TestClient from app import cache as cache_mod, db app, fake = app_with_fake_gitea with TestClient(app) as client: provision_user_row(user_id=1, login="ben", role="owner") # Seed a super-draft entry directly on meta-main with a # `models:` frontmatter field. seed_super_draft doesn't take # this field, so we write the meta-repo file ourselves and # let the cache reconciler parse it. entry_text = ( "---\n" "slug: ohm\n" "title: OHM\n" "state: super-draft\n" "id: null\n" "repo: null\n" "proposed_by: ben\n" "proposed_at: 2026-05-23\n" "graduated_at: null\n" "graduated_by: null\n" "owners: []\n" "arbiters: []\n" "tags: []\n" "models:\n" " - claude\n" "---\n\n" "The pitch goes here.\n" ) sha = fake._next_sha() fake.files[("wiggleverse", "meta", "main", "rfcs/ohm.md")] = { "content": entry_text, "sha": sha, } fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea)) row = db.conn().execute( "SELECT models_json FROM cached_rfcs WHERE slug = 'ohm'" ).fetchone() assert row is not None assert row["models_json"] is not None assert json.loads(row["models_json"]) == ["claude"] # And an entry without `models:` lands NULL. no_models_text = entry_text.replace("models:\n - claude\n", "") sha2 = fake._next_sha() fake.files[("wiggleverse", "meta", "main", "rfcs/beta.md")] = { "content": no_models_text.replace("slug: ohm", "slug: beta") .replace("title: OHM", "title: Beta"), "sha": sha2, } fake.branches[("wiggleverse", "meta")]["main"]["sha"] = sha2 asyncio.run(cache_mod.refresh_meta_repo(app.state.config, app.state.gitea)) row = db.conn().execute( "SELECT models_json FROM cached_rfcs WHERE slug = 'beta'" ).fetchone() assert row is not None assert row["models_json"] is None # --------------------------------------------------------------------------- # entry.py: absent / empty / populated round-trip the parser+serializer # --------------------------------------------------------------------------- def test_entry_models_field_absent_vs_empty_distinguished(): """The absent vs. empty distinction is load-bearing per §6.6. parse(serialize(x)) preserves None as None and [] as [].""" from app import entry as entry_mod absent = entry_mod.Entry(slug="x", title="X") assert absent.models is None text_absent = entry_mod.serialize(absent) assert "models:" not in text_absent round_absent = entry_mod.parse(text_absent) assert round_absent.models is None opted_out = entry_mod.Entry(slug="y", title="Y", models=[]) text_empty = entry_mod.serialize(opted_out) assert "models: []" in text_empty round_empty = entry_mod.parse(text_empty) assert round_empty.models == [] populated = entry_mod.Entry(slug="z", title="Z", models=["claude", "gemini"]) text_full = entry_mod.serialize(populated) round_full = entry_mod.parse(text_full) assert round_full.models == ["claude", "gemini"]