Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions docs/architecture/chat.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ The list handed to the generator is `[system, *history within budget, user]`.
| `completed` | last | `assistant_completed_at`, the final `text` |

- Every frame is `data: {json}\n\n`, and the stream ends with `data: [DONE]\n\n` so a client can tell completion from a dropped connection. The response sends `Cache-Control: no-cache` and `X-Accel-Buffering: no` so nothing buffers it into one late blob.
- A turn that fails before producing any text is deleted, both halves, and the stream goes from `error` straight to `[DONE]`, even if the model had reasoned. A turn that fails midway keeps its partial text.
- A turn that ends without any answer text is deleted, both halves, and the stream goes from `error` straight to `[DONE]`, even if the model had reasoned. That holds whether generation failed or closed cleanly with nothing to say; the clean case is sent as `unknown`, because discarding the turn also removes the person's own message and silence would hide that. A turn that fails midway keeps its partial text.
- The trace comes from the provider's `chat_deltas()`, which marks each chunk as answer or reasoning ([`connections.md`](connections.md#runtime)). It never reaches `resolve_citations()`, so a `[n]` inside it is neither rewritten nor stored as a citation.
- Errors are sorted by exception type, HTTP status and provider, and for a 400 by the body's `error.type` ([`errors.py`](../../surfsense_local/backend/modules/chat/errors.py)), into `provider_auth`, `provider_not_found`, `provider_rate_limited`, `provider_unavailable`, `model_cannot_run`, `context_too_long`, `network`, `timeout` and `unknown`, each with a plain-language English message. The frontend shows its own translated text per kind ([`chat-error-text.ts`](../../surfsense_local/frontend/src/features/chat/chat-error-text.ts)) and falls back to the backend's message for a kind it does not know.

Expand Down Expand Up @@ -126,7 +126,6 @@ The list handed to the generator is `[system, *history within budget, user]`.

## Known gaps

- A stream that ends without an error but yields no text still renames the thread and stores an empty assistant turn; the discard guard is `failed and not parts`.
- `budget.py` prices the question at 1,024 tokens and says `MessageText` enforces that, but `MessageText` has no length limit, so a long question can push a turn past the model's window.
- No live region announces streamed text, and focus does not move to the conversation heading after a thread switch; the dashboard design asks for both.
- No test covers a client disconnecting mid-reply. The assistant's text is written only when generation ends, inside the stream, so whether a disconnected reply is kept is unverified.
Expand Down
9 changes: 9 additions & 0 deletions surfsense_local/backend/modules/chat/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -122,3 +122,12 @@ def _is_context_too_long(response: httpx.Response) -> bool:
error = payload.get("error") if isinstance(payload, dict) else None
error_type = error.get("type") if isinstance(error, dict) else None
return error_type == _CONTEXT_TOO_LONG_ERROR_TYPE


def empty_reply_error() -> tuple[ChatErrorKind, str]:
"""A stream that closed with no answer text: nothing raised, but nothing to keep.

`unknown` rather than a new kind, whose text would need translating: a
retry can succeed, since sampling differs from one run to the next.
"""
return ChatErrorKind.UNKNOWN, _MESSAGES[ChatErrorKind.UNKNOWN]
20 changes: 17 additions & 3 deletions surfsense_local/backend/modules/chat/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
from api.dependencies import SessionDep, transact
from modules.chat.budget import answer_max_tokens, history_budget
from modules.chat.dependencies import ThreadDep
from modules.chat.errors import classify_chat_error
from modules.chat.errors import classify_chat_error, empty_reply_error
from modules.chat.history import TokenCounter, build_messages
from modules.chat.models import ChatMessage, ChatThread, MessageRole
from modules.chat.prompt import build_context, resolve_citations
Expand Down Expand Up @@ -217,7 +217,9 @@ async def stream() -> AsyncIterator[bytes]:
)
failed = True
finally:
if failed and not parts:
# No answer text is no reply, whether it failed, closed cleanly or
# only thought: keeping it would leave a blank bubble and a rename.
if not parts:
await transact(
session, _discard_turn, user_message, assistant_message
)
Expand All @@ -240,7 +242,19 @@ async def stream() -> AsyncIterator[bytes]:
)
assistant_completed_at = _iso(assistant_message.completed_at)

