"""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") == []