7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
289 lines
11 KiB
Python
289 lines
11 KiB
Python
"""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"]() == ""
|