"""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") 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