From fb17ba0657b3bbe7909aeb17f286cacbc77db362 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 16:25:37 -0400 Subject: [PATCH] fix: bot_reset cascades to guest references in other chats --- chat/state/entities.py | 7 ++ tests/test_reset.py | 164 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) diff --git a/chat/state/entities.py b/chat/state/entities.py index df14565..7a00edc 100644 --- a/chat/state/entities.py +++ b/chat/state/entities.py @@ -66,6 +66,13 @@ def _apply_bot_reset(conn: Connection, e: Event) -> None: "DELETE FROM edges WHERE source_id = ? OR target_id = ?", (bot_id, bot_id), ) + + # Phase 2 cascade: clear guest references in other bots' chats so the host + # doesn't see a stale guest_bot_id pointing at this (now-purged) bot. + conn.execute( + "UPDATE chats SET guest_bot_id = NULL WHERE guest_bot_id = ?", + (bot_id,), + ) # NOTE: bots row itself is preserved (identity, kickoff_prose intact). # NOTE: "you" activity (entity_id="you") may linger from a deleted chat; # acceptable for v1 — Phase 1.5 cleanup if needed. diff --git a/tests/test_reset.py b/tests/test_reset.py index 7d3c1b8..abfd835 100644 --- a/tests/test_reset.py +++ b/tests/test_reset.py @@ -183,3 +183,167 @@ def test_bot_list_renders_reset_form(client, tmp_path): assert response.status_code == 200 assert "Reset" in response.text assert "confirm_name" in response.text + + +def _seed_two_bots_with_guest_link( + db: Path, *, extra_events: list[dict] | None = None +) -> None: + """Seed bot_a + bot_b, each hosting their own chat, with bot_b a guest in chat_bot_a. + + ``extra_events`` is appended after the guest_added event and projected + together with the rest of the seed (so handlers run only once per event). + """ + with open_db(db) as conn: + # bot_a + its chat + append_event( + conn, + kind="bot_authored", + payload={ + "id": "bot_a", + "name": "BotA", + "persona": "thoughtful", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "", + }, + ) + 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": "", + }, + ) + # bot_b + its own chat + append_event( + conn, + kind="bot_authored", + payload={ + "id": "bot_b", + "name": "BotB", + "persona": "curious", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "friend", + "kickoff_prose": "", + }, + ) + append_event( + conn, + kind="chat_created", + payload={ + "id": "chat_bot_b", + "host_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1", + "weather": "", + }, + ) + # bot_b joins chat_bot_a as a guest. + append_event( + conn, + kind="guest_added", + payload={ + "chat_id": "chat_bot_a", + "guest_bot_id": "bot_b", + }, + ) + for ev in extra_events or []: + append_event(conn, kind=ev["kind"], payload=ev["payload"]) + project(conn) + + +def test_reset_clears_guest_reference_in_other_chats(client, tmp_path): + db = tmp_path / "test.db" + _seed_two_bots_with_guest_link(db) + + # Sanity-check the seed: bot_b is the guest in bot_a's chat. + from chat.state.world import get_chat + with open_db(db) as conn: + assert get_chat(conn, "chat_bot_a")["guest_bot_id"] == "bot_b" + assert get_chat(conn, "chat_bot_b") is not None + + response = client.post( + "/bots/bot_b/reset", + data={"confirm_name": "BotB"}, + follow_redirects=False, + ) + assert response.status_code == 303 + + with open_db(db) as conn: + # The guest reference in bot_a's chat is cleared. + chat_a = get_chat(conn, "chat_bot_a") + assert chat_a is not None + assert chat_a["guest_bot_id"] is None + + # bot_b's own chat is gone (Phase 1 host purge behavior). + assert get_chat(conn, "chat_bot_b") is None + + # bot_a is untouched. + assert conn.execute( + "SELECT COUNT(*) FROM bots WHERE id = 'bot_a'" + ).fetchone()[0] == 1 + + +def test_reset_purges_guest_memories_from_other_chats(client, tmp_path): + db = tmp_path / "test.db" + _seed_two_bots_with_guest_link( + db, + extra_events=[ + # bot_b is a guest in chat_bot_a and remembers things from there. + { + "kind": "memory_written", + "payload": { + "owner_id": "bot_b", + "chat_id": "chat_bot_a", + "pov_summary": "Met BotA; she was tense.", + "witness_you": 1, + "witness_host": 1, + "witness_guest": 1, + "significance": 3, + }, + }, + # And a memory from bot_b's own chat for good measure. + { + "kind": "memory_written", + "payload": { + "owner_id": "bot_b", + "chat_id": "chat_bot_b", + "pov_summary": "A quiet evening at home.", + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "significance": 1, + }, + }, + ], + ) + + with open_db(db) as conn: + # Sanity: bot_b owns 2 memories pre-reset, one in each chat. + assert conn.execute( + "SELECT COUNT(*) FROM memories WHERE owner_id = 'bot_b'" + ).fetchone()[0] == 2 + + response = client.post( + "/bots/bot_b/reset", + data={"confirm_name": "BotB"}, + follow_redirects=False, + ) + assert response.status_code == 303 + + with open_db(db) as conn: + # ALL of bot_b's memories are gone, including the cross-chat one in chat_bot_a. + assert conn.execute( + "SELECT COUNT(*) FROM memories WHERE owner_id = 'bot_b'" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT COUNT(*) FROM memories WHERE owner_id = 'bot_b' AND chat_id = 'chat_bot_a'" + ).fetchone()[0] == 0