4b6817381b
Benchmark image — build + push to ECR (any adapter) / build + push (push) Waiting to run
CI / quality (ubuntu-latest) (push) Waiting to run
CI / test (tools-runtime) (push) Waiting to run
CI / test (e2e-general) (push) Waiting to run
CI / test (cli-runtime) (push) Waiting to run
CI / test (e2e-provider-and-openclaw) (push) Waiting to run
CI / test (integrations-and-misc) (push) Waiting to run
CI / coverage-report (push) Blocked by required conditions
CI / test-kubernetes (push) Waiting to run
CI / should-run-thorough (push) Waiting to run
CI / test-thorough (cloudwatch-demo) (push) Blocked by required conditions
CI / test-thorough (flink-ecs) (push) Blocked by required conditions
CI / test-thorough (upstream-lambda) (push) Blocked by required conditions
CI / test-thorough (prefect-ecs-fargate) (push) Blocked by required conditions
CodeQL / Analyze (python) (push) Waiting to run
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Blocked by required conditions
Release / publish-release (push) Blocked by required conditions
Release / publish-main-release (push) Blocked by required conditions
Release / prepare (push) Waiting to run
Release / verify (push) Blocked by required conditions
Release / build-python-dist (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Blocked by required conditions
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Blocked by required conditions
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Waiting to run
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Waiting to run
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
325 lines
12 KiB
Python
325 lines
12 KiB
Python
"""Tests for the registered-tool catalog used by the ``/tools list`` slash command."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
from collections.abc import Iterator
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from rich.console import Console
|
|
|
|
from core.tool_framework.registered_tool import RegisteredTool
|
|
from surfaces.interactive_shell.command_registry import dispatch_slash
|
|
from surfaces.interactive_shell.command_registry.tools_cmds import _TOOLS_FIRST_ARGS, _cmd_tools
|
|
from surfaces.interactive_shell.session import Session
|
|
from surfaces.interactive_shell.ui.tables import tool_catalog
|
|
from surfaces.interactive_shell.ui.tables.tool_catalog import (
|
|
ToolCatalogEntry,
|
|
_summarize_input_schema,
|
|
build_tool_catalog,
|
|
format_tool_catalog_text,
|
|
)
|
|
|
|
|
|
def _make_tool(
|
|
name: str,
|
|
*,
|
|
description: str = "Tool description.",
|
|
surfaces: tuple[str, ...] = ("investigation",),
|
|
input_schema: dict[str, Any] | None = None,
|
|
origin_module: str = "tools.registry",
|
|
) -> RegisteredTool:
|
|
"""Construct a minimal RegisteredTool stub for catalog rendering tests."""
|
|
return RegisteredTool(
|
|
name=name,
|
|
description=description,
|
|
input_schema=input_schema or {"type": "object", "properties": {}, "required": []},
|
|
source="knowledge",
|
|
run=lambda **_: None,
|
|
surfaces=surfaces,
|
|
origin_module=origin_module,
|
|
origin_name=name,
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def fake_registry(monkeypatch: pytest.MonkeyPatch) -> Iterator[list[RegisteredTool]]:
|
|
"""Replace the live registry with a curated set so tests are deterministic."""
|
|
tools: list[RegisteredTool] = []
|
|
|
|
def _fake_get_registered_tools(surface: str | None = None) -> list[RegisteredTool]:
|
|
if surface is None:
|
|
return list(tools)
|
|
return [t for t in tools if surface in t.surfaces]
|
|
|
|
monkeypatch.setattr(tool_catalog, "get_registered_tools", _fake_get_registered_tools)
|
|
yield tools
|
|
|
|
|
|
class TestSummarizeInputSchema:
|
|
def test_empty_schema_renders_no_params(self) -> None:
|
|
assert _summarize_input_schema({}) == "(no params)"
|
|
assert (
|
|
_summarize_input_schema({"type": "object", "properties": {}, "required": []})
|
|
== "(no params)"
|
|
)
|
|
|
|
def test_required_params_render_without_question_mark(self) -> None:
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
|
|
"required": ["query", "limit"],
|
|
}
|
|
assert _summarize_input_schema(schema) == "query: string, limit: integer"
|
|
|
|
def test_optional_params_get_question_mark_suffix(self) -> None:
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}, "limit": {"type": "integer"}},
|
|
"required": ["query"],
|
|
}
|
|
assert _summarize_input_schema(schema) == "query: string, limit?: integer"
|
|
|
|
def test_untyped_property_renders_as_any(self) -> None:
|
|
schema = {
|
|
"type": "object",
|
|
"properties": {"cloudops_backend": {}},
|
|
"required": ["cloudops_backend"],
|
|
}
|
|
assert _summarize_input_schema(schema) == "cloudops_backend: any"
|
|
|
|
def test_overlong_summary_is_truncated_with_ellipsis(self) -> None:
|
|
# Build a schema large enough to exceed the 200-char cap.
|
|
properties = {f"param_{i}": {"type": "string"} for i in range(40)}
|
|
schema = {
|
|
"type": "object",
|
|
"properties": properties,
|
|
"required": list(properties.keys()),
|
|
}
|
|
rendered = _summarize_input_schema(schema)
|
|
assert len(rendered) <= 200
|
|
assert rendered.endswith("…")
|
|
|
|
|
|
class TestBuildToolCatalog:
|
|
def test_returns_empty_list_when_no_tools(self, fake_registry: list[RegisteredTool]) -> None:
|
|
del fake_registry # unused — registry is empty by default
|
|
assert build_tool_catalog() == []
|
|
|
|
def test_projects_each_tool_into_a_catalog_entry(
|
|
self, fake_registry: list[RegisteredTool]
|
|
) -> None:
|
|
fake_registry.append(
|
|
_make_tool(
|
|
"search_github",
|
|
description="Search GitHub code.",
|
|
surfaces=("investigation", "chat"),
|
|
input_schema={
|
|
"type": "object",
|
|
"properties": {"query": {"type": "string"}},
|
|
"required": ["query"],
|
|
},
|
|
)
|
|
)
|
|
entries = build_tool_catalog()
|
|
assert len(entries) == 1
|
|
entry = entries[0]
|
|
assert entry.name == "search_github"
|
|
assert entry.surfaces == ("investigation", "chat")
|
|
assert entry.description == "Search GitHub code."
|
|
assert entry.input_schema_summary == "query: string"
|
|
# Source file for the registry module resolves to its repo-relative path.
|
|
assert entry.source_file == "tools/registry.py"
|
|
|
|
def test_filters_by_surface_when_provided(self, fake_registry: list[RegisteredTool]) -> None:
|
|
fake_registry.extend(
|
|
[
|
|
_make_tool("inv_only", surfaces=("investigation",)),
|
|
_make_tool("chat_only", surfaces=("chat",)),
|
|
_make_tool("both", surfaces=("investigation", "chat")),
|
|
]
|
|
)
|
|
chat_entries = build_tool_catalog(surface="chat")
|
|
names = {e.name for e in chat_entries}
|
|
assert names == {"chat_only", "both"}
|
|
|
|
def test_unresolvable_origin_module_yields_empty_source_file(
|
|
self, fake_registry: list[RegisteredTool]
|
|
) -> None:
|
|
# A tool that registered but whose origin module no longer imports
|
|
# cleanly (e.g. partial uninstall) must still surface in the catalog.
|
|
fake_registry.append(_make_tool("orphan", origin_module="not.a.real.module"))
|
|
entries = build_tool_catalog()
|
|
assert len(entries) == 1
|
|
assert entries[0].source_file == ""
|
|
|
|
def test_origin_module_outside_repo_returns_absolute_source_path(
|
|
self, fake_registry: list[RegisteredTool], monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
outside = Path("/virtual/site-packages/some_pkg/plugin.py")
|
|
fake_mod = SimpleNamespace(__file__=str(outside))
|
|
|
|
monkeypatch.setattr(tool_catalog.importlib, "import_module", lambda _: fake_mod)
|
|
fake_registry.append(_make_tool("ext_tool", origin_module="some_pkg.plugin"))
|
|
entries = build_tool_catalog()
|
|
assert len(entries) == 1
|
|
assert entries[0].source_file == outside.as_posix()
|
|
|
|
|
|
class TestFormatToolCatalogText:
|
|
def test_returns_empty_string_for_empty_catalog(self) -> None:
|
|
assert format_tool_catalog_text([]) == ""
|
|
|
|
def test_groups_by_surface_with_investigation_first(self) -> None:
|
|
entries = [
|
|
ToolCatalogEntry(
|
|
name="alpha",
|
|
surfaces=("investigation",),
|
|
description="alpha desc",
|
|
source_file="tools/alpha.py",
|
|
input_schema_summary="(no params)",
|
|
),
|
|
ToolCatalogEntry(
|
|
name="beta",
|
|
surfaces=("chat",),
|
|
description="beta desc",
|
|
source_file="tools/beta.py",
|
|
input_schema_summary="x: string",
|
|
),
|
|
]
|
|
text = format_tool_catalog_text(entries)
|
|
# Investigation header must precede chat header (canonical ordering).
|
|
inv_pos = text.find("## investigation")
|
|
chat_pos = text.find("## chat")
|
|
assert inv_pos != -1 and chat_pos != -1
|
|
assert inv_pos < chat_pos
|
|
assert "alpha" in text and "beta" in text
|
|
|
|
def test_dual_surface_tool_appears_under_each_surface(self) -> None:
|
|
entries = [
|
|
ToolCatalogEntry(
|
|
name="dual",
|
|
surfaces=("investigation", "chat"),
|
|
description="dual desc",
|
|
source_file="tools/dual.py",
|
|
input_schema_summary="(no params)",
|
|
)
|
|
]
|
|
text = format_tool_catalog_text(entries)
|
|
# Dual-surface tools surface in BOTH groups so the user can tell which
|
|
# tools the chat agent vs the investigation pipeline can reach.
|
|
assert text.count("**dual**") == 2
|
|
assert "## investigation (1 tool)" in text
|
|
assert "## chat (1 tool)" in text
|
|
|
|
def test_omits_source_line_when_source_file_unknown(self) -> None:
|
|
entries = [
|
|
ToolCatalogEntry(
|
|
name="orphan",
|
|
surfaces=("investigation",),
|
|
description="orphan desc",
|
|
source_file="",
|
|
input_schema_summary="(no params)",
|
|
)
|
|
]
|
|
text = format_tool_catalog_text(entries)
|
|
assert "**orphan**" in text
|
|
assert "source:" not in text
|
|
|
|
|
|
class TestListToolsSlashCommand:
|
|
"""``/tools list`` reaches the catalog and prints non-empty output."""
|
|
|
|
def _capture(self) -> tuple[Console, io.StringIO]:
|
|
buf = io.StringIO()
|
|
return Console(file=buf, force_terminal=False, highlight=False), buf
|
|
|
|
def test_list_tools_prints_grouped_catalog(self) -> None:
|
|
console, buf = self._capture()
|
|
session = Session()
|
|
# Stub the catalog so the test stays decoupled from registry contents.
|
|
fake = [
|
|
ToolCatalogEntry(
|
|
name="search_github",
|
|
surfaces=("investigation", "chat"),
|
|
description="Search GitHub code.",
|
|
source_file="tools/search_github.py",
|
|
input_schema_summary="query: string",
|
|
)
|
|
]
|
|
with patch(
|
|
"surfaces.interactive_shell.command_registry.tools_cmds.build_tool_catalog",
|
|
return_value=fake,
|
|
):
|
|
assert _cmd_tools(session, console, ["list"]) is True
|
|
out = buf.getvalue()
|
|
assert "search_github" in out
|
|
assert "investigation" in out
|
|
assert "chat" in out
|
|
assert "Search GitHub code." in out
|
|
|
|
def test_bare_tools_command_prints_full_registered_catalog(self) -> None:
|
|
console, buf = self._capture()
|
|
session = Session()
|
|
fake = [
|
|
ToolCatalogEntry(
|
|
name="telegram_send_message",
|
|
surfaces=("investigation", "chat"),
|
|
description="Send a Telegram message.",
|
|
source_file="tools/telegram_send_message_tool/tool.py",
|
|
input_schema_summary="message: string",
|
|
)
|
|
]
|
|
with patch(
|
|
"surfaces.interactive_shell.command_registry.tools_cmds.build_tool_catalog",
|
|
return_value=fake,
|
|
) as catalog:
|
|
assert dispatch_slash("/tools", session, console) is True
|
|
catalog.assert_called_once_with()
|
|
out = buf.getvalue()
|
|
assert "telegram_send_message" in out
|
|
assert "investigation" in out
|
|
assert "chat" in out
|
|
|
|
def test_live_catalog_includes_telegram_send_message(self) -> None:
|
|
names = {entry.name for entry in build_tool_catalog()}
|
|
assert "telegram_send_message" in names
|
|
|
|
def test_list_tools_disables_markup_for_plain_catalog_text(self) -> None:
|
|
console, buf = self._capture()
|
|
session = Session()
|
|
fake = [
|
|
ToolCatalogEntry(
|
|
name="risky_tool",
|
|
surfaces=("investigation",),
|
|
description="Payload [bold]injection[/bold] attempt",
|
|
source_file="tools/risky.py",
|
|
input_schema_summary="x: string",
|
|
)
|
|
]
|
|
with patch(
|
|
"surfaces.interactive_shell.command_registry.tools_cmds.build_tool_catalog",
|
|
return_value=fake,
|
|
):
|
|
assert _cmd_tools(session, console, ["list"]) is True
|
|
out = buf.getvalue()
|
|
assert "[bold]injection[/bold]" in out
|
|
|
|
def test_list_tools_handles_empty_registry(self) -> None:
|
|
console, buf = self._capture()
|
|
session = Session()
|
|
with patch(
|
|
"surfaces.interactive_shell.command_registry.tools_cmds.build_tool_catalog",
|
|
return_value=[],
|
|
):
|
|
assert _cmd_tools(session, console, ["list"]) is True
|
|
assert "no tools registered" in buf.getvalue()
|
|
|
|
def test_tools_first_args_advertise_list_for_tab_completion(self) -> None:
|
|
names = {arg for arg, _hint in _TOOLS_FIRST_ARGS}
|
|
assert "list" in names
|