Files
AstrBot/tests/test_booter_decoupling.py
T

413 lines
15 KiB
Python

"""TDD tests for booter decoupling refactoring.
Tests written BEFORE implementation — all should initially FAIL (red).
After each implementation step, the corresponding tests should turn green.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
# ═══════════════════════ Step 1: 常量 ═══════════════════════
class TestBooterConstants:
def test_constants_exist(self):
from astrbot.core.computer.booters.constants import (
BOOTER_BOXLITE,
BOOTER_SHIPYARD,
BOOTER_SHIPYARD_NEO,
)
assert BOOTER_SHIPYARD == "shipyard"
assert BOOTER_SHIPYARD_NEO == "shipyard_neo"
assert BOOTER_BOXLITE == "boxlite"
# ═══════════════════════ Step 2: Prompt 常量 ═══════════════════════
class TestNeoPromptConstants:
def test_neo_file_path_prompt_exists(self):
from astrbot.core.computer.prompts import NEO_FILE_PATH_PROMPT
assert "relative" in NEO_FILE_PATH_PROMPT.lower()
assert "workspace" in NEO_FILE_PATH_PROMPT.lower()
def test_neo_skill_lifecycle_prompt_exists(self):
from astrbot.core.computer.prompts import NEO_SKILL_LIFECYCLE_PROMPT
assert "astrbot_create_skill_payload" in NEO_SKILL_LIFECYCLE_PROMPT
assert "astrbot_promote_skill_candidate" in NEO_SKILL_LIFECYCLE_PROMPT
# ═══════════════════════ Step 3: 基类接口 ═══════════════════════
class TestComputerBooterBaseInterface:
def test_get_default_tools_returns_empty(self):
from astrbot.core.computer.booters.base import ComputerBooter
assert ComputerBooter.get_default_tools() == []
def test_get_tools_delegates_to_class(self):
from astrbot.core.computer.booters.base import ComputerBooter
booter = ComputerBooter()
assert booter.get_tools() == []
def test_get_system_prompt_parts_returns_empty(self):
from astrbot.core.computer.booters.base import ComputerBooter
assert ComputerBooter.get_system_prompt_parts() == []
# ═══════════════════════ Step 4: Booter 子类工具声明 ═══════════════════════
class TestShipyardBooterTools:
def test_get_default_tools_returns_4(self):
from astrbot.core.computer.booters.shipyard import ShipyardBooter
tools = ShipyardBooter.get_default_tools()
assert len(tools) == 4
names = {t.name for t in tools}
assert "astrbot_execute_shell" in names
assert "astrbot_execute_ipython" in names
assert "astrbot_upload_file" in names
assert "astrbot_download_file" in names
def test_get_system_prompt_parts_empty(self):
from astrbot.core.computer.booters.shipyard import ShipyardBooter
assert ShipyardBooter.get_system_prompt_parts() == []
class TestShipyardNeoBooterTools:
def _make_booter(self, caps=None):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
booter = ShipyardNeoBooter(
endpoint_url="http://localhost:8114",
access_token="sk-bay-test",
)
if caps is not None:
booter._sandbox = SimpleNamespace(capabilities=caps)
return booter
def test_get_default_tools_returns_18(self):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
tools = ShipyardNeoBooter.get_default_tools()
assert len(tools) == 18 # 4 base + 11 Neo + 3 browser
names = {t.name for t in tools}
assert "astrbot_execute_browser" in names
assert "astrbot_create_skill_candidate" in names
assert "astrbot_execute_shell" in names
def test_get_tools_no_boot_returns_default(self):
booter = self._make_booter()
tools = booter.get_tools()
assert len(tools) == 18
def test_get_tools_with_browser(self):
booter = self._make_booter(caps=["python", "shell", "filesystem", "browser"])
tools = booter.get_tools()
assert len(tools) == 18
names = {t.name for t in tools}
assert "astrbot_execute_browser" in names
def test_get_tools_without_browser(self):
booter = self._make_booter(caps=["python", "shell", "filesystem"])
tools = booter.get_tools()
assert len(tools) == 15 # no browser
names = {t.name for t in tools}
assert "astrbot_execute_browser" not in names
assert "astrbot_create_skill_candidate" in names
def test_get_system_prompt_parts_has_neo_prompts(self):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
parts = ShipyardNeoBooter.get_system_prompt_parts()
assert len(parts) == 2
combined = "".join(parts)
assert "relative" in combined.lower()
assert "astrbot_create_skill_payload" in combined
class TestBoxliteBooterTools:
def test_get_default_tools_returns_4(self):
pytest.importorskip("boxlite")
from astrbot.core.computer.booters.boxlite import BoxliteBooter
tools = BoxliteBooter.get_default_tools()
assert len(tools) == 4
names = {t.name for t in tools}
assert "astrbot_execute_shell" in names
def test_get_system_prompt_parts_empty(self):
pytest.importorskip("boxlite")
from astrbot.core.computer.booters.boxlite import BoxliteBooter
assert BoxliteBooter.get_system_prompt_parts() == []
# ═══════════════════════ Step 5: computer_client API ═══════════════════════
class TestComputerClientAPI:
def test_get_sandbox_tools_unknown_session(self):
from astrbot.core.computer.computer_client import get_sandbox_tools
with patch("astrbot.core.computer.computer_client.session_booter", {}):
assert get_sandbox_tools("unknown") == []
def test_get_sandbox_tools_with_booted_session(self):
from astrbot.core.computer.computer_client import get_sandbox_tools
fake_booter = SimpleNamespace(
get_tools=lambda: ["tool1", "tool2"],
)
with patch(
"astrbot.core.computer.computer_client.session_booter",
{"s1": fake_booter},
):
assert get_sandbox_tools("s1") == ["tool1", "tool2"]
def test_get_default_sandbox_tools_neo(self):
from astrbot.core.computer.computer_client import get_default_sandbox_tools
tools = get_default_sandbox_tools({"booter": "shipyard_neo"})
assert len(tools) == 18
def test_get_default_sandbox_tools_shipyard(self):
from astrbot.core.computer.computer_client import get_default_sandbox_tools
tools = get_default_sandbox_tools({"booter": "shipyard"})
assert len(tools) == 4
def test_get_default_sandbox_tools_boxlite(self):
pytest.importorskip("boxlite")
from astrbot.core.computer.computer_client import get_default_sandbox_tools
tools = get_default_sandbox_tools({"booter": "boxlite"})
assert len(tools) == 4
def test_get_default_sandbox_tools_unknown_type(self):
from astrbot.core.computer.computer_client import get_default_sandbox_tools
tools = get_default_sandbox_tools({"booter": "nonexistent"})
assert tools == []
def test_get_sandbox_prompt_parts_neo(self):
from astrbot.core.computer.computer_client import get_sandbox_prompt_parts
parts = get_sandbox_prompt_parts({"booter": "shipyard_neo"})
assert len(parts) == 2
def test_get_sandbox_prompt_parts_shipyard(self):
from astrbot.core.computer.computer_client import get_sandbox_prompt_parts
parts = get_sandbox_prompt_parts({"booter": "shipyard"})
assert parts == []
# ═══════════════════════ Step 6+7: 集成测试 ═══════════════════════
class TestApplySandboxToolsRefactored:
"""After refactoring, _apply_sandbox_tools uses unified API."""
def _tool_names(self, req) -> set[str]:
if req.func_tool is None:
return set()
return {t.name for t in req.func_tool.tools}
def _neo_default_tools(self):
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
return ShipyardNeoBooter.get_default_tools()
def _shipyard_default_tools(self):
from astrbot.core.computer.booters.shipyard import ShipyardBooter
return ShipyardBooter.get_default_tools()
def test_neo_tools_registered_via_unified_api(self):
try:
from astrbot.core.astr_main_agent import _apply_sandbox_tools
except ImportError:
pytest.skip("circular import")
config = SimpleNamespace(sandbox_cfg={"booter": "shipyard_neo"})
req = SimpleNamespace(func_tool=None, system_prompt="")
with (
patch(
"astrbot.core.computer.computer_client.get_sandbox_tools",
return_value=[],
),
patch(
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
return_value=self._neo_default_tools(),
),
patch(
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
return_value=[],
),
):
_apply_sandbox_tools(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_create_skill_candidate" in names
assert "astrbot_execute_browser" in names
assert len(names) == 18
def test_neo_prompt_injected(self):
try:
from astrbot.core.astr_main_agent import _apply_sandbox_tools
except ImportError:
pytest.skip("circular import")
config = SimpleNamespace(sandbox_cfg={"booter": "shipyard_neo"})
req = SimpleNamespace(func_tool=None, system_prompt="")
with (
patch(
"astrbot.core.computer.computer_client.get_sandbox_tools",
return_value=[],
),
patch(
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
return_value=[],
),
patch(
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
return_value=[
"\n[Shipyard Neo File Path Rule]\nrelative workspace path\n",
"\n[Neo Skill Lifecycle Workflow]\nastrbot_create_skill_payload\n",
],
),
):
_apply_sandbox_tools(config, req, "session-1")
assert "relative" in req.system_prompt.lower()
assert "astrbot_create_skill_payload" in req.system_prompt
def test_shipyard_no_neo_prompt(self):
try:
from astrbot.core.astr_main_agent import _apply_sandbox_tools
except ImportError:
pytest.skip("circular import")
config = SimpleNamespace(sandbox_cfg={"booter": "shipyard"})
req = SimpleNamespace(func_tool=None, system_prompt="")
with (
patch(
"astrbot.core.computer.computer_client.get_sandbox_tools",
return_value=[],
),
patch(
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
return_value=self._shipyard_default_tools(),
),
patch(
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
return_value=[],
),
):
_apply_sandbox_tools(config, req, "session-1")
names = self._tool_names(req)
assert len(names) == 4
assert "Neo Skill Lifecycle" not in req.system_prompt
def test_booted_session_without_browser(self):
"""Booted session without browser capability → no browser tools."""
try:
from astrbot.core.astr_main_agent import _apply_sandbox_tools
except ImportError:
pytest.skip("circular import")
from astrbot.core.computer.booters.shipyard_neo import ShipyardNeoBooter
fake_booter = ShipyardNeoBooter(
endpoint_url="http://localhost:8114",
access_token="sk-bay-test",
)
fake_booter._sandbox = SimpleNamespace(
capabilities=["python", "shell", "filesystem"]
)
config = SimpleNamespace(sandbox_cfg={"booter": "shipyard_neo"})
req = SimpleNamespace(func_tool=None, system_prompt="")
with (
patch(
"astrbot.core.computer.computer_client.get_sandbox_tools",
return_value=fake_booter.get_tools(),
),
patch(
"astrbot.core.computer.computer_client.get_default_sandbox_tools",
return_value=[],
),
patch(
"astrbot.core.computer.computer_client.get_sandbox_prompt_parts",
return_value=[],
),
):
_apply_sandbox_tools(config, req, "session-1")
names = self._tool_names(req)
assert "astrbot_execute_browser" not in names
assert "astrbot_create_skill_candidate" in names
assert len(names) == 15
class TestSubagentHandoffTools:
"""Subagent should get same tools as main agent."""
def test_sandbox_runtime_gets_neo_tools(self):
try:
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
except ImportError:
pytest.skip("circular import")
with patch("astrbot.core.computer.computer_client.session_booter", {}):
tools = FunctionToolExecutor._get_runtime_computer_tools(
"sandbox",
session_id=None,
sandbox_cfg={"booter": "shipyard_neo"},
)
assert "astrbot_create_skill_candidate" in tools
assert len(tools) == 18
def test_sandbox_runtime_shipyard_only_4(self):
try:
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
except ImportError:
pytest.skip("circular import")
with patch("astrbot.core.computer.computer_client.session_booter", {}):
tools = FunctionToolExecutor._get_runtime_computer_tools(
"sandbox",
session_id=None,
sandbox_cfg={"booter": "shipyard"},
)
assert len(tools) == 4
assert "astrbot_create_skill_candidate" not in tools
def test_sandbox_runtime_empty_config_still_gets_default_tools(self):
try:
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
except ImportError:
pytest.skip("circular import")
tools = FunctionToolExecutor._get_runtime_computer_tools(
"sandbox",
session_id=None,
sandbox_cfg={},
)
assert "astrbot_create_skill_candidate" in tools
assert len(tools) == 18
def test_local_runtime_unchanged(self):
try:
from astrbot.core.astr_agent_tool_exec import FunctionToolExecutor
except ImportError:
pytest.skip("circular import")
tools = FunctionToolExecutor._get_runtime_computer_tools(
"local",
session_id=None,
sandbox_cfg={},
)
assert len(tools) == 2