"""Unit tests for ChatContextManager.""" class TestChatContextManagerBuildResearchContext: """Tests for ChatContextManager.build_research_context method.""" def test_build_research_context_returns_required_keys( self, sample_messages, sample_accumulated_context ): """Test that build_research_context returns all required keys.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="session-123", messages=sample_messages, accumulated_context=sample_accumulated_context, ) result = manager.build_research_context() # Actual return keys from implementation required_keys = { "session_id", "original_query", "accumulated_findings", "past_findings", "key_entities", "topics", "is_multi_turn", "turn_count", } assert required_keys.issubset(set(result.keys())) # past_findings must equal accumulated_findings (research engine expects this key) assert result["past_findings"] == result["accumulated_findings"] def test_build_research_context_is_multi_turn_false_for_empty_messages( self, sample_accumulated_context ): """Test that is_multi_turn is False when no previous messages.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="session-123", messages=[], accumulated_context=sample_accumulated_context, ) result = manager.build_research_context() assert result["is_multi_turn"] is False def test_build_research_context_is_multi_turn_true_for_existing_messages( self, sample_messages, sample_accumulated_context ): """Test that is_multi_turn is True when previous messages exist.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="session-123", messages=sample_messages, accumulated_context=sample_accumulated_context, ) result = manager.build_research_context() assert result["is_multi_turn"] is True def test_build_research_context_includes_turn_count( self, sample_messages, sample_accumulated_context ): """Test that turn_count is included in the context.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="session-123", messages=sample_messages, accumulated_context=sample_accumulated_context, ) result = manager.build_research_context() assert result["turn_count"] == len(sample_messages) def test_build_research_context_includes_session_id( self, sample_messages, sample_accumulated_context ): """Test that session_id is included in the context.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="test-session-abc", messages=sample_messages, accumulated_context=sample_accumulated_context, ) result = manager.build_research_context() assert result["session_id"] == "test-session-abc" class TestChatContextManagerExtraction: """Tests for ChatContextManager extraction methods via build_research_context.""" def test_extract_findings_from_history_only_assistant_with_research( self, sample_messages, sample_accumulated_context ): """Test that only assistant messages with research_id are extracted.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="session-123", messages=sample_messages, accumulated_context=sample_accumulated_context, ) result = manager.build_research_context() # accumulated_findings is a string containing research content findings = result["accumulated_findings"] assert isinstance(findings, str) # Should contain content from assistant messages with research_id assert len(findings) > 0, ( "Findings should contain assistant research content" ) assert ( "quantum" in findings.lower() or "application" in findings.lower() ) def test_extract_findings_limits_to_5_recent( self, many_messages, sample_accumulated_context ): """Test that findings are limited to 5 most recent.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="session-123", messages=many_messages, accumulated_context=sample_accumulated_context, ) result = manager.build_research_context() # accumulated_findings is a string (joined with "\n\n---\n\n") findings = result["accumulated_findings"] if findings: # Count separators to determine number of findings separator_count = findings.count("\n\n---\n\n") # Number of findings = separator_count + 1 (if any content) finding_count = separator_count + 1 if findings.strip() else 0 assert finding_count <= 5 class TestChatContextManagerCreateSummary: """Tests for ChatContextManager _create_summary method via extract_context_updates.""" def test_create_summary_finds_first_paragraph(self): """Test that _create_summary finds the first substantial paragraph.""" from src.local_deep_research.chat.context import ChatContextManager content = """# Heading This is the first paragraph with substantial content about quantum computing. This is the second paragraph.""" manager = ChatContextManager( session_id="session-123", messages=[], accumulated_context={}, ) result = manager.extract_context_updates(content) # summary_addition should contain first substantial paragraph summary = result["summary_addition"] # Should find first substantial paragraph (skipping header) assert "quantum computing" in summary.lower() or len(summary) > 0 def test_create_summary_skips_headers(self): """Test that _create_summary skips markdown headers when substantial content follows.""" from src.local_deep_research.chat.context import ChatContextManager # Content with a substantial paragraph (>50 chars) after headers content = """# Main Heading ## Sub Heading This is the actual content paragraph with more than fifty characters of meaningful text about the topic.""" manager = ChatContextManager( session_id="session-123", messages=[], accumulated_context={}, ) result = manager.extract_context_updates(content) # summary_addition should not start with # (header) # when there's a substantial paragraph after the headers summary = result["summary_addition"] if summary: assert not summary.strip().startswith("#") def test_create_summary_truncates_long_content(self): """Test that _create_summary truncates long paragraphs to 300 chars.""" from src.local_deep_research.chat.context import ChatContextManager long_paragraph = "X" * 500 manager = ChatContextManager( session_id="session-123", messages=[], accumulated_context={}, ) result = manager.extract_context_updates(long_paragraph) # summary_addition should be truncated summary = result["summary_addition"] assert len(summary) <= 303 # 300 chars + "..." class TestChatContextManagerExtractContextUpdates: """Tests for ChatContextManager.extract_context_updates method.""" def test_extract_context_updates_returns_required_keys(self): """Test that extract_context_updates returns all required keys.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="session-123", messages=[], accumulated_context={}, ) result = manager.extract_context_updates("Some new content") required_keys = { "new_entities", "new_topics", "summary_addition", } assert required_keys.issubset(set(result.keys())) class TestChatContextManagerKeyEntitiesAndTopics: """Tests for key entities and topics handling.""" def test_get_key_entities_from_accumulated_context( self, sample_messages, sample_accumulated_context ): """Test that key entities are retrieved from accumulated context.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="session-123", messages=sample_messages, accumulated_context=sample_accumulated_context, ) result = manager.build_research_context() # Should include entities from accumulated context (limited to 20) assert "key_entities" in result assert isinstance(result["key_entities"], list) assert len(result["key_entities"]) <= 20 def test_get_topics_from_accumulated_context( self, sample_messages, sample_accumulated_context ): """Test that topics are retrieved from accumulated context.""" from src.local_deep_research.chat.context import ChatContextManager manager = ChatContextManager( session_id="session-123", messages=sample_messages, accumulated_context=sample_accumulated_context, ) result = manager.build_research_context() # Should include topics from accumulated context (limited to 10) assert "topics" in result assert isinstance(result["topics"], list) assert len(result["topics"]) <= 10 class TestBuildResearchContextFocusedSummary: """build_research_context condenses prior turns into a query-focused summary.""" def _conversation(self): return [ { "role": "user", "content": "What is quantum computing?", "message_type": "query", }, { "role": "assistant", "content": "Quantum computing uses qubits and superposition.", "message_type": "response", "research_id": "r1", }, ] def test_focused_summary_used_as_past_findings(self, mocker): """With a query + snapshot, past_findings is the focused LLM summary.""" from src.local_deep_research.chat.context import ChatContextManager fake_llm = mocker.Mock() fake_llm.invoke.return_value = mocker.Mock( content="Prior work, focused on cost." ) get_llm = mocker.patch( "src.local_deep_research.config.llm_config.get_llm", return_value=fake_llm, ) manager = ChatContextManager( session_id="s1", messages=self._conversation(), accumulated_context={}, settings_snapshot={"llm.provider": "ollama"}, ) result = manager.build_research_context( current_query="How much does it cost?" ) assert result["past_findings"] == "Prior work, focused on cost." assert result["accumulated_findings"] == "Prior work, focused on cost." get_llm.assert_called_once() # The new question must drive the focus of the summary prompt. prompt = fake_llm.invoke.call_args.args[0] assert "How much does it cost?" in prompt # The transcript fed to the summarizer includes both roles. assert "User:" in prompt and "Assistant:" in prompt def test_no_current_query_uses_raw_findings(self, mocker): """A no-arg call (no focus question) makes no LLM call.""" from src.local_deep_research.chat.context import ChatContextManager get_llm = mocker.patch( "src.local_deep_research.config.llm_config.get_llm" ) manager = ChatContextManager( session_id="s1", messages=self._conversation(), accumulated_context={}, settings_snapshot={"llm.provider": "ollama"}, ) result = manager.build_research_context() get_llm.assert_not_called() assert "qubits" in result["past_findings"].lower() class TestFollowupContextModes: """chat.followup_context_mode selects what prior context a follow-up gets.""" def _conversation(self): return [ { "role": "user", "content": "What is quantum computing?", "message_type": "query", }, { "role": "assistant", "content": "Quantum computing uses qubits and superposition.", "message_type": "response", "research_id": "r1", }, ] def _manager(self, mode): from src.local_deep_research.chat.context import ChatContextManager return ChatContextManager( session_id="s1", messages=self._conversation(), accumulated_context={}, settings_snapshot={"chat.followup_context_mode": mode}, ) def test_raw_mode_uses_recent_findings_no_llm(self, mocker): get_llm = mocker.patch( "src.local_deep_research.config.llm_config.get_llm" ) result = self._manager("raw").build_research_context( current_query="cost?" ) get_llm.assert_not_called() assert "qubits" in result["past_findings"].lower() def test_full_mode_sends_whole_transcript_no_llm(self, mocker): get_llm = mocker.patch( "src.local_deep_research.config.llm_config.get_llm" ) result = self._manager("full").build_research_context( current_query="cost?" ) get_llm.assert_not_called() past = result["past_findings"] assert "User:" in past and "Assistant:" in past assert "What is quantum computing?" in past def test_none_mode_sends_no_prior_findings_no_llm(self, mocker): get_llm = mocker.patch( "src.local_deep_research.config.llm_config.get_llm" ) result = self._manager("none").build_research_context( current_query="cost?" ) get_llm.assert_not_called() assert result["past_findings"] == "" def test_summary_mode_invokes_llm(self, mocker): fake_llm = mocker.Mock() fake_llm.invoke.return_value = mocker.Mock(content="Focused summary.") mocker.patch( "src.local_deep_research.config.llm_config.get_llm", return_value=fake_llm, ) result = self._manager("summary").build_research_context( current_query="cost?" ) assert result["past_findings"] == "Focused summary." fake_llm.invoke.assert_called_once() def test_summary_mode_empty_when_llm_cannot_be_built(self, mocker): """A get_llm() failure (e.g. misconfigured provider) degrades the summary to empty rather than crashing the follow-up request.""" mocker.patch( "src.local_deep_research.config.llm_config.get_llm", side_effect=RuntimeError("no provider configured"), ) result = self._manager("summary").build_research_context( current_query="cost?" ) assert result["past_findings"] == ""