c265e4ce0f
When a host->candidate edge already exists from a prior chat, the Add-guest form renders the prose textarea disabled with an "already know each other" note. Submission without the explicit "re-seed anyway" toggle skips seed_inter_bot_edges so existing edge content (affinity, trust, knowledge, summaries) survives — guest_added and group_node_initialized still fire. A small inline script enables / disables the textarea per-option based on a pre-computed existing_guest_edges dict surfaced by the GET handler.
479 lines
17 KiB
Python
479 lines
17 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")
|
|
|
|
|
|
# --- 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 "<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
|