feat: frontend turn_html_replace SSE handler for regenerate live-swap (T86)

This commit is contained in:
Joseph Doherty
2026-04-26 22:26:09 -04:00
parent 82701d3c18
commit aea20a2c83
10 changed files with 212 additions and 17 deletions
+23
View File
@@ -85,3 +85,26 @@ def test_render_prose_mixed_full_message():
assert '<em class="action">looks up</em>' in out
# The apostrophe in ``she's`` is HTML-escaped to ``&#x27;``.
assert '<span class="ooc">((she&#x27;s tired))</span>' in out
def test_render_turn_html_stamps_event_id_when_provided():
"""T86 follow-up: when ``event_id`` is supplied the wrapper DIV
carries ``id="turn-<event_id>"`` so the chat-page
``turn_html_replace`` SSE handler can locate the prior turn DOM
node by id and swap it in-place. Without the id the handler's
``getElementById('turn-' + supersedes_id)`` lookup misses and
the regenerated turn appends instead of replaces.
"""
out = render_turn_html("BotA", "Hello.", role="bot", event_id=42)
assert 'id="turn-42"' in out
# The id must sit on the wrapper DIV, not somewhere nested inside.
assert out.startswith('<div id="turn-42" class="turn turn-bot">')
def test_render_turn_html_omits_id_when_event_id_missing():
"""Legacy callers (no ``event_id`` passed) get a clean DIV with no
id attribute — preserves the pre-T86 fragment shape.
"""
out = render_turn_html("BotA", "Hello.", role="bot")
assert "id=" not in out
assert out.startswith('<div class="turn turn-bot">')
+71
View File
@@ -174,3 +174,74 @@ def test_chat_html_includes_stop_streaming_script(client, tmp_path):
assert "stop-streaming" in body or "isStreaming" in body
# Cancel route reference must be wired so the Stop button can call it.
assert "/turns/cancel" in body
def test_chat_html_has_turn_html_replace_listener(client, tmp_path):
"""T86: the chat shell wires a JS handler for the ``turn_html_replace``
SSE event so regenerate-driven swaps land in connected tabs without a
page refresh.
This is a presence / string-check test: it verifies the handler is
embedded in the rendered template but does NOT drive a real browser
(no headless runner is wired into this test environment). The end-to-
end behaviour — receiving the event over SSE and replacing the prior
turn's DOM node — is therefore not exercised here; a manual smoke
check or future browser-driven test would close that gap.
"""
_seed_chat(tmp_path / "test.db")
response = client.get("/chats/chat_bot_a")
assert response.status_code == 200
body = response.text
# The handler must be wired against the SSE event name the backend
# publishes (chat.services.regenerate -> "turn_html_replace").
assert "turn_html_replace" in body
# Confirm the handler reads the JSON payload's ``supersedes_id`` so
# it can locate the prior turn node. The exact lookup mechanism may
# vary, but the field name is part of the contract with the backend.
assert "supersedes_id" in body
def test_rendered_turn_html_includes_event_id(client, tmp_path):
"""T86 follow-up: the chat-detail Jinja loop stamps
``id="turn-<event_id>"`` on every rendered turn DIV. Without this id
the ``turn_html_replace`` SSE handler's ``getElementById`` lookup
misses, falls through to ``insertAdjacentHTML('beforeend', …)``, and
the regenerated turn appears APPENDED instead of swapped in-place
(rendering the primary handler path dead code — exactly the gap the
T86 reviewer flagged).
Seed a user_turn + assistant_turn, GET the chat page, and assert the
response body carries both turns' event ids on the wrapper DIVs.
"""
db_path = tmp_path / "test.db"
_seed_chat(db_path)
with open_db(db_path) as conn:
ut_id = append_event(
conn,
kind="user_turn",
payload={
"chat_id": "chat_bot_a",
"prose": "hello bot",
"segments": [],
},
)
at_id = append_event(
conn,
kind="assistant_turn",
payload={
"chat_id": "chat_bot_a",
"speaker_id": "bot_a",
"text": "Hi there.",
"truncated": False,
"user_turn_id": ut_id,
},
)
conn.commit()
response = client.get("/chats/chat_bot_a")
assert response.status_code == 200
body = response.text
# Both seeded turns must carry ``id="turn-<event_id>"`` so the SSE
# in-place swap can find them.
assert f'id="turn-{ut_id}"' in body
assert f'id="turn-{at_id}"' in body
+9 -3
View File
@@ -104,10 +104,16 @@ def test_read_recent_dialogue_returns_chronological_pairs(tmp_path):
with open_db(db) as conn:
out = read_recent_dialogue(conn, "chat_a", limit=10)
assert out == [
{"speaker": "you", "text": "hello"},
{"speaker": "bot_a", "text": "Original."},
# Each entry now carries the source ``event_log.id`` as ``event_id``
# (T86 follow-up) so the chat-detail Jinja loop can stamp
# ``id="turn-<n>"`` on each rendered turn DIV — needed by the
# ``turn_html_replace`` SSE handler for in-place regenerate swaps.
speakers = [(e["speaker"], e["text"]) for e in out]
assert speakers == [
("you", "hello"),
("bot_a", "Original."),
]
assert all("event_id" in e and isinstance(e["event_id"], int) for e in out)
def test_read_recent_dialogue_filters_superseded_and_other_chats(tmp_path):