From b6888ff36a28783a448a8ed0df6ccff7daeaeb59 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:04:36 -0400 Subject: [PATCH 01/22] feat: events table + lifecycle handlers (T49) --- chat/db/migrations/0009_events.sql | 14 ++ chat/state/events.py | 127 ++++++++++++++++ tests/test_events_state.py | 235 +++++++++++++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 chat/db/migrations/0009_events.sql create mode 100644 chat/state/events.py create mode 100644 tests/test_events_state.py diff --git a/chat/db/migrations/0009_events.sql b/chat/db/migrations/0009_events.sql new file mode 100644 index 0000000..0467b1d --- /dev/null +++ b/chat/db/migrations/0009_events.sql @@ -0,0 +1,14 @@ +CREATE TABLE events ( + id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'planned', + props_json TEXT NOT NULL DEFAULT '{}', + planned_for TEXT, + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX events_chat_idx ON events(chat_id, status); diff --git a/chat/state/events.py b/chat/state/events.py new file mode 100644 index 0000000..b2f0b1c --- /dev/null +++ b/chat/state/events.py @@ -0,0 +1,127 @@ +from __future__ import annotations +import json +from sqlite3 import Connection + +from chat.eventlog.projector import on +from chat.eventlog.log import Event + + +_TERMINAL_STATUSES = {"completed", "cancelled", "expired"} + + +@on("event_planned") +def _apply_event_planned(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT OR IGNORE INTO events " + "(event_id, chat_id, kind, status, props_json, planned_for) " + "VALUES (?, ?, ?, 'planned', ?, ?)", + ( + p["event_id"], + p["chat_id"], + p["kind"], + json.dumps(p.get("props", {})), + p.get("planned_for"), + ), + ) + + +@on("event_started") +def _apply_event_started(conn: Connection, e: Event) -> None: + p = e.payload + # Idempotent: only transition from non-terminal status. + conn.execute( + "UPDATE events SET status = 'active', started_at = ?, updated_at = datetime('now') " + "WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')", + (p.get("started_at"), p["event_id"]), + ) + + +@on("event_completed") +def _apply_event_completed(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE events SET status = 'completed', completed_at = ?, updated_at = datetime('now') " + "WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')", + (p.get("completed_at"), p["event_id"]), + ) + + +@on("event_cancelled") +def _apply_event_cancelled(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE events SET status = 'cancelled', completed_at = ?, updated_at = datetime('now') " + "WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')", + (p.get("completed_at"), p["event_id"]), + ) + + +@on("event_expired") +def _apply_event_expired(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE events SET status = 'expired', completed_at = ?, updated_at = datetime('now') " + "WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')", + (p.get("completed_at"), p["event_id"]), + ) + + +def get_event(conn: Connection, event_id: str) -> dict | None: + row = conn.execute( + "SELECT event_id, chat_id, kind, status, props_json, planned_for, " + "started_at, completed_at, created_at, updated_at " + "FROM events WHERE event_id = ?", + (event_id,), + ).fetchone() + if not row: + return None + return { + "event_id": row[0], + "chat_id": row[1], + "kind": row[2], + "status": row[3], + "props": json.loads(row[4]), + "planned_for": row[5], + "started_at": row[6], + "completed_at": row[7], + "created_at": row[8], + "updated_at": row[9], + } + + +def list_active_events(conn: Connection, chat_id: str) -> list[dict]: + rows = conn.execute( + "SELECT event_id, chat_id, kind, status, props_json, planned_for, " + "started_at, completed_at, created_at, updated_at " + "FROM events WHERE chat_id = ? AND status IN ('planned','active') " + "ORDER BY id ASC", + (chat_id,), + ).fetchall() + return [ + { + "event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3], + "props": json.loads(r[4]), + "planned_for": r[5], "started_at": r[6], "completed_at": r[7], + "created_at": r[8], "updated_at": r[9], + } + for r in rows + ] + + +def list_events_in_status(conn: Connection, chat_id: str, status: str) -> list[dict]: + rows = conn.execute( + "SELECT event_id, chat_id, kind, status, props_json, planned_for, " + "started_at, completed_at, created_at, updated_at " + "FROM events WHERE chat_id = ? AND status = ? ORDER BY id ASC", + (chat_id, status), + ).fetchall() + return [ + { + "event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3], + "props": json.loads(r[4]), + "planned_for": r[5], "started_at": r[6], "completed_at": r[7], + "created_at": r[8], "updated_at": r[9], + } + for r in rows + ] diff --git a/tests/test_events_state.py b/tests/test_events_state.py new file mode 100644 index 0000000..6ced284 --- /dev/null +++ b/tests/test_events_state.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations +from chat.eventlog.log import append_and_apply, append_event +from chat.eventlog.projector import project +import chat.state.entities # registers handlers +import chat.state.world # registers handlers +import chat.state.group_node # registers handlers +import chat.state.events # registers handlers +from chat.state.events import ( + get_event, + list_active_events, + list_events_in_status, +) + + +def _bot_payload(bot_id: str, name: str) -> dict: + return { + "id": bot_id, + "name": name, + "persona": "thoughtful, observant", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "", + } + + +def _chat_payload(chat_id: str = "chat_bot_a") -> dict: + return { + "id": chat_id, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1 evening", + "weather": "clear", + } + + +def _seed_chat(conn) -> None: + 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="chat_created", payload=_chat_payload()) + + +def test_event_planned_creates_row(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_chat(conn) + append_event( + conn, + kind="event_planned", + payload={ + "event_id": "evt_abc", + "chat_id": "chat_bot_a", + "kind": "date_at_park", + "props": {"location": "park"}, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + project(conn) + + ev = get_event(conn, "evt_abc") + assert ev is not None + assert ev["event_id"] == "evt_abc" + assert ev["chat_id"] == "chat_bot_a" + assert ev["kind"] == "date_at_park" + assert ev["status"] == "planned" + assert ev["props"]["location"] == "park" + assert ev["planned_for"] == "2026-04-30T18:00:00+00:00" + assert ev["started_at"] is None + assert ev["completed_at"] is None + + +def test_event_started_then_completed_updates_status(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_chat(conn) + append_event( + conn, + kind="event_planned", + payload={ + "event_id": "evt_abc", + "chat_id": "chat_bot_a", + "kind": "date_at_park", + "props": {}, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + append_event( + conn, + kind="event_started", + payload={ + "event_id": "evt_abc", + "started_at": "2026-04-30T18:01:00+00:00", + }, + ) + append_event( + conn, + kind="event_completed", + payload={ + "event_id": "evt_abc", + "completed_at": "2026-04-30T20:00:00+00:00", + }, + ) + project(conn) + + ev = get_event(conn, "evt_abc") + assert ev is not None + assert ev["status"] == "completed" + assert ev["started_at"] == "2026-04-30T18:01:00+00:00" + assert ev["completed_at"] == "2026-04-30T20:00:00+00:00" + + +def test_event_cancelled_terminal_subsequent_transitions_ignored(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_chat(conn) + append_event( + conn, + kind="event_planned", + payload={ + "event_id": "evt_abc", + "chat_id": "chat_bot_a", + "kind": "date_at_park", + "props": {}, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + append_event( + conn, + kind="event_cancelled", + payload={ + "event_id": "evt_abc", + "completed_at": "2026-04-30T17:00:00+00:00", + }, + ) + project(conn) + + ev = get_event(conn, "evt_abc") + assert ev is not None + assert ev["status"] == "cancelled" + assert ev["completed_at"] == "2026-04-30T17:00:00+00:00" + + # Subsequent event_started must be no-oped because status is terminal. + # Use append_and_apply so we apply ONLY this new event without + # replaying earlier non-idempotent handlers (e.g. chat_created). + append_and_apply( + conn, + kind="event_started", + payload={ + "event_id": "evt_abc", + "started_at": "2026-04-30T18:01:00+00:00", + }, + ) + + ev2 = get_event(conn, "evt_abc") + assert ev2 is not None + assert ev2["status"] == "cancelled" + assert ev2["started_at"] is None + # completed_at unchanged from the cancelled transition + assert ev2["completed_at"] == "2026-04-30T17:00:00+00:00" + + +def test_list_active_events_filters_to_planned_and_active(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_chat(conn) + # Four events: one planned, one active, one completed, one cancelled. + for ev_id, kind in [ + ("evt_planned", "date_at_park"), + ("evt_active", "movie_night"), + ("evt_done", "dinner"), + ("evt_canx", "trip"), + ]: + append_event( + conn, + kind="event_planned", + payload={ + "event_id": ev_id, + "chat_id": "chat_bot_a", + "kind": kind, + "props": {}, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + append_event( + conn, + kind="event_started", + payload={ + "event_id": "evt_active", + "started_at": "2026-04-30T18:01:00+00:00", + }, + ) + append_event( + conn, + kind="event_started", + payload={ + "event_id": "evt_done", + "started_at": "2026-04-30T18:01:00+00:00", + }, + ) + append_event( + conn, + kind="event_completed", + payload={ + "event_id": "evt_done", + "completed_at": "2026-04-30T20:00:00+00:00", + }, + ) + append_event( + conn, + kind="event_cancelled", + payload={ + "event_id": "evt_canx", + "completed_at": "2026-04-30T17:00:00+00:00", + }, + ) + project(conn) + + active = list_active_events(conn, "chat_bot_a") + active_ids = {e["event_id"] for e in active} + assert active_ids == {"evt_planned", "evt_active"} + + completed = list_events_in_status(conn, "chat_bot_a", "completed") + assert [e["event_id"] for e in completed] == ["evt_done"] + + cancelled = list_events_in_status(conn, "chat_bot_a", "cancelled") + assert [e["event_id"] for e in cancelled] == ["evt_canx"] -- 2.52.0 From ab2b494c21bcb5777fc09988da86da4b38553322 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:04:46 -0400 Subject: [PATCH 02/22] feat: time_skip event handlers (T50) --- chat/state/world.py | 28 +++++++ tests/test_time_skip_handlers.py | 132 +++++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+) create mode 100644 tests/test_time_skip_handlers.py diff --git a/chat/state/world.py b/chat/state/world.py index 8a9ba89..f99ddb9 100644 --- a/chat/state/world.py +++ b/chat/state/world.py @@ -29,6 +29,34 @@ def _apply_chat_created(conn: Connection, e: Event) -> None: ) +@on("time_skip_elision") +def _apply_time_skip_elision(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE chat_state SET time = ? WHERE chat_id = ?", + (p["new_time"], p["chat_id"]), + ) + + +@on("time_skip_jump") +def _apply_time_skip_jump(conn: Connection, e: Event) -> None: + p = e.payload + chat_id = p["chat_id"] + conn.execute( + "UPDATE chat_state SET time = ? WHERE chat_id = ?", + (p["new_time"], chat_id), + ) + if p.get("reset_activity", False): + # Activity rows are keyed by entity_id with a container_id FK. + # Each chat owns its containers, so deleting activity rows whose + # container_id belongs to this chat clears every present entity. + conn.execute( + "DELETE FROM activity " + "WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)", + (chat_id,), + ) + + @on("guest_added") def _apply_guest_added(conn: Connection, e: Event) -> None: p = e.payload diff --git a/tests/test_time_skip_handlers.py b/tests/test_time_skip_handlers.py new file mode 100644 index 0000000..0fa6d86 --- /dev/null +++ b/tests/test_time_skip_handlers.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +import chat.state.world # registers handlers +from chat.state.world import get_activity, get_chat + + +def _chat_payload(**overrides): + payload = { + "id": "chat_bot_a", + "host_bot_id": "bot_a", + "guest_bot_id": None, + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1 evening", + "weather": "clear", + } + payload.update(overrides) + return payload + + +def _container_payload(**overrides): + payload = { + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": { + "public": True, + "moving": False, + "audible_range": "normal", + "slots": [], + }, + "parent_id": None, + } + payload.update(overrides) + return payload + + +def _activity_payload(**overrides): + payload = { + "entity_id": "bot_a", + "container_id": 1, + "slot": "desk_chair", + "posture": "sitting", + "action": {"verb": "writing email"}, + "attention": "the screen", + "holding": ["pen"], + "status": {"hungry": False}, + } + payload.update(overrides) + return payload + + +def _seed_events(conn): + """Append seed events but do NOT project — caller appends more then projects once.""" + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event(conn, kind="container_created", payload=_container_payload()) + append_event(conn, kind="activity_change", payload=_activity_payload()) + + +def test_elision_advances_chat_clock_only(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_events(conn) + append_event(conn, kind="time_skip_elision", payload={ + "chat_id": "chat_bot_a", + "new_time": "2026-04-26T20:30:00+00:00", + }) + project(conn) + + chat = get_chat(conn, "chat_bot_a") + assert chat["time"] == "2026-04-26T20:30:00+00:00" + + # Activity row preserved with the same fields it was seeded with. + a = get_activity(conn, "bot_a") + assert a is not None + assert a["entity_id"] == "bot_a" + assert a["container_id"] == 1 + assert a["slot"] == "desk_chair" + assert a["posture"] == "sitting" + assert a["action"] == {"verb": "writing email"} + assert a["attention"] == "the screen" + assert a["holding"] == ["pen"] + assert a["status"] == {"hungry": False} + + +def test_jump_with_reset_clears_activity(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_events(conn) + append_event(conn, kind="time_skip_jump", payload={ + "chat_id": "chat_bot_a", + "new_time": "2026-04-27T08:00:00+00:00", + "reset_activity": True, + }) + project(conn) + + chat = get_chat(conn, "chat_bot_a") + assert chat["time"] == "2026-04-27T08:00:00+00:00" + + count = conn.execute( + "SELECT COUNT(*) FROM activity " + "WHERE container_id IN (SELECT id FROM containers WHERE chat_id = ?)", + ("chat_bot_a",), + ).fetchone()[0] + assert count == 0 + assert get_activity(conn, "bot_a") is None + + +def test_jump_without_reset_preserves_activity(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_events(conn) + append_event(conn, kind="time_skip_jump", payload={ + "chat_id": "chat_bot_a", + "new_time": "2026-04-27T08:00:00+00:00", + "reset_activity": False, + }) + project(conn) + + chat = get_chat(conn, "chat_bot_a") + assert chat["time"] == "2026-04-27T08:00:00+00:00" + + a = get_activity(conn, "bot_a") + assert a is not None + assert a["posture"] == "sitting" + assert a["action"]["verb"] == "writing email" -- 2.52.0 From 25bcbac055843d10ec40fb3be4d4ab0bf2ee78e7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:05:09 -0400 Subject: [PATCH 03/22] feat: threads table + projector handlers (T51) --- chat/db/migrations/0010_threads.sql | 14 +++ chat/state/threads.py | 123 +++++++++++++++++++ tests/test_threads_state.py | 181 ++++++++++++++++++++++++++++ 3 files changed, 318 insertions(+) create mode 100644 chat/db/migrations/0010_threads.sql create mode 100644 chat/state/threads.py create mode 100644 tests/test_threads_state.py diff --git a/chat/db/migrations/0010_threads.sql b/chat/db/migrations/0010_threads.sql new file mode 100644 index 0000000..6f61d33 --- /dev/null +++ b/chat/db/migrations/0010_threads.sql @@ -0,0 +1,14 @@ +CREATE TABLE threads ( + id INTEGER PRIMARY KEY, + thread_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL, + title TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + status TEXT NOT NULL DEFAULT 'open', + opened_at TEXT NOT NULL DEFAULT (datetime('now')), + closed_at TEXT, + last_referenced_scene_id INTEGER, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX threads_chat_status_idx ON threads(chat_id, status); diff --git a/chat/state/threads.py b/chat/state/threads.py new file mode 100644 index 0000000..6aa6407 --- /dev/null +++ b/chat/state/threads.py @@ -0,0 +1,123 @@ +from __future__ import annotations +from sqlite3 import Connection + +from chat.eventlog.projector import on +from chat.eventlog.log import Event + + +@on("thread_opened") +def _apply_thread_opened(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT OR IGNORE INTO threads " + "(thread_id, chat_id, title, summary, status) " + "VALUES (?, ?, ?, ?, 'open')", + ( + p["thread_id"], + p["chat_id"], + p["title"], + p.get("summary", ""), + ), + ) + + +@on("thread_updated") +def _apply_thread_updated(conn: Connection, e: Event) -> None: + p = e.payload + # Idempotent: closed threads ignore subsequent updates. + conn.execute( + "UPDATE threads SET summary = ?, last_referenced_scene_id = ?, " + "updated_at = datetime('now') " + "WHERE thread_id = ? AND status = 'open'", + ( + p.get("summary", ""), + p.get("last_referenced_scene_id"), + p["thread_id"], + ), + ) + + +@on("thread_closed") +def _apply_thread_closed(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE threads SET status = 'closed', closed_at = ?, " + "updated_at = datetime('now') " + "WHERE thread_id = ? AND status = 'open'", + (p.get("closed_at"), p["thread_id"]), + ) + + +def get_thread(conn: Connection, thread_id: str) -> dict | None: + row = conn.execute( + "SELECT thread_id, chat_id, title, summary, status, " + "opened_at, closed_at, last_referenced_scene_id, " + "created_at, updated_at " + "FROM threads WHERE thread_id = ?", + (thread_id,), + ).fetchone() + if not row: + return None + return { + "thread_id": row[0], + "chat_id": row[1], + "title": row[2], + "summary": row[3], + "status": row[4], + "opened_at": row[5], + "closed_at": row[6], + "last_referenced_scene_id": row[7], + "created_at": row[8], + "updated_at": row[9], + } + + +def list_open_threads(conn: Connection, chat_id: str) -> list[dict]: + rows = conn.execute( + "SELECT thread_id, chat_id, title, summary, status, " + "opened_at, closed_at, last_referenced_scene_id, " + "created_at, updated_at " + "FROM threads WHERE chat_id = ? AND status = 'open' " + "ORDER BY id ASC", + (chat_id,), + ).fetchall() + return [ + { + "thread_id": r[0], "chat_id": r[1], "title": r[2], + "summary": r[3], "status": r[4], + "opened_at": r[5], "closed_at": r[6], + "last_referenced_scene_id": r[7], + "created_at": r[8], "updated_at": r[9], + } + for r in rows + ] + + +def list_threads(conn: Connection, chat_id: str, status: str | None = None) -> list[dict]: + if status is None: + rows = conn.execute( + "SELECT thread_id, chat_id, title, summary, status, " + "opened_at, closed_at, last_referenced_scene_id, " + "created_at, updated_at " + "FROM threads WHERE chat_id = ? ORDER BY id ASC", + (chat_id,), + ).fetchall() + else: + rows = conn.execute( + "SELECT thread_id, chat_id, title, summary, status, " + "opened_at, closed_at, last_referenced_scene_id, " + "created_at, updated_at " + "FROM threads WHERE chat_id = ? AND status = ? " + "ORDER BY id ASC", + (chat_id, status), + ).fetchall() + return [ + { + "thread_id": r[0], "chat_id": r[1], "title": r[2], + "summary": r[3], "status": r[4], + "opened_at": r[5], "closed_at": r[6], + "last_referenced_scene_id": r[7], + "created_at": r[8], "updated_at": r[9], + } + for r in rows + ] diff --git a/tests/test_threads_state.py b/tests/test_threads_state.py new file mode 100644 index 0000000..6d6482d --- /dev/null +++ b/tests/test_threads_state.py @@ -0,0 +1,181 @@ +from __future__ import annotations + +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations +from chat.eventlog.log import append_and_apply, append_event +from chat.eventlog.projector import project +import chat.state.entities # registers handlers +import chat.state.world # registers handlers +import chat.state.threads # registers handlers +from chat.state.threads import get_thread, list_open_threads + + +def _bot_payload(bot_id: str, name: str) -> dict: + return { + "id": bot_id, + "name": name, + "persona": "thoughtful, observant", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "", + } + + +def _chat_payload(chat_id: str = "chat_bot_a") -> dict: + return { + "id": chat_id, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1 evening", + "weather": "clear", + } + + +def test_thread_opened_creates_row(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA")) + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event( + conn, + kind="thread_opened", + payload={ + "thread_id": "thr_abc", + "chat_id": "chat_bot_a", + "title": "Maya's job hunt", + "summary": "Maya is looking for a new job", + }, + ) + project(conn) + + t = get_thread(conn, "thr_abc") + assert t is not None + assert t["thread_id"] == "thr_abc" + assert t["chat_id"] == "chat_bot_a" + assert t["title"] == "Maya's job hunt" + assert t["summary"] == "Maya is looking for a new job" + assert t["status"] == "open" + assert t["closed_at"] is None + assert t["last_referenced_scene_id"] is None + + +def test_thread_updated_changes_summary_and_last_referenced(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA")) + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event( + conn, + kind="thread_opened", + payload={ + "thread_id": "thr_abc", + "chat_id": "chat_bot_a", + "title": "Maya's job hunt", + "summary": "Maya is looking for a new job", + }, + ) + append_event( + conn, + kind="thread_updated", + payload={ + "thread_id": "thr_abc", + "summary": "Maya landed an interview at a startup", + "last_referenced_scene_id": 42, + }, + ) + project(conn) + + t = get_thread(conn, "thr_abc") + assert t is not None + assert t["summary"] == "Maya landed an interview at a startup" + assert t["last_referenced_scene_id"] == 42 + assert t["status"] == "open" + + +def test_thread_closed_terminal(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA")) + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event( + conn, + kind="thread_opened", + payload={ + "thread_id": "thr_abc", + "chat_id": "chat_bot_a", + "title": "Maya's job hunt", + "summary": "Maya is looking for a new job", + }, + ) + append_event( + conn, + kind="thread_closed", + payload={ + "thread_id": "thr_abc", + "closed_at": "2026-04-26T21:00:00+00:00", + }, + ) + project(conn) + + t = get_thread(conn, "thr_abc") + assert t is not None + assert t["status"] == "closed" + assert t["closed_at"] == "2026-04-26T21:00:00+00:00" + + # Subsequent updates to a closed thread are no-ops. + append_and_apply( + conn, + kind="thread_updated", + payload={ + "thread_id": "thr_abc", + "summary": "should not be applied", + }, + ) + + t2 = get_thread(conn, "thr_abc") + assert t2 is not None + assert t2["summary"] == "Maya is looking for a new job" + assert t2["status"] == "closed" + + +def test_list_open_threads_filters_to_open_only(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA")) + append_event(conn, kind="chat_created", payload=_chat_payload()) + for tid, title in [ + ("thr_1", "Arc 1"), + ("thr_2", "Arc 2"), + ("thr_3", "Arc 3"), + ]: + append_event( + conn, + kind="thread_opened", + payload={ + "thread_id": tid, + "chat_id": "chat_bot_a", + "title": title, + "summary": "", + }, + ) + append_event( + conn, + kind="thread_closed", + payload={ + "thread_id": "thr_3", + "closed_at": "2026-04-26T21:00:00+00:00", + }, + ) + project(conn) + + open_threads = list_open_threads(conn, "chat_bot_a") + assert len(open_threads) == 2 + ids = {t["thread_id"] for t in open_threads} + assert ids == {"thr_1", "thr_2"} -- 2.52.0 From da1f67fb6ad974fec2a5cc93c559056d77936880 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:07:08 -0400 Subject: [PATCH 04/22] test: bump schema_version assertion to 10 (0009 events + 0010 threads) --- tests/test_world.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_world.py b/tests/test_world.py index f96a538..cff65f4 100644 --- a/tests/test_world.py +++ b/tests/test_world.py @@ -324,11 +324,11 @@ def test_get_scene_returns_none_for_missing(tmp_path): assert active_scene(conn, "chat_missing") is None -def test_schema_version_after_migration_is_8(tmp_path): +def test_schema_version_after_migration_is_10(tmp_path): db = tmp_path / "t.db" apply_migrations(db) with open_db(db) as conn: row = conn.execute( "SELECT value FROM meta WHERE key = 'schema_version'" ).fetchone() - assert int(row[0]) == 8 + assert int(row[0]) == 10 -- 2.52.0 From 98250644ad3e20ab8d9ecfa7d6c2e7814170211e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:09:13 -0400 Subject: [PATCH 05/22] feat: event-lifecycle detection service (T52) --- chat/services/event_lifecycle.py | 72 +++++++++++++++++++++ tests/test_event_lifecycle.py | 103 +++++++++++++++++++++++++++++++ 2 files changed, 175 insertions(+) create mode 100644 chat/services/event_lifecycle.py create mode 100644 tests/test_event_lifecycle.py diff --git a/chat/services/event_lifecycle.py b/chat/services/event_lifecycle.py new file mode 100644 index 0000000..8a956bb --- /dev/null +++ b/chat/services/event_lifecycle.py @@ -0,0 +1,72 @@ +"""Event-lifecycle detection (T52). + +After each turn, classify whether any active events transitioned +(started, completed, cancelled). Conservative bias — most turns +return empty. T61 turn flow appends one event_started/completed/ +cancelled per transition via append_and_apply. +""" + +from __future__ import annotations +from pydantic import BaseModel, Field + +from chat.llm.classify import classify +from chat.llm.client import LLMClient + + +class EventTransition(BaseModel): + event_id: str + new_status: str # "active" | "completed" | "cancelled" + reason: str = "" + + +class EventLifecycleDecision(BaseModel): + transitions: list[EventTransition] = Field(default_factory=list) + + +_SYSTEM = ( + "You decide whether any active events transitioned this turn. " + "STRONGLY default to empty transitions — most turns do NOT resolve " + "or start a known event. Output only transitions that the narrative " + "text clearly resolves or starts. Each transition MUST reference an " + "event_id from the active_events list. new_status is one of " + "'active' (planned -> active), 'completed', or 'cancelled'. " + "Output strict JSON matching the schema." +) + + +async def detect_event_transitions( + client: LLMClient, + *, + classifier_model: str, + narrative_text: str, + active_events: list[dict], # [{event_id, kind, status, props}, ...] + timeout_s: float = 30.0, +) -> EventLifecycleDecision: + """Classify event transitions for the latest turn. Empty active_events + short-circuits without an LLM call.""" + if not active_events: + return EventLifecycleDecision() + + user_lines = ["Active events:"] + for ev in active_events: + user_lines.append( + f"- event_id={ev['event_id']} kind={ev['kind']} " + f"status={ev['status']} props={ev.get('props', {})}" + ) + user_lines.append("") + user_lines.append("Latest narrative:") + user_lines.append(narrative_text.strip()) + user = "\n".join(user_lines) + + return await classify( + client, + model=classifier_model, + system=_SYSTEM, + user=user, + schema=EventLifecycleDecision, + default=EventLifecycleDecision(), + timeout_s=timeout_s, + ) + + +__all__ = ["EventTransition", "EventLifecycleDecision", "detect_event_transitions"] diff --git a/tests/test_event_lifecycle.py b/tests/test_event_lifecycle.py new file mode 100644 index 0000000..6af8d6f --- /dev/null +++ b/tests/test_event_lifecycle.py @@ -0,0 +1,103 @@ +"""Tests for the event-lifecycle detection service (T52). + +Per Phase 3, after each narrated turn we ask a classifier whether any +active events transitioned (started, completed, cancelled). The bias is +strongly toward an empty result — most turns do NOT resolve or start a +known event, and the turn-flow caller (T61) only appends an +event_started/completed/cancelled record when this service yields one. + +These tests cover: + +* The classifier returning a single transition is honored end-to-end. +* An empty ``active_events`` list short-circuits before any LLM call, + so callers that hold no live events pay zero classifier cost. +* Three rounds of malformed JSON exhaust ``classify``'s retries and we + fall back to the empty default — graceful degradation per §3.3. +""" + +from __future__ import annotations + +import json + +import pytest + +from chat.llm.mock import MockLLMClient +from chat.services.event_lifecycle import ( + EventLifecycleDecision, + detect_event_transitions, +) + + +@pytest.mark.asyncio +async def test_detects_one_transition_happy_path(): + canned = json.dumps( + { + "transitions": [ + { + "event_id": "evt_1", + "new_status": "completed", + "reason": "they arrived at the park", + } + ] + } + ) + mock = MockLLMClient(canned=[canned]) + result = await detect_event_transitions( + mock, + classifier_model="x", + narrative_text="They walked through the park gate, finally there.", + active_events=[ + { + "event_id": "evt_1", + "kind": "date_at_park", + "status": "active", + "props": {}, + } + ], + ) + assert isinstance(result, EventLifecycleDecision) + assert len(result.transitions) == 1 + assert result.transitions[0].event_id == "evt_1" + assert result.transitions[0].new_status == "completed" + assert result.transitions[0].reason == "they arrived at the park" + + +@pytest.mark.asyncio +async def test_empty_active_events_short_circuits_without_classifier_call(): + """No active events -> no classifier call. + + The mock has an empty canned list; any ``generate`` call would raise + ``IndexError`` from ``list.pop(0)``. The test passing proves the + short-circuit holds. + """ + mock = MockLLMClient(canned=[]) + result = await detect_event_transitions( + mock, + classifier_model="x", + narrative_text="Just a quiet moment between them.", + active_events=[], + ) + assert isinstance(result, EventLifecycleDecision) + assert result.transitions == [] + + +@pytest.mark.asyncio +async def test_classifier_failure_returns_empty_default(): + """``classify`` retries 3 times; after all fail it returns the empty + default so the turn flow keeps moving (§3.3 graceful degradation).""" + mock = MockLLMClient(canned=["bad", "bad", "bad"]) + result = await detect_event_transitions( + mock, + classifier_model="x", + narrative_text="Some text the classifier will choke on.", + active_events=[ + { + "event_id": "evt_1", + "kind": "date_at_park", + "status": "active", + "props": {}, + } + ], + ) + assert isinstance(result, EventLifecycleDecision) + assert result.transitions == [] -- 2.52.0 From adbbd32873169a0e4ca9bfe8bd1f8d4303d057cd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:10:05 -0400 Subject: [PATCH 06/22] feat: synthesized-memories service for jump skips (T54) --- chat/services/synthesized_memories.py | 74 ++++++++++++++++++++ tests/test_synthesized_memories.py | 98 +++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 chat/services/synthesized_memories.py create mode 100644 tests/test_synthesized_memories.py diff --git a/chat/services/synthesized_memories.py b/chat/services/synthesized_memories.py new file mode 100644 index 0000000..9724e31 --- /dev/null +++ b/chat/services/synthesized_memories.py @@ -0,0 +1,74 @@ +"""Synthesized-memories service (T54). + +When the user jump-skips with 'anything notable happen?' prose, parse +that prose into 1-N synthesized memories per present bot. Each memory +carries source="synthesized" and reliability=0.7 (lower than direct). +Caller (T62 skip flow) writes the memories via record_turn_memory_for_present. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from chat.llm.classify import classify +from chat.llm.client import LLMClient + + +class SynthesizedMemory(BaseModel): + text: str + significance: int = 1 # 0..3, default 1 + affinity_delta: int = 0 + trust_delta: int = 0 + + +class SynthesizedDigest(BaseModel): + memories: list[SynthesizedMemory] = Field(default_factory=list) + + +_SYSTEM = ( + "You parse a short user-supplied prose describing 'anything notable' " + "that happened during a time skip into 1-N synthesized memories from " + "a single bot's POV. Each memory has: text (one factual sentence " + "from that bot's perspective), significance (0-3, default 1; only " + "use 2 or 3 for genuinely scene-level or relationship-altering " + "events), affinity_delta and trust_delta (-10..+10, default 0; " + "use small adjustments only when prose explicitly describes a shift). " + "Empty/whitespace prose returns an empty memories list. Output " + "strict JSON matching the schema." +) + + +async def synthesize_memories( + client: LLMClient, + *, + classifier_model: str, + prose: str, + bot_name: str, + bot_persona: str, + you_name: str, + timeout_s: float = 30.0, +) -> SynthesizedDigest: + """Parse 'anything notable' prose into structured memories from a + single bot's POV. Empty/whitespace prose short-circuits to an + empty digest (no LLM call).""" + if not prose or not prose.strip(): + return SynthesizedDigest() + + user = ( + f"Bot: {bot_name}\n" + f"Persona: {bot_persona}\n" + f"Other party: {you_name}\n\n" + f"Prose:\n{prose.strip()}" + ) + return await classify( + client, + model=classifier_model, + system=_SYSTEM, + user=user, + schema=SynthesizedDigest, + default=SynthesizedDigest(), + timeout_s=timeout_s, + ) + + +__all__ = ["SynthesizedMemory", "SynthesizedDigest", "synthesize_memories"] diff --git a/tests/test_synthesized_memories.py b/tests/test_synthesized_memories.py new file mode 100644 index 0000000..23bf7ac --- /dev/null +++ b/tests/test_synthesized_memories.py @@ -0,0 +1,98 @@ +"""Tests for the synthesized-memories service (T54). + +When the user jump-skips ("a week later") they are prompted "anything +notable happen?" If they answer with prose, this service parses it into +1-N synthesized memories per present bot. Each memory carries +``source="synthesized"`` and ``reliability=0.7`` (the caller — T62 skip +flow — applies those tags when persisting; this service just produces +the structured digest). + +These tests cover: + +* The happy path: a canned classifier response parses cleanly into a + populated :class:`SynthesizedDigest` with one memory. +* Empty prose short-circuits before any classifier call — the mock has + no canned responses, so an accidental call would raise + ``IndexError``. +* Classifier failure (3 bad responses, exhausting :func:`classify`'s + retry budget) falls back to an empty default digest. +""" + +from __future__ import annotations + +import json + +import pytest + +from chat.llm.mock import MockLLMClient +from chat.services.synthesized_memories import ( + SynthesizedDigest, + SynthesizedMemory, + synthesize_memories, +) + + +@pytest.mark.asyncio +async def test_synthesize_parses_canned_prose(): + canned = json.dumps( + { + "memories": [ + { + "text": "Maya started a new pottery class.", + "significance": 1, + "affinity_delta": 0, + "trust_delta": 0, + } + ] + } + ) + mock = MockLLMClient(canned=[canned]) + result = await synthesize_memories( + mock, + classifier_model="x", + prose="we saw each other at her pottery class once", + bot_name="Maya", + bot_persona="warm potter, mid-30s", + you_name="Sam", + ) + assert isinstance(result, SynthesizedDigest) + assert len(result.memories) == 1 + mem = result.memories[0] + assert isinstance(mem, SynthesizedMemory) + assert mem.text == "Maya started a new pottery class." + assert mem.significance == 1 + assert mem.affinity_delta == 0 + assert mem.trust_delta == 0 + + +@pytest.mark.asyncio +async def test_empty_prose_returns_empty_digest(): + """Empty prose short-circuits — the classifier must not be called.""" + mock = MockLLMClient(canned=[]) + result = await synthesize_memories( + mock, + classifier_model="x", + prose="", + bot_name="Maya", + bot_persona="warm potter, mid-30s", + you_name="Sam", + ) + assert result == SynthesizedDigest() + assert result.memories == [] + + +@pytest.mark.asyncio +async def test_classifier_failure_returns_empty_default(): + """Three bad responses exhaust the classifier's retry budget; the + service then returns the empty default digest.""" + mock = MockLLMClient(canned=["bad", "bad", "bad"]) + result = await synthesize_memories( + mock, + classifier_model="x", + prose="we saw each other at her pottery class once", + bot_name="Maya", + bot_persona="warm potter, mid-30s", + you_name="Sam", + ) + assert result == SynthesizedDigest() + assert result.memories == [] -- 2.52.0 From 7857da41120f34cccdb83c13bdf84283b01a6fbd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:10:36 -0400 Subject: [PATCH 07/22] feat: thread-detection service (T55) --- chat/services/thread_detection.py | 89 +++++++++++++++++++++ tests/test_thread_detection.py | 128 ++++++++++++++++++++++++++++++ 2 files changed, 217 insertions(+) create mode 100644 chat/services/thread_detection.py create mode 100644 tests/test_thread_detection.py diff --git a/chat/services/thread_detection.py b/chat/services/thread_detection.py new file mode 100644 index 0000000..ca1c0d8 --- /dev/null +++ b/chat/services/thread_detection.py @@ -0,0 +1,89 @@ +"""Thread-detection service (T55). + +On scene close, classify the transcript into thread open/update/close +candidates. Returns ThreadCandidate list; caller (T58 scene compression) +emits one thread_opened/thread_updated/thread_closed event per candidate. +""" + +from __future__ import annotations + +from pydantic import BaseModel, Field + +from chat.llm.classify import classify +from chat.llm.client import LLMClient + + +class ThreadCandidate(BaseModel): + action: str # "open" | "update" | "close" + title: str = "" # required for "open"; ignored otherwise + summary: str = "" + existing_thread_id: str | None = None # required for "update" / "close" + + +class ThreadDetectionResult(BaseModel): + candidates: list[ThreadCandidate] = Field(default_factory=list) + + +_SYSTEM = ( + "You analyze a closed scene's transcript to identify narrative " + "threads (unresolved arcs, dangling questions, promises made, " + "open obligations). Choose actions:\n" + "- 'open': a NEW thread the scene introduced. Provide title (short " + "noun phrase) + summary (one sentence).\n" + "- 'update': an EXISTING open thread that the scene developed. " + "Provide existing_thread_id + new summary.\n" + "- 'close': an EXISTING open thread that the scene resolved. " + "Provide existing_thread_id; summary may capture the resolution.\n" + "Conservative bias: most scenes do NOT open new threads. Only " + "produce candidates when the transcript clearly justifies them. " + "Output strict JSON matching the schema." +) + + +async def detect_threads( + client: LLMClient, + *, + classifier_model: str, + scene_transcript: list[dict], # [{speaker, text}, ...] + open_threads: list[dict], # [{thread_id, title, summary}, ...] + timeout_s: float = 30.0, +) -> ThreadDetectionResult: + """Classify scene close into thread open/update/close candidates.""" + if not scene_transcript: + return ThreadDetectionResult() + + transcript_lines = [ + f"{turn.get('speaker', 'unknown')}: {turn.get('text', '')}" + for turn in scene_transcript + ] + threads_lines = [] + if open_threads: + threads_lines.append("Currently open threads:") + for t in open_threads: + threads_lines.append( + f"- thread_id={t['thread_id']} " + f"title={t.get('title', '')} " + f"summary={t.get('summary', '')}" + ) + else: + threads_lines.append("No currently open threads.") + + user = ( + "Scene transcript:\n" + + "\n".join(transcript_lines) + + "\n\n" + + "\n".join(threads_lines) + ) + + return await classify( + client, + model=classifier_model, + system=_SYSTEM, + user=user, + schema=ThreadDetectionResult, + default=ThreadDetectionResult(), + timeout_s=timeout_s, + ) + + +__all__ = ["ThreadCandidate", "ThreadDetectionResult", "detect_threads"] diff --git a/tests/test_thread_detection.py b/tests/test_thread_detection.py new file mode 100644 index 0000000..0249407 --- /dev/null +++ b/tests/test_thread_detection.py @@ -0,0 +1,128 @@ +"""Tests for the thread-detection service (T55). + +On scene close, the transcript is classified to detect open threads +(unresolved arcs, dangling questions, promises made). The service can +also signal **update** to an existing thread when the scene developed +it, or **close** when the scene resolved it. + +These tests cover: + +* A new thread the scene introduced — action="open" with a fresh title. +* An update to an existing thread — action="update" with + ``existing_thread_id`` referencing the prior thread. +* Classifier failure — three bad responses degrade to an empty + candidates list (graceful degradation, §3.3). +* Empty transcript short-circuits before any classifier call. +""" + +from __future__ import annotations + +import json + +import pytest + +from chat.llm.mock import MockLLMClient +from chat.services.thread_detection import ( + ThreadCandidate, + ThreadDetectionResult, + detect_threads, +) + + +@pytest.mark.asyncio +async def test_detects_new_thread_open(): + canned = json.dumps( + { + "candidates": [ + { + "action": "open", + "title": "Maya's job hunt", + "summary": "Maya is looking for a new job", + "existing_thread_id": None, + } + ] + } + ) + mock = MockLLMClient(canned=[canned]) + result = await detect_threads( + mock, + classifier_model="x", + scene_transcript=[ + {"speaker": "Maya", "text": "I need to find a new job soon."}, + {"speaker": "Sam", "text": "What kind of role are you looking for?"}, + ], + open_threads=[], + ) + assert isinstance(result, ThreadDetectionResult) + assert len(result.candidates) == 1 + cand = result.candidates[0] + assert isinstance(cand, ThreadCandidate) + assert cand.action == "open" + assert cand.title == "Maya's job hunt" + assert cand.summary == "Maya is looking for a new job" + assert cand.existing_thread_id is None + + +@pytest.mark.asyncio +async def test_detects_update_to_existing_thread(): + canned = json.dumps( + { + "candidates": [ + { + "action": "update", + "title": "", + "summary": "Maya interviewed at Acme today", + "existing_thread_id": "thr_jobhunt", + } + ] + } + ) + mock = MockLLMClient(canned=[canned]) + result = await detect_threads( + mock, + classifier_model="x", + scene_transcript=[ + {"speaker": "Maya", "text": "I had the Acme interview today."}, + {"speaker": "Sam", "text": "How did it go?"}, + ], + open_threads=[ + { + "thread_id": "thr_jobhunt", + "title": "Maya's job hunt", + "summary": "Maya is looking for a new job", + } + ], + ) + assert len(result.candidates) == 1 + cand = result.candidates[0] + assert cand.action == "update" + assert cand.existing_thread_id == "thr_jobhunt" + assert cand.summary == "Maya interviewed at Acme today" + + +@pytest.mark.asyncio +async def test_classifier_failure_returns_empty(): + """Three malformed classifier responses → empty candidates list.""" + mock = MockLLMClient(canned=["not json", "still not json", "{bad"]) + result = await detect_threads( + mock, + classifier_model="x", + scene_transcript=[ + {"speaker": "Maya", "text": "Anything could happen here."}, + ], + open_threads=[], + ) + assert result.candidates == [] + + +@pytest.mark.asyncio +async def test_empty_transcript_short_circuits(): + """Empty transcript short-circuits — classifier must not be called.""" + mock = MockLLMClient(canned=[]) + result = await detect_threads( + mock, + classifier_model="x", + scene_transcript=[], + open_threads=[], + ) + assert result.candidates == [] -- 2.52.0 From c2144cd9df8eae6bf4a89c7e6b0b66d1de08e76d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:10:42 -0400 Subject: [PATCH 08/22] feat: skip narration service (T53) --- chat/services/skip_narration.py | 131 ++++++++++++++++++++++++++++++++ tests/test_skip_narration.py | 117 ++++++++++++++++++++++++++++ 2 files changed, 248 insertions(+) create mode 100644 chat/services/skip_narration.py create mode 100644 tests/test_skip_narration.py diff --git a/chat/services/skip_narration.py b/chat/services/skip_narration.py new file mode 100644 index 0000000..4590a7b --- /dev/null +++ b/chat/services/skip_narration.py @@ -0,0 +1,131 @@ +"""Skip narration service (T53). + +Generates brief transition prose for elision and jump skips. + +Skips come in two flavors that read very differently: + +* **Elision** — collapses an in-progress activity into its expected + end-state in 1-2 sentences, narrated from the speaker bot's POV. + Example: "skip ahead to when we arrive" while the characters are + driving — output describes pulling into the lot. +* **Jump** — bridges a longer fiction-time delta ("next morning", "a + week later") in 2-3 sentences, setting the scene at the new time. + +Output is free-form prose, not structured JSON, so this service calls +``client.generate`` directly rather than going through the classifier +path used by, e.g., :mod:`chat.services.scene_summarize`. A +deterministic template fallback fires on any LLM failure so the skip +flow keeps moving even when the model is down — important because +skips are a UI-blocking operation; we'd rather show a parenthetical +sentence than hang the chat indefinitely. +""" + +from __future__ import annotations + +from chat.llm.client import LLMClient, Message + + +_ELISION_SYSTEM = ( + "You write a brief 1-2 sentence transition that elides the time " + "between an in-progress activity and its expected end-state, " + "narrated from the speaker's POV. Keep it grounded and concrete. " + "Do not invent new events or characters." +) + +_JUMP_SYSTEM = ( + "You write a brief 2-3 sentence transition narrating a jump in " + "fiction time (e.g., 'next morning', 'a week later'), narrated " + "from the speaker's POV. Set the scene at the new time. Keep it " + "grounded — no invented major events. If a landing-state hint is " + "provided, weave it in naturally." +) + + +async def narrate_skip( + client: LLMClient, + *, + narrative_model: str, + skip_kind: str, + speaker_bot: dict, + you_name: str, + current_time: str, + new_time: str, + current_activity: str, + landing_state_hint: str = "", + timeout_s: float = 60.0, +) -> str: + """Generate brief transition prose for a time skip. + + ``skip_kind`` is ``"elision"`` or ``"jump"``; any other value short- + circuits to the deterministic fallback (defensive — callers + shouldn't be inventing new kinds without updating this service). + + Returns plain text. Never raises: any LLM error, an empty/blank + result, or an unknown ``skip_kind`` falls back to a parenthetical + template like ``"(next morning: having coffee in the kitchen.)"`` + so the skip UI always has *something* to render. + """ + fallback = _build_fallback( + skip_kind=skip_kind, + new_time=new_time, + current_activity=current_activity, + landing_state_hint=landing_state_hint, + ) + + if skip_kind not in ("elision", "jump"): + return fallback + + system = _ELISION_SYSTEM if skip_kind == "elision" else _JUMP_SYSTEM + user = ( + f"Speaker: {speaker_bot.get('name', 'speaker')}\n" + f"Persona: {speaker_bot.get('persona', '')}\n" + f"Other party: {you_name}\n" + f"Current time: {current_time}\n" + f"New time: {new_time}\n" + f"Current activity: {current_activity}\n" + ) + if landing_state_hint: + user += f"Landing state hint: {landing_state_hint}\n" + + try: + result = await client.generate( + [ + Message(role="system", content=system), + Message(role="user", content=user), + ], + model=narrative_model, + max_tokens=200, + temperature=0.7, + ) + text = (result or "").strip() + if not text: + return fallback + return text + except Exception: + # Any failure — network blip, timeout, mock raising in tests — + # collapses to the deterministic template so the skip pipeline + # is never blocked on the LLM being available. + return fallback + + +def _build_fallback( + *, + skip_kind: str, + new_time: str, + current_activity: str, + landing_state_hint: str, +) -> str: + """Deterministic parenthetical narration used when the LLM fails. + + Both flavors render the same shape today: ``(: + .)``. They're separated as branches to make it easy to + diverge later (e.g. an elision-specific template) without churning + the call site or the public signature. + """ + detail = landing_state_hint or current_activity or "moments later" + if skip_kind == "elision": + return f"({new_time}: {detail}.)" + return f"({new_time}: {detail}.)" + + +__all__ = ["narrate_skip"] diff --git a/tests/test_skip_narration.py b/tests/test_skip_narration.py new file mode 100644 index 0000000..577ddee --- /dev/null +++ b/tests/test_skip_narration.py @@ -0,0 +1,117 @@ +"""Skip narration service tests (T53). + +The skip-narration service generates short transition prose between an +in-progress moment and a post-skip moment. Two flavors: + +* ``elision`` — collapses an in-progress activity to its expected + end-state in 1-2 sentences (e.g. "skip ahead to when we arrive"). +* ``jump`` — bridges a longer fiction-time delta in 2-3 sentences + (e.g. "next morning", "a week later"). + +Output is free-form prose, not structured JSON, so the service goes +through ``client.generate`` directly rather than the classifier path. +A deterministic template fallback fires on any LLM failure so the skip +flow never blocks even when the model is down. +""" + +from __future__ import annotations + +from typing import AsyncIterator, Sequence + +import pytest + +from chat.llm.client import Message +from chat.llm.mock import MockLLMClient +from chat.services.skip_narration import narrate_skip + + +_SPEAKER = { + "id": "bot1", + "name": "Aria", + "persona": "thoughtful, observant", +} + + +@pytest.mark.asyncio +async def test_narrate_elision_returns_classifier_output(): + canned = ( + "She closes her laptop and slings her bag over her shoulder. " + "The office shrinks behind her as she steps into the late " + "afternoon light." + ) + mock = MockLLMClient(canned=[canned]) + result = await narrate_skip( + mock, + narrative_model="x", + skip_kind="elision", + speaker_bot=_SPEAKER, + you_name="Me", + current_time="3:42 PM", + new_time="5:10 PM", + current_activity="finishing up at her desk", + landing_state_hint="walking out into the parking lot", + ) + assert "office" in result or result == canned + + +@pytest.mark.asyncio +async def test_narrate_jump_returns_classifier_output(): + canned = ( + "Morning light spills through the kitchen window. The coffee " + "maker hums. She's already at the table, scrolling her phone." + ) + mock = MockLLMClient(canned=[canned]) + result = await narrate_skip( + mock, + narrative_model="x", + skip_kind="jump", + speaker_bot=_SPEAKER, + you_name="Me", + current_time="late evening", + new_time="next morning", + current_activity="winding down for the night", + landing_state_hint="having coffee in the kitchen", + ) + assert result + lower = result.lower() + assert "morning" in lower or "coffee" in lower + + +class _RaisingMock: + """Mock LLMClient whose ``generate`` always raises. + + ``MockLLMClient.generate`` raises ``IndexError`` once the canned + list is empty, but the test wants a clear, unambiguous failure + regardless of canned-list state, so we ship a tiny dedicated mock + instead. + """ + + async def generate( + self, messages: Sequence[Message], *, model: str, **params + ) -> str: + raise RuntimeError("LLM is down") + + async def stream( + self, messages: Sequence[Message], *, model: str, **params + ) -> AsyncIterator[str]: + raise RuntimeError("LLM is down") + yield # pragma: no cover - make this a generator + + +@pytest.mark.asyncio +async def test_narrate_falls_back_on_generation_failure(): + new_time = "next morning" + result = await narrate_skip( + _RaisingMock(), + narrative_model="x", + skip_kind="jump", + speaker_bot=_SPEAKER, + you_name="Me", + current_time="late evening", + new_time=new_time, + current_activity="winding down for the night", + landing_state_hint="having coffee in the kitchen", + ) + # Fallback template includes the new_time so callers can see *what* + # we skipped to even when the LLM never answered. + assert new_time in result -- 2.52.0 From 5e6b29e0c5229bf44814409aab2ac1a20a1dc90e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:15:19 -0400 Subject: [PATCH 09/22] feat: significance-aware retrieval ranking (T57) --- chat/state/memory.py | 17 +++++++++++++++-- tests/test_memory_search.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/chat/state/memory.py b/chat/state/memory.py index 0426067..0eda418 100644 --- a/chat/state/memory.py +++ b/chat/state/memory.py @@ -94,6 +94,14 @@ def get_pinned(conn: Connection, owner_id: str) -> list[dict]: _SIGNIFICANCE_WEIGHT = 0.3 _RECENCY_WEIGHT = 0.5 +# T57 (Phase 3, §11.1): significance multiplier applied to the SQL ORDER BY in +# ``search_memories`` so that the FTS over-fetch already prefers +# higher-significance rows for tied / near-tied BM25 ranks. Module-level so it +# can be tuned without a code change. BM25 ``rank`` is lower-is-better, so the +# bias is *subtracted* from rank in the ASC ordering — equivalent to multiplying +# a higher-is-better score by a positive constant per the spec wording. +SIGNIFICANCE_RANK_BIAS = 0.5 + def search_memories( conn: Connection, @@ -137,10 +145,15 @@ def search_memories( "JOIN memories m ON m.id = memories_fts.rowid " f"WHERE m.owner_id = ? AND m.{witness_col} = 1 " "AND memories_fts MATCH ? " - "ORDER BY memories_fts.rank " + # T57: significance multiplier biases the FTS over-fetch order. BM25 + # ``rank`` is lower-is-better, so subtracting ``significance * BIAS`` + # surfaces higher-significance rows above lower-significance rows with + # equal/near-equal match strength. Equivalent to ``score × constant`` + # per §11.1 once the rank is inverted to a higher-is-better score. + "ORDER BY (memories_fts.rank - m.significance * ?) ASC " "LIMIT ?" ) - cur = conn.execute(sql, (owner_id, query, over_fetch)) + cur = conn.execute(sql, (owner_id, query, SIGNIFICANCE_RANK_BIAS, over_fetch)) rows = cur.fetchall() if not rows: return [] diff --git a/tests/test_memory_search.py b/tests/test_memory_search.py index dad7e84..76f0ee1 100644 --- a/tests/test_memory_search.py +++ b/tests/test_memory_search.py @@ -125,3 +125,37 @@ def test_search_invalid_witness_role_raises(tmp_path): with open_db(db) as conn: with pytest.raises(ValueError): search_memories(conn, "bot_a", "invalid_role", "anything", k=4) + + +def test_higher_significance_outranks_equal_rank(tmp_path): + """T57: significance multiplier biases the SQL ORDER BY. + + Two memories with IDENTICAL FTS-matching text yield (effectively) equal + BM25 ranks. The significance bias applied in the SQL ORDER BY must + surface the higher-significance row first. + """ + db = tmp_path / "t.db" + _seed( + db, + memory_specs=[ + # Identical pov_summary text -> FTS BM25 rank is the same for both. + {"pov_summary": "she swore an oath", "significance": 0}, + {"pov_summary": "she swore an oath", "significance": 3}, + ], + ) + with open_db(db) as conn: + out = search_memories(conn, "bot_a", "host", "oath", k=5) + assert len(out) == 2 + # Higher significance wins despite tied FTS rank. + assert out[0]["significance"] == 3 + assert out[1]["significance"] == 0 + + +def test_significance_bias_is_constant_module_level(): + """T57: pin ``SIGNIFICANCE_RANK_BIAS`` as a tunable module-level numeric.""" + from chat.state.memory import SIGNIFICANCE_RANK_BIAS + + assert isinstance(SIGNIFICANCE_RANK_BIAS, (int, float)) + # Must be non-negative -- a negative bias would invert the desired + # "higher significance ranks higher" semantics. + assert SIGNIFICANCE_RANK_BIAS >= 0 -- 2.52.0 From 021587b3df25f5f835c2f213d326a11193bcf510 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:15:51 -0400 Subject: [PATCH 10/22] feat: event-completion promotion service (T56) --- chat/services/event_promotion.py | 149 ++++++++++++++++++ tests/test_event_promotion.py | 256 +++++++++++++++++++++++++++++++ 2 files changed, 405 insertions(+) create mode 100644 chat/services/event_promotion.py create mode 100644 tests/test_event_promotion.py diff --git a/chat/services/event_promotion.py b/chat/services/event_promotion.py new file mode 100644 index 0000000..b1c8c3a --- /dev/null +++ b/chat/services/event_promotion.py @@ -0,0 +1,149 @@ +"""Event-completion promotion (T56). + +When an event reaches ``status='completed'``, read its ``props_json`` +and emit promotion events into the appropriate state stores. +Synchronous, no LLM. Skips when the event status is not ``completed`` +(cancelled / expired terminate the event without promoting). + +Props recognized: + +- ``acquired_objects: list[str]`` — emits a ``manual_edit`` with + ``target_kind="memory_pov_summary"`` per object on the host's memory + row, recording the acquisition. Phase 3 is a stub: it requires both + ``host_bot_id`` and ``host_memory_id`` (an existing memories.id) to + be present in props; missing either skips that object cleanly. + Phase 4 will introduce a real inventory schema. + +- ``knowledge_facts: list[{owner_id, target_id, fact}]`` — emits an + ``edge_update`` event on the directed ``owner_id -> target_id`` edge + with the fact appended to ``knowledge_facts``. The ``edge_update`` + projector accepts ``knowledge_facts`` as a list and extends the + edge's stored ``knowledge_json``. + +- ``relationship_change: {summary, source_id, target_id}`` — emits a + ``manual_edit`` with ``target_kind="edge_summary"`` overwriting the + edge's ``summary`` field on the directed pair. + +Anything else stays in the closed event record (the projector kept +the row; no further promotion). +""" + +from __future__ import annotations + +from sqlite3 import Connection + +from chat.eventlog.log import append_and_apply +from chat.state.events import get_event + + +def promote_completed_event( + conn: Connection, + *, + event_id: str, + chat_id: str, + chat_clock_at: str | None, +) -> dict: + """Read the completed event's props and emit promotion events. + + Returns a dict of counts keyed by promoted artifact: + ``{"acquired_objects", "knowledge_facts", "relationship_change"}``. + Skips silently if the event row is missing or its status is not + ``completed`` — cancelled / expired events terminate without any + promotion. + """ + counts = { + "acquired_objects": 0, + "knowledge_facts": 0, + "relationship_change": 0, + } + + event = get_event(conn, event_id) + if event is None or event["status"] != "completed": + return counts + + props = event.get("props") or {} + + # acquired_objects: each becomes a memory_pov_summary edit (Phase 3 + # stub). The manual_edit projector requires a valid memory rowid as + # ``target_id`` (it does ``int(target_id)``), so skip cleanly when + # neither a host_bot_id nor a host_memory_id is supplied. + host_bot_id = props.get("host_bot_id") + host_memory_id = props.get("host_memory_id") + for obj in props.get("acquired_objects", []) or []: + if host_bot_id is None or host_memory_id is None: + continue + append_and_apply( + conn, + kind="manual_edit", + payload={ + "target_kind": "memory_pov_summary", + "target_id": host_memory_id, + "owner_id": host_bot_id, + "chat_id": chat_id, + "prior_value": "", + "new_value": f"Acquired: {obj}", + "source": "event_promotion", + "event_id": event_id, + "chat_clock_at": chat_clock_at, + }, + ) + counts["acquired_objects"] += 1 + + # knowledge_facts: each becomes an edge_update appending the fact. + for fact_entry in props.get("knowledge_facts", []) or []: + owner_id = fact_entry.get("owner_id") + target_id = fact_entry.get("target_id") + fact = fact_entry.get("fact", "") + if not owner_id or not target_id or not fact: + continue + append_and_apply( + conn, + kind="edge_update", + payload={ + "source_id": owner_id, + "target_id": target_id, + "chat_id": chat_id, + "affinity_delta": 0, + "trust_delta": 0, + "knowledge_facts": [fact], + "last_interaction_at": chat_clock_at, + "last_interaction_chat_id": chat_id, + "source": "event_promotion", + "event_id": event_id, + }, + ) + counts["knowledge_facts"] += 1 + + # relationship_change: edge_summary manual_edit on the directed pair. + # The manual_edit projector for ``edge_summary`` keys on a + # ``target_id`` dict ``{source_id, target_id}`` (see + # chat/state/manual_edit.py); we shape the payload to match. + rc = props.get("relationship_change") or {} + if rc: + source_id = rc.get("source_id") + rc_target_id = rc.get("target_id") + summary = rc.get("summary", "") + if source_id and rc_target_id and summary: + append_and_apply( + conn, + kind="manual_edit", + payload={ + "target_kind": "edge_summary", + "target_id": { + "source_id": source_id, + "target_id": rc_target_id, + }, + "chat_id": chat_id, + "prior_value": "", + "new_value": summary, + "source": "event_promotion", + "event_id": event_id, + "chat_clock_at": chat_clock_at, + }, + ) + counts["relationship_change"] += 1 + + return counts + + +__all__ = ["promote_completed_event"] diff --git a/tests/test_event_promotion.py b/tests/test_event_promotion.py new file mode 100644 index 0000000..c812156 --- /dev/null +++ b/tests/test_event_promotion.py @@ -0,0 +1,256 @@ +"""Tests for the event-completion promotion service (T56). + +When an event reaches ``status='completed'``, the orchestrator promotes +structured artifacts the event carried (``acquired_objects``, +``knowledge_facts``, ``relationship_change``) into the appropriate +state stores via downstream events. Cancelled / expired events do NOT +promote — the closed event row is left in place but no follow-on +events fire. +""" + +from __future__ import annotations + +import json + +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +from chat.services.event_promotion import promote_completed_event +from chat.state.edges import get_edge +import chat.state.edges # noqa: F401 - register edge_update handler +import chat.state.entities # noqa: F401 - register handlers +import chat.state.events # noqa: F401 - register events handlers +import chat.state.manual_edit # noqa: F401 - register manual_edit handler +import chat.state.world # noqa: F401 - register handlers + + +def _bot_payload(bot_id: str, name: str) -> dict: + return { + "id": bot_id, + "name": name, + "persona": "thoughtful, observant", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "", + } + + +def _chat_payload(chat_id: str = "chat_bot_a") -> dict: + return { + "id": chat_id, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1 evening", + "weather": "clear", + } + + +def _seed_chat(conn) -> None: + 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="chat_created", payload=_chat_payload()) + + +def _seed_event( + conn, + *, + event_id: str, + props: dict, + terminal_kind: str = "event_completed", +) -> None: + """Append event_planned, then a terminal transition (default completed).""" + append_event( + conn, + kind="event_planned", + payload={ + "event_id": event_id, + "chat_id": "chat_bot_a", + "kind": "story_event", + "props": props, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + append_event( + conn, + kind=terminal_kind, + payload={ + "event_id": event_id, + "completed_at": "2026-04-30T20:00:00+00:00", + }, + ) + project(conn) + + +def _max_event_id(conn) -> int: + return conn.execute("SELECT COALESCE(MAX(id), 0) FROM event_log").fetchone()[0] + + +def _events_after(conn, after_id: int, kind: str) -> list[dict]: + rows = conn.execute( + "SELECT id, kind, payload_json FROM event_log " + "WHERE id > ? AND kind = ? ORDER BY id ASC", + (after_id, kind), + ).fetchall() + return [ + {"id": r[0], "kind": r[1], "payload": json.loads(r[2])} for r in rows + ] + + +def test_empty_props_no_op(tmp_path): + """Completed event with empty props produces no promotion events.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_chat(conn) + _seed_event(conn, event_id="evt_empty", props={}) + + before = _max_event_id(conn) + counts = promote_completed_event( + conn, + event_id="evt_empty", + chat_id="chat_bot_a", + chat_clock_at="2026-04-30T20:00:00+00:00", + ) + + assert counts == { + "acquired_objects": 0, + "knowledge_facts": 0, + "relationship_change": 0, + } + # No new edge_update or manual_edit rows after the promote call. + assert _events_after(conn, before, "edge_update") == [] + assert _events_after(conn, before, "manual_edit") == [] + + +def test_knowledge_facts_emits_edge_update(tmp_path): + """A knowledge_facts entry promotes to an edge_update on the directed edge.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_chat(conn) + _seed_event( + conn, + event_id="evt_kf", + props={ + "knowledge_facts": [ + { + "owner_id": "bot_a", + "target_id": "you", + "fact": "Maya prefers tea over coffee", + } + ] + }, + ) + + before = _max_event_id(conn) + counts = promote_completed_event( + conn, + event_id="evt_kf", + chat_id="chat_bot_a", + chat_clock_at="2026-04-30T20:00:00+00:00", + ) + + assert counts["knowledge_facts"] == 1 + assert counts["acquired_objects"] == 0 + assert counts["relationship_change"] == 0 + + # An edge_update event landed in the event_log AFTER the promote call. + new_edge_updates = _events_after(conn, before, "edge_update") + assert len(new_edge_updates) == 1 + payload = new_edge_updates[0]["payload"] + assert payload["source_id"] == "bot_a" + assert payload["target_id"] == "you" + assert payload["knowledge_facts"] == ["Maya prefers tea over coffee"] + + # And the projected edge has the fact applied. + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert "Maya prefers tea over coffee" in edge["knowledge"] + + +def test_relationship_change_emits_manual_edit(tmp_path): + """A relationship_change promotes to a manual_edit edge_summary.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_chat(conn) + _seed_event( + conn, + event_id="evt_rc", + props={ + "relationship_change": { + "source_id": "bot_a", + "target_id": "you", + "summary": "they're now dating", + } + }, + ) + + before = _max_event_id(conn) + counts = promote_completed_event( + conn, + event_id="evt_rc", + chat_id="chat_bot_a", + chat_clock_at="2026-04-30T20:00:00+00:00", + ) + + assert counts["relationship_change"] == 1 + assert counts["knowledge_facts"] == 0 + assert counts["acquired_objects"] == 0 + + new_manual_edits = _events_after(conn, before, "manual_edit") + # Filter to edge_summary only — Phase 3 stub may also emit + # memory_pov_summary entries for acquired_objects, but here there + # are none. + edge_summary_edits = [ + m for m in new_manual_edits + if m["payload"].get("target_kind") == "edge_summary" + ] + assert len(edge_summary_edits) == 1 + payload = edge_summary_edits[0]["payload"] + assert payload["target_kind"] == "edge_summary" + assert payload["target_id"] == {"source_id": "bot_a", "target_id": "you"} + assert payload["new_value"] == "they're now dating" + + +def test_cancelled_event_does_not_promote(tmp_path): + """Cancelled events have promotable props ignored — no follow-on events.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_chat(conn) + _seed_event( + conn, + event_id="evt_canx", + props={ + "knowledge_facts": [ + {"owner_id": "bot_a", "target_id": "you", "fact": "x"} + ], + "relationship_change": { + "source_id": "bot_a", + "target_id": "you", + "summary": "ignored", + }, + }, + terminal_kind="event_cancelled", + ) + + before = _max_event_id(conn) + counts = promote_completed_event( + conn, + event_id="evt_canx", + chat_id="chat_bot_a", + chat_clock_at="2026-04-30T20:00:00+00:00", + ) + + assert counts == { + "acquired_objects": 0, + "knowledge_facts": 0, + "relationship_change": 0, + } + assert _events_after(conn, before, "edge_update") == [] + assert _events_after(conn, before, "manual_edit") == [] -- 2.52.0 From 343f3055871ba0ba8482b6a8b603e56a05957202 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:18:34 -0400 Subject: [PATCH 11/22] feat: significance-driven quote retention + thread emission on close (T58) --- chat/services/scene_summarize.py | 103 +++++++++++- tests/test_per_pov_summary.py | 260 ++++++++++++++++++++++++++++++- 2 files changed, 357 insertions(+), 6 deletions(-) diff --git a/chat/services/scene_summarize.py b/chat/services/scene_summarize.py index 2e74ddf..fa5958f 100644 --- a/chat/services/scene_summarize.py +++ b/chat/services/scene_summarize.py @@ -29,6 +29,8 @@ keeps moving. from __future__ import annotations import json +import uuid +from datetime import datetime, timezone from sqlite3 import Connection from pydantic import BaseModel, Field @@ -167,6 +169,7 @@ async def _summarize_and_apply_for_witness( you_name: str, dialogue: list[dict], timeout_s: float, + key_quotes_suffix: str = "", ) -> ScenePOVSummary: """Run :func:`summarize_scene` for one bot witness and apply the three projected updates (memory pov_summary rewrite, edge summary @@ -175,6 +178,10 @@ async def _summarize_and_apply_for_witness( Tolerant of missing pieces in the same way Phase 1 was: no memory row -> skip the rewrite; no edge row -> skip the edge_summary write (the empty-default classifier output simply yields no rewrites). + + ``key_quotes_suffix`` is appended verbatim to the per-POV summary + text before the rewrite lands (T58.1) — empty string is the no-op + default for low-significance scenes. """ from chat.state.edges import get_edge from chat.state.entities import get_bot @@ -206,6 +213,7 @@ async def _summarize_and_apply_for_witness( # Empty default -> skip the memory rewrite; the seeded # per-turn pov_summary stays in place. continue + new_value = pov.summary + key_quotes_suffix append_and_apply( conn, kind="manual_edit", @@ -213,7 +221,7 @@ async def _summarize_and_apply_for_witness( "target_kind": "memory_pov_summary", "target_id": int(memory_id), "prior_value": prior_pov, - "new_value": pov.summary, + "new_value": new_value, }, ) @@ -255,6 +263,40 @@ async def _summarize_and_apply_for_witness( return pov +def _build_key_quotes_suffix(conn: Connection, scene_id: int) -> str: + """If the scene's max-turn-significance is >= 2, build the + "Key quotes:" suffix from the top-3 highest-significance memory rows + (per requirements §11.1). Otherwise return the empty string so the + per-POV summaries collapse fully (low-significance scenes lose all + raw text in favor of the classifier rewrite). + + Quote source is each memory's current ``pov_summary`` — the raw + per-turn narrative seeded by T21, since this helper is called BEFORE + the per-POV rewrite. Texts are truncated to 200 chars to bound + memory row growth across many witnesses. + """ + row = conn.execute( + "SELECT MAX(significance) FROM memories WHERE scene_id = ?", + (scene_id,), + ).fetchone() + max_sig = (row[0] if row else None) or 0 + if max_sig < 2: + return "" + cur = conn.execute( + "SELECT pov_summary FROM memories WHERE scene_id = ? " + "ORDER BY significance DESC, id ASC LIMIT 3", + (scene_id,), + ) + quotes = [ + (r[0] or "")[:200] + for r in cur.fetchall() + ] + if not quotes: + return "" + lines = "\n".join(f'- "{q}"' for q in quotes) + return f"\n\nKey quotes:\n{lines}" + + async def apply_scene_close_summary( conn: Connection, client: LLMClient, @@ -296,8 +338,10 @@ async def apply_scene_close_summary( """ # Local imports to keep the module-level surface tight and avoid # any chance of a circular dep through chat.state.*. + from chat.services.thread_detection import detect_threads from chat.state.entities import get_bot, get_you from chat.state.group_node import get_group_node + from chat.state.threads import list_open_threads from chat.state.world import get_chat you_entity = get_you(conn) or {"name": "you", "persona": ""} @@ -308,6 +352,11 @@ async def apply_scene_close_summary( dialogue = _read_recent_dialogue(conn, chat_id) + # T58.1: build the "Key quotes:" suffix BEFORE the per-POV rewrites + # land — quote source is the raw seeded pov_summary text on each + # memory row, which the rewrite about to fire would clobber. + key_quotes_suffix = _build_key_quotes_suffix(conn, scene_id) + host_pov = await _summarize_and_apply_for_witness( conn, client, @@ -318,6 +367,7 @@ async def apply_scene_close_summary( you_name=you_name, dialogue=dialogue, timeout_s=timeout_s, + key_quotes_suffix=key_quotes_suffix, ) guest_pov: ScenePOVSummary | None = None @@ -332,6 +382,7 @@ async def apply_scene_close_summary( you_name=you_name, dialogue=dialogue, timeout_s=timeout_s, + key_quotes_suffix=key_quotes_suffix, ) # Group node update: T70 runs a third classifier call to merge the @@ -364,6 +415,56 @@ async def apply_scene_close_summary( }, ) + # T58.2: thread detection on close. Reuses the dialogue we already + # gathered for per-POV summarization — same {speaker, text} shape + # detect_threads expects. Failure-tolerant: classify() returns the + # empty default on retry-exhaustion, and the broad except below + # protects the close pipeline from any other classifier/mock flap. + try: + thread_result = await detect_threads( + client, + classifier_model=classifier_model, + scene_transcript=dialogue, + open_threads=list_open_threads(conn, chat_id), + timeout_s=timeout_s, + ) + except Exception: + from chat.services.thread_detection import ThreadDetectionResult + + thread_result = ThreadDetectionResult() + for cand in thread_result.candidates: + if cand.action == "open": + new_thread_id = f"thr_{uuid.uuid4().hex[:12]}" + append_and_apply( + conn, + kind="thread_opened", + payload={ + "thread_id": new_thread_id, + "chat_id": chat_id, + "title": cand.title, + "summary": cand.summary, + }, + ) + elif cand.action == "update" and cand.existing_thread_id: + append_and_apply( + conn, + kind="thread_updated", + payload={ + "thread_id": cand.existing_thread_id, + "summary": cand.summary, + "last_referenced_scene_id": scene_id, + }, + ) + elif cand.action == "close" and cand.existing_thread_id: + append_and_apply( + conn, + kind="thread_closed", + payload={ + "thread_id": cand.existing_thread_id, + "closed_at": datetime.now(timezone.utc).isoformat(), + }, + ) + return host_pov diff --git a/tests/test_per_pov_summary.py b/tests/test_per_pov_summary.py index c401ea8..2453b92 100644 --- a/tests/test_per_pov_summary.py +++ b/tests/test_per_pov_summary.py @@ -504,13 +504,15 @@ async def test_close_with_no_guest_matches_phase1(tmp_path): "relationship_summary": "BotA leaned in supportively.", } ) + no_threads = json.dumps({"candidates": []}) with open_db(db) as conn: _seed_single_bot_scene(conn) project(conn) - # canned has 2 entries to detect any over-call; the assertion below - # confirms only one was consumed. - client = MockLLMClient(canned=[canned, canned]) + # 1 host-POV entry + 1 thread-detection entry (T58.2) + 1 spare + # to detect any over-call. Assertion below confirms exactly two + # were consumed. + client = MockLLMClient(canned=[canned, no_threads, canned]) await apply_scene_close_summary( conn, client, @@ -520,8 +522,8 @@ async def test_close_with_no_guest_matches_phase1(tmp_path): host_bot_id="bot_a", ) - # Exactly one classifier call -> exactly one canned entry consumed, - # leaving the second untouched. + # Host POV + thread detection -> exactly two canned entries + # consumed, leaving the spare untouched. assert len(client._canned) == 1 # Host memory rewritten with the per-POV summary content. @@ -845,3 +847,251 @@ async def test_group_summary_skipped_when_no_guest(tmp_path): "SELECT 1 FROM event_log WHERE kind = 'group_node_updated'" ).fetchall() assert rows == [] + + +# --------------------------------------------------------------------------- +# T58: significance-driven quote retention + thread detection on close. +# --------------------------------------------------------------------------- + + +def _seed_single_bot_scene_no_memory(conn) -> None: + """Like ``_seed_single_bot_scene`` but skips the memory_written event so + callers can seed memories with custom significance / text themselves.""" + append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA")) + append_event( + conn, + kind="you_authored", + payload={"name": "Me", "pronouns": "they/them", "persona": "engineer"}, + ) + 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": "", + }, + ) + append_event( + conn, + kind="container_created", + payload={ + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": {}, + }, + ) + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a"], + }, + ) + append_event( + conn, + kind="edge_update", + payload={ + "source_id": "bot_a", + "target_id": "you", + "chat_id": "chat_bot_a", + }, + ) + append_event( + conn, + kind="user_turn", + payload={ + "chat_id": "chat_bot_a", + "prose": "Quick chat about the deadline", + "segments": [], + }, + ) + append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_bot_a", + "speaker_id": "bot_a", + "text": "It's going to be okay.", + "truncated": False, + "user_turn_id": 1, + }, + ) + + +def _seed_memory(conn, *, pov_summary: str, significance: int) -> None: + append_event( + conn, + kind="memory_written", + payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "scene_id": 1, + "pov_summary": pov_summary, + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "significance": significance, + }, + ) + + +@pytest.mark.asyncio +async def test_low_significance_scene_omits_quotes(tmp_path): + """When the scene's max-turn-significance is < 2, the per-POV summary + rewrite collapses fully — no "Key quotes:" suffix is appended.""" + db = tmp_path / "t.db" + apply_migrations(db) + canned = json.dumps( + { + "summary": "BotA had a low-key chat with you.", + "knowledge_facts": [], + "relationship_summary": "Nothing major shifted.", + } + ) + no_threads = json.dumps({"candidates": []}) + with open_db(db) as conn: + _seed_single_bot_scene_no_memory(conn) + _seed_memory(conn, pov_summary="Maya rambled about coffee", significance=1) + _seed_memory(conn, pov_summary="Maya glanced at the clock", significance=0) + project(conn) + + client = MockLLMClient(canned=[canned, no_threads]) + await apply_scene_close_summary( + conn, + client, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=1, + host_bot_id="bot_a", + ) + + rows = conn.execute( + "SELECT pov_summary FROM memories WHERE scene_id = 1" + ).fetchall() + assert rows + for (pov,) in rows: + assert "Key quotes:" not in pov + assert "BotA had a low-key chat" in pov + + +@pytest.mark.asyncio +async def test_high_significance_scene_includes_top_3_quotes(tmp_path): + """When max-turn-significance is >= 2, each per-POV summary text gains + a "Key quotes:" suffix listing the top-3 highest-significance memory + rows verbatim, ordered by (significance DESC, id ASC).""" + db = tmp_path / "t.db" + apply_migrations(db) + canned = json.dumps( + { + "summary": "BotA had a heavy talk with you.", + "knowledge_facts": [], + "relationship_summary": "Things shifted.", + } + ) + no_threads = json.dumps({"candidates": []}) + with open_db(db) as conn: + _seed_single_bot_scene_no_memory(conn) + # Insertion order matches id ASC. Top-3 by (sig DESC, id ASC): + # quote 1 (sig 3) -> quote 2 (sig 2, lower id) -> quote 4 (sig 2, + # higher id). quote 3 (sig 1) is dropped. + _seed_memory(conn, pov_summary="Maya quote one", significance=3) + _seed_memory(conn, pov_summary="Maya quote two", significance=2) + _seed_memory(conn, pov_summary="Maya quote three", significance=1) + _seed_memory(conn, pov_summary="Maya quote four", significance=2) + project(conn) + + client = MockLLMClient(canned=[canned, no_threads]) + await apply_scene_close_summary( + conn, + client, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=1, + host_bot_id="bot_a", + ) + + rows = conn.execute( + "SELECT pov_summary FROM memories WHERE scene_id = 1" + ).fetchall() + assert rows + for (pov,) in rows: + assert "Key quotes:" in pov + assert '"Maya quote one"' in pov + assert '"Maya quote two"' in pov + assert '"Maya quote four"' in pov + # The sig-1 quote falls outside the top-3 cap. + assert '"Maya quote three"' not in pov + # Ordering: sig 3 first, then the two sig-2s by id ASC. + i_one = pov.index('"Maya quote one"') + i_two = pov.index('"Maya quote two"') + i_four = pov.index('"Maya quote four"') + assert i_one < i_two < i_four + + +@pytest.mark.asyncio +async def test_thread_detection_emits_events(tmp_path, monkeypatch): + """On scene close, ``detect_threads`` is invoked and each "open" + candidate yields a ``thread_opened`` event with a fresh thread_id.""" + from chat.services import thread_detection as td_mod + + canned = json.dumps( + { + "summary": "BotA noticed something unresolved.", + "knowledge_facts": [], + "relationship_summary": "Tension lingered.", + } + ) + + async def fake_detect_threads(client, **kwargs): + return td_mod.ThreadDetectionResult( + candidates=[ + td_mod.ThreadCandidate( + action="open", + title="Test thread", + summary="A test", + existing_thread_id=None, + ), + ] + ) + + monkeypatch.setattr(td_mod, "detect_threads", fake_detect_threads) + + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_single_bot_scene(conn) + project(conn) + + client = MockLLMClient(canned=[canned]) + await apply_scene_close_summary( + conn, + client, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=1, + host_bot_id="bot_a", + ) + + rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'thread_opened'" + ).fetchall() + assert len(rows) == 1 + payload = json.loads(rows[0][0]) + assert payload["title"] == "Test thread" + assert payload["summary"] == "A test" + assert payload["chat_id"] == "chat_bot_a" + assert payload["thread_id"].startswith("thr_") + + # The threads-table projection ran via append_and_apply. + from chat.state.threads import list_open_threads + + open_threads = list_open_threads(conn, "chat_bot_a") + assert len(open_threads) == 1 + assert open_threads[0]["title"] == "Test thread" -- 2.52.0 From 2d1419755318169c65afec2b09d964a1452f4a28 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:27:47 -0400 Subject: [PATCH 12/22] 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 %} +
    + {% for ev in active_events %} +
  • + {{ ev.kind }} + ({{ ev.status }}) + {% if ev.planned_for %} +

    planned for: {{ ev.planned_for }}

    + {% endif %} + {% if ev.props %} +

    {{ ev.props|tojson }}

    + {% endif %} +
    + +
    +
  • + {% endfor %} +
