"""reload_all() — the single implementation behind reload_tools and the admin button.
The MCP half is the delicate one. MCP registrations are rebuilt from what the
servers answer *right now* — that is the only way a server removed from the
config, or a tool a server stopped exposing, ever leaves the registry. So the
rebuild happens in two steps: collect everything, then commit it in one swap. A
rebuild that fails, or a server that never answers, must leave the previous
registrations exactly as they were rather than an empty registry.
"""
import asyncio
import textwrap
import pytest
from navi.context_providers._loader import ContextProviderRegistry
from navi.core.registry import ToolRegistry
from navi.core.reload import ReloadReport, reload_all
def _write(path, name, version="v1", broken=False):
if broken:
path.write_text("not python (")
return
path.write_text(
textwrap.dedent(
f'''
name = "{name}"
description = "{version}"
parameters = {{"type": "object", "properties": {{}}}}
async def execute(params):
return "{version}"
'''
).lstrip()
)
class FakeClient:
def __init__(self, server, tools, silent=False):
self.server = server
self.connected = True
self._tools = tools
self.silent = silent
async def list_tools(self):
if self.silent:
raise RuntimeError(f"no answer from {self.server}")
return [
type("T", (), {"name": n, "description": "d", "inputSchema": {}})
for n in self._tools
]
class FakeMcpManager:
"""Reconnects to whatever servers the test put in ``servers``."""
def __init__(self, servers: dict[str, list[str]]):
self.servers = servers
self.reloads = 0
self.fail = False
# Servers that stop answering: ``reload_all`` succeeds, listing them
# does not — the shape of a server that is connected but silent.
self.silent: set[str] = set()
@property
def clients(self):
return {
s: FakeClient(s, t, silent=s in self.silent)
for s, t in self.servers.items()
}
async def reload_all(self):
self.reloads += 1
if self.fail:
raise RuntimeError("mcp is down")
class HangingMcpManager:
"""A manager whose reload never returns — the shape of the prod hang."""
clients: dict = {}
async def reload_all(self):
await asyncio.sleep(3600)
@pytest.fixture
def isolated_settings(tmp_path, monkeypatch):
"""Point tools_dir at a scratch directory and control enabled.json."""
import navi.core.reload as reload_mod
# Settings is frozen — replace the object the module sees, not the field.
monkeypatch.setattr(
reload_mod, "settings", reload_mod.settings.model_copy(update={"tools_dir": str(tmp_path)})
)
# enabled.json is read through tool_utils by both reload and the agent.
monkeypatch.setattr("navi.core.tool_utils.load_user_enabled_tools", list)
return tmp_path
class FakeBuiltin:
name = "reload_tools"
description = "d"
parameters = {}
async def test_reports_what_was_loaded(isolated_settings):
_write(isolated_settings / "alpha.py", "alpha")
_write(isolated_settings / "beta.py", "beta")
registry = ToolRegistry()
registry.register(FakeBuiltin(), builtin=True)
report = await reload_all(registry, None, None)
assert report.ok is True
assert sorted(report.tools) == ["alpha", "beta"]
assert report.total_tools == 3
assert report.mcp_error is None
assert "Tools (2): alpha, beta." in report.lines()
async def test_enabled_json_naming_a_tool_that_does_not_exist_is_reported(isolated_settings, monkeypatch):
"""The gmail case: the name stayed in enabled.json after its file stopped
loading, and nothing said so — the tool just vanished from every profile."""
_write(isolated_settings / "alpha.py", "alpha")
monkeypatch.setattr("navi.core.tool_utils.load_user_enabled_tools", lambda: ["ghost"])
registry = ToolRegistry()
report = await reload_all(registry, None, None)
assert report.missing_enabled == ["ghost"]
assert report.ok is True # the reload itself worked; the config is wrong
assert any("ghost" in line for line in report.lines())
async def test_one_broken_file_does_not_hide_the_rest(isolated_settings):
_write(isolated_settings / "alpha.py", "alpha")
_write(isolated_settings / "broken.py", "broken", broken=True)
registry = ToolRegistry()
report = await reload_all(registry, None, None)
assert report.ok is False
assert report.tools == ["alpha"]
assert list(report.tool_errors) == ["broken.py"]
assert registry.get("alpha") is not None
async def test_context_providers_are_reloaded(isolated_settings, tmp_path, monkeypatch):
import navi.core.reload as reload_mod
providers = tmp_path / "providers"
providers.mkdir()
(providers / "clock.py").write_text(
textwrap.dedent(
'''
name = "clock"
description = "the time"
global_provider = False
async def get_context():
return "12:00"
'''
).lstrip()
)
monkeypatch.setattr(
reload_mod,
"settings",
reload_mod.settings.model_copy(update={"context_providers_dir": str(providers)}),
)
cp_registry = ContextProviderRegistry()
report = await reload_all(ToolRegistry(), cp_registry, None)
assert report.providers == ["clock"]
assert cp_registry.get("clock") is not None
async def test_mcp_tools_of_a_removed_server_do_not_survive(isolated_settings):
registry = ToolRegistry()
manager = FakeMcpManager({"alpha": ["ping", "pong"]})
first = await reload_all(registry, None, manager)
assert first.mcp_tools == ["mcp__alpha__ping", "mcp__alpha__pong"]
# The server now exposes only one tool...
manager.servers = {"alpha": ["ping"]}
second = await reload_all(registry, None, manager)
assert second.mcp_tools == ["mcp__alpha__ping"]
assert "mcp__alpha__pong" not in [t.name for t in registry.all()]
# ...and then leaves the config entirely.
manager.servers = {}
third = await reload_all(registry, None, manager)
assert third.mcp_tools == []
assert registry.all() == []
async def test_builtin_tools_survive_an_mcp_reload(isolated_settings):
registry = ToolRegistry()
registry.register(FakeBuiltin(), builtin=True)
await reload_all(registry, None, FakeMcpManager({"alpha": ["ping"]}))
assert [t.name for t in registry.all()] == ["reload_tools", "mcp__alpha__ping"]
async def test_an_mcp_failure_is_isolated_and_reported(isolated_settings):
"""A dead MCP server must not erase what the tool pass already loaded."""
_write(isolated_settings / "alpha.py", "alpha")
registry = ToolRegistry()
manager = FakeMcpManager({})
manager.fail = True
report = await reload_all(registry, None, manager)
assert report.ok is False
assert report.tools == ["alpha"]
assert "mcp is down" in report.mcp_error
assert registry.get("alpha") is not None
async def test_a_failed_mcp_pass_keeps_the_tools_it_had(isolated_settings):
"""The registry is committed in one step at the end of the rebuild, so a
rebuild that dies halfway leaves what it had — not an empty registry with
the model holding MCP names that resolve to nothing."""
registry = ToolRegistry()
manager = FakeMcpManager({"alpha": ["ping", "pong"]})
await reload_all(registry, None, manager)
manager.fail = True
report = await reload_all(registry, None, manager)
assert report.mcp_error is not None
assert registry.get("mcp__alpha__ping") is not None
assert registry.get("mcp__alpha__pong") is not None
assert any("left untouched" in line for line in report.lines())
async def test_a_silent_server_cannot_hang_the_reload(isolated_settings, monkeypatch):
"""A server that never answers must cost the turn nothing: the phase is
budgeted, and the report says so instead of the tool call hanging."""
import navi.core.reload as reload_mod
monkeypatch.setattr(reload_mod, "MCP_RELOAD_BUDGET_S", 0.2)
registry = ToolRegistry()
manager = FakeMcpManager({"alpha": ["ping"]})
await reload_all(registry, None, manager)
report = await asyncio.wait_for(
reload_all(registry, None, HangingMcpManager()), timeout=5
)
assert report.ok is False
assert "did not finish within" in report.mcp_error
assert registry.get("mcp__alpha__ping") is not None
assert any("left untouched" in line for line in report.lines())
async def test_the_tools_of_a_silent_server_do_not_take_the_others_down(isolated_settings):
"""One server that will not list its tools loses its own registrations for
this round — the health check re-registers them when it answers again —
while every other server keeps its own."""
registry = ToolRegistry()
manager = FakeMcpManager({"alpha": ["ping"], "beta": ["pong"]})
await reload_all(registry, None, manager)
manager.silent = {"alpha"}
report = await reload_all(registry, None, manager)
assert report.mcp_error is None
assert report.mcp_tools == ["mcp__beta__pong"]
assert registry.get("mcp__beta__pong") is not None
assert [t.name for t in registry.all()] == ["mcp__beta__pong"]
def test_report_serialises_for_the_api():
report = ReloadReport(tools=["a"], total_tools=2, tool_errors={"x.py": "boom"})
data = report.as_dict()
assert data["ok"] is False
assert data["tools"] == ["a"] and data["total_tools"] == 2
assert data["tool_errors"] == {"x.py": "boom"}
assert set(data) == {
"ok", "tools", "total_tools", "tool_errors", "missing_enabled",
"providers", "provider_errors", "mcp_tools", "mcp_error",
}