"""Tests for report generator progress tracking, cancellation, and iteration override. Covers three fixes to IntegratedReportGenerator: 1. Iteration override fix — settings_snapshot is now modified instead of the dead-code search_system.max_iterations attribute. 2. Progress callbacks — subsection-level progress reported during report generation. 3. Cancellation — progress_callback fires before iteration override so termination propagates cleanly without corrupting settings. """ from unittest.mock import MagicMock import pytest from local_deep_research.report_generator import IntegratedReportGenerator from local_deep_research.web.services.research_service import ( _DETAILED_REPORT_PROGRESS_END, _DETAILED_REPORT_PROGRESS_START, ) # ── Fixtures ── @pytest.fixture def mock_search_system(): """Create a mock search system with a strategy that has settings_snapshot.""" system = MagicMock() system.strategy = MagicMock() system.strategy.settings_snapshot = {"search.iterations": 3} system.strategy.max_iterations = 3 system.all_links_of_system = [] system.analyze_topic.return_value = { "current_knowledge": "Test research content" } return system @pytest.fixture def mock_model(): """Create a mock LLM model.""" model = MagicMock() model.invoke.return_value = MagicMock( content=( "STRUCTURE\n" "1. Introduction\n" " - Overview | Provide an overview\n" " - Background | Historical context\n" "2. Analysis\n" " - Data | Present the data\n" "END_STRUCTURE" ) ) return model @pytest.fixture def generator(mock_search_system, mock_model): """Create an IntegratedReportGenerator with mocked dependencies.""" gen = IntegratedReportGenerator.__new__(IntegratedReportGenerator) gen.search_system = mock_search_system gen.model = mock_model gen.searches_per_section = 2 gen.max_context_sections = 3 gen.max_context_chars = 4000 return gen @pytest.fixture def simple_structure(): """A simple report structure with known subsection counts.""" return [ { "name": "Introduction", "subsections": [ {"name": "Overview", "purpose": "Provide an overview"}, {"name": "Background", "purpose": "Historical context"}, ], }, { "name": "Analysis", "subsections": [ {"name": "Data", "purpose": "Present the data"}, ], }, ] @pytest.fixture def initial_findings(): """Standard initial findings dict.""" return {"current_knowledge": "Initial research findings about the topic."} # ── Progress callback tests ── class TestProgressCallbackInGenerateReport: """Tests for progress_callback parameter in generate_report().""" def test_generate_report_without_callback_works( self, generator, initial_findings ): """generate_report() still works when no progress_callback is passed.""" result = generator.generate_report(initial_findings, "test query") assert "content" in result assert "metadata" in result def test_generate_report_calls_callback_for_all_phases( self, generator, initial_findings ): """progress_callback is called for structure, section research, formatting, and completion.""" callback = MagicMock() generator.generate_report( initial_findings, "test query", progress_callback=callback ) # Extract all phases reported phases = [c.args[2]["phase"] for c in callback.call_args_list] assert "report_structure" in phases assert "report_section_research" in phases assert "report_formatting" in phases assert "report_complete" in phases def test_progress_starts_at_zero_and_ends_at_100( self, generator, initial_findings ): """First callback is 0%, last is 100%.""" callback = MagicMock() generator.generate_report( initial_findings, "test query", progress_callback=callback ) first_pct = callback.call_args_list[0].args[1] last_pct = callback.call_args_list[-1].args[1] assert first_pct == 0 assert last_pct == 100 def test_progress_is_monotonically_nondecreasing( self, generator, initial_findings ): """Progress percentages never decrease.""" callback = MagicMock() generator.generate_report( initial_findings, "test query", progress_callback=callback ) percentages = [c.args[1] for c in callback.call_args_list] for i in range(1, len(percentages)): assert percentages[i] >= percentages[i - 1], ( f"Progress went backwards: {percentages[i - 1]} -> {percentages[i]}" ) class TestProgressCallbackInSectionResearch: """Tests for subsection-level progress tracking.""" def test_progress_reported_for_each_subsection( self, generator, simple_structure, initial_findings ): """Each subsection triggers a progress callback.""" callback = MagicMock() generator._research_and_generate_sections( initial_findings, simple_structure, "test query", progress_callback=callback, ) # 3 subsections total (2 in Introduction + 1 in Analysis) section_calls = [ c for c in callback.call_args_list if c.args[2].get("phase") == "report_section_research" ] assert len(section_calls) == 3 def test_progress_message_includes_section_and_subsection_names( self, generator, simple_structure, initial_findings ): """Progress messages reference section > subsection.""" callback = MagicMock() generator._research_and_generate_sections( initial_findings, simple_structure, "test query", progress_callback=callback, ) messages = [c.args[0] for c in callback.call_args_list] assert any("Introduction" in m and "Overview" in m for m in messages) assert any("Introduction" in m and "Background" in m for m in messages) assert any("Analysis" in m and "Data" in m for m in messages) def test_progress_percentage_scales_with_subsections( self, generator, simple_structure, initial_findings ): """Progress ranges from 10% to 90% across subsections.""" callback = MagicMock() generator._research_and_generate_sections( initial_findings, simple_structure, "test query", progress_callback=callback, ) section_calls = [ c for c in callback.call_args_list if c.args[2].get("phase") == "report_section_research" ] percentages = [c.args[1] for c in section_calls] # First subsection: 10 + (0/3)*80 = 10 assert percentages[0] == 10 # Second subsection: 10 + (1/3)*80 ≈ 36 assert percentages[1] == 36 # Third subsection: 10 + (2/3)*80 ≈ 63 assert percentages[2] == 63 def test_empty_subsections_section_gets_one_progress_call( self, generator, initial_findings ): """A section with no subsections auto-creates one and gets one progress call.""" structure = [{"name": "Solo Section", "subsections": []}] callback = MagicMock() generator._research_and_generate_sections( initial_findings, structure, "test query", progress_callback=callback, ) section_calls = [ c for c in callback.call_args_list if c.args[2].get("phase") == "report_section_research" ] assert len(section_calls) == 1 def test_no_callback_still_works( self, generator, simple_structure, initial_findings ): """_research_and_generate_sections works without progress_callback.""" result = generator._research_and_generate_sections( initial_findings, simple_structure, "test query" ) assert "Introduction" in result assert "Analysis" in result # ── Cancellation tests ── class TestCancellation: """Tests for cancellation via progress_callback exception.""" def test_cancellation_stops_processing( self, generator, simple_structure, initial_findings ): """When progress_callback raises, processing stops immediately.""" call_count = 0 def cancelling_callback(message, pct, metadata): nonlocal call_count call_count += 1 if call_count == 2: # Cancel on second subsection raise Exception("Research was terminated by user") with pytest.raises(Exception, match="terminated by user"): generator._research_and_generate_sections( initial_findings, simple_structure, "test query", progress_callback=cancelling_callback, ) # Only 1 subsection should have been fully searched # (first callback fires, search happens, second callback fires and raises) assert generator.search_system.analyze_topic.call_count == 1 def test_cancelled_before_iteration_override( self, generator, simple_structure, initial_findings ): """Cancellation fires before iteration override so settings_snapshot is never modified.""" original_value = generator.search_system.strategy.settings_snapshot[ "search.iterations" ] def cancel_immediately(message, pct, metadata): raise Exception("Research was terminated by user") with pytest.raises(Exception, match="terminated by user"): generator._research_and_generate_sections( initial_findings, simple_structure, "test query", progress_callback=cancel_immediately, ) # Settings should be untouched since cancellation was before override assert ( generator.search_system.strategy.settings_snapshot[ "search.iterations" ] == original_value ) def test_terminated_propagates_and_iterations_not_corrupted( self, generator, initial_findings ): """If analyze_topic raises after override, settings are still restored.""" generator.search_system.analyze_topic.side_effect = Exception( "Research was terminated by user" ) structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Test purpose"}], } ] callback = MagicMock() # Non-cancelling callback with pytest.raises(Exception, match="terminated by user"): generator._research_and_generate_sections( initial_findings, structure, "test query", progress_callback=callback, ) # Despite the error, iterations should be restored assert ( generator.search_system.strategy.settings_snapshot[ "search.iterations" ] == 3 ) # ── Iteration override tests ── class TestIterationOverride: """Tests for the iteration override fix.""" def test_settings_snapshot_set_to_1_during_search( self, generator, initial_findings ): """During analyze_topic, strategy.settings_snapshot['search.iterations'] is 1.""" captured_values = [] def capture_iterations(query): captured_values.append( generator.search_system.strategy.settings_snapshot.get( "search.iterations" ) ) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = capture_iterations structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert captured_values == [1] def test_settings_snapshot_restored_after_search( self, generator, initial_findings ): """After search completes, settings_snapshot is restored to original value.""" structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert ( generator.search_system.strategy.settings_snapshot[ "search.iterations" ] == 3 ) def test_settings_snapshot_restored_after_error( self, generator, initial_findings ): """After search error, settings_snapshot is still restored.""" generator.search_system.analyze_topic.side_effect = RuntimeError( "Search failed" ) structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] with pytest.raises(RuntimeError, match="Search failed"): generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert ( generator.search_system.strategy.settings_snapshot[ "search.iterations" ] == 3 ) def test_absent_key_removed_after_search(self, generator, initial_findings): """If search.iterations wasn't in snapshot before, it's removed after.""" del generator.search_system.strategy.settings_snapshot[ "search.iterations" ] structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert ( "search.iterations" not in generator.search_system.strategy.settings_snapshot ) def test_absent_key_removed_after_error(self, generator, initial_findings): """If search.iterations wasn't in snapshot, it's removed even after error.""" del generator.search_system.strategy.settings_snapshot[ "search.iterations" ] generator.search_system.analyze_topic.side_effect = RuntimeError("fail") structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] with pytest.raises(RuntimeError): generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert ( "search.iterations" not in generator.search_system.strategy.settings_snapshot ) def test_max_iterations_belt_and_suspenders( self, generator, initial_findings ): """strategy.max_iterations is also set to 1 during search.""" captured_values = [] def capture_max_iter(query): captured_values.append( generator.search_system.strategy.max_iterations ) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = capture_max_iter structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert captured_values == [1] # Restored after assert generator.search_system.strategy.max_iterations == 3 def test_max_iterations_restored_after_error( self, generator, initial_findings ): """strategy.max_iterations is restored even after error.""" generator.search_system.analyze_topic.side_effect = RuntimeError("fail") structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] with pytest.raises(RuntimeError): generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert generator.search_system.strategy.max_iterations == 3 def test_multiple_subsections_each_override_and_restore( self, generator, simple_structure, initial_findings ): """Each subsection independently overrides and restores iterations.""" captured_values = [] def capture_iterations(query): captured_values.append( generator.search_system.strategy.settings_snapshot.get( "search.iterations" ) ) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = capture_iterations generator._research_and_generate_sections( initial_findings, simple_structure, "test query" ) # Each search should see iterations=1 assert captured_values == [1, 1, 1] # After all, should be restored assert ( generator.search_system.strategy.settings_snapshot[ "search.iterations" ] == 3 ) # ── Completed subsections counter tests ── class TestCompletedSubsectionsCounter: """Tests that completed_subsections only increments after success.""" def test_counter_not_incremented_on_error( self, generator, initial_findings ): """completed_subsections doesn't increment when analyze_topic raises.""" call_count = 0 def fail_on_first(query): nonlocal call_count call_count += 1 if call_count == 1: raise RuntimeError("First subsection failed") return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = fail_on_first structure = [ { "name": "Section", "subsections": [ {"name": "Sub1", "purpose": "P1"}, {"name": "Sub2", "purpose": "P2"}, ], } ] progress_pcts = [] callback = MagicMock( side_effect=lambda msg, pct, meta: progress_pcts.append(pct) ) with pytest.raises(RuntimeError, match="First subsection failed"): generator._research_and_generate_sections( initial_findings, structure, "test query", progress_callback=callback, ) # Only 1 progress call happened (for the first subsection, before it failed) section_calls = [ c for c in callback.call_args_list if c.args[2].get("phase") == "report_section_research" ] assert len(section_calls) == 1 # ── Report progress callback wrapper tests ── class TestReportProgressCallbackWrapper: """Tests for the report_progress_callback wrapper in research_service.""" def test_maps_0_to_range_start(self): """0% internal maps to start of report range (10%).""" outer_callback = MagicMock() start = _DETAILED_REPORT_PROGRESS_START end = _DETAILED_REPORT_PROGRESS_END report_range = end - start # Simulate the wrapper logic from research_service.py def report_progress_callback(message, progress_percent, metadata): if progress_percent is not None: adjusted = start + (progress_percent / 100) * report_range else: adjusted = progress_percent outer_callback(message, adjusted, metadata) report_progress_callback("test", 0, {"phase": "report_structure"}) outer_callback.assert_called_with( "test", 10.0, {"phase": "report_structure"} ) def test_maps_50_to_range_midpoint(self): """50% internal maps to midpoint of report range (55%).""" outer_callback = MagicMock() start = _DETAILED_REPORT_PROGRESS_START end = _DETAILED_REPORT_PROGRESS_END report_range = end - start def report_progress_callback(message, progress_percent, metadata): if progress_percent is not None: adjusted = start + (progress_percent / 100) * report_range else: adjusted = progress_percent outer_callback(message, adjusted, metadata) report_progress_callback( "test", 50, {"phase": "report_section_research"} ) outer_callback.assert_called_with( "test", 55.0, {"phase": "report_section_research"} ) def test_maps_100_to_range_end(self): """100% internal maps to end of report range (100%).""" outer_callback = MagicMock() start = _DETAILED_REPORT_PROGRESS_START end = _DETAILED_REPORT_PROGRESS_END report_range = end - start def report_progress_callback(message, progress_percent, metadata): if progress_percent is not None: adjusted = start + (progress_percent / 100) * report_range else: adjusted = progress_percent outer_callback(message, adjusted, metadata) report_progress_callback("test", 100, {"phase": "report_complete"}) outer_callback.assert_called_with( "test", 100.0, {"phase": "report_complete"} ) def test_none_progress_passed_through(self): """None progress is passed through unchanged.""" outer_callback = MagicMock() start = _DETAILED_REPORT_PROGRESS_START end = _DETAILED_REPORT_PROGRESS_END report_range = end - start def report_progress_callback(message, progress_percent, metadata): if progress_percent is not None: adjusted = start + (progress_percent / 100) * report_range else: adjusted = progress_percent outer_callback(message, adjusted, metadata) report_progress_callback("test", None, {"phase": "error"}) outer_callback.assert_called_with("test", None, {"phase": "error"}) def test_no_double_mapping_with_outer_phases(self): """Report phases don't match outer callback's report_generation check.""" # The phases used by report generator (report_structure, # report_section_research, report_formatting) must NOT match # the outer callback's "report_generation" phase check to avoid # double-mapping. report_phases = [ "report_structure", "report_section_research", "report_formatting", "report_complete", ] for phase in report_phases: assert phase != "report_generation" # ── Empty subsections handling ── class TestEmptySubsectionsHandling: """Tests for consistent handling of sections with no subsections.""" def test_empty_subsections_auto_created(self, generator, initial_findings): """Sections with empty subsections get one auto-created subsection.""" structure = [{"name": "Empty Section", "subsections": []}] result = generator._research_and_generate_sections( initial_findings, structure, "test query" ) # Should have generated content for the auto-created subsection assert "Empty Section" in result assert generator.search_system.analyze_topic.call_count == 1 def test_section_with_pipe_in_name_parsed_correctly( self, generator, initial_findings ): """Sections with '|' in name get purpose parsed from the name.""" structure = [{"name": "My Section | Custom purpose", "subsections": []}] result = generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert "My Section | Custom purpose" in result def test_progress_count_consistent_for_empty_subsections( self, generator, initial_findings ): """Progress counting uses max(len(subsections), 1) for empty sections.""" structure = [ {"name": "Section A", "subsections": []}, { "name": "Section B", "subsections": [ {"name": "Sub1", "purpose": "P1"}, {"name": "Sub2", "purpose": "P2"}, ], }, ] callback = MagicMock() generator._research_and_generate_sections( initial_findings, structure, "test query", progress_callback=callback, ) # Total: 1 (auto-created) + 2 = 3 subsections section_calls = [ c for c in callback.call_args_list if c.args[2].get("phase") == "report_section_research" ] assert len(section_calls) == 3 # ── Integration test ── class TestEndToEndProgress: """Integration tests for the full generate_report flow with progress.""" def test_full_report_generation_with_progress( self, generator, initial_findings ): """Full generate_report flow calls progress for all phases in order.""" phases_seen = [] def tracking_callback(message, pct, metadata): phases_seen.append(metadata.get("phase")) generator.generate_report( initial_findings, "test query", progress_callback=tracking_callback, ) # Verify phase ordering assert phases_seen[0] == "report_structure" assert phases_seen[-2] == "report_formatting" assert phases_seen[-1] == "report_complete" # Section research phases should be in the middle research_phases = [ p for p in phases_seen if p == "report_section_research" ] assert len(research_phases) >= 1 def test_full_report_cancellation_at_structure_phase( self, generator, initial_findings ): """Cancellation during structure determination propagates cleanly.""" def cancel_at_structure(message, pct, metadata): if metadata.get("phase") == "report_structure": raise Exception("Research was terminated by user") with pytest.raises(Exception, match="terminated by user"): generator.generate_report( initial_findings, "test query", progress_callback=cancel_at_structure, ) def test_full_report_cancellation_during_section_research( self, generator, initial_findings ): """Cancellation during section research propagates cleanly.""" call_count = 0 def cancel_after_first_section(message, pct, metadata): nonlocal call_count if metadata.get("phase") == "report_section_research": call_count += 1 if call_count >= 2: raise Exception("Research was terminated by user") with pytest.raises(Exception, match="terminated by user"): generator.generate_report( initial_findings, "test query", progress_callback=cancel_after_first_section, ) # Settings should be restored after cancellation assert ( generator.search_system.strategy.settings_snapshot[ "search.iterations" ] == 3 ) # ── Content accumulation tests ── class TestContentAccumulation: """Tests for context passing between subsections to avoid repetition.""" def test_later_subsections_receive_earlier_content_in_query( self, generator, simple_structure, initial_findings ): """Queries for later subsections include content from earlier ones.""" captured_queries = [] def capture_query(query): captured_queries.append(query) return { "current_knowledge": f"Content for call {len(captured_queries)}" } generator.search_system.analyze_topic.side_effect = capture_query generator._research_and_generate_sections( initial_findings, simple_structure, "test query" ) # First subsection query should NOT contain previous content marker assert "CONTENT ALREADY WRITTEN" not in captured_queries[0] # Second subsection (same section) should contain first subsection's content assert "CONTENT ALREADY WRITTEN" in captured_queries[1] assert "Content for call 1" in captured_queries[1] # Third subsection (different section) should contain earlier content assert "CONTENT ALREADY WRITTEN" in captured_queries[2] def test_accumulated_findings_formatted_with_section_labels( self, generator, initial_findings ): """Accumulated findings include [Section > Subsection] labels.""" captured_queries = [] def capture_query(query): captured_queries.append(query) return {"current_knowledge": "Some content"} generator.search_system.analyze_topic.side_effect = capture_query structure = [ { "name": "First Section", "subsections": [ {"name": "Sub A", "purpose": "Purpose A"}, {"name": "Sub B", "purpose": "Purpose B"}, ], } ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) # Second query should reference the first subsection's label assert "[First Section > Sub A]" in captured_queries[1] def test_empty_content_not_accumulated(self, generator, initial_findings): """Subsections with empty content are not added to accumulated_findings.""" call_count = 0 def alternating_content(query): nonlocal call_count call_count += 1 if call_count == 1: return {"current_knowledge": ""} # Empty return {"current_knowledge": "Real content"} generator.search_system.analyze_topic.side_effect = alternating_content structure = [ { "name": "Section", "subsections": [ {"name": "Empty Sub", "purpose": "P1"}, {"name": "Real Sub", "purpose": "P2"}, ], } ] captured_queries = [] original_side_effect = generator.search_system.analyze_topic.side_effect def capture_and_call(query): captured_queries.append(query) return original_side_effect(query) generator.search_system.analyze_topic.side_effect = capture_and_call generator._research_and_generate_sections( initial_findings, structure, "test query" ) # Second query should NOT contain previous context since first was empty assert "CONTENT ALREADY WRITTEN" not in captured_queries[1] def test_context_limited_to_max_context_sections( self, generator, initial_findings ): """Only the last max_context_sections are included in context.""" generator.max_context_sections = 2 captured_queries = [] def capture_query(query): captured_queries.append(query) return {"current_knowledge": f"Content #{len(captured_queries)}"} generator.search_system.analyze_topic.side_effect = capture_query structure = [ { "name": f"Section {i}", "subsections": [{"name": f"Sub {i}", "purpose": f"P{i}"}], } for i in range(4) ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) # Fourth query (index 3) should contain sections 2 and 3, not section 1 # (max_context_sections=2, so only last 2 are included) last_query = captured_queries[3] assert "Content #3" in last_query # Most recent assert "Content #2" in last_query # Second most recent assert "Content #1" not in last_query # Dropped due to limit # ── Query construction tests ── class TestQueryConstruction: """Tests for verifying queries passed to analyze_topic.""" def test_subsection_query_contains_purpose( self, generator, initial_findings ): """Query includes the subsection's purpose.""" captured_queries = [] def capture(query): captured_queries.append(query) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = capture structure = [ { "name": "Section", "subsections": [ {"name": "Details", "purpose": "Explain the mechanism"} ], } ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert "Explain the mechanism" in captured_queries[0] def test_subsection_query_contains_original_query( self, generator, initial_findings ): """Query includes the user's original research query.""" captured_queries = [] def capture(query): captured_queries.append(query) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = capture structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] generator._research_and_generate_sections( initial_findings, structure, "my specific research topic" ) assert "my specific research topic" in captured_queries[0] def test_subsection_query_lists_other_sections( self, generator, initial_findings ): """Query includes names of other sections for context.""" captured_queries = [] def capture(query): captured_queries.append(query) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = capture structure = [ { "name": "Introduction", "subsections": [{"name": "Overview", "purpose": "P1"}], }, { "name": "Methodology", "subsections": [{"name": "Approach", "purpose": "P2"}], }, { "name": "Results", "subsections": [{"name": "Findings", "purpose": "P3"}], }, ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) # First section's query should mention the other sections assert "Methodology" in captured_queries[0] assert "Results" in captured_queries[0] # Second section's query should mention its siblings assert "Introduction" in captured_queries[1] assert "Results" in captured_queries[1] def test_multi_subsection_query_lists_sibling_subsections( self, generator, initial_findings ): """When a section has multiple subsections, each query lists its siblings.""" captured_queries = [] def capture(query): captured_queries.append(query) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = capture structure = [ { "name": "Analysis", "subsections": [ {"name": "Quantitative", "purpose": "Numbers and stats"}, {"name": "Qualitative", "purpose": "Themes and patterns"}, {"name": "Mixed", "purpose": "Combined methods"}, ], } ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) # First subsection query should reference the other two assert "Qualitative" in captured_queries[0] assert "Mixed" in captured_queries[0] # Second subsection should reference its siblings assert "Quantitative" in captured_queries[1] assert "Mixed" in captured_queries[1] def test_single_subsection_uses_section_level_prompt( self, generator, initial_findings ): """Single-subsection sections use the standalone section prompt.""" captured_queries = [] def capture(query): captured_queries.append(query) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = capture structure = [ { "name": "Conclusion", "subsections": [ {"name": "Conclusion", "purpose": "Summarize findings"} ], } ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) # Single-subsection uses "standalone section" language assert "standalone section" in captured_queries[0].lower() def test_multi_subsection_uses_focused_prompt( self, generator, initial_findings ): """Multi-subsection sections use the focused subsection prompt.""" captured_queries = [] def capture(query): captured_queries.append(query) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = capture structure = [ { "name": "Analysis", "subsections": [ {"name": "Sub1", "purpose": "P1"}, {"name": "Sub2", "purpose": "P2"}, ], } ] generator._research_and_generate_sections( initial_findings, structure, "test query" ) # Multi-subsection uses "Focus ONLY" language assert "Focus ONLY" in captured_queries[0] # ── Section content assembly tests ── class TestSectionContentAssembly: """Tests for how subsection content is assembled into section output.""" def test_multi_subsection_section_includes_subsection_headers( self, generator, initial_findings ): """Sections with multiple subsections get ## headers for each.""" generator.search_system.analyze_topic.return_value = { "current_knowledge": "Content here" } structure = [ { "name": "Analysis", "subsections": [ {"name": "Data Review", "purpose": "Review the data"}, {"name": "Interpretation", "purpose": "Interpret results"}, ], } ] sections = generator._research_and_generate_sections( initial_findings, structure, "test query" ) content = sections["Analysis"] assert "## 1.1 Data Review" in content assert "## 1.2 Interpretation" in content def test_single_subsection_section_omits_subsection_header( self, generator, initial_findings ): """Sections with one subsection do NOT get a ## header.""" generator.search_system.analyze_topic.return_value = { "current_knowledge": "Content here" } structure = [ { "name": "Conclusion", "subsections": [ {"name": "Summary", "purpose": "Summarize findings"} ], } ] sections = generator._research_and_generate_sections( initial_findings, structure, "test query" ) content = sections["Conclusion"] assert "## Summary" not in content def test_section_starts_with_section_heading( self, generator, initial_findings ): """Each section's content starts with a # heading.""" generator.search_system.analyze_topic.return_value = { "current_knowledge": "Content" } structure = [ { "name": "My Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] sections = generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert sections["My Section"].startswith("# 1. My Section") def test_subsection_content_from_analyze_topic_included( self, generator, initial_findings ): """Content returned by analyze_topic appears in the section output.""" generator.search_system.analyze_topic.return_value = { "current_knowledge": "Specific content about the mechanism of action" } structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] sections = generator._research_and_generate_sections( initial_findings, structure, "test query" ) assert ( "Specific content about the mechanism of action" in sections["Section"] ) def test_multiple_subsections_content_combined( self, generator, initial_findings ): """Content from all subsections in a section is combined.""" call_count = 0 def unique_content(query): nonlocal call_count call_count += 1 return {"current_knowledge": f"UNIQUE_CONTENT_{call_count}"} generator.search_system.analyze_topic.side_effect = unique_content structure = [ { "name": "Section", "subsections": [ {"name": "Part A", "purpose": "First part"}, {"name": "Part B", "purpose": "Second part"}, ], } ] sections = generator._research_and_generate_sections( initial_findings, structure, "test query" ) content = sections["Section"] assert "UNIQUE_CONTENT_1" in content assert "UNIQUE_CONTENT_2" in content # ── Progress metadata completeness tests ── class TestProgressMetadataCompleteness: """Tests for metadata fields in progress callbacks.""" def test_section_research_metadata_includes_subsection_name( self, generator, simple_structure, initial_findings ): """report_section_research callbacks include subsection name in metadata.""" callback = MagicMock() generator._research_and_generate_sections( initial_findings, simple_structure, "test query", progress_callback=callback, ) section_calls = [ c for c in callback.call_args_list if c.args[2].get("phase") == "report_section_research" ] subsection_names = [c.args[2]["subsection"] for c in section_calls] assert "Overview" in subsection_names assert "Background" in subsection_names assert "Data" in subsection_names def test_structure_phase_metadata_has_phase_key( self, generator, initial_findings ): """report_structure phase callback has 'phase' in metadata.""" callback = MagicMock() generator.generate_report( initial_findings, "test query", progress_callback=callback ) first_call = callback.call_args_list[0] assert "phase" in first_call.args[2] assert first_call.args[2]["phase"] == "report_structure" def test_formatting_phase_metadata_has_phase_key( self, generator, initial_findings ): """report_formatting phase callback has 'phase' in metadata.""" callback = MagicMock() generator.generate_report( initial_findings, "test query", progress_callback=callback ) formatting_calls = [ c for c in callback.call_args_list if c.args[2].get("phase") == "report_formatting" ] assert len(formatting_calls) == 1 # ── Progress-before-override ordering test ── class TestProgressBeforeOverrideOrdering: """Tests that progress callback fires BEFORE iteration override.""" def test_iterations_unchanged_when_progress_callback_fires( self, generator, initial_findings ): """At the moment progress_callback fires, search.iterations is still original.""" iterations_at_callback_time = [] def check_iterations(message, pct, metadata): if metadata.get("phase") == "report_section_research": iterations_at_callback_time.append( generator.search_system.strategy.settings_snapshot.get( "search.iterations" ) ) structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] generator._research_and_generate_sections( initial_findings, structure, "test query", progress_callback=check_iterations, ) # At callback time, iterations should still be original (3), not overridden (1) assert iterations_at_callback_time == [3] def test_iterations_overridden_only_during_analyze_topic( self, generator, initial_findings ): """Iteration override is active only during analyze_topic, not during callback.""" timeline = [] def tracking_callback(message, pct, metadata): if metadata.get("phase") == "report_section_research": val = generator.search_system.strategy.settings_snapshot.get( "search.iterations" ) timeline.append(("callback", val)) def tracking_analyze(query): val = generator.search_system.strategy.settings_snapshot.get( "search.iterations" ) timeline.append(("analyze", val)) return {"current_knowledge": "content"} generator.search_system.analyze_topic.side_effect = tracking_analyze structure = [ { "name": "Section", "subsections": [{"name": "Sub", "purpose": "Purpose"}], } ] generator._research_and_generate_sections( initial_findings, structure, "test query", progress_callback=tracking_callback, ) # Timeline: callback fires with original (3), then analyze sees override (1) assert timeline == [("callback", 3), ("analyze", 1)] # ── Cancellation between sections ── class TestCancellationBetweenSections: """Tests for cancellation that occurs between sections.""" def test_cancel_after_first_section_before_second( self, generator, initial_findings ): """Cancelling after all subsections of section 1 prevents section 2.""" sections_started = [] def cancel_on_second_section(message, pct, metadata): if metadata.get("phase") == "report_section_research": sub = metadata.get("subsection", "") sections_started.append(sub) # Cancel when we reach section 2's subsection if sub == "Analysis Sub": raise Exception("Research was terminated by user") structure = [ { "name": "Section 1", "subsections": [{"name": "S1 Sub", "purpose": "P1"}], }, { "name": "Section 2", "subsections": [{"name": "Analysis Sub", "purpose": "P2"}], }, ] with pytest.raises(Exception, match="terminated by user"): generator._research_and_generate_sections( initial_findings, structure, "test query", progress_callback=cancel_on_second_section, ) # Only section 1's subsection was fully searched assert generator.search_system.analyze_topic.call_count == 1 def test_settings_restored_after_cancel_between_sections( self, generator, initial_findings ): """Settings are clean after cancellation between sections.""" call_count = 0 def cancel_on_third(message, pct, metadata): nonlocal call_count if metadata.get("phase") == "report_section_research": call_count += 1 if call_count == 3: raise Exception("Research was terminated by user") structure = [ { "name": "Section 1", "subsections": [ {"name": "Sub1", "purpose": "P1"}, {"name": "Sub2", "purpose": "P2"}, ], }, { "name": "Section 2", "subsections": [{"name": "Sub3", "purpose": "P3"}], }, ] with pytest.raises(Exception, match="terminated by user"): generator._research_and_generate_sections( initial_findings, structure, "test query", progress_callback=cancel_on_third, ) # Iterations restored: cancel fires before override for sub3 assert ( generator.search_system.strategy.settings_snapshot[ "search.iterations" ] == 3 ) assert generator.search_system.strategy.max_iterations == 3 def test_partial_results_available_after_cancel( self, generator, initial_findings ): """After cancellation, the exception propagates - no partial result returned.""" call_count = 0 def cancel_on_second_section(message, pct, metadata): nonlocal call_count if metadata.get("phase") == "report_section_research": call_count += 1 if call_count == 2: raise Exception("Research was terminated by user") structure = [ { "name": "Section 1", "subsections": [{"name": "Sub1", "purpose": "P1"}], }, { "name": "Section 2", "subsections": [{"name": "Sub2", "purpose": "P2"}], }, ] with pytest.raises(Exception, match="terminated by user"): generator._research_and_generate_sections( initial_findings, structure, "test query", progress_callback=cancel_on_second_section, ) # The exception propagates — caller must handle it # Verify section 1 was fully processed (1 analyze_topic call) assert generator.search_system.analyze_topic.call_count == 1 # ── Research service wrapper integration ── class TestResearchServiceWrapperIntegration: """Tests that verify the actual research_service.py wrapper code path.""" def test_wrapper_defined_in_run_research_process(self): """research_service defines report_progress_callback with correct formula.""" import inspect from local_deep_research.web.services.research_service import ( run_research_process, ) source = inspect.getsource(run_research_process) # Verify the wrapper is defined and used assert "report_progress_callback" in source assert "progress_callback=report_progress_callback" in source assert "_DETAILED_REPORT_PROGRESS_START" in source def test_wrapper_handles_none_progress(self): """The wrapper code handles None progress_percent.""" import inspect from local_deep_research.web.services.research_service import ( run_research_process, ) source = inspect.getsource(run_research_process) # Verify None handling exists assert "if progress_percent is not None" in source