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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user