Files
AstrBot/tests/test_profile_aware_tools.py
Weilong Liao 0d8e8682db refactor(core): migrate backend backbone from Quart to FastAPI and introduce more OpenAPI (#8688)
* refactor: migrate to fastapi

* structure refactor

* fix: pyright fix

* refactor: improve error handling and public messages in plugin services

* feat(api): refactor API client integration and enhance request handling

- Updated API client configuration to use a dedicated HTTP client.
- Introduced utility functions for generating options, queries, and form data for API requests.
- Refactored multiple API methods to utilize the new utility functions for improved consistency and readability.
- Renamed types for clarity and updated import statements accordingly.

feat(docs): add script to update OpenAPI JSON from YAML spec

- Created a Python script to convert OpenAPI YAML specification to JSON format.
- The script supports customizable input and output paths.
- Ensured the script handles directory creation for output paths and validates the YAML structure.

* fix

* feat(auth): implement rate limiting for v1 login endpoint and enhance request handling

* Refactor dashboard API routers to use legacy_router for backward compatibility

- Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime.
- Updated route definitions to ensure existing endpoints remain functional under the new router structure.
- Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins.
- Added a test case to validate the functionality of the new Quart request context handling in plugin extensions.

* chore: remove cli test

* fix: update dashboard tests for fastapi migration

* chore: satisfy ruff checks

* fix: update openapi api key scopes

* fix: sync config scope chip selection

* fix: restore quart dependency

* docs: clarify quart plugin api compatibility

* docs: update openapi scope documentation

* fix: use singular skill openapi scope

* fix: hide update service exception details

* fix: address fastapi review comments

* fix: address dashboard review findings

* docs: revert unrelated package deployment changes

* docs: update agent api generation guidance

* feat: add plugin page web api helpers

* docs: add plugin page bridge demo

* fix: type plugin upload files

* fix: stabilize plugin page uploads

* fix: type plugin web request proxy

* docs: remove plugin page docs example

* fix: authenticate plugin page SSE bridge
2026-06-14 15:03:26 +08:00

291 lines
11 KiB
Python

"""Tests for profile-aware sandbox selection and conditional tool registration."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
# ═══════════════════════════════════════════════════════════════
# ShipyardNeoBooter.capabilities
# ═══════════════════════════════════════════════════════════════
class TestShipyardNeoBooterCapabilities:
"""Test capabilities property on ShipyardNeoBooter."""
def _make_booter(self, sandbox_caps: list[str] | None = None):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
booter = ShipyardNeoBooter(
endpoint_url="http://localhost:8114",
access_token="sk-bay-test",
)
if sandbox_caps is not None:
booter._sandbox = SimpleNamespace(capabilities=sandbox_caps)
return booter
def test_none_before_boot(self):
booter = self._make_booter()
assert booter.capabilities is None
def test_returns_tuple_after_boot(self):
booter = self._make_booter(["python", "shell", "filesystem"])
assert booter.capabilities == ("python", "shell", "filesystem")
assert isinstance(booter.capabilities, tuple)
def test_includes_browser_when_present(self):
booter = self._make_booter(["python", "shell", "filesystem", "browser"])
assert "browser" in booter.capabilities
def test_no_browser_when_absent(self):
booter = self._make_booter(["python", "shell", "filesystem"])
assert "browser" not in booter.capabilities
def test_returns_immutable(self):
"""Verify capabilities returns an immutable tuple."""
booter = self._make_booter(["python"])
caps = booter.capabilities
assert isinstance(caps, tuple)
with pytest.raises(AttributeError):
caps.append("mutated") # type: ignore[attr-defined]
# ═══════════════════════════════════════════════════════════════
# _apply_sandbox_tools — conditional browser tool registration
# ═══════════════════════════════════════════════════════════════
def _make_config(booter_type: str = "shipyard_neo"):
return SimpleNamespace(
sandbox_cfg={"booter": booter_type},
)
def _make_req():
return SimpleNamespace(func_tool=None, system_prompt="")
def _import_apply_sandbox_tools():
"""Import _apply_sandbox_tools, skipping if circular-import fails."""
try:
from astrbot.core.astr_main_agent import _apply_sandbox_tools
return _apply_sandbox_tools
except ImportError:
pytest.skip("Cannot import _apply_sandbox_tools (circular import in test env)")
class TestApplySandboxToolsConditional:
"""Verify browser tools are conditionally registered."""
def _tool_names(self, req) -> set[str]:
"""Extract tool names from a request's func_tool."""
if req.func_tool is None:
return set()
return {t.name for t in req.func_tool.tools}
def test_no_session_registers_all(self):
"""First request (no booted session) → all tools including browser."""
fn = _import_apply_sandbox_tools()
config = _make_config("shipyard_neo")
req = _make_req()
with patch("astrbot.core.computer.computer_client.session_booter", {}):
fn(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_execute_browser" in names
assert "astrbot_execute_browser_batch" in names
assert "astrbot_run_browser_skill" in names
def test_with_browser_capability(self):
"""Booted session with browser capability → browser tools registered."""
fn = _import_apply_sandbox_tools()
config = _make_config("shipyard_neo")
req = _make_req()
fake_booter = SimpleNamespace(
capabilities=["python", "shell", "filesystem", "browser"]
)
with patch(
"astrbot.core.computer.computer_client.session_booter",
{"session-1": fake_booter},
):
fn(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_execute_browser" in names
def test_without_browser_capability(self):
"""Booted session WITHOUT browser capability → browser tools NOT registered."""
fn = _import_apply_sandbox_tools()
config = _make_config("shipyard_neo")
req = _make_req()
fake_booter = SimpleNamespace(capabilities=["python", "shell", "filesystem"])
with patch(
"astrbot.core.computer.computer_client.session_booter",
{"session-1": fake_booter},
):
fn(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_execute_browser" not in names
assert "astrbot_execute_browser_batch" not in names
assert "astrbot_run_browser_skill" not in names
# Skill tools should still be registered
assert "astrbot_get_execution_history" in names
def test_skill_tools_always_registered(self):
"""Skill lifecycle tools are registered regardless of capabilities."""
fn = _import_apply_sandbox_tools()
config = _make_config("shipyard_neo")
req = _make_req()
fake_booter = SimpleNamespace(capabilities=["python"])
with patch(
"astrbot.core.computer.computer_client.session_booter",
{"session-1": fake_booter},
):
fn(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_create_skill_candidate" in names
assert "astrbot_promote_skill_candidate" in names
# ═══════════════════════════════════════════════════════════════
# _resolve_profile
# ═══════════════════════════════════════════════════════════════
class TestResolveProfile:
"""Test smart profile selection logic."""
def _make_booter(self, profile: str = ""):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
return ShipyardNeoBooter(
endpoint_url="http://localhost:8114",
access_token="sk-bay-test",
profile=profile,
)
@pytest.mark.asyncio
async def test_user_specified_profile_honoured(self):
"""User explicitly sets a non-default profile → use it directly."""
booter = self._make_booter(profile="browser-python")
client = SimpleNamespace() # list_profiles should NOT be called
result = await booter._resolve_profile(client)
assert result == "browser-python"
@pytest.mark.asyncio
async def test_user_specified_default_profile_honoured(self):
"""User explicitly sets python-default → use it directly."""
booter = self._make_booter(profile="python-default")
client = SimpleNamespace() # list_profiles should NOT be called
result = await booter._resolve_profile(client)
assert result == "python-default"
@pytest.mark.asyncio
async def test_selects_browser_profile(self):
"""When profile is empty, prefer an available profile with browser."""
async def _mock_list_profiles():
return SimpleNamespace(
items=[
SimpleNamespace(
id="python-default",
capabilities=["python", "shell", "filesystem"],
),
SimpleNamespace(
id="browser-python",
capabilities=["python", "shell", "filesystem", "browser"],
),
]
)
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_mock_list_profiles)
result = await booter._resolve_profile(client)
assert result == "browser-python"
@pytest.mark.asyncio
async def test_falls_back_to_default_on_api_error(self):
"""API error → graceful fallback to python-default."""
async def _failing_list_profiles():
raise ConnectionError("Bay unreachable")
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_failing_list_profiles)
result = await booter._resolve_profile(client)
assert result == "python-default"
@pytest.mark.asyncio
async def test_falls_back_on_empty_profiles(self):
"""Empty profile list → python-default."""
async def _empty_list_profiles():
return SimpleNamespace(items=[])
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_empty_list_profiles)
result = await booter._resolve_profile(client)
assert result == "python-default"
@pytest.mark.asyncio
async def test_single_profile_selected(self):
"""Only one profile available → use it."""
async def _single_profile():
return SimpleNamespace(
items=[
SimpleNamespace(
id="python-data",
capabilities=["python", "shell", "filesystem"],
),
]
)
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_single_profile)
result = await booter._resolve_profile(client)
assert result == "python-data"
@pytest.mark.asyncio
async def test_auth_error_not_silenced(self):
"""UnauthorizedError must propagate, not be downgraded to fallback."""
from shipyard_neo.errors import UnauthorizedError
async def _unauthorized_list_profiles():
raise UnauthorizedError("bad token")
booter = self._make_booter()
client = SimpleNamespace(list_profiles=_unauthorized_list_profiles)
with pytest.raises(UnauthorizedError):
await booter._resolve_profile(client)
# ═══════════════════════════════════════════════════════════════
# ComputerBooter base class
# ═══════════════════════════════════════════════════════════════
class TestBaseComputerBooter:
"""Verify base class defaults."""
def test_capabilities_default_none(self):
from astrbot.core.computer.booters.base import ComputerBooter
booter = ComputerBooter()
assert booter.capabilities is None
def test_browser_default_none(self):
from astrbot.core.computer.booters.base import ComputerBooter
booter = ComputerBooter()
assert booter.browser is None