From bb83d970885f5d28ce85273dd5a9e290d70a0a24 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 15:59:48 -0400 Subject: [PATCH] feat: drawer guest add/remove + render --- chat/templates/_drawer.html | 95 +++++++++++ chat/web/drawer.py | 222 ++++++++++++++++++++++++- tests/test_drawer_guest.py | 322 ++++++++++++++++++++++++++++++++++++ 3 files changed, 638 insertions(+), 1 deletion(-) create mode 100644 tests/test_drawer_guest.py diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html index 4ebfc22..6bfb887 100644 --- a/chat/templates/_drawer.html +++ b/chat/templates/_drawer.html @@ -43,6 +43,101 @@ {% endfor %} + {% if guest_bot %} +
+

Guest

+

{{ guest_bot.name }}

+ {% if guest_activity %} +

{{ guest_activity.posture or "—" }} / {{ (guest_activity.action or {}).verb or "—" }}

+ {% if guest_activity.attention %}

attention: {{ guest_activity.attention }}

{% endif %} + {% if guest_activity.holding %}

holding: {{ guest_activity.holding|join(", ") }}

{% endif %} + {% else %} +

No activity recorded.

+ {% endif %} + + {% if edge_h2g %} +
+ {{ host_bot.name }} → {{ guest_bot.name }} +

Affinity: {{ edge_h2g.affinity }}/100 · Trust: {{ edge_h2g.trust }}/100

+ {% if edge_h2g.knowledge %} +
Knowledge ({{ edge_h2g.knowledge|length }}) +
    {% for fact in edge_h2g.knowledge %}
  • {{ fact }}
  • {% endfor %}
+
+ {% endif %} +
+ {% endif %} + {% if edge_g2h %} +
+ {{ guest_bot.name }} → {{ host_bot.name }} +

Affinity: {{ edge_g2h.affinity }}/100 · Trust: {{ edge_g2h.trust }}/100

+ {% if edge_g2h.knowledge %} +
Knowledge ({{ edge_g2h.knowledge|length }}) +
    {% for fact in edge_g2h.knowledge %}
  • {{ fact }}
  • {% endfor %}
+
+ {% endif %} +
+ {% endif %} + {% if edge_y2g %} +
+ you → {{ guest_bot.name }} +

Affinity: {{ edge_y2g.affinity }}/100 · Trust: {{ edge_y2g.trust }}/100

+
+ {% endif %} + {% if edge_g2y %} +
+ {{ guest_bot.name }} → you +

Affinity: {{ edge_g2y.affinity }}/100 · Trust: {{ edge_g2y.trust }}/100

+
+ {% endif %} + +
+ +
+
+ {% else %} +
+

Add guest

+ {% if available_guests %} +
+ + + +
+ {% else %} +

No other bots authored yet.

+ {% endif %} +
+ {% endif %} + + {% if group_node %} +
+

Group

+ {% if group_node.summary %} +

{{ group_node.summary }}

+ {% else %} +

No group summary yet.

+ {% endif %} + {% if group_node.dynamic %} +

Dynamic: {{ group_node.dynamic }}

+ {% endif %} +
+ {% endif %} +

Edges

