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:
@@ -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