diff --git a/chat/services/regenerate.py b/chat/services/regenerate.py index eff02a2..6c1abad 100644 --- a/chat/services/regenerate.py +++ b/chat/services/regenerate.py @@ -26,6 +26,7 @@ Phase 1 simplifications (per the plan's "bound it" guidance): so affinity/trust/knowledge reflect the new output. - The route does not broadcast a fresh ``turn_html`` SSE event; T34 polishes UI swaps. The user refreshes the page to see the new turn. + *(T73.1 closed this gap — see Phase 2.5 changes below.)* Phase 2 changes (T44): @@ -42,6 +43,27 @@ Phase 2 changes (T44): is not invoked here. If the prior turn fired an interjection it remains attached to the original assistant_turn (which is superseded alongside the regenerated turn) — Phase 2.5 will revisit. + +Phase 2.5 changes: + +- T73.1: After the new ``assistant_turn`` lands we publish a + ``turn_html_replace`` SSE event carrying the rendered HTML for the + regenerated turn plus the original assistant_turn's event_id as + ``supersedes_id`` so connected tabs can swap the prior DOM node + in-place. We use a NEW event name (rather than re-using ``turn_html``) + because the existing HTMX ``sse-swap="turn_html"`` consumer expects a + raw-HTML body and an *append* semantic; ``turn_html_replace`` is a + JSON payload (sse.py auto-serialises when extra keys accompany + ``data``) so the front-end JS can read ``supersedes_id`` and replace + the right node. +- T73.2: Interjection regeneration. When the original assistant_turn + group included an interjection beat we redo BOTH the primary and the + interjection — re-running ``detect_interjection`` against the new + primary text. If the classifier returns False this time we supersede + the original interjection without appending a replacement. +- T73.3: The defensive degrade-to-1:1 for stale ``guest_bot_id`` + references was removed — Phase 2 T47 fixed the root cause (resets + clear the reference) so the guard is dead code. """ from __future__ import annotations @@ -58,6 +80,7 @@ from chat.state.edges import get_edge from chat.state.entities import get_bot, get_you from chat.state.world import active_scene, get_chat from chat.web.pubsub import publish +from chat.web.render import render_turn_html async def regenerate_assistant_turn( @@ -238,6 +261,27 @@ async def regenerate_assistant_turn( (new_assistant_event_id, original_assistant_event_id), ) + # 7a. Broadcast a turn_html_replace SSE event so connected tabs can + # swap the prior assistant_turn DOM node in-place (T73.1, Phase 1.5 + # backlog #2). Uses a separate event name from post_turn's + # ``turn_html`` (which is append-only) because regenerate is a + # *replace* operation — see module docstring for the rationale. + speaker_name_for_render = ( + speaker_bot.get("name", "bot") if speaker_bot is not None else "bot" + ) + new_turn_html = render_turn_html( + speaker_name_for_render, new_text, role="bot" + ) + await publish( + chat_id, + { + "event": "turn_html_replace", + "data": new_turn_html, + "turn_id": new_assistant_event_id, + "supersedes_id": original_assistant_event_id, + }, + ) + # 8. Re-run downstream classifier passes (memory write + state update # for every directed pair across present entities). Significance is # intentionally skipped on regenerate (the prior score remains diff --git a/tests/test_regenerate.py b/tests/test_regenerate.py index b561d5f..f923dd5 100644 --- a/tests/test_regenerate.py +++ b/tests/test_regenerate.py @@ -271,3 +271,85 @@ def test_regenerate_404_when_assistant_turn_missing(client, tmp_path): assert response.status_code == 404 finally: app.dependency_overrides.clear() + + +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"