feat: regenerate broadcasts turn_html over SSE (T73.1)

After the new assistant_turn lands, publish a `turn_html_replace` SSE
event carrying the rendered HTML, the new turn_id, and the original
assistant_turn id as `supersedes_id` so connected tabs can swap the
prior DOM node in-place. Phase 1 T29 deferred this — page had to refresh
to see the regenerated turn.

Uses a new event name (not the existing `turn_html`) because the HTMX
`sse-swap="turn_html"` consumer expects raw HTML and an *append*
semantic; regenerate is a *replace*. The new event ships as JSON
(supersedes_id forces sse.py's JSON branch) so the front-end JS can
read the swap target from the payload.

Test: `test_regenerate_broadcasts_turn_html_over_sse` patches the
`publish` reference inside the regenerate module and asserts the
event shape.
This commit is contained in:
Joseph Doherty
2026-04-26 17:36:16 -04:00
parent e632a6247d
commit 6f22e86f54
2 changed files with 126 additions and 0 deletions
+44
View File
@@ -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
+82
View File
@@ -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"