Files
LangBot/tests/unit_tests/provider/test_tool_manager_native.py
Junyan Qin 18ad51e21e test: repair stale skill/sandbox tests for feat/sandbox
The skill subsystem moved to Tool-Call activation and a Box-managed
skill store; several tests still asserted removed APIs and a sys.modules
stub leaked across the suite. Full unit suite now green (was 23 failing).

- test_skill_tools: drop TestSkillManagerActivation (text-marker API
  removed); rewrite TestSkillActivationHelper around the current
  skill.activation.register_activated_skill; replace the CRUD
  TestSkillAuthoringToolLoader with TestSkillToolLoader covering the
  current activate/register_skill tools and sandbox-availability gating
- test_tool_manager_native: ToolManager attr is skill_tool_loader (not
  skill_authoring_tool_loader); native loader now exposes 6 tools
  (exec/read/write/edit/glob/grep) and requires initialize() with a
  backend-available get_status()
- test_localagent_sandbox_exec: remove obsolete activation-marker
  leakage tests and their helper providers
- test_model_service / pipeline conftest: give the mocks skill_mgr=None
  so PreProcessor's local-agent skill-binding guard short-circuits
- test_n8nsvapi: stop permanently overwriting sys.modules
  ('langbot.pkg.provider.runner' etc.); save and restore around the
  import so other modules get the real LocalAgentRunner base class

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 22:56:25 +08:00

251 lines
8.5 KiB
Python

