Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 135 additions & 3 deletions tests/test_agentic_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@

import pytest

from src.tool_classifier.agentic_loop import AgenticLoop
from src.tool_classifier.enums import AgenticLoopStatus
from src.tool_classifier.param_extractor import ParamExtractionResult
from tool_classifier.agentic_loop import AgenticLoop
from tool_classifier.enums import AgenticLoopStatus
from tool_classifier.param_extractor import ParamExtractionResult


# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -540,6 +540,7 @@ async def fake_to_thread(fn: Any, *args: Any, **kwargs: Any) -> Any:
_HISTORY,
{"validFrom": "2026-01-01"},
"en",
1,
)


Expand Down Expand Up @@ -963,3 +964,134 @@ async def test_user_exit_during_stream_returns_empty_tokens(self) -> None:
assert tokens == []
# Collected params returned unchanged on exit
assert result.collected_params == {"validFrom": "2026-01-01"}


# ---------------------------------------------------------------------------
# seeded_params — L2 param_update pre-population at turn 0
# ---------------------------------------------------------------------------


class TestSeededParamsTurn0:
"""Verify that seeded_params from L2 follow-up routing are merged into
collected_params at turn 0 only, with collected_params taking priority."""

@pytest.mark.asyncio
async def test_seeded_params_merged_at_turn_0(self) -> None:
"""seeded_params are prepended to collected_params when turn_count=0."""
# Extractor returns only validFrom as newly extracted; countryIsoCode comes
# from seeded_params.
extractor_mock = _make_extractor_mock(
_extraction(
{"validFrom": "2026-01-01"},
[], # nothing missing — both params will be present after seed merge
"none",
)
)
loop = _make_loop(extractor_mock)

result = await loop.run_turn(
chat_id=_CHAT_ID,
user_message="January 2026",
conversation_history=[],
params_schema=_SCHEMA_TWO_REQUIRED,
collected_params={},
turn_count=0,
seeded_params={"countryIsoCode": "EE"},
)

# Both params present → COMPLETED
assert result.status == AgenticLoopStatus.COMPLETED
assert result.collected_params.get("countryIsoCode") == "EE"
assert result.collected_params.get("validFrom") == "2026-01-01"

@pytest.mark.asyncio
async def test_collected_params_override_seeded_params(self) -> None:
"""collected_params values beat seeded_params when the key overlaps."""
extractor_mock = _make_extractor_mock(
_extraction(
{"validFrom": "2026-06-01"},
[],
"none",
)
)
loop = _make_loop(extractor_mock)

result = await loop.run_turn(
chat_id=_CHAT_ID,
user_message="June 2026",
conversation_history=[],
params_schema=_SCHEMA_TWO_REQUIRED,
collected_params={"countryIsoCode": "LV"}, # explicit value takes priority
turn_count=0,
seeded_params={"countryIsoCode": "EE"}, # seeded value must be overridden
)

assert result.collected_params.get("countryIsoCode") == "LV"

@pytest.mark.asyncio
async def test_seeded_params_not_applied_on_subsequent_turns(self) -> None:
"""seeded_params are ignored when turn_count > 0."""
extractor_mock = _make_extractor_mock(
_extraction({}, ["countryIsoCode", "validFrom"], "Which country and date?")
)
loop = _make_loop(extractor_mock)

result = await loop.run_turn(
chat_id=_CHAT_ID,
user_message="hello",
conversation_history=[],
params_schema=_SCHEMA_TWO_REQUIRED,
collected_params={},
turn_count=1, # NOT turn 0 → seeded_params must be ignored
seeded_params={"countryIsoCode": "EE", "validFrom": "2026-01-01"},
)

# Even though seeded_params would satisfy all required params, they should
# not be applied after turn 0 → still NEEDS_INPUT
assert result.status == AgenticLoopStatus.NEEDS_INPUT
# seeded values not present in collected_params
assert "countryIsoCode" not in result.collected_params
assert "validFrom" not in result.collected_params

@pytest.mark.asyncio
async def test_seeded_params_none_does_not_raise(self) -> None:
"""Passing seeded_params=None (default) at turn 0 behaves normally."""
extractor_mock = _make_extractor_mock(
_extraction({}, ["countryIsoCode", "validFrom"], "Which country?")
)
loop = _make_loop(extractor_mock)

result = await loop.run_turn(
chat_id=_CHAT_ID,
user_message="hello",
conversation_history=[],
params_schema=_SCHEMA_TWO_REQUIRED,
collected_params={},
turn_count=0,
seeded_params=None,
)

assert result.status == AgenticLoopStatus.NEEDS_INPUT

@pytest.mark.asyncio
async def test_seeded_params_partial_fill_still_asks_for_missing(self) -> None:
"""seeded_params satisfy only one of two required params → still NEEDS_INPUT."""
extractor_mock = _make_extractor_mock(
_extraction({}, ["validFrom"], "From which date?")
)
loop = _make_loop(extractor_mock)

