Spaces:
Sleeping
Sleeping
| """Tests for agents/research.py. | |
| run_research_agent: blocking loop — search tool routing, multi-call, error path. | |
| stream_research_agent: FakeStream pattern — status/token/done event sequence. | |
| search_trials_api, _flatten_and_rank, and bulk_parse_and_strip are patched so | |
| tests are isolated to the loop/orchestration logic in research.py. | |
| """ | |
| from __future__ import annotations | |
| from unittest.mock import MagicMock, patch | |
| import pytest | |
| from agents.research import run_research_agent, stream_research_agent, _phase_rank, _rank_and_slim | |
| from models import PatientProfile | |
| from tests.conftest import FakeStream, make_message, make_text_block, make_tool_use_block | |
| # --------------------------------------------------------------------------- | |
| # _phase_rank and _rank_and_slim | |
| # --------------------------------------------------------------------------- | |
| def _trial(study_type="INTERVENTIONAL", phase="PHASE3", distance=10.0, **extra): | |
| return {"study_type": study_type, "phase": phase, "closest_site_miles": distance, **extra} | |
| class TestPhaseRank: | |
| def test_phase4_beats_phase3(self): | |
| assert _phase_rank(_trial(phase="PHASE4")) < _phase_rank(_trial(phase="PHASE3")) | |
| def test_phase3_beats_phase2(self): | |
| assert _phase_rank(_trial(phase="PHASE3")) < _phase_rank(_trial(phase="PHASE2")) | |
| def test_phase2_beats_phase1(self): | |
| assert _phase_rank(_trial(phase="PHASE2")) < _phase_rank(_trial(phase="PHASE1")) | |
| def test_phase1_beats_eap(self): | |
| assert _phase_rank(_trial(phase="PHASE1")) < _phase_rank(_trial(study_type="EXPANDED_ACCESS", phase="")) | |
| def test_eap_beats_observational(self): | |
| assert _phase_rank(_trial(study_type="EXPANDED_ACCESS", phase="")) < _phase_rank(_trial(study_type="OBSERVATIONAL", phase="")) | |
| def test_na_phase_interventional_between_phase1_and_eap(self): | |
| rank_na = _phase_rank(_trial(phase="NA")) | |
| assert _phase_rank(_trial(phase="PHASE1")) < rank_na | |
| assert rank_na < _phase_rank(_trial(study_type="EXPANDED_ACCESS", phase="")) | |
| class TestRankAndSlim: | |
| def test_phase4_sorted_before_phase3(self): | |
| trials = [_trial(phase="PHASE3", nct_id="B"), _trial(phase="PHASE4", nct_id="A")] | |
| result = _rank_and_slim(trials) | |
| assert result[0]["nct_id"] == "A" | |
| def test_within_same_phase_closer_first(self): | |
| trials = [_trial(phase="PHASE3", distance=50.0, nct_id="far"), _trial(phase="PHASE3", distance=5.0, nct_id="near")] | |
| result = _rank_and_slim(trials) | |
| assert result[0]["nct_id"] == "near" | |
| def test_capped_at_max_trials(self): | |
| trials = [_trial(phase="PHASE2", nct_id=str(i)) for i in range(20)] | |
| result = _rank_and_slim(trials) | |
| assert len(result) <= 15 | |
| def test_strips_summary_and_conditions(self): | |
| trials = [_trial(phase="PHASE3", summary="long text", conditions=["ALS"])] | |
| result = _rank_and_slim(trials) | |
| assert "summary" not in result[0] | |
| assert "conditions" not in result[0] | |
| def test_nearest_sites_capped_at_3(self): | |
| sites = [{"label": f"Site {i}"} for i in range(5)] | |
| trials = [_trial(phase="PHASE3", nearest_sites=sites)] | |
| result = _rank_and_slim(trials) | |
| assert len(result[0]["nearest_sites"]) == 3 | |
| def test_intervention_description_stripped(self): | |
| iv = [{"type": "DRUG", "name": "DrugX", "description": "long description text"}] | |
| trials = [_trial(phase="PHASE3", interventions=iv)] | |
| result = _rank_and_slim(trials) | |
| assert "description" not in result[0]["interventions"][0] | |
| assert result[0]["interventions"][0]["name"] == "DrugX" | |
| PATCH_SEARCH = "agents.research.search_trials_api" | |
| PATCH_FLATTEN = "agents.research._flatten_and_rank" | |
| PATCH_BULK = "agents.research.bulk_parse_and_strip" | |
| def _search_block(condition: str = "ALS") -> MagicMock: | |
| return make_tool_use_block( | |
| "search_clinical_trials", | |
| {"condition": condition, "lat": 42.33, "lon": -71.10, "radius_miles": 100}, | |
| tool_use_id="tu_search1", | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # run_research_agent | |
| # --------------------------------------------------------------------------- | |
| class TestRunResearchAgent: | |
| def test_returns_text_on_end_turn(self, mock_client, als_patient): | |
| final_msg = make_message( | |
| content=[make_text_block("Here are your trials.")], | |
| stop_reason="end_turn", | |
| ) | |
| mock_client.messages.create.return_value = final_msg | |
| result = run_research_agent(mock_client, als_patient) | |
| assert result == "Here are your trials." | |
| def test_no_trials_text_fallback(self, mock_client, als_patient): | |
| final_msg = make_message(content=[], stop_reason="end_turn") | |
| mock_client.messages.create.return_value = final_msg | |
| result = run_research_agent(mock_client, als_patient) | |
| assert result == "No analysis produced." | |
| def test_one_search_call_then_end_turn(self, mock_client, als_patient): | |
| search_msg = make_message( | |
| content=[_search_block()], stop_reason="tool_use" | |
| ) | |
| final_msg = make_message( | |
| content=[make_text_block("Found 3 trials.")], stop_reason="end_turn" | |
| ) | |
| mock_client.messages.create.side_effect = [search_msg, final_msg] | |
| with patch(PATCH_SEARCH, return_value=[]) as ms, \ | |
| patch(PATCH_FLATTEN, return_value=[]) as mf, \ | |
| patch(PATCH_BULK, return_value=[]) as mb: | |
| result = run_research_agent(mock_client, als_patient) | |
| assert result == "Found 3 trials." | |
| ms.assert_called_once() | |
| mf.assert_called_once() | |
| mb.assert_called_once() | |
| def test_two_search_calls_before_end_turn(self, mock_client, als_patient): | |
| search_msg1 = make_message(content=[_search_block("ALS")], stop_reason="tool_use") | |
| search_msg2 = make_message(content=[_search_block("Motor Neuron Disease")], stop_reason="tool_use") | |
| final_msg = make_message( | |
| content=[make_text_block("Done.")], stop_reason="end_turn" | |
| ) | |
| mock_client.messages.create.side_effect = [search_msg1, search_msg2, final_msg] | |
| with patch(PATCH_SEARCH, return_value=[]), \ | |
| patch(PATCH_FLATTEN, return_value=[]), \ | |
| patch(PATCH_BULK, return_value=[]): | |
| result = run_research_agent(mock_client, als_patient) | |
| assert result == "Done." | |
| def test_profile_phases_enforced_in_interventional_search(self, mock_client, als_patient): | |
| """Patient's phase preference is passed to search_trials_api, not whatever LLM chose.""" | |
| patient_with_phases = als_patient.__class__( | |
| **{**als_patient.__dict__, "phases": ["3", "4"]} | |
| ) | |
| # LLM passes no phases in its tool call args | |
| search_msg = make_message(content=[_search_block()], stop_reason="tool_use") | |
| final_msg = make_message(content=[make_text_block("Done.")], stop_reason="end_turn") | |
| mock_client.messages.create.side_effect = [search_msg, final_msg] | |
| with patch(PATCH_SEARCH, return_value=[]) as ms, \ | |
| patch(PATCH_FLATTEN, return_value=[]), \ | |
| patch(PATCH_BULK, return_value=[]): | |
| run_research_agent(mock_client, patient_with_phases) | |
| _, kwargs = ms.call_args | |
| assert kwargs["phases"] == ["3", "4"] | |
| def test_no_phase_filter_when_profile_phases_empty(self, mock_client, als_patient): | |
| """No phase filter applied when patient has no phase preference.""" | |
| patient_no_phases = als_patient.__class__( | |
| **{**als_patient.__dict__, "phases": []} | |
| ) | |
| search_msg = make_message(content=[_search_block()], stop_reason="tool_use") | |
| final_msg = make_message(content=[make_text_block("Done.")], stop_reason="end_turn") | |
| mock_client.messages.create.side_effect = [search_msg, final_msg] | |
| with patch(PATCH_SEARCH, return_value=[]) as ms, \ | |
| patch(PATCH_FLATTEN, return_value=[]), \ | |
| patch(PATCH_BULK, return_value=[]): | |
| run_research_agent(mock_client, patient_no_phases) | |
| _, kwargs = ms.call_args | |
| assert kwargs["phases"] is None | |
| def test_search_results_serialized_into_tool_result(self, mock_client, als_patient): | |
| """Verify flatten output flows through bulk_parse and into the tool-result message.""" | |
| import json as _json | |
| fake_trial = {"nct_id": "NCT00000001", "title": "ALS Trial", "parsed_criteria": []} | |
| search_msg = make_message(content=[_search_block()], stop_reason="tool_use") | |
| final_msg = make_message( | |
| content=[make_text_block("Here are results.")], stop_reason="end_turn" | |
| ) | |
| mock_client.messages.create.side_effect = [search_msg, final_msg] | |
| with patch(PATCH_SEARCH, return_value=[{}]), \ | |
| patch(PATCH_FLATTEN, return_value=[fake_trial]), \ | |
| patch(PATCH_BULK, return_value=[fake_trial]): | |
| run_research_agent(mock_client, als_patient) | |
| second_call_msgs = mock_client.messages.create.call_args_list[1][1]["messages"] | |
| user_msgs = [m for m in second_call_msgs if m["role"] == "user"] | |
| tool_blocks = [ | |
| b for m in user_msgs | |
| for b in (m["content"] if isinstance(m["content"], list) else []) | |
| if isinstance(b, dict) and b.get("type") == "tool_result" | |
| ] | |
| assert tool_blocks, "No tool_result block found in second call" | |
| content = _json.loads(tool_blocks[0]["content"]) | |
| assert content[0]["nct_id"] == "NCT00000001" | |
| def test_search_api_error_passes_error_content(self, mock_client, als_patient): | |
| search_msg = make_message(content=[_search_block()], stop_reason="tool_use") | |
| final_msg = make_message( | |
| content=[make_text_block("API was unavailable.")], stop_reason="end_turn" | |
| ) | |
| mock_client.messages.create.side_effect = [search_msg, final_msg] | |
| with patch(PATCH_SEARCH, side_effect=Exception("timeout")), \ | |
| patch(PATCH_FLATTEN, return_value=[]), \ | |
| patch(PATCH_BULK, return_value=[]): | |
| result = run_research_agent(mock_client, als_patient) | |
| assert result == "API was unavailable." | |
| # messages is passed by reference and mutated after each call, so search | |
| # the captured list for the tool_result block rather than relying on index | |
| second_call_msgs = mock_client.messages.create.call_args_list[1][1]["messages"] | |
| user_msgs = [m for m in second_call_msgs if m["role"] == "user"] | |
| tool_result_blocks = [ | |
| b for m in user_msgs | |
| for b in (m["content"] if isinstance(m["content"], list) else []) | |
| if isinstance(b, dict) and b.get("type") == "tool_result" | |
| ] | |
| assert any(b.get("is_error") is True for b in tool_result_blocks) | |
| # --------------------------------------------------------------------------- | |
| # stream_research_agent | |
| # --------------------------------------------------------------------------- | |
| class TestStreamResearchAgent: | |
| def test_yields_token_events(self, mock_client, als_patient): | |
| final_msg = make_message( | |
| content=[make_text_block("Here are 3 trials.")], | |
| stop_reason="end_turn", | |
| ) | |
| mock_client.messages.stream.return_value = FakeStream( | |
| tokens=["Here ", "are ", "3 trials."], | |
| final_message=final_msg, | |
| ) | |
| events = list(stream_research_agent(mock_client, als_patient)) | |
| token_events = [e for e in events if e[0] == "token"] | |
| assert token_events == [("token", "Here "), ("token", "are "), ("token", "3 trials.")] | |
| def test_yields_done_event_on_end_turn(self, mock_client, als_patient): | |
| final_msg = make_message( | |
| content=[make_text_block("Analysis complete.")], | |
| stop_reason="end_turn", | |
| ) | |
| mock_client.messages.stream.return_value = FakeStream( | |
| tokens=["Analysis complete."], | |
| final_message=final_msg, | |
| ) | |
| events = list(stream_research_agent(mock_client, als_patient)) | |
| done_events = [e for e in events if e[0] == "done"] | |
| assert len(done_events) == 1 | |
| assert done_events[0][1] == "Analysis complete." | |
| def test_yields_status_event_before_search(self, mock_client, als_patient): | |
| search_msg = make_message(content=[_search_block()], stop_reason="tool_use") | |
| final_msg = make_message( | |
| content=[make_text_block("Done.")], stop_reason="end_turn" | |
| ) | |
| mock_client.messages.stream.side_effect = [ | |
| FakeStream(tokens=[], final_message=search_msg), | |
| FakeStream(tokens=["Done."], final_message=final_msg), | |
| ] | |
| with patch(PATCH_SEARCH, return_value=[]), \ | |
| patch(PATCH_FLATTEN, return_value=[]), \ | |
| patch(PATCH_BULK, return_value=[]): | |
| events = list(stream_research_agent(mock_client, als_patient)) | |
| status_events = [e for e in events if e[0] == "status"] | |
| assert len(status_events) >= 1 | |
| def test_terminates_with_done_as_last_event(self, mock_client, als_patient): | |
| final_msg = make_message( | |
| content=[make_text_block("Finished.")], stop_reason="end_turn" | |
| ) | |
| mock_client.messages.stream.return_value = FakeStream( | |
| tokens=["Finished."], final_message=final_msg | |
| ) | |
| events = list(stream_research_agent(mock_client, als_patient)) | |
| assert events[-1][0] == "done" | |
| def test_search_api_error_still_yields_done(self, mock_client, als_patient): | |
| search_msg = make_message(content=[_search_block()], stop_reason="tool_use") | |
| final_msg = make_message( | |
| content=[make_text_block("API was down.")], stop_reason="end_turn" | |
| ) | |
| mock_client.messages.stream.side_effect = [ | |
| FakeStream(tokens=[], final_message=search_msg), | |
| FakeStream(tokens=["API was down."], final_message=final_msg), | |
| ] | |
| with patch(PATCH_SEARCH, side_effect=Exception("connect timeout")), \ | |
| patch(PATCH_FLATTEN, return_value=[]), \ | |
| patch(PATCH_BULK, return_value=[]): | |
| events = list(stream_research_agent(mock_client, als_patient)) | |
| assert events[-1][0] == "done" | |
| def test_two_stream_calls_for_one_search_cycle(self, mock_client, als_patient): | |
| search_msg = make_message(content=[_search_block()], stop_reason="tool_use") | |
| final_msg = make_message( | |
| content=[make_text_block("Results.")], stop_reason="end_turn" | |
| ) | |
| mock_client.messages.stream.side_effect = [ | |
| FakeStream(tokens=[], final_message=search_msg), | |
| FakeStream(tokens=["Results."], final_message=final_msg), | |
| ] | |
| with patch(PATCH_SEARCH, return_value=[]), \ | |
| patch(PATCH_FLATTEN, return_value=[]), \ | |
| patch(PATCH_BULK, return_value=[]): | |
| list(stream_research_agent(mock_client, als_patient)) | |
| assert mock_client.messages.stream.call_count == 2 | |