Temporary chats

A clock in the top-right starts one. It is never listed in the sidebar and
is swept a day after the last thing said in it.

A real row rather than something held in the browser, because a reload, a
crash or a background tab all look identical from here -- "delete when you
navigate away" would lose conversations people meant to keep. The flag rides
in the URL (/chat?temporary=1) rather than in JavaScript, so it survives a
reload and can be bookmarked, and the composer carries it as a hidden field
beside model_id.

Keep clears the flag. Without a way out, a conversation that turns out to
matter is destroyed a day later with no recourse, and people would find that
out exactly once.

archived was filtered in three places and temporary mirrors all three, plus
Folder.visible_chats. It also skips the unread flag in _persist: there is no
sidebar row for the dot to land on, and the toast would name a chat nobody
can navigate to.

The sweep measures age from the newest message, not from the chat row.
created_at would destroy a conversation still in use at hour 23, and
updated_at does not move when a message is inserted -- onupdate fires on an
UPDATE of the chat, and adding a message is not one. It runs at startup
beside the existing upload sweep.

Deleting a chat cascades its rows but leaves the files on disk; only the
orphan sweep unlinks anything, and it looks only at uploads that were never
attached. files.remove_files_for_chats() closes that for the new sweep. The
same hole in delete_chat is pre-existing and left for its own change, which
can now call the same helper.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jaroslav Beneš
2026-08-01 00:45:03 +02:00
parent e185edc9e1
commit 09eecbdd9a
12 changed files with 387 additions and 10 deletions
+222
View File
@@ -0,0 +1,222 @@
"""Temporary chats: hidden from the sidebar, and swept a day later."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from fastapi.testclient import TestClient
from sqlalchemy import 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
def _connection(db) -> Connection:
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
def _temporary(db, chat_id: str, *, title: str = "Passing thought") -> Chat:
chat = db.get(Chat, chat_id)
chat.temporary = True
chat.title = title
db.commit()
return chat
# --- Hidden ------------------------------------------------------------------
def test_a_temporary_chat_is_not_in_the_sidebar(client: TestClient, db, registered, make_chat):
_connection(db)
_temporary(db, make_chat())
assert "Passing thought" not in client.get("/chat").text
def test_a_temporary_chat_inside_a_folder_is_not_listed(
client: TestClient, db, registered, make_chat
):
"""The folder branch renders through the relationship, which is why this
needs asserting separately from the unfiled list."""
_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})
_temporary(db, chat_id)
page = client.get("/chat").text
assert "Passing thought" not in page
assert "Empty" in page
def test_a_temporary_chat_gets_no_unread_dot(client: TestClient, db, registered, make_chat):
"""There is no sidebar row for the dot, and the toast would name a chat
nobody can navigate to."""
_connection(db)
chat = _temporary(db, make_chat())
chat.unread = True
db.commit()
response = client.get("/api/chats/unread")
assert chat.id not in response.text
assert "HX-Trigger" not in response.headers
def test_user_chats_excludes_temporary_ones(db, registered, make_chat, user_id):
_connection(db)
_temporary(db, make_chat())
assert chat_service.user_chats(db, user_id) == []
def test_a_finished_temporary_reply_is_not_unread(db, registered, make_chat):
from lembas.services import generation as generation_service
_connection(db)
chat_id = make_chat()
_temporary(db, chat_id)
reply = Message(chat_id=chat_id, role="assistant", content="", complete=False)
db.add(reply)
db.commit()
generation = generation_service.Generation(chat_id=chat_id, message_id=reply.id)
generation.content.append("Waybread.")
generation_service._persist(generation, "", 0.0)
db.expire_all()
assert db.get(Chat, chat_id).unread is False
# --- Starting one -------------------------------------------------------------
def test_the_new_chat_screen_carries_the_flag(client: TestClient, db, registered):
_connection(db)
assert 'name="temporary"' not in client.get("/chat").text
assert 'name="temporary"' in client.get("/chat?temporary=1").text
def test_starting_a_temporary_chat_sets_the_flag(client: TestClient, db, registered):
_connection(db)
client.post("/api/chats/start", data={"content": "hello", "temporary": "true"})
assert db.scalar(select(Chat)).temporary is True
def test_starting_an_ordinary_chat_does_not(client: TestClient, db, registered):
_connection(db)
client.post("/api/chats/start", data={"content": "hello"})
assert db.scalar(select(Chat)).temporary is False
# --- Keeping one --------------------------------------------------------------
def test_keeping_a_chat_clears_the_flag(client: TestClient, db, registered, make_chat):
_connection(db)
chat = _temporary(db, make_chat())
response = client.post(f"/api/chats/{chat.id}/keep")
assert response.status_code == 204
assert response.headers["HX-Refresh"] == "true"
db.refresh(chat)
assert chat.temporary is False
assert "Passing thought" in client.get("/chat").text
def test_keeping_someone_elses_chat_is_not_found(client: TestClient, db, registered, make_chat):
from lembas.db.models import User
from lembas.security.passwords import hash_password
_connection(db)
chat = _temporary(db, make_chat())
other = User(name="Sam", email="s@shire.test", password_hash=hash_password("x"))
db.add(other)
db.commit()
chat.user_id = other.id
db.commit()
assert client.post(f"/api/chats/{chat.id}/keep").status_code == 404
# --- The sweep ----------------------------------------------------------------
def _aged(db, chat_id: str, hours: float) -> None:
"""Backdate the chat's newest message, which is what the sweep measures."""
when = datetime.now(UTC) - timedelta(hours=hours)
message = Message(chat_id=chat_id, role="user", content="hello", created_at=when)
db.add(message)
db.commit()
def test_the_sweep_removes_a_stale_temporary_chat(db, registered, make_chat):
_connection(db)
chat_id = make_chat()
_temporary(db, chat_id)
_aged(db, chat_id, 25)
assert chat_service.sweep_temporary(db) == 1
assert db.get(Chat, chat_id) is None
def test_the_sweep_keeps_one_still_in_use(db, registered, make_chat):
"""Age is measured from the newest message. A conversation still going at
hour 23 must not vanish mid-sentence."""
_connection(db)
chat_id = make_chat()
_temporary(db, chat_id)
_aged(db, chat_id, 40)
_aged(db, chat_id, 0.5)
assert chat_service.sweep_temporary(db) == 0
assert db.get(Chat, chat_id) is not None
def test_the_sweep_leaves_ordinary_chats_alone(db, registered, make_chat):
_connection(db)
chat_id = make_chat()
_aged(db, chat_id, 500)
assert chat_service.sweep_temporary(db) == 0
assert db.get(Chat, chat_id) is not None
def test_a_temporary_chat_with_no_messages_ages_from_its_own_row(db, registered, make_chat):
_connection(db)
chat_id = make_chat()
chat = _temporary(db, chat_id)
chat.created_at = datetime.now(UTC) - timedelta(hours=30)
db.commit()
assert chat_service.sweep_temporary(db) == 1
def test_the_sweep_unlinks_the_files_too(client: TestClient, db, registered, make_chat):
"""Deleting a chat cascades the rows but leaves the files on disk. Anything
that deletes chats has to remove them while the rows still say which."""
from lembas.db.models import Attachment
from lembas.services.files import stored_path
_connection(db)
chat_id = make_chat()
client.post("/api/files", files={"file": ("notes.txt", b"some words", "text/plain")})
attachment = db.scalar(select(Attachment))
attachment.chat_id = chat_id
message = Message(chat_id=chat_id, role="user", content="look")
db.add(message)
db.commit()
attachment.message_id = message.id
db.commit()
path = stored_path(attachment.stored_name)
assert path is not None and path.exists()
_temporary(db, chat_id)
db.query(Message).filter(Message.id == message.id).update(
{"created_at": datetime.now(UTC) - timedelta(hours=30)}
)
db.commit()
chat_service.sweep_temporary(db)
assert not path.exists()