Background generation, unread replies, send/stop, PLAN.md
**Replies now run in the background.** Generation was driven by the SSE request, so navigating away or opening another chat cut the answer off mid-sentence. services/generation.py owns the work as its own task and the SSE endpoint merely follows it. Verified: attached briefly, closed the connection, went to another page -- the reply finished anyway, 832 characters, not marked stopped, auto-titled. Reattaching works because both `render` and `reasoning` frames now carry the whole block rather than a delta. A follower arriving late has no earlier fragments to append to, so deltas would leave it permanently missing the beginning. Verified: attached six seconds in and the first frame already contained 517 characters written while nobody watched. **Unread indicator.** A reply that lands with no follower attached marks its chat unread; the sidebar polls every 10s for out-of-band dot spans plus an HX-Trigger that raises a toast. Polled rather than pushed: a browser sitting on another chat has no connection to the one that finished, and an always-on channel per tab is a lot of machinery for a green dot. `unread_notified` stops the same arrival being announced every tick. Follower count is what decides "was anyone watching", so reading it as it arrives does not mark it unread -- verified both ways. **Stop is the send button.** While a reply is being written the send button becomes a red stop square, found via a MutationObserver on the thread since the composer and the streaming bubble are far apart in the document. The in-bubble Stop is gone. **Attachment border removed.** As asked -- an attachment is a picture, and the frame only ever drew at the wrong width. The anchor now shrink-wraps and the img's width/height attributes are overridden so a small image shows at its own size. Adds PLAN.md: what is built, what is not, known limits, and the decisions that look like oversights until you know the reason. 239 tests, ruff clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+126
-9
@@ -355,12 +355,10 @@ def test_streaming_reports_an_unreachable_endpoint_in_the_thread(
|
||||
|
||||
|
||||
# --- Stopping a stream -------------------------------------------------------
|
||||
def test_stopping_marks_the_message_and_keeps_what_arrived(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
def test_stopping_asks_the_generation_to_stop(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
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
_add_connection(db)
|
||||
chat_id = make_chat()
|
||||
@@ -370,8 +368,11 @@ def test_stopping_marks_the_message_and_keeps_what_arrived(
|
||||
assert client.post(
|
||||
f"/api/chats/{chat_id}/messages/{message.id}/stop"
|
||||
).status_code == 204
|
||||
assert message.id in _CANCELLED
|
||||
_CANCELLED.discard(message.id)
|
||||
|
||||
running = generation_service.get(message.id)
|
||||
# The endpoint points at 127.0.0.1:1, so the task may already have failed
|
||||
# and finished; either way the request must be accepted, not error.
|
||||
assert running is None or running.cancel or running.done
|
||||
|
||||
|
||||
def test_stopping_someone_elses_message_is_refused(client: TestClient, db, registered, make_chat):
|
||||
@@ -391,12 +392,16 @@ def test_stopping_someone_elses_message_is_refused(client: TestClient, db, regis
|
||||
).status_code == 404
|
||||
|
||||
|
||||
def test_the_streaming_bubble_offers_a_stop_button(client: TestClient, db, registered, make_chat):
|
||||
def test_the_streaming_bubble_carries_the_sse_connection(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""Stop lives on the composer's send button now, and the JS finds the
|
||||
running message through this attribute."""
|
||||
_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
|
||||
assert "sse-connect" in response.text
|
||||
assert f"/api/chats/{chat_id}/messages/" in response.text
|
||||
|
||||
|
||||
def test_the_streaming_bubble_renders_markdown_not_raw_tokens(
|
||||
@@ -501,3 +506,115 @@ def test_cancelling_an_edit_restores_the_bubble(client: TestClient, db, register
|
||||
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
|
||||
|
||||
|
||||
# --- Background generation ---------------------------------------------------
|
||||
def test_sending_launches_the_generation_immediately(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""The reply is produced by a task, not by the browser watching it. That is
|
||||
what lets you navigate away without cutting it off."""
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
_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 generation_service.get(message.id) is not None
|
||||
|
||||
|
||||
def test_starting_a_chat_launches_the_generation(client: TestClient, db, registered):
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
_add_connection(db)
|
||||
client.post("/api/chats/start", data={"content": "hi"})
|
||||
message = db.scalar(select(Message).where(Message.role == "assistant"))
|
||||
assert generation_service.get(message.id) is not None
|
||||
|
||||
|
||||
def test_asking_twice_does_not_start_a_second_generation(
|
||||
client: TestClient, db, registered, make_chat
|
||||
):
|
||||
"""A page load finding an unfinished reply must attach, not restart."""
|
||||
from lembas.services import generation as generation_service
|
||||
|
||||
_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"))
|
||||
|
||||
first = generation_service.get(message.id)
|
||||
assert generation_service.ensure(chat_id, message.id) is first
|
||||
|
||||
|
||||
# --- Unread -------------------------------------------------------------------
|
||||
def test_the_unread_poll_reports_dots(client: TestClient, db, registered, make_chat):
|
||||
_add_connection(db)
|
||||
chat_id = make_chat()
|
||||
chat = db.get(Chat, chat_id)
|
||||
chat.unread = True
|
||||
db.commit()
|
||||
|
||||
response = client.get("/api/chats/unread")
|
||||
assert f'id="unread-{chat_id}"' in response.text
|
||||
assert "hidden" not in response.text
|
||||
assert "lembas:unread" in response.headers.get("HX-Trigger", "")
|
||||
|
||||
|
||||
def test_an_arrival_is_announced_once(client: TestClient, db, registered, make_chat):
|
||||
"""Otherwise the same reply would toast every ten seconds forever."""
|
||||
_add_connection(db)
|
||||
chat_id = make_chat()
|
||||
chat = db.get(Chat, chat_id)
|
||||
chat.unread = True
|
||||
db.commit()
|
||||
|
||||
assert "HX-Trigger" in client.get("/api/chats/unread").headers
|
||||
assert "HX-Trigger" not in client.get("/api/chats/unread").headers
|
||||
|
||||
|
||||
def test_opening_a_chat_marks_it_read(client: TestClient, db, registered, make_chat):
|
||||
_add_connection(db)
|
||||
chat_id = make_chat()
|
||||
chat = db.get(Chat, chat_id)
|
||||
chat.unread = True
|
||||
db.commit()
|
||||
|
||||
client.get(f"/chat/{chat_id}")
|
||||
db.expire_all()
|
||||
assert db.get(Chat, chat_id).unread is False
|
||||
|
||||
|
||||
def test_a_read_chat_reports_a_hidden_dot(client: TestClient, db, registered, make_chat):
|
||||
_add_connection(db)
|
||||
chat_id = make_chat()
|
||||
response = client.get("/api/chats/unread")
|
||||
assert f'id="unread-{chat_id}"' in response.text
|
||||
assert "hidden" in response.text
|
||||
|
||||
|
||||
def test_the_unread_poll_only_sees_your_own_chats(client: TestClient, db, registered, make_chat):
|
||||
_add_connection(db)
|
||||
mine = 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,
|
||||
)
|
||||
assert mine not in client.get("/api/chats/unread").text
|
||||
|
||||
|
||||
def test_the_sidebar_shows_an_unread_dot(client: TestClient, db, registered, make_chat):
|
||||
_add_connection(db)
|
||||
chat_id = make_chat()
|
||||
chat = db.get(Chat, chat_id)
|
||||
chat.unread = True
|
||||
db.commit()
|
||||
|
||||
# Rendered on another page, so the dot is visible while looking elsewhere.
|
||||
page = client.get("/chat").text
|
||||
assert f'id="unread-{chat_id}" class="unread-dot"' in page
|
||||
assert 'hx-get="/api/chats/unread"' in page
|
||||
|
||||
Reference in New Issue
Block a user