merge: T42 drawer guest add/remove + render
This commit is contained in:
@@ -43,6 +43,101 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</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">
|
<section class="drawer-section">
|
||||||
<h3>Edges</h3>
|
<h3>Edges</h3>
|
||||||
{% if edge_b2y %}
|
{% if edge_b2y %}
|
||||||
|
|||||||
+221
-1
@@ -32,9 +32,11 @@ from fastapi.responses import HTMLResponse
|
|||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
from chat.eventlog.log import append_and_apply
|
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.services.scene_summarize import apply_scene_close_summary
|
||||||
from chat.state.edges import get_edge
|
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.memory import get_pinned
|
||||||
from chat.state.world import active_scene, get_activity, get_chat, get_container
|
from chat.state.world import active_scene, get_activity, get_chat, get_container
|
||||||
from chat.web.bots import get_conn
|
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_b2y = get_edge(conn, chat["host_bot_id"], "you")
|
||||||
edge_y2b = get_edge(conn, "you", chat["host_bot_id"])
|
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.
|
# Recent memories from host's POV (witness_host = 1), most recent first.
|
||||||
# Raw query keeps this read self-contained — no projector helper exposes
|
# Raw query keeps this read self-contained — no projector helper exposes
|
||||||
# "latest N for an owner" yet and the drawer is the only consumer.
|
# "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,
|
"bot_activity": bot_activity,
|
||||||
"edge_b2y": edge_b2y,
|
"edge_b2y": edge_b2y,
|
||||||
"edge_y2b": edge_y2b,
|
"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,
|
"recent_memories": recent_memories,
|
||||||
"pinned": pinned,
|
"pinned": pinned,
|
||||||
"pin_cap": PIN_CAP,
|
"pin_cap": PIN_CAP,
|
||||||
@@ -304,3 +340,187 @@ async def toggle_memory_pin(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
return await drawer(chat_id, request, conn)
|
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