"""T42: drawer guest add/remove + render. The drawer grows a "Guest" section (when a guest bot is present in the chat), a "Group" section sourced from the ``group_node`` row, an "Add guest" form (visible while no guest is present), and a "Remove guest" button (visible while one is). The two new POST endpoints emit ``guest_added`` / ``guest_removed`` events plus ancillary updates: * ``POST /chats/{chat_id}/drawer/guest/add`` runs the relationship-seed classifier (T38) over the user-supplied prose and emits an ``edge_update`` per direction when the seed comes back non-default. It also seeds a ``group_node_initialized`` row when none exists yet. * ``POST /chats/{chat_id}/drawer/guest/remove`` first emits ``scene_closed`` for the active scene so the host -> you scene closes cleanly before the guest leaves. """ from __future__ import annotations import json from pathlib import Path import pytest from fastapi.testclient import TestClient from chat.app import app from chat.db.connection import open_db from chat.eventlog.log import append_event from chat.eventlog.projector import project from chat.llm.mock import MockLLMClient @pytest.fixture def client(tmp_path, monkeypatch): cfg = tmp_path / "config.toml" cfg.write_text('featherless_api_key = "test"\n') monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg)) db = tmp_path / "test.db" monkeypatch.setenv("CHAT_DB_PATH", str(db)) with TestClient(app) as c: if hasattr(app.state, "background_worker"): app.state.background_worker.enabled = False yield c def _bot_payload(bot_id: str, name: str) -> dict: return { "id": bot_id, "name": name, "persona": "...", "voice_samples": [], "traits": [], "backstory": "", "initial_relationship_to_you": "", "kickoff_prose": "", } def _seed_chat(db: Path, *, with_scene: bool = True) -> None: """Seed a chat hosted by ``bot_a`` (with ``bot_b`` authored as a candidate guest) and, by default, an open scene so the ``guest_removed`` flow has something to close. """ with open_db(db) as conn: append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA")) append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB")) append_event( conn, kind="you_authored", payload={"name": "Me", "pronouns": "they/them", "persona": ""}, ) append_event( conn, kind="chat_created", payload={ "id": "chat_bot_a", "host_bot_id": "bot_a", "initial_time": "2026-04-26T20:00:00+00:00", "narrative_anchor": "Day 1", "weather": "", }, ) if with_scene: append_event( conn, kind="scene_opened", payload={ "chat_id": "chat_bot_a", "container_id": None, "started_at": "2026-04-26T20:00:00+00:00", "participants": ["you", "bot_a"], }, ) project(conn) def _override_llm(canned: list[str]): """Wire a ``MockLLMClient`` into the drawer's LLM dependency.""" from chat.web.kickoff import get_llm_client app.dependency_overrides[get_llm_client] = lambda: MockLLMClient( canned=list(canned) ) def test_drawer_no_guest_omits_guest_section(client, tmp_path): _seed_chat(tmp_path / "test.db") response = client.get("/chats/chat_bot_a/drawer") assert response.status_code == 200 body = response.text # No guest-section header; the "Add guest" form should be visible instead. assert "

Guest