{% if edge_b2y %} diff --git a/chat/web/drawer.py b/chat/web/drawer.py index 3b61e50..d38d850 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -32,9 +32,11 @@ from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates from chat.eventlog.log import append_and_apply +from chat.services.relationship_seed import seed_inter_bot_edges from chat.services.scene_summarize import apply_scene_close_summary from chat.state.edges import get_edge -from chat.state.entities import get_bot, get_you +from chat.state.entities import get_bot, get_you, list_bots +from chat.state.group_node import get_group_node from chat.state.memory import get_pinned from chat.state.world import active_scene, get_activity, get_chat, get_container from chat.web.bots import get_conn @@ -78,6 +80,32 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): edge_b2y = get_edge(conn, chat["host_bot_id"], "you") edge_y2b = get_edge(conn, "you", chat["host_bot_id"]) + # T42: guest + group context. Empty defaults keep the template happy + # when no guest is present (the relevant sections render conditionally). + guest_bot = None + guest_activity = None + edge_h2g = None + edge_g2h = None + edge_y2g = None + edge_g2y = None + available_guests: list[dict] = [] + group_node = None + if chat.get("guest_bot_id"): + guest_bot_id = chat["guest_bot_id"] + guest_bot = get_bot(conn, guest_bot_id) + guest_activity = get_activity(conn, guest_bot_id) + edge_h2g = get_edge(conn, chat["host_bot_id"], guest_bot_id) + edge_g2h = get_edge(conn, guest_bot_id, chat["host_bot_id"]) + edge_y2g = get_edge(conn, "you", guest_bot_id) + edge_g2y = get_edge(conn, guest_bot_id, "you") + else: + # Candidates for the "Add guest" dropdown — every authored bot + # except the host (and "you", which is implicit, never a bot row). + available_guests = [ + b for b in list_bots(conn) if b["id"] != chat["host_bot_id"] + ] + group_node = get_group_node(conn, chat_id) + # Recent memories from host's POV (witness_host = 1), most recent first. # Raw query keeps this read self-contained — no projector helper exposes # "latest N for an owner" yet and the drawer is the only consumer. @@ -117,6 +145,14 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): "bot_activity": bot_activity, "edge_b2y": edge_b2y, "edge_y2b": edge_y2b, + "guest_bot": guest_bot, + "guest_activity": guest_activity, + "edge_h2g": edge_h2g, + "edge_g2h": edge_g2h, + "edge_y2g": edge_y2g, + "edge_g2y": edge_g2y, + "available_guests": available_guests, + "group_node": group_node, "recent_memories": recent_memories, "pinned": pinned, "pin_cap": PIN_CAP, @@ -304,3 +340,187 @@ async def toggle_memory_pin( }, ) return await drawer(chat_id, request, conn) + + +# --- T42 guest add/remove ------------------------------------------------- +# +# Adding a guest fans out into up to four events: a ``guest_added`` to flip +# ``chats.guest_bot_id``, two ``edge_update`` events seeded from the +# user-supplied prose (skipped when the prose is empty / the seed comes back +# default), and a ``group_node_initialized`` if no row exists yet — three +# entities now share the chat so the §8.4 group node becomes meaningful. +# +# Removing a guest first emits ``scene_closed`` for the active scene (so any +# host -> you scene closes cleanly with the guest still in scope) before +# clearing the guest_bot_id; per spec the next user message implicitly opens +# a fresh you+host scene via Phase 1's mid-chat reset behavior. + + +def _seed_is_default(seed) -> bool: + """Treat a seed as a no-op when both summaries are empty AND both + delta pairs are zero AND both fact lists are empty. + """ + return ( + not seed.a_to_b_summary + and not seed.b_to_a_summary + and seed.a_to_b_affinity_delta == 0 + and seed.a_to_b_trust_delta == 0 + and seed.b_to_a_affinity_delta == 0 + and seed.b_to_a_trust_delta == 0 + and not seed.a_to_b_knowledge_facts + and not seed.b_to_a_knowledge_facts + ) + + +@router.post( + "/chats/{chat_id}/drawer/guest/add", + response_class=HTMLResponse, +) +async def add_guest( + chat_id: str, + request: Request, + guest_bot_id: str = Form(...), + relationship_prose: str = Form(""), + conn=Depends(get_conn), + client=Depends(get_llm_client), +): + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + if chat.get("guest_bot_id") is not None: + raise HTTPException( + status_code=400, + detail="a guest is already present in this chat", + ) + + if guest_bot_id == chat["host_bot_id"]: + raise HTTPException( + status_code=400, detail="guest must differ from host" + ) + + guest_bot = get_bot(conn, guest_bot_id) + if guest_bot is None: + raise HTTPException( + status_code=404, detail=f"guest bot not found: {guest_bot_id}" + ) + + host_bot = get_bot(conn, chat["host_bot_id"]) + if host_bot is None: + raise HTTPException( + status_code=404, + detail=f"host bot not found: {chat['host_bot_id']}", + ) + + settings = request.app.state.settings + seed = await seed_inter_bot_edges( + client, + classifier_model=settings.classifier_model, + bot_a_id=chat["host_bot_id"], + bot_a_name=host_bot["name"], + bot_b_id=guest_bot_id, + bot_b_name=guest_bot["name"], + relationship_prose=relationship_prose, + timeout_s=settings.classifier_timeout_s, + ) + + append_and_apply( + conn, + kind="guest_added", + payload={"chat_id": chat_id, "guest_bot_id": guest_bot_id}, + ) + + # Emit edge_update only when the seed carries content. Empty prose + # short-circuits inside ``seed_inter_bot_edges`` to a default seed, + # so this skips the two extra log entries on the no-prose path. + # NOTE: ``_apply_edge_update`` does not accept a ``summary`` field — + # per-direction summary is set via the per-pov scene-close path + # (T27), not direct edge_update. We therefore drop seed.*_summary + # here; the deltas + knowledge_facts are what materializes. + if not _seed_is_default(seed): + append_and_apply( + conn, + kind="edge_update", + payload={ + "source_id": chat["host_bot_id"], + "target_id": guest_bot_id, + "chat_id": chat_id, + "affinity_delta": seed.a_to_b_affinity_delta, + "trust_delta": seed.a_to_b_trust_delta, + "knowledge_facts": seed.a_to_b_knowledge_facts, + "last_interaction_at": chat.get("time"), + "last_interaction_chat_id": chat_id, + }, + ) + append_and_apply( + conn, + kind="edge_update", + payload={ + "source_id": guest_bot_id, + "target_id": chat["host_bot_id"], + "chat_id": chat_id, + "affinity_delta": seed.b_to_a_affinity_delta, + "trust_delta": seed.b_to_a_trust_delta, + "knowledge_facts": seed.b_to_a_knowledge_facts, + "last_interaction_at": chat.get("time"), + "last_interaction_chat_id": chat_id, + }, + ) + + # Three entities now share the chat (you, host, guest) — initialize + # the group node row if Wave 1's reader doesn't see one yet. + if get_group_node(conn, chat_id) is None: + append_and_apply( + conn, + kind="group_node_initialized", + payload={ + "chat_id": chat_id, + "members": ["you", chat["host_bot_id"], guest_bot_id], + "summary": "", + "dynamic": "", + "threads": [], + }, + ) + + return await drawer(chat_id, request, conn) + + +@router.post( + "/chats/{chat_id}/drawer/guest/remove", + response_class=HTMLResponse, +) +async def remove_guest( + chat_id: str, + request: Request, + conn=Depends(get_conn), +): + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + if chat.get("guest_bot_id") is None: + raise HTTPException( + status_code=400, detail="no guest present in this chat" + ) + + # Close the active scene (if any) before flipping guest_bot_id so + # the scene record carries the guest as a participant. + scene = active_scene(conn, chat_id) + if scene is not None: + append_and_apply( + conn, + kind="scene_closed", + payload={ + "scene_id": scene["id"], + "ended_at": chat.get("time"), + "significance": 0, + }, + ) + + append_and_apply( + conn, + kind="guest_removed", + payload={"chat_id": chat_id}, + ) + + return await drawer(chat_id, request, conn) diff --git a/tests/test_drawer_guest.py b/tests/test_drawer_guest.py new file mode 100644 index 0000000..a599c03 --- /dev/null +++ b/tests/test_drawer_guest.py @@ -0,0 +1,322 @@ +"""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