"""Regenerate flow (T29). POST ``/chats//turns//regenerate`` re-streams the assistant turn, supersedes the prior ``assistant_turn`` event, and — when prose is supplied — captures a ``user_turn_edit`` event that supersedes the original ``user_turn``. These tests cover the functional core required by the plan: - Without edit: a new ``assistant_turn`` is appended; the original is marked ``superseded_by`` the new one. - With edit: a ``user_turn_edit`` event is appended; the original ``user_turn`` is also marked ``superseded_by``. - Missing event id returns 404. """ from __future__ import annotations import json 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 from chat.llm.mock import MockLLMClient @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: # Disable lifespan-managed background worker (would otherwise try # to score significance through Featherless with the test key). if hasattr(app.state, "background_worker"): app.state.background_worker.enabled = False yield c app.dependency_overrides.clear() def _seed_with_one_turn(db_path): """Seed bot, chat, edges/activity, and ONE round of user_turn + assistant_turn. Returns ``(user_turn_event_id, assistant_turn_event_id)``. """ with open_db(db_path) as conn: append_event( conn, kind="bot_authored", payload={ "id": "bot_a", "name": "BotA", "persona": "thoughtful", "voice_samples": [], "traits": [], "backstory": "", "initial_relationship_to_you": "", "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="edge_update", payload={ "source_id": "bot_a", "target_id": "you", "chat_id": "chat_bot_a", }, ) append_event( conn, kind="edge_update", payload={ "source_id": "you", "target_id": "bot_a", "chat_id": "chat_bot_a", }, ) append_event( conn, kind="activity_change", payload={ "entity_id": "you", "posture": "sitting", "action": {"verb": "talking"}, "attention": "", "holding": [], "status": {}, }, ) append_event( conn, kind="activity_change", payload={ "entity_id": "bot_a", "posture": "sitting", "action": {"verb": "listening"}, "attention": "", "holding": [], "status": {}, }, ) # First round: user_turn + assistant_turn. ut_id = append_event( conn, kind="user_turn", payload={ "chat_id": "chat_bot_a", "prose": "hello", "segments": [], }, ) at_id = append_event( conn, kind="assistant_turn", payload={ "chat_id": "chat_bot_a", "speaker_id": "bot_a", "text": "Original response.", "truncated": False, "user_turn_id": ut_id, }, ) project(conn) return ut_id, at_id def test_regenerate_without_edit_creates_new_assistant_turn(client, tmp_path): """Reissuing the regenerate POST with no prose should: - Stream a new ``assistant_turn`` carrying ``regenerated_from`` and the canned narrative text. - Mark the original ``assistant_turn`` row as ``superseded_by`` the new one. """ ut_id, at_id = _seed_with_one_turn(tmp_path / "test.db") narrative_canned = "New response." state_canned = json.dumps( {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} ) canned = [narrative_canned, state_canned, state_canned] from chat.web.kickoff import get_llm_client app.dependency_overrides[get_llm_client] = lambda: MockLLMClient( canned=list(canned) ) try: response = client.post( f"/chats/chat_bot_a/turns/{at_id}/regenerate", data={} ) assert response.status_code == 204 finally: app.dependency_overrides.clear() with open_db(tmp_path / "test.db") as conn: # Original assistant_turn is now superseded. row = conn.execute( "SELECT superseded_by FROM event_log WHERE id = ?", (at_id,) ).fetchone() assert row[0] is not None # A new assistant_turn exists, links back to the original, and # carries the canned narrative text. cur = conn.execute( "SELECT id, payload_json FROM event_log " "WHERE kind = 'assistant_turn' AND id != ? " "AND superseded_by IS NULL", (at_id,), ).fetchall() assert len(cur) == 1 new_id, new_payload_json = cur[0] new_payload = json.loads(new_payload_json) assert new_payload["text"] == "New response." assert new_payload["regenerated_from"] == at_id # The original assistant_turn's superseded_by points at the new one. assert row[0] == new_id # The original user_turn is NOT touched when no prose was supplied. ut_row = conn.execute( "SELECT superseded_by FROM event_log WHERE id = ?", (ut_id,) ).fetchone() assert ut_row[0] is None def test_regenerate_with_edit_appends_user_turn_edit(client, tmp_path): """Supplying ``prose`` should: - Append a ``user_turn_edit`` event whose payload references the original user_turn id and carries the edited prose. - Mark the original ``user_turn`` as ``superseded_by`` the edit. """ ut_id, at_id = _seed_with_one_turn(tmp_path / "test.db") narrative_canned = "Reply to edited." state_canned = json.dumps( {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} ) canned = [narrative_canned, state_canned, state_canned] from chat.web.kickoff import get_llm_client app.dependency_overrides[get_llm_client] = lambda: MockLLMClient( canned=list(canned) ) try: response = client.post( f"/chats/chat_bot_a/turns/{at_id}/regenerate", data={"prose": "edited prose"}, ) assert response.status_code == 204 finally: app.dependency_overrides.clear() with open_db(tmp_path / "test.db") as conn: # A user_turn_edit event was appended with the edited prose and # a back-pointer to the original user_turn. cur = conn.execute( "SELECT payload_json FROM event_log WHERE kind = 'user_turn_edit'" ).fetchall() assert len(cur) == 1 edit_payload = json.loads(cur[0][0]) assert edit_payload["prose"] == "edited prose" assert edit_payload["supersedes_user_turn_id"] == ut_id assert edit_payload["chat_id"] == "chat_bot_a" # Original user_turn is now superseded. ut_row = conn.execute( "SELECT superseded_by FROM event_log WHERE id = ?", (ut_id,) ).fetchone() assert ut_row[0] is not None # Original assistant_turn is also superseded by the new one. at_row = conn.execute( "SELECT superseded_by FROM event_log WHERE id = ?", (at_id,) ).fetchone() assert at_row[0] is not None def test_regenerate_404_when_assistant_turn_missing(client, tmp_path): """An unknown ``event_id`` returns 404.""" _seed_with_one_turn(tmp_path / "test.db") from chat.web.kickoff import get_llm_client app.dependency_overrides[get_llm_client] = lambda: MockLLMClient( canned=["x", "y", "z"] ) try: response = client.post( "/chats/chat_bot_a/turns/99999/regenerate", data={} ) assert response.status_code == 404 finally: app.dependency_overrides.clear() def _seed_with_interjection_group(db_path): """Seed a multi-entity scene with a (primary + interjection) group. Returns ``(user_turn_id, primary_at_id, interjection_at_id)``. The primary speaker is the host (bot_a); the silent witness who interjected is the guest (bot_b). Mirrors the convention in chat/web/turns.py — both assistant_turns share the same ``user_turn_id`` and the interjection's payload carries ``interjection_of=``. """ with open_db(db_path) as conn: for bot_id, name, persona in ( ("bot_a", "BotA", "thoughtful"), ("bot_b", "BotB", "loud"), ): append_event( conn, kind="bot_authored", payload={ "id": bot_id, "name": name, "persona": persona, "voice_samples": [], "traits": [], "backstory": "", "initial_relationship_to_you": "", "kickoff_prose": "", }, ) append_event( conn, kind="chat_created", payload={ "id": "chat_multi", "host_bot_id": "bot_a", "guest_bot_id": "bot_b", "initial_time": "2026-04-26T20:00:00+00:00", "narrative_anchor": "Day 1", "weather": "", }, ) for src, tgt in ( ("bot_a", "you"), ("you", "bot_a"), ("bot_b", "you"), ("you", "bot_b"), ("bot_a", "bot_b"), ("bot_b", "bot_a"), ): append_event( conn, kind="edge_update", payload={ "source_id": src, "target_id": tgt, "chat_id": "chat_multi", }, ) for entity_id in ("you", "bot_a", "bot_b"): append_event( conn, kind="activity_change", payload={ "entity_id": entity_id, "posture": "sitting", "action": {"verb": "talking"}, "attention": "", "holding": [], "status": {}, }, ) ut_id = append_event( conn, kind="user_turn", payload={ "chat_id": "chat_multi", "prose": "hello", "segments": [], }, ) primary_id = append_event( conn, kind="assistant_turn", payload={ "chat_id": "chat_multi", "speaker_id": "bot_a", "text": "Original primary.", "truncated": False, "user_turn_id": ut_id, }, ) interjection_id = append_event( conn, kind="assistant_turn", payload={ "chat_id": "chat_multi", "speaker_id": "bot_b", "text": "Original interjection!", "truncated": False, "user_turn_id": ut_id, "interjection_of": "bot_a", }, ) project(conn) return ut_id, primary_id, interjection_id def test_regenerate_broadcasts_turn_html_over_sse( tmp_path, monkeypatch ): """T73.1: regenerate publishes a ``turn_html_replace`` SSE event so connected tabs swap the prior turn's DOM node in place. The event carries: - ``data``: rendered HTML for the new turn - ``turn_id``: event_id of the new assistant_turn - ``supersedes_id``: event_id of the original assistant_turn """ 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.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) ut_id, at_id = _seed_with_one_turn(db_path) published: list[tuple[str, dict]] = [] async def _capture(chat_id, event): published.append((chat_id, event)) # Patch the imported reference inside the regenerate module so the # service's call site goes through our spy. monkeypatch.setattr(regenerate_module, "publish", _capture) narrative_canned = "Refreshed reply." state_canned = json.dumps( {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} ) canned = [narrative_canned, state_canned, state_canned] 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_bot_a", original_assistant_event_id=at_id, ) ) assert new_text == narrative_canned # Find the new assistant_turn event_id for cross-checking. cur = conn.execute( "SELECT id FROM event_log " "WHERE kind = 'assistant_turn' AND id != ? " "AND superseded_by IS NULL", (at_id,), ).fetchone() new_at_id = cur[0] # Filter out per-token publishes; we want the replace broadcast. replace_calls = [ ev for (_cid, ev) in published if ev.get("event") == "turn_html_replace" ] assert len(replace_calls) == 1 payload = replace_calls[0] assert payload["supersedes_id"] == at_id assert payload["turn_id"] == new_at_id # The HTML carries the new narrative text and the speaker name. assert "Refreshed reply." in payload["data"] assert "BotA" in payload["data"] # Sanity: every publish targeted this chat. for cid, _ev in published: assert cid == "chat_bot_a" def test_regenerate_with_interjection_redoes_both_turns(tmp_path, monkeypatch): """T73.2: when the original turn group included an interjection, both the primary and the interjection are regenerated. Setup: 3-entity scene (host BotA + guest BotB + you) with a prior (primary by BotA + interjection by BotB) group. Mock the interjection classifier to return ``should_interject=True`` so the follow-on regenerates too. Assert: 2 new assistant_turns exist for the same user_turn_id, the second carrying ``interjection_of`` pointing at the new primary's speaker_id. Both originals are superseded. """ 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) ut_id, primary_id, interjection_id = _seed_with_interjection_group(db_path) # Stub detect_interjection so the classifier "fires" with new prose. async def _stub_should_interject(*_args, **_kwargs): return InterjectionDecision(should_interject=True, reason="fired") monkeypatch.setattr( regenerate_module, "detect_interjection", _stub_should_interject ) # Canned queue: # 1. New primary narrative stream. # 2-7. Six state-update classifier calls (one per directed pair # across host/you/guest = 6 pairs) for the primary pass. # 8. New interjection narrative stream. # 9-14. Six state-update classifier calls for the post-interjection # pass. state_canned = json.dumps( {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} ) canned: list[str] = [] canned.append("New primary text.") canned.extend([state_canned] * 6) canned.append("New interjection text!") canned.extend([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=primary_id, ) ) assert new_text == "New primary text." # Both originals are superseded. primary_super = conn.execute( "SELECT superseded_by FROM event_log WHERE id = ?", (primary_id,) ).fetchone()[0] interjection_super = conn.execute( "SELECT superseded_by FROM event_log WHERE id = ?", (interjection_id,), ).fetchone()[0] assert primary_super is not None assert interjection_super is not None # Two NEW assistant_turn events exist (the regenerated primary # and the regenerated interjection), both pinned to the same # user_turn_id as the originals. cur = conn.execute( "SELECT id, payload_json FROM event_log " "WHERE kind = 'assistant_turn' AND id NOT IN (?, ?) " "ORDER BY id", (primary_id, interjection_id), ).fetchall() assert len(cur) == 2 new_primary_id, new_primary_payload_json = cur[0] new_interjection_id, new_interjection_payload_json = cur[1] new_primary_payload = json.loads(new_primary_payload_json) new_interjection_payload = json.loads(new_interjection_payload_json) assert new_primary_payload["text"] == "New primary text." assert new_primary_payload["speaker_id"] == "bot_a" assert new_primary_payload["user_turn_id"] == ut_id assert new_primary_payload["regenerated_from"] == primary_id assert "interjection_of" not in new_primary_payload assert new_interjection_payload["text"] == "New interjection text!" assert new_interjection_payload["speaker_id"] == "bot_b" assert new_interjection_payload["user_turn_id"] == ut_id assert new_interjection_payload["regenerated_from"] == interjection_id # interjection_of links to the new primary's speaker (matches # the existing convention in chat/web/turns.py). assert new_interjection_payload["interjection_of"] == "bot_a" # The originals' supersede pointers reach the new ones. assert primary_super == new_primary_id assert interjection_super == new_interjection_id def test_regenerate_drops_interjection_when_classifier_returns_false( tmp_path, monkeypatch ): """T73.2: when the original group included an interjection but the classifier returns False this time, the new group is primary-only. The original interjection is still superseded (we don't leave it visible in the timeline alongside a regenerated primary it no longer follows from), but no replacement assistant_turn is appended. """ 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) ut_id, primary_id, interjection_id = _seed_with_interjection_group(db_path) async def _stub_no_interject(*_args, **_kwargs): return InterjectionDecision( should_interject=False, reason="quiet" ) monkeypatch.setattr( regenerate_module, "detect_interjection", _stub_no_interject ) # Canned queue: primary narrative + 6 state-update calls. No # interjection stream because the classifier short-circuits. state_canned = json.dumps( {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} ) canned: list[str] = ["New primary text."] + [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=primary_id, ) ) assert new_text == "New primary text." # Original primary superseded by the new primary. primary_super = conn.execute( "SELECT superseded_by FROM event_log WHERE id = ?", (primary_id,) ).fetchone()[0] # Original interjection ALSO superseded — we don't leave a # dangling beat attached to a regenerated primary that no longer # warrants a follow-on. Back-pointer goes to the new primary. interjection_super = conn.execute( "SELECT superseded_by FROM event_log WHERE id = ?", (interjection_id,), ).fetchone()[0] assert primary_super is not None assert interjection_super is not None assert interjection_super == primary_super # both point at new primary # Exactly ONE new assistant_turn — the primary; no replacement # interjection. cur = conn.execute( "SELECT payload_json FROM event_log " "WHERE kind = 'assistant_turn' AND id NOT IN (?, ?) " "AND superseded_by IS NULL", (primary_id, interjection_id), ).fetchall() assert len(cur) == 1 new_primary_payload = json.loads(cur[0][0]) assert new_primary_payload["text"] == "New primary text." assert "interjection_of" not in new_primary_payload def test_regenerate_with_prior_lifecycle_logs_warning(tmp_path, monkeypatch, caplog): """T83.4: when the superseded assistant_turn already produced lifecycle transitions (event_started / event_completed / event_cancelled), regenerate emits a WARNING naming the un-rolled- back transitions. Phase 3.5 documents the gap; the actual rollback is Phase 4 work. """ import asyncio import logging from chat.config import Settings from chat.db.migrate import apply_migrations from chat.eventlog.log import append_and_apply 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) _ut_id, at_id = _seed_with_one_turn(db_path) # After the assistant_turn lands, simulate that the turn flow # produced an event_completed transition. ``append_and_apply`` is # the standard path so the events projection updates. with open_db(db_path) as conn: append_and_apply( conn, kind="event_planned", payload={ "event_id": "evt_x", "chat_id": "chat_bot_a", "kind": "story_event", "props": {}, "planned_for": "2026-04-30T18:00:00+00:00", }, ) append_and_apply( conn, kind="event_started", payload={ "event_id": "evt_x", "started_at": "2026-04-30T19:00:00+00:00", }, ) completed_id = append_and_apply( conn, kind="event_completed", payload={ "event_id": "evt_x", "completed_at": "2026-04-30T19:30:00+00:00", }, ) assert completed_id is not None state_canned = json.dumps( {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} ) mock_client = MockLLMClient( canned=["Refreshed reply.", state_canned, state_canned] ) settings = Settings(featherless_api_key="test") caplog.set_level(logging.WARNING, logger="chat.services.regenerate") with open_db(db_path) as conn: asyncio.run( regenerate_assistant_turn( conn, mock_client, settings=settings, chat_id="chat_bot_a", original_assistant_event_id=at_id, ) ) # The warning records the count and at least one of the affected # event_log ids (event_started + event_completed = at minimum 2). warnings = [ r for r in caplog.records if r.levelname == "WARNING" ] matching = [w for w in warnings if "lifecycle transition" in w.getMessage()] assert matching, ( "expected a WARNING about un-rolled-back lifecycle transitions; " f"got: {[w.getMessage() for w in warnings]}" ) msg = matching[0].getMessage() # Reference the original superseded turn's id and the event_completed # row's id. assert str(at_id) in msg assert str(completed_id) in msg # T90.2: wording was tightened from "from superseded turn" to # "at-or-after turn " — when regenerating an OLDER turn, the # listed transitions may include legitimate intervening-turn ones # that stand on their own. The new phrasing avoids implying the # warning's target turn directly authored every listed transition. assert "at-or-after turn" in msg assert "from superseded turn" not in msg 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 mid-regenerate stream. Mirrors the meanwhile registration pattern pinned by tests/test_meanwhile_turn_flow.py. Snapshot pattern: a custom MockLLMClient subclass captures the presence of the chat_id in ``_in_flight_tasks`` at the first stream yield (when the regenerate coroutine is awaiting our generator and the task is alive). Post-flight, the entry must be cleaned up so the next regenerate / turn registers a fresh task. """ import asyncio from typing import AsyncIterator, Sequence from chat.config import Settings from chat.db.migrate import apply_migrations from chat.llm.client import Message from chat.services.regenerate import regenerate_assistant_turn from chat.web.turns import _in_flight_tasks 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) _ut_id, at_id = _seed_with_one_turn(db_path) in_flight_snapshot: dict = {} class _SnapshotMock(MockLLMClient): async def stream( self, messages: Sequence[Message], *, model: str, **params ) -> AsyncIterator[str]: text = self._canned.pop(0) for i, ch in enumerate(text): if i == 0: in_flight_snapshot["present"] = ( "chat_bot_a" in _in_flight_tasks ) in_flight_snapshot["task"] = _in_flight_tasks.get( "chat_bot_a" ) yield ch state_canned = json.dumps( {"affinity_delta": 0, "trust_delta": 0, "knowledge_facts": []} ) mock_client = _SnapshotMock( canned=["Refreshed reply.", state_canned, state_canned] ) settings = Settings(featherless_api_key="test") # Pre-condition: registry empty for this chat. assert "chat_bot_a" not in _in_flight_tasks with open_db(db_path) as conn: new_text = asyncio.run( regenerate_assistant_turn( conn, mock_client, settings=settings, chat_id="chat_bot_a", original_assistant_event_id=at_id, ) ) assert new_text == "Refreshed reply." # Mid-flight: the streaming task was present in the registry, and # the captured value was an asyncio.Task. assert in_flight_snapshot.get("present") is True, ( "_in_flight_tasks was empty at first yield — regenerate stream " "isn't registering its task" ) assert isinstance(in_flight_snapshot.get("task"), asyncio.Task) # Post-flight: the entry has been cleaned up. assert "chat_bot_a" not in _in_flight_tasks