from __future__ import annotations
import os
import tempfile
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot.pkg.provider.tools.loaders.native import NativeToolLoader
from langbot.pkg.provider.tools.toolmgr import ToolManager
class StubLoader:
def __init__(self, tools: list[resource_tool.LLMTool] | None = None, invoke_result=None):
self._tools = tools or []
self._invoke_result = invoke_result
async def get_tools(self, *_args, **_kwargs):
return self._tools
async def has_tool(self, name: str) -> bool:
return any(tool.name == name for tool in self._tools)
async def invoke_tool(self, name: str, parameters: dict, query):
return self._invoke_result(name, parameters, query) if callable(self._invoke_result) else self._invoke_result
async def shutdown(self):
return None
def make_tool(name: str) -> resource_tool.LLMTool:
return resource_tool.LLMTool(
name=name,
human_desc=name,
description=name,
parameters={'type': 'object', 'properties': {}},
func=lambda parameters: parameters,
)
@pytest.mark.asyncio
async def test_tool_manager_omits_skill_authoring_tools_by_default():
manager = ToolManager(SimpleNamespace())
manager.native_tool_loader = StubLoader([make_tool('exec')])
manager.skill_tool_loader = StubLoader([make_tool('activate')])
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
tools = await manager.get_all_tools()
assert [tool.name for tool in tools] == ['exec', 'plugin_tool', 'mcp_tool']
@pytest.mark.asyncio
async def test_tool_manager_includes_skill_authoring_tools_when_requested():
manager = ToolManager(SimpleNamespace())
manager.native_tool_loader = StubLoader([make_tool('exec')])
manager.skill_tool_loader = StubLoader([make_tool('activate')])
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
tools = await manager.get_all_tools(include_skill_authoring=True)
assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool']
@pytest.mark.asyncio
async def test_tool_manager_routes_native_tool_calls():
app = SimpleNamespace()
manager = ToolManager(app)
manager.native_tool_loader = StubLoader([make_tool('exec')], invoke_result={'backend': 'fake'})
manager.skill_tool_loader = StubLoader([make_tool('activate')])
manager.plugin_tool_loader = StubLoader([make_tool('plugin_tool')])
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
result = await manager.execute_func_call('exec', {'command': 'pwd'}, query=Mock())
assert result == {'backend': 'fake'}
@pytest.mark.asyncio
async def test_native_tool_loader_hides_tools_when_box_unavailable():
loader = NativeToolLoader(SimpleNamespace(box_service=SimpleNamespace(available=False)))
assert await loader.get_tools() == []
for tool_name in ('exec', 'read', 'write', 'edit', 'glob', 'grep'):
assert await loader.has_tool(tool_name) is False
@pytest.mark.asyncio
async def test_native_tool_loader_exposes_all_tools_when_box_available():
box_service = SimpleNamespace(
available=True,
get_status=AsyncMock(return_value={'backend': {'available': True}}),
)
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
await loader.initialize()
tools = await loader.get_tools()
assert [tool.name for tool in tools] == ['exec', 'read', 'write', 'edit', 'glob', 'grep']
for tool_name in ('exec', 'read', 'write', 'edit', 'glob', 'grep'):
assert await loader.has_tool(tool_name) is True
# ── read/write/edit file tool tests ─────────────────────────────
def _make_loader_with_workspace(tmpdir: str) -> tuple[NativeToolLoader, Mock]:
logger = Mock()
box_service = SimpleNamespace(available=True, default_workspace=tmpdir)
ap = SimpleNamespace(box_service=box_service, logger=logger)
return NativeToolLoader(ap), logger
def _make_query() -> Mock:
q = Mock()
q.query_id = 'test-query-1'
return q
@pytest.mark.asyncio
async def test_read_file():
with tempfile.TemporaryDirectory() as tmpdir:
loader, _ = _make_loader_with_workspace(tmpdir)
with open(os.path.join(tmpdir, 'hello.txt'), 'w') as f:
f.write('hello world')
result = await loader.invoke_tool('read', {'path': '/workspace/hello.txt'}, _make_query())
assert result['ok'] is True
assert result['content'] == 'hello world'
@pytest.mark.asyncio
async def test_read_nonexistent_file():
with tempfile.TemporaryDirectory() as tmpdir:
loader, _ = _make_loader_with_workspace(tmpdir)
result = await loader.invoke_tool('read', {'path': '/workspace/no_such.txt'}, _make_query())
assert result['ok'] is False
assert 'not found' in result['error'].lower()
@pytest.mark.asyncio
async def test_read_directory():
with tempfile.TemporaryDirectory() as tmpdir:
loader, _ = _make_loader_with_workspace(tmpdir)
os.makedirs(os.path.join(tmpdir, 'subdir'))
with open(os.path.join(tmpdir, 'a.txt'), 'w') as f:
f.write('a')
result = await loader.invoke_tool('read', {'path': '/workspace'}, _make_query())
assert result['ok'] is True
assert result['is_directory'] is True
assert 'a.txt' in result['content']
@pytest.mark.asyncio
async def test_write_creates_file():
with tempfile.TemporaryDirectory() as tmpdir:
loader, _ = _make_loader_with_workspace(tmpdir)
result = await loader.invoke_tool(
'write', {'path': '/workspace/new.txt', 'content': 'new content'}, _make_query()
)
assert result['ok'] is True
with open(os.path.join(tmpdir, 'new.txt')) as f:
assert f.read() == 'new content'
@pytest.mark.asyncio
async def test_write_creates_subdirectories():
with tempfile.TemporaryDirectory() as tmpdir:
loader, _ = _make_loader_with_workspace(tmpdir)
result = await loader.invoke_tool(
'write', {'path': '/workspace/sub/deep/file.txt', 'content': 'nested'}, _make_query()
)
assert result['ok'] is True
with open(os.path.join(tmpdir, 'sub', 'deep', 'file.txt')) as f:
assert f.read() == 'nested'
@pytest.mark.asyncio
async def test_edit_replaces_unique_string():
with tempfile.TemporaryDirectory() as tmpdir:
loader, _ = _make_loader_with_workspace(tmpdir)
with open(os.path.join(tmpdir, 'code.py'), 'w') as f:
f.write('def foo():\n return 1\n')
result = await loader.invoke_tool(
'edit',
{'path': '/workspace/code.py', 'old_string': 'return 1', 'new_string': 'return 42'},
_make_query(),
)
assert result['ok'] is True
with open(os.path.join(tmpdir, 'code.py')) as f:
assert f.read() == 'def foo():\n return 42\n'
@pytest.mark.asyncio
async def test_edit_rejects_ambiguous_match():
with tempfile.TemporaryDirectory() as tmpdir:
loader, _ = _make_loader_with_workspace(tmpdir)
with open(os.path.join(tmpdir, 'dup.txt'), 'w') as f:
f.write('aaa\naaa\n')
result = await loader.invoke_tool(
'edit',
{'path': '/workspace/dup.txt', 'old_string': 'aaa', 'new_string': 'bbb'},
_make_query(),
)
assert result['ok'] is False
assert '2' in result['error']
@pytest.mark.asyncio
async def test_edit_rejects_missing_string():
with tempfile.TemporaryDirectory() as tmpdir:
loader, _ = _make_loader_with_workspace(tmpdir)
with open(os.path.join(tmpdir, 'x.txt'), 'w') as f:
f.write('hello')
result = await loader.invoke_tool(
'edit',
{'path': '/workspace/x.txt', 'old_string': 'nope', 'new_string': 'yes'},
_make_query(),
)
assert result['ok'] is False
assert 'not found' in result['error'].lower()
@pytest.mark.asyncio
async def test_path_escape_blocked():
with tempfile.TemporaryDirectory() as tmpdir:
loader, _ = _make_loader_with_workspace(tmpdir)
with pytest.raises(ValueError, match='escapes'):
await loader.invoke_tool('read', {'path': '/workspace/../../etc/passwd'}, _make_query())