merge: T59 drawer events / threads / skip controls

This commit is contained in:
Joseph Doherty
2026-04-26 20:29:40 -04:00
3 changed files with 962 additions and 1 deletions
+115
View File
@@ -41,6 +41,121 @@
{% endif %}
</div>
{% endfor %}
<details class="skip-controls">
<summary>Elision skip</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/skip/elision"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Landing state hint:
<input type="text" name="landing_state_hint"
placeholder="e.g. arriving at the office">
</label>
<label>
New time (ISO 8601):
<input type="text" name="new_time" required
placeholder="2026-04-26T20:30:00+00:00">
</label>
<button type="submit">Skip ahead</button>
</form>
</details>
<details class="skip-controls">
<summary>Jump skip</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/skip/jump"
hx-target="#drawer" hx-swap="innerHTML">
<label>
New time (ISO 8601):
<input type="text" name="new_time" required
placeholder="2026-04-27T08:00:00+00:00">
</label>
<label>
Anything notable happen? (optional)
<textarea name="notable_prose" rows="3"
placeholder="leave blank to jump without synthesizing memories"></textarea>
</label>
<label>
<input type="checkbox" name="reset_activity" value="1">
Reset activity at landing
</label>
<button type="submit">Jump ahead</button>
</form>
</details>
</section>
<section class="drawer-section">
<h3>Events</h3>
{% if active_events %}
<ul class="event-list">
{% for ev in active_events %}
<li class="event-row">
<strong>{{ ev.kind }}</strong>
<span class="muted"> ({{ ev.status }})</span>
{% if ev.planned_for %}
<p class="muted">planned for: {{ ev.planned_for }}</p>
{% endif %}
{% if ev.props %}
<p class="muted">{{ ev.props|tojson }}</p>
{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/event/cancel/{{ ev.event_id }}"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Cancel</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No active events.</p>
{% endif %}
<details>
<summary>Plan event</summary>
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/event/plan"
hx-target="#drawer" hx-swap="innerHTML">
<label>
Kind:
<input type="text" name="kind" required
placeholder="e.g. dinner_reservation">
</label>
<label>
Planned for (ISO 8601):
<input type="text" name="planned_for" required
placeholder="2026-04-26T19:00:00+00:00">
</label>
<label>
Props (JSON):
<textarea name="props_json" rows="3"
placeholder='{"location": "Bistro X"}'>{}</textarea>
</label>
<button type="submit">Plan event</button>
</form>
</details>
</section>
<section class="drawer-section">
<h3>Threads</h3>
{% if open_threads %}
<ul class="thread-list">
{% for th in open_threads %}
<li class="thread-row">
<strong>{{ th.title }}</strong>
{% if th.summary %}
<p>{{ th.summary }}</p>
{% endif %}
<form class="inline-edit"
hx-post="/chats/{{ chat.id }}/drawer/thread/close/{{ th.thread_id }}"
hx-target="#drawer" hx-swap="innerHTML">
<button type="submit">Close</button>
</form>
</li>
{% endfor %}
</ul>
{% else %}
<p class="muted">No open threads.</p>
{% endif %}
</section>
{% if guest_bot %}
+395 -1
View File
@@ -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)
+452
View File
@@ -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 "<h3>Events</h3>" in body
assert "<h3>Threads</h3>" 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") == []