" not in body assert "Add guest" in body def test_drawer_add_guest_seeds_edges_and_group_node(client, tmp_path): _seed_chat(tmp_path / "test.db") canned = json.dumps( { "a_to_b_summary": "old college friend", "a_to_b_knowledge_facts": ["studied physics together"], "a_to_b_affinity_delta": 4, "a_to_b_trust_delta": -1, "b_to_a_summary": "former roommate", "b_to_a_knowledge_facts": ["lived together junior year"], "b_to_a_affinity_delta": 3, "b_to_a_trust_delta": 0, } ) _override_llm([canned]) try: response = client.post( "/chats/chat_bot_a/drawer/guest/add", data={ "guest_bot_id": "bot_b", "relationship_prose": ( "Alice and Bob met in college and studied physics together." ), }, ) assert response.status_code == 200 finally: app.dependency_overrides.clear() with open_db(tmp_path / "test.db") as conn: from chat.state.edges import get_edge from chat.state.group_node import get_group_node from chat.state.world import get_chat chat = get_chat(conn, "chat_bot_a") assert chat["guest_bot_id"] == "bot_b" edge_a_to_b = get_edge(conn, "bot_a", "bot_b") edge_b_to_a = get_edge(conn, "bot_b", "bot_a") # Seed deltas applied around the 50/50 default. assert edge_a_to_b["affinity"] == 54 assert edge_a_to_b["trust"] == 49 assert "studied physics together" in edge_a_to_b["knowledge"] assert edge_b_to_a["affinity"] == 53 assert edge_b_to_a["trust"] == 50 assert "lived together junior year" in edge_b_to_a["knowledge"] group = get_group_node(conn, "chat_bot_a") assert group is not None assert set(group["members"]) == {"you", "bot_a", "bot_b"} def test_drawer_add_guest_empty_prose_skips_edge_update(client, tmp_path): _seed_chat(tmp_path / "test.db") # No canned responses: the seed function short-circuits on empty prose # so no LLM call should happen. _override_llm([]) try: response = client.post( "/chats/chat_bot_a/drawer/guest/add", data={"guest_bot_id": "bot_b", "relationship_prose": " "}, ) assert response.status_code == 200 finally: app.dependency_overrides.clear() with open_db(tmp_path / "test.db") as conn: from chat.state.world import get_chat chat = get_chat(conn, "chat_bot_a") assert chat["guest_bot_id"] == "bot_b" # guest_added fires but no edge_update events between bot_a and bot_b. added = conn.execute( "SELECT COUNT(*) FROM event_log WHERE kind = 'guest_added'" ).fetchone()[0] assert added == 1 edge_updates = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'edge_update'" ).fetchall() for (payload_json,) in edge_updates: payload = json.loads(payload_json) pair = {payload.get("source_id"), payload.get("target_id")} assert pair != {"bot_a", "bot_b"}, ( "no edge_update should be emitted between host and guest " "when prose is empty" ) def test_drawer_add_guest_when_already_present_returns_400(client, tmp_path): _seed_chat(tmp_path / "test.db") # Pre-attach a guest directly via append_and_apply so we don't replay # the prior chat_created (which would violate UNIQUE on chats.id). from chat.eventlog.log import append_and_apply with open_db(tmp_path / "test.db") as conn: append_and_apply( conn, kind="bot_authored", payload=_bot_payload("bot_c", "BotC"), ) append_and_apply( conn, kind="guest_added", payload={"chat_id": "chat_bot_a", "guest_bot_id": "bot_b"}, ) _override_llm([]) try: response = client.post( "/chats/chat_bot_a/drawer/guest/add", data={"guest_bot_id": "bot_c", "relationship_prose": ""}, ) assert response.status_code == 400 finally: app.dependency_overrides.clear() def test_drawer_remove_guest_clears_and_closes_scene(client, tmp_path): _seed_chat(tmp_path / "test.db") from chat.eventlog.log import append_and_apply with open_db(tmp_path / "test.db") as conn: append_and_apply( conn, kind="guest_added", payload={"chat_id": "chat_bot_a", "guest_bot_id": "bot_b"}, ) response = client.post("/chats/chat_bot_a/drawer/guest/remove") assert response.status_code == 200 with open_db(tmp_path / "test.db") as conn: from chat.state.world import active_scene, get_chat chat = get_chat(conn, "chat_bot_a") assert chat["guest_bot_id"] is None assert active_scene(conn, "chat_bot_a") is None kinds = [ row[0] for row in conn.execute( "SELECT kind FROM event_log ORDER BY id" ).fetchall() ] # scene_closed must precede guest_removed in the log. assert "scene_closed" in kinds assert "guest_removed" in kinds assert kinds.index("scene_closed") < kinds.index("guest_removed") # --- T72.2 first-meeting gate ---------------------------------------------- def _seed_host_to_guest_edge(db: Path) -> None: """Materialise a bot_a -> bot_b edge so the gate's check fires.""" from chat.eventlog.log import append_and_apply with open_db(db) as conn: append_and_apply( conn, kind="edge_update", payload={ "source_id": "bot_a", "target_id": "bot_b", "chat_id": "chat_bot_a", "affinity_delta": 0, "knowledge_facts": ["already met before"], }, ) def test_add_guest_form_disables_prose_when_edge_exists(client, tmp_path): """When host->candidate edge already exists, the GET partial renders the textarea disabled and surfaces the "already know each other" message so the user knows submitting will skip the seed. """ _seed_chat(tmp_path / "test.db") _seed_host_to_guest_edge(tmp_path / "test.db") response = client.get("/chats/chat_bot_a/drawer") assert response.status_code == 200 body = response.text # Note + disabled state both present. The textarea sits next to the # ``add-guest-prose`` class so we can match it specifically. assert "already know each other" in body assert 'class="add-guest-prose"' in body # The textarea for the first (auto-selected) candidate should be # disabled in the initial markup since an edge exists. assert "disabled" in body.split('class="add-guest-prose"', 1)[1].split(">", 1)[0] # And the option carries the ``data-existing-edge="true"`` attribute # the inline JS uses to flip state on subsequent select changes. assert 'data-existing-edge="true"' in body def test_add_guest_with_existing_edge_skips_seed_call(client, tmp_path): """Submitting the Add-guest form WITHOUT toggling re-seed must skip ``seed_inter_bot_edges`` entirely. We assert this via an empty mock queue: if the seed function had been called it would have consumed a canned response (or raised because none was available). """ _seed_chat(tmp_path / "test.db") _seed_host_to_guest_edge(tmp_path / "test.db") # Empty queue: any classifier call would raise inside MockLLMClient. canned_queue: list[str] = [] _override_llm(canned_queue) try: response = client.post( "/chats/chat_bot_a/drawer/guest/add", data={ "guest_bot_id": "bot_b", "relationship_prose": "ignored prose", # NO reseed flag — gate should suppress the seed call. }, ) assert response.status_code == 200 finally: app.dependency_overrides.clear() with open_db(tmp_path / "test.db") as conn: from chat.state.edges import get_edge from chat.state.world import get_chat chat = get_chat(conn, "chat_bot_a") assert chat["guest_bot_id"] == "bot_b" # The pre-seeded knowledge fact survives — proof the seed didn't run # and overwrite the existing edge. edge = get_edge(conn, "bot_a", "bot_b") assert "already met before" in edge["knowledge"] # Exactly one guest_added; no new edge_update events between # bot_a and bot_b (the pre-seed edge_update from the test setup # is the only edge_update on this pair). added = conn.execute( "SELECT COUNT(*) FROM event_log WHERE kind = 'guest_added'" ).fetchone()[0] assert added == 1 edge_updates = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'edge_update'" ).fetchall() # Only the pre-seed edge_update from _seed_host_to_guest_edge. ab_updates = [ json.loads(p[0]) for p in edge_updates if { json.loads(p[0]).get("source_id"), json.loads(p[0]).get("target_id"), } == {"bot_a", "bot_b"} ] assert len(ab_updates) == 1 assert ab_updates[0]["knowledge_facts"] == ["already met before"] def test_add_guest_with_existing_edge_and_reseed_runs_seed(client, tmp_path): """Toggling ``re-seed anyway`` flips the gate off — the existing flow runs (seed produces deltas, two ``edge_update`` events fire). """ _seed_chat(tmp_path / "test.db") _seed_host_to_guest_edge(tmp_path / "test.db") canned = json.dumps( { "a_to_b_summary": "reconnected", "a_to_b_knowledge_facts": ["new fact"], "a_to_b_affinity_delta": 2, "a_to_b_trust_delta": 1, "b_to_a_summary": "reconnected", "b_to_a_knowledge_facts": [], "b_to_a_affinity_delta": 1, "b_to_a_trust_delta": 0, } ) _override_llm([canned]) try: response = client.post( "/chats/chat_bot_a/drawer/guest/add", data={ "guest_bot_id": "bot_b", "relationship_prose": "fresh prose", "reseed": "1", }, ) assert response.status_code == 200 finally: app.dependency_overrides.clear() with open_db(tmp_path / "test.db") as conn: edge_updates = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'edge_update'" ).fetchall() # Pre-seed (1) + two from the re-seed = 3 edge_updates total. ab_updates = [ json.loads(p[0]) for p in edge_updates if { json.loads(p[0]).get("source_id"), json.loads(p[0]).get("target_id"), } == {"bot_a", "bot_b"} ] assert len(ab_updates) == 3 def test_drawer_with_guest_renders_guest_and_group_sections(client, tmp_path): _seed_chat(tmp_path / "test.db") from chat.eventlog.log import append_and_apply with open_db(tmp_path / "test.db") as conn: append_and_apply( conn, kind="guest_added", payload={"chat_id": "chat_bot_a", "guest_bot_id": "bot_b"}, ) # Activity for the guest so the section has content to render. append_and_apply( conn, kind="activity_change", payload={ "entity_id": "bot_b", "posture": "leaning", "action": {"verb": "smirking"}, "attention": "BotA", }, ) # Edges in all four directions involving the guest. for src, tgt in (("bot_a", "bot_b"), ("bot_b", "bot_a"), ("you", "bot_b"), ("bot_b", "you")): append_and_apply( conn, kind="edge_update", payload={ "source_id": src, "target_id": tgt, "chat_id": "chat_bot_a", "affinity_delta": 1, }, ) append_and_apply( conn, kind="group_node_initialized", payload={ "chat_id": "chat_bot_a", "members": ["you", "bot_a", "bot_b"], "summary": "Three friends catching up over drinks.", "dynamic": "warm and conspiratorial", }, ) response = client.get("/chats/chat_bot_a/drawer") assert response.status_code == 200 body = response.text assert "

Guest

" in body assert "BotB" in body assert "smirking" in body assert "

Group

" in body assert "Three friends catching up over drinks." in body assert "warm and conspiratorial" in body # "Remove guest" button is visible when a guest is present. assert "Remove guest" in body