From a0d7debce58444b43c8201935d6d9214c2a17784 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 15:46:16 -0400 Subject: [PATCH] feat: group_node schema + projector handlers --- chat/db/migrations/0008_group_node.sql | 8 ++ chat/state/group_node.py | 50 ++++++++++++ tests/test_group_node.py | 101 +++++++++++++++++++++++++ 3 files changed, 159 insertions(+) create mode 100644 chat/db/migrations/0008_group_node.sql create mode 100644 chat/state/group_node.py create mode 100644 tests/test_group_node.py diff --git a/chat/db/migrations/0008_group_node.sql b/chat/db/migrations/0008_group_node.sql new file mode 100644 index 0000000..9a7f0ec --- /dev/null +++ b/chat/db/migrations/0008_group_node.sql @@ -0,0 +1,8 @@ +CREATE TABLE group_node ( + chat_id TEXT PRIMARY KEY, + members_json TEXT NOT NULL, + summary TEXT NOT NULL DEFAULT '', + dynamic TEXT NOT NULL DEFAULT '', + threads_json TEXT NOT NULL DEFAULT '[]', + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); diff --git a/chat/state/group_node.py b/chat/state/group_node.py new file mode 100644 index 0000000..87d795d --- /dev/null +++ b/chat/state/group_node.py @@ -0,0 +1,50 @@ +from __future__ import annotations +import json +from sqlite3 import Connection +from chat.eventlog.projector import on +from chat.eventlog.log import Event + + +@on("group_node_initialized") +def _apply_group_node_initialized(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT OR REPLACE INTO group_node " + "(chat_id, members_json, summary, dynamic, threads_json) " + "VALUES (?, ?, ?, ?, ?)", + ( + p["chat_id"], + json.dumps(p["members"]), + p.get("summary", ""), + p.get("dynamic", ""), + json.dumps(p.get("threads", [])), + ), + ) + + +@on("group_node_updated") +def _apply_group_node_updated(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE group_node SET summary = ?, dynamic = ?, updated_at = datetime('now') " + "WHERE chat_id = ?", + (p.get("summary", ""), p.get("dynamic", ""), p["chat_id"]), + ) + + +def get_group_node(conn: Connection, chat_id: str) -> dict | None: + row = conn.execute( + "SELECT chat_id, members_json, summary, dynamic, threads_json, updated_at " + "FROM group_node WHERE chat_id = ?", + (chat_id,), + ).fetchone() + if not row: + return None + return { + "chat_id": row[0], + "members": json.loads(row[1]), + "summary": row[2], + "dynamic": row[3], + "threads": json.loads(row[4]), + "updated_at": row[5], + } diff --git a/tests/test_group_node.py b/tests/test_group_node.py new file mode 100644 index 0000000..1cf1b42 --- /dev/null +++ b/tests/test_group_node.py @@ -0,0 +1,101 @@ +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.group_node # registers handlers +from chat.state.group_node import get_group_node + + +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_group_node_initialized_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="bot_authored", payload=_bot_payload("bot_b", "BotB")) + append_event(conn, kind="chat_created", payload=_chat_payload()) + append_event( + conn, + kind="group_node_initialized", + payload={ + "chat_id": "chat_bot_a", + "members": ["you", "bot_a", "bot_b"], + }, + ) + project(conn) + + gn = get_group_node(conn, "chat_bot_a") + assert gn is not None + assert gn["chat_id"] == "chat_bot_a" + assert gn["members"] == ["you", "bot_a", "bot_b"] + assert gn["summary"] == "" + assert gn["dynamic"] == "" + assert gn["threads"] == [] + + +def test_group_node_updated_changes_summary_and_dynamic(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="group_node_initialized", + payload={ + "chat_id": "chat_bot_a", + "members": ["you", "bot_a", "bot_b"], + }, + ) + append_event( + conn, + kind="group_node_updated", + payload={ + "chat_id": "chat_bot_a", + "summary": "Three coworkers chatting about the project.", + "dynamic": "Tense but cordial.", + }, + ) + project(conn) + + gn = get_group_node(conn, "chat_bot_a") + assert gn is not None + assert gn["summary"] == "Three coworkers chatting about the project." + assert gn["dynamic"] == "Tense but cordial." + # Members preserved across update + assert gn["members"] == ["you", "bot_a", "bot_b"] + + +def test_get_group_node_returns_none_for_missing_chat(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + assert get_group_node(conn, "chat_missing") is None