diff --git a/chat/services/regenerate.py b/chat/services/regenerate.py index 6565aea..c61b684 100644 --- a/chat/services/regenerate.py +++ b/chat/services/regenerate.py @@ -148,6 +148,13 @@ async def regenerate_assistant_turn( # the silent witness (the bot that wasn't the primary addressee). # Filter on ``superseded_by IS NULL`` so prior regenerates of this # group don't reappear as siblings. + # + # T83.3: push the chat_id filter into SQL via ``json_extract`` so + # the query doesn't scan every assistant_turn row across the whole + # database. ``LIMIT 50`` bounds worst-case work even when chat_id + # isn't selective (e.g. a single chat with many turns) — we only + # need the one matching sibling. Mirrors the SQL pattern in + # ``chat.web.meanwhile._last_meanwhile_speaker``. original_interjection_event_id: int | None = None original_interjection_payload: dict | None = None if original_user_turn_id is not None: @@ -155,8 +162,11 @@ async def regenerate_assistant_turn( "SELECT id, payload_json FROM event_log " "WHERE kind = 'assistant_turn' " " AND id != ? " - " AND superseded_by IS NULL", - (original_assistant_event_id,), + " AND superseded_by IS NULL " + " AND json_extract(payload_json, '$.chat_id') = ? " + "ORDER BY id DESC " + "LIMIT 50", + (original_assistant_event_id, chat_id), ) for sib_id, sib_payload_json in sibling_cur.fetchall(): sib_payload = json.loads(sib_payload_json) diff --git a/tests/test_regenerate.py b/tests/test_regenerate.py index f065980..0ffbd37 100644 --- a/tests/test_regenerate.py +++ b/tests/test_regenerate.py @@ -664,6 +664,182 @@ def test_regenerate_drops_interjection_when_classifier_returns_false( assert "interjection_of" not in new_primary_payload +def test_regenerate_sibling_lookup_scoped_to_chat(tmp_path, monkeypatch): + """T83.3: regenerate's sibling-interjection lookup is scoped to the + chat being regenerated. + + Setup: TWO chats, each with a primary + interjection turn group whose + rows happen to share the same ``user_turn_id`` value (the projector + assigns event_log ids monotonically across the whole database, so + when each chat is seeded back-to-back the chat A primary lands on a + different ``user_turn_id`` than chat B's — but in older versions the + sibling query had no chat predicate, so it could in principle latch + onto a row from a different chat if ids collided in some unusual + flow). We construct the seeding so chat B's interjection has the + SAME ``interjection_of`` value as the chat A primary's speaker_id — + pre-T83.3 the global query could have picked it up. + + Assert: regenerating the chat A primary leaves chat B's rows + untouched (no supersede), and the regenerated chat A turn group's + interjection (the only one regenerate should regenerate) has its + ``regenerated_from`` pointing at the chat A original interjection, + not chat B's. + """ + import asyncio + + from chat.config import Settings + from chat.db.migrate import apply_migrations + from chat.services import regenerate as regenerate_module + from chat.services.interjection import InterjectionDecision + from chat.services.regenerate import regenerate_assistant_turn + + db_path = tmp_path / "test.db" + cfg = tmp_path / "config.toml" + cfg.write_text('featherless_api_key = "test"\n') + monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg)) + monkeypatch.setenv("CHAT_DB_PATH", str(db_path)) + apply_migrations(db_path) + + # Seed chat A's interjection group. + a_ut_id, a_primary_id, a_interjection_id = _seed_with_interjection_group( + db_path + ) + + # Seed chat B with the same shape but a different chat_id and bot + # ids, then add an interjection group whose ``interjection_of`` + # points at "bot_a" so a global (unscoped) query could collide. + with open_db(db_path) as conn: + for bot_id, name in (("bot_c", "BotC"), ("bot_d", "BotD")): + append_event( + conn, + kind="bot_authored", + payload={ + "id": bot_id, + "name": name, + "persona": "", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "", + "kickoff_prose": "", + }, + ) + append_event( + conn, + kind="chat_created", + payload={ + "id": "chat_other", + "host_bot_id": "bot_c", + "guest_bot_id": "bot_d", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1", + "weather": "", + }, + ) + b_ut_id = append_event( + conn, + kind="user_turn", + payload={ + "chat_id": "chat_other", + "prose": "different chat", + "segments": [], + }, + ) + b_primary_id = append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_other", + "speaker_id": "bot_c", + "text": "Other primary.", + "truncated": False, + "user_turn_id": b_ut_id, + }, + ) + # The chat B interjection's ``interjection_of`` references + # "bot_a" — the chat A primary's speaker. Pre-T83.3 the global + # sibling query could mis-match this row. + b_interjection_id = append_event( + conn, + kind="assistant_turn", + payload={ + "chat_id": "chat_other", + "speaker_id": "bot_d", + "text": "Cross-chat noise.", + "truncated": False, + "user_turn_id": b_ut_id, + "interjection_of": "bot_a", + }, + ) + + # Stub the interjection classifier to return True so the regenerate + # actively walks the sibling-discovery path. + async def _stub_should_interject(*_args, **_kwargs): + return InterjectionDecision(should_interject=True, reason="fired") + + monkeypatch.setattr( + regenerate_module, "detect_interjection", _stub_should_interject + ) + + state_canned = json.dumps( + {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} + ) + canned: list[str] = ( + ["New chat A primary."] + + [state_canned] * 6 + + ["New chat A interjection."] + + [state_canned] * 6 + ) + mock_client = MockLLMClient(canned=list(canned)) + settings = Settings(featherless_api_key="test") + + with open_db(db_path) as conn: + new_text = asyncio.run( + regenerate_assistant_turn( + conn, + mock_client, + settings=settings, + chat_id="chat_multi", + original_assistant_event_id=a_primary_id, + ) + ) + assert new_text == "New chat A primary." + + # Chat B rows are untouched — neither superseded nor referenced. + b_primary_super = conn.execute( + "SELECT superseded_by FROM event_log WHERE id = ?", + (b_primary_id,), + ).fetchone()[0] + b_interjection_super = conn.execute( + "SELECT superseded_by FROM event_log WHERE id = ?", + (b_interjection_id,), + ).fetchone()[0] + assert b_primary_super is None + assert b_interjection_super is None + + # Chat A's regenerated interjection has its ``regenerated_from`` + # pointing at chat A's original interjection — NOT chat B's. + cur = conn.execute( + "SELECT payload_json FROM event_log " + "WHERE kind = 'assistant_turn' " + " AND id NOT IN (?, ?, ?, ?) " + " AND superseded_by IS NULL", + (a_primary_id, a_interjection_id, b_primary_id, b_interjection_id), + ).fetchall() + # Two new rows: regenerated primary + regenerated interjection. + assert len(cur) == 2 + payloads = [json.loads(row[0]) for row in cur] + # Find the regenerated interjection (carries interjection_of). + new_interject_payloads = [ + p for p in payloads if p.get("interjection_of") + ] + assert len(new_interject_payloads) == 1 + assert new_interject_payloads[0]["regenerated_from"] == a_interjection_id + # Pin chat scope on every new row. + for p in payloads: + assert p["chat_id"] == "chat_multi" + + def test_regenerate_registers_task_in_in_flight_tasks(tmp_path, monkeypatch): """T83.1: regenerate's streaming Task is registered in the chat-keyed ``_in_flight_tasks`` dict so the /turns/cancel route can cancel a