feat: drawer guest add/remove + render
This commit is contained in:
@@ -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 "<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
|
||||
Reference in New Issue
Block a user