Files
LLeMbas/tests/test_chat.py
T
Jaroslav Beneš a071d8486b Live Markdown, stop, rewind, custom picker, dialogs
Seven things.

**Reasoning starts closed.** The answer is what the reader is waiting
for; the thinking is one click away.

**Image borders.** .attachments__image was a block-level <a>, so its
border stretched the full column around a narrow picture. inline-block,
and the frame is the picture. Same fix for the composer thumbnail.

**Markdown now renders during the stream.** The generator re-renders the
answer so far and sends it as a `render` event at most every 100ms,
swapped with innerHTML, instead of appending escaped tokens and
formatting everything at the end. Re-rendering whole rather than
appending is the point: a list or a code fence is only correct once its
context exists, and partial syntax resolves itself as more arrives.
Measured against a live model: 29 render events, formatting visible from
the first content token.

**Stop button.** A stop request goes into an in-process set the
generator checks between chunks; whatever arrived is kept, because a
half-written answer the reader chose to cut short is still worth having.
Measured: stream ended 0.2s after the request, 1155 characters
preserved, message marked stopped rather than errored. Navigating away
does the same thing via CancelledError.

**Rewind and edit.** Edit one of your own turns and everything after it
is deleted, then the conversation runs on from there. Deliberately not
branching: that needs a UI for choosing between versions, and "go back
and try again from here" is what was asked for. The form states how many
messages will be discarded before you confirm.

**Custom model picker.** A <select> renders only text in an <option>, so
it can never show an avatar. Built from buttons and a hidden input, with
descriptions, capability tags, a filter box past eight models, and
arrow-key navigation written out by hand since there is no native widget
doing it.

**Notification system.** lembas.notify/confirm/prompt in ui.js, built on
<dialog> so focus trapping, Escape and page inertness come from the
browser. htmx:confirm is intercepted, so every existing hx-confirm gets
the themed dialog with no change at the call site; the browser's grey
confirm() is gone from every template.

230 tests, ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-21 14:33:04 +02:00

504 lines
18 KiB
Python

