"""Unit tests for single-agent deep_agent flows with mocked tool results.""" from __future__ import annotations from datetime import date, timedelta from types import SimpleNamespace from unittest.mock import patch import pytest from langchain_core.messages import AIMessage, ToolMessage from app.core.deep_agent import _infer_floating_domain, _normalize_tagged_list_lines, run_floating_stream, run_home class _FakeTool: name = "list_tasks" async def ainvoke(self, args): return {"rows": [{"id": "task-1", "title": "Mock Task"}], "echo": args} class _FakeLLM: def __init__(self) -> None: self.agent_calls = 0 def bind_tools(self, _tools): return self async def ainvoke(self, messages): system_prompt = str(getattr(messages[0], "content", "")) if messages else "" if "strict domain classifier" in system_prompt: return AIMessage(content='{"type":"timeline","id":"tl-1","section":null}') self.agent_calls += 1 if self.agent_calls == 1: return AIMessage( content="", tool_calls=[ { "id": "call-1", "name": "list_tasks", "args": {"project_id": "proj-1"}, } ], ) tool_messages = [m for m in messages if isinstance(m, ToolMessage)] assert tool_messages, "Expected at least one tool message" return AIMessage(content=f"Final answer from mocked tool: {tool_messages[-1].content}") async def astream(self, _messages): yield SimpleNamespace(content="stream-") yield SimpleNamespace(content="ok") @pytest.mark.asyncio async def test_run_home_uses_mocked_tool_result(): fake_llm = _FakeLLM() with patch("app.core.deep_agent.get_llm", return_value=fake_llm), patch( "app.core.deep_agent._all_tools", return_value=[_FakeTool()] ): out = await run_home("user-1", "list my tasks", {}) assert "Final answer from mocked tool" in out assert "Mock Task" in out @pytest.mark.asyncio async def test_run_floating_stream_emits_domain_then_tokens_with_mocked_tool_result(): fake_llm = _FakeLLM() with patch("app.core.deep_agent.get_llm", return_value=fake_llm), patch( "app.core.deep_agent._all_tools", return_value=[_FakeTool()] ): events = [] async for event in run_floating_stream( "user-1", "show me timeline updates", {"scope": {"type": "timeline", "id": "tl-1"}}, ): events.append(event) assert events[0] == ( "floating_domain", {"type": "timeline", "id": "tl-1", "section": None}, ) assert ("token", "stream-") in events assert ("token", "ok") in events @pytest.mark.asyncio async def test_infer_floating_domain_prefers_message_intent_over_scope_type(): class _ClassifierOnlyLLM: async def ainvoke(self, _messages): return AIMessage( content='{"type":"project","id":"213213-312321-312312-421321","section":"task"}' ) with patch("app.core.deep_agent.get_llm", return_value=_ClassifierOnlyLLM()): domain = await _infer_floating_domain( "Quali sono i miei task per il progetto X", { "scope": {"type": "timeline"}, "resolved_project_id": "213213-312321-312312-421321", }, ) assert domain == { "type": "project", "id": "213213-312321-312312-421321", "section": "task", } def test_normalize_tagged_list_lines_rewrites_mixed_task_lines_to_tag_only_lines(): raw = ( "Certo!\n\n" "1. **Task A** — priorita high [task-1]\n" "2. **Task B** — priorita medium [task-2]\n" ) out = _normalize_tagged_list_lines(raw, "quali sono le prossime attivita?") assert "[task-1]" in out assert "[task-2]" in out assert "Task A" not in out assert "Task B" not in out def test_normalize_tagged_list_lines_filters_upcoming_timeline_query_to_current_month_future_only(): today = date.today() tomorrow = today + timedelta(days=1) yesterday = today - timedelta(days=1) next_month = (today.replace(day=28) + timedelta(days=5)).replace(day=1) raw = "\n".join( [ f"- Milestone old — {yesterday.strftime('%d/%m/%Y')} [tl-old]", f"- Milestone next — {tomorrow.strftime('%d/%m/%Y')} [tl-next]", f"- Milestone future — {next_month.strftime('%d/%m/%Y')} [tl-future]", ] ) out = _normalize_tagged_list_lines(raw, "invece i miei eventi prossimi?") assert "[tl-next]" in out assert "[tl-old]" not in out assert "[tl-future]" not in out