Files
wehub-resource-sync 7a0da7932b
Backwards Compatibility / Verify Encryption Constants (push) Waiting to run
Backwards Compatibility / PyPI Version Compatibility (push) Waiting to run
Backwards Compatibility / Database Migration Tests (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Blocked by required conditions
Docker Tests (Consolidated) / detect-changes (push) Waiting to run
Docker Tests (Consolidated) / Build Test Image (push) Waiting to run
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Blocked by required conditions
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Blocked by required conditions
Docker Tests (Consolidated) / Accessibility Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Unit Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / LLM Example Tests (push) Blocked by required conditions
Docker Tests (Consolidated) / Production Image Smoke Test (push) Blocked by required conditions
Docker Tests (Consolidated) / Infrastructure Tests (push) Blocked by required conditions
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Waiting to run
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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

1595 lines
52 KiB
Python

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