From cce96588e29796c487d3916a00202fe361ed8531 Mon Sep 17 00:00:00 2001 From: KiriAky 107 Date: Sat, 5 Sep 2026 10:31:42 +0800 Subject: [PATCH] =?UTF-8?q?fix(chat):=20=E9=98=B2=E6=AD=A2=E4=BC=9A?= =?UTF-8?q?=E8=AF=9D=E5=88=87=E6=8D=A2=E4=B8=B2=E5=86=99=E5=92=8C=E5=88=A0?= =?UTF-8?q?=E9=99=A4=E5=90=8E=E5=A4=8D=E6=B4=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/services/chat_history.py | 4 + backend/tests/test_chat_history.py | 41 +++++++- frontend/src/features/chat/ChatView.vue | 4 +- frontend/src/stores/chat.spec.ts | 128 ++++++++++++++++++++++++ frontend/src/stores/chat.ts | 36 +++++-- 5 files changed, 200 insertions(+), 13 deletions(-) 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) {