"""Chat, folders, and the streaming reply path against a mocked endpoint."""
from __future__ import annotations
import httpx
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import func, select
from lembas.db.models import Chat, Connection, Folder, Message, Model
from lembas.services import chat as chat_service
from lembas.services.crypto import encrypt
from lembas.services.llm.openai_client import Endpoint, LLMError, delta_text, list_models
from lembas.services.sse import event
# --- SSE framing -------------------------------------------------------------
def test_sse_event_framing():
assert event("token", "hello") == "event: token\ndata: hello\n\n"
def test_sse_splits_newlines_across_data_lines():
"""A payload with a newline must become several data: lines. Sending a raw
newline truncates the event, which is what breaks the first code block a
model emits."""
assert event("token", "a\nb") == "event: token\ndata: a\ndata: b\n\n"
def test_sse_round_trips_through_the_browser_rejoin_rule():
payload = "line one\nline two\n\nline four"
framed = event("token", payload)
body = framed.split("\n", 1)[1]
rejoined = "\n".join(
line.removeprefix("data: ") for line in body.split("\n") if line.startswith("data:")
)
assert rejoined == payload
# --- Delta parsing -----------------------------------------------------------
def test_delta_text_reads_the_normal_shape():
assert delta_text({"choices": [{"delta": {"content": "hi"}}]}) == "hi"
def test_delta_text_handles_typed_content_parts():
chunk = {"choices": [{"delta": {"content": [{"type": "text", "text": "hi"}]}}]}
assert delta_text(chunk) == "hi"
@pytest.mark.parametrize(
"chunk", [{}, {"choices": []}, {"choices": [{}]}, {"choices": [{"delta": {}}]}]
)
def test_delta_text_tolerates_junk(chunk):
"""Providers vary; an unexpected chunk shape must not kill a reply."""
assert delta_text(chunk) == ""
# --- Endpoint URL handling ---------------------------------------------------
@pytest.mark.parametrize(
("base", "expected"),
[
("http://host:1234", "http://host:1234/v1/models"),
("http://host:1234/v1", "http://host:1234/v1/models"),
("http://host:1234/", "http://host:1234/v1/models"),
],
)
def test_base_url_with_or_without_v1(base, expected):
"""Users should not have to guess which form is expected."""
assert Endpoint(base_url=base.rstrip("/"), api_key="", extra_headers={}).url(
"models"
) == expected
def test_no_authorization_header_without_a_key():
"""Local runners often reject an empty bearer token outright."""
assert "Authorization" not in Endpoint("http://h", "", {}).headers()
assert Endpoint("http://h", "k", {}).headers()["Authorization"] == "Bearer k"
# --- Model discovery ---------------------------------------------------------
async def test_list_models_accepts_the_bare_list_shape():
"""The spec says {"data": [...]}, but some servers return a bare list."""
def handler(_request):
return httpx.Response(200, json=[{"id": "a"}, {"id": "b"}])
original = httpx.AsyncClient
class Patched(original):
def __init__(self, **kwargs):
super().__init__(transport=httpx.MockTransport(handler), **kwargs)
httpx.AsyncClient = Patched
try:
assert [m["id"] for m in await list_models(Endpoint("http://h", "", {}))] == ["a", "b"]
finally:
httpx.AsyncClient = original
async def test_list_models_reports_a_rejected_key_readably():
def handler(_request):
return httpx.Response(401, json={"error": {"message": "Incorrect API key."}})
original = httpx.AsyncClient
class Patched(original):
def __init__(self, **kwargs):
super().__init__(transport=httpx.MockTransport(handler), **kwargs)
httpx.AsyncClient = Patched
try:
with pytest.raises(LLMError) as caught:
await list_models(Endpoint("http://h", "bad", {}))
assert "rejected" in caught.value.message
assert "Incorrect API key." in caught.value.message
finally:
httpx.AsyncClient = original
# --- Titles ------------------------------------------------------------------
def test_fallback_title_keeps_a_short_message_intact():
assert chat_service.fallback_title("What is lembas?") == "What is lembas?"
def test_fallback_title_trims_on_a_word_boundary():
title = chat_service.fallback_title("word " * 60)
assert len(title) <= chat_service.MAX_TITLE_LENGTH + 1
assert title.endswith("…")
def test_fallback_title_of_nothing():
assert chat_service.fallback_title(" ") == "New chat"
# --- Chats and folders (through the API) -------------------------------------
def _add_connection(db) -> Connection:
# Port 1 refuses connections, which is what the error-path test relies on.
connection = Connection(
name="Test", base_url="http://127.0.0.1:1", api_key_encrypted=encrypt("")
)
db.add(connection)
db.commit()
db.add(Model(connection_id=connection.id, model_id="test-model"))
db.commit()
return connection
# --- Starting a chat ---------------------------------------------------------
def test_starting_a_chat_creates_it_and_redirects(client: TestClient, db, registered):
_add_connection(db)
response = client.post("/api/chats/start", data={"content": "Hello there"})
assert response.status_code == 204
assert response.headers["HX-Redirect"].startswith("/chat/")
chat = db.scalar(select(Chat))
assert chat.model_id == "test-model"
messages = db.scalars(select(Message).order_by(Message.created_at)).all()
assert [m.role for m in messages] == ["user", "assistant"]
assert messages[0].content == "Hello there"
def test_starting_with_nothing_creates_no_chat(client: TestClient, db, registered):
"""The whole point of lazy creation: an abandoned composer leaves nothing."""
_add_connection(db)
assert client.post("/api/chats/start", data={"content": " "}).status_code == 204
assert db.scalar(select(Chat)) is None
def test_visiting_the_chat_page_creates_nothing(client: TestClient, db, registered):
_add_connection(db)
assert client.get("/chat").status_code == 200
assert db.scalar(select(Chat)) is None
def test_starting_a_chat_honours_the_requested_model(client: TestClient, db, registered):
"""The pinned-model shortcuts pass ?model=, which arrives here."""
connection = _add_connection(db)
db.add(Model(connection_id=connection.id, model_id="other-model", position=5))
db.commit()
client.post("/api/chats/start", data={"content": "hi", "model_id": "other-model"})
assert db.scalar(select(Chat)).model_id == "other-model"
def test_starting_a_chat_ignores_a_model_you_cannot_reach(client: TestClient, db, registered):
connection = _add_connection(db)
db.add(Model(connection_id=connection.id, model_id="secret", position=5, public=False))
db.commit()
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
client.post("/api/chats/start", data={"content": "hi", "model_id": "secret"})
assert db.scalar(select(Chat)).model_id == "test-model"
def test_posting_a_message_stores_both_turns(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello there"})
assert response.status_code == 200
messages = db.scalars(select(Message).order_by(Message.created_at)).all()
assert [m.role for m in messages] == ["user", "assistant"]
assert messages[0].content == "Hello there"
# The assistant row is created empty and incomplete; that is what carries
# the sse-connect the browser uses to start the stream.
assert messages[1].content == ""
assert messages[1].complete is False
assert "sse-connect" in response.text
def test_empty_message_is_ignored(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
assert client.post(f"/api/chats/{chat_id}/messages", data={"content": " "}).status_code == 204
assert db.scalar(select(Message)) is None
def test_a_chat_belonging_to_someone_else_is_not_found(
client: TestClient, db, registered, make_chat
):
_add_connection(db)
chat_id = make_chat()
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
# 404 and not 403: whether that id exists is not this endpoint's to reveal.
assert client.get(f"/chat/{chat_id}").status_code == 404
def test_renaming_a_chat_stops_it_being_auto_titled(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"title": "My own title"})
chat = db.get(Chat, chat_id)
db.refresh(chat)
assert chat.title == "My own title"
assert chat.title_generated is True
def test_deleting_a_chat_removes_its_messages(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
client.delete(f"/api/chats/{chat_id}")
assert db.scalar(select(Chat)) is None
assert db.scalar(select(Message)) is None
def test_deleting_a_folder_keeps_the_chats_inside_it(client: TestClient, db, registered, make_chat):
"""Losing a conversation to a mis-clicked folder delete is unforgivable."""
_add_connection(db)
client.post("/api/folders", data={"name": "Quests"})
folder = db.scalar(select(Folder))
chat_id = make_chat()
client.patch(f"/api/chats/{chat_id}", data={"folder_id": folder.id})
client.delete(f"/api/folders/{folder.id}")
chat = db.get(Chat, chat_id)
db.refresh(chat)
assert chat is not None
assert chat.folder_id is None
def test_a_folder_cannot_be_moved_inside_itself(client: TestClient, db, registered):
client.post("/api/folders", data={"name": "Outer"})
folder = db.scalar(select(Folder))
response = client.patch(f"/api/folders/{folder.id}", data={"parent_id": folder.id})
assert response.status_code == 400
# --- Request building --------------------------------------------------------
def test_request_forwards_only_known_sampling_parameters(db, user_id):
"""A stray key in params_json must not become a 400 from the provider that
looks like a LLeMbas bug."""
connection = _add_connection(db)
chat = Chat(
user_id=user_id,
model_id="test-model",
connection_id=connection.id,
params_json={"temperature": 0.4, "nonsense": "drop me"},
)
db.add(chat)
db.commit()
payload = chat_service.build_request(db, chat)
assert payload["temperature"] == 0.4
assert "nonsense" not in payload
def test_history_skips_failed_and_empty_turns(db, user_id):
"""Sending an empty assistant message upsets several providers."""
connection = _add_connection(db)
chat = Chat(user_id=user_id, model_id="test-model", connection_id=connection.id)
db.add(chat)
db.commit()
db.add_all(
[
Message(chat_id=chat.id, role="user", content="one"),
Message(chat_id=chat.id, role="assistant", content="", error="boom"),
Message(chat_id=chat.id, role="user", content="two"),
]
)
db.commit()
contents = [m["content"] for m in chat_service.build_request(db, chat)["messages"]]
assert contents == ["one", "two"]
def test_system_prompt_leads_the_message_list(db, user_id):
connection = _add_connection(db)
chat = Chat(
user_id=user_id,
model_id="test-model",
connection_id=connection.id,
system_prompt="You are terse.",
)
db.add(chat)
db.commit()
messages = chat_service.build_request(db, chat)["messages"]
assert messages[0] == {"role": "system", "content": "You are terse."}
def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
client: TestClient, db, registered
, make_chat):
"""A failed turn must never be an unexplained blank bubble."""
_add_connection(db) # points at 127.0.0.1:1, which refuses connections
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "Hello"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
response = client.get(f"/api/chats/{chat_id}/messages/{message.id}/stream")
assert response.status_code == 200
assert "Could not reach" in response.text
assert "alert--error" in response.text
db.refresh(message)
assert message.complete is True
assert message.error
# --- Stopping a stream -------------------------------------------------------
def test_stopping_marks_the_message_and_keeps_what_arrived(
client: TestClient, db, registered, make_chat
):
"""A half-written answer the reader chose to cut short is still worth
having; discarding it would be a surprise."""
from lembas.api.chats import _CANCELLED
_add_connection(db)
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
assert client.post(
f"/api/chats/{chat_id}/messages/{message.id}/stop"
).status_code == 204
assert message.id in _CANCELLED
_CANCELLED.discard(message.id)
def test_stopping_someone_elses_message_is_refused(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
message = db.scalar(select(Message).where(Message.role == "assistant"))
client.post("/auth/logout", follow_redirects=False)
client.post(
"/auth/register",
data={"name": "Sam", "email": "sam@shire.test", "password": "potatoes-po-ta-toes"},
follow_redirects=False,
)
assert client.post(
f"/api/chats/{chat_id}/messages/{message.id}/stop"
).status_code == 404
def test_the_streaming_bubble_offers_a_stop_button(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
assert "/stop" in response.text
assert "msg__stop" in response.text
def test_the_streaming_bubble_renders_markdown_not_raw_tokens(
client: TestClient, db, registered, make_chat
):
"""The body receives re-rendered Markdown, so formatting appears as the
model writes rather than snapping in at the end."""
_add_connection(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
assert 'sse-swap="render"' in response.text
assert 'hx-swap="innerHTML"' in response.text
def test_reasoning_starts_closed(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
response = client.post(f"/api/chats/{chat_id}/messages", data={"content": "hi"})
block = response.text[response.text.index("reasoning--live"):]
assert not block[: block.index(">")].strip().endswith("open")
# --- Rewinding ---------------------------------------------------------------
def _exchange(client: TestClient, db, chat_id: str, text: str) -> Message:
client.post(f"/api/chats/{chat_id}/messages", data={"content": text})
assistant = db.scalars(
select(Message).where(Message.role == "assistant").order_by(Message.created_at)
).all()[-1]
assistant.content = f"reply to {text}"
assistant.complete = True
db.commit()
return assistant
def test_editing_rewinds_and_discards_later_messages(
client: TestClient, db, registered, make_chat
):
_add_connection(db)
chat_id = make_chat()
_exchange(client, db, chat_id, "first")
_exchange(client, db, chat_id, "second")
assert db.scalar(select(func.count()).select_from(Message)) == 4
first_user = db.scalars(
select(Message).where(Message.role == "user").order_by(Message.created_at)
).first()
client.post(
f"/api/chats/{chat_id}/messages/{first_user.id}/edit",
data={"content": "first, revised"},
)
# The edit happened in the request's session; this one still holds the old
# instance in its identity map.
db.expire_all()
remaining = db.scalars(select(Message).order_by(Message.created_at)).all()
assert [m.role for m in remaining] == ["user", "assistant"]
assert remaining[0].content == "first, revised"
# The fresh assistant row is incomplete, which is what restarts the stream.
assert remaining[1].complete is False
def test_the_edit_form_says_how_much_will_be_lost(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
_exchange(client, db, chat_id, "first")
_exchange(client, db, chat_id, "second")
first_user = db.scalars(
select(Message).where(Message.role == "user").order_by(Message.created_at)
).first()
page = client.get(f"/api/chats/{chat_id}/messages/{first_user.id}/edit").text
assert "3 messages after this one will be deleted" in page
def test_only_your_own_turns_can_be_edited(client: TestClient, db, registered, make_chat):
"""Rewriting what the model said would be inventing history."""
_add_connection(db)
chat_id = make_chat()
assistant = _exchange(client, db, chat_id, "hello")
assert client.get(
f"/api/chats/{chat_id}/messages/{assistant.id}/edit"
).status_code == 404
def test_an_edit_cannot_empty_a_message(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
_exchange(client, db, chat_id, "hello")
user_message = db.scalar(select(Message).where(Message.role == "user"))
assert client.post(
f"/api/chats/{chat_id}/messages/{user_message.id}/edit", data={"content": " "}
).status_code == 400
def test_cancelling_an_edit_restores_the_bubble(client: TestClient, db, registered, make_chat):
_add_connection(db)
chat_id = make_chat()
_exchange(client, db, chat_id, "unchanged")
user_message = db.scalar(select(Message).where(Message.role == "user"))
page = client.get(f"/api/chats/{chat_id}/messages/{user_message.id}/cancel-edit").text
assert "unchanged" in page
assert "edit-form" not in page