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
216 lines
8.0 KiB
Python
216 lines
8.0 KiB
Python
"""Unit tests for ``_chat_step_decision`` in research_service.
|
|
|
|
This helper encodes the live-emit/reload symmetry invariant:
|
|
|
|
> For chat sessions, what the live socket emits MUST equal what
|
|
> ``loadSession`` reconstructs from chat_progress_steps on reload.
|
|
|
|
The progress_callback inside ``run_research_process`` consults this
|
|
helper for every callback event and uses its (persist, suppress_emit)
|
|
return to gate both the DB write and the socket emit.
|
|
|
|
The tests below pin down the contract per scenario.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from src.local_deep_research.web.services.research_service import (
|
|
_chat_step_decision,
|
|
_compose_chat_step_content,
|
|
_STEP_PHASES,
|
|
)
|
|
|
|
|
|
class TestNonStepPhases:
|
|
"""Phases not in _STEP_PHASES (e.g. 'complete') never persist
|
|
but never block the emit either — the completion handler needs them."""
|
|
|
|
def test_complete_phase_no_persist_no_suppress(self):
|
|
# "complete" is intentionally excluded from _STEP_PHASES so it
|
|
# doesn't get a row ordered after the response message.
|
|
assert "complete" not in _STEP_PHASES
|
|
persist, suppress = _chat_step_decision(
|
|
phase="complete", last_step_phase=None, is_final=True
|
|
)
|
|
assert persist is False
|
|
assert suppress is False
|
|
|
|
def test_unknown_phase_no_persist_no_suppress(self):
|
|
persist, suppress = _chat_step_decision(
|
|
phase="some-future-phase",
|
|
last_step_phase=None,
|
|
is_final=False,
|
|
)
|
|
assert persist is False
|
|
assert suppress is False
|
|
|
|
def test_none_phase_no_persist_no_suppress(self):
|
|
persist, suppress = _chat_step_decision(
|
|
phase=None, last_step_phase=None, is_final=False
|
|
)
|
|
assert persist is False
|
|
assert suppress is False
|
|
|
|
|
|
class TestFirstStepEver:
|
|
"""The first step of a research always persists; no prior phase."""
|
|
|
|
@pytest.mark.parametrize("phase", sorted(_STEP_PHASES))
|
|
def test_first_step_persists_and_emits(self, phase):
|
|
persist, suppress = _chat_step_decision(
|
|
phase=phase, last_step_phase=None, is_final=False
|
|
)
|
|
assert persist is True, f"phase={phase} should persist when no prior"
|
|
assert suppress is False, (
|
|
f"phase={phase} should also emit on first occurrence"
|
|
)
|
|
|
|
|
|
class TestPhaseTransition:
|
|
"""Different phase from the last persisted one: persist + emit."""
|
|
|
|
def test_search_after_init_persists(self):
|
|
persist, suppress = _chat_step_decision(
|
|
phase="search", last_step_phase="init", is_final=False
|
|
)
|
|
assert (persist, suppress) == (True, False)
|
|
|
|
def test_report_generation_after_search_persists(self):
|
|
persist, suppress = _chat_step_decision(
|
|
phase="report_generation",
|
|
last_step_phase="search",
|
|
is_final=False,
|
|
)
|
|
assert (persist, suppress) == (True, False)
|
|
|
|
|
|
class TestRepeatPhaseDedup:
|
|
"""Same phase as last persisted: dedup blocks persistence, AND when
|
|
the event is non-final the emit is dropped to maintain symmetry.
|
|
'observation' is whitelisted to repeat.
|
|
"""
|
|
|
|
def test_repeat_search_non_final_blocks_both(self):
|
|
# The symmetry guarantee: if we wouldn't persist this step, we
|
|
# don't emit it either — reload would not surface it.
|
|
persist, suppress = _chat_step_decision(
|
|
phase="search", last_step_phase="search", is_final=False
|
|
)
|
|
assert persist is False
|
|
assert suppress is True
|
|
|
|
def test_repeat_report_generation_non_final_blocks_both(self):
|
|
persist, suppress = _chat_step_decision(
|
|
phase="report_generation",
|
|
last_step_phase="report_generation",
|
|
is_final=False,
|
|
)
|
|
assert (persist, suppress) == (False, True)
|
|
|
|
def test_observation_can_repeat(self):
|
|
"""'observation' is the documented exception to the dedup —
|
|
it represents distinct in-iteration findings and must always
|
|
persist + emit."""
|
|
persist, suppress = _chat_step_decision(
|
|
phase="observation",
|
|
last_step_phase="observation",
|
|
is_final=False,
|
|
)
|
|
assert persist is True
|
|
assert suppress is False
|
|
|
|
def test_repeat_phase_but_is_final_still_emits(self):
|
|
"""Final phases (error, report_complete) emit even on repeat so
|
|
the client completion handler fires. Persist is still blocked by
|
|
the dedup so we don't write a duplicate row."""
|
|
persist, suppress = _chat_step_decision(
|
|
phase="error", last_step_phase="error", is_final=True
|
|
)
|
|
assert persist is False
|
|
# The crucial part: do NOT suppress the emit when is_final, even
|
|
# though the DB write was deduped.
|
|
assert suppress is False
|
|
|
|
def test_repeat_report_complete_is_final_still_emits(self):
|
|
persist, suppress = _chat_step_decision(
|
|
phase="report_complete",
|
|
last_step_phase="report_complete",
|
|
is_final=True,
|
|
)
|
|
assert (persist, suppress) == (False, False)
|
|
|
|
|
|
class TestComposeChatStepContent:
|
|
"""``_compose_chat_step_content`` appends the observation detail from
|
|
``metadata["content"]`` beneath the one-line message — and ONLY for
|
|
observation events. Other phases reuse metadata["content"] for large
|
|
payloads (full synthesized answers) that must not be duplicated into
|
|
step rows. The same composition happens client-side for live steps
|
|
(chat.js handleProgressUpdate), preserving the symmetry invariant."""
|
|
|
|
def test_observation_with_detail_appends_it(self):
|
|
out = _compose_chat_step_content(
|
|
"📄 From the web (SearXNG): [1] Title…",
|
|
"observation",
|
|
{"content": "[1] Title (http://a.com)\nFull snippet"},
|
|
)
|
|
assert out == (
|
|
"📄 From the web (SearXNG): [1] Title…"
|
|
"\n\n[1] Title (http://a.com)\nFull snippet"
|
|
)
|
|
|
|
def test_observation_without_detail_keeps_message(self):
|
|
out = _compose_chat_step_content(
|
|
"📄 From PubMed: no results", "observation", {}
|
|
)
|
|
assert out == "📄 From PubMed: no results"
|
|
|
|
def test_non_observation_phase_ignores_content(self):
|
|
out = _compose_chat_step_content(
|
|
"Generating final answer…",
|
|
"output_generation",
|
|
{"content": "the entire synthesized answer"},
|
|
)
|
|
assert out == "Generating final answer…"
|
|
|
|
def test_empty_detail_keeps_message(self):
|
|
out = _compose_chat_step_content(
|
|
"📄 From arXiv: …", "observation", {"content": ""}
|
|
)
|
|
assert out == "📄 From arXiv: …"
|
|
|
|
|
|
class TestSymmetryInvariant:
|
|
"""Property-style assertion of the invariant: for every event in
|
|
_STEP_PHASES, if we persist we also emit; if we don't persist and
|
|
it's not final, we must suppress the emit so reload matches live.
|
|
"""
|
|
|
|
@pytest.mark.parametrize(
|
|
"phase",
|
|
sorted(_STEP_PHASES),
|
|
)
|
|
@pytest.mark.parametrize("last_phase", sorted(_STEP_PHASES) + [None])
|
|
@pytest.mark.parametrize("is_final", [True, False])
|
|
def test_invariant_holds(self, phase, last_phase, is_final):
|
|
persist, suppress = _chat_step_decision(
|
|
phase=phase, last_step_phase=last_phase, is_final=is_final
|
|
)
|
|
if persist:
|
|
# If we persist, we must also emit so the live UI surfaces it.
|
|
assert suppress is False, (
|
|
f"persist=True + suppress=True breaks symmetry: "
|
|
f"phase={phase}, last={last_phase}, is_final={is_final}"
|
|
)
|
|
elif not is_final:
|
|
# Non-final and no persist => emit must be suppressed so
|
|
# live UI doesn't surface an event that reload will miss.
|
|
assert suppress is True, (
|
|
f"non-final + not-persist + not-suppress breaks "
|
|
f"symmetry: phase={phase}, last={last_phase}"
|
|
)
|
|
else:
|
|
# Final + not-persist: emit is allowed through so the
|
|
# client completion handler fires.
|
|
assert suppress is False
|