"""T59: drawer events / threads / skip controls.
Extends the chat drawer with three new sections (Events, Threads, Skip)
and five new POST endpoints:
* ``POST /chats/{chat_id}/drawer/event/plan`` — emits ``event_planned``.
* ``POST /chats/{chat_id}/drawer/event/cancel/{event_id}`` — emits
``event_cancelled``.
* ``POST /chats/{chat_id}/drawer/skip/elision`` — validates new_time,
emits ``time_skip_elision`` plus an ``assistant_turn`` carrying the
narrated transition prose from :mod:`chat.services.skip_narration`.
* ``POST /chats/{chat_id}/drawer/skip/jump`` — validates new_time, emits
``time_skip_jump`` plus per-bot synthesized ``memory_written`` events
derived from the user-supplied "anything notable" prose, and an
``assistant_turn`` carrying the narration.
* ``POST /chats/{chat_id}/drawer/thread/close/{thread_id}`` — emits
``thread_closed``.
Each route returns the refreshed drawer partial (HTMX swap target) so
the tests assert both the persisted event_log effect AND the rendered
section content. Wire-up follows the T42 ``MockLLMClient`` pattern.
"""
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_and_apply, 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) so the skip-jump path can write per-bot synthesized
memories when a guest is present.
"""
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)
)
# ---------------------------------------------------------------------------
# 1. Empty drawer state — Events + Threads sections render but show
# empty-state copy (no row markup) when no events / threads exist.
# ---------------------------------------------------------------------------
def test_get_drawer_with_no_events_or_threads_omits_sections(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
# Sections render with empty-state copy — deterministic markers.
assert "
Events
" in body
assert "Threads
" in body
assert "No active events" in body
assert "No open threads" in body
# Skip controls always render under Activity (gated by chat clock).
assert "Elision skip" in body
assert "Jump skip" in body
# ---------------------------------------------------------------------------
# 2. POST event/plan — event_planned lands and the drawer lists it.
# ---------------------------------------------------------------------------
def test_post_event_plan_appends_event_planned_and_renders(client, tmp_path):
_seed_chat(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/event/plan",
data={
"kind": "dinner_reservation",
"planned_for": "2026-04-26T19:00:00+00:00",
"props_json": json.dumps({"restaurant": "Bistro X"}),
},
)
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'event_planned'"
).fetchall()
assert len(rows) == 1
payload = json.loads(rows[0][0])
assert payload["kind"] == "dinner_reservation"
assert payload["chat_id"] == "chat_bot_a"
assert payload["planned_for"] == "2026-04-26T19:00:00+00:00"
assert payload["props"] == {"restaurant": "Bistro X"}
assert payload["event_id"].startswith("evt_")
# Refreshed partial lists the new event by kind.
body = response.text
assert "dinner_reservation" in body
def test_post_event_plan_invalid_props_json_returns_400(client, tmp_path):
_seed_chat(tmp_path / "test.db")
response = client.post(
"/chats/chat_bot_a/drawer/event/plan",
data={
"kind": "dinner_reservation",
"planned_for": "2026-04-26T19:00:00+00:00",
"props_json": "not valid json {",
},
)
assert response.status_code == 400
# ---------------------------------------------------------------------------
# 3. POST event/cancel — event_cancelled lands and the active list drops it.
# ---------------------------------------------------------------------------
def test_post_event_cancel_appends_event_cancelled(client, tmp_path):
_seed_chat(tmp_path / "test.db")
# Plan first via the route so the test exercises both sides.
plan_resp = client.post(
"/chats/chat_bot_a/drawer/event/plan",
data={
"kind": "doctor_visit",
"planned_for": "2026-04-27T09:00:00+00:00",
"props_json": "{}",
},
)
assert plan_resp.status_code == 200
with open_db(tmp_path / "test.db") as conn:
row = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'event_planned' "
"ORDER BY id DESC LIMIT 1"
).fetchone()
event_id = json.loads(row[0])["event_id"]
cancel_resp = client.post(
f"/chats/chat_bot_a/drawer/event/cancel/{event_id}"
)
assert cancel_resp.status_code == 200
with open_db(tmp_path / "test.db") as conn:
cancelled = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'event_cancelled'"
).fetchall()
assert len(cancelled) == 1
cp = json.loads(cancelled[0][0])
assert cp["event_id"] == event_id
# Active-events query should no longer surface this event.
from chat.state.events import list_active_events
assert list_active_events(conn, "chat_bot_a") == []
# ---------------------------------------------------------------------------
# 4. POST skip/elision — emits time_skip_elision + assistant_turn narration.
# ---------------------------------------------------------------------------
def test_post_skip_elision_advances_clock_and_emits_narration(client, tmp_path):
_seed_chat(tmp_path / "test.db")
canned_narration = "We pull up to the curb just before sunset."
_override_llm([canned_narration])
try:
response = client.post(
"/chats/chat_bot_a/drawer/skip/elision",
data={
"landing_state_hint": "arriving at the venue",
"new_time": "2026-04-26T20:30:00+00:00",
},
)
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["time"] == "2026-04-26T20:30:00+00:00"
skip_rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'time_skip_elision'"
).fetchall()
assert len(skip_rows) == 1
sp = json.loads(skip_rows[0][0])
assert sp["chat_id"] == "chat_bot_a"
assert sp["new_time"] == "2026-04-26T20:30:00+00:00"
# An assistant_turn event landed with the narration text.
turn_rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'assistant_turn'"
).fetchall()
assert len(turn_rows) == 1
tp = json.loads(turn_rows[0][0])
assert tp["chat_id"] == "chat_bot_a"
assert tp["text"].strip() # non-empty narration
assert tp["speaker_id"] == "bot_a"
def test_skip_route_404_via_typed_exception_class(client, tmp_path):
"""T81: drawer skip routes 404 via :class:`ChatNotFoundError`.
Pre-T81, the route caught ``ValueError`` and recovered the 404 case
by sniffing ``str(exc).startswith("chat not found")`` — fragile if
the message ever changed wording. The controller now raises a typed
exception so the route dispatches on type. Asserting the 404 from
the unseeded chat exercises the typed branch end-to-end; importing
the class confirms it's a real subclass of ``Exception`` and not a
re-export of ``ValueError`` (which would defeat the type split).
"""
# Don't seed any chat — the controller hits ``get_chat`` returning
# ``None`` and raises ``ChatNotFoundError``. The drawer route then
# maps that to ``404`` via the typed handler (no string sniff).
_override_llm([])
try:
response = client.post(
"/chats/nonexistent/drawer/skip/elision",
data={
"landing_state_hint": "x",
"new_time": "2026-04-26T20:30:00+00:00",
},
)
assert response.status_code == 404
finally:
app.dependency_overrides.clear()
# The exception class itself is importable, distinct from ValueError,
# and a proper Exception subclass — pinning the type-based dispatch
# so future refactors can't quietly collapse it back to a string sniff.
from chat.web.skip import ChatNotFoundError
assert ChatNotFoundError is not None
assert issubclass(ChatNotFoundError, Exception)
assert not issubclass(ChatNotFoundError, ValueError)
def test_post_skip_elision_invalid_time_returns_400(client, tmp_path):
_seed_chat(tmp_path / "test.db")
_override_llm([])
try:
# Garbled ISO timestamp.
bad_resp = client.post(
"/chats/chat_bot_a/drawer/skip/elision",
data={"landing_state_hint": "x", "new_time": "not-a-time"},
)
assert bad_resp.status_code == 400
# Backwards-in-time skip: chat seeded at 20:00, asking 19:00.
backwards_resp = client.post(
"/chats/chat_bot_a/drawer/skip/elision",
data={
"landing_state_hint": "x",
"new_time": "2026-04-26T19:00:00+00:00",
},
)
assert backwards_resp.status_code == 400
finally:
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# 5. POST skip/jump — synthesized memories per present bot + narration.
# ---------------------------------------------------------------------------
def test_post_skip_jump_with_notable_prose_writes_synthesized_memories(
client, tmp_path
):
_seed_chat(tmp_path / "test.db")
# Single host present (no guest) — exactly one synthesize call,
# one narration call. The synthesize digest carries two memories so
# we can assert N writes lands the right shape.
digest_json = json.dumps(
{
"memories": [
{
"text": "We bumped into an old friend at the cafe.",
"significance": 1,
"affinity_delta": 0,
"trust_delta": 0,
},
{
"text": "It started raining on the walk home.",
"significance": 1,
"affinity_delta": 0,
"trust_delta": 0,
},
]
}
)
narration = "The afternoon slipped by quickly."
_override_llm([digest_json, narration])
try:
response = client.post(
"/chats/chat_bot_a/drawer/skip/jump",
data={
"new_time": "2026-04-27T08:00:00+00:00",
"notable_prose": (
"We ran into an old friend, and it rained on the way back."
),
"reset_activity": "1",
},
)
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["time"] == "2026-04-27T08:00:00+00:00"
jump_rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'time_skip_jump'"
).fetchall()
assert len(jump_rows) == 1
jp = json.loads(jump_rows[0][0])
assert jp["chat_id"] == "chat_bot_a"
assert jp["new_time"] == "2026-04-27T08:00:00+00:00"
assert jp["reset_activity"] is True
# Two synthesized memories land for the lone host bot
# (record_turn_memory_for_present writes one row per present bot
# per call — host only here, so 2 memories x 1 bot = 2 events).
mem_rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'memory_written'"
).fetchall()
synth_payloads = [
json.loads(r[0])
for r in mem_rows
if json.loads(r[0]).get("source") == "synthesized"
]
assert len(synth_payloads) == 2
for p in synth_payloads:
assert p["owner_id"] == "bot_a"
assert p["chat_id"] == "chat_bot_a"
# And the assistant_turn narration landed.
turn_rows = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'assistant_turn'"
).fetchall()
assert len(turn_rows) == 1
tp = json.loads(turn_rows[0][0])
assert tp["text"].strip()
assert tp["speaker_id"] == "bot_a"
def test_post_skip_jump_with_empty_prose_skips_memory_writes(client, tmp_path):
_seed_chat(tmp_path / "test.db")
# Empty prose short-circuits in synthesize_memories before any LLM call,
# so the canned queue only needs the narration.
narration = "(next morning: still in the kitchen.)"
_override_llm([narration])
try:
response = client.post(
"/chats/chat_bot_a/drawer/skip/jump",
data={
"new_time": "2026-04-27T08:00:00+00:00",
"notable_prose": " ",
"reset_activity": "",
},
)
assert response.status_code == 200
finally:
app.dependency_overrides.clear()
with open_db(tmp_path / "test.db") as conn:
synth = conn.execute(
"SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written' "
"AND payload_json LIKE '%synthesized%'"
).fetchone()[0]
assert synth == 0
# ---------------------------------------------------------------------------
# 6. POST thread/close — thread_closed lands and the open list drops it.
# ---------------------------------------------------------------------------
def test_post_thread_close_appends_thread_closed(client, tmp_path):
_seed_chat(tmp_path / "test.db")
# Open a thread directly via append_and_apply so the test focuses on
# the close route's effect.
with open_db(tmp_path / "test.db") as conn:
append_and_apply(
conn,
kind="thread_opened",
payload={
"thread_id": "thr_alpha",
"chat_id": "chat_bot_a",
"title": "the missing key",
"summary": "Couldn't find the key.",
},
)
response = client.post("/chats/chat_bot_a/drawer/thread/close/thr_alpha")
assert response.status_code == 200
with open_db(tmp_path / "test.db") as conn:
closed = conn.execute(
"SELECT payload_json FROM event_log WHERE kind = 'thread_closed'"
).fetchall()
assert len(closed) == 1
cp = json.loads(closed[0][0])
assert cp["thread_id"] == "thr_alpha"
from chat.state.threads import list_open_threads
assert list_open_threads(conn, "chat_bot_a") == []