merge: T63 meanwhile scene schema + state
This commit is contained in:
@@ -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;
|
||||
@@ -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
|
||||
]
|
||||
@@ -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") == []
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user