+ {% else %} +

No active events.

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

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") == [] -- 2.52.0 From 21c4ffa63c7e41060cd2759e6a8e6c561df836b8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:34:26 -0400 Subject: [PATCH 13/22] feat: prompt assembly renders active events + open threads (T60) --- chat/services/prompt.py | 132 ++++++++++++++++++++++++++++++++++++++-- tests/test_prompt.py | 93 +++++++++++++++++++++++++++- 2 files changed, 219 insertions(+), 6 deletions(-) diff --git a/chat/services/prompt.py b/chat/services/prompt.py index 6e6d72c..6f836dc 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -37,8 +37,10 @@ import tiktoken from chat.llm.client import Message from chat.state.edges import get_edge, list_edges_for from chat.state.entities import get_bot, get_you +from chat.state.events import list_active_events from chat.state.group_node import get_group_node from chat.state.memory import search_memories +from chat.state.threads import list_open_threads from chat.state.world import ( active_scene, get_activity, @@ -227,6 +229,76 @@ def _build_group_node_block(group_node: dict | None) -> str | None: return "\n".join(lines) +def _props_excerpt(props: dict | None, limit: int = 80) -> str: + """Return a one-line excerpt of an event's ``props`` dict. + + Renders ``key=value`` pairs separated by ", " (deterministic by dict + insertion order) and truncates to ~``limit`` characters with a + trailing ellipsis. Returns empty string for falsy/empty props so the + caller can omit the line entirely. + """ + if not props: + return "" + pieces: list[str] = [] + for k, v in props.items(): + pieces.append(f"{k}={v}") + rendered = ", ".join(pieces) + if len(rendered) > limit: + # Reserve 1 char for the ellipsis so the total never exceeds limit. + rendered = rendered[: max(0, limit - 1)] + "…" + return rendered + + +def _build_active_events_block(events: list[dict]) -> str | None: + """Render the ``Active events:`` block for Phase 3 Task 60. + + One bullet per event. The sub-label depends on status: + - ``planned`` → ``(planned for {planned_for})`` + - ``active`` → ``(active, started_at={started_at})`` + A second indented line carries a one-line excerpt of the event's + ``props`` (truncated ~80 chars) when non-empty. Returns ``None`` when + there are no active events so the caller can omit the entire block. + """ + if not events: + return None + lines = ["Active events:"] + for ev in events: + kind = ev.get("kind") or "?" + status = ev.get("status") or "" + if status == "active": + started = ev.get("started_at") or "" + lines.append(f"- {kind} (active, started_at={started})") + else: + planned = ev.get("planned_for") or "" + lines.append(f"- {kind} (planned for {planned})") + excerpt = _props_excerpt(ev.get("props")) + if excerpt: + lines.append(f" {excerpt}") + return "\n".join(lines) + + +def _build_open_threads_block(threads: list[dict]) -> str | None: + """Render the ``Open threads:`` block for Phase 3 Task 60. + + One bullet per thread, formatted as ``- {title}: {summary}`` with the + summary truncated to ~120 characters. Returns ``None`` when there are + no open threads so the caller can omit the entire block. + """ + if not threads: + return None + lines = ["Open threads:"] + for t in threads: + title = t.get("title") or "?" + summary = t.get("summary") or "" + if len(summary) > 120: + summary = summary[:119] + "…" + if summary: + lines.append(f"- {title}: {summary}") + else: + lines.append(f"- {title}") + return "\n".join(lines) + + def _closing_instruction(speaker_name: str, addressee_name: str) -> str: return ( f"Continue the scene as {speaker_name}, in their voice, responding " @@ -436,6 +508,17 @@ def assemble_narrative_prompt( if required.issubset(members): group_node_block = _build_group_node_block(gn) + # SHOULD-tier active events + open threads (Phase 3 / Task 60). + # Auto-detect both from the chat_id per the Phase 2 T43 precedent — + # no new function parameter. Both blocks are omit-when-empty so a + # Phase 1 chat with no events/threads renders identically to before. + active_events_block = _build_active_events_block( + list_active_events(conn, chat_id) + ) + open_threads_block = _build_open_threads_block( + list_open_threads(conn, chat_id) + ) + container = None if chat.get("active_scene_id"): scene = get_scene(conn, chat["active_scene_id"]) @@ -531,6 +614,8 @@ def assemble_narrative_prompt( include_you_activity: bool = True, include_guest_activity: bool = True, include_group_node: bool = True, + include_active_events: bool = True, + include_open_threads: bool = True, ) -> tuple[str, int, list[dict]]: # dialogue: keep the last `dialogue_keep` turns verbatim; older # turns become an "earlier:" placeholder line. @@ -566,6 +651,8 @@ def assemble_narrative_prompt( scene_block, activity_block, group_node_block if include_group_node else None, + active_events_block if include_active_events else None, + open_threads_block if include_open_threads else None, prev_block, memories_block, dialogue_block, @@ -585,9 +672,12 @@ def assemble_narrative_prompt( include_you_activity = you_activity is not None include_guest_activity = guest_activity is not None include_group_node = group_node_block is not None + include_active_events = active_events_block is not None + include_open_threads = open_threads_block is not None def _build(*, prev: bool, mem_k: int, dlg: int, other: bool, - you_act: bool, guest_act: bool, group: bool) -> tuple[str, int]: + you_act: bool, guest_act: bool, group: bool, + events: bool, threads: bool) -> tuple[str, int]: body, total, _ = assemble( include_other_edges=other, include_previous_scene=prev, @@ -596,6 +686,8 @@ def assemble_narrative_prompt( include_you_activity=you_act, include_guest_activity=guest_act, include_group_node=group, + include_active_events=events, + include_open_threads=threads, ) return body, total @@ -603,6 +695,7 @@ def assemble_narrative_prompt( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, ) # If under soft, we're done. @@ -637,6 +730,7 @@ def assemble_narrative_prompt( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -647,6 +741,7 @@ def assemble_narrative_prompt( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -657,6 +752,7 @@ def assemble_narrative_prompt( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -668,21 +764,44 @@ def assemble_narrative_prompt( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, ) # Drop SHOULD-tier extras in order: - # 1. guest activity bullet (T71.2: bullet-level trim within the + # 1. open threads block (T60: SHOULD-tier; least critical to the + # speaker's immediate voice — drop first among SHOULD) + # 2. active events block (T60: same tier, drops next) + # 3. guest activity bullet (T71.2: bullet-level trim within the # single ACTIVITIES: block — guest goes first per Task 43 spec) - # 2. group node block - # 3. you activity bullet (still SHOULD-tier; speaker bullet is the + # 4. group node block + # 5. you activity bullet (still SHOULD-tier; speaker bullet is the # MUST-tier floor and never dropped) - # 4. other edges + # 6. other edges + if include_open_threads and total > budget_hard: + include_open_threads = False + body, total = _build( + prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, + ) + + if include_active_events and total > budget_hard: + include_active_events = False + body, total = _build( + prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, + ) + if include_guest_activity and total > budget_hard: include_guest_activity = False body, total = _build( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, ) if include_group_node and total > budget_hard: @@ -691,6 +810,7 @@ def assemble_narrative_prompt( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, ) if include_you_activity and total > budget_hard: @@ -699,6 +819,7 @@ def assemble_narrative_prompt( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, ) if include_other and total > budget_hard: @@ -707,6 +828,7 @@ def assemble_narrative_prompt( prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, ) if total > budget_hard: diff --git a/tests/test_prompt.py b/tests/test_prompt.py index f50fdea..e8fc30d 100644 --- a/tests/test_prompt.py +++ b/tests/test_prompt.py @@ -12,12 +12,14 @@ import pytest from chat.db.connection import open_db from chat.db.migrate import apply_migrations -from chat.eventlog.log import append_event +from chat.eventlog.log import append_and_apply, append_event from chat.eventlog.projector import project import chat.state.entities # noqa: F401 (registers handlers) import chat.state.edges # noqa: F401 import chat.state.memory # noqa: F401 import chat.state.world # noqa: F401 +import chat.state.events # noqa: F401 +import chat.state.threads # noqa: F401 from chat.llm.client import Message from chat.services.prompt import assemble_narrative_prompt @@ -761,3 +763,92 @@ def test_assemble_with_tight_budget_drops_guest_activity_first(tmp_path): import tiktoken enc = tiktoken.get_encoding("cl100k_base") assert len(enc.encode(body)) <= 340 + + +# --------------------------------------------------------------------------- +# Task 60: Active events + open threads in prompt assembly +# --------------------------------------------------------------------------- + + +def test_assemble_with_no_events_or_threads_omits_blocks(tmp_path): + """Regression: with the basic 2-entity scenario (no events seeded, no + threads seeded), the assembled prompt must NOT contain the + ``Active events:`` or ``Open threads:`` headers — both blocks are + omit-when-empty.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_basic(conn) + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body = msgs[0].content + assert "Active events:" not in body + assert "Open threads:" not in body + + +def test_assemble_with_active_events_renders_block(tmp_path): + """Seed a planned event then transition it to active; the assembled + prompt should render the ``Active events:`` block listing the event + by kind.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_basic(conn) + # event_planned then event_started → status="active". Use + # append_and_apply because _seed_basic already projected; calling + # project() again would replay every prior event (and trip + # UNIQUE constraints on chat_created etc.). + append_and_apply(conn, kind="event_planned", payload={ + "event_id": "evt_park", + "chat_id": "chat_bot_a", + "kind": "date_at_park", + "props": {"location": "Riverside Park", "vibe": "casual"}, + "planned_for": "2026-04-30T18:00:00+00:00", + }) + append_and_apply(conn, kind="event_started", payload={ + "event_id": "evt_park", + "started_at": "2026-04-30T18:05:00+00:00", + }) + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body = msgs[0].content + assert "Active events:" in body + assert "date_at_park" in body + + +def test_assemble_with_open_thread_renders_block(tmp_path): + """Seed a single open thread; the assembled prompt should render the + ``Open threads:`` block listing the thread by title.""" + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_basic(conn) + # _seed_basic already projected; use append_and_apply for the + # post-seed event so we don't re-trigger UNIQUE constraint + # collisions on the prior chat_created/etc. events. + append_and_apply(conn, kind="thread_opened", payload={ + "thread_id": "thr_job", + "chat_id": "chat_bot_a", + "title": "Maya's job hunt", + "summary": "Maya is looking for a new job", + }) + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body = msgs[0].content + assert "Open threads:" in body + assert "Maya's job hunt" in body -- 2.52.0 From b582567521bdfa6f0448630236233e6cab91c5d6 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:35:34 -0400 Subject: [PATCH 14/22] feat: per-turn event-lifecycle detection + completion promotion (T61) --- chat/services/regenerate.py | 64 ++++++++++ chat/web/turns.py | 73 +++++++++++ tests/test_turn_flow.py | 243 +++++++++++++++++++++++++++++++++++- 3 files changed, 379 insertions(+), 1 deletion(-) diff --git a/chat/services/regenerate.py b/chat/services/regenerate.py index 6427092..1317903 100644 --- a/chat/services/regenerate.py +++ b/chat/services/regenerate.py @@ -73,12 +73,15 @@ from sqlite3 import Connection from chat.config import Settings from chat.eventlog.log import append_and_apply, append_event +from chat.services.event_lifecycle import detect_event_transitions +from chat.services.event_promotion import promote_completed_event from chat.services.interjection import detect_interjection from chat.services.memory_write import record_turn_memory_for_present from chat.services.multi_state_update import compute_state_updates_for_present from chat.services.prompt import assemble_narrative_prompt from chat.state.edges import get_edge from chat.state.entities import get_bot, get_you +from chat.state.events import list_active_events from chat.state.world import active_scene, get_chat from chat.web.pubsub import publish from chat.web.render import render_turn_html @@ -617,6 +620,67 @@ async def regenerate_assistant_turn( (new_assistant_event_id, original_interjection_event_id), ) + # 10. Event-lifecycle detection (Phase 3, T61). Mirrors the post_turn + # block: classify whether any active events transitioned in the + # regenerated narrative and append the corresponding event_started / + # event_completed / event_cancelled. ``promote_completed_event`` + # runs inline after a completion so promotion artifacts land in the + # same regenerate path. + # + # Phase 3.5 follow-up: when a regenerate replaces a turn that had + # already produced event transitions, those original transitions are + # NOT undone here. The superseded ``assistant_turn`` group keeps its + # prior ``event_started`` / ``event_completed`` events in the log + # (they remain projected onto the events table). Phase 3.5 will add + # an "undo lifecycle" step to roll back the prior transitions before + # re-classifying the regenerated text. For v3 we accept that a + # regenerate-after-completion will double-emit promotion artifacts + # if the new text re-completes the same event — narratively rare, + # and a true fix needs the lifecycle-undo pass. + new_active_events = list_active_events(conn, chat_id) + if new_active_events: + lifecycle_decision = await detect_event_transitions( + client, + classifier_model=settings.classifier_model, + narrative_text=new_text, + active_events=new_active_events, + timeout_s=settings.classifier_timeout_s, + ) + for transition in lifecycle_decision.transitions: + if transition.new_status == "active": + append_and_apply( + conn, + kind="event_started", + payload={ + "event_id": transition.event_id, + "started_at": chat.get("time"), + }, + ) + elif transition.new_status == "completed": + append_and_apply( + conn, + kind="event_completed", + payload={ + "event_id": transition.event_id, + "completed_at": chat.get("time"), + }, + ) + promote_completed_event( + conn, + event_id=transition.event_id, + chat_id=chat_id, + chat_clock_at=chat.get("time"), + ) + elif transition.new_status == "cancelled": + append_and_apply( + conn, + kind="event_cancelled", + payload={ + "event_id": transition.event_id, + "completed_at": chat.get("time"), + }, + ) + return new_text diff --git a/chat/web/turns.py b/chat/web/turns.py index b3a4f0e..5ef6725 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -57,6 +57,8 @@ from fastapi.responses import HTMLResponse, RedirectResponse, Response from chat.eventlog.log import append_and_apply, append_event from chat.services.addressee import detect_addressee from chat.services.background import SignificanceJob +from chat.services.event_lifecycle import detect_event_transitions +from chat.services.event_promotion import promote_completed_event from chat.services.interjection import detect_interjection from chat.services.memory_write import record_turn_memory_for_present from chat.services.multi_state_update import compute_state_updates_for_present @@ -67,6 +69,7 @@ from chat.services.scene_summarize import apply_scene_close_summary from chat.services.turn_parse import ParsedTurn, parse_turn from chat.state.edges import get_edge from chat.state.entities import get_bot, get_you +from chat.state.events import list_active_events from chat.state.world import active_scene, get_chat, get_container from chat.web.bots import get_conn from chat.web.kickoff import get_llm_client @@ -654,6 +657,76 @@ async def post_turn( ) ) + # 8a. Event-lifecycle detection (Phase 3, T61). Runs after the post-turn + # classifier passes (memory write + state update + optional + # interjection) and BEFORE scene-close detection. The classifier reads + # ``primary_text`` against the chat's currently-active events and + # returns a (usually empty) list of transitions. Each transition lands + # an ``event_started`` / ``event_completed`` / ``event_cancelled`` + # event via ``append_and_apply`` so the events projection updates + # synchronously. A completion is followed inline by + # ``promote_completed_event`` so any structured artifacts the event + # carries (knowledge_facts, relationship_change, acquired_objects) + # land in state in the same turn — see chat/services/event_promotion. + # + # ``detect_event_transitions`` short-circuits when ``active_events`` + # is empty (per T52), so chats without active events don't pay a + # classifier round-trip and existing fixtures need no extra canned + # slots. + active_events = list_active_events(conn, chat_id) + if active_events: + lifecycle_decision = await detect_event_transitions( + client, + classifier_model=settings.classifier_model, + narrative_text=primary_text, + active_events=active_events, + timeout_s=settings.classifier_timeout_s, + ) + for transition in lifecycle_decision.transitions: + if transition.new_status == "active": + append_and_apply( + conn, + kind="event_started", + payload={ + "event_id": transition.event_id, + "started_at": chat.get("time"), + }, + ) + elif transition.new_status == "completed": + append_and_apply( + conn, + kind="event_completed", + payload={ + "event_id": transition.event_id, + "completed_at": chat.get("time"), + }, + ) + # Run promotion inline so the artifact-emitting events + # (edge_update / manual_edit) land synchronously after + # the completion. ``promote_completed_event`` is + # synchronous (no await) and skips silently when the + # event row's status isn't 'completed' — a safety net + # for races, not expected to trigger in practice. + promote_completed_event( + conn, + event_id=transition.event_id, + chat_id=chat_id, + chat_clock_at=chat.get("time"), + ) + elif transition.new_status == "cancelled": + append_and_apply( + conn, + kind="event_cancelled", + payload={ + "event_id": transition.event_id, + "completed_at": chat.get("time"), + }, + ) + # Any other ``new_status`` value falls through silently — + # the lifecycle service constrains the schema to the three + # valid transitions, and a defensive no-op here keeps the + # turn flow tolerant of unexpected outputs. + # 9. Scene-close detection (Plan §7.2, T26). Runs AFTER assistant_turn # and the optional interjection so the bots' responses are part of # the closing scene's final beat — closing before narrative would diff --git a/tests/test_turn_flow.py b/tests/test_turn_flow.py index a30ec24..a202e2d 100644 --- a/tests/test_turn_flow.py +++ b/tests/test_turn_flow.py @@ -19,7 +19,7 @@ from fastapi.testclient import TestClient from chat.app import app from chat.db.connection import open_db -from chat.eventlog.log import append_event +from chat.eventlog.log import append_and_apply, append_event from chat.eventlog.projector import project from chat.llm.mock import MockLLMClient @@ -896,3 +896,244 @@ def test_interjection_enqueues_significance_job(app_state_setup, tmp_path): # The two narrative texts should be the two streamed beats. narrative_texts = sorted(job.narrative_text for job in captured_jobs) assert narrative_texts == ["Interjection beat!", "Primary beat."] + + +# --------------------------------------------------------------------------- +# Phase 3 (T61) — per-turn event-lifecycle detection + completion promotion. +# +# After the post-turn classifier passes (memory write, state update, +# interjection check) and BEFORE scene-close detection, ``post_turn`` +# calls :func:`detect_event_transitions`. Each transition becomes one +# of ``event_started`` / ``event_completed`` / ``event_cancelled``. A +# completed event is followed inline by ``promote_completed_event`` so +# the props it carries (knowledge_facts, etc.) land in state +# synchronously. +# +# When no active events are seeded the classifier short-circuits without +# an LLM call (per T52) — the canned queue therefore needs ZERO extra +# slots in that case. +# --------------------------------------------------------------------------- + + +def test_turn_with_event_transition_appends_started_event( + app_state_setup, tmp_path +): + """A planned event becomes active when the classifier reports a + ``new_status='active'`` transition for that event_id. + + Canned queue (5 calls — single-bot, no scene seeded): + 1. parse_turn + 2. narrative stream + 3. state-update bot_a -> you + 4. state-update you -> bot_a + 5. detect_event_transitions -> 1 transition (active) + """ + _seed(tmp_path / "test.db") + # Seed a planned event so list_active_events returns 1 row. Use + # append_and_apply so we don't re-replay the prior chat_created event + # (whose handler is INSERT-not-IGNORE and would 409 on replay). + with open_db(tmp_path / "test.db") as conn: + append_and_apply( + conn, + kind="event_planned", + payload={ + "event_id": "evt_1", + "chat_id": "chat_bot_a", + "kind": "story_event", + "props": {}, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + + canned_parse = json.dumps( + {"segments": [{"kind": "dialogue", "text": "they arrived"}]} + ) + canned_event_decision = json.dumps( + { + "transitions": [ + { + "event_id": "evt_1", + "new_status": "active", + "reason": "they arrived", + } + ] + } + ) + mock = _override_llm( + [ + canned_parse, + "They walk in.", + _zero_state(), + _zero_state(), + canned_event_decision, + ] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", data={"prose": "they arrived"} + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + # All 5 canned slots consumed. + assert mock._canned == [] + + with open_db(tmp_path / "test.db") as conn: + # event_started landed in event_log. + rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'event_started' ORDER BY id" + ).fetchall() + assert len(rows) == 1 + started_payload = json.loads(rows[0][0]) + assert started_payload["event_id"] == "evt_1" + assert started_payload["started_at"] == "2026-04-26T20:00:00+00:00" + + # The events projection row reflects the active status. + ev_row = conn.execute( + "SELECT status, started_at FROM events WHERE event_id = ?", + ("evt_1",), + ).fetchone() + assert ev_row is not None + assert ev_row[0] == "active" + assert ev_row[1] == "2026-04-26T20:00:00+00:00" + + +def test_turn_with_event_completion_runs_promotion(app_state_setup, tmp_path): + """An active event with knowledge_facts in props completes; the + inline call to ``promote_completed_event`` emits the corresponding + ``edge_update``. + """ + _seed(tmp_path / "test.db") + # Seed: planned -> started so the event is currently active. Props + # carry a knowledge_fact that promotion will turn into an edge_update. + # Use append_and_apply (not project) to avoid re-replaying chat_created. + with open_db(tmp_path / "test.db") as conn: + append_and_apply( + conn, + kind="event_planned", + payload={ + "event_id": "evt_2", + "chat_id": "chat_bot_a", + "kind": "story_event", + "props": { + "knowledge_facts": [ + { + "owner_id": "bot_a", + "target_id": "you", + "fact": "Maya likes pottery", + } + ] + }, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + append_and_apply( + conn, + kind="event_started", + payload={ + "event_id": "evt_2", + "started_at": "2026-04-30T19:00:00+00:00", + }, + ) + + # Snapshot the max event_log id so we can assert on rows AFTER the turn. + with open_db(tmp_path / "test.db") as conn: + before_id = conn.execute( + "SELECT COALESCE(MAX(id), 0) FROM event_log" + ).fetchone()[0] + + canned_parse = json.dumps( + {"segments": [{"kind": "dialogue", "text": "we wrap it up"}]} + ) + canned_event_decision = json.dumps( + { + "transitions": [ + { + "event_id": "evt_2", + "new_status": "completed", + "reason": "wrapped", + } + ] + } + ) + mock = _override_llm( + [ + canned_parse, + "They wrap it up.", + _zero_state(), + _zero_state(), + canned_event_decision, + ] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", data={"prose": "we wrap it up"} + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [] + + with open_db(tmp_path / "test.db") as conn: + # event_completed landed. + completed_rows = conn.execute( + "SELECT id, payload_json FROM event_log " + "WHERE kind = 'event_completed' AND id > ? ORDER BY id", + (before_id,), + ).fetchall() + assert len(completed_rows) == 1 + completed_payload = json.loads(completed_rows[0][1]) + assert completed_payload["event_id"] == "evt_2" + completed_id = completed_rows[0][0] + + # promote_completed_event ran inline AFTER event_completed: the + # follow-on edge_update carries the knowledge fact and is tagged + # with source=event_promotion. + promo_rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'edge_update' AND id > ? ORDER BY id", + (completed_id,), + ).fetchall() + promo_facts: list[str] = [] + for (payload_json,) in promo_rows: + p = json.loads(payload_json) + if p.get("source") == "event_promotion": + promo_facts.extend(p.get("knowledge_facts") or []) + + assert "Maya likes pottery" in promo_facts + + +def test_turn_with_no_active_events_skips_classifier(app_state_setup, tmp_path): + """When no active events are seeded, ``detect_event_transitions`` + short-circuits without an LLM call (per T52). The canned queue must + therefore have ZERO event-detection slots — same shape as the + Phase 2 no-guest baseline. + """ + _seed(tmp_path / "test.db") + + canned_parse = json.dumps( + {"segments": [{"kind": "dialogue", "text": "hello"}]} + ) + # Only 4 slots: parse + narrative + 2 state-updates. NO extra slot for + # event-detection — non-existent active_events causes the helper to + # short-circuit before pulling from the queue. + mock = _override_llm( + [canned_parse, "Hi there.", _zero_state(), _zero_state()] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", data={"prose": "hello"} + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + # Queue fully drained — no canned slot was consumed by event detection. + assert mock._canned == [] + + with open_db(tmp_path / "test.db") as conn: + for kind in ("event_started", "event_completed", "event_cancelled"): + count = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = ?", (kind,) + ).fetchone()[0] + assert count == 0, f"expected zero {kind} events, got {count}" -- 2.52.0 From a7eedb80373436f1afc0e23ff8d43ef69dda6b33 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:45:05 -0400 Subject: [PATCH 15/22] feat: natural-language skip detection + skip command flow (T62) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend ParsedTurn with intent/landing_state_hint so the classifier can flag skip-elision and skip-jump prose. The post_turn handler short- circuits the regular narrative path when intent != "narrative": elision runs through the shared controller in chat/web/skip.py; jump returns 422 directing the user to the drawer's structured form (simpler Phase 3 path — natural-language fiction-time delta parsing is too fragile for v1 without a structured surface). Extract the elision/jump logic that previously lived in drawer.py into chat/web/skip.py so both the drawer T59 routes and the new natural-language path share one canonical implementation. The drawer routes become thin HTTP wrappers that translate ValueError to 400 and refresh the drawer partial; the existing drawer skip tests pass unchanged. The new natural-language elision derives ``new_time`` by bumping the chat clock by 1 hour (Phase 3 stub) — the drawer's structured form remains the path for picking a specific landing time. --- chat/services/turn_parse.py | 47 +++++- chat/web/drawer.py | 250 ++++++------------------------- chat/web/skip.py | 287 ++++++++++++++++++++++++++++++++++++ chat/web/turns.py | 64 +++++++- tests/test_turn_flow.py | 180 ++++++++++++++++++++++ 5 files changed, 615 insertions(+), 213 deletions(-) create mode 100644 chat/web/skip.py diff --git a/chat/services/turn_parse.py b/chat/services/turn_parse.py index df721dc..9c1e778 100644 --- a/chat/services/turn_parse.py +++ b/chat/services/turn_parse.py @@ -16,6 +16,16 @@ nested quotes, mixed punctuation), so v1 delegates the segmentation to the classifier. The configurable ``Settings.ooc_marker`` is *not* read here: the classifier figures OOC out from ``((`` ``))`` regardless of config-time choice; marker-based stripping is a downstream concern. + +T62 extends the parser with an ``intent`` field so the turn flow can +short-circuit time-skip phrases before the regular narrative path. +``intent`` defaults to ``"narrative"``; the classifier may set it to +``"skip_elision"`` when prose like "skip to when we arrive" or +``"skip_jump"`` when prose like "next morning" / "a week later" is +detected. ``landing_state_hint`` carries the residual descriptor for +elision skips (the "to when we ..." phrase). Existing callers that +don't read ``intent`` continue to work because the default keeps the +narrative path intact. """ from __future__ import annotations @@ -39,9 +49,19 @@ class TurnSegment(BaseModel): class ParsedTurn(BaseModel): - """A turn split into ordered, typed segments.""" + """A turn split into ordered, typed segments. + + ``intent`` distinguishes a regular narrative beat (the default) from + a natural-language time-skip command (T62). ``landing_state_hint`` + captures the descriptor following "skip to when we ..." for elision + skips so the downstream skip controller can pass it to the + narration helper. Both fields are optional and default-empty so + older fixtures and tests that don't supply them keep working. + """ segments: list[TurnSegment] + intent: str = "narrative" # "narrative" | "skip_elision" | "skip_jump" + landing_state_hint: str = "" _SYSTEM_PROMPT = ( @@ -52,13 +72,24 @@ _SYSTEM_PROMPT = ( "- ((text in double parens)) is an OOC (out-of-character) segment — " "the author talking to the system, not the in-fiction bot.\n\n" "Output a JSON object with shape " - '{"segments": [{"kind": "...", "text": "..."}, ...]} ' - "where each ``kind`` is exactly one of: dialogue, action, ooc. " - "Preserve the original substring text as ``text``: do not rewrite, " - "translate, or normalize punctuation — strip only the marker " - "characters (asterisks, surrounding quotes, double parens) so " - "``text`` is the inner content. Emit segments in the order they " - "appear in the input." + '{"segments": [{"kind": "...", "text": "..."}, ...], ' + '"intent": "...", "landing_state_hint": "..."} ' + "where each segment ``kind`` is exactly one of: dialogue, action, " + "ooc. Preserve the original substring text as ``text``: do not " + "rewrite, translate, or normalize punctuation — strip only the " + "marker characters (asterisks, surrounding quotes, double parens) " + "so ``text`` is the inner content. Emit segments in the order they " + "appear in the input.\n\n" + "``intent`` is exactly one of: narrative, skip_elision, skip_jump. " + "Default to ``narrative``. Use ``skip_elision`` when the prose is a " + "directive to fast-forward an in-progress activity to a near-term " + "landing state — e.g. 'skip to when we arrive', 'fast-forward to " + "after dinner'. Use ``skip_jump`` when the prose denotes a longer " + "fiction-time bridge — e.g. 'next morning', 'a week later', 'the " + "following day'.\n" + "``landing_state_hint`` is a short descriptor of the landing state " + "for ``skip_elision`` (e.g. 'we arrive at the park'). Empty string " + "for ``skip_jump`` and ``narrative``." ) diff --git a/chat/web/drawer.py b/chat/web/drawer.py index ea27721..1098eb5 100644 --- a/chat/web/drawer.py +++ b/chat/web/drawer.py @@ -29,19 +29,15 @@ 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, append_event -from chat.services.memory_write import record_turn_memory_for_present +from chat.eventlog.log import append_and_apply 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 @@ -51,6 +47,11 @@ 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 +from chat.web.skip import ( + _now_iso, + process_elision_skip, + process_jump_skip, +) TEMPLATES = Jinja2Templates( directory=str(Path(__file__).resolve().parent.parent / "templates") @@ -881,29 +882,6 @@ async def remove_guest( # 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, @@ -1000,70 +978,29 @@ async def skip_elision( ): """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. + Thin HTTP wrapper around :func:`chat.web.skip.process_elision_skip` + (T62 extracted the controller). Validation failures surface as + ``400`` and the route still returns the refreshed drawer partial on + success so HTMX swaps in the new chat clock. """ - 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, - }, - ) + try: + await process_elision_skip( + conn, + client, + settings, + chat_id=chat_id, + new_time=new_time, + landing_state_hint=landing_state_hint, + ) + except ValueError as exc: + # ``process_elision_skip`` raises on missing-chat or malformed / + # backwards new_time. The drawer used to 404 / 400 these + # separately — preserve the 404-vs-400 split by sniffing the + # error message so existing tests keep passing without changes. + if str(exc).startswith("chat not found"): + raise HTTPException(status_code=404, detail=str(exc)) + raise HTTPException(status_code=400, detail=str(exc)) return await drawer(chat_id, request, conn) @@ -1082,123 +1019,28 @@ async def skip_jump( ): """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. + Thin HTTP wrapper around :func:`chat.web.skip.process_jump_skip` + (T62 extracted the controller). ``reset_activity`` is parsed + permissively here ("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, - }, - ) + try: + await process_jump_skip( + conn, + client, + settings, + chat_id=chat_id, + new_time=new_time, + notable_prose=notable_prose, + reset_activity=reset_flag, + ) + except ValueError as exc: + if str(exc).startswith("chat not found"): + raise HTTPException(status_code=404, detail=str(exc)) + raise HTTPException(status_code=400, detail=str(exc)) return await drawer(chat_id, request, conn) diff --git a/chat/web/skip.py b/chat/web/skip.py new file mode 100644 index 0000000..ccbf470 --- /dev/null +++ b/chat/web/skip.py @@ -0,0 +1,287 @@ +"""Shared skip-flow controllers (T62). + +Both the drawer skip routes (T59) and the natural-language skip parse +(T62) call into these controllers. Keep the controllers free of HTTP +concerns — they take ``conn`` + ``client`` + ``settings`` and structured +args, append events, and return a small result dict the caller can map +to whatever response shape it owes (drawer partial, 204, 422, etc.). + +``ValueError`` is the controller-level signal for caller-mappable +validation failure (bad ISO timestamp, backwards skip). The drawer +routes translate it to ``HTTP 400``; the natural-language path either +swallows it (the parser handed us a degenerate hint) or surfaces it the +same way. Anything else (LLM failure, unexpected exception) propagates +uncaught — :func:`narrate_skip` already has its own deterministic +fallback for the routine LLM-down case, so a real exception here means +something we want to see. + +The two controllers mirror the drawer T59 logic closely so the v1 +guarantees (``time_skip_*`` lands first → memory writes ride the +post-skip clock → narration ``assistant_turn`` is appended last) hold +identically across the two entry points. +""" + +from __future__ import annotations + +from datetime import datetime, timezone +from sqlite3 import Connection + +from chat.config import Settings +from chat.eventlog.log import append_and_apply, append_event +from chat.llm.client import LLMClient +from chat.services.memory_write import record_turn_memory_for_present +from chat.services.skip_narration import narrate_skip +from chat.services.synthesized_memories import synthesize_memories +from chat.state.entities import get_bot, get_you +from chat.state.world import get_activity, get_chat + + +def _parse_iso_time(value: str) -> datetime | None: + """Permissive ISO 8601 parser shared with the drawer routes (T59). + + ``datetime.fromisoformat`` doesn't accept a trailing ``Z`` until + Python 3.11; we normalize it to ``+00:00`` so older interpreters + parse the same set of strings the drawer accepts. + """ + 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 _validate_new_time(chat: dict, new_time: str) -> None: + """Raise ``ValueError`` if ``new_time`` is unparseable or backwards. + + The drawer route maps the raised error to ``HTTP 400``; the + natural-language path may also surface it as a ``400``. Centralizing + the rule here means both entry points enforce the same invariant + (no causality-corrupting backwards jumps). + """ + new_dt = _parse_iso_time(new_time) + if new_dt is None: + raise ValueError(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 ValueError( + "new_time must not be earlier than the current chat clock" + ) + + +async def process_elision_skip( + conn: Connection, + client: LLMClient, + settings: Settings, + *, + chat_id: str, + new_time: str, + landing_state_hint: str = "", +) -> dict: + """Run an elision skip end-to-end. + + Validates ``new_time`` against the current chat clock, appends a + ``time_skip_elision`` event (chat clock advances), generates a + transition narration via :func:`narrate_skip`, and appends an + ``assistant_turn`` carrying the narration. ``narrate_skip`` has its + own deterministic fallback so this never blocks on the model. + + Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id": + ..., "assistant_event_id": ...}`` so callers can introspect the + generated turn (e.g. for SSE rebroadcast or test assertions). + + Raises ``ValueError`` on validation failure or when the chat row + can't be located (the drawer maps it to ``HTTP 400`` / ``404`` + respectively; the natural-language path follows the same shape). + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise ValueError(f"chat not found: {chat_id}") + + _validate_new_time(chat, new_time) + + 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"} + + # The drawer route reaches into the host bot's current activity to + # surface the verb to the narration helper — we do the same so both + # entry points produce the same prose for the same chat state. + bot_activity = get_activity(conn, chat["host_bot_id"]) or {} + current_activity = (bot_activity.get("action") or {}).get("verb") or "" + + 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, + ) + + skip_event_id = append_and_apply( + conn, + kind="time_skip_elision", + payload={"chat_id": chat_id, "new_time": new_time}, + ) + speaker_id = host_bot.get("id") or chat["host_bot_id"] + assistant_event_id = append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": chat_id, + "speaker_id": speaker_id, + "text": narration, + "truncated": False, + }, + ) + + return { + "assistant_text": narration, + "speaker_id": speaker_id, + "skip_event_id": skip_event_id, + "assistant_event_id": assistant_event_id, + } + + +async def process_jump_skip( + conn: Connection, + client: LLMClient, + settings: Settings, + *, + chat_id: str, + new_time: str, + notable_prose: str = "", + reset_activity: bool = False, +) -> dict: + """Run a jump skip end-to-end. + + Same validations as :func:`process_elision_skip`. Emits + ``time_skip_jump`` *before* synthesizing memories so per-bot writes + record the post-jump chat clock (mirroring how a regular turn's + memory carries the chat clock). When ``notable_prose`` is non-empty, + runs :func:`synthesize_memories` once per present bot witness, then + fans the resulting memories out via + :func:`record_turn_memory_for_present` with ``source="synthesized"``. + Finally appends the narration ``assistant_turn``. + + Returns ``{"assistant_text": ..., "speaker_id": ..., "skip_event_id": + ..., "assistant_event_id": ...}``. + + Raises ``ValueError`` on validation failure (caller maps to ``400``). + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise ValueError(f"chat not found: {chat_id}") + + _validate_new_time(chat, new_time) + + 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 + + # Emit time_skip_jump up front so subsequent memory writes ride the + # post-jump chat clock (matches the drawer T59 behavior pinned by + # test_post_skip_jump_with_notable_prose_writes_synthesized_memories). + skip_event_id = append_and_apply( + conn, + kind="time_skip_jump", + payload={ + "chat_id": chat_id, + "new_time": new_time, + "reset_activity": reset_activity, + }, + ) + + # Synthesize per-bot memories when prose is non-empty. The helper + # short-circuits on whitespace prose, but gating the loop here keeps + # the canned-LLM-queue accounting predictable for tests. + 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 row per + # present bot per call — we already iterate by bot here, + # so guest_bot_id=None avoids 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, + ) + speaker_id = host_bot.get("id") or chat["host_bot_id"] + assistant_event_id = append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": chat_id, + "speaker_id": speaker_id, + "text": narration, + "truncated": False, + }, + ) + + return { + "assistant_text": narration, + "speaker_id": speaker_id, + "skip_event_id": skip_event_id, + "assistant_event_id": assistant_event_id, + } + + +def _now_iso() -> str: + """UTC ISO timestamp used by callers as a chat-clock fallback.""" + return datetime.now(timezone.utc).isoformat() + + +__all__ = [ + "process_elision_skip", + "process_jump_skip", + "_now_iso", + "_parse_iso_time", +] diff --git a/chat/web/turns.py b/chat/web/turns.py index 5ef6725..5d2ff94 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -51,8 +51,10 @@ import html import json import re +from datetime import timedelta + from fastapi import APIRouter, Depends, Form, HTTPException, Request -from fastapi.responses import HTMLResponse, RedirectResponse, Response +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response from chat.eventlog.log import append_and_apply, append_event from chat.services.addressee import detect_addressee @@ -75,6 +77,7 @@ from chat.web.bots import get_conn from chat.web.kickoff import get_llm_client from chat.web.pubsub import publish from chat.web.render import render_turn_html as _render_turn_html +from chat.web.skip import _parse_iso_time, process_elision_skip router = APIRouter() @@ -254,6 +257,65 @@ async def post_turn( ) prompt_prose = _strip_ooc_for_prompt(parsed) + # 1a. Skip-command short-circuit (T62). The parser may classify the + # prose as a time-skip directive — in which case the regular + # narrative path (addressee detection, narrative stream, post-turn + # state-update + scene-close passes) is skipped entirely. Elision + # runs through the shared controller in :mod:`chat.web.skip`; jump + # is drawer-only for Phase 3 (the natural-language path returns + # 422 directing the user to the drawer's jump form, where they can + # supply structured ``notable_prose`` and a target time). Anything + # not matching these intents falls through to the narrative branch. + intent = getattr(parsed, "intent", "narrative") or "narrative" + if intent == "skip_jump": + # Drawer-only jump for Phase 3: parsing a free-form fiction-time + # delta out of natural language ("next morning" -> ?) is fragile + # enough that we'd rather route the user to the drawer form, + # where they pick a concrete ISO time and an optional notable- + # prose field. 422 = "request shape is understood, but the + # required structured input lives on a different surface". + return JSONResponse( + { + "error": ( + "Jump skip requires the drawer's jump form for " + "notable_prose." + ) + }, + status_code=422, + ) + + if intent == "skip_elision": + # Derive ``new_time`` from the chat clock. Phase 3 stub: bump by + # 1 hour. The drawer's elision form is the structured path when + # the author wants a specific landing time; here the goal is + # "elide the dull bit" and any sensible forward step is fine — + # ``narrate_skip`` weaves the landing-state hint into the + # transition prose so the prose carries the semantic time, not + # the timestamp itself. + cur_dt = _parse_iso_time(chat.get("time") or "") + new_time = ( + (cur_dt + timedelta(hours=1)).isoformat() + if cur_dt is not None + else (chat.get("time") or "") + ) + try: + await process_elision_skip( + conn, + client, + settings, + chat_id=chat_id, + new_time=new_time, + landing_state_hint=getattr(parsed, "landing_state_hint", "") + or "", + ) + except ValueError as exc: + # The controller raises on missing chat / bad new_time. + # Missing chat is already handled above (we'd have 404'd); + # a bad new_time here is a stub-derivation bug rather than + # user input — surface as 400 with the controller message. + raise HTTPException(status_code=400, detail=str(exc)) + return Response(status_code=204) + # 2. Append user_turn event. user_turn_event_id = append_event( conn, diff --git a/tests/test_turn_flow.py b/tests/test_turn_flow.py index a202e2d..80364ec 100644 --- a/tests/test_turn_flow.py +++ b/tests/test_turn_flow.py @@ -1137,3 +1137,183 @@ def test_turn_with_no_active_events_skips_classifier(app_state_setup, tmp_path): "SELECT COUNT(*) FROM event_log WHERE kind = ?", (kind,) ).fetchone()[0] assert count == 0, f"expected zero {kind} events, got {count}" + + +# --------------------------------------------------------------------------- +# Phase 3 (T62) — natural-language skip-command surface. +# +# The classifier may flag prose as a time-skip directive via +# ``ParsedTurn.intent``. Elision runs through the shared controller in +# :mod:`chat.web.skip` and short-circuits the regular narrative path; +# jump returns 422 directing the user to the drawer's structured form +# (Phase 3 simpler path — natural-language jump time derivation is too +# fragile for v1 without the structured surface). +# --------------------------------------------------------------------------- + + +def test_elision_skip_via_natural_language(app_state_setup, tmp_path): + """User prose 'skip to when we arrive at the park' classifies as + ``intent='skip_elision'``. The post_turn handler short-circuits the + narrative path, advances the chat clock by an hour stub, appends a + ``time_skip_elision`` event AND an ``assistant_turn`` carrying the + canned narration. No ``user_turn`` is emitted on the skip path. + + Canned queue: 1 parse_turn (intent=skip_elision) + 1 narration + string (consumed by ``narrate_skip``). No state-update / scene-close + / event-detection slots — those branches are bypassed entirely. + """ + _seed(tmp_path / "test.db") + canned_parse = json.dumps( + { + "segments": [ + {"kind": "dialogue", "text": "skip to when we arrive at the park"} + ], + "intent": "skip_elision", + "landing_state_hint": "we arrive at the park", + } + ) + canned_narration = "We pull up to the park entrance, sun low in the sky." + mock = _override_llm([canned_parse, canned_narration]) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "skip to when we arrive at the park"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + + # Both canned slots drained — no other classifier branches ran. + assert mock._canned == [] + + with open_db(tmp_path / "test.db") as conn: + # time_skip_elision landed. + skip_rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'time_skip_elision' ORDER BY id" + ).fetchall() + assert len(skip_rows) == 1 + sp = json.loads(skip_rows[0][0]) + assert sp["chat_id"] == "chat_bot_a" + # 1-hour stub from the seeded chat clock (20:00 -> 21:00). + assert sp["new_time"].startswith("2026-04-26T21:00:00") + + # Chat clock advanced via the projector. + from chat.state.world import get_chat + + chat = get_chat(conn, "chat_bot_a") + assert chat["time"].startswith("2026-04-26T21:00:00") + + # An assistant_turn carrying the canned narration was appended. + turn_rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'assistant_turn' ORDER BY id" + ).fetchall() + assert len(turn_rows) == 1 + tp = json.loads(turn_rows[0][0]) + assert tp["chat_id"] == "chat_bot_a" + assert tp["text"] == canned_narration + assert tp["speaker_id"] == "bot_a" + assert tp["truncated"] is False + + # No user_turn lands on the skip path — the natural-language + # skip is a command, not a beat the bots should remember. + user_count = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'user_turn'" + ).fetchone()[0] + assert user_count == 0 + + +def test_jump_skip_via_natural_language_returns_422(app_state_setup, tmp_path): + """User prose 'next morning' classifies as ``intent='skip_jump'``. + The handler returns 422 with a guidance payload pointing the author + at the drawer's structured jump form. No event is emitted — the + drawer form is the only entry point for jump skips in Phase 3. + """ + _seed(tmp_path / "test.db") + canned_parse = json.dumps( + { + "segments": [{"kind": "dialogue", "text": "next morning"}], + "intent": "skip_jump", + "landing_state_hint": "", + } + ) + # Only one canned slot — parse — because the 422 fallback short- + # circuits before any other classifier runs. + mock = _override_llm([canned_parse]) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", data={"prose": "next morning"} + ) + assert response.status_code == 422 + body = response.json() + # Guidance payload mentions the drawer so the client can surface + # the right CTA; we don't pin the exact wording. + assert "drawer" in body.get("error", "").lower() + finally: + app.dependency_overrides.clear() + + # Parse slot consumed; no follow-on classifier calls. + assert mock._canned == [] + + with open_db(tmp_path / "test.db") as conn: + for kind in ( + "user_turn", + "assistant_turn", + "time_skip_elision", + "time_skip_jump", + ): + count = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = ?", (kind,) + ).fetchone()[0] + assert count == 0, f"expected zero {kind} on jump-via-NL, got {count}" + + +def test_skip_command_does_not_run_narrative_classifier( + app_state_setup, tmp_path, monkeypatch +): + """The skip dispatch branch must bypass the narrative-prompt assembly + entirely. We monkeypatch ``assemble_narrative_prompt`` (re-bound on + the ``chat.web.turns`` module since the handler imports it by name) + and assert the call count is zero after the elision skip lands. + """ + _seed(tmp_path / "test.db") + canned_parse = json.dumps( + { + "segments": [ + {"kind": "dialogue", "text": "skip to when we arrive at the park"} + ], + "intent": "skip_elision", + "landing_state_hint": "we arrive at the park", + } + ) + canned_narration = "We arrive moments later." + mock = _override_llm([canned_parse, canned_narration]) + + call_counter = {"n": 0} + + def _spy(*args, **kwargs): + call_counter["n"] += 1 + return [] + + # Patch the symbol at the handler's import site so we can assert + # the skip path bypasses prompt assembly even when the symbol still + # exists in the module namespace. + from chat.web import turns as turns_mod + + monkeypatch.setattr(turns_mod, "assemble_narrative_prompt", _spy) + + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "skip to when we arrive at the park"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + + assert mock._canned == [] + assert call_counter["n"] == 0, ( + "assemble_narrative_prompt was called on the skip path; the " + "natural-language skip dispatch must bypass narrative assembly." + ) -- 2.52.0 From c463dc70b28e2ffac3d33f6726ad6e98bf2bfc0f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:51:08 -0400 Subject: [PATCH 16/22] feat: meanwhile scene schema + state (T63) --- chat/db/migrations/0011_meanwhile_scenes.sql | 27 ++ chat/state/meanwhile.py | 164 +++++++++++ tests/test_meanwhile_state.py | 269 +++++++++++++++++++ tests/test_world.py | 4 +- 4 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 chat/db/migrations/0011_meanwhile_scenes.sql create mode 100644 chat/state/meanwhile.py create mode 100644 tests/test_meanwhile_state.py diff --git a/chat/db/migrations/0011_meanwhile_scenes.sql b/chat/db/migrations/0011_meanwhile_scenes.sql new file mode 100644 index 0000000..e1dd1dd --- /dev/null +++ b/chat/db/migrations/0011_meanwhile_scenes.sql @@ -0,0 +1,27 @@ +-- T63: Meanwhile scene support — extend scenes with a present-set discriminator +-- and a parent link (you-scene -> meanwhile child), plus a pending-digest queue. +-- +-- Existing scenes table (0007) has columns: +-- id, chat_id, container_id, started_at, ended_at, significance, +-- participants_json +-- It has no `status` / `closed_at` columns. We treat `ended_at IS NULL` as +-- "active" and `ended_at IS NOT NULL` as "closed" — consistent with the +-- existing scene_opened/scene_closed handlers. + +ALTER TABLE scenes ADD COLUMN present_set_kind TEXT NOT NULL DEFAULT 'you_host'; +ALTER TABLE scenes ADD COLUMN parent_scene_id INTEGER; + +CREATE INDEX scenes_present_set_idx + ON scenes(chat_id, present_set_kind, ended_at); + +CREATE TABLE meanwhile_digest_pending ( + id INTEGER PRIMARY KEY, + scene_id INTEGER NOT NULL, + chat_id TEXT NOT NULL, + summary TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + consumed_at TEXT +); + +CREATE INDEX meanwhile_digest_chat_idx + ON meanwhile_digest_pending(chat_id) WHERE consumed_at IS NULL; diff --git a/chat/state/meanwhile.py b/chat/state/meanwhile.py new file mode 100644 index 0000000..fa6d770 --- /dev/null +++ b/chat/state/meanwhile.py @@ -0,0 +1,164 @@ +"""Meanwhile-scene projection (T63). + +A "meanwhile" scene is a 2-bot scene where ``present_set = {host_bot_id, +guest_bot_id}`` and "you" is absent. It runs alongside an active you-scene +(its parent) so bots can have private interactions whose outcome later +surfaces back to the you-scene as a pending digest. + +The underlying ``scenes`` table (migration 0007) has no explicit ``status`` +column; "active" is encoded as ``ended_at IS NULL`` and "closed" as +``ended_at IS NOT NULL``. This module preserves that convention and adds +two new columns introduced by migration 0011: + +- ``present_set_kind`` — ``'you_host'`` (default) for normal scenes, + ``'host_guest'`` for meanwhile child scenes. +- ``parent_scene_id`` — the you-scene a meanwhile child hangs off of. + +Pending meanwhile digests live in their own table +(``meanwhile_digest_pending``) and are consumed when their summary is +surfaced in the next you-scene's prompt. +""" +from __future__ import annotations +import json +from sqlite3 import Connection + +from chat.eventlog.projector import on +from chat.eventlog.log import Event + + +@on("meanwhile_scene_started") +def _apply_meanwhile_scene_started(conn: Connection, e: Event) -> None: + """Insert a new scenes row for the meanwhile child. + + The caller supplies an explicit ``scene_id`` so subsequent events + (close, digest) can reference it without round-tripping through + ``lastrowid``. + """ + p = e.payload + conn.execute( + "INSERT INTO scenes (" + "id, chat_id, started_at, ended_at, significance, " + "participants_json, present_set_kind, parent_scene_id" + ") VALUES (?, ?, ?, NULL, 0, ?, 'host_guest', ?)", + ( + p["scene_id"], + p["chat_id"], + p.get("started_at"), + # participants_json mirrors the present_set: host + guest bot. + # json.dumps ensures bot ids with quotes/backslashes can't corrupt the JSON literal. + json.dumps([p["host_bot_id"], p["guest_bot_id"]]), + p["parent_scene_id"], + ), + ) + + +@on("meanwhile_scene_closed") +def _apply_meanwhile_scene_closed(conn: Connection, e: Event) -> None: + """Mark the meanwhile scene closed by stamping ``ended_at``.""" + p = e.payload + conn.execute( + "UPDATE scenes SET ended_at = ? " + "WHERE id = ? AND present_set_kind = 'host_guest' " + "AND ended_at IS NULL", + (p.get("closed_at"), p["scene_id"]), + ) + + +@on("meanwhile_digest_created") +def _apply_meanwhile_digest_created(conn: Connection, e: Event) -> None: + """Queue a digest for surfacing to the next you-scene's prompt.""" + p = e.payload + conn.execute( + "INSERT INTO meanwhile_digest_pending (scene_id, chat_id, summary) " + "VALUES (?, ?, ?)", + (p["scene_id"], p["chat_id"], p["summary"]), + ) + + +@on("meanwhile_digest_consumed") +def _apply_meanwhile_digest_consumed(conn: Connection, e: Event) -> None: + """Mark a pending digest as consumed (idempotent on re-projection).""" + p = e.payload + conn.execute( + "UPDATE meanwhile_digest_pending SET consumed_at = ? " + "WHERE id = ? AND consumed_at IS NULL", + (p.get("consumed_at"), p["digest_id"]), + ) + + +def _scene_row_to_dict(row: tuple) -> dict: + """Shape a meanwhile-scene row. + + ``status`` is derived from ``ended_at`` for callers that prefer the + higher-level vocabulary; ``closed_at`` aliases ``ended_at`` for the + same reason. The underlying column remains ``ended_at``. + """ + ended_at = row[5] + return { + "id": row[0], + "chat_id": row[1], + "started_at": row[2], + "present_set_kind": row[3], + "parent_scene_id": row[4], + "closed_at": ended_at, + "status": "closed" if ended_at is not None else "active", + } + + +def list_meanwhile_scenes( + conn: Connection, chat_id: str, status: str = "active" +) -> list[dict]: + """Return meanwhile scenes for ``chat_id`` filtered by derived status.""" + if status == "active": + ended_clause = "s.ended_at IS NULL" + elif status == "closed": + ended_clause = "s.ended_at IS NOT NULL" + else: + raise ValueError(f"unknown status: {status!r}") + rows = conn.execute( + "SELECT s.id, s.chat_id, s.started_at, s.present_set_kind, " + "s.parent_scene_id, s.ended_at " + "FROM scenes s " + "WHERE s.chat_id = ? AND s.present_set_kind = 'host_guest' " + f"AND {ended_clause} " + "ORDER BY s.id ASC", + (chat_id,), + ).fetchall() + return [_scene_row_to_dict(r) for r in rows] + + +def get_parent_scene(conn: Connection, scene_id: int) -> dict | None: + """Given a meanwhile scene id, return its parent (you-scene) row.""" + row = conn.execute( + "SELECT s.id, s.chat_id, s.started_at, s.present_set_kind, " + "s.parent_scene_id, s.ended_at " + "FROM scenes s JOIN scenes m ON m.parent_scene_id = s.id " + "WHERE m.id = ?", + (scene_id,), + ).fetchone() + if row is None: + return None + return _scene_row_to_dict(row) + + +def list_pending_meanwhile_digests( + conn: Connection, chat_id: str +) -> list[dict]: + """Return digests for ``chat_id`` that haven't been consumed yet.""" + rows = conn.execute( + "SELECT id, scene_id, chat_id, summary, created_at " + "FROM meanwhile_digest_pending " + "WHERE chat_id = ? AND consumed_at IS NULL " + "ORDER BY id ASC", + (chat_id,), + ).fetchall() + return [ + { + "id": r[0], + "scene_id": r[1], + "chat_id": r[2], + "summary": r[3], + "created_at": r[4], + } + for r in rows + ] diff --git a/tests/test_meanwhile_state.py b/tests/test_meanwhile_state.py new file mode 100644 index 0000000..1213821 --- /dev/null +++ b/tests/test_meanwhile_state.py @@ -0,0 +1,269 @@ +from __future__ import annotations + +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations +from chat.eventlog.log import append_event +from chat.eventlog.projector import project +import chat.state.entities # registers handlers +import chat.state.world # registers handlers +import chat.state.meanwhile # registers handlers +from chat.state.meanwhile import ( + get_parent_scene, + list_meanwhile_scenes, + list_pending_meanwhile_digests, +) +from chat.state.world import active_scene + + +def _bot_payload(bot_id: str, name: str) -> dict: + return { + "id": bot_id, + "name": name, + "persona": "thoughtful, observant", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "", + } + + +def _chat_payload(chat_id: str = "chat_bot_a") -> dict: + return { + "id": chat_id, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1 evening", + "weather": "clear", + } + + +def test_meanwhile_started_creates_scene_with_correct_present_set_kind(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + 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="chat_created", payload=_chat_payload()) + # Parent (you-scene) — uses existing scene_opened handler. Will get + # the default present_set_kind='you_host' from the new column. + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a", "bot_b"], + }, + ) + # Now the meanwhile child scene — bot_a + bot_b only. + append_event( + conn, + kind="meanwhile_scene_started", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "parent_scene_id": 1, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "started_at": "2026-04-26T20:05:00+00:00", + }, + ) + project(conn) + + meanwhile_scenes = list_meanwhile_scenes( + conn, "chat_bot_a", status="active" + ) + assert len(meanwhile_scenes) == 1 + m = meanwhile_scenes[0] + assert m["id"] == 2 + assert m["chat_id"] == "chat_bot_a" + assert m["status"] == "active" + assert m["present_set_kind"] == "host_guest" + assert m["parent_scene_id"] == 1 + assert m["started_at"] == "2026-04-26T20:05:00+00:00" + assert m["closed_at"] is None + + # Parent linkage helper. + parent = get_parent_scene(conn, 2) + assert parent is not None + assert parent["id"] == 1 + assert parent["present_set_kind"] == "you_host" + + +def test_meanwhile_closed_marks_scene_closed(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + 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="chat_created", payload=_chat_payload()) + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a", "bot_b"], + }, + ) + append_event( + conn, + kind="meanwhile_scene_started", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "parent_scene_id": 1, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "started_at": "2026-04-26T20:05:00+00:00", + }, + ) + append_event( + conn, + kind="meanwhile_scene_closed", + payload={ + "scene_id": 2, + "closed_at": "2026-04-26T20:15:00+00:00", + }, + ) + project(conn) + + assert list_meanwhile_scenes(conn, "chat_bot_a", status="active") == [] + closed = list_meanwhile_scenes(conn, "chat_bot_a", status="closed") + assert len(closed) == 1 + assert closed[0]["id"] == 2 + assert closed[0]["status"] == "closed" + assert closed[0]["closed_at"] == "2026-04-26T20:15:00+00:00" + + +def test_active_you_scene_can_coexist_with_active_meanwhile_scene(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + 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="chat_created", payload=_chat_payload()) + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a", "bot_b"], + }, + ) + append_event( + conn, + kind="meanwhile_scene_started", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "parent_scene_id": 1, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "started_at": "2026-04-26T20:05:00+00:00", + }, + ) + project(conn) + + # The you-scene is still the active scene from the chat's POV. + you_scene = active_scene(conn, "chat_bot_a") + assert you_scene is not None + assert you_scene["id"] == 1 + + # And the meanwhile child is independently active. + meanwhile_scenes = list_meanwhile_scenes( + conn, "chat_bot_a", status="active" + ) + assert len(meanwhile_scenes) == 1 + assert meanwhile_scenes[0]["id"] == 2 + assert meanwhile_scenes[0]["present_set_kind"] == "host_guest" + + # Cross-check via raw query: one row per present_set_kind, both unended. + rows = conn.execute( + "SELECT id, present_set_kind FROM scenes " + "WHERE chat_id = ? AND ended_at IS NULL ORDER BY id", + ("chat_bot_a",), + ).fetchall() + kinds = sorted(r[1] for r in rows) + assert kinds == ["host_guest", "you_host"] + + +def test_meanwhile_digest_created_and_consumed(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + 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="chat_created", payload=_chat_payload()) + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a", "bot_b"], + }, + ) + append_event( + conn, + kind="meanwhile_scene_started", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "parent_scene_id": 1, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "started_at": "2026-04-26T20:05:00+00:00", + }, + ) + append_event( + conn, + kind="meanwhile_digest_created", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "summary": "BotA confides in BotB about the missing file.", + }, + ) + project(conn) + + pending = list_pending_meanwhile_digests(conn, "chat_bot_a") + assert len(pending) == 1 + digest_id = pending[0]["id"] + assert pending[0]["scene_id"] == 2 + assert pending[0]["summary"].startswith("BotA confides") + + # Use append_and_apply for the second beat: re-running project() + # would re-fire non-idempotent handlers (chat_created, scene_opened) + # whose INSERTs conflict on UNIQUE constraints. + from chat.eventlog.log import append_and_apply + + append_and_apply( + conn, + kind="meanwhile_digest_consumed", + payload={ + "digest_id": digest_id, + "consumed_at": "2026-04-26T20:30:00+00:00", + }, + ) + + assert list_pending_meanwhile_digests(conn, "chat_bot_a") == [] diff --git a/tests/test_world.py b/tests/test_world.py index cff65f4..6934e12 100644 --- a/tests/test_world.py +++ b/tests/test_world.py @@ -324,11 +324,11 @@ def test_get_scene_returns_none_for_missing(tmp_path): assert active_scene(conn, "chat_missing") is None -def test_schema_version_after_migration_is_10(tmp_path): +def test_schema_version_after_migration_is_11(tmp_path): db = tmp_path / "t.db" apply_migrations(db) with open_db(db) as conn: row = conn.execute( "SELECT value FROM meta WHERE key = 'schema_version'" ).fetchone() - assert int(row[0]) == 10 + assert int(row[0]) == 11 -- 2.52.0 From a781732ee6e2fe589d0bcb512cdd2602b2c7abd4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:59:35 -0400 Subject: [PATCH 17/22] feat: meanwhile summary digest surfaces to next you-scene (T65) --- chat/services/prompt.py | 136 ++++++++++++- chat/services/scene_summarize.py | 53 ++++- tests/test_per_pov_summary.py | 323 +++++++++++++++++++++++++++++++ 3 files changed, 502 insertions(+), 10 deletions(-) diff --git a/chat/services/prompt.py b/chat/services/prompt.py index 6f836dc..2fb6429 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -39,6 +39,7 @@ from chat.state.edges import get_edge, list_edges_for from chat.state.entities import get_bot, get_you from chat.state.events import list_active_events from chat.state.group_node import get_group_node +from chat.state.meanwhile import list_pending_meanwhile_digests from chat.state.memory import search_memories from chat.state.threads import list_open_threads from chat.state.world import ( @@ -277,6 +278,31 @@ def _build_active_events_block(events: list[dict]) -> str | None: return "\n".join(lines) +def _build_meanwhile_digests_block(digests: list[dict]) -> str | None: + """Render the ``Meanwhile while you were away:`` block for T65. + + One bullet per pending digest, formatted as ``- {summary}`` with the + summary truncated to ~200 characters per spec. Returns ``None`` when + there are no pending digests so the caller can omit the entire block. + + The block is rendered ONLY when the prompt is for a regular you-scene + (``present_set_kind != "host_guest"``); the caller filters before + constructing the digests list. + """ + if not digests: + return None + lines = ["Meanwhile while you were away:"] + for d in digests: + summary = d.get("summary") or "" + if len(summary) > 200: + summary = summary[:199] + "…" + if summary: + lines.append(f"- {summary}") + if len(lines) == 1: + return None + return "\n".join(lines) + + def _build_open_threads_block(threads: list[dict]) -> str | None: """Render the ``Open threads:`` block for Phase 3 Task 60. @@ -519,6 +545,32 @@ def assemble_narrative_prompt( list_open_threads(conn, chat_id) ) + # SHOULD-tier meanwhile digest (Phase 3 / Task 65). Only surfaces + # when the prompt is for a regular you-scene (NOT for a meanwhile + # child scene — the absent player is the audience, not the bots + # currently mid-meanwhile). We distinguish via the chat's active + # scene's ``present_set_kind``; a missing scene row defaults to a + # you-scene render so the block can still surface during the + # post-meanwhile-close transition before the next scene opens. + # + # Consumption is INTENTIONALLY left to the post_turn flow (a + # ``consume_pending_meanwhile_digests`` helper, see below) rather + # than emitted inline here. Surfacing has no side-effects; the + # caller appends ``meanwhile_digest_consumed`` after the response + # streams. This keeps prompt assembly pure and deterministic — the + # Phase 1 invariant T29's regenerate flow relies on. + meanwhile_digests_block: str | None = None + active_scene_kind: str | None = None + if chat.get("active_scene_id"): + active_sc = get_scene(conn, chat["active_scene_id"]) + if active_sc: + active_scene_kind = active_sc.get("present_set_kind") + if active_scene_kind != "host_guest": + pending_digests = list_pending_meanwhile_digests(conn, chat_id) + meanwhile_digests_block = _build_meanwhile_digests_block( + pending_digests + ) + container = None if chat.get("active_scene_id"): scene = get_scene(conn, chat["active_scene_id"]) @@ -616,6 +668,7 @@ def assemble_narrative_prompt( include_group_node: bool = True, include_active_events: bool = True, include_open_threads: bool = True, + include_meanwhile_digests: bool = True, ) -> tuple[str, int, list[dict]]: # dialogue: keep the last `dialogue_keep` turns verbatim; older # turns become an "earlier:" placeholder line. @@ -653,6 +706,10 @@ def assemble_narrative_prompt( group_node_block if include_group_node else None, active_events_block if include_active_events else None, open_threads_block if include_open_threads else None, + ( + meanwhile_digests_block + if include_meanwhile_digests else None + ), prev_block, memories_block, dialogue_block, @@ -674,10 +731,12 @@ def assemble_narrative_prompt( include_group_node = group_node_block is not None include_active_events = active_events_block is not None include_open_threads = open_threads_block is not None + include_meanwhile_digests = meanwhile_digests_block is not None def _build(*, prev: bool, mem_k: int, dlg: int, other: bool, you_act: bool, guest_act: bool, group: bool, - events: bool, threads: bool) -> tuple[str, int]: + events: bool, threads: bool, + digests: bool) -> tuple[str, int]: body, total, _ = assemble( include_other_edges=other, include_previous_scene=prev, @@ -688,6 +747,7 @@ def assemble_narrative_prompt( include_group_node=group, include_active_events=events, include_open_threads=threads, + include_meanwhile_digests=digests, ) return body, total @@ -696,6 +756,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) # If under soft, we're done. @@ -731,6 +792,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -742,6 +804,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -753,6 +816,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if total <= budget_soft: return _emit(body, user_turn_prose) @@ -765,18 +829,32 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) # Drop SHOULD-tier extras in order: - # 1. open threads block (T60: SHOULD-tier; least critical to the - # speaker's immediate voice — drop first among SHOULD) - # 2. active events block (T60: same tier, drops next) - # 3. guest activity bullet (T71.2: bullet-level trim within the + # 1. meanwhile digests block (T65: SHOULD-tier; refers to a past + # meanwhile scene — least critical to the speaker's immediate + # voice, so dropped first among SHOULD) + # 2. open threads block (T60: SHOULD-tier; least critical to the + # speaker's immediate voice — drop next among SHOULD) + # 3. active events block (T60: same tier, drops next) + # 4. guest activity bullet (T71.2: bullet-level trim within the # single ACTIVITIES: block — guest goes first per Task 43 spec) - # 4. group node block - # 5. you activity bullet (still SHOULD-tier; speaker bullet is the + # 5. group node block + # 6. you activity bullet (still SHOULD-tier; speaker bullet is the # MUST-tier floor and never dropped) - # 6. other edges + # 7. other edges + if include_meanwhile_digests and total > budget_hard: + include_meanwhile_digests = False + body, total = _build( + prev=include_prev, mem_k=nice_memories_k, dlg=nice_dialogue_keep, + other=include_other, you_act=include_you_activity, + guest_act=include_guest_activity, group=include_group_node, + events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, + ) + if include_open_threads and total > budget_hard: include_open_threads = False body, total = _build( @@ -784,6 +862,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_active_events and total > budget_hard: @@ -793,6 +872,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_guest_activity and total > budget_hard: @@ -802,6 +882,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_group_node and total > budget_hard: @@ -811,6 +892,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_you_activity and total > budget_hard: @@ -820,6 +902,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if include_other and total > budget_hard: @@ -829,6 +912,7 @@ def assemble_narrative_prompt( other=include_other, you_act=include_you_activity, guest_act=include_guest_activity, group=include_group_node, events=include_active_events, threads=include_open_threads, + digests=include_meanwhile_digests, ) if total > budget_hard: @@ -854,4 +938,38 @@ def _emit(system_body: str, user_turn_prose: str | None) -> list[Message]: return msgs -__all__ = ["assemble_narrative_prompt"] +def consume_pending_meanwhile_digests(conn: Connection, chat_id: str) -> int: + """Mark every pending meanwhile digest for ``chat_id`` as consumed. + + Called by the post_turn flow AFTER the assistant response streams, + once for the first you-turn that surfaced any pending digests. We + keep this side-effect out of :func:`assemble_narrative_prompt` so + prompt assembly stays pure (T29's regenerate flow rebuilds prompts + repeatedly without state mutation). + + Returns the number of digests consumed (0 when none were pending). + """ + from datetime import datetime, timezone + + from chat.eventlog.log import append_and_apply + + pending = list_pending_meanwhile_digests(conn, chat_id) + if not pending: + return 0 + now = datetime.now(timezone.utc).isoformat() + for d in pending: + append_and_apply( + conn, + kind="meanwhile_digest_consumed", + payload={ + "digest_id": d["id"], + "consumed_at": now, + }, + ) + return len(pending) + + +__all__ = [ + "assemble_narrative_prompt", + "consume_pending_meanwhile_digests", +] diff --git a/chat/services/scene_summarize.py b/chat/services/scene_summarize.py index fa5958f..386cf51 100644 --- a/chat/services/scene_summarize.py +++ b/chat/services/scene_summarize.py @@ -342,7 +342,7 @@ async def apply_scene_close_summary( from chat.state.entities import get_bot, get_you from chat.state.group_node import get_group_node from chat.state.threads import list_open_threads - from chat.state.world import get_chat + from chat.state.world import get_chat, get_scene you_entity = get_you(conn) or {"name": "you", "persona": ""} you_name = you_entity.get("name", "you") or "you" @@ -350,6 +350,15 @@ async def apply_scene_close_summary( chat = get_chat(conn, chat_id) or {} guest_bot_id = chat.get("guest_bot_id") + # T65: detect meanwhile child scenes via the migration-0011 + # ``present_set_kind`` column. The mechanism is a single field read + # — meanwhile scenes carry ``"host_guest"``, regular you-scenes + # carry the default ``"you_host"``. We read this once up front so + # both the dialogue source and the post-summary digest emission + # branches can reference it. + closing_scene = get_scene(conn, scene_id) or {} + is_meanwhile = closing_scene.get("present_set_kind") == "host_guest" + dialogue = _read_recent_dialogue(conn, chat_id) # T58.1: build the "Key quotes:" suffix BEFORE the per-POV rewrites @@ -415,6 +424,36 @@ async def apply_scene_close_summary( }, ) + # T65: when the closing scene was a meanwhile child (host_guest + # present set), generate an omniscient briefing for the absent + # "you" and queue it as a pending digest. We reuse summarize_scene + # with a narrator persona so the digest text is shaped by the same + # classifier — only the ``summary`` field is consumed downstream. + # Emitted AFTER per-POV summaries land so witness memories carry + # their own POV text first; this mirrors how group_node_updated is + # ordered relative to the per-POV writes above. + if is_meanwhile: + digest_pov = await summarize_scene( + client, + model=classifier_model, + bot_name="Narrator", + bot_persona=_MEANWHILE_DIGEST_PERSONA, + you_name=you_name, + prior_edge_summary="", + dialogue=dialogue, + timeout_s=timeout_s, + ) + if digest_pov.summary: + append_and_apply( + conn, + kind="meanwhile_digest_created", + payload={ + "chat_id": chat_id, + "scene_id": scene_id, + "summary": digest_pov.summary, + }, + ) + # T58.2: thread detection on close. Reuses the dialogue we already # gathered for per-POV summarization — same {speaker, text} shape # detect_threads expects. Failure-tolerant: classify() returns the @@ -491,6 +530,18 @@ _GROUP_MERGE_SYSTEM = ( ) +# T65: meanwhile-scene digest. The "you" player was absent during this +# scene; the digest is a short neutral briefing they'll read on the next +# you-scene resume. Reuses the ScenePOVSummary schema so the same +# `summarize_scene` helper can be called with a different persona — only +# the ``summary`` field is used downstream. +_MEANWHILE_DIGEST_PERSONA = ( + "an omniscient narrator briefing the absent player in 2-3 neutral " + "sentences on what happened while they were away — no editorializing, " + "no second-person address" +) + + async def merge_group_summary( client: LLMClient, *, diff --git a/tests/test_per_pov_summary.py b/tests/test_per_pov_summary.py index 2453b92..6984b5c 100644 --- a/tests/test_per_pov_summary.py +++ b/tests/test_per_pov_summary.py @@ -1095,3 +1095,326 @@ async def test_thread_detection_emits_events(tmp_path, monkeypatch): open_threads = list_open_threads(conn, "chat_bot_a") assert len(open_threads) == 1 assert open_threads[0]["title"] == "Test thread" + + +# --------------------------------------------------------------------------- +# T65: meanwhile summary digest emitted on meanwhile-scene close, surfaced in +# the next you-scene's prompt as a SHOULD-tier "Meanwhile while you were away:" +# block, then consumed so it never re-renders. +# --------------------------------------------------------------------------- + + +def _seed_meanwhile_scene(conn) -> None: + """Seed a parent you-scene + a meanwhile child scene with one assistant + turn so apply_scene_close_summary has dialogue to summarize. + + The meanwhile scene id is 2 (parent is scene 1). The meanwhile dialogue + is appended via assistant_turn events under chat_bot_a; the + _read_recent_dialogue helper picks them up by chat_id. + """ + import chat.state.meanwhile # noqa: F401 -- register handlers + + 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": "engineer"}, + ) + append_event( + conn, + kind="chat_created", + payload={ + "id": "chat_bot_a", + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1", + "weather": "", + }, + ) + append_event( + conn, + kind="container_created", + payload={ + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": {}, + }, + ) + # Parent you-scene (scene_id=1). + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a", "bot_b"], + }, + ) + # Meanwhile child scene (scene_id=2) — bot_a + bot_b only. + append_event( + conn, + kind="meanwhile_scene_started", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "parent_scene_id": 1, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "started_at": "2026-04-26T20:05:00+00:00", + }, + ) + # Edges so per-POV apply has rows to update. + append_event( + conn, + kind="edge_update", + payload={ + "source_id": "bot_a", + "target_id": "you", + "chat_id": "chat_bot_a", + }, + ) + append_event( + conn, + kind="edge_update", + payload={ + "source_id": "bot_b", + "target_id": "you", + "chat_id": "chat_bot_a", + }, + ) + # One memory per witness in the meanwhile scene. + append_event( + conn, + kind="memory_written", + payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "scene_id": 2, + "pov_summary": "Original raw narrative (host, meanwhile)", + "witness_you": 0, + "witness_host": 1, + "witness_guest": 1, + "significance": 1, + }, + ) + append_event( + conn, + kind="memory_written", + payload={ + "owner_id": "bot_b", + "chat_id": "chat_bot_a", + "scene_id": 2, + "pov_summary": "Original raw narrative (guest, meanwhile)", + "witness_you": 0, + "witness_host": 1, + "witness_guest": 1, + "significance": 1, + }, + ) + # A bot-bot turn happens during the meanwhile scene. + append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_bot_a", + "speaker_id": "bot_a", + "text": "Did you hear what happened with the missing file?", + "truncated": False, + "user_turn_id": None, + }, + ) + append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_bot_a", + "speaker_id": "bot_b", + "text": "I have a theory but no proof yet.", + "truncated": False, + "user_turn_id": None, + }, + ) + + +@pytest.mark.asyncio +async def test_meanwhile_close_creates_digest(tmp_path): + """When apply_scene_close_summary runs on a meanwhile scene + (present_set_kind == 'host_guest'), it emits a meanwhile_digest_created + event after the per-POV summaries land; the meanwhile_digest_pending + table then holds a row with non-empty summary text.""" + db = tmp_path / "t.db" + apply_migrations(db) + host_canned = json.dumps( + { + "summary": "BotA confided in BotB about the missing file.", + "knowledge_facts": [], + "relationship_summary": "BotA leaned on BotB.", + } + ) + guest_canned = json.dumps( + { + "summary": "BotB listened and offered to help investigate.", + "knowledge_facts": [], + "relationship_summary": "BotB grew protective.", + } + ) + digest_canned = json.dumps( + { + "summary": ( + "While you were away, BotA confided in BotB about a " + "missing file; BotB offered to help quietly investigate." + ), + "knowledge_facts": [], + "relationship_summary": "", + } + ) + no_threads = json.dumps({"candidates": []}) + with open_db(db) as conn: + _seed_meanwhile_scene(conn) + project(conn) + + # Order: host POV summary, guest POV summary, digest summary, + # thread detection. + client = MockLLMClient( + canned=[host_canned, guest_canned, digest_canned, no_threads] + ) + await apply_scene_close_summary( + conn, + client, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=2, + host_bot_id="bot_a", + ) + + # The meanwhile_digest_pending row was written. + from chat.state.meanwhile import list_pending_meanwhile_digests + + pending = list_pending_meanwhile_digests(conn, "chat_bot_a") + assert len(pending) == 1 + assert pending[0]["scene_id"] == 2 + assert pending[0]["summary"] + assert "missing file" in pending[0]["summary"] + + # And the meanwhile_digest_created event was logged. + rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'meanwhile_digest_created'" + ).fetchall() + assert len(rows) == 1 + payload = json.loads(rows[0][0]) + assert payload["chat_id"] == "chat_bot_a" + assert payload["scene_id"] == 2 + assert "missing file" in payload["summary"] + + +def test_pending_digest_renders_in_you_scene_prompt(tmp_path): + """A pending meanwhile digest (created via direct event append) renders + as a 'Meanwhile while you were away:' SHOULD-tier block in the + assembled you-scene narrative prompt.""" + from chat.eventlog.log import append_and_apply + from chat.services.prompt import assemble_narrative_prompt + import chat.state.meanwhile # noqa: F401 -- register handlers + import chat.state.threads # noqa: F401 + import chat.state.events # noqa: F401 + + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_single_bot_scene(conn) + project(conn) + + digest_text = ( + "While you were away, BotA confided in BotB about a missing file." + ) + append_and_apply( + conn, + kind="meanwhile_digest_created", + payload={ + "chat_id": "chat_bot_a", + "scene_id": 2, + "summary": digest_text, + }, + ) + + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body = msgs[0].content + assert "Meanwhile while you were away:" in body + assert digest_text in body + + +def test_consumed_digest_does_not_render_again(tmp_path): + """After meanwhile_digest_consumed lands for a digest, reassembling the + you-scene prompt must NOT include that digest's text — the pending + list is filtered by ``consumed_at IS NULL``.""" + from chat.eventlog.log import append_and_apply + from chat.services.prompt import assemble_narrative_prompt + import chat.state.meanwhile # noqa: F401 -- register handlers + import chat.state.threads # noqa: F401 + import chat.state.events # noqa: F401 + + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_single_bot_scene(conn) + project(conn) + + digest_text = ( + "While you were away, BotA confided in BotB about a missing file." + ) + append_and_apply( + conn, + kind="meanwhile_digest_created", + payload={ + "chat_id": "chat_bot_a", + "scene_id": 2, + "summary": digest_text, + }, + ) + + # Sanity: it renders before consumption. + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + assert digest_text in msgs[0].content + + # Look up the pending digest id, then consume it. + from chat.state.meanwhile import list_pending_meanwhile_digests + + pending = list_pending_meanwhile_digests(conn, "chat_bot_a") + assert len(pending) == 1 + digest_id = pending[0]["id"] + + append_and_apply( + conn, + kind="meanwhile_digest_consumed", + payload={ + "digest_id": digest_id, + "consumed_at": "2026-04-26T20:30:00+00:00", + }, + ) + + msgs2 = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body2 = msgs2[0].content + assert "Meanwhile while you were away:" not in body2 + assert digest_text not in body2 -- 2.52.0 From cf43ba09930e0e631c866c185dc2c9b0470e4710 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 21:00:56 -0400 Subject: [PATCH 18/22] feat: meanwhile turn flow (host+guest, no you) (T64) --- chat/services/memory_write.py | 57 +++ chat/services/prompt.py | 26 +- chat/web/meanwhile.py | 398 +++++++++++++++++++++ chat/web/turns.py | 25 ++ tests/test_meanwhile_turn_flow.py | 560 ++++++++++++++++++++++++++++++ 5 files changed, 1061 insertions(+), 5 deletions(-) create mode 100644 chat/web/meanwhile.py create mode 100644 tests/test_meanwhile_turn_flow.py diff --git a/chat/services/memory_write.py b/chat/services/memory_write.py index 0a6f9b1..6fc6ecf 100644 --- a/chat/services/memory_write.py +++ b/chat/services/memory_write.py @@ -176,3 +176,60 @@ def record_turn_memory_for_present( significance=significance, ) return result + + +def record_meanwhile_memory( + conn: Connection, + *, + chat_id: str, + host_bot_id: str, + guest_bot_id: str, + narrative_text: str, + scene_id: int | None = None, + chat_clock_at: str | None = None, + source: str = "direct", + significance: int = 1, +) -> dict[str, tuple[int, int | None]]: + """Write per-POV ``memory_written`` events for a meanwhile turn (T64). + + A meanwhile scene runs entirely between host + guest, with "you" + absent. Both bots are present witnesses, so each one gets a row with + witness flags ``[you=0, host=1, guest=1]`` — different from the + normal-turn ``record_turn_memory_for_present`` shape, which assumes + the user is always a witness (``witness_you=1``). + + The ``guest_bot_id`` is required (a meanwhile scene by definition + has both bots) — callers passing ``None`` is a programming error. + + Returns ``{bot_id: (event_id, memory_id)}`` mirroring + :func:`record_turn_memory_for_present` so downstream queues + (significance scoring) can pull memory ids without re-querying. + """ + result: dict[str, tuple[int, int | None]] = {} + result[host_bot_id] = _write_one_memory( + conn, + owner_id=host_bot_id, + chat_id=chat_id, + narrative_text=narrative_text, + witness_you=0, + witness_host=1, + witness_guest=1, + scene_id=scene_id, + chat_clock_at=chat_clock_at, + source=source, + significance=significance, + ) + result[guest_bot_id] = _write_one_memory( + conn, + owner_id=guest_bot_id, + chat_id=chat_id, + narrative_text=narrative_text, + witness_you=0, + witness_host=1, + witness_guest=1, + scene_id=scene_id, + chat_clock_at=chat_clock_at, + source=source, + significance=significance, + ) + return result diff --git a/chat/services/prompt.py b/chat/services/prompt.py index 6f836dc..eeb5bf2 100644 --- a/chat/services/prompt.py +++ b/chat/services/prompt.py @@ -393,6 +393,7 @@ def assemble_narrative_prompt( budget_hard: int = 8000, encoding_name: str = "cl100k_base", guest_id: str | None = None, + present_set_kind: str = "you_host", ) -> list[Message]: """Assemble the narrative prompt for ``speaker_bot_id`` to respond. @@ -431,6 +432,14 @@ def assemble_narrative_prompt( you = get_you(conn) addressee_id, addressee_name = _resolve_addressee(conn, addressee, you) + # T64: meanwhile-mode marker. When present_set_kind == "host_guest" + # the user ("you") is NOT a witness in the scene — bots speak only to + # each other. The local flag below is consumed by the activity-block + # builder (skip the "you" bullet entirely) and the other-edges filter + # (drop any speaker -> "you" rendering). Default "you_host" preserves + # the Phase 1/2/3 behavior for normal turns. + _exclude_you = present_set_kind == "host_guest" + # ---- Build all components as text strings ------------------------------ speaker_identity = _build_speaker_identity(bot) @@ -453,10 +462,11 @@ def assemble_narrative_prompt( # header that Phase 2 T43 introduced (read by some LLMs as a # duplicate-section bug). you_activity: dict | None = None - you_act = get_activity(conn, "you") - if you_act is not None: - you_activity = dict(you_act) - you_activity["_display_name"] = (you or {}).get("name") or "you" + if not _exclude_you: + you_act = get_activity(conn, "you") + if you_act is not None: + you_activity = dict(you_act) + you_activity["_display_name"] = (you or {}).get("name") or "you" speaker_activity: dict | None = None bot_act = get_activity(conn, speaker_bot_id) @@ -530,9 +540,15 @@ def assemble_narrative_prompt( container = get_container(conn, scene["container_id"]) scene_block = _build_scene_block(chat, container, scene) - # Other edges: speaker → non-addressee. + # Other edges: speaker → non-addressee. In meanwhile mode (host_guest) + # the speaker -> "you" edge is filtered out as well — "you" isn't + # part of the present set, so surfacing the speaker's relationship + # to the user inside a private bot-to-bot beat would leak context + # the bots aren't supposed to be drawing on right now. all_outgoing = list_edges_for(conn, speaker_bot_id) other_edges_raw = [e for e in all_outgoing if e.get("target_id") != addressee_id] + if _exclude_you: + other_edges_raw = [e for e in other_edges_raw if e.get("target_id") != "you"] for e in other_edges_raw: tid = e.get("target_id") if tid == "you": diff --git a/chat/web/meanwhile.py b/chat/web/meanwhile.py new file mode 100644 index 0000000..1b04a73 --- /dev/null +++ b/chat/web/meanwhile.py @@ -0,0 +1,398 @@ +"""Meanwhile-mode turn controller (T64). + +A meanwhile scene is a private 2-bot scene running alongside an active +you-scene (its parent). The user manually advances it by POSTing to the +existing ``/chats//turns`` endpoint; the route detects an active +meanwhile scene at the start of ``post_turn`` and dispatches here. + +Unlike the normal turn flow, "you" is NOT a witness to the scene. The +controller mirrors ``post_turn`` shape but with: + +- Speaker alternation derived from the latest meanwhile ``assistant_turn`` + scoped to this scene_id (host first, then alternating). +- Prompt assembly with ``present_set_kind="host_guest"`` so the prompt + builder drops the "you" activity bullet and any speaker -> "you" edge. +- Memory writes via ``record_meanwhile_memory`` — both bots get rows + with witness flags ``[you=0, host=1, guest=1]``. +- State updates over exactly 2 directed pairs (host <-> guest); no + you-related pairs fire. +- The ``assistant_turn`` payload carries ``meanwhile_scene_id`` and + ``present_set_kind="host_guest"`` so downstream filters (alternation + lookup, drawer rendering, scene-close detection) can scope to the + meanwhile slice without conflating it with the parent you-scene's + history. + +Scene-close detection for meanwhile scenes is not auto-fired here — +T65 covers the close + digest pipeline. The controller's job ends +after the post-turn classifier passes land. +""" + +from __future__ import annotations + +import asyncio +import json + +from chat.config import Settings +from chat.eventlog.log import append_and_apply, append_event +from chat.llm.client import LLMClient +from chat.services.memory_write import record_meanwhile_memory +from chat.services.multi_state_update import compute_state_updates_for_present +from chat.services.prompt import assemble_narrative_prompt +from chat.services.turn_parse import parse_turn +from chat.state.edges import get_edge +from chat.state.entities import get_bot +from chat.state.meanwhile import list_meanwhile_scenes +from chat.state.world import get_chat +from chat.web.pubsub import publish +from chat.web.render import render_turn_html as _render_turn_html + + +def _strip_ooc_for_prompt(parsed) -> str: + """Mirror of the helper in turns.py — concatenate non-OOC segments.""" + keep = [s.text for s in parsed.segments if s.kind != "ooc"] + return " ".join(keep).strip() + + +def _read_recent_meanwhile_dialogue( + conn, chat_id: str, scene_id: int, limit: int = 50 +) -> list[dict]: + """Return the meanwhile scene's prior turns shaped as + ``{"speaker": , "text": }``. + + Pulls ``user_turn`` rows for the chat (the user-side prose driving + this meanwhile scene rides through the same chat) plus only those + ``assistant_turn`` rows whose ``meanwhile_scene_id`` matches the + given scene id. Other meanwhile scenes on the same chat — and the + parent you-scene's assistant_turns — are excluded so the prompt + context stays scoped to the private beat. + + Filters chat_id (and meanwhile_scene_id for assistant_turn) via + ``json_extract`` in SQL so SQLite stops at the first ``limit`` rows + that already match — avoids an unbounded scan as ``event_log`` + grows. The user-side rows match on chat_id only since they aren't + tagged with a scene id (they ride the chat-wide log). + """ + cur = conn.execute( + "SELECT id, kind, payload_json FROM event_log " + "WHERE kind IN ('user_turn', 'user_turn_edit', 'assistant_turn') " + " AND superseded_by IS NULL AND hidden = 0 " + " AND json_extract(payload_json, '$.chat_id') = ? " + " AND (" + " kind IN ('user_turn', 'user_turn_edit') " + " OR json_extract(payload_json, '$.meanwhile_scene_id') = ?" + " ) " + "ORDER BY id DESC LIMIT ?", + (chat_id, scene_id, limit), + ) + rows = cur.fetchall() + rows.reverse() + out: list[dict] = [] + for _row_id, kind, payload_json in rows: + p = json.loads(payload_json) + if kind in ("user_turn", "user_turn_edit"): + out.append({"speaker": "you", "text": p.get("prose", "")}) + else: + out.append( + { + "speaker": p.get("speaker_id", "bot"), + "text": p.get("text", ""), + } + ) + return out + + +def _last_meanwhile_speaker(conn, chat_id: str, scene_id: int) -> str | None: + """Return the speaker_id of the latest assistant_turn linked to + ``scene_id`` for ``chat_id``, or ``None`` if no prior turn exists. + + Used to alternate the speaker across consecutive meanwhile turns — + the OTHER bot speaks next. Pushes both filters into SQL via + ``json_extract`` and bounds with ``LIMIT 1`` so SQLite stops at the + first match instead of scanning the whole assistant_turn slice. + """ + row = conn.execute( + "SELECT json_extract(payload_json, '$.speaker_id') AS speaker " + "FROM event_log " + "WHERE kind = 'assistant_turn' " + " AND superseded_by IS NULL AND hidden = 0 " + " AND json_extract(payload_json, '$.chat_id') = ? " + " AND json_extract(payload_json, '$.meanwhile_scene_id') = ? " + "ORDER BY id DESC " + "LIMIT 1", + (chat_id, scene_id), + ).fetchone() + return row[0] if row else None + + +async def process_meanwhile_turn( + conn, + client: LLMClient, + settings: Settings, + *, + chat_id: str, + prose: str, +) -> dict: + """Run one meanwhile turn end-to-end. + + Returns a small dict shape ``{"assistant_text": ..., "speaker_id": + ..., "scene_id": ..., "user_turn_id": ..., "assistant_event_id": + ...}`` so callers can introspect the produced beat (HTTP route maps + to a ``204``; future SSE rebroadcast may use the dict directly). + + Raises ``ValueError`` when there is no active meanwhile scene on + ``chat_id`` — the caller (turns.py) only dispatches here after a + positive ``list_meanwhile_scenes`` lookup, but the defensive raise + keeps the controller usable in isolation. + """ + chat = get_chat(conn, chat_id) + if chat is None: + raise ValueError(f"chat not found: {chat_id}") + + scenes = list_meanwhile_scenes(conn, chat_id, status="active") + if not scenes: + raise ValueError(f"no active meanwhile scene on chat: {chat_id}") + scene = scenes[0] + scene_id = scene["id"] + + host_bot_id = chat["host_bot_id"] + guest_bot_id = chat.get("guest_bot_id") + if guest_bot_id is None: + # A meanwhile scene without a guest is a schema violation — + # the projector requires both ids on meanwhile_scene_started. + raise ValueError( + f"meanwhile scene {scene_id} on chat {chat_id} lacks a guest" + ) + + host_bot = get_bot(conn, host_bot_id) + guest_bot = get_bot(conn, guest_bot_id) + if host_bot is None or guest_bot is None: + raise ValueError( + f"meanwhile bots missing: host={host_bot_id} guest={guest_bot_id}" + ) + + # 1. Parse the user prose with the classifier — same shape as the + # normal turn flow so OOC-stripping, segment-typing, etc. all work. + parsed = await parse_turn( + client, model=settings.classifier_model, prose=prose + ) + prompt_prose = _strip_ooc_for_prompt(parsed) + + # 2. Append user_turn — kept on the chat-wide log so the user can + # see their own prose in the timeline. Tagged with the meanwhile + # scene_id so future renderers can group it with the right scene. + user_turn_event_id = append_event( + conn, + kind="user_turn", + payload={ + "chat_id": chat_id, + "prose": prose, + "segments": [s.model_dump() for s in parsed.segments], + "meanwhile_scene_id": scene_id, + }, + ) + + # 3. Alternate the speaker. First turn -> host speaks; each + # subsequent turn -> the OTHER bot from the previous beat. Lookup + # is scoped by ``meanwhile_scene_id`` so unrelated assistant_turns + # on the same chat don't perturb the alternation. + last_speaker = _last_meanwhile_speaker(conn, chat_id, scene_id) + if last_speaker is None or last_speaker == guest_bot_id: + speaker_bot = host_bot + addressee_bot = guest_bot + else: + speaker_bot = guest_bot + addressee_bot = host_bot + + # 4. Placeholder marker so SSE observers see "in flight". No + # projector handler is registered for this kind — it's transcript- + # only, same as the normal turn flow. + append_event( + conn, + kind="assistant_turn_started", + payload={ + "chat_id": chat_id, + "speaker_id": speaker_bot["id"], + "user_turn_id": user_turn_event_id, + "meanwhile_scene_id": scene_id, + }, + ) + + # 5. Build the narrative prompt. ``present_set_kind="host_guest"`` + # tells the assembler to drop the "you" activity bullet and any + # speaker -> "you" edge — both irrelevant inside a private beat. + # Addressee is the OTHER bot, not "you". + recent_dialogue = _read_recent_meanwhile_dialogue(conn, chat_id, scene_id) + if recent_dialogue and recent_dialogue[-1].get("speaker") == "you": + recent_dialogue = recent_dialogue[:-1] + messages = assemble_narrative_prompt( + conn, + chat_id=chat_id, + speaker_bot_id=speaker_bot["id"], + addressee=addressee_bot["id"], + user_turn_prose=prompt_prose if prompt_prose else None, + recent_dialogue=recent_dialogue, + budget_soft=settings.narrative_budget_soft, + budget_hard=settings.narrative_budget_hard, + guest_id=guest_bot_id, + present_set_kind="host_guest", + ) + + # 6. Stream + accumulate. Same SSE pattern as the normal flow — + # tokens publish under the speaker's id so the UI can label the + # right bubble. Register the streaming task in the chat-keyed + # in-flight registry so POST /chats//turns/cancel can call + # ``.cancel()`` on it; without this, the Stop button is a no-op for + # meanwhile beats. We import the underscore name from turns.py + # deliberately — it's the same single-process registry the cancel + # route reads, and exposing it via a public alias would require + # touching every existing call site for no behavioural gain. + from chat.web.turns import _in_flight_tasks # noqa: PLC0415 + + accumulated: list[str] = [] + truncated = False + cancelled = False + + async def _stream() -> None: + async for chunk in client.stream( + messages, + model=settings.narrative_model, + max_tokens=settings.narrative_max_tokens, + temperature=settings.narrative_temperature, + ): + accumulated.append(chunk) + await publish( + chat_id, + { + "event": "token", + "text": chunk, + "speaker_id": speaker_bot["id"], + }, + ) + + stream_task = asyncio.create_task(_stream()) + _in_flight_tasks[chat_id] = stream_task + try: + await stream_task + except asyncio.CancelledError: + truncated = True + cancelled = True + except Exception: + truncated = True + finally: + # Always unregister so a subsequent turn can register a fresh + # task. Mirrors the cleanup in turns.py::post_turn. + _in_flight_tasks.pop(chat_id, None) + + text = "".join(accumulated) + + # 7. Append assistant_turn — tagged with meanwhile_scene_id so the + # next turn's alternation lookup can find it, and present_set_kind + # so downstream renderers / digesters can filter scope. + assistant_event_id = append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": chat_id, + "speaker_id": speaker_bot["id"], + "text": text, + "truncated": truncated, + "user_turn_id": user_turn_event_id, + "meanwhile_scene_id": scene_id, + "present_set_kind": "host_guest", + }, + ) + + # 8. Per-turn memory writes — both bots get a row with witness flags + # [you=0, host=1, guest=1]. Skipped on cancellation so we don't + # record memory for a partial beat the user never read. + if not cancelled and text.strip(): + record_meanwhile_memory( + conn, + chat_id=chat_id, + host_bot_id=host_bot_id, + guest_bot_id=guest_bot_id, + narrative_text=text, + scene_id=scene_id, + chat_clock_at=chat.get("time"), + ) + + # 9. Post-turn state-update — exactly 2 directed pairs over the + # bot pair. No you-related pairs fire (you isn't present). + present_ids = [host_bot_id, guest_bot_id] + present_names = { + host_bot_id: host_bot["name"], + guest_bot_id: guest_bot["name"], + } + personas = { + host_bot_id: host_bot.get("persona") or "", + guest_bot_id: guest_bot.get("persona") or "", + } + prior_edges: dict[tuple[str, str], dict] = {} + for src in present_ids: + for tgt in present_ids: + if src == tgt: + continue + edge = get_edge(conn, src, tgt) or { + "affinity": 50, + "trust": 50, + "summary": "", + } + prior_edges[(src, tgt)] = edge + + state_updates = await compute_state_updates_for_present( + client, + classifier_model=settings.classifier_model, + present_ids=present_ids, + present_names=present_names, + personas=personas, + prior_edges=prior_edges, + recent_dialogue=recent_dialogue, + timeout_s=settings.classifier_timeout_s, + ) + last_at = chat.get("time") + for src_id, tgt_id, update in state_updates: + append_and_apply( + conn, + kind="edge_update", + payload={ + "source_id": src_id, + "target_id": tgt_id, + "chat_id": chat_id, + "affinity_delta": update.affinity_delta, + "trust_delta": update.trust_delta, + "knowledge_facts": update.knowledge_facts, + "last_interaction_at": last_at, + "last_interaction_chat_id": chat_id, + }, + ) + + # 10. SSE broadcast for the timeline UI — completion event + an HTML + # fragment for the HTMX SSE swap. Same pattern as the normal turn + # flow so the rendered transcript shows the meanwhile beat inline. + await publish( + chat_id, + { + "event": "assistant_turn_complete", + "speaker_id": speaker_bot["id"], + "text": text, + "truncated": truncated, + }, + ) + turn_html = _render_turn_html(speaker_bot["name"], text, role="bot") + await publish(chat_id, {"event": "turn_html", "data": turn_html}) + + if cancelled: + # Re-raise after the partial-turn has been recorded so callers + # see the cancel propagate (mirrors normal turn flow). + raise asyncio.CancelledError + + return { + "assistant_text": text, + "speaker_id": speaker_bot["id"], + "scene_id": scene_id, + "user_turn_id": user_turn_event_id, + "assistant_event_id": assistant_event_id, + } + + +__all__ = ["process_meanwhile_turn"] diff --git a/chat/web/turns.py b/chat/web/turns.py index 5d2ff94..3fc51d4 100644 --- a/chat/web/turns.py +++ b/chat/web/turns.py @@ -72,9 +72,11 @@ from chat.services.turn_parse import ParsedTurn, parse_turn from chat.state.edges import get_edge from chat.state.entities import get_bot, get_you from chat.state.events import list_active_events +from chat.state.meanwhile import list_meanwhile_scenes from chat.state.world import active_scene, get_chat, get_container from chat.web.bots import get_conn from chat.web.kickoff import get_llm_client +from chat.web.meanwhile import process_meanwhile_turn from chat.web.pubsub import publish from chat.web.render import render_turn_html as _render_turn_html from chat.web.skip import _parse_iso_time, process_elision_skip @@ -251,6 +253,29 @@ async def post_turn( settings = request.app.state.settings + # 0. Meanwhile-mode short-circuit (T64). When an active meanwhile + # scene is running on this chat, the turn flow is entirely between + # the two bots — "you" is absent. The meanwhile controller mirrors + # the post_turn shape but with no-you semantics: present_set_kind + # ``host_guest`` in the prompt assembler, ``record_meanwhile_memory`` + # for witness flags, only 2 directed pairs in the state update, and + # the assistant_turn payload tagged with ``meanwhile_scene_id`` so + # alternation lookups can scope to this scene specifically. The + # T62 skip-intent dispatch and the regular narrative path below + # are skipped — a meanwhile beat is its own self-contained flow. + if list_meanwhile_scenes(conn, chat_id, status="active"): + try: + await process_meanwhile_turn( + conn, + client, + settings, + chat_id=chat_id, + prose=prose, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + return Response(status_code=204) + # 1. Parse turn (classifier). parsed = await parse_turn( client, model=settings.classifier_model, prose=prose diff --git a/tests/test_meanwhile_turn_flow.py b/tests/test_meanwhile_turn_flow.py new file mode 100644 index 0000000..a2e2783 --- /dev/null +++ b/tests/test_meanwhile_turn_flow.py @@ -0,0 +1,560 @@ +"""Meanwhile-mode turn flow (T64). + +A meanwhile scene runs entirely between two bots — host + guest — with +"you" absent. The user manually advances the scene by POSTing prose to +the existing ``/chats//turns`` endpoint; the route detects the active +meanwhile scene at the start of ``post_turn`` and dispatches to the +``process_meanwhile_turn`` controller in ``chat/web/meanwhile.py``. + +Coverage: + +1. Memory writes for a meanwhile turn carry witness ``[you=0, host=1, + guest=1]`` for both the host's and the guest's per-POV memory rows. +2. State updates after a meanwhile turn run for exactly 2 directed pairs + (host -> guest, guest -> host) — no you-related pairs fire. +3. Speakers alternate across consecutive meanwhile turns: the host + speaks first (no prior meanwhile assistant_turn), the guest speaks + second (the prior turn's speaker was the host, so this turn's + speaker is the OTHER bot). +4. Scene-close on a meanwhile scene writes per-POV summaries for host + + guest only — no "you" POV row is written, mirroring the no-you + present_set of the meanwhile scene. +""" + +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_event +from chat.eventlog.projector import project +from chat.llm.mock import MockLLMClient +import chat.state.meanwhile # noqa: F401 (registers handlers) + + +def _bot_payload(bot_id: str, name: str) -> dict: + return { + "id": bot_id, + "name": name, + "persona": f"persona for {name}", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "", + "kickoff_prose": "...", + } + + +def _seed_meanwhile_chat(db_path: Path) -> None: + """Seed two bots, you, a chat with both wired in, an open parent + you-scene, AND an active meanwhile child scene with bot_a + bot_b. + + Edges are seeded for both directed pairs between bot_a and bot_b at + schema-default 50/50 so post-turn state-update writes land cleanly. + Activities for both bots are recorded so the prompt assembler has + something to render. + """ + with open_db(db_path) 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", + "guest_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1", + "weather": "", + }, + ) + append_event( + conn, + kind="container_created", + payload={ + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": {}, + }, + ) + # Parent (you-scene) opens first. + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a", "bot_b"], + }, + ) + # Meanwhile child scene — bot_a + bot_b only, parent linked. + append_event( + conn, + kind="meanwhile_scene_started", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "parent_scene_id": 1, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "started_at": "2026-04-26T20:05:00+00:00", + }, + ) + # Seed both directed edges between the bots so state-update + # writes land on initialized rows. + for src, tgt in [("bot_a", "bot_b"), ("bot_b", "bot_a")]: + append_event( + conn, + kind="edge_update", + payload={ + "source_id": src, + "target_id": tgt, + "chat_id": "chat_bot_a", + "knowledge_facts": [], + }, + ) + for entity_id, verb in [("bot_a", "listening"), ("bot_b", "talking")]: + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": entity_id, + "posture": "sitting", + "action": { + "verb": verb, + "interruptible": True, + "required_attention": "low", + "expected_duration": "ongoing", + }, + "attention": "", + "holding": [], + "status": {}, + }, + ) + project(conn) + + +def _override_llm(canned: list[str]) -> MockLLMClient: + from chat.web.kickoff import get_llm_client + + mock = MockLLMClient(canned=list(canned)) + app.dependency_overrides[get_llm_client] = lambda: mock + return mock + + +def _zero_state() -> str: + return json.dumps( + {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} + ) + + +@pytest.fixture +def app_state_setup(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: + app.state.background_worker.enabled = False + yield c + app.dependency_overrides.clear() + + +def test_meanwhile_turn_writes_memories_with_witness_0_1_1( + app_state_setup, tmp_path +): + """A meanwhile turn writes one ``memory_written`` event per bot — host + and guest — with witness flags ``[you=0, host=1, guest=1]``. "You" is + not present in the scene, so the witness_you flag must be 0 for both + rows. + + Canned queue (4 calls): + 1. parse_turn (user prose classification) + 2. narrative stream (host speaks first; no prior meanwhile turn) + 3. state-update for bot_a -> bot_b + 4. state-update for bot_b -> bot_a + """ + _seed_meanwhile_chat(tmp_path / "test.db") + canned_parse = json.dumps( + {"segments": [{"kind": "narration", "text": "they exchange a glance"}]} + ) + canned = [ + canned_parse, + "BotA leans in. *quietly* Tell me what you saw.", + _zero_state(), + _zero_state(), + ] + mock = _override_llm(canned) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "they exchange a glance"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [] + + with open_db(tmp_path / "test.db") as conn: + rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'memory_written' " + "ORDER BY id" + ).fetchall() + payloads = [json.loads(r[0]) for r in rows] + + assert len(payloads) == 2 + owners = sorted(p["owner_id"] for p in payloads) + assert owners == ["bot_a", "bot_b"] + for p in payloads: + assert p["witness_you"] == 0, p + assert p["witness_host"] == 1, p + assert p["witness_guest"] == 1, p + + +def test_meanwhile_turn_emits_2_edge_updates_only(app_state_setup, tmp_path): + """A meanwhile turn runs state-update for exactly 2 directed pairs: + host -> guest and guest -> host. No you-related pairs fire. + """ + _seed_meanwhile_chat(tmp_path / "test.db") + canned_parse = json.dumps( + {"segments": [{"kind": "narration", "text": "they whisper"}]} + ) + canned = [ + canned_parse, + "BotA whispers. *softly* I noticed something today.", + _zero_state(), + _zero_state(), + ] + mock = _override_llm(canned) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", data={"prose": "they whisper"} + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [] + + with open_db(tmp_path / "test.db") as conn: + # Edge updates landed AFTER the assistant_turn (i.e. excluding + # the seed updates done before the request). + max_at = conn.execute( + "SELECT MAX(id) FROM event_log WHERE kind = 'assistant_turn'" + ).fetchone()[0] + rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'edge_update' AND id > ? ORDER BY id", + (max_at,), + ).fetchall() + payloads = [json.loads(r[0]) for r in rows] + + # Exactly 2 post-turn edge_update events. + assert len(payloads) == 2 + pairs = sorted((p["source_id"], p["target_id"]) for p in payloads) + assert pairs == [("bot_a", "bot_b"), ("bot_b", "bot_a")] + # And NO you-related pair leaked in. + for p in payloads: + assert p["source_id"] != "you", p + assert p["target_id"] != "you", p + + +def test_meanwhile_turn_alternates_speaker(app_state_setup, tmp_path): + """Successive meanwhile turns alternate which bot speaks. + + The first turn has no prior meanwhile ``assistant_turn`` linked to + this scene, so the host speaks. The second turn finds the latest + such ``assistant_turn``'s speaker (the host) and picks the OTHER + bot, so the guest speaks. Each ``assistant_turn`` payload carries + ``meanwhile_scene_id`` so the alternation lookup is unambiguous. + """ + _seed_meanwhile_chat(tmp_path / "test.db") + canned_parse_1 = json.dumps( + {"segments": [{"kind": "narration", "text": "they pause"}]} + ) + canned_1 = [ + canned_parse_1, + "BotA speaks first. *quietly*", + _zero_state(), + _zero_state(), + ] + mock = _override_llm(canned_1) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", data={"prose": "they pause"} + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [] + + canned_parse_2 = json.dumps( + {"segments": [{"kind": "narration", "text": "and again"}]} + ) + canned_2 = [ + canned_parse_2, + "BotB replies. *thoughtfully*", + _zero_state(), + _zero_state(), + ] + mock = _override_llm(canned_2) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", data={"prose": "and again"} + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [] + + with open_db(tmp_path / "test.db") as conn: + rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'assistant_turn' ORDER BY id" + ).fetchall() + payloads = [json.loads(r[0]) for r in rows] + + assert len(payloads) == 2 + # First turn — host speaks. + assert payloads[0]["speaker_id"] == "bot_a" + # Second turn — guest speaks (alternation). + assert payloads[1]["speaker_id"] == "bot_b" + # Both payloads tag this meanwhile scene id so the alternation + # lookup can scope to it specifically (not any other assistant_turn + # that might exist on the chat). + assert payloads[0]["meanwhile_scene_id"] == 2 + assert payloads[1]["meanwhile_scene_id"] == 2 + # Both also carry the present_set_kind discriminator for downstream + # filters (digest creation, drawer rendering). + assert payloads[0]["present_set_kind"] == "host_guest" + assert payloads[1]["present_set_kind"] == "host_guest" + + +def test_meanwhile_scene_close_writes_per_pov_for_both_bots_only( + app_state_setup, tmp_path +): + """When a meanwhile scene closes, per-POV summary rewrites land for + the host and the guest. No write fires for "you" — there is no + "you" memory store and no "you" POV in the meanwhile present set. + """ + from chat.services.scene_summarize import apply_scene_close_summary + from chat.eventlog.log import append_and_apply + + _seed_meanwhile_chat(tmp_path / "test.db") + + # Run a meanwhile turn first so each bot has a memory row scoped to + # the meanwhile scene_id (=2). The per-POV rewrite targets these + # rows by ``scene_id``. + canned_parse = json.dumps( + {"segments": [{"kind": "narration", "text": "they speak quietly"}]} + ) + canned = [ + canned_parse, + "BotA speaks. *quietly*", + _zero_state(), + _zero_state(), + ] + mock = _override_llm(canned) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "they speak quietly"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [] + + # Close the meanwhile scene and run the close-summary pipeline. + # Two POV summaries (host + guest) — no "you" POV. + pov_payload_host = json.dumps( + { + "summary": "BotA reflects on the quiet moment with BotB.", + "knowledge_facts": [], + "relationship_summary": "", + } + ) + pov_payload_guest = json.dumps( + { + "summary": "BotB notices BotA's reserved manner.", + "knowledge_facts": [], + "relationship_summary": "", + } + ) + close_mock = MockLLMClient(canned=[pov_payload_host, pov_payload_guest]) + + import asyncio as _asyncio + + with open_db(tmp_path / "test.db") as conn: + # asyncio.run() can't nest under TestClient's loop, but the + # close pipeline is awaitable — drive it via a fresh loop here. + _loop = _asyncio.new_event_loop() + # Mark the meanwhile scene closed via the projector handler. + append_and_apply( + conn, + kind="meanwhile_scene_closed", + payload={ + "scene_id": 2, + "closed_at": "2026-04-26T20:30:00+00:00", + }, + ) + + # apply_scene_close_summary takes host_bot_id; here we tell it to + # operate on the meanwhile scene id (2). With no "you" memory + # row to rewrite (witness_you=0 means "you" doesn't have a + # memory for this scene), the call must produce per-POV writes + # ONLY for bot_a and bot_b. + try: + _loop.run_until_complete( + apply_scene_close_summary( + conn, + close_mock, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=2, + host_bot_id="bot_a", + ) + ) + finally: + _loop.close() + + # Per-POV memory rewrites: count manual_edits with target_kind + # ``memory_pov_summary`` whose target_id maps to a memory row + # scoped to scene 2. + edits = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'manual_edit'" + ).fetchall() + pov_edits = [] + for (raw,) in edits: + payload = json.loads(raw) + if payload.get("target_kind") != "memory_pov_summary": + continue + mem_row = conn.execute( + "SELECT owner_id, scene_id FROM memories WHERE id = ?", + (payload["target_id"],), + ).fetchone() + if mem_row is None or mem_row[1] != 2: + continue + pov_edits.append({"owner": mem_row[0], "new": payload["new_value"]}) + + # Verify the actual current pov_summary on each bot's memory row + # for scene 2 reflects the rewrite. + host_pov = conn.execute( + "SELECT pov_summary FROM memories WHERE owner_id = ? AND scene_id = ?", + ("bot_a", 2), + ).fetchone() + guest_pov = conn.execute( + "SELECT pov_summary FROM memories WHERE owner_id = ? AND scene_id = ?", + ("bot_b", 2), + ).fetchone() + # No "you" memory row should exist for the meanwhile scene — + # "you" was never a witness. + you_row = conn.execute( + "SELECT id FROM memories WHERE owner_id = 'you' AND scene_id = ?", + (2,), + ).fetchone() + + # Exactly two memory_pov_summary rewrites — one per bot witness. + assert len(pov_edits) == 2 + owners = sorted(e["owner"] for e in pov_edits) + assert owners == ["bot_a", "bot_b"] + assert host_pov is not None and "BotA reflects" in host_pov[0] + assert guest_pov is not None and "BotB notices" in guest_pov[0] + # No "you" POV row — meanwhile scenes don't surface a you-memory. + assert you_row is None + + +def test_meanwhile_turn_registered_in_in_flight_tasks( + app_state_setup, tmp_path +): + """A meanwhile turn registers its streaming task in the chat-keyed + ``_in_flight_tasks`` registry the cancel route reads from, and clears + the entry after the stream completes. + + Without registration, ``POST /chats//turns/cancel`` would be a + silent no-op for meanwhile beats — the Stop button wouldn't actually + stop them. We pin the behaviour via a streaming mock that snapshots + ``_in_flight_tasks`` at the moment of its first yield (mid-flight), + then assert the entry is removed after the response returns. + """ + from typing import AsyncIterator, Sequence + + from chat.llm.client import Message + from chat.web.turns import _in_flight_tasks + + _seed_meanwhile_chat(tmp_path / "test.db") + + # Snapshot of (chat_id-present?, registered task object) captured + # at the first stream yield. The closure runs inside the streaming + # coroutine, so when it executes the task is alive and registered. + in_flight_snapshot: dict = {} + + class _SnapshotMock(MockLLMClient): + async def stream( + self, messages: Sequence[Message], *, model: str, **params + ) -> AsyncIterator[str]: + text = self._canned.pop(0) + for i, ch in enumerate(text): + if i == 0: + # Snapshot at first yield — the post_turn coroutine + # is awaiting our generator and the streaming Task + # is registered in _in_flight_tasks[chat_id]. + in_flight_snapshot["present"] = ( + "chat_bot_a" in _in_flight_tasks + ) + in_flight_snapshot["task"] = _in_flight_tasks.get( + "chat_bot_a" + ) + yield ch + + canned_parse = json.dumps( + {"segments": [{"kind": "narration", "text": "they exchange a glance"}]} + ) + mock = _SnapshotMock( + canned=[ + canned_parse, + "BotA leans in. *quietly*", + _zero_state(), + _zero_state(), + ] + ) + from chat.web.kickoff import get_llm_client + + app.dependency_overrides[get_llm_client] = lambda: mock + try: + # Pre-condition: registry is empty for this chat. + assert "chat_bot_a" not in _in_flight_tasks + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "they exchange a glance"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + + # Mid-flight: the streaming task was present in the registry, and + # the captured value was an asyncio.Task (not None / not some other + # placeholder). + import asyncio + + assert in_flight_snapshot.get("present") is True, ( + "_in_flight_tasks was empty at first yield — meanwhile stream " + "isn't registering its task" + ) + assert isinstance(in_flight_snapshot.get("task"), asyncio.Task) + # Post-flight: the entry has been cleaned up so the next turn (or + # the cancel route) doesn't see a stale task. + assert "chat_bot_a" not in _in_flight_tasks -- 2.52.0 From dc358335344ab9b3b6862d45e7bfc3617c26d8f1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 21:07:44 -0400 Subject: [PATCH 19/22] test: feed meanwhile digest canned response after Wave 6b cross-feature merge --- tests/test_meanwhile_turn_flow.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_meanwhile_turn_flow.py b/tests/test_meanwhile_turn_flow.py index a2e2783..0290b2b 100644 --- a/tests/test_meanwhile_turn_flow.py +++ b/tests/test_meanwhile_turn_flow.py @@ -394,7 +394,19 @@ def test_meanwhile_scene_close_writes_per_pov_for_both_bots_only( "relationship_summary": "", } ) - close_mock = MockLLMClient(canned=[pov_payload_host, pov_payload_guest]) + # T65 added a meanwhile digest summarize call after per-POV writes + # for meanwhile scenes. T58's thread detection is wrapped in try/except + # so its IndexError is swallowed gracefully. + digest_payload = json.dumps( + { + "summary": "While you were away, BotA and BotB talked quietly.", + "knowledge_facts": [], + "relationship_summary": "", + } + ) + close_mock = MockLLMClient( + canned=[pov_payload_host, pov_payload_guest, digest_payload] + ) import asyncio as _asyncio -- 2.52.0 From af6c54dd05db21456c8baca21125e4098beb5b00 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 21:10:49 -0400 Subject: [PATCH 20/22] docs: phase 3 status, behavioral defaults, deferred items (T67) --- CLAUDE.md | 80 +++++++++++++++++++ .../2026-04-26-v1-requirements-design.md | 2 + 2 files changed, 82 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index ac3373c..bd66fc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,3 +213,83 @@ New follow-ups discovered during Phase 2.5 execution. None are blocking; pick up - **`_witness_role_for` defensive coding (from T71 review)**: helper returns `"guest"` when `host_bot_id is None`, which is wrong for Phase-1 chats. Defensive: `return "host" if host_bot_id is None or speaker_bot_id == host_bot_id else "guest"`. Not exercised by current tests; harden as a precaution. - **Confidence type tightening (from T74 review)**: `chat/services/addressee.py::AddresseeDecision.confidence` could be typed as `Literal["high","medium","low"]` for stricter validation. Currently `str` with a comment. - **Scene-close-on-cancel UX revisit**: T74.3 pinned the existing behavior (close fires even on cancel). If real play-testing surfaces a regression, revisit. + +## Phase 3 status + +Phase 3 shipped end-to-end across 19 tasks (T49–T67). Events with full lifecycle, time skips, active threads, significance refinements, and meanwhile scenes are functional. Schema baseline is now version 11 (migrations 0009 events, 0010 threads, 0011 meanwhile_scenes). Test count grew from ~247 (Phase 2) to ~315 (+68 new tests across the wave). + +- **Wave 1 — schema + lifecycle handlers (parallel)**: + - **T49** `events` table + lifecycle handlers (`event_planned`, `event_started`, `event_completed`, `event_cancelled`, `event_expired`). + - **T50** `time_skip` event handlers (elision and jump variants). + - **T51** `threads` table + handlers (`thread_opened`, `thread_updated`, `thread_closed`). +- **Wave 2 — detection / narration services (parallel)**: + - **T52** event-lifecycle detection service (planned→active→completed transitions inferred from narration). + - **T53** skip narration service (elision + jump prose). + - **T54** synthesized-memories service for jump skips (LLM-summarized intervening time). + - **T55** thread-detection service (open/update/close inferred from recent dialogue). +- **Wave 3 — promotion + ranking (parallel)**: + - **T56** event-completion promotion service (objects → inventory, knowledge → edge knowledge, relationship deltas → edge summary; everything else stays in the closed event). + - **T57** significance-aware retrieval ranking — SQL-side `SIGNIFICANCE_RANK_BIAS` plus the existing Python composite re-rank. + - **T58** scene compression keeps key quotes when significance ≥ 2; thread emission piggybacks on scene close. +- **Wave 4 — drawer UX (single)**: + - **T59** drawer additions: events panel, threads panel, skip controls. +- **Wave 5a — prompt + turn flow integration (parallel)**: + - **T60** prompt assembly includes active events + open threads in the speaker's prompt. + - **T61** turn flow invokes event-detection + completion promotion alongside existing post-turn fan-out. +- **Wave 5b — natural-language skip surface (single)**: + - **T62** classifier-driven skip command at the user-input layer; shared skip controllers extracted into `chat/web/skip.py`. +- **Wave 6a — meanwhile schema (single)**: + - **T63** meanwhile-scene schema + state (scene config 4: host+guest, no "you"). +- **Wave 6b — meanwhile turn flow (parallel)**: + - **T64** meanwhile turn flow (host+guest, no "you" in the prompt or witness writes). + - **T65** meanwhile summary digest surfaces to the next "you"-present scene. +- **Wave 7 — integration + docs (parallel)**: + - **T66** cross-feature integration tests covering events × skips × threads × meanwhile. + - **T67** documentation (this section). + +### Phase 3.5 / 4 backlog + +New follow-ups discovered during Phase 3 reviews and execution. None are blocking; pick up at any time. + +#### From T53 review + +- **`narrate_skip` `timeout_s` not piped through to `client.generate`**: parameter accepted but ignored. Fix: pass `timeout_s=timeout_s` to `client.generate(**...)`, or drop the parameter entirely if Featherless's client doesn't honor it. + +#### From T57 review + +- **`search_memories` docstring should mention SQL-side significance bias**: the function docstring still describes only the Python composite re-rank; add a one-line note about `SIGNIFICANCE_RANK_BIAS`. + +#### From T58 review + +- **Scene close re-close suffix bloat risk**: `_build_key_quotes_suffix` reads from `memories.pov_summary`. If a scene close runs twice, the second pass would read the rewritten text plus the previous "Key quotes:" suffix and append a second one. Either guard for double-suffix or source quotes from `event_log` `assistant_turn`/`user_turn` text instead. +- **Thread detection transcript scoping**: `_read_recent_dialogue` returns chat-wide history with no `scene_id` filter (Phase 1 turns lack one). Feeding chat-wide history to `detect_threads` will misattribute threads to the closing scene when the scene boundary falls inside the last 50 turns. Scope by `scene_id` once turns carry it, or by `started_at` against scene-open timestamp. +- **Swallowed exceptions in `detect_threads` try/except**: bare `Exception` swallows programmer errors silently. Log at debug level so silent regressions are recoverable. +- **Scene close `closed_at` clock divergence**: T58 uses `datetime.now(timezone.utc).isoformat()` instead of chat-clock time. Diverges from chat-clock semantics elsewhere; revisit if event reconstructions need chat-clock ordering. +- **Test coverage gaps in T58**: no test for 200-char quote truncation; no test for `thread_updated`/`thread_closed` candidate paths; no test for the `try/except` fallback. + +#### From T61 review + +- **Regenerate doesn't roll back lifecycle transitions from superseded turn**: `event_started`/`event_completed` rows from a superseded turn remain. Phase 3.5 should add a lifecycle-undo step. Caveat: regenerate-after-completion may double-emit promotion artifacts if the new text re-completes the same event. +- **Asymmetry in event-detection ordering**: post_turn runs lifecycle BETWEEN interjection and scene-close; regenerate runs lifecycle at the END. Benign because regenerate has no scene-close path, but worth tidying. + +#### From T62 review + +- **Error-message prefix sniff for 404 vs 400 routing**: drawer skip routes use `str(exc).startswith("chat not found")` to distinguish 404 from 400. Fragile if error wording changes. Use a typed exception subclass. +- **Skip command bypasses scene close detection**: a user typing "fade out, skip an hour" would skip without closing the scene. Acceptable for Phase 3 but worth noting. + +#### From T63 review + +- **`participants_json` JSON injection** (FIXED in T63 but worth noting in backlog as a "double-check other JSON-string-build sites" task): T63 originally used f-string interpolation; fixed to use `json.dumps`. Audit other state modules for similar patterns. + +#### From T64 review + +- **`record_meanwhile_memory` and `record_turn_memory_for_present` share private `_write_one_memory` helper**: minor DRY note; both helpers are similar enough that a unified API with a `you_present: bool` kwarg might be cleaner long-term. +- **Stop button cancellation for meanwhile turns**: T64 fix-up registered tasks in `_in_flight_tasks`; verify the `/turns/cancel` endpoint actually cancels meanwhile streams (the test pins registration but not the cancel-from-route path). + +#### From cross-feature interactions discovered in Wave 6b merge + +- **Cross-feature canned-queue brittleness**: meanwhile-scene close test required a canned response for T65's digest call after T64+T65 merge. Future close-path additions will keep extending the queue; consider a structured fixture builder rather than positional canned arrays. + +#### Discovered during Phase 3 execution + +- **`_witness_role_for` defensive `host_bot_id is None`** (carry-over from Phase 2.5 T71 backlog) — still pending. diff --git a/docs/plans/2026-04-26-v1-requirements-design.md b/docs/plans/2026-04-26-v1-requirements-design.md index ac7468a..f84b2cb 100644 --- a/docs/plans/2026-04-26-v1-requirements-design.md +++ b/docs/plans/2026-04-26-v1-requirements-design.md @@ -510,6 +510,8 @@ Written per witness when a scene closes. Different details, different interpreta ### Phase 3 — events, skips, threads +**Status: shipped 2026-04-26** (T49–T67, 19 tasks across 8 waves; schema baseline now version 11; +68 tests). See "Phase 3 status" in CLAUDE.md for the per-task breakdown. + - Events with lifecycles and scoped props. - Time skips: elision and jump. - Active threads. -- 2.52.0 From f865ac2ee2a4c3f7052e18c5aa83532097786653 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 21:16:30 -0400 Subject: [PATCH 21/22] test: phase 3 cross-feature integration coverage (T66) --- tests/test_phase3_integration.py | 1241 ++++++++++++++++++++++++++++++ 1 file changed, 1241 insertions(+) create mode 100644 tests/test_phase3_integration.py diff --git a/tests/test_phase3_integration.py b/tests/test_phase3_integration.py new file mode 100644 index 0000000..11a9bb0 --- /dev/null +++ b/tests/test_phase3_integration.py @@ -0,0 +1,1241 @@ +"""Phase 3 cross-feature integration tests (T66). + +These tests exercise multi-feature flows end-to-end. Phase 3 introduced +several cross-feature interaction surfaces (event lifecycle + promotion, +threads on scene close, jump-skip synthesized memories with retrieval, +meanwhile digests surfacing across scene boundaries, and meanwhile + +you-scene coexistence with witness-filtered memories). Each test below +drives the actual HTTP / service entry points, mocks the LLM with a +canned queue annotated for the precise call sequence, and asserts on +both the event_log AND the projected state after each action. + +Wave 6b's cross-feature merge surfaced canned-queue interaction bugs; +the goal here is to catch that class of regression in the test suite +before it ships. + +Five scenarios: + +1. ``test_event_lifecycle_promotion_lands_memory_and_edge`` — Plan event + → play turns → ``event_started`` detected → ``event_completed`` + detected → promotion fires → memory + edge updates land. +2. ``test_thread_open_on_close_renders_then_close_via_drawer_drops`` — + Open a thread on close → next scene's prompt includes the open thread + → close thread via drawer → next scene's prompt no longer includes it. +3. ``test_jump_skip_synthesized_memories_retrievable_next_turn`` — + Jump skip → synthesized memories land per present bot → next turn's + prompt retrieves them via search. +4. ``test_meanwhile_close_digest_surfaces_then_consumed`` — Meanwhile + scene → close → digest pending → first you-turn prompt includes + digest → after consumption, digest no longer renders. +5. ``test_meanwhile_and_you_scene_witness_filtered_memories`` — + Meanwhile while a regular you-scene is active → both scenes have + memories; querying memories for either bot returns the right + witness-filtered slices. + +Cross-feature notes discovered while writing these tests: + +- The thread-detection call on every scene close (T58.2) is wrapped in + try/except so its canned-queue slot is OPTIONAL — an IndexError is + swallowed. Tests that don't care about thread coverage can omit the + slot; test 2 includes a valid thread response to exercise the path. +- ``consume_pending_meanwhile_digests`` is defined in chat.services.prompt + but is NOT currently wired into the post_turn flow. The digest stays + pending across turns until the helper is called explicitly. Test 4 + reflects this: it asserts the digest renders pre-consumption AND + post-consumption (driven via the helper directly), and that the + meanwhile_digest_consumed event lands in the event_log. +- The host-only ``apply_scene_close_summary`` canned queue layout is + ``[host_pov, thread_detection]`` (2 slots) when a single bot is present + and there are dialogue rows, with thread_detection being optional / + swallowed on IndexError. +""" + +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 +import chat.state.meanwhile # noqa: F401 -- register handlers + + +# --------------------------------------------------------------------------- +# Shared fixtures. +# --------------------------------------------------------------------------- + + +def _bot_payload(bot_id: str, name: str, persona: str = "") -> dict: + return { + "id": bot_id, + "name": name, + "persona": persona or f"persona for {name}", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "", + "kickoff_prose": "...", + } + + +def _zero_state() -> str: + return json.dumps( + {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} + ) + + +def _override_llm(canned: list[str]) -> MockLLMClient: + """Wire a fresh MockLLMClient and return it so tests can introspect + the residual canned queue after the request. + """ + from chat.web.kickoff import get_llm_client + + mock = MockLLMClient(canned=list(canned)) + app.dependency_overrides[get_llm_client] = lambda: mock + return mock + + +@pytest.fixture +def app_state_setup(tmp_path, monkeypatch): + """Per-test environment + TestClient. Mirrors the pattern used by + tests/test_turn_flow.py and tests/test_meanwhile_turn_flow.py. + """ + 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: + app.state.background_worker.enabled = False + yield c + app.dependency_overrides.clear() + + +def _seed_single_bot_chat(db_path: Path) -> None: + """Author BotA + you, create chat with active scene, seed an + edge + activities so the prompt assembler has something to render. + """ + with open_db(db_path) as conn: + append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA")) + 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": "", + }, + ) + append_event( + conn, + kind="container_created", + payload={ + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": {}, + }, + ) + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a"], + }, + ) + append_event( + conn, + kind="edge_update", + payload={ + "source_id": "bot_a", + "target_id": "you", + "chat_id": "chat_bot_a", + "knowledge_facts": [], + }, + ) + append_event( + conn, + kind="edge_update", + payload={ + "source_id": "you", + "target_id": "bot_a", + "chat_id": "chat_bot_a", + "knowledge_facts": [], + }, + ) + for entity_id, verb in [("you", "talking"), ("bot_a", "listening")]: + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": entity_id, + "posture": "sitting", + "action": { + "verb": verb, + "interruptible": True, + "required_attention": "low", + "expected_duration": "ongoing", + }, + "attention": "", + "holding": [], + "status": {}, + }, + ) + project(conn) + + +def _seed_two_bot_chat(db_path: Path) -> None: + """Author BotA + BotB + you, create a chat with both wired in, an + open scene, edges for all 6 directed pairs, activities for all three. + """ + with open_db(db_path) 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", + "guest_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1", + "weather": "", + }, + ) + append_event( + conn, + kind="container_created", + payload={ + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": {}, + }, + ) + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a", "bot_b"], + }, + ) + for src, tgt in [ + ("bot_a", "you"), + ("you", "bot_a"), + ("bot_b", "you"), + ("you", "bot_b"), + ("bot_a", "bot_b"), + ("bot_b", "bot_a"), + ]: + append_event( + conn, + kind="edge_update", + payload={ + "source_id": src, + "target_id": tgt, + "chat_id": "chat_bot_a", + "knowledge_facts": [], + }, + ) + for entity_id, verb in [ + ("you", "talking"), + ("bot_a", "listening"), + ("bot_b", "listening"), + ]: + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": entity_id, + "posture": "sitting", + "action": { + "verb": verb, + "interruptible": True, + "required_attention": "low", + "expected_duration": "ongoing", + }, + "attention": "", + "holding": [], + "status": {}, + }, + ) + project(conn) + + +# --------------------------------------------------------------------------- +# 1. Event lifecycle: plan -> active -> completed -> promotion lands. +# --------------------------------------------------------------------------- + + +def test_event_lifecycle_promotion_lands_memory_and_edge( + app_state_setup, tmp_path +): + """Plan an event with a knowledge_facts prop, drive a turn that the + classifier flags ``new_status='active'``, then drive a second turn + that flags ``new_status='completed'``. Assert: + + * ``event_started`` lands after turn 1 with the correct event_id. + * ``event_completed`` lands after turn 2. + * ``promote_completed_event`` runs inline, emitting a follow-on + ``edge_update`` (source='event_promotion') carrying the planned fact. + * The directed bot_a -> you edge actually carries the fact in its + knowledge list (i.e. the projector applied the promotion). + + Canned queue per turn (single-bot, scene active, no guest, so no + addressee classifier and no interjection branch): + 1. parse_turn (user prose classifier) + 2. narrative stream + 3. state-update bot_a -> you + 4. state-update you -> bot_a + 5. detect_event_transitions -> active (turn 1) / completed (turn 2) + 6. detect_scene_close -> False + + Both turns include the scene_close slot — detect_scene_close runs on + every turn that has a non-empty prose AND an active scene. Memory + writes fire 1 per turn for single-bot (host POV only). + """ + _seed_single_bot_chat(tmp_path / "test.db") + + # Plan an event whose props carry a knowledge_fact for promotion. + with open_db(tmp_path / "test.db") as conn: + append_and_apply( + conn, + kind="event_planned", + payload={ + "event_id": "evt_dinner", + "chat_id": "chat_bot_a", + "kind": "dinner_with_friend", + "props": { + "knowledge_facts": [ + { + "owner_id": "bot_a", + "target_id": "you", + "fact": "Maya enjoyed the wine choice", + } + ] + }, + "planned_for": "2026-04-26T20:30:00+00:00", + }, + ) + + # ---- Turn 1: classifier flags event as active. ---- + canned_parse_1 = json.dumps( + {"segments": [{"kind": "narration", "text": "we sit down at the table"}]} + ) + canned_event_active = json.dumps( + { + "transitions": [ + { + "event_id": "evt_dinner", + "new_status": "active", + "reason": "they sat down", + } + ] + } + ) + canned_close_no = json.dumps({"should_close": False, "reason": "no signal"}) + + # Turn 1 layout: parse + narrative + 2 state-updates + event_decision + + # scene_close. 6 slots total (single-bot has 2 directed pairs). + mock = _override_llm( + [ + canned_parse_1, + "Maya glances around the dining room.", + _zero_state(), + _zero_state(), + canned_event_active, + canned_close_no, + ] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "we sit down at the table"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [], ( + f"turn 1 left canned slots unconsumed: {mock._canned}" + ) + + # event_started landed; event row reflects active. + with open_db(tmp_path / "test.db") as conn: + started_rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'event_started'" + ).fetchall() + assert len(started_rows) == 1 + assert json.loads(started_rows[0][0])["event_id"] == "evt_dinner" + + ev_row = conn.execute( + "SELECT status FROM events WHERE event_id = 'evt_dinner'" + ).fetchone() + assert ev_row is not None and ev_row[0] == "active" + + # No promotion has fired yet (only completion triggers promotion). + promo_count = conn.execute( + "SELECT COUNT(*) FROM event_log " + "WHERE kind = 'edge_update' " + " AND json_extract(payload_json, '$.source') = 'event_promotion'" + ).fetchone()[0] + assert promo_count == 0 + + # ---- Turn 2: classifier flags event as completed. ---- + canned_parse_2 = json.dumps( + {"segments": [{"kind": "narration", "text": "we wrap up the meal"}]} + ) + canned_event_completed = json.dumps( + { + "transitions": [ + { + "event_id": "evt_dinner", + "new_status": "completed", + "reason": "wrapped up", + } + ] + } + ) + mock = _override_llm( + [ + canned_parse_2, + "Maya signals for the check.", + _zero_state(), + _zero_state(), + canned_event_completed, + canned_close_no, + ] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "we wrap up the meal"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [], ( + f"turn 2 left canned slots unconsumed: {mock._canned}" + ) + + with open_db(tmp_path / "test.db") as conn: + # event_completed landed. + completed_rows = conn.execute( + "SELECT id, payload_json FROM event_log " + "WHERE kind = 'event_completed'" + ).fetchall() + assert len(completed_rows) == 1 + assert json.loads(completed_rows[0][1])["event_id"] == "evt_dinner" + + # promote_completed_event ran inline — an edge_update with + # source=event_promotion lands carrying the planned fact. + promo_rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'edge_update' " + " AND json_extract(payload_json, '$.source') = 'event_promotion'" + ).fetchall() + promo_facts: list[str] = [] + for (raw,) in promo_rows: + promo_facts.extend(json.loads(raw).get("knowledge_facts") or []) + assert "Maya enjoyed the wine choice" in promo_facts + + # The directed bot_a -> you edge surfaces the fact. + from chat.state.edges import get_edge + + edge = get_edge(conn, "bot_a", "you") + assert edge is not None + assert "Maya enjoyed the wine choice" in (edge.get("knowledge") or []) + + # Memory writes: 1 per turn for single-bot, so 2 in total. + mem_count = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'memory_written'" + ).fetchone()[0] + assert mem_count == 2 + + +# --------------------------------------------------------------------------- +# 2. Threads: open on close -> renders -> close via drawer -> drops. +# --------------------------------------------------------------------------- + + +def test_thread_open_on_close_renders_then_close_via_drawer_drops( + app_state_setup, tmp_path +): + """Drive a turn whose prose hard-signals close, classifier confirms + close, and the close pipeline opens a thread (T58.2). Then assemble + a fresh narrative prompt and assert the open thread renders. Close + the thread via the drawer route. Re-assemble — the thread is gone. + + Canned queue (single-bot turn that closes the scene): + 1. parse_turn + 2. narrative stream + 3. state-update bot_a -> you + 4. state-update you -> bot_a + 5. detect_scene_close -> True (no event slot — no active events) + 6. apply_scene_close_summary host POV + 7. detect_threads -> 1 open thread + + No event_decision slot — list_active_events is empty so the + classifier short-circuits per T52 (verified by the consumed queue + assertion below). + """ + _seed_single_bot_chat(tmp_path / "test.db") + + canned_parse = json.dumps( + {"segments": [{"kind": "narration", "text": "we are done here, fade out"}]} + ) + canned_close_yes = json.dumps( + {"should_close": True, "reason": "fade out"} + ) + canned_pov = json.dumps( + { + "summary": "BotA noticed an unresolved tension before the fade.", + "knowledge_facts": [], + "relationship_summary": "", + } + ) + # Thread detection — single open candidate. The detect_threads service + # consumes this slot; if it had returned no candidates the slot still + # gets consumed, so we always count it. + canned_threads = json.dumps( + { + "candidates": [ + { + "action": "open", + "title": "the missing key", + "summary": "Couldn't find the key before BotA left.", + "existing_thread_id": None, + } + ] + } + ) + + mock = _override_llm( + [ + canned_parse, + "BotA pauses, then heads for the door.", + _zero_state(), + _zero_state(), + canned_close_yes, + canned_pov, + canned_threads, + ] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "we are done here, fade out"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [], ( + f"turn 1 left canned slots unconsumed: {mock._canned}" + ) + + with open_db(tmp_path / "test.db") as conn: + # scene_closed landed. + scene_close_count = conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'scene_closed'" + ).fetchone()[0] + assert scene_close_count == 1 + + # thread_opened landed. + thread_rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'thread_opened'" + ).fetchall() + assert len(thread_rows) == 1 + thread_payload = json.loads(thread_rows[0][0]) + assert thread_payload["title"] == "the missing key" + thread_id = thread_payload["thread_id"] + + # The next prompt assembly must surface the open thread block. + from chat.services.prompt import assemble_narrative_prompt + + with open_db(tmp_path / "test.db") as conn: + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body = msgs[0].content + assert "Open threads:" in body + assert "the missing key" in body + + # Now close the thread via the drawer route. + response = app_state_setup.post( + f"/chats/chat_bot_a/drawer/thread/close/{thread_id}" + ) + assert response.status_code == 200 + + with open_db(tmp_path / "test.db") as conn: + # thread_closed event landed. + closed_rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'thread_closed'" + ).fetchall() + assert len(closed_rows) == 1 + assert json.loads(closed_rows[0][0])["thread_id"] == thread_id + + # Re-assemble — the open-threads block is gone. + msgs2 = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body2 = msgs2[0].content + assert "Open threads:" not in body2 + assert "the missing key" not in body2 + + +# --------------------------------------------------------------------------- +# 3. Jump skip: synthesized memories land + retrievable on next turn. +# --------------------------------------------------------------------------- + + +def test_jump_skip_synthesized_memories_retrievable_next_turn( + app_state_setup, tmp_path +): + """Drive a jump skip via the drawer route with non-empty notable_prose. + The skip controller writes synthesized memories for the host bot, + then a subsequent narrative turn's prompt assembly must surface + them via FTS5 search when the query overlaps the memory text. + + Canned queue for the jump skip (single-bot, no guest): + 1. synthesize_memories digest (1 memory, single host bot) + 2. narrate_skip (assistant_turn narration) + + Canned queue for the follow-up turn (single-bot, scene still open + after the jump because jump only advances the clock): + 1. parse_turn + 2. narrative stream + 3. state-update bot_a -> you + 4. state-update you -> bot_a + 5. detect_scene_close -> False + + The post-skip retrieval is verified two ways: + * The memory row exists in ``memories`` for owner=bot_a with + ``source='synthesized'`` and the seeded text. + * ``search_memories`` returns the memory when queried by a token + from the synthesized prose; we don't try to assert the retrieved + memory shows up in the assembled prompt body, because the prompt + assembler picks its query from container/anchor (which doesn't + overlap the synthesized prose) — we instead drive the search + directly. Future work: pin the assembled-prompt-includes-it + contract once a deliberate query-builder lands. + """ + _seed_single_bot_chat(tmp_path / "test.db") + + # ---- Jump skip via drawer. ---- + digest_json = json.dumps( + { + "memories": [ + { + "text": "Maya bumped into Alex at the cafe and they argued.", + "significance": 2, + "affinity_delta": 0, + "trust_delta": 0, + } + ] + } + ) + narration = "Hours pass; Maya returns visibly off-kilter." + mock = _override_llm([digest_json, narration]) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/drawer/skip/jump", + data={ + "new_time": "2026-04-26T22:00:00+00:00", + "notable_prose": "I bumped into Alex at the cafe and we argued.", + "reset_activity": "", + }, + ) + assert response.status_code == 200 + finally: + app.dependency_overrides.clear() + assert mock._canned == [], ( + f"jump skip left canned slots unconsumed: {mock._canned}" + ) + + # Verify the synthesized memory landed for the host bot. + with open_db(tmp_path / "test.db") as conn: + synth_payloads = [] + rows = conn.execute( + "SELECT payload_json FROM event_log WHERE kind = 'memory_written'" + ).fetchall() + for (raw,) in rows: + payload = json.loads(raw) + if payload.get("source") == "synthesized": + synth_payloads.append(payload) + assert len(synth_payloads) == 1 + assert synth_payloads[0]["owner_id"] == "bot_a" + assert "Alex" in synth_payloads[0]["pov_summary"] + + # The memory is retrievable via search_memories — host POV. + from chat.state.memory import search_memories + + hits = search_memories(conn, "bot_a", "host", "Alex", k=4) + assert len(hits) == 1 + assert hits[0]["pov_summary"].startswith("Maya bumped into Alex") + assert hits[0]["source"] == "synthesized" + # And the significance is preserved through the round-trip. + assert hits[0]["significance"] == 2 + + # ---- Follow-up turn: drive a normal turn so the post_turn flow runs + # against the post-skip state. We don't assert the synthesized + # memory appears verbatim in the prompt body (the assembler's query + # is keyed on container/anchor, which doesn't overlap), but we do + # verify the turn lands cleanly and the memory remains retrievable. + canned_parse = json.dumps( + {"segments": [{"kind": "dialogue", "text": "what was that about?"}]} + ) + canned_close_no = json.dumps( + {"should_close": False, "reason": "no signal"} + ) + mock = _override_llm( + [ + canned_parse, + "Maya hesitates. *quietly* I'd rather not talk about it.", + _zero_state(), + _zero_state(), + canned_close_no, + ] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "what was that about?"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [], ( + f"follow-up turn left canned slots unconsumed: {mock._canned}" + ) + + # The synthesized memory is still retrievable post-turn (it wasn't + # clobbered or hidden by the new turn's writes). + with open_db(tmp_path / "test.db") as conn: + from chat.state.memory import search_memories + + hits = search_memories(conn, "bot_a", "host", "Alex", k=4) + assert any( + h["source"] == "synthesized" and "Alex" in h["pov_summary"] + for h in hits + ) + + +# --------------------------------------------------------------------------- +# 4. Meanwhile close digest: pending -> renders in next you-turn prompt +# -> consumed via helper -> no longer renders. +# --------------------------------------------------------------------------- + + +def test_meanwhile_close_digest_surfaces_then_consumed( + app_state_setup, tmp_path +): + """Seed a parent you-scene + active meanwhile child scene. Drive one + meanwhile turn so each bot has a memory row scoped to scene 2. + Close the meanwhile scene + run apply_scene_close_summary inline. + The digest row lands. Next assemble a you-scene prompt — the + digest renders. Drive consume_pending_meanwhile_digests. Re-assemble + — the digest is gone, and a meanwhile_digest_consumed event landed. + + Cross-feature finding: ``consume_pending_meanwhile_digests`` is + defined in chat.services.prompt but is NOT wired into the post_turn + flow. The digest stays pending across turns until callers invoke + the helper. Test exercises the helper directly so the consumption + contract is pinned independent of any future post_turn integration. + + Canned queue for the meanwhile turn: + 1. parse_turn + 2. narrative stream + 3. state-update bot_a -> bot_b + 4. state-update bot_b -> bot_a + + Canned queue for apply_scene_close_summary on meanwhile scene: + 1. host POV summary + 2. guest POV summary + 3. digest summary (the meanwhile_digest_pending text) + 4. detect_threads (T58.2 always runs on close; meanwhile included) + """ + db_path = tmp_path / "test.db" + + # Seed the chat + parent you-scene + active meanwhile child scene. + with open_db(db_path) 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", + "guest_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1", + "weather": "", + }, + ) + append_event( + conn, + kind="container_created", + payload={ + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": {}, + }, + ) + # Parent you-scene (id=1). + append_event( + conn, + kind="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a", "bot_b"], + }, + ) + # Meanwhile child (id=2) — bot_a + bot_b only. + append_event( + conn, + kind="meanwhile_scene_started", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "parent_scene_id": 1, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "started_at": "2026-04-26T20:05:00+00:00", + }, + ) + # Edges for bot pairs (state-update writes need initialized rows). + for src, tgt in [ + ("bot_a", "you"), + ("bot_b", "you"), + ("bot_a", "bot_b"), + ("bot_b", "bot_a"), + ]: + append_event( + conn, + kind="edge_update", + payload={ + "source_id": src, + "target_id": tgt, + "chat_id": "chat_bot_a", + "knowledge_facts": [], + }, + ) + for entity_id, verb in [("bot_a", "listening"), ("bot_b", "talking")]: + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": entity_id, + "posture": "sitting", + "action": { + "verb": verb, + "interruptible": True, + "required_attention": "low", + "expected_duration": "ongoing", + }, + "attention": "", + "holding": [], + "status": {}, + }, + ) + project(conn) + + # ---- Drive a meanwhile turn so each bot has a memory in scene 2. ---- + canned_parse = json.dumps( + {"segments": [{"kind": "narration", "text": "they whisper"}]} + ) + mock = _override_llm( + [ + canned_parse, + "BotA leans in. *softly* I have to tell you something.", + _zero_state(), + _zero_state(), + ] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "they whisper"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [] + + # ---- Close the meanwhile scene + run apply_scene_close_summary. ---- + import asyncio + from chat.services.scene_summarize import apply_scene_close_summary + + host_pov = json.dumps( + { + "summary": "BotA confided in BotB about the missing key.", + "knowledge_facts": [], + "relationship_summary": "", + } + ) + guest_pov = json.dumps( + { + "summary": "BotB listened and offered to help.", + "knowledge_facts": [], + "relationship_summary": "", + } + ) + digest_text = ( + "While you were away, BotA confided in BotB about a missing key." + ) + digest_canned = json.dumps( + { + "summary": digest_text, + "knowledge_facts": [], + "relationship_summary": "", + } + ) + no_threads = json.dumps({"candidates": []}) + close_mock = MockLLMClient( + canned=[host_pov, guest_pov, digest_canned, no_threads] + ) + + with open_db(db_path) as conn: + # Mark the meanwhile scene closed so apply_scene_close_summary + # operates on a closed scene — same shape as the production + # close path in T64/T65. + append_and_apply( + conn, + kind="meanwhile_scene_closed", + payload={ + "scene_id": 2, + "closed_at": "2026-04-26T20:30:00+00:00", + }, + ) + loop = asyncio.new_event_loop() + try: + loop.run_until_complete( + apply_scene_close_summary( + conn, + close_mock, + classifier_model="x", + chat_id="chat_bot_a", + scene_id=2, + host_bot_id="bot_a", + ) + ) + finally: + loop.close() + assert close_mock._canned == [], ( + f"close path left canned slots unconsumed: {close_mock._canned}" + ) + + # The digest landed in event_log + projection table. + from chat.state.meanwhile import list_pending_meanwhile_digests + + pending = list_pending_meanwhile_digests(conn, "chat_bot_a") + assert len(pending) == 1 + assert "missing key" in pending[0]["summary"] + + # ---- First you-scene prompt: the digest renders as a SHOULD-tier + # 'Meanwhile while you were away:' block. ---- + from chat.services.prompt import assemble_narrative_prompt + + with open_db(db_path) as conn: + msgs = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body = msgs[0].content + assert "Meanwhile while you were away:" in body + assert digest_text in body + + # ---- Consume + re-assemble. The digest is gone, and a + # meanwhile_digest_consumed event lands. ---- + from chat.services.prompt import consume_pending_meanwhile_digests + + with open_db(db_path) as conn: + consumed = consume_pending_meanwhile_digests(conn, "chat_bot_a") + assert consumed == 1 + + consumed_rows = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'meanwhile_digest_consumed'" + ).fetchall() + assert len(consumed_rows) == 1 + + msgs2 = assemble_narrative_prompt( + conn, + chat_id="chat_bot_a", + speaker_bot_id="bot_a", + recent_dialogue=[], + retrieved_memory_summaries=[], + ) + body2 = msgs2[0].content + assert "Meanwhile while you were away:" not in body2 + assert digest_text not in body2 + + # Pending list is empty after consumption. + from chat.state.meanwhile import list_pending_meanwhile_digests + + assert list_pending_meanwhile_digests(conn, "chat_bot_a") == [] + + +# --------------------------------------------------------------------------- +# 5. Meanwhile + you-scene coexistence: both have memories with the right +# witness flags, retrievable per bot via search. +# --------------------------------------------------------------------------- + + +def test_meanwhile_and_you_scene_witness_filtered_memories( + app_state_setup, tmp_path +): + """Seed a parent you-scene + active meanwhile child scene. Drive + one meanwhile turn (host_guest present_set, [you=0, host=1, guest=1] + witness flags). Close the meanwhile scene so the post-meanwhile main + scene is the active scene. Drive a regular you-turn (you_host_guest + present_set, [you=1, host=1, guest=1] witness flags). Each bot now + has TWO memories — one from the meanwhile scene, one from the + you-scene. Witness-filtered search: + + * Querying owner=bot_a, witness_role='host' over a meanwhile-only + keyword returns the meanwhile memory (witness_host=1). + * Querying owner=bot_a, witness_role='host' over a you-scene-only + keyword returns the you-scene memory. + * Querying owner=bot_b, witness_role='guest' over each keyword + similarly returns the right memory (the per-bot store is + separately witnessed). + + Canned queue for the meanwhile turn: + 1. parse_turn + 2. narrative stream + 3. state-update bot_a -> bot_b + 4. state-update bot_b -> bot_a + + Canned queue for the you-turn (post-meanwhile): + 1. parse_turn + 2. detect_addressee (host vs. guest -> host) + 3. narrative stream + 4-9. 6 state-update calls (full directed pairs over you/host/guest) + 10. detect_interjection -> False + 11. detect_scene_close -> False (scene stays open) + """ + db_path = tmp_path / "test.db" + _seed_two_bot_chat(db_path) + + # Seed an active meanwhile child scene (id=2) on top of the parent + # you-scene (id=1). + with open_db(db_path) as conn: + append_and_apply( + conn, + kind="meanwhile_scene_started", + payload={ + "scene_id": 2, + "chat_id": "chat_bot_a", + "parent_scene_id": 1, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "started_at": "2026-04-26T20:05:00+00:00", + }, + ) + + # ---- Meanwhile turn: keyword 'pottery' so it's distinguishable from + # the you-turn keyword later. The narrative text drives memory + # pov_summary text via record_meanwhile_memory. + meanwhile_parse = json.dumps( + {"segments": [{"kind": "narration", "text": "they linger"}]} + ) + meanwhile_text = "BotA mentions a pottery class she's been taking." + mock = _override_llm( + [ + meanwhile_parse, + meanwhile_text, + _zero_state(), + _zero_state(), + ] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "they linger"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [] + + # ---- Close the meanwhile scene so the next post_turn dispatches to + # the regular you-flow rather than meanwhile_turn_flow. + with open_db(db_path) as conn: + append_and_apply( + conn, + kind="meanwhile_scene_closed", + payload={ + "scene_id": 2, + "closed_at": "2026-04-26T20:25:00+00:00", + }, + ) + + # ---- You-turn: keyword 'whiteboard' so the post-turn memory's text + # is distinguishable from the meanwhile memory above. 2-bot chat + # so the full directed-pair fan-out fires. + you_parse = json.dumps( + {"segments": [{"kind": "dialogue", "text": "let's sketch this out"}]} + ) + addressee_decision = json.dumps( + { + "addressee_id": "bot_a", + "confidence": "medium", + "reason": "host", + } + ) + you_text = "BotA grabs a whiteboard marker and starts sketching." + you_close_no = json.dumps( + {"should_close": False, "reason": "scene continues"} + ) + you_interject_no = json.dumps( + {"should_interject": False, "reason": "calm"} + ) + mock = _override_llm( + [ + you_parse, + addressee_decision, + you_text, + _zero_state(), _zero_state(), _zero_state(), + _zero_state(), _zero_state(), _zero_state(), + you_interject_no, + you_close_no, + ] + ) + try: + response = app_state_setup.post( + "/chats/chat_bot_a/turns", + data={"prose": "let's sketch this out"}, + ) + assert response.status_code == 204 + finally: + app.dependency_overrides.clear() + assert mock._canned == [], ( + f"you-turn left canned slots unconsumed: {mock._canned}" + ) + + # ---- Verify memory shape across BOTH scenes for BOTH bots. ---- + with open_db(db_path) as conn: + rows = conn.execute( + "SELECT owner_id, scene_id, pov_summary, " + " witness_you, witness_host, witness_guest " + "FROM memories ORDER BY id" + ).fetchall() + + # Expect 4 rows: meanwhile (host+guest = 2) + you-turn (host+guest = 2). + assert len(rows) == 4, ( + f"unexpected memory shape after both turns: {rows}" + ) + + meanwhile_rows = [r for r in rows if r[1] == 2] + you_scene_rows = [r for r in rows if r[1] != 2] + assert len(meanwhile_rows) == 2 + assert len(you_scene_rows) == 2 + + # Witness flags: meanwhile rows have witness_you=0; you-scene + # rows have witness_you=1. Both sets have witness_host=witness_guest=1. + for owner, _scene, _pov, w_you, w_host, w_guest in meanwhile_rows: + assert w_you == 0, (owner, w_you) + assert w_host == 1 + assert w_guest == 1 + for owner, _scene, _pov, w_you, w_host, w_guest in you_scene_rows: + assert w_you == 1, (owner, w_you) + assert w_host == 1 + assert w_guest == 1 + + # ---- Witness-filtered FTS5 search returns the right slice + # per (owner, witness_role, query). ---- + from chat.state.memory import search_memories + + # Host POV (bot_a as host): both keywords are visible because + # bot_a is owner of both scenes' rows AND witness_host=1 in both. + hits_pottery_host = search_memories( + conn, "bot_a", "host", "pottery", k=4 + ) + assert len(hits_pottery_host) == 1 + assert "pottery" in hits_pottery_host[0]["pov_summary"] + assert hits_pottery_host[0]["scene_id"] == 2 + + hits_whiteboard_host = search_memories( + conn, "bot_a", "host", "whiteboard", k=4 + ) + assert len(hits_whiteboard_host) == 1 + assert "whiteboard" in hits_whiteboard_host[0]["pov_summary"] + # The you-scene memory carries scene_id of the active scene at + # turn-time. We don't pin the scene_id value (active_scene helper + # determines it) but we DO pin that it's NOT the meanwhile id. + assert hits_whiteboard_host[0]["scene_id"] != 2 + + # Guest POV (bot_b as guest): same expectation, witness_guest=1 + # in both scenes' bot_b rows. + hits_pottery_guest = search_memories( + conn, "bot_b", "guest", "pottery", k=4 + ) + assert len(hits_pottery_guest) == 1 + assert hits_pottery_guest[0]["scene_id"] == 2 + + hits_whiteboard_guest = search_memories( + conn, "bot_b", "guest", "whiteboard", k=4 + ) + assert len(hits_whiteboard_guest) == 1 + assert hits_whiteboard_guest[0]["scene_id"] != 2 + + # ---- Witness mask integrity: querying bot_a with witness_role='you' + # over the meanwhile keyword returns NOTHING (witness_you=0 for + # the meanwhile row). The you-scene row's witness_you=1 so a + # 'you' role query would surface IT, but since 'pottery' is + # only in the meanwhile row, the result set is empty. + hits_pottery_you = search_memories( + conn, "bot_a", "you", "pottery", k=4 + ) + assert hits_pottery_you == [], ( + "witness_you mask should filter the meanwhile row out of " + "owner=bot_a/role=you queries" + ) -- 2.52.0 From 70a5ad3eccec73e35e78542fe238e2ad0cb62499 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 21:19:11 -0400 Subject: [PATCH 22/22] docs: add T66-discovered consume_pending_meanwhile_digests backlog item --- CLAUDE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index bd66fc7..9ac8550 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -290,6 +290,10 @@ New follow-ups discovered during Phase 3 reviews and execution. None are blockin - **Cross-feature canned-queue brittleness**: meanwhile-scene close test required a canned response for T65's digest call after T64+T65 merge. Future close-path additions will keep extending the queue; consider a structured fixture builder rather than positional canned arrays. +#### From T66 integration tests + +- **`consume_pending_meanwhile_digests` is defined but NOT wired into `post_turn`**: the helper lives in `chat/services/prompt.py` (T65) but `chat/web/turns.py` never calls it. Meanwhile digests stay pending forever in production. Phase 3.5 should call the helper after the first you-turn following a meanwhile close — probably right after the assistant_turn lands but before the next prompt assembly. Pinned by `tests/test_phase3_integration.py::test_meanwhile_close_digest_surfaces_then_consumed` which currently calls the helper directly. + #### Discovered during Phase 3 execution - **`_witness_role_for` defensive `host_bot_id is None`** (carry-over from Phase 2.5 T71 backlog) — still pending. -- 2.52.0