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
+
+
+
+
+
+
+
+ Threads
+ {% if open_threads %}
+
+ {% for th in open_threads %}
+ -
+ {{ th.title }}
+ {% if th.summary %}
+
{{ th.summary }}
+ {% endif %}
+
+
+ {% endfor %}
+
+ {% 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") == []