453 lines
16 KiB
Python
453 lines
16 KiB
Python
"""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") == []
|