if failed and not parts:
if not parts:
if not failed:
# Discarding the turn removes the person's own message too, so
# say so rather than close in silence.
kind, message = empty_reply_error()
yield _frame(
{
"type": "error",
"kind": kind,
"message": message,
"provider": selected.provider,
}
)
yield _DONE
return

Expand Down
15 changes: 14 additions & 1 deletion surfsense_local/backend/tests/integration/chat/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
# as llama-server does under `--reasoning-format deepseek`. Empty: no thinking.
_REASONING: list[str] = []

# The answer the stub streams, or None for REPLY_DELTAS. Empty: a model that
# closes its stream without writing anything.
_ANSWER: list[str] | None = None


class StubRouterChat(BaseHTTPRequestHandler):
"""The router's OpenAI chat endpoint, streaming its reply as SSE.
Expand Down Expand Up @@ -80,6 +84,8 @@ def do_POST(self) -> None:
["Revenue ", "Growth"]
if request.get("max_tokens") == 12
else REPLY_DELTAS
if _ANSWER is None
else _ANSWER
)
trace = [] if request.get("max_tokens") == 12 else _REASONING
chunks = (
Expand Down Expand Up @@ -109,9 +115,10 @@ def log_message(self, *args: object) -> None:
@pytest.fixture
def llamacpp_server(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[dict]]:
"""A real llama-server stand-in on a real port; yields the requests it sees."""
global _PROPS_N_CTX, _TOKENS_PER_WORD
global _PROPS_N_CTX, _TOKENS_PER_WORD, _ANSWER
_REQUESTS.clear()
_REASONING.clear()
_ANSWER = None
_PROPS_N_CTX = None
_TOKENS_PER_WORD = None
server = ThreadingHTTPServer(("127.0.0.1", 0), StubRouterChat)
Expand Down Expand Up @@ -170,6 +177,12 @@ def set_reasoning(pieces: list[str]) -> None:
_REASONING[:] = pieces


def set_answer(pieces: list[str]) -> None:
"""Make the `llamacpp_server` answer with these deltas instead of the default."""
global _ANSWER
_ANSWER = pieces


@pytest.fixture
def llamacpp_server_unauthorized(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
"""A chat stand-in that fails every request, for exercising error handling."""
Expand Down
27 changes: 27 additions & 0 deletions surfsense_local/backend/tests/integration/chat/test_chat.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from modules.workspaces.models import Workspace
from shared.db import create_session_factory
from tests.integration.chat.conftest import (
set_answer,
set_props_n_ctx,
set_reasoning,
set_tokens_per_word,
Expand Down Expand Up @@ -364,6 +365,32 @@ async def test_a_failed_reply_is_classified_and_leaves_no_trace(
assert threads[0]["title"] == "New chat"


@pytest.mark.parametrize("reasoning", [[], ["The note says ", "nothing useful."]])
async def test_a_reply_with_no_text_leaves_no_trace(
client: AsyncClient,
engine: Engine,
real_model: object,
llamacpp_server: list[dict],
reasoning: list[str],
) -> None:
"""A stream that closes cleanly with no answer, even after thinking, is discarded."""
set_reasoning(reasoning)
set_answer([])
workspace_id, _ids = _seed(engine)
thread_id = await _open_thread(client, workspace_id)

events = await _send(client, thread_id, "what happened?")

error = next(event for event in events if event["type"] == "error")
assert error["kind"] == "unknown"
assert not any(event["type"] == "completed" for event in events)

stored = (await client.get(f"/chat/threads/{thread_id}/messages")).json()
assert stored == []
threads = (await client.get(f"/workspaces/{workspace_id}/chat/threads")).json()
assert threads[0]["title"] == "New chat"


async def test_a_thread_with_no_model_selected_is_a_409(client: AsyncClient) -> None:
"""Refused before retrieval, so the frontend can route the user to setup."""
workspace = (await client.post("/workspaces", json={"name": "w"})).json()
Expand Down
Loading