"""tool_manual — reaching the right manual, and failing usefully.
The bug this covers: the tool looked up `manuals/<name>.md` by the exact string the
model sent, then fell back to `registry.get(name)`. The agent calls MCP tools by
their full name (`mcp__navi-3d__compile_scad`), but the file is named after the tool
(`compile_scad.md`) — so a hand-written manual that existed was unreachable, and the
agent silently got a thin schema dump instead. The executor resolves such names fine;
the tool that *documents* them did not.
"""
import pytest
from navi.core.registry import ProfileRegistry, ToolRegistry
from navi.profiles.base import ToolConfig, ToolScopeConfig
from navi.tools import tool_manual
from navi.tools._internal.base import ToolContext
from navi.tools.tool_manual import ToolManualTool
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)
@pytest.fixture
def manuals(tmp_path, monkeypatch):
"""A manuals/ tree this test owns, with the guides/ subdir beside it."""
root = tmp_path / "manuals"
(root / "guides").mkdir(parents=True)
monkeypatch.setattr(tool_manual, "MANUALS_DIR", root)
monkeypatch.setattr(tool_manual, "GUIDES_DIR", root / "guides")
return root
class FakeMcpManager:
"""Stand-in for McpManager — group name to tool names, as a 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 build(registry=None, profiles=None, mcp_manager=None) -> ToolManualTool:
return ToolManualTool(
registry=registry, profile_registry=profiles, mcp_manager=mcp_manager
)
def registry_with(*tools: FakeTool) -> ToolRegistry:
registry = ToolRegistry()
for tool in tools:
registry.register(tool, builtin=True)
return registry
def profiles_with(profile) -> ProfileRegistry:
registry = ProfileRegistry()
registry.register(profile)
return registry
def profile_with(profile_id="developer", native=(), mcp=None, subagent_native=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,
),
)
class TestManualFiles:
async def test_a_bare_name_finds_the_manual(self, manuals):
(manuals / "compile_scad.md").write_text("# compile_scad\n\nhand-written\n")
result = await build().execute({"tool_name": "compile_scad"})
assert result.success is True
assert "hand-written" in result.output
async def test_the_full_mcp_name_reaches_the_hand_written_manual(self, manuals):
"""The regression: the agent calls the MCP spelling, the file is the bare one."""
(manuals / "compile_scad.md").write_text("# compile_scad\n\nhand-written\n")
result = await build().execute({"tool_name": "mcp__navi-3d__compile_scad"})
assert result.success is True
assert "hand-written" in result.output
async def test_an_underscored_server_name_reaches_it_too(self, manuals):
(manuals / "lint_scad.md").write_text("# lint_scad\n\nhand-written\n")
result = await build().execute({"tool_name": "mcp__navi_3d__lint_scad"})
assert "hand-written" in result.output
async def test_the_name_is_matched_case_insensitively(self, manuals):
(manuals / "compile_scad.md").write_text("# compile_scad\n\nhand-written\n")
result = await build().execute({"tool_name": "Compile_SCAD"})
assert "hand-written" in result.output
async def test_a_dash_spelling_finds_the_underscored_file(self, manuals):
(manuals / "compile_scad.md").write_text("# compile_scad\n\nhand-written\n")
result = await build().execute({"tool_name": "compile-scad"})
assert "hand-written" in result.output
async def test_a_guide_is_reachable_by_name(self, manuals):
"""Guides live in guides/ and are not tools, but naming one must still read it."""
(manuals / "guides" / "write_context_provider.md").write_text("# guide\n\nbody\n")
result = await build().execute({"tool_name": "write_context_provider"})
assert result.success is True
assert "body" in result.output
async def test_the_hand_written_manual_beats_the_generated_one(self, manuals):
(manuals / "filesystem.md").write_text("# filesystem\n\nhand-written\n")
registry = registry_with(FakeTool("filesystem", description="schema description"))
result = await build(registry=registry).execute({"tool_name": "filesystem"})
assert "hand-written" in result.output
assert "schema description" not in result.output
class TestGeneratedManual:
async def test_an_unwritten_manual_falls_back_to_the_schema(self, manuals):
registry = registry_with(FakeTool("todo", description="Manage the todo list"))
result = await build(registry=registry).execute({"tool_name": "todo"})
assert result.success is True
assert "# todo" in result.output
assert "Manage the todo list" in result.output
async def test_a_bare_mcp_name_resolves_against_the_profiles_toolset(self, manuals):
"""The same name the executor would resolve must resolve here — mcp group '*'."""
registry = registry_with(FakeTool("mcp__navi-web__web_search", description="Search"))
profiles = profiles_with(profile_with("developer", mcp={"navi-web": ["*"]}))
result = await build(registry=registry, profiles=profiles).execute(
{"tool_name": "web_search"}, ctx=ToolContext(profile_id="developer")
)
assert result.success is True
assert "# mcp__navi-web__web_search" in result.output
class TestGeneratedRendering:
"""The generated manual is what most tools get, so it has to render the whole
schema: a model that cannot see the shape of an array of objects guesses it."""
def tool(self, parameters, description="A tool"):
return FakeTool("shape_probe", description=description, parameters=parameters)
async def render(self, manuals, parameters, **kwargs):
registry = registry_with(self.tool({"type": "object", **parameters}))
result = await build(registry=registry, **kwargs).execute({"tool_name": "shape_probe"})
return result.output
async def test_the_header_names_the_source_and_the_schema(self, manuals):
out = await self.render(manuals, {"properties": {}})
assert "Generated from the tool's JSON schema (source: native)" in out
assert "not a hand-written manual" in out
async def test_an_mcp_tool_reports_its_server_as_the_source(self, manuals):
registry = registry_with(FakeTool("mcp__navi-3d__compile_scad", description="Scad"))
profiles = profiles_with(profile_with("developer", mcp={"navi-3d": ["*"]}))
result = await build(registry=registry, profiles=profiles).execute(
{"tool_name": "compile_scad"}, ctx=ToolContext(profile_id="developer")
)
assert "source: mcp__navi-3d__" in result.output
async def test_a_tool_with_no_parameters_says_so(self, manuals):
out = await self.render(manuals, {})
assert "takes no parameters" in out
async def test_a_nested_object_is_spelled_out(self, manuals):
out = await self.render(
manuals,
{
"properties": {
"filter": {
"type": "object",
"description": "Restrict the search",
"properties": {
"limit": {"type": "integer", "description": "How many"},
"tag": {"type": "string"},
},
"required": ["limit"],
}
}
},
)
assert "- `filter` (object, optional): Restrict the search" in out
assert "`limit` (integer, required): How many" in out
assert "`tag` (string, optional)" in out
async def test_an_array_of_objects_shows_the_item_shape(self, manuals):
out = await self.render(
manuals,
{
"properties": {
"entries": {
"type": "array",
"description": "Rows to write",
"items": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Key"},
"value": {"type": "number"},
},
"required": ["name"],
},
}
}
},
)
assert "(array of object, optional): Rows to write" in out
assert "each item:" in out
assert "`name` (string, required): Key" in out
assert "`value` (number, optional)" in out
async def test_an_array_of_scalars_names_the_item_type(self, manuals):
out = await self.render(manuals, {"properties": {"tags": {"type": "array", "items": {"type": "string"}}}})
assert "(array of string, optional)" in out
async def test_enum_and_default_are_shown(self, manuals):
out = await self.render(
manuals,
{
"properties": {
"action": {
"type": "string",
"description": "What to do",
"enum": ["add", "remove"],
"default": "add",
}
}
},
)
assert "one of: 'add', 'remove'" in out
assert "default: 'add'" in out
async def test_a_union_is_rendered_with_its_branches(self, manuals):
out = await self.render(
manuals,
{
"properties": {
"target": {
"description": "Where to write",
"oneOf": [
{"type": "string", "description": "A path"},
{"type": "array", "items": {"type": "string"}},
],
}
}
},
)
assert "(string | array of string, optional): Where to write" in out
assert "oneOf — one of:" in out
assert "• string — A path" in out
async def test_deep_nesting_is_capped_not_unbounded(self, manuals):
"""A pathological schema must not turn the manual into an infinite tree."""
spec = {"type": "string", "description": "bottom"}
for level in range(12):
spec = {
"type": "object",
"properties": {"next": spec},
"description": f"level {level}",
}
out = await self.render(manuals, {"properties": {"root": spec}})
assert "nested deeper" in out
assert out.count("- `next`") <= 6
class TestProfileScope:
async def test_a_registered_tool_warns_it_is_not_enabled_for_the_profile(self, manuals):
registry = registry_with(FakeTool("ssh_exec", description="Run a remote command"))
profiles = profiles_with(profile_with("secretary", native=["filesystem"]))
result = await build(registry=registry, profiles=profiles).execute(
{"tool_name": "ssh_exec"}, ctx=ToolContext(profile_id="secretary")
)
# Still documents it — the hint must not turn into a refusal — but says why
# calling it would fail.
assert result.success is True
assert "not enabled for profile 'secretary'" in result.output
assert "Run a remote command" in result.output
async def test_a_tool_only_in_the_subagent_scope_is_not_called_disabled(self, manuals):
"""A sub-agent asking about its own tool must not be told it lacks it."""
registry = registry_with(FakeTool("ssh_exec", description="Run a remote command"))
profiles = profiles_with(profile_with("developer", subagent_native=["ssh_exec"]))
result = await build(registry=registry, profiles=profiles).execute(
{"tool_name": "ssh_exec"}, ctx=ToolContext(profile_id="developer")
)
assert "not enabled" not in result.output
assert "Run a remote command" in result.output
async def test_without_an_active_profile_a_registered_tool_is_still_documented(self, manuals):
registry = registry_with(FakeTool("ssh_exec", description="Run a remote command"))
result = await build(registry=registry).execute({"tool_name": "ssh_exec"})
assert result.success is True
assert "not enabled for this run" in result.output
assert "Run a remote command" in result.output
class TestNoArgument:
"""The old code did params["tool_name"].strip() — KeyError when the model omits
the key, AttributeError when it sends null or a number. Neither is an answer, so
a missing name now returns the catalogue of what is documented."""
async def test_no_name_gives_the_catalogue_of_manuals(self, manuals):
(manuals / "filesystem.md").write_text("# filesystem\n")
(manuals / "todo.md").write_text("# todo\n")
result = await build().execute({})
assert result.success is True
assert "filesystem" in result.output
assert "todo" in result.output
assert "tool_name" in result.output
@pytest.mark.parametrize("bad", [None, 42, [], {"a": 1}])
async def test_a_non_string_name_is_not_a_crash(self, manuals, bad):
(manuals / "filesystem.md").write_text("# filesystem\n")
result = await build().execute({"tool_name": bad})
assert result.success is True
assert "filesystem" in result.output
async def test_a_whitespace_name_is_not_a_crash(self, manuals):
(manuals / "filesystem.md").write_text("# filesystem\n")
result = await build().execute({"tool_name": " "})
assert result.success is True
assert "filesystem" in result.output
async def test_no_manuals_at_all_is_not_a_crash(self, manuals):
result = await build().execute({})
assert result.success is False
assert result.error == "no_manuals"
class TestIndex:
async def test_manuals_are_grouped_by_source(self, manuals):
"""A manual is filed under the tool's name — which source it belongs to comes
from the registry, not from how the file happens to be spelled."""
(manuals / "filesystem.md").write_text("# filesystem\n")
(manuals / "web_search.md").write_text("# web_search\n")
registry = registry_with(
FakeTool("filesystem"), FakeTool("mcp__navi-web__web_search")
)
result = await build(registry=registry).execute({})
assert "native (1): filesystem" in result.output
assert "mcp__navi-web__ (1): web_search" in result.output
async def test_guides_are_listed_apart_from_tools(self, manuals):
(manuals / "filesystem.md").write_text("# filesystem\n")
(manuals / "guides" / "write_context_provider.md").write_text("# guide\n")
result = await build().execute({})
assert "guides (not tools; readable by name): write_context_provider" in result.output
assert "write_context_provider" not in result.output.split("guides")[0]
async def test_the_index_says_other_tools_still_have_a_manual(self, manuals):
(manuals / "filesystem.md").write_text("# filesystem\n")
result = await build().execute({})
assert "generated from its schema" in result.output
class TestMiss:
async def test_a_typo_gets_a_suggestion(self, manuals):
registry = registry_with(FakeTool("filesystem", description="Files"))
result = await build(registry=registry).execute({"tool_name": "filesistem"})
assert result.success is False
assert result.error == "not_found"
assert "filesystem" in result.output
async def test_a_typo_in_a_bare_mcp_name_gets_a_suggestion(self, manuals):
registry = registry_with(FakeTool("mcp__navi-web__web_search", description="Search"))
result = await build(registry=registry).execute({"tool_name": "web_serch"})
assert "web_search" in result.output
async def test_a_hopeless_name_still_points_at_list_tools(self, manuals):
result = await build().execute({"tool_name": "zzzz_qqqq_nonsense"})
assert result.success is False
assert "list_tools" in result.output