merge: T42 drawer guest add/remove + render
This commit is contained in:
@@ -43,6 +43,101 @@
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% if guest_bot %}
|
||||
<section class="drawer-section">
|
||||
<h3>Guest</h3>
|
||||
<p><strong>{{ guest_bot.name }}</strong></p>
|
||||
{% if guest_activity %}
|
||||
<p>{{ guest_activity.posture or "—" }} / {{ (guest_activity.action or {}).verb or "—" }}</p>
|
||||
{% if guest_activity.attention %}<p class="muted">attention: {{ guest_activity.attention }}</p>{% endif %}
|
||||
{% if guest_activity.holding %}<p class="muted">holding: {{ guest_activity.holding|join(", ") }}</p>{% endif %}
|
||||
{% else %}
|
||||
<p class="muted">No activity recorded.</p>
|
||||
{% endif %}
|
||||
|
||||
{% if edge_h2g %}
|
||||
<div class="edge-row">
|
||||
<strong>{{ host_bot.name }} → {{ guest_bot.name }}</strong>
|
||||
<p>Affinity: {{ edge_h2g.affinity }}/100 · Trust: {{ edge_h2g.trust }}/100</p>
|
||||
{% if edge_h2g.knowledge %}
|
||||
<details><summary>Knowledge ({{ edge_h2g.knowledge|length }})</summary>
|
||||
<ul>{% for fact in edge_h2g.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if edge_g2h %}
|
||||
<div class="edge-row">
|
||||
<strong>{{ guest_bot.name }} → {{ host_bot.name }}</strong>
|
||||
<p>Affinity: {{ edge_g2h.affinity }}/100 · Trust: {{ edge_g2h.trust }}/100</p>
|
||||
{% if edge_g2h.knowledge %}
|
||||
<details><summary>Knowledge ({{ edge_g2h.knowledge|length }})</summary>
|
||||
<ul>{% for fact in edge_g2h.knowledge %}<li>{{ fact }}</li>{% endfor %}</ul>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if edge_y2g %}
|
||||
<div class="edge-row">
|
||||
<strong>you → {{ guest_bot.name }}</strong>
|
||||
<p>Affinity: {{ edge_y2g.affinity }}/100 · Trust: {{ edge_y2g.trust }}/100</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if edge_g2y %}
|
||||
<div class="edge-row">
|
||||
<strong>{{ guest_bot.name }} → you</strong>
|
||||
<p>Affinity: {{ edge_g2y.affinity }}/100 · Trust: {{ edge_g2y.trust }}/100</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/guest/remove"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<button type="submit">Remove guest</button>
|
||||
</form>
|
||||
</section>
|
||||
{% else %}
|
||||
<section class="drawer-section">
|
||||
<h3>Add guest</h3>
|
||||
{% if available_guests %}
|
||||
<form class="inline-edit"
|
||||
hx-post="/chats/{{ chat.id }}/drawer/guest/add"
|
||||
hx-target="#drawer" hx-swap="innerHTML">
|
||||
<label>
|
||||
Bot:
|
||||
<select name="guest_bot_id" required>
|
||||
{% for b in available_guests %}
|
||||
<option value="{{ b.id }}">{{ b.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Have they met before? Describe how (leave blank if not):
|
||||
<textarea name="relationship_prose" rows="3"
|
||||
placeholder="e.g. Old college friends who studied physics together."></textarea>
|
||||
</label>
|
||||
<button type="submit">Add guest</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="muted">No other bots authored yet.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if group_node %}
|
||||
<section class="drawer-section">
|
||||
<h3>Group</h3>
|
||||
{% if group_node.summary %}
|
||||
<p>{{ group_node.summary }}</p>
|
||||
{% else %}
|
||||
<p class="muted">No group summary yet.</p>
|
||||
{% endif %}
|
||||
{% if group_node.dynamic %}
|
||||
<p class="muted">Dynamic: {{ group_node.dynamic }}</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<section class="drawer-section">
|
||||
<h3>Edges</h3>
|
||||
{% if edge_b2y %}
|
||||
|
||||
+221
-1
@@ -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)
|
||||
|
||||
@@ -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