"""Tests for list_tools.
Two failures this tool must not have. It must not advertise a tool the agent
cannot call — the list is built from the profile config, but the call goes to
the live registry, and an MCP server that never connected leaves a phantom
entry the agent then calls into a "tool not found". And it must not dump the
whole catalogue: an MCP-heavy profile carries ~125 tools / ~35 KB, which is
9k tokens spent to answer "do I have anything for ssh".
"""
import pytest
from navi.core.registry import ProfileRegistry, ToolRegistry
from navi.profiles.base import ToolConfig, ToolScopeConfig
from navi.tools._internal.base import ToolContext
from navi.tools.list_tools import ListToolsTool
from tests.conftest_factory import FakeTool, make_profile
@pytest.fixture(autouse=True)
def no_user_enabled_tools(monkeypatch):
"""Every profile also picks up tools/enabled.json, a real file on the machine
running the tests — tests must see exactly what they declare."""
monkeypatch.setattr("navi.core.tool_utils.load_user_enabled_tools", list)
class FakeMcpManager:
"""Stand-in for McpManager — group name to tool names, as the server config holds them."""
def __init__(self, groups: dict[str, dict[str, list[str]]] | None = None) -> None:
self._groups = groups or {}
def resolve_group(self, server_name: str, group_name: str) -> list[str]:
return list(self._groups.get(server_name, {}).get(group_name, []))
def make_profile_with(
profile_id: str,
native: list[str],
mcp: dict | None = None,
subagent_native: list[str] | None = None,
):
"""A profile holding exactly the declared tools — no enabled_tools migration on top."""
subagent = (
ToolScopeConfig(native=list(subagent_native))
if subagent_native is not None
else ToolScopeConfig()
)
return make_profile(
profile_id,
enabled_tools=[],
tools=ToolConfig(
agent=ToolScopeConfig(native=list(native), mcp=dict(mcp or {})),
subagent=subagent,
),
)
def make_profiles(*profiles) -> ProfileRegistry:
registry = ProfileRegistry()
for profile in profiles:
registry.register(profile)
return registry
def make_registry(*specs: tuple[str, str]) -> ToolRegistry:
"""specs: (name, description) pairs — the live, registered tools."""
registry = ToolRegistry()
for name, description in specs:
registry.register(FakeTool(name, description=description), builtin=True)
return registry
def make_tool(registry, profiles, groups=None) -> ListToolsTool:
return ListToolsTool(
registry=registry,
profile_registry=profiles,
mcp_manager=FakeMcpManager(groups),
)
@pytest.mark.asyncio
async def test_lists_only_the_requested_profiles_tools():
profiles = make_profiles(
make_profile_with("secretary", ["todo"]),
make_profile_with("developer", ["code_exec"]),
)
registry = make_registry(("todo", "notes"), ("code_exec", "runs"), ("weather", "forecast"))
result = await make_tool(registry, profiles).execute({"profile_id": "secretary"})
assert result.success is True
assert "todo" in result.output
assert "code_exec" not in result.output
assert "weather" not in result.output
@pytest.mark.asyncio
async def test_compact_output_groups_names_by_source():
groups = {"tgclient": {"read": ["dialogs_list", "messages_history"]}}
profiles = make_profiles(
make_profile_with("secretary", ["todo"], {"tgclient": ["read"]})
)
registry = make_registry(
("todo", "notes and tasks"),
("mcp__tgclient__dialogs_list", "list dialogs"),
("mcp__tgclient__messages_history", "read history"),
)
result = await make_tool(registry, profiles, groups).execute({"profile_id": "secretary"})
assert "native (1): todo" in result.output
assert (
"mcp__tgclient__ (2): mcp__tgclient__dialogs_list, mcp__tgclient__messages_history"
in result.output
)
# Descriptions are what makes the output huge — they stay out unless asked for.
assert "notes and tasks" not in result.output
@pytest.mark.asyncio
async def test_verbose_is_an_order_of_magnitude_larger():
"""Descriptions are what costs the context — 125 tools of them is ~35 KB."""
names = [f"tool_{i}" for i in range(20)]
profiles = make_profiles(make_profile_with("secretary", names))
registry = make_registry(*[(name, "d" * 200) for name in names])
tool = make_tool(registry, profiles)
compact = (await tool.execute({"profile_id": "secretary"})).output
verbose = (await tool.execute({"profile_id": "secretary", "verbose": True})).output
assert "d" * 200 not in compact
assert "d" * 200 in verbose
assert len(verbose) > len(compact) * 5
@pytest.mark.asyncio
async def test_query_filters_by_name_and_by_description():
profiles = make_profiles(make_profile_with("secretary", ["todo", "sync_files"]))
registry = make_registry(("todo", "notes and tasks"), ("sync_files", "copy files"))
tool = make_tool(registry, profiles)
by_name = (await tool.execute({"profile_id": "secretary", "query": "files"})).output
assert "mcp__" not in by_name
assert "sync_files" in by_name
assert "todo" not in by_name
assert "matching 'files'" in by_name
by_description = (await tool.execute({"profile_id": "secretary", "query": "tasks"})).output
assert "todo" in by_description
assert "sync_files" not in by_description
@pytest.mark.asyncio
async def test_declared_but_unregistered_tools_are_not_advertised():
"""The navi_ui case: the profile config declares it, the server never connected."""
groups = {"navi_ui": {"ui": ["render_component"]}}
profiles = make_profiles(
make_profile_with("secretary", ["todo"], {"navi_ui": ["ui"]})
)
registry = make_registry(("todo", "notes")) # render_component was never registered
result = await make_tool(registry, profiles, groups).execute({"profile_id": "secretary"})
assert "Not registered (1): mcp__navi_ui__render_component" in result.output
# Counted as neither available nor part of a source section.
assert "(1 available)" in result.output
assert "mcp__navi_ui__ (1)" not in result.output
@pytest.mark.asyncio
async def test_query_still_reports_a_matching_unregistered_tool():
""""Do I have anything for render" must answer "declared, but not callable"."""
groups = {"navi_ui": {"ui": ["render_component"]}}
profiles = make_profiles(
make_profile_with("secretary", ["todo"], {"navi_ui": ["ui"]})
)
registry = make_registry(("todo", "notes"))
result = await make_tool(registry, profiles, groups).execute(
{"profile_id": "secretary", "query": "render"}
)
assert "Not registered (1): mcp__navi_ui__render_component" in result.output
assert "0 of 2 declared" in result.output
@pytest.mark.asyncio
async def test_wildcard_group_takes_every_registered_tool_of_that_server():
profiles = make_profiles(
make_profile_with("secretary", ["todo"], {"tgclient": ["*"]})
)
registry = make_registry(
("todo", "notes"),
("mcp__tgclient__dialogs_list", "dialogs"),
("mcp__other__whatever", "not this server"),
)
result = await make_tool(registry, profiles).execute({"profile_id": "secretary"})
assert "mcp__tgclient__dialogs_list" in result.output
assert "mcp__other__whatever" not in result.output
@pytest.mark.asyncio
async def test_no_profile_in_context_is_an_error_that_says_what_to_do():
result = await make_tool(make_registry(), make_profiles()).execute({})
assert result.success is False
assert result.error == "no_active_profile"
assert "list_profiles" in result.output
@pytest.mark.asyncio
async def test_unknown_profile_is_rejected():
registry = make_registry(("todo", "notes"))
profiles = make_profiles(make_profile_with("secretary", ["todo"]))
result = await make_tool(registry, profiles).execute({"profile_id": "nope"})
assert result.success is False
assert result.error == "profile_not_found"
@pytest.mark.asyncio
async def test_defaults_to_the_profile_the_run_executes_as():
"""Omitted profile_id means "mine" — the agent answers "what do I have" with no argument."""
profiles = make_profiles(
make_profile_with("secretary", ["todo"]),
make_profile_with("server_admin", ["ssh_exec"]),
)
registry = make_registry(("todo", "notes"), ("ssh_exec", "remote"))
tool = make_tool(registry, profiles)
result = await tool.execute({}, ctx=ToolContext(profile_id="server_admin"))
assert "ssh_exec" in result.output
assert "todo" not in result.output
# Which profile was assumed, so the answer cannot be misread as another's.
assert "'server_admin' (current profile)" in result.output
@pytest.mark.asyncio
async def test_explicit_profile_id_beats_the_running_profile():
profiles = make_profiles(
make_profile_with("secretary", ["todo"]),
make_profile_with("server_admin", ["ssh_exec"]),
)
registry = make_registry(("todo", "notes"), ("ssh_exec", "remote"))
result = await make_tool(registry, profiles).execute(
{"profile_id": "secretary"}, ctx=ToolContext(profile_id="server_admin")
)
assert "todo" in result.output
assert "ssh_exec" not in result.output
assert "current profile" not in result.output
@pytest.mark.asyncio
async def test_scope_subagent_lists_what_a_spawned_agent_would_get():
profiles = make_profiles(
make_profile_with("server_admin", ["ssh_exec", "todo"], subagent_native=["ssh_exec"])
)
registry = make_registry(("ssh_exec", "remote"), ("todo", "planning"))
agent_side = await make_tool(registry, profiles).execute({"profile_id": "server_admin"})
subagent_side = await make_tool(registry, profiles).execute(
{"profile_id": "server_admin", "scope": "subagent"}
)
assert "todo" in agent_side.output
assert "todo" not in subagent_side.output
assert "ssh_exec" in subagent_side.output
assert "(subagent scope)" in subagent_side.output
@pytest.mark.asyncio
async def test_unknown_scope_is_rejected():
profiles = make_profiles(make_profile_with("secretary", ["todo"]))
registry = make_registry(("todo", "notes"))
result = await make_tool(registry, profiles).execute(
{"profile_id": "secretary", "scope": "worker"}
)
assert result.success is False
assert result.error == "unknown_scope"