from __future__ import annotations import pytest from app.llm.base import ChatResult, ProviderError, ToolCall from app.models import ChatMessage, ChatSession from app.orchestrator import run_turn from app.tools.registry import ToolContext class FakeRouter: def __init__(self, results: list[ChatResult]): self._results = list(results) self.calls = 0 async def chat(self, messages, tools, tier="large", temperature=None): self.calls += 1 return self._results.pop(0) class FakeShopify: async def execute(self, query, variables=None): return {"orders": {"edges": []}} @pytest.fixture(autouse=True) def no_real_search(monkeypatch): async def fake_search(session, query, k=4, *, tenant_id=None): return [] from app.rag import index monkeypatch.setattr(index, "search", fake_search) async def _session(db): s = ChatSession(shop="x") db.add(s) await db.flush() return s async def test_info_turn_uses_knowledge_tool_then_answers(db_session): router = FakeRouter( [ ChatResult( content=None, tool_calls=[ToolCall(id="t1", name="search_knowledge", arguments={"query": "envíos"})], finish_reason="tool_calls", ), ChatResult(content="Hacemos envíos gratis desde 199€.", tool_calls=[], finish_reason="stop"), ] ) ctx = ToolContext(db=db_session, session=await _session(db_session)) resp = await run_turn(router, ctx, "¿hacéis envíos gratis?", brand_name="Tienda") assert resp.reply == "Hacemos envíos gratis desde 199€." assert resp.used_tools == ["search_knowledge"] assert router.calls == 2 # persisted user + assistant from sqlalchemy import select msgs = (await db_session.execute(select(ChatMessage))).scalars().all() roles = [m.role for m in msgs] assert "user" in roles and "assistant" in roles async def test_order_turn_unverified_asks_for_credentials(db_session): router = FakeRouter( [ ChatResult( content=None, tool_calls=[ToolCall(id="o1", name="lookup_order", arguments={})], finish_reason="tool_calls", ), ChatResult( content="Para localizar tu pedido necesito tu email y número de pedido.", tool_calls=[], finish_reason="stop", ), ] ) ctx = ToolContext(db=db_session, session=await _session(db_session), shopify=FakeShopify()) resp = await run_turn(router, ctx, "¿dónde está mi pedido?") assert "email" in resp.reply.lower() assert resp.used_tools == ["lookup_order"] class _ToolFailRouter: """Fails (ProviderError) whenever tools are sent; answers when no tools.""" def __init__(self, reply="Respuesta sin herramientas."): self.reply = reply self.calls_with_tools = 0 async def chat(self, messages, tools, tier="large", temperature=None): if tools: self.calls_with_tools += 1 raise ProviderError("tool_use_failed") return ChatResult(content=self.reply, tool_calls=[], finish_reason="stop") class _AlwaysFailRouter: async def chat(self, messages, tools, tier="large", temperature=None): raise ProviderError("down") async def test_degrades_to_no_tools_when_tool_calling_fails(db_session): router = _ToolFailRouter() ctx = ToolContext(db=db_session, session=await _session(db_session)) resp = await run_turn(router, ctx, "¿qué vendéis?") assert resp.reply == "Respuesta sin herramientas." assert router.calls_with_tools >= 1 # it tried tools first, then degraded async def test_total_provider_failure_returns_fallback_not_500(db_session): from app.orchestrator import FALLBACK_REPLY ctx = ToolContext(db=db_session, session=await _session(db_session)) resp = await run_turn(_AlwaysFailRouter(), ctx, "hola") assert resp.reply == FALLBACK_REPLY async def test_tool_budget_exhausted_forces_final_answer(db_session): # Always returns tool_calls; orchestrator must stop and force a final answer. loop_result = ChatResult( content=None, tool_calls=[ToolCall(id="t", name="search_knowledge", arguments={"query": "x"})], finish_reason="tool_calls", ) router = FakeRouter( [loop_result, loop_result, loop_result, loop_result, ChatResult(content="Respuesta final.", tool_calls=[], finish_reason="stop")] ) ctx = ToolContext(db=db_session, session=await _session(db_session)) resp = await run_turn(router, ctx, "bucle") assert resp.reply == "Respuesta final." assert router.calls == 5 # 4 tool iters + 1 forced final