diff --git a/chat/state/entities.py b/chat/state/entities.py index 7a00edc..47e8093 100644 --- a/chat/state/entities.py +++ b/chat/state/entities.py @@ -48,6 +48,17 @@ def _apply_bot_reset(conn: Connection, e: Event) -> None: "SELECT id FROM chats WHERE host_bot_id = ?", (bot_id,) ).fetchall() ] + # T69: purge orphaned "you" activity rows pointing at containers in this + # bot's chats BEFORE the containers/chats themselves are deleted, otherwise + # the subqueries find nothing and the FK constraint on activity.container_id + # blocks the container delete. + conn.execute( + "DELETE FROM activity WHERE entity_id = 'you' " + "AND container_id IN (SELECT id FROM containers WHERE chat_id IN (" + " SELECT id FROM chats WHERE host_bot_id = ?" + "))", + (bot_id,), + ) for chat_id in chat_ids: conn.execute("DELETE FROM scenes WHERE chat_id = ?", (chat_id,)) conn.execute("DELETE FROM containers WHERE chat_id = ?", (chat_id,)) @@ -74,8 +85,6 @@ def _apply_bot_reset(conn: Connection, e: Event) -> None: (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. def get_bot(conn: Connection, bot_id: str) -> dict | None: diff --git a/tests/test_reset.py b/tests/test_reset.py index abfd835..797bce4 100644 --- a/tests/test_reset.py +++ b/tests/test_reset.py @@ -292,6 +292,189 @@ def test_reset_clears_guest_reference_in_other_chats(client, tmp_path): ).fetchone()[0] == 1 +def test_reset_purges_orphaned_you_activity_rows(client, tmp_path): + """T69: when a bot's chats are deleted, "you" activity rows tied to those + chats' containers should also be purged (otherwise they linger orphaned).""" + db = tmp_path / "test.db" + with open_db(db) as conn: + 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": "", + }, + ) + append_event( + conn, + kind="container_created", + payload={ + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": {}, + }, + ) + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": "you", + "container_id": 1, + "posture": "standing", + "action": {"verb": "watching"}, + }, + ) + project(conn) + # Sanity: the "you" activity row exists and points at the container. + assert conn.execute( + "SELECT COUNT(*) FROM activity WHERE entity_id = 'you'" + ).fetchone()[0] == 1 + + response = client.post( + "/bots/bot_a/reset", + data={"confirm_name": "BotA"}, + follow_redirects=False, + ) + assert response.status_code == 303 + + with open_db(db) as conn: + # The orphaned "you" activity row tied to bot_a's purged container is gone. + assert conn.execute( + "SELECT COUNT(*) FROM activity WHERE entity_id = 'you'" + ).fetchone()[0] == 0 + + +def test_reset_does_not_purge_you_activity_in_other_chats(client, tmp_path): + """T69: resetting bot_a must leave a "you" activity row pointing at + bot_b's container intact — only orphans from the reset bot's chats go.""" + db = tmp_path / "test.db" + with open_db(db) as conn: + # bot_a + its chat + container. + 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": "", + }, + ) + append_event( + conn, + kind="container_created", + payload={ + "chat_id": "chat_bot_a", + "name": "office", + "type": "workplace", + "properties": {}, + }, + ) + # bot_b + its chat + container. + 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": "", + }, + ) + append_event( + conn, + kind="container_created", + payload={ + "chat_id": "chat_bot_b", + "name": "kitchen", + "type": "home", + "properties": {}, + }, + ) + # The activity table is keyed on entity_id (PRIMARY KEY), so only one + # "you" row exists at a time. Point it at bot_b's container so reset of + # bot_a should NOT touch it. + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": "you", + "container_id": 2, # kitchen, in chat_bot_b + "posture": "sitting", + "action": {"verb": "reading"}, + }, + ) + project(conn) + # Sanity: the "you" activity row is in bot_b's container. + row = conn.execute( + "SELECT container_id FROM activity WHERE entity_id = 'you'" + ).fetchone() + assert row is not None and row[0] == 2 + + response = client.post( + "/bots/bot_a/reset", + data={"confirm_name": "BotA"}, + follow_redirects=False, + ) + assert response.status_code == 303 + + with open_db(db) as conn: + # The "you" activity in bot_b's container is preserved. + row = conn.execute( + "SELECT container_id FROM activity WHERE entity_id = 'you'" + ).fetchone() + assert row is not None + assert row[0] == 2 + + def test_reset_purges_guest_memories_from_other_chats(client, tmp_path): db = tmp_path / "test.db" _seed_two_bots_with_guest_link(