Files
chat/tests/test_drawer_guest.py
2026-04-26 15:59:48 -04:00

323 lines
11 KiB
Python

"""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 "<h3>Guest</h3>" 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 "<h3>Guest</h3>" in body
assert "BotB" in body
assert "smirking" in body
assert "<h3>Group</h3>" 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