"""Streaming UX tests (T34): cancel route, recent-dialogue user_turn_edit inclusion, and the chat-shell embeds the streaming JS hooks. The cancel route is exercised at the no-op level only — the full mid-stream cancel path is covered indirectly by T19's CancelledError handling. We verify here that the route itself is registered and silently 204s when no in-flight task exists, since the JS Stop button fires unconditionally. The user_turn_edit inclusion test is the T29 follow-up fix: without it, the original user_turn drops out of the timeline (correctly) but the edited prose never lands (incorrectly), so the rendered chat detail is missing the user's most recent words. """ from __future__ import annotations from pathlib import Path import pytest from fastapi.testclient import TestClient from chat.app import app from chat.db.connection import open_db from chat.eventlog.log import append_event from chat.eventlog.projector import project @pytest.fixture def client(tmp_path, monkeypatch): cfg = tmp_path / "config.toml" cfg.write_text('featherless_api_key = "test"\n') monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg)) db = tmp_path / "test.db" monkeypatch.setenv("CHAT_DB_PATH", str(db)) with TestClient(app) as c: # Disable the lifespan-managed background worker so it doesn't # try to score significance through Featherless with the fake key. worker = getattr(app.state, "background_worker", None) if worker is not None: worker.enabled = False yield c def _seed_chat( db_path: Path, bot_id: str = "bot_a", chat_id: str = "chat_bot_a", ) -> None: """Seed a bot + chat with the activity rows the prompt assembler expects.""" with open_db(db_path) as conn: append_event( conn, kind="bot_authored", payload={ "id": bot_id, "name": "BotA", "persona": "...", "voice_samples": [], "traits": [], "backstory": "", "initial_relationship_to_you": "", "kickoff_prose": "...", }, ) append_event( conn, kind="chat_created", payload={ "id": chat_id, "host_bot_id": bot_id, "initial_time": "2026-04-26T20:00:00+00:00", "narrative_anchor": "Day 1", "weather": "", }, ) append_event( conn, kind="edge_update", payload={ "source_id": bot_id, "target_id": "you", "chat_id": chat_id, }, ) append_event( conn, kind="edge_update", payload={ "source_id": "you", "target_id": bot_id, "chat_id": chat_id, }, ) append_event( conn, kind="activity_change", payload={ "entity_id": "you", "posture": "sitting", "action": {"verb": "talking"}, }, ) append_event( conn, kind="activity_change", payload={ "entity_id": bot_id, "posture": "sitting", "action": {"verb": "listening"}, }, ) project(conn) def test_cancel_route_no_op_when_no_in_flight(client, tmp_path): """Hitting cancel with nothing streaming returns 204 silently.""" _seed_chat(tmp_path / "test.db") response = client.post("/chats/chat_bot_a/turns/cancel") assert response.status_code == 204 def test_user_turn_edit_appears_in_recent_dialogue(client, tmp_path): """The chat-detail timeline includes a user_turn_edit's prose. Original user_turn is superseded by the edit, so it drops out, but the edit's prose should render in its place. """ 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": "OriginalUserText", "segments": [], }, ) edit_id = append_event( conn, kind="user_turn_edit", payload={ "chat_id": "chat_bot_a", "prose": "EditedUserText", "supersedes_user_turn_id": ut_id, }, ) conn.execute( "UPDATE event_log SET superseded_by = ? WHERE id = ?", (edit_id, ut_id), ) conn.commit() # No project() call — user_turn / user_turn_edit have no projector # handlers (transcript-only kinds), and re-projecting would replay # chat_created and trip its UNIQUE constraint. response = client.get("/chats/chat_bot_a") assert response.status_code == 200 body = response.text assert "EditedUserText" in body # The original (now-superseded) prose must not render. assert "OriginalUserText" not in body def test_chat_html_includes_stop_streaming_script(client, tmp_path): """The chat shell embeds the streaming-JS hooks (Stop button + send-lock).""" _seed_chat(tmp_path / "test.db") response = client.get("/chats/chat_bot_a") assert response.status_code == 200 body = response.text # Either the CSS class for the Stop button or the JS state flag must # appear in the embedded script — both are load-bearing for T34. 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-"`` 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-"`` 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