Files
roboco/tests/integration/test_optimal_mentor_error_surfacing.py
T
Renn F b18bdcd41a fix(optimal): serialize singleton init, normalize kb_search index_types, surface mentor errors
The OptimalService singleton published the instance before initialize()
finished, so a concurrent caller could observe _initialized=False and hit
"OptimalService not initialized" during RAG indexing. Build the instance,
initialize it, then publish under a lazily-bound asyncio lock so all callers
share a fully-initialized singleton.

roboco_kb_search forwarded the legacy alias index_types=['docs'], which is
not a valid IndexType value (the enum value is 'documentation'), producing a
400 at the route. Normalize the alias in the client before the request is
sent and fix the misleading tool docstring.

The mentor route let exceptions from mentor.ask escape as a bare 500 that
masked the real cause. Catch, log the true upstream error with stack, and
surface it in the response detail so failures are diagnosable.
2026-06-03 18:54:07 +02:00

86 lines
2.9 KiB
Python

"""The mentor route must surface (log) the real upstream error, not mask it.
Previously an exception raised inside ``mentor.ask`` escaped the route as a
bare 500 whose body carried no diagnosable cause — agents saw only a generic
mask. The route must now log the true error and return a clean envelope whose
``detail`` names the real cause.
"""
from __future__ import annotations
from http import HTTPStatus
from typing import TYPE_CHECKING
from unittest.mock import AsyncMock, patch
from uuid import uuid4
import pytest
import pytest_asyncio
from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context
from roboco.api.routes.optimal import router as optimal_router
from roboco.models import AgentRole
from roboco.models.permissions import AgentContext
if TYPE_CHECKING:
from collections.abc import AsyncIterator
_HDR = {"X-Agent-ID": str(uuid4()), "X-Agent-Role": "ceo"}
@pytest_asyncio.fixture
async def optimal_client() -> AsyncIterator[AsyncClient]:
app = FastAPI()
app.include_router(optimal_router, prefix="/api/optimal")
async def _override_agent() -> AgentContext:
return AgentContext(agent_id=uuid4(), role=AgentRole.CEO, team=None)
app.dependency_overrides[get_agent_context] = _override_agent
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
yield client
app.dependency_overrides.clear()
@pytest.mark.asyncio
async def test_mentor_ask_logs_and_surfaces_real_upstream_error(
optimal_client: AsyncClient,
) -> None:
mentor = AsyncMock()
mentor._optimal_service = object() # already initialized
mentor.ask = AsyncMock(side_effect=RuntimeError("ollama connection refused"))
with (
patch("roboco.api.routes.optimal.get_mentor_service") as mock_mentor,
patch("roboco.api.routes.optimal.get_optimal_service") as mock_get,
patch("roboco.api.routes.optimal.logger") as mock_logger,
):
mock_mentor.return_value = mentor
mock_get.return_value = AsyncMock()
response = await optimal_client.post(
"/api/optimal/mentor/ask",
json={"question": "What"},
headers=_HDR,
)
# The route must not 200 on a failed ask.
assert response.status_code in (
HTTPStatus.INTERNAL_SERVER_ERROR,
HTTPStatus.SERVICE_UNAVAILABLE,
)
# The real cause must be diagnosable in the response body, not masked.
assert "ollama connection refused" in response.text
# The real error must be logged (so it is diagnosable server-side even if
# the envelope is later sanitized).
logged_text = "".join(
str(call.args) + str(call.kwargs)
for call in (
*mock_logger.error.call_args_list,
*mock_logger.exception.call_args_list,
)
)
assert "ollama connection refused" in logged_text