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