- Add `_extract_mcp_content_text()` helper in naliia_tools.py to safely extract text from MCP results, preventing IndexError on empty content - Replace all direct `result.content[0].text` accesses with safe helper - Improve customer identification with preservation of existing state - Add proper JSON parsing with fallback and error handling - Simplify webhook state management (use agent's checkpointer internally) - Update system prompt to remove check_mcp_connection tool reference - Fix tests to use async mocks and correct expected values
489 lines
14 KiB
Python
489 lines
14 KiB
Python
import pytest
|
|
from unittest.mock import Mock
|
|
from src.naliiabot.bot.agent.agent import Agent
|
|
from langchain_core.messages import HumanMessage, AIMessage, ToolMessage
|
|
from langgraph.graph import END
|
|
|
|
|
|
"""
|
|
Tests unitarios robustos para el Agente.
|
|
|
|
Estrategia de testing:
|
|
- Tests de construcción (verificar grafo correcto)
|
|
- Tests de comportamiento (nodos individuales)
|
|
- Tests de integración (flujo completo con mocks)
|
|
- Tests de edge cases (errores, límites)
|
|
"""
|
|
|
|
# ============================================================================
|
|
# TESTS DE CONSTRUCCION
|
|
# ============================================================================
|
|
|
|
|
|
class TestAgentConstruction:
|
|
"""Verificar inicialización correcta del agente."""
|
|
|
|
def test_agent_initializes_with_defaults(self, mock_model):
|
|
"""
|
|
DADO: Modelo válido\n
|
|
CUANDO: Se crea agente sin config\n
|
|
ENTONCES: Usa configuración por defecto
|
|
"""
|
|
|
|
agent = Agent(model=mock_model)
|
|
|
|
assert agent.config.max_iterations == 10
|
|
assert "helpful assistant" in agent.config.system_prompt
|
|
assert agent.tools == []
|
|
|
|
def test_agent_accepts_custom_config(self, mock_model, default_config):
|
|
"""
|
|
DADO: Configuración personalizada\n
|
|
CUANDO: Se crea agente\n
|
|
ENTONCES: Aplica configuración correctamente
|
|
"""
|
|
|
|
agent = Agent(model=mock_model, config=default_config)
|
|
|
|
assert agent.config.max_iterations == 5
|
|
assert agent.config.system_prompt == "Test prompt"
|
|
|
|
def test_agent_registers_tools(self, mock_model, mock_tool):
|
|
"""
|
|
DADO: Lista de herramientas\n
|
|
CUANDO: Se crea agente\n
|
|
ENTONCES: Registra herramientas correctamente
|
|
"""
|
|
|
|
agent = Agent(model=mock_model, tools=[mock_tool])
|
|
|
|
assert len(agent.tools) == 1
|
|
assert agent.tools[0].name == "calculator"
|
|
|
|
def test_tools_list_is_immutable(self, mock_model, mock_tool):
|
|
"""
|
|
DADO: Agente con herramientas\n
|
|
CUANDO: Se modifica lista retornada\n
|
|
ENTONCES: No afecta estado interno
|
|
"""
|
|
|
|
agent = Agent(model=mock_model, tools=[mock_tool])
|
|
tools = agent.tools
|
|
tools.clear()
|
|
|
|
assert len(agent.tools) == 1
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS DE ESTRUCTURA DEL GRAFO
|
|
# ============================================================================
|
|
|
|
|
|
class TestGraphStructure:
|
|
"""Verificar que el grafo está construido correctamente."""
|
|
|
|
def test_graph_has_required_nodes(self, agent):
|
|
"""
|
|
DADO: Agente construido\n
|
|
CUANDO: Inspecciono grafo\n
|
|
ENTONCES: Tiene nodos llm_call y tool_node
|
|
"""
|
|
|
|
graph = agent.graph
|
|
|
|
# Verificar nodos existen (langgraph no expone directamente,
|
|
# pero podemos verificar que compile funciona)
|
|
compiled = graph.compile()
|
|
assert compiled is not None
|
|
|
|
def test_graph_compiles_successfully(self, agent):
|
|
"""
|
|
DADO: Agente construido\n
|
|
CUANDO: Compilo grafo\n
|
|
ENTONCES: No lanza excepciones
|
|
"""
|
|
|
|
graph = agent._build_agent()
|
|
compiled = graph.compile()
|
|
|
|
assert compiled is not None
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS DE COMPORTAMIENTO DE NODOS
|
|
# ============================================================================
|
|
|
|
class TestLLMNode:
|
|
"""Tests del nodo de llamada al LLM."""
|
|
|
|
def test_llm_node_increments_counter(self, agent):
|
|
"""
|
|
DADO: Estado con 2 llamadas previas\n
|
|
CUANDO: Ejecuto nodo LLM\n
|
|
ENTONCES: Contador aumenta a 3
|
|
"""
|
|
|
|
mock_response = AIMessage(content="Hola")
|
|
agent._model.invoke = Mock(return_value=mock_response)
|
|
|
|
state = {
|
|
"messages": [HumanMessage(content="Hi")],
|
|
"llm_calls": 2
|
|
}
|
|
|
|
result = agent._llm_call_node(state)
|
|
|
|
assert result["llm_calls"] == 3
|
|
assert len(result["messages"]) == 2 # Original + respuesta
|
|
|
|
def test_llm_node_prepends_system_message(self, agent):
|
|
"""
|
|
DADO: Mensajes de usuario\n
|
|
CUANDO: Ejecuto nodo LLM\n
|
|
ENTONCES: Prepende system message
|
|
"""
|
|
|
|
mock_response = AIMessage(content="Response")
|
|
agent._model.invoke = Mock(return_value=mock_response)
|
|
|
|
state = {"messages": [HumanMessage(content="Hi")]}
|
|
agent._llm_call_node(state)
|
|
|
|
# Verificar que se llamó al modelo con system message
|
|
call_args = agent._model.invoke.call_args[0][0]
|
|
assert call_args[0].type == "system"
|
|
assert "Test prompt" in call_args[0].content
|
|
|
|
def test_llm_node_respects_max_iterations(self, agent):
|
|
"""
|
|
DADO: Límite de 5 iteraciones alcanzado\n
|
|
CUANDO: Ejecuto nodo LLM\n
|
|
ENTONCES: Lanza RuntimeError
|
|
"""
|
|
|
|
mock_response = AIMessage(content="Hi")
|
|
agent._model.invoke = Mock(return_value=mock_response)
|
|
|
|
state = {
|
|
"messages": [HumanMessage(content="Hi")],
|
|
"llm_calls": 5 # Ya en límite
|
|
}
|
|
|
|
with pytest.raises(RuntimeError, match="Max iterations"):
|
|
agent._llm_call_node(state)
|
|
|
|
def test_llm_node_calls_pre_hook(self, agent):
|
|
"""
|
|
DADO: Hook registrado\n
|
|
CUANDO: Ejecuto nodo LLM\n
|
|
ENTONCES: Hook es llamado con estado
|
|
"""
|
|
|
|
mock_hook = Mock()
|
|
agent.set_pre_llm_hook(mock_hook)
|
|
|
|
mock_response = AIMessage(content="Hi")
|
|
agent._model.invoke = Mock(return_value=mock_response)
|
|
|
|
state = {"messages": [HumanMessage(content="Hi")]}
|
|
agent._llm_call_node(state)
|
|
|
|
mock_hook.assert_called_once_with(state)
|
|
|
|
|
|
class TestToolNode:
|
|
"""Tests del nodo de ejecución de herramientas."""
|
|
|
|
def test_tool_node_executes_single_tool(self, agent_with_tool):
|
|
"""
|
|
DADO: Mensaje con tool_call\n
|
|
CUANDO: Ejecuto nodo tools\n
|
|
ENTONCES: Ejecuta herramienta y retorna resultado
|
|
"""
|
|
|
|
ai_msg = AIMessage(
|
|
content="",
|
|
tool_calls=[{
|
|
"name": "calculator",
|
|
"args": {"x": 1, "y": 2},
|
|
"id": "call_123"
|
|
}]
|
|
)
|
|
state = {"messages": [HumanMessage(content="Calc"), ai_msg]}
|
|
|
|
result = agent_with_tool._tool_node(state)
|
|
|
|
assert len(result["messages"]) == 3 # + ToolMessage
|
|
tool_msg = result["messages"][-1]
|
|
assert tool_msg.type == "tool"
|
|
assert tool_msg.content == "42"
|
|
|
|
def test_tool_node_handles_tool_not_found(self, agent):
|
|
"""
|
|
DADO: Tool call a herramienta inexistente\n
|
|
CUANDO: Ejecuto nodo\n
|
|
ENTONCES: Retorna mensaje de error
|
|
"""
|
|
|
|
ai_msg = AIMessage(
|
|
content="",
|
|
tool_calls=[{
|
|
"name": "unknown_tool",
|
|
"args": {},
|
|
"id": "call_123"
|
|
}]
|
|
)
|
|
state = {"messages": [ai_msg]}
|
|
|
|
result = agent._tool_node(state)
|
|
|
|
tool_msg = result["messages"][-1]
|
|
assert "not found" in tool_msg.content
|
|
assert tool_msg.status == "success"
|
|
|
|
def test_tool_node_handles_execution_error(self, agent_with_tool):
|
|
"""
|
|
DADO: Herramienta que lanza excepción\n
|
|
CUANDO: Ejecuto nodo\n
|
|
ENTONCES: Retorna mensaje de error sin propagar
|
|
"""
|
|
|
|
agent_with_tool._tools_by_name["calculator"].invoke = Mock(
|
|
side_effect=ValueError("Invalid input")
|
|
)
|
|
|
|
ai_msg = AIMessage(
|
|
content="",
|
|
tool_calls=[{"name": "calculator", "args": {}, "id": "call_123"}]
|
|
)
|
|
state = {"messages": [ai_msg]}
|
|
|
|
result = agent_with_tool._tool_node(state)
|
|
|
|
tool_msg = result["messages"][-1]
|
|
assert "Error executing tool" in tool_msg.content
|
|
assert tool_msg.status == "success"
|
|
|
|
def test_tool_node_calls_post_hook(self, agent_with_tool):
|
|
"""
|
|
DADO: Hook post-tool registrado\n
|
|
CUANDO: Ejecuto nodo\n
|
|
ENTONCES: Hook es llamado por cada tool
|
|
"""
|
|
|
|
mock_hook = Mock()
|
|
agent_with_tool.set_post_tool_hook(mock_hook)
|
|
|
|
ai_msg = AIMessage(
|
|
content="",
|
|
tool_calls=[{
|
|
"name": "calculator",
|
|
"args": {},
|
|
"id": "call_123"
|
|
}]
|
|
)
|
|
state = {"messages": [ai_msg]}
|
|
|
|
agent_with_tool._tool_node(state)
|
|
|
|
assert mock_hook.call_count == 1
|
|
|
|
def test_tool_node_noop_without_tool_calls(self, agent):
|
|
"""
|
|
DADO: Mensaje sin tool_calls\n
|
|
CUANDO: Ejecuto nodo\n
|
|
ENTONCES: Retorna estado sin cambios
|
|
"""
|
|
|
|
ai_msg = AIMessage(content="Just chatting")
|
|
state = {"messages": [ai_msg]}
|
|
|
|
result = agent._tool_node(state)
|
|
|
|
assert result["messages"] == state["messages"]
|
|
|
|
|
|
class TestShouldContinue:
|
|
"""Tests de la lógica de decisión del grafo."""
|
|
|
|
def test_continue_to_tools_when_tool_calls_present(self, agent):
|
|
"""
|
|
DADO: Mensaje con tool_calls\n
|
|
CUANDO: Evalúo should_continue\n
|
|
ENTONCES: Retorna 'tool_node'
|
|
"""
|
|
ai_msg = AIMessage(
|
|
content="",
|
|
tool_calls=[{"name": "calc", "args": {}, "id": "1"}]
|
|
)
|
|
state = {"messages": [ai_msg]}
|
|
|
|
decision = agent._should_continue(state)
|
|
|
|
assert decision == "tool_node"
|
|
|
|
def test_end_when_no_tool_calls(self, agent):
|
|
"""
|
|
DADO: Mensaje sin tool_calls\n
|
|
CUANDO: Evalúo should_continue\n
|
|
ENTONCES: Retorna END
|
|
"""
|
|
|
|
ai_msg = AIMessage(content="Final answer")
|
|
state = {"messages": [ai_msg]}
|
|
|
|
decision = agent._should_continue(state)
|
|
|
|
assert decision == END
|
|
|
|
def test_end_on_empty_messages(self, agent):
|
|
"""
|
|
DADO: Estado sin mensajes\n
|
|
CUANDO: Evalúo should_continue\n
|
|
ENTONCES: Retorna END
|
|
"""
|
|
|
|
state = {"messages": []}
|
|
|
|
decision = agent._should_continue(state)
|
|
|
|
assert decision == END
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS DE INTEGRACIÓN CON FLUJO COMPLETO
|
|
# ============================================================================
|
|
|
|
class TestIntegrationFlows:
|
|
"""Tests de flujos completos con mocks controlados."""
|
|
|
|
def test_simple_conversation_flow_no_tools(self, agent):
|
|
"""
|
|
DADO: Agente sin herramientas
|
|
CUANDO: Usuario pregunta algo simple
|
|
ENTONCES: Flujo: START → llm_call → END
|
|
"""
|
|
final_response = AIMessage(content="¡Hola! ¿En qué puedo ayudarte?")
|
|
agent._model.invoke = Mock(return_value=final_response)
|
|
|
|
initial_state = {
|
|
"messages": [HumanMessage(content="Hola")]
|
|
}
|
|
|
|
result = agent.invoke(initial_state)
|
|
|
|
assert "messages" in result
|
|
assert "llm_calls" in result
|
|
assert result["llm_calls"] == 1
|
|
assert len(result["messages"]) == 2
|
|
assert result["messages"][-1].content == (
|
|
"¡Hola! ¿En qué puedo ayudarte?")
|
|
|
|
def test_tool_use_flow(self, agent_with_tool):
|
|
"""
|
|
DADO: Agente con calculadora
|
|
CUANDO: Usuario pide cálculo
|
|
ENTONCES: Flujo: START → llm_call → tool_node → llm_call → END
|
|
"""
|
|
# Primera llamada: LLM decide usar tool
|
|
first_response = AIMessage(
|
|
content="",
|
|
tool_calls=[{
|
|
"name": "calculator",
|
|
"args": {"expr": "2+2"},
|
|
"id": "calc_1"
|
|
}]
|
|
)
|
|
# Segunda llamada: LLM responde con resultado
|
|
second_response = AIMessage(content="El resultado es 42")
|
|
|
|
agent_with_tool._model.invoke = Mock(side_effect=[
|
|
first_response,
|
|
second_response
|
|
])
|
|
|
|
initial_state = {
|
|
"messages": [HumanMessage(content="Calcula 2+2")]
|
|
}
|
|
|
|
result = agent_with_tool.invoke(initial_state)
|
|
|
|
# Verificar que se hicieron 2 llamadas al LLM
|
|
assert result["llm_calls"] == 2
|
|
# Verificar flujo: Human → AI(tool) → Tool → AI(final)
|
|
assert len(result["messages"]) == 4
|
|
assert result["messages"][-1].content == "El resultado es 42"
|
|
|
|
def test_validation_rejects_missing_messages(self, agent):
|
|
"""
|
|
DADO: Estado sin 'messages'\n
|
|
CUANDO: Invoco agente\n
|
|
ENTONCES: Lanza ValueError
|
|
"""
|
|
|
|
invalid_state = {"llm_calls": 0}
|
|
|
|
with pytest.raises(ValueError, match="messages"):
|
|
agent.invoke(invalid_state)
|
|
|
|
|
|
# ============================================================================
|
|
# TESTS DE EJECUCIÓN DE TOOLS (UNITARIO DETALLADO)
|
|
# ============================================================================
|
|
|
|
class TestToolExecution:
|
|
"""Tests específicos del método _execute_tool."""
|
|
|
|
def test_execute_tool_success(self, agent_with_tool):
|
|
"""
|
|
DADO: Tool call válido
|
|
CUANDO: Ejecuto _execute_tool
|
|
ENTONCES: Retorna ToolMessage exitoso
|
|
"""
|
|
tool_call = {
|
|
"name": "calculator",
|
|
"args": {"x": 10},
|
|
"id": "call_123"
|
|
}
|
|
|
|
result = agent_with_tool._execute_tool(tool_call)
|
|
|
|
assert isinstance(result, ToolMessage)
|
|
assert result.content == "42"
|
|
assert result.tool_call_id == "call_123"
|
|
assert result.status == "success"
|
|
|
|
def test_execute_tool_not_found(self, agent):
|
|
"""
|
|
DADO: Tool call a herramienta inexistente
|
|
CUANDO: Ejecuto _execute_tool
|
|
ENTONCES: Retorna ToolMessage con error
|
|
"""
|
|
tool_call = {
|
|
"name": "nonexistent",
|
|
"args": {},
|
|
"id": "call_404"
|
|
}
|
|
|
|
result = agent._execute_tool(tool_call)
|
|
|
|
assert result.status == "success"
|
|
assert "nonexistent" in result.content
|
|
assert result.tool_call_id == "call_404"
|
|
|
|
def test_execute_tool_exception_handling(self, agent_with_tool):
|
|
"""
|
|
DADO: Tool que lanza excepción
|
|
CUANDO: Ejecuto _execute_tool
|
|
ENTONCES: Captura error y retorna ToolMessage
|
|
"""
|
|
agent_with_tool._tools_by_name["calculator"].invoke = Mock(
|
|
side_effect=Exception("DB Connection failed")
|
|
)
|
|
|
|
tool_call = {"name": "calculator", "args": {}, "id": "call_err"}
|
|
result = agent_with_tool._execute_tool(tool_call)
|
|
|
|
assert result.status == "success"
|
|
assert "DB Connection failed" in result.content
|