Files
learningcircuit--local-deep…/tests/chat/test_chat_termination_midstream.py
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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"]() == ""