mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-31 08:13:11 +03:00
Move blocking filesystem, persistence, subprocess, media, and DNS work off the gateway event loop while preserving existing contracts. Add bounded cancellation and responsiveness regression coverage.
255 lines
8.1 KiB
Python
255 lines
8.1 KiB
Python
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from nanobot.agent.tools import cli_apps as cli_apps_tool
|
|
from nanobot.agent.tools.cli_apps import CliAppsTool
|
|
from nanobot.agent.tools.context import RequestContext
|
|
from nanobot.apps.cli.service import CliAppManager, CliAppsRuntimeConfig
|
|
|
|
|
|
def _write_cache(path: Path, registry: dict) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(
|
|
json.dumps({"_cached_at": time.time(), "data": registry}),
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def test_run_cli_app_uses_installed_registry_app(
|
|
tmp_path: Path,
|
|
monkeypatch,
|
|
) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
data_dir = tmp_path / "data"
|
|
registry = {
|
|
"meta": {"updated": "2026-04-16"},
|
|
"clis": [
|
|
{
|
|
"name": "gimp",
|
|
"display_name": "GIMP",
|
|
"version": "1.0.0",
|
|
"description": "Image editing",
|
|
"category": "image",
|
|
"install_cmd": "pip install cli-anything-gimp",
|
|
"entry_point": "cli-anything-gimp",
|
|
}
|
|
],
|
|
}
|
|
_write_cache(data_dir / "harness_registry_cache.json", registry)
|
|
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
|
_write_cache(data_dir / "extensions_registry_cache.json", {"meta": {}, "clis": []})
|
|
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
|
{"gimp": {"entry_point": "cli-anything-gimp"}}
|
|
)
|
|
resolved = str(tmp_path / "bin" / "cli-anything-gimp")
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.shutil.which",
|
|
lambda entry: resolved if entry == "cli-anything-gimp" else None,
|
|
)
|
|
|
|
class _Process:
|
|
returncode = 0
|
|
|
|
async def communicate(self) -> tuple[bytes, bytes]:
|
|
return b"tool:--json project list", b""
|
|
|
|
async def fake_create_subprocess_exec(*argv: str, **kwargs: object) -> _Process:
|
|
assert argv == (resolved, "--json", "project", "list")
|
|
assert "shell" not in kwargs
|
|
return _Process()
|
|
|
|
monkeypatch.setattr(
|
|
"nanobot.apps.cli.service.asyncio.create_subprocess_exec",
|
|
fake_create_subprocess_exec,
|
|
)
|
|
monkeypatch.setattr("nanobot.apps.cli.service._WindowsJob.create", lambda: None)
|
|
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
|
|
|
tool = CliAppsTool(
|
|
workspace=workspace,
|
|
restrict_to_workspace=True,
|
|
runtime=CliAppsRuntimeConfig(run_timeout=5),
|
|
)
|
|
assert tool.name == "run_cli_app"
|
|
|
|
result = asyncio.run(
|
|
tool.execute(
|
|
name="gimp",
|
|
args=["project", "list"],
|
|
json=True,
|
|
working_dir=str(workspace),
|
|
)
|
|
)
|
|
|
|
assert "CLI app 'gimp' exited 0" in result
|
|
assert "tool:--json project list" in result
|
|
|
|
|
|
def test_run_cli_app_rejects_uninstalled_app(tmp_path: Path, monkeypatch) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
data_dir = tmp_path / "data"
|
|
registry = {
|
|
"meta": {"updated": "2026-04-16"},
|
|
"clis": [
|
|
{
|
|
"name": "gimp",
|
|
"display_name": "GIMP",
|
|
"version": "1.0.0",
|
|
"description": "Image editing",
|
|
"category": "image",
|
|
"install_cmd": "pip install cli-anything-gimp",
|
|
"entry_point": "cli-anything-gimp",
|
|
}
|
|
],
|
|
}
|
|
_write_cache(data_dir / "harness_registry_cache.json", registry)
|
|
_write_cache(data_dir / "public_registry_cache.json", {"meta": {}, "clis": []})
|
|
_write_cache(data_dir / "extensions_registry_cache.json", {"meta": {}, "clis": []})
|
|
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
|
tool = CliAppsTool(workspace=workspace, restrict_to_workspace=True)
|
|
|
|
result = asyncio.run(tool.execute(name="gimp"))
|
|
|
|
assert "not installed" in result
|
|
|
|
|
|
def test_run_cli_app_description_names_only_settings_installed_apps(tmp_path: Path, monkeypatch) -> None:
|
|
workspace = tmp_path / "workspace"
|
|
workspace.mkdir()
|
|
data_dir = tmp_path / "data"
|
|
CliAppManager(workspace=workspace, data_dir=data_dir)._save_installed(
|
|
{"drawio": {"entry_point": "cli-anything-drawio"}}
|
|
)
|
|
monkeypatch.setattr("nanobot.apps.cli.service.get_runtime_subdir", lambda _name: data_dir)
|
|
|
|
tool = CliAppsTool(workspace=workspace)
|
|
|
|
assert "Settings CLI Apps: drawio" in tool.description
|
|
assert "ordinary system CLIs such as git, gh" in tool.description
|
|
|
|
|
|
def test_cli_app_tool_provides_context_only_for_attachment(tmp_path: Path) -> None:
|
|
tool = CliAppsTool(workspace=tmp_path)
|
|
provider = tool.runtime_context_provider()
|
|
assert provider is not None
|
|
|
|
empty = asyncio.run(provider(RequestContext(
|
|
channel="websocket",
|
|
chat_id="chat",
|
|
original_user_text="hello",
|
|
workspace=tmp_path,
|
|
)))
|
|
attached = asyncio.run(provider(RequestContext(
|
|
channel="websocket",
|
|
chat_id="chat",
|
|
original_user_text="use @drawio",
|
|
metadata={
|
|
"cli_apps": [{
|
|
"name": "drawio",
|
|
"entry_point": "cli-anything-drawio",
|
|
}],
|
|
},
|
|
workspace=tmp_path,
|
|
)))
|
|
|
|
assert empty is None
|
|
assert attached is not None
|
|
assert attached.source == "cli_apps"
|
|
assert "CLI App Attachment: @drawio" in attached.content
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_cli_app_uses_threaded_sync_fallback_for_compatible_manager(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
event_loop_thread = threading.get_ident()
|
|
seen: dict[str, object] = {}
|
|
|
|
class CompatibleManager:
|
|
def __init__(
|
|
self,
|
|
*,
|
|
workspace: Path,
|
|
runtime: CliAppsRuntimeConfig,
|
|
) -> None:
|
|
seen["workspace"] = workspace
|
|
seen["runtime"] = runtime
|
|
|
|
def run_async(self, *_args: object, **_kwargs: object) -> str:
|
|
raise AssertionError("a synchronous compatibility method must not be awaited")
|
|
|
|
def run(self, name: str, **kwargs: object) -> str:
|
|
seen["thread"] = threading.get_ident()
|
|
seen["name"] = name
|
|
seen["kwargs"] = kwargs
|
|
return "compatible result"
|
|
|
|
monkeypatch.setattr(cli_apps_tool, "CliAppManager", CompatibleManager)
|
|
runtime = CliAppsRuntimeConfig(run_timeout=7)
|
|
tool = CliAppsTool(workspace=tmp_path, restrict_to_workspace=True, runtime=runtime)
|
|
|
|
result = await tool.execute(
|
|
name="demo",
|
|
args=["show"],
|
|
json=True,
|
|
working_dir=str(tmp_path),
|
|
timeout=3,
|
|
)
|
|
|
|
assert result == "compatible result"
|
|
assert seen["workspace"] == tmp_path
|
|
assert seen["runtime"] is runtime
|
|
assert seen["thread"] != event_loop_thread
|
|
assert seen["name"] == "demo"
|
|
assert seen["kwargs"] == {
|
|
"args": ["show"],
|
|
"json_output": True,
|
|
"working_dir": str(tmp_path),
|
|
"timeout": 3,
|
|
"restrict_to_workspace": True,
|
|
}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_cli_app_preserves_native_manager_cancellation(
|
|
tmp_path: Path,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
started = asyncio.Event()
|
|
cancelled = asyncio.Event()
|
|
|
|
async def fake_run_async(self: CliAppManager, name: str, **_kwargs: object) -> str:
|
|
assert name == "demo"
|
|
started.set()
|
|
try:
|
|
await asyncio.Future()
|
|
except asyncio.CancelledError:
|
|
cancelled.set()
|
|
raise
|
|
raise AssertionError("unreachable")
|
|
|
|
def fail_sync_run(self: CliAppManager, name: str, **_kwargs: object) -> str:
|
|
raise AssertionError(f"native async manager fell back to run({name!r})")
|
|
|
|
monkeypatch.setattr(CliAppManager, "run_async", fake_run_async)
|
|
monkeypatch.setattr(CliAppManager, "run", fail_sync_run)
|
|
tool = CliAppsTool(workspace=tmp_path)
|
|
|
|
task = asyncio.create_task(tool.execute(name="demo"))
|
|
await asyncio.wait_for(started.wait(), timeout=1)
|
|
task.cancel()
|
|
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert cancelled.is_set()
|