From 82be8b3f5146da55d73f8ef82bd1610312838c8d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 14:07:56 -0400 Subject: [PATCH] feat: bot reset with hard confirm and event-driven purge --- chat/services/reset.py | 23 +++++ chat/state/entities.py | 40 ++++++++ chat/templates/bot_list.html | 13 ++- chat/web/bots.py | 19 ++++ tests/test_reset.py | 185 +++++++++++++++++++++++++++++++++++ 5 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 chat/services/reset.py create mode 100644 tests/test_reset.py diff --git a/chat/services/reset.py b/chat/services/reset.py new file mode 100644 index 0000000..a8e573d --- /dev/null +++ b/chat/services/reset.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from sqlite3 import Connection + +from chat.eventlog.log import append_and_apply +from chat.state.entities import get_bot + + +def reset_bot(conn: Connection, bot_id: str, *, confirm_name: str) -> None: + """Reset a bot's runtime state via a ``bot_reset`` event. + + Validates that ``confirm_name`` matches the bot's stored ``name`` + exactly (case-sensitive, no trim). Raises: + + - ``ValueError("bot {bot_id} not found")`` when the bot is missing. + - ``ValueError("confirm_name does not match bot name")`` on mismatch. + """ + bot = get_bot(conn, bot_id) + if bot is None: + raise ValueError(f"bot {bot_id} not found") + if confirm_name != bot["name"]: + raise ValueError("confirm_name does not match bot name") + append_and_apply(conn, kind="bot_reset", payload={"bot_id": bot_id}) diff --git a/chat/state/entities.py b/chat/state/entities.py index 57d0cc3..df14565 100644 --- a/chat/state/entities.py +++ b/chat/state/entities.py @@ -31,6 +31,46 @@ def _apply_you_authored(conn: Connection, e: Event) -> None: ) +@on("bot_reset") +def _apply_bot_reset(conn: Connection, e: Event) -> None: + """Purge per-bot runtime state while preserving the bot's identity row. + + Wipes chats hosted by this bot (with cascading chat-scoped tables), + memories owned by this bot, edges involving this bot, and the bot's own + activity row. The ``bots`` row itself is preserved so identity, + initial-relationship, and kickoff prose remain authored. + """ + bot_id = e.payload["bot_id"] + + chat_ids = [ + row[0] + for row in conn.execute( + "SELECT id FROM chats WHERE host_bot_id = ?", (bot_id,) + ).fetchall() + ] + 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,)) + conn.execute("DELETE FROM chat_state WHERE chat_id = ?", (chat_id,)) + conn.execute("DELETE FROM chats WHERE id = ?", (chat_id,)) + + # Activity for this bot's entity row (independent of chat_id since the + # ``activity`` table is keyed on entity_id). + conn.execute("DELETE FROM activity WHERE entity_id = ?", (bot_id,)) + + # Memories authored by this bot. + conn.execute("DELETE FROM memories WHERE owner_id = ?", (bot_id,)) + + # Edges in either direction involving this bot. + conn.execute( + "DELETE FROM edges WHERE source_id = ? OR target_id = ?", + (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. + + def get_bot(conn: Connection, bot_id: str) -> dict | None: row = conn.execute("SELECT * FROM bots WHERE id = ?", (bot_id,)).fetchone() if not row: diff --git a/chat/templates/bot_list.html b/chat/templates/bot_list.html index 7a2a65b..247d886 100644 --- a/chat/templates/bot_list.html +++ b/chat/templates/bot_list.html @@ -8,7 +8,18 @@ {% if bots %} {% else %} diff --git a/chat/web/bots.py b/chat/web/bots.py index d06ba7b..d9666f3 100644 --- a/chat/web/bots.py +++ b/chat/web/bots.py @@ -118,3 +118,22 @@ async def bot_create( append_event(conn, kind="bot_authored", payload=payload) project(conn) return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303) + + +@router.post("/bots/{bot_id}/reset") +async def reset_bot_route( + bot_id: str, + request: Request, + confirm_name: str = Form(""), + conn=Depends(get_conn), +): + from chat.services.reset import reset_bot + + try: + reset_bot(conn, bot_id, confirm_name=confirm_name) + except ValueError as e: + msg = str(e).lower() + if "not found" in msg: + raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) + return RedirectResponse(url="/bots", status_code=303) diff --git a/tests/test_reset.py b/tests/test_reset.py new file mode 100644 index 0000000..7d3c1b8 --- /dev/null +++ b/tests/test_reset.py @@ -0,0 +1,185 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from chat.app import app +from chat.db.connection import open_db +from chat.eventlog.log import append_event +from chat.eventlog.projector import project + + +@pytest.fixture +def client(tmp_path, monkeypatch): + cfg = tmp_path / "config.toml" + cfg.write_text('featherless_api_key = "test"\n') + monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg)) + db = tmp_path / "test.db" + monkeypatch.setenv("CHAT_DB_PATH", str(db)) + with TestClient(app) as c: + if hasattr(app.state, "background_worker"): + app.state.background_worker.enabled = False + yield c + + +def _seed_bot_with_state(db: Path) -> None: + """Seed a bot plus a chat, container, scene, edge, memory, and activity row.""" + with open_db(db) as conn: + append_event( + conn, + kind="bot_authored", + payload={ + "id": "bot_a", + "name": "BotA", + "persona": "thoughtful, observant", + "voice_samples": [], + "traits": ["shy"], + "backstory": "", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "you stay late", + }, + ) + 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="scene_opened", + payload={ + "chat_id": "chat_bot_a", + "container_id": 1, + "started_at": "2026-04-26T20:00:00+00:00", + "participants": ["you", "bot_a"], + }, + ) + append_event( + conn, + kind="edge_update", + payload={ + "source_id": "bot_a", + "target_id": "you", + "chat_id": "chat_bot_a", + "affinity_delta": 5, + "trust_delta": 2, + }, + ) + append_event( + conn, + kind="memory_written", + payload={ + "owner_id": "bot_a", + "chat_id": "chat_bot_a", + "pov_summary": "Talked about her sister", + "witness_you": 1, + "witness_host": 1, + "witness_guest": 0, + "significance": 2, + }, + ) + append_event( + conn, + kind="activity_change", + payload={ + "entity_id": "bot_a", + "posture": "sitting", + "action": {"verb": "writing"}, + }, + ) + project(conn) + + +def test_reset_purges_state_but_preserves_identity(client, tmp_path): + _seed_bot_with_state(tmp_path / "test.db") + response = client.post( + "/bots/bot_a/reset", + data={"confirm_name": "BotA"}, + follow_redirects=False, + ) + assert response.status_code == 303 + assert response.headers["location"] == "/bots" + + with open_db(tmp_path / "test.db") as conn: + # Identity preserved. + bot = conn.execute( + "SELECT id, name, kickoff_prose, initial_relationship_to_you " + "FROM bots WHERE id = 'bot_a'" + ).fetchone() + assert bot is not None + assert bot[1] == "BotA" + assert bot[2] == "you stay late" + assert bot[3] == "coworker" + + # State purged. + assert conn.execute( + "SELECT COUNT(*) FROM chats WHERE host_bot_id = 'bot_a'" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT COUNT(*) FROM scenes WHERE chat_id = 'chat_bot_a'" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT COUNT(*) FROM containers WHERE chat_id = 'chat_bot_a'" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT COUNT(*) FROM chat_state WHERE chat_id = 'chat_bot_a'" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT COUNT(*) FROM memories WHERE owner_id = 'bot_a'" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT COUNT(*) FROM edges WHERE source_id = 'bot_a' OR target_id = 'bot_a'" + ).fetchone()[0] == 0 + assert conn.execute( + "SELECT COUNT(*) FROM activity WHERE entity_id = 'bot_a'" + ).fetchone()[0] == 0 + + # Event log records the bot_reset event. + assert conn.execute( + "SELECT COUNT(*) FROM event_log WHERE kind = 'bot_reset'" + ).fetchone()[0] == 1 + + +def test_reset_400_when_confirm_name_mismatch(client, tmp_path): + _seed_bot_with_state(tmp_path / "test.db") + response = client.post( + "/bots/bot_a/reset", + data={"confirm_name": "WrongName"}, + follow_redirects=False, + ) + assert response.status_code == 400 + + +def test_reset_404_when_bot_missing(client): + response = client.post( + "/bots/no_such/reset", + data={"confirm_name": "Anything"}, + follow_redirects=False, + ) + assert response.status_code == 404 + + +def test_bot_list_renders_reset_form(client, tmp_path): + _seed_bot_with_state(tmp_path / "test.db") + response = client.get("/bots") + assert response.status_code == 200 + assert "Reset" in response.text + assert "confirm_name" in response.text