"""Mid-stream termination tests — exercise ``_make_chat_stream_callback`` end-to-end through ``BaseCitationHandler._invoke_with_streaming`` to verify that clicking Stop while the LLM is streaming actually interrupts the loop, persists the partial chunks, and propagates ``ResearchTerminatedException``. Uses a fake LLM that yields chunks one at a time; the test flips the termination flag after the Nth chunk and asserts the rest never run. """ from unittest.mock import MagicMock, patch import pytest from local_deep_research.exceptions import ResearchTerminatedException class _FakeStreamingLLM: """Minimal LLM with ``.stream()`` yielding one chunk at a time.""" def __init__(self, chunks): self._chunks = chunks self.invoke_called = False def stream(self, _prompt): for c in self._chunks: yield c def invoke(self, _prompt): # If termination falls through to ``invoke()`` we want to know — # see the ``test_terminated_does_not_fall_back_to_invoke`` case. self.invoke_called = True return "FALLBACK" def _build_callback_and_state(research_id): """Build the production stream_callback wired to a fake socket.""" from local_deep_research.web.services.research_service import ( _make_chat_stream_callback, ) streaming_state = { "chunks_sent": 0, "chunks": [], "_bytes": 0, "_truncated": False, } sock = MagicMock() cb = _make_chat_stream_callback(research_id, streaming_state, sock) return cb, streaming_state, sock class TestStreamCallbackBuffering: def test_buffers_chunks_for_partial_persistence(self): cb, state, _sock = _build_callback_and_state("rid-1") with patch( "local_deep_research.web.routes.globals.is_termination_requested", return_value=False, ): for chunk in ["Hel", "lo, ", "wor", "ld"]: cb(chunk) assert state["chunks_sent"] == 4 assert "".join(state["chunks"]) == "Hello, world" assert state["_truncated"] is False def test_buffer_caps_at_max_bytes(self): from local_deep_research.web.services import research_service cb, state, _sock = _build_callback_and_state("rid-cap") big_chunk = "x" * (research_service._MAX_PARTIAL_BUFFER_BYTES // 2 + 1) with patch( "local_deep_research.web.routes.globals.is_termination_requested", return_value=False, ): cb(big_chunk) cb(big_chunk) # second push should overflow the cap assert state["_truncated"] is True # The first chunk was buffered in full; the second was dropped from # the buffer but ``chunks_sent`` keeps counting. assert "".join(state["chunks"]) == big_chunk assert state["chunks_sent"] == 2 def test_empty_chunks_ignored(self): cb, state, _sock = _build_callback_and_state("rid-empty") with patch( "local_deep_research.web.routes.globals.is_termination_requested", return_value=False, ): cb("") cb(None) assert state["chunks_sent"] == 0 assert state["chunks"] == [] class TestStreamCallbackTermination: def test_raises_when_termination_flag_set(self): cb, state, _sock = _build_callback_and_state("rid-x") with patch( "local_deep_research.web.routes.globals.is_termination_requested", return_value=True, ): with pytest.raises(ResearchTerminatedException): cb("any-chunk") # The chunk was buffered before the raise — partial persistence # depends on this. assert state["chunks"] == ["any-chunk"] assert state["chunks_sent"] == 1 def test_propagates_through_invoke_with_streaming(self): """The full path: fake LLM yields chunks, our callback raises mid-stream, ``BaseCitationHandler._invoke_with_streaming`` lets it propagate rather than falling back to ``llm.invoke()``.""" from local_deep_research.citation_handlers.standard_citation_handler import ( StandardCitationHandler, ) all_chunks = ["chunk1", "chunk2", "chunk3", "chunk4", "chunk5"] fake_llm = _FakeStreamingLLM(all_chunks) handler = StandardCitationHandler(fake_llm) cb, state, _sock = _build_callback_and_state("rid-mid") handler.set_stream_callback(cb) # Termination flips after chunk 2 — keep a counter. seen = {"count": 0} def termination_after_two(_rid): seen["count"] += 1 return seen["count"] > 2 with patch( "local_deep_research.web.routes.globals.is_termination_requested", side_effect=termination_after_two, ): with pytest.raises(ResearchTerminatedException): handler._invoke_with_streaming("prompt") # Streamed exactly 3 chunks (the third triggered termination after # being buffered) — the remaining two never ran. assert state["chunks_sent"] == 3 assert "".join(state["chunks"]) == "chunk1chunk2chunk3" # Critically: the LLM's ``invoke()`` fallback must NOT have run — # otherwise termination would silently complete the response. assert fake_llm.invoke_called is False def test_no_termination_completes_normally(self): """Sanity: with termination always False, streaming completes and returns the full joined text.""" from local_deep_research.citation_handlers.standard_citation_handler import ( StandardCitationHandler, ) chunks = ["a", "b", "c"] handler = StandardCitationHandler(_FakeStreamingLLM(chunks)) cb, state, _sock = _build_callback_and_state("rid-ok") handler.set_stream_callback(cb) with patch( "local_deep_research.web.routes.globals.is_termination_requested", return_value=False, ): result = handler._invoke_with_streaming("prompt") assert result == "abc" assert state["chunks_sent"] == 3 assert "".join(state["chunks"]) == "abc" class TestCarryBufferFlush: """Regression guard: carry-buffer flush before the is_final sentinel. When inline-citation hyperlinking is active, a chunk ending in a partial bracket token like ``[12`` is held in the carry buffer until the closing ``]`` arrives. If the LLM stream ends while a partial token is still held, the completion finalizer (``_save_chat_message_and_context``) flushes it via ``streaming_state['_flush_carry']`` before emitting the ``is_final`` sentinel — otherwise the trailing text is silently dropped from the client's accumulated view. """ def _build_with_citations(self, research_id): from local_deep_research.web.services.research_service import ( _make_chat_stream_callback, ) streaming_state = { "chunks_sent": 0, "chunks": [], "_bytes": 0, "_truncated": False, } sock = MagicMock() # Non-empty sources so the carry path activates; a pass-through # formatter lets us assert on the raw carry behaviour without # depending on CitationFormatter's substitution details. formatter = MagicMock() formatter.apply_inline_hyperlinks.side_effect = lambda text, sources: ( text ) cb = _make_chat_stream_callback( research_id, streaming_state, sock, source_resolver=lambda: [{"url": "https://example.com"}], formatter=formatter, ) return cb, streaming_state, sock def _emitted_chunks(self, sock): return "".join( call.args[2]["chunk"] for call in sock.emit_to_subscribers.call_args_list if call.args and call.args[0] == "response_chunk" ) def test_flush_carry_is_registered(self): """The completion finalizer reaches the carry via this hook.""" _cb, state, _sock = self._build_with_citations("rid-flush-reg") assert callable(state.get("_flush_carry")) def test_partial_bracket_held_then_flushed(self): cb, state, sock = self._build_with_citations("rid-flush") with patch( "local_deep_research.web.routes.globals.is_termination_requested", return_value=False, ): # Stream ends mid-token: "[12" must be held back, not emitted. cb("See the source [12") # The partial token is withheld from the live socket emit... assert "[12" not in self._emitted_chunks(sock) # ...but the safe prefix was emitted. assert "See the source" in self._emitted_chunks(sock) # The finalizer's flush releases the held fragment so the tail # isn't lost when the stream ends without a closing ']'. assert state["_flush_carry"]() == "[12" # Idempotent: a second flush (e.g. duplicate finalizer call) # returns nothing rather than re-emitting. assert state["_flush_carry"]() == "" def test_flush_empty_when_no_partial_token(self): """A clean stream (no trailing partial bracket) leaves nothing in carry, so the finalizer's flush is a no-op.""" cb, state, _sock = self._build_with_citations("rid-clean") with patch( "local_deep_research.web.routes.globals.is_termination_requested", return_value=False, ): cb("A complete sentence with no open bracket.") assert state["_flush_carry"]() == "" def test_carry_caps_and_flushes_on_overflow(self): """Regression guard: carry-buffer overflow handling. A never-closing '[' followed by a long digit run must NOT grow the carry buffer without bound. Past _MAX_CARRY_BYTES the fragment is flushed raw to the socket and the carry is cleared, so a hostile/misbehaving LLM can't consume unbounded memory. """ from local_deep_research.web.services import research_service cb, state, sock = self._build_with_citations("rid-cap") # A bracket opener followed by more digits than the carry cap. long_token = "[" + "1" * (research_service._MAX_CARRY_BYTES + 50) with patch( "local_deep_research.web.routes.globals.is_termination_requested", return_value=False, ): cb(long_token) # The oversized fragment was flushed to the socket, not held... assert long_token in self._emitted_chunks(sock) # ...and the carry buffer is empty afterwards (nothing to grow). assert state["_flush_carry"]() == ""