"""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