feat: streaming UX with Stop, disconnect handling, send-lock
This commit is contained in:
@@ -0,0 +1,176 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user