From 2d1419755318169c65afec2b09d964a1452f4a28 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:27:47 -0400 Subject: [PATCH] feat: drawer events / threads / skip controls (T59) --- chat/templates/_drawer.html | 115 ++++++ chat/web/drawer.py | 396 +++++++++++++++++++- tests/test_drawer_events_threads_skip.py | 452 +++++++++++++++++++++++ 3 files changed, 962 insertions(+), 1 deletion(-) create mode 100644 tests/test_drawer_events_threads_skip.py diff --git a/chat/templates/_drawer.html b/chat/templates/_drawer.html index 2cf48a7..43a659a 100644 --- a/chat/templates/_drawer.html +++ b/chat/templates/_drawer.html @@ -41,6 +41,121 @@ {% endif %} {% endfor %} + +
+ Elision skip +
+ + + +
+
+ +
+ Jump skip +
+ + + + +
+
+ + +
+

Events

+ {% if active_events %} + + {% else %} +

No active events.

+ {% endif %} +
+ Plan event +
+ + + + +
+
+
+ +
+

Threads

+ {% if open_threads %} + + {% else %} +

No open threads.

+ {% endif %}
{% if guest_bot %} diff --git a/chat/web/drawer.py b/chat/web/drawer.py index fdfbda4..ea27721 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -27,19 +27,27 @@ one so a later inverse edit can restore state (§6.4 final paragraph). from __future__ import annotations +import json +import uuid +from datetime import datetime, timezone from pathlib import Path from fastapi import APIRouter, Depends, Form, HTTPException, Request from fastapi.responses import HTMLResponse from fastapi.templating import Jinja2Templates -from chat.eventlog.log import append_and_apply +from chat.eventlog.log import append_and_apply, append_event +from chat.services.memory_write import record_turn_memory_for_present from chat.services.relationship_seed import seed_inter_bot_edges from chat.services.scene_summarize import apply_scene_close_summary +from chat.services.skip_narration import narrate_skip +from chat.services.synthesized_memories import synthesize_memories from chat.state.edges import get_edge from chat.state.entities import get_bot, get_you, list_bots +from chat.state.events import list_active_events from chat.state.group_node import get_group_node from chat.state.memory import get_pinned +from chat.state.threads import list_open_threads from chat.state.world import active_scene, get_activity, get_chat, get_container from chat.web.bots import get_conn from chat.web.kickoff import get_llm_client @@ -155,6 +163,10 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): pinned = get_pinned(conn, chat["host_bot_id"]) + # T59: active events + open threads for the new drawer sections. + active_events = list_active_events(conn, chat_id) + open_threads = list_open_threads(conn, chat_id) + return TEMPLATES.TemplateResponse( request, "_drawer.html", @@ -180,6 +192,8 @@ async def drawer(chat_id: str, request: Request, conn=Depends(get_conn)): "recent_memories": recent_memories, "pinned": pinned, "pin_cap": PIN_CAP, + "active_events": active_events, + "open_threads": open_threads, }, ) @@ -839,3 +853,383 @@ async def remove_guest( ) return await drawer(chat_id, request, conn) + + +# --- T59 events / threads / skip controls -------------------------------- +# +# Five drawer-driven endpoints that emit Phase 3 event-log entries: +# +# * ``event_planned`` / ``event_cancelled`` for the events panel — props +# arrive as a JSON-encoded form field so the user can author arbitrary +# structured side-info without a custom HTMX widget per kind. +# * ``time_skip_elision`` / ``time_skip_jump`` for the skip panel — +# each emits the projector event AND an ``assistant_turn`` carrying the +# narration prose from :mod:`chat.services.skip_narration`. Jump skips +# ALSO write per-bot synthesized memories from any user-supplied +# ``notable_prose`` via :func:`synthesize_memories` + +# :func:`record_turn_memory_for_present`. +# * ``thread_closed`` for the threads panel. +# +# Skip narration is appended via plain ``append_event`` (assistant_turn +# has no projector handler — it's a transcript-only kind, see +# :func:`chat.web.turns._read_recent_dialogue`). The user will see the +# new turn on the next chat-detail page load; we do NOT broadcast via +# ``publish`` here because the SSE channel is scoped to the chat-detail +# page and the drawer partial is the response body — adding cross-cutting +# SSE here would require dragging the publish import + chat-channel state +# into the drawer module without a meaningful UX gain (the drawer only +# rerenders itself on these submissions). + + +def _parse_iso_time(value: str) -> datetime | None: + """Permissive ISO 8601 parser for skip route validation. + + ``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until 3.11, + so we normalize it to ``+00:00`` first. Returns ``None`` on parse + failure so the caller can return ``400`` with a stable error shape. + """ + if not value: + return None + try: + v = value.strip() + if v.endswith("Z"): + v = v[:-1] + "+00:00" + return datetime.fromisoformat(v) + except (TypeError, ValueError): + return None + + +def _now_iso() -> str: + """UTC ISO timestamp used as a fallback when the chat clock is unset.""" + return datetime.now(timezone.utc).isoformat() + + +@router.post( + "/chats/{chat_id}/drawer/event/plan", + response_class=HTMLResponse, +) +async def plan_event( + chat_id: str, + request: Request, + kind: str = Form(...), + planned_for: str = Form(...), + props_json: str = Form("{}"), + conn=Depends(get_conn), +): + """Append an ``event_planned`` row from the drawer's "Plan event" form. + + ``props_json`` is parsed into a dict before being attached to the + payload so the projector can treat it as structured data. Bad JSON + yields ``400`` — the form template renders an inline error in that + case so the user can fix-and-resubmit without losing their input. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + try: + props = json.loads(props_json) if props_json.strip() else {} + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=400, detail=f"props_json must be valid JSON: {exc}" + ) + if not isinstance(props, dict): + raise HTTPException( + status_code=400, detail="props_json must encode a JSON object" + ) + + event_id = f"evt_{uuid.uuid4().hex[:12]}" + append_and_apply( + conn, + kind="event_planned", + payload={ + "event_id": event_id, + "chat_id": chat_id, + "kind": kind, + "props": props, + "planned_for": planned_for, + }, + ) + return await drawer(chat_id, request, conn) + + +@router.post( + "/chats/{chat_id}/drawer/event/cancel/{event_id}", + response_class=HTMLResponse, +) +async def cancel_event( + chat_id: str, + event_id: str, + request: Request, + conn=Depends(get_conn), +): + """Append an ``event_cancelled`` row for ``event_id``. + + ``completed_at`` is sourced from the chat clock (so cancellations + timeline-align with the rest of the fiction) with a UTC-now fallback + when the clock isn't set. The projector is idempotent on terminal + statuses so a stale double-submit is harmless. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + completed_at = chat.get("time") or _now_iso() + append_and_apply( + conn, + kind="event_cancelled", + payload={ + "event_id": event_id, + "completed_at": completed_at, + }, + ) + return await drawer(chat_id, request, conn) + + +@router.post( + "/chats/{chat_id}/drawer/skip/elision", + response_class=HTMLResponse, +) +async def skip_elision( + chat_id: str, + request: Request, + landing_state_hint: str = Form(""), + new_time: str = Form(...), + conn=Depends(get_conn), + client=Depends(get_llm_client), +): + """Elision skip: collapse in-progress activity into its end-state. + + Validates ``new_time`` is ISO 8601 AND non-decreasing relative to the + chat clock (a backwards skip would corrupt downstream causality). + Emits ``time_skip_elision`` first (chat clock advances) then an + ``assistant_turn`` carrying the narrated transition from + :func:`chat.services.skip_narration.narrate_skip`. The narration call + has its own LLM-failure fallback so this route never blocks on a + flaky model. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + new_dt = _parse_iso_time(new_time) + if new_dt is None: + raise HTTPException( + status_code=400, + detail=f"new_time must be ISO 8601, got {new_time!r}", + ) + cur_dt = _parse_iso_time(chat.get("time") or "") + if cur_dt is not None and new_dt < cur_dt: + raise HTTPException( + status_code=400, + detail="new_time must not be earlier than the current chat clock", + ) + + host_bot = get_bot(conn, chat["host_bot_id"]) or { + "name": "host", + "persona": "", + } + you_entity = get_you(conn) or {"name": "you"} + bot_activity = get_activity(conn, chat["host_bot_id"]) or {} + current_activity = ( + (bot_activity.get("action") or {}).get("verb") or "" + ) + + settings = request.app.state.settings + narration = await narrate_skip( + client, + narrative_model=settings.narrative_model, + skip_kind="elision", + speaker_bot=host_bot, + you_name=you_entity.get("name") or "you", + current_time=chat.get("time") or "", + new_time=new_time, + current_activity=current_activity, + landing_state_hint=landing_state_hint, + timeout_s=settings.classifier_timeout_s, + ) + + append_and_apply( + conn, + kind="time_skip_elision", + payload={"chat_id": chat_id, "new_time": new_time}, + ) + append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": chat_id, + "speaker_id": host_bot["id"] if "id" in host_bot else chat["host_bot_id"], + "text": narration, + "truncated": False, + }, + ) + return await drawer(chat_id, request, conn) + + +@router.post( + "/chats/{chat_id}/drawer/skip/jump", + response_class=HTMLResponse, +) +async def skip_jump( + chat_id: str, + request: Request, + new_time: str = Form(...), + notable_prose: str = Form(""), + reset_activity: str = Form(""), + conn=Depends(get_conn), + client=Depends(get_llm_client), +): + """Jump skip: bridge a longer fiction-time delta. + + Same ISO + non-decreasing validations as the elision route. When + ``notable_prose`` is non-empty, runs :func:`synthesize_memories` + once per present bot witness (host always; guest when present), + then writes one ``memory_written`` per synthesized memory via + :func:`record_turn_memory_for_present` (which fans out to host + + guest internally). Each call writes ``source="synthesized"`` so the + retrieval ranker can treat them as lower-reliability than direct + turn memories. Finally emits ``time_skip_jump`` and the narration + ``assistant_turn``. + + ``reset_activity`` is parsed permissively ("1" / "true" / "on" / + "yes" — same shape as the add-guest reseed flag) since HTML + checkboxes typically post the literal "1" or omit the field + entirely. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + new_dt = _parse_iso_time(new_time) + if new_dt is None: + raise HTTPException( + status_code=400, + detail=f"new_time must be ISO 8601, got {new_time!r}", + ) + cur_dt = _parse_iso_time(chat.get("time") or "") + if cur_dt is not None and new_dt < cur_dt: + raise HTTPException( + status_code=400, + detail="new_time must not be earlier than the current chat clock", + ) + + reset_flag = reset_activity.lower() in ("1", "true", "on", "yes") + + host_bot = get_bot(conn, chat["host_bot_id"]) or { + "id": chat["host_bot_id"], + "name": "host", + "persona": "", + } + you_entity = get_you(conn) or {"name": "you"} + you_name = you_entity.get("name") or "you" + guest_bot_id = chat.get("guest_bot_id") + guest_bot = get_bot(conn, guest_bot_id) if guest_bot_id else None + + settings = request.app.state.settings + + # Emit time_skip_jump up front so the chat clock is at the new time + # before any memory writes — they should record at the post-jump + # clock, mirroring how a regular turn's memory carries the chat clock. + append_and_apply( + conn, + kind="time_skip_jump", + payload={ + "chat_id": chat_id, + "new_time": new_time, + "reset_activity": reset_flag, + }, + ) + + # Synthesize memories per present bot witness when prose is non-empty. + # ``synthesize_memories`` short-circuits on whitespace prose so this + # is safe to call unconditionally, but we gate the loop to avoid + # iterating a fixed empty list. + if notable_prose.strip(): + present_bots: list[dict] = [host_bot] + if guest_bot is not None: + present_bots.append(guest_bot) + for bot in present_bots: + digest = await synthesize_memories( + client, + classifier_model=settings.classifier_model, + prose=notable_prose, + bot_name=bot.get("name") or "", + bot_persona=bot.get("persona") or "", + you_name=you_name, + timeout_s=settings.classifier_timeout_s, + ) + for mem in digest.memories: + # ``record_turn_memory_for_present`` writes one + # ``memory_written`` per present bot per call. Calling it + # once per synthesized memory means N memories x M bots + # = N*M events; the loop above already iterates by bot + # so we pass guest_bot_id=None here to avoid double- + # writing the guest's row when bot==guest. + record_turn_memory_for_present( + conn, + chat_id=chat_id, + host_bot_id=bot["id"], + guest_bot_id=None, + narrative_text=mem.text, + chat_clock_at=new_time, + source="synthesized", + significance=mem.significance, + ) + + narration = await narrate_skip( + client, + narrative_model=settings.narrative_model, + skip_kind="jump", + speaker_bot=host_bot, + you_name=you_name, + current_time=chat.get("time") or "", + new_time=new_time, + current_activity="", + landing_state_hint=notable_prose, + timeout_s=settings.classifier_timeout_s, + ) + append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": chat_id, + "speaker_id": host_bot.get("id") or chat["host_bot_id"], + "text": narration, + "truncated": False, + }, + ) + return await drawer(chat_id, request, conn) + + +@router.post( + "/chats/{chat_id}/drawer/thread/close/{thread_id}", + response_class=HTMLResponse, +) +async def close_thread( + chat_id: str, + thread_id: str, + request: Request, + conn=Depends(get_conn), +): + """Append a ``thread_closed`` row for ``thread_id``. + + Mirrors :func:`cancel_event` — chat-clock-or-now timestamp, projector + handles idempotency. The drawer's open-threads list is sourced from + ``list_open_threads`` which filters by ``status='open'`` so a stale + double-submit is a no-op visually. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise HTTPException(status_code=404, detail=f"chat not found: {chat_id}") + + closed_at = chat.get("time") or _now_iso() + append_and_apply( + conn, + kind="thread_closed", + payload={ + "thread_id": thread_id, + "closed_at": closed_at, + }, + ) + return await drawer(chat_id, request, conn) diff --git a/tests/test_drawer_events_threads_skip.py b/tests/test_drawer_events_threads_skip.py new file mode 100644 index 0000000..ab1dafd --- /dev/null +++ b/tests/test_drawer_events_threads_skip.py @@ -0,0 +1,452 @@ +"""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_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") == []