"""Messages: one conversation that never ends, and a request that must. The failure this file mostly guards against is not visible on screen at all — a conversation whose request grows with it until the endpoint refuses, which is what `_too_big` exists for elsewhere and what the live chunk prevents here. The rest is the cursor, which has the same four traps as `thread_tail` going the other way. A message that cannot be scrolled back to is a message that is gone. """ from __future__ import annotations from datetime import UTC, datetime, timedelta import pytest from fastapi.testclient import TestClient from sqlalchemy import select from lembas.db.models import KIND_MESSAGES, Chat, Message, User from lembas.services import messages as messages_service @pytest.fixture(autouse=True) def a_model_exists(db, registered): """Not scaffolding. `for_user` seeds the conversation from `default_model`, which answers with a *pair* — and with no model configured it answers None, so the whole of that path is skipped and a bug in it cannot be seen. It was: the pair was assigned straight to `model_id`, SQLite refused a tuple in a String column, and every one of these tests passed anyway. """ from lembas.db.models import Connection, Model from lembas.services.crypto import encrypt 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 None def _user(db) -> User: return db.scalars(select(User).order_by(User.created_at)).first() def _fill(db, chat: Chat, count: int, *, day: int = 1) -> list[Message]: """`count` alternating turns, a minute apart so the order is unambiguous.""" start = datetime(2026, 1, day, tzinfo=UTC) rows = [] for index in range(count): rows.append( Message( chat_id=chat.id, role="user" if index % 2 == 0 else "assistant", content=f"turn {index}", created_at=start + timedelta(minutes=index), complete=True, ) ) db.add_all(rows) db.commit() return rows # --- The conversation itself ------------------------------------------------------ def test_there_is_exactly_one_per_person(client: TestClient, db, registered): """Get-or-create, so a schedule can post here before anybody has opened the page — the second deliberate exception to "chats are created lazily".""" first = messages_service.for_user(db, _user(db)) second = messages_service.for_user(db, _user(db)) assert first.id == second.id assert first.kind == KIND_MESSAGES assert len(db.scalars(select(Chat).where(Chat.kind == KIND_MESSAGES)).all()) == 1 def test_it_is_not_in_the_chat_tree(client: TestClient, db, registered): """It has a section of its own. This is the kind-leakage trap again, from the other side.""" from lembas.api.pages import sidebar_context conversation = messages_service.for_user(db, _user(db)) listed = {c.id for c in sidebar_context(db, _user(db))["unfiled_chats"]} assert conversation.id not in listed # --- What reaches the model ------------------------------------------------------- def test_the_request_does_not_grow_with_the_conversation( client: TestClient, db, registered ): """The whole point. A conversation meant to run for years cannot all be sent, and a request that grows until the endpoint refuses it is the failure nobody sees coming — there is nothing wrong on screen right up until it stops working. """ from lembas.services import chat as chat_service conversation = messages_service.for_user(db, _user(db)) _fill(db, conversation, 10) short = chat_service.build_messages(db, conversation) _fill(db, conversation, 300) long = chat_service.build_messages(db, conversation) assert len(short) == 10 assert len(long) == messages_service.LIVE_CHUNK assert len(long) < len(short) + 300 def test_it_is_the_most_recent_turns_that_are_sent(client: TestClient, db, registered): from lembas.services import chat as chat_service conversation = messages_service.for_user(db, _user(db)) _fill(db, conversation, messages_service.LIVE_CHUNK + 20) payload = chat_service.build_messages(db, conversation) bodies = [entry["content"] for entry in payload] assert bodies[-1] == f"turn {messages_service.LIVE_CHUNK + 19}" assert "turn 0" not in bodies def test_an_ordinary_chat_still_sends_everything(client: TestClient, db, registered): """The bound is one branch on one kind. A chat is not silently truncated.""" from lembas.services import chat as chat_service ordinary = Chat(user_id=_user(db).id, model_id="m") db.add(ordinary) db.commit() _fill(db, ordinary, messages_service.LIVE_CHUNK + 20) assert len(chat_service.build_messages(db, ordinary)) == messages_service.LIVE_CHUNK + 20 def test_compaction_never_fires_on_it(client: TestClient, db, registered): """Two mechanisms narrowing one transcript is how a summary ends up summarising a summary — and this one would be summarising turns that are already outside the request.""" from lembas.services import compaction as compaction_service conversation = messages_service.for_user(db, _user(db)) _fill(db, conversation, 200) assert compaction_service.should_compact(db, conversation) is False # --- Nothing is lost --------------------------------------------------------------- def test_every_turn_is_kept_however_old(client: TestClient, db, registered): """Bounded in the request, unbounded on disk. Deliberately not folded into text: the visible conversation would be identical either way, so the only thing destroying the rows would buy is disk — against irreversibility.""" conversation = messages_service.for_user(db, _user(db)) _fill(db, conversation, 250) assert messages_service.count(db, conversation) == 250 # --- Reading backwards -------------------------------------------------------------- def test_the_page_opens_on_the_latest_chunk(client: TestClient, db, registered): conversation = messages_service.for_user(db, _user(db)) _fill(db, conversation, 120) body = client.get("/messages").text assert "turn 119" in body assert "turn 0" not in body assert "history-sentinel" in body def test_a_short_conversation_has_no_sentinel(client: TestClient, db, registered): conversation = messages_service.for_user(db, _user(db)) _fill(db, conversation, 5) assert "history-sentinel" not in client.get("/messages").text def test_scrolling_up_returns_the_page_before(client: TestClient, db, registered): conversation = messages_service.for_user(db, _user(db)) rows = _fill(db, conversation, 200) oldest_shown = rows[-messages_service.LIVE_CHUNK] response = client.get(f"/api/messages/history?before={oldest_shown.id}") assert response.status_code == 200 assert f"turn {200 - messages_service.LIVE_CHUNK - 1}" in response.text # The turn it was asked to go before is not repeated. assert f">turn {200 - messages_service.LIVE_CHUNK}<" not in response.text def test_a_cursor_it_cannot_place_is_answered_with_204( client: TestClient, db, registered ): """Never with "the oldest page": that would prepend a block the reader is already looking at, and a duplicated transcript is something only a reload can reconcile.""" conversation = messages_service.for_user(db, _user(db)) _fill(db, conversation, 100) other = Chat(user_id=_user(db).id, model_id="m") db.add(other) db.commit() stray = Message(chat_id=other.id, role="user", content="elsewhere") db.add(stray) db.commit() assert client.get("/api/messages/history").status_code == 204 assert client.get("/api/messages/history?before=nope").status_code == 204 assert client.get(f"/api/messages/history?before={stray.id}").status_code == 204 def test_the_oldest_page_stops_rather_than_looping(client: TestClient, db, registered): conversation = messages_service.for_user(db, _user(db)) rows = _fill(db, conversation, 3) response = client.get(f"/api/messages/history?before={rows[0].id}") assert response.status_code == 204 def test_two_turns_sharing_a_timestamp_are_each_returned_once( client: TestClient, db, registered ): """The `id` tie-breaker. Under a bare `<`, a row sharing the cursor's microsecond can never be reached — and a message that cannot be scrolled back to is a message that is gone.""" conversation = messages_service.for_user(db, _user(db)) stamp = datetime(2026, 1, 1, tzinfo=UTC) twins = [ Message(chat_id=conversation.id, role="user", content=f"same {i}", created_at=stamp) for i in range(2) ] db.add_all(twins) db.commit() # A later day, so the cursor is unambiguously after both twins -- otherwise # the cursor shares their stamp and the test is about id ordering, which is # random, rather than about the tie-breaker. later = _fill(db, conversation, 2, day=2) page = messages_service.older_than(db, conversation, later[0]) assert {m.content for m in page} == {"same 0", "same 1"} def test_the_sentinel_names_its_own_target(client: TestClient, db, registered): """It sits on a page whose composer form carries `hx-target="#thread"`, and htmx resolves that by walking up the DOM. The jobs chip demonstrated once what an unstated target does to a transcript.""" conversation = messages_service.for_user(db, _user(db)) _fill(db, conversation, 120) body = client.get("/messages").text sentinel = next(chunk for chunk in body.split("