diff --git a/backend/app/services/chat_history.py b/backend/app/services/chat_history.py index c98a385..76ceedc 100644 --- a/backend/app/services/chat_history.py +++ b/backend/app/services/chat_history.py @@ -147,6 +147,10 @@ def _append_message_in_transaction( "SELECT 1 FROM chat_conversations WHERE conversation_id=?", (conversation_id,) ).fetchone() if conversation is None: + # A stream may finish after deletion. Check under BEGIN IMMEDIATE so + # deletion and assistant persistence cannot recreate an orphaned chat. + if role == "assistant": + return conn.execute( "INSERT INTO chat_conversations(conversation_id,title,created_at,updated_at) VALUES(?,?,?,?)", (conversation_id, title, now, now), diff --git a/backend/tests/test_chat_history.py b/backend/tests/test_chat_history.py index a4ed03f..f4dec3b 100644 --- a/backend/tests/test_chat_history.py +++ b/backend/tests/test_chat_history.py @@ -1,9 +1,11 @@ +import asyncio from datetime import datetime, timezone from types import SimpleNamespace from fastapi.testclient import TestClient +import pytest -from app.contracts import ModelEvent, ModelEventType +from app.contracts import ChatRequest, ModelEvent, ModelEventType from app.main import app from app.services import chat_history @@ -80,3 +82,40 @@ def test_chat_conversation_crud_api() -> None: missing = client.get("/api/chat/conversations/crud/messages") assert missing.status_code == 404 assert missing.json()["error"]["code"] == "CONVERSATION_NOT_FOUND" + + +@pytest.mark.parametrize("close_early", [True, False]) +@pytest.mark.parametrize("deleted", [True, False]) +def test_stream_finalization_respects_conversation_deletion(monkeypatch, close_early, deleted) -> None: + from app import routes + + class Adapter: + async def stream(self, _request): + now = datetime.now(timezone.utc) + yield ModelEvent(event=ModelEventType.text_delta, data={"text": "partial answer"}, timestamp=now) + yield ModelEvent(event=ModelEventType.done, timestamp=now) + + monkeypatch.setattr(routes, "provider_or_404", lambda _: SimpleNamespace(adapter=Adapter())) + + async def scenario(): + response = await routes.chat(ChatRequest( + provider_id="configured", model="model", conversation_id="stream", + use_rag=False, messages=[{"role": "user", "content": "question"}], + )) + await anext(response.body_iterator) + if deleted: + assert chat_history.delete("stream") + if close_early: + await response.body_iterator.aclose() + else: + async for _ in response.body_iterator: + pass + if deleted: + assert chat_history.get("stream") is None + assert chat_history.list_conversations(50, 0)[1] == 0 + else: + messages, total = chat_history.list_messages("stream", 50, 0) + assert total == 2 + assert [message.content for message in messages] == ["question", "partial answer"] + + asyncio.run(scenario()) diff --git a/frontend/src/features/chat/ChatView.vue b/frontend/src/features/chat/ChatView.vue index 23e8bd8..b991ad5 100644 --- a/frontend/src/features/chat/ChatView.vue +++ b/frontend/src/features/chat/ChatView.vue @@ -94,8 +94,8 @@ async function openCitation(citation: Citation) {