"""reload_all() — the single implementation behind reload_tools and the admin button.
The MCP half is the delicate one: register_mcp_tools only adds registrations, so
a reload that reconnects without dropping the old ones leaves ghost tools — for
every server taken out of the config, and for every tool a server stopped
exposing. They stay visible to the model and fail only when called.
"""
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):
self.server = server
self.connected = True
self._tools = tools
async def list_tools(self):
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
@property
def clients(self):
return {s: FakeClient(s, t) for s, t in self.servers.items()}
async def reload_all(self):
self.reloads += 1
if self.fail:
raise RuntimeError("mcp is down")
@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
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",
}