result = await loop.run_turn(
chat_id=_CHAT_ID,
user_message="Estonia",
conversation_history=[],
params_schema=_SCHEMA_TWO_REQUIRED,
collected_params={},
turn_count=0,
seeded_params={"countryIsoCode": "EE"}, # only one param seeded
)

# validFrom still missing → NEEDS_INPUT
assert result.status == AgenticLoopStatus.NEEDS_INPUT
# But seeded countryIsoCode should be present
assert result.collected_params.get("countryIsoCode") == "EE"
6 changes: 3 additions & 3 deletions tests/test_api_caller.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,16 @@
import httpx
import pytest

from src.tool_classifier.api_caller import APICaller, CircuitBreaker
from src.tool_classifier.constants import (
from tool_classifier.api_caller import APICaller, CircuitBreaker
from tool_classifier.constants import (
CB_STATE_CLOSED,
CB_STATE_HALF_OPEN,
CB_STATE_OPEN,
CIRCUIT_BREAKER_OPEN_MESSAGES,
SERVICE_TIMEOUT_MESSAGES,
SERVICE_UNAVAILABLE_MESSAGES,
)
from src.tool_classifier.models import APICallResult
from tool_classifier.models import APICallResult


# ---------------------------------------------------------------------------
Expand Down
2 changes: 1 addition & 1 deletion tests/test_api_response_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
import dspy.streaming
import pytest

from src.tool_classifier.api_response_formatter import (
from tool_classifier.api_response_formatter import (
APIResponseFormatterModule,
_FORMATTER_ERROR_MESSAGES,
)
Expand Down
65 changes: 58 additions & 7 deletions tests/test_api_semantic_searcher.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,13 +448,6 @@ async def test_multiple_medium_triggers_disambiguation(self) -> None:
client.get = AsyncMock(return_value=count_resp)
client.post = AsyncMock(side_effect=[dense_resp, hybrid_resp])

mock_disambiguator = MagicMock()
mock_disambiguator.return_value = None # "forward" returns string or None
# Wrap in a module-like object that has a forward() callable via __call__
mock_disambiguator_module = MagicMock()
mock_disambiguator_module.forward = MagicMock(return_value="ep-holidays")
mock_disambiguator_module.__call__ = MagicMock(return_value="ep-holidays")

# Inject our disambiguator — searcher calls self._disambiguator(query, candidates)
# which in turn calls forward() via __call__
async_disambiguator = MagicMock()
Expand Down Expand Up @@ -507,6 +500,64 @@ async def test_disambiguation_rejects_all_returns_empty(self) -> None:

assert results == []

@pytest.mark.asyncio
async def test_disambiguation_rejects_all_multi_candidates_returns_top_with_hint(
self,
) -> None:
"""Disambiguator returns None for >1 medium candidates → top candidate returned
with multi_intent_hint=True and llm_validated=False so IntentDecomposer gate
can run in the classifier."""
cos_a = API_TOOL_MIN_THRESHOLD + 0.08 # higher cosine → becomes 'top'
cos_b = API_TOOL_MIN_THRESHOLD + 0.02

dense_points = [
_point({**_EP_HOLIDAYS}, cos_a),
_point({**_EP_WEATHER}, cos_b),
]
hybrid_points = [
_point({**_EP_HOLIDAYS}, 0.012),
_point({**_EP_WEATHER}, 0.009),
]

dense_resp = _make_qdrant_dense_response(dense_points)
hybrid_resp = _make_qdrant_hybrid_response(hybrid_points)
count_resp = _make_count_response(10)

client = AsyncMock()
client.get = AsyncMock(return_value=count_resp)
client.post = AsyncMock(side_effect=[dense_resp, hybrid_resp])

searcher = _make_searcher(client)

# asyncio.to_thread is called twice:
# 1st call → _get_query_embedding → must return a valid embedding vector
# 2nd call → disambiguator.forward → must return None ("none" response)
precomputed = [0.1] * 10
_call_count = 0

async def _to_thread_side_effect(fn: Any, *args: Any, **kwargs: Any) -> Any:
nonlocal _call_count
_call_count += 1
if _call_count == 1:
return precomputed # embedding call
return None # disambiguator call → rejects all candidates

with patch(
"tool_classifier.api_semantic_searcher.asyncio.to_thread",
side_effect=_to_thread_side_effect,
):
results = await searcher.search("holidays AND weather")

# Must return exactly one result — the top cosine candidate
assert len(results) == 1
top = results[0]
# Top candidate by cosine score is ep-holidays
assert top.endpoint_id == "ep-holidays"
# NOT llm_validated — disambiguator explicitly rejected it
assert top.llm_validated is False
# multi_intent_hint signals the classifier to try IntentDecomposer
assert top.multi_intent_hint is True


class TestSearchBelowThreshold:
@pytest.mark.asyncio
Expand Down
Loading
Loading