Files
AstrBot/tests/unit/test_astr_main_agent.py

1944 lines
72 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for astr_main_agent module."""
import os
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from astrbot.core import astr_main_agent as ama
from astrbot.core.agent.mcp_client import MCPTool
from astrbot.core.agent.tool import FunctionTool, ToolSet
from astrbot.core.conversation_mgr import Conversation
from astrbot.core.message.components import File, Image, Plain, Reply, Video
from astrbot.core.platform.astr_message_event import AstrMessageEvent
from astrbot.core.platform.platform_metadata import PlatformMetadata
from astrbot.core.provider import Provider
from astrbot.core.provider.entities import ProviderRequest
from astrbot.core.skills.skill_manager import SkillInfo
from astrbot.core.star.star import StarMetadata
@pytest.fixture
def mock_provider():
"""Create a mock provider."""
provider = MagicMock(spec=Provider)
provider.provider_config = {
"id": "test-provider",
"modalities": ["image", "tool_use"],
}
provider.get_model.return_value = "gpt-4"
return provider
@pytest.fixture
def mock_context():
"""Create a mock Context."""
ctx = MagicMock()
ctx.get_config.return_value = {}
ctx.conversation_manager = MagicMock()
ctx.persona_manager = MagicMock()
ctx.persona_manager.personas_v3 = []
ctx.persona_manager.resolve_selected_persona = AsyncMock(
return_value=(None, None, None, False)
)
ctx.persona_manager.get_persona_v3_by_id = MagicMock(return_value=None)
tool_mgr = MagicMock()
tool_mgr.get_builtin_tool.side_effect = lambda cls, **kwargs: cls(**kwargs)
ctx.get_llm_tool_manager.return_value = tool_mgr
ctx.subagent_orchestrator = None
return ctx
@pytest.fixture
def mock_event():
"""Create a mock AstrMessageEvent."""
platform_meta = PlatformMetadata(
id="test_platform",
name="test_platform",
description="Test platform",
)
message_obj = MagicMock()
message_obj.message = [Plain(text="Hello")]
message_obj.sender = MagicMock(user_id="user123", nickname="TestUser")
message_obj.group_id = None
message_obj.group = None
event = MagicMock(spec=AstrMessageEvent)
event.message_str = "Hello"
event.message_obj = message_obj
event.platform_meta = platform_meta
event.session_id = "session123"
event.unified_msg_origin = "test_platform:private:session123"
event.get_extra.return_value = None
event.get_platform_name.return_value = "test_platform"
event.get_platform_id.return_value = "test_platform"
event.get_group_id.return_value = None
event.get_sender_name.return_value = "TestUser"
event.trace = MagicMock()
event.plugins_name = None
return event
@pytest.fixture
def mock_conversation():
"""Create a mock conversation."""
conv = MagicMock(spec=Conversation)
conv.cid = "conv-id"
conv.persona_id = None
conv.history = "[]"
return conv
def test_provider_supports_modality_requires_explicit_list():
provider = MagicMock(spec=Provider)
provider.provider_config = {"modalities": ["text", "image"]}
assert ama._provider_supports_modality(provider, "image")
provider.provider_config = {"modalities": ["text"]}
assert not ama._provider_supports_modality(provider, "image")
provider.provider_config = {}
assert not ama._provider_supports_modality(provider, "image")
provider.provider_config = {"modalities": "image"}
assert not ama._provider_supports_modality(provider, "image")
@pytest.fixture
def sample_config():
"""Create a sample MainAgentBuildConfig."""
module = ama
return module.MainAgentBuildConfig(
tool_call_timeout=60,
streaming_response=True,
file_extract_enabled=True,
file_extract_prov="moonshotai",
file_extract_msh_api_key="test-api-key",
)
def _new_mock_conversation(cid: str = "conv-id") -> MagicMock:
conv = MagicMock(spec=Conversation)
conv.cid = cid
conv.persona_id = None
conv.history = "[]"
return conv
def _setup_conversation_for_build(conv_mgr, cid: str = "conv-id") -> MagicMock:
conv_mgr.get_curr_conversation_id = AsyncMock(return_value=None)
conv_mgr.new_conversation = AsyncMock(return_value=cid)
conversation = _new_mock_conversation(cid=cid)
conv_mgr.get_conversation = AsyncMock(return_value=conversation)
return conversation
class TestMainAgentBuildConfig:
"""Tests for MainAgentBuildConfig dataclass."""
def test_config_initialization(self):
"""Test MainAgentBuildConfig initialization with defaults."""
module = ama
config = module.MainAgentBuildConfig(tool_call_timeout=60)
assert config.tool_call_timeout == 60
assert config.tool_schema_mode == "full"
assert config.provider_wake_prefix == ""
assert config.streaming_response is True
assert config.sanitize_context_by_modalities is False
assert config.kb_agentic_mode is False
assert config.file_extract_enabled is False
assert config.llm_safety_mode is True
def test_config_with_custom_values(self):
"""Test MainAgentBuildConfig with custom values."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=120,
tool_schema_mode="skills-like",
provider_wake_prefix="/",
streaming_response=False,
kb_agentic_mode=True,
file_extract_enabled=True,
computer_use_runtime="sandbox",
add_cron_tools=False,
)
assert config.tool_call_timeout == 120
assert config.tool_schema_mode == "skills-like"
assert config.provider_wake_prefix == "/"
assert config.streaming_response is False
assert config.kb_agentic_mode is True
assert config.file_extract_enabled is True
assert config.computer_use_runtime == "sandbox"
assert config.add_cron_tools is False
class TestSelectProvider:
"""Tests for _select_provider function."""
def test_select_provider_by_id(self, mock_event, mock_context, mock_provider):
"""Test selecting provider by ID from event extra."""
module = ama
mock_event.get_extra.side_effect = lambda k: (
"test-provider" if k == "selected_provider" else None
)
mock_context.get_provider_by_id.return_value = mock_provider
result = module._select_provider(mock_event, mock_context)
assert result == mock_provider
mock_context.get_provider_by_id.assert_called_once_with("test-provider")
def test_select_provider_not_found(self, mock_event, mock_context):
"""Test selecting provider when ID is not found."""
module = ama
mock_event.get_extra.side_effect = lambda k: (
"non-existent" if k == "selected_provider" else None
)
mock_context.get_provider_by_id.return_value = None
result = module._select_provider(mock_event, mock_context)
assert result is None
mock_event.set_extra.assert_called_with(
module.LLM_ERROR_MESSAGE_EXTRA_KEY,
"LLM 请求失败:未找到指定的提供商 `non-existent`。请检查提供商配置或重新选择可用模型。",
)
def test_select_provider_invalid_type(self, mock_event, mock_context):
"""Test selecting provider when result is not a Provider instance."""
module = ama
mock_event.get_extra.side_effect = lambda k: (
"invalid" if k == "selected_provider" else None
)
mock_context.get_provider_by_id.return_value = "not a provider"
result = module._select_provider(mock_event, mock_context)
assert result is None
mock_event.set_extra.assert_called_with(
module.LLM_ERROR_MESSAGE_EXTRA_KEY,
"LLM 请求失败选择的提供商类型无效str已跳过本次请求。",
)
def test_select_provider_fallback(self, mock_event, mock_context, mock_provider):
"""Test provider selection fallback to using provider."""
module = ama
mock_event.get_extra.return_value = None
mock_context.get_using_provider.return_value = mock_provider
result = module._select_provider(mock_event, mock_context)
assert result == mock_provider
mock_context.get_using_provider.assert_called_once_with(
umo=mock_event.unified_msg_origin
)
def test_select_provider_fallback_error(self, mock_event, mock_context):
"""Test provider selection when fallback raises ValueError."""
module = ama
mock_event.get_extra.return_value = None
mock_context.get_using_provider.side_effect = ValueError("Test error")
result = module._select_provider(mock_event, mock_context)
assert result is None
mock_event.set_extra.assert_called_with(
module.LLM_ERROR_MESSAGE_EXTRA_KEY,
"LLM 请求失败Test error",
)
class TestGetSessionConv:
"""Tests for _get_session_conv function."""
@pytest.mark.asyncio
async def test_get_session_conv_existing(
self, mock_event, mock_context, mock_conversation
):
"""Test getting existing conversation."""
module = ama
conv_mgr = mock_context.conversation_manager
conv_mgr.get_curr_conversation_id = AsyncMock(return_value="existing-conv-id")
conv_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
result = await module._get_session_conv(mock_event, mock_context)
assert result == mock_conversation
conv_mgr.get_curr_conversation_id.assert_called_once_with(
mock_event.unified_msg_origin
)
conv_mgr.get_conversation.assert_called_once_with(
mock_event.unified_msg_origin, "existing-conv-id"
)
@pytest.mark.asyncio
async def test_get_session_conv_create_new(self, mock_event, mock_context):
"""Test creating new conversation when none exists."""
module = ama
conv_mgr = mock_context.conversation_manager
conv_mgr.get_curr_conversation_id = AsyncMock(return_value=None)
conv_mgr.new_conversation = AsyncMock(return_value="new-conv-id")
mock_conversation = MagicMock(spec=Conversation)
mock_conversation.cid = "new-conv-id"
mock_conversation.persona_id = None
mock_conversation.history = "[]"
conv_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
result = await module._get_session_conv(mock_event, mock_context)
assert result == mock_conversation
conv_mgr.new_conversation.assert_called_once_with(
mock_event.unified_msg_origin, mock_event.get_platform_id()
)
@pytest.mark.asyncio
async def test_get_session_conv_retry(self, mock_event, mock_context):
"""Test retrying conversation creation after failure."""
module = ama
conv_mgr = mock_context.conversation_manager
conv_mgr.get_curr_conversation_id = AsyncMock(return_value="conv-id")
conv_mgr.get_conversation = AsyncMock(return_value=None)
conv_mgr.new_conversation = AsyncMock(return_value="retry-conv-id")
mock_conversation = MagicMock(spec=Conversation)
mock_conversation.cid = "retry-conv-id"
mock_conversation.persona_id = None
mock_conversation.history = "[]"
conv_mgr.get_conversation.side_effect = [None, mock_conversation]
result = await module._get_session_conv(mock_event, mock_context)
assert result == mock_conversation
assert conv_mgr.new_conversation.call_count == 1
assert conv_mgr.get_conversation.call_count == 2
@pytest.mark.asyncio
async def test_get_session_conv_failure(self, mock_event, mock_context):
"""Test RuntimeError when conversation creation fails."""
module = ama
conv_mgr = mock_context.conversation_manager
conv_mgr.get_curr_conversation_id = AsyncMock(return_value=None)
conv_mgr.new_conversation = AsyncMock(return_value="new-conv-id")
conv_mgr.get_conversation = AsyncMock(return_value=None)
with pytest.raises(RuntimeError, match="无法创建新的对话。"):
await module._get_session_conv(mock_event, mock_context)
class TestApplyKb:
"""Tests for _apply_kb function."""
@pytest.mark.asyncio
async def test_apply_kb_without_agentic_mode(self, mock_event, mock_context):
"""Test applying knowledge base in non-agentic mode."""
module = ama
req = ProviderRequest(prompt="test question", system_prompt="System prompt")
config = module.MainAgentBuildConfig(
tool_call_timeout=60, kb_agentic_mode=False
)
with patch(
"astrbot.core.astr_main_agent.retrieve_knowledge_base",
AsyncMock(return_value="KB result"),
):
await module._apply_kb(mock_event, req, mock_context, config)
assert "[Related Knowledge Base Results]:" in req.system_prompt
assert "KB result" in req.system_prompt
@pytest.mark.asyncio
async def test_apply_kb_with_agentic_mode(self, mock_event, mock_context):
"""Test applying knowledge base in agentic mode."""
module = ama
req = ProviderRequest(prompt="test question")
config = module.MainAgentBuildConfig(tool_call_timeout=60, kb_agentic_mode=True)
await module._apply_kb(mock_event, req, mock_context, config)
assert req.func_tool is not None
@pytest.mark.asyncio
async def test_apply_kb_no_prompt(self, mock_event, mock_context):
"""Test applying knowledge base when prompt is None."""
module = ama
req = ProviderRequest(prompt=None, system_prompt="System")
config = module.MainAgentBuildConfig(
tool_call_timeout=60, kb_agentic_mode=False
)
await module._apply_kb(mock_event, req, mock_context, config)
assert req.system_prompt == "System"
@pytest.mark.asyncio
@pytest.mark.parametrize("prompt", ["", " \n\t"])
async def test_apply_kb_blank_prompt(self, prompt, mock_event, mock_context):
"""Test applying knowledge base when prompt is blank."""
module = ama
req = ProviderRequest(prompt=prompt, system_prompt="System")
config = module.MainAgentBuildConfig(
tool_call_timeout=60, kb_agentic_mode=False
)
retrieve = AsyncMock(return_value="KB result")
with patch("astrbot.core.astr_main_agent.retrieve_knowledge_base", retrieve):
await module._apply_kb(mock_event, req, mock_context, config)
retrieve.assert_not_awaited()
assert req.system_prompt == "System"
@pytest.mark.asyncio
async def test_apply_kb_no_result(self, mock_event, mock_context):
"""Test applying knowledge base when no result is returned."""
module = ama
req = ProviderRequest(prompt="test", system_prompt="System")
config = module.MainAgentBuildConfig(
tool_call_timeout=60, kb_agentic_mode=False
)
with patch(
"astrbot.core.astr_main_agent.retrieve_knowledge_base",
AsyncMock(return_value=None),
):
await module._apply_kb(mock_event, req, mock_context, config)
assert req.system_prompt == "System"
@pytest.mark.asyncio
async def test_apply_kb_with_existing_tools(self, mock_event, mock_context):
"""Test applying knowledge base with existing toolset."""
module = ama
existing_tools = ToolSet()
req = ProviderRequest(prompt="test", func_tool=existing_tools)
config = module.MainAgentBuildConfig(tool_call_timeout=60, kb_agentic_mode=True)
await module._apply_kb(mock_event, req, mock_context, config)
assert req.func_tool is not None
class TestBuiltinToolInjection:
"""Tests for builtin tool injection paths."""
@pytest.mark.asyncio
async def test_apply_web_search_tools_uses_builtin_tool_manager(
self, mock_event, mock_context
):
"""Test web search tool injection through the builtin tool manager."""
module = ama
req = ProviderRequest()
mock_context.get_config.return_value = {
"provider_settings": {
"web_search": True,
"websearch_provider": "baidu_ai_search",
}
}
builtin_tool = MagicMock(spec=FunctionTool)
builtin_tool.name = "web_search_baidu"
tool_mgr = MagicMock()
tool_mgr.get_builtin_tool.return_value = builtin_tool
mock_context.get_llm_tool_manager.return_value = tool_mgr
await module._apply_web_search_tools(mock_event, req, mock_context)
tool_mgr.get_builtin_tool.assert_called_once_with(module.BaiduWebSearchTool)
assert req.func_tool is not None
assert req.func_tool.get_tool("web_search_baidu") is builtin_tool
@pytest.mark.asyncio
async def test_apply_web_search_tools_adds_firecrawl_search_and_extract_tools(
self, mock_event, mock_context
):
"""Test Firecrawl web search injects search and extract tools."""
module = ama
req = ProviderRequest()
mock_context.get_config.return_value = {
"provider_settings": {
"web_search": True,
"websearch_provider": "firecrawl",
}
}
search_tool = MagicMock(spec=FunctionTool)
search_tool.name = "web_search_firecrawl"
extract_tool = MagicMock(spec=FunctionTool)
extract_tool.name = "firecrawl_extract_web_page"
tool_mgr = MagicMock()
tool_mgr.get_builtin_tool.side_effect = [search_tool, extract_tool]
mock_context.get_llm_tool_manager.return_value = tool_mgr
await module._apply_web_search_tools(mock_event, req, mock_context)
assert tool_mgr.get_builtin_tool.call_args_list == [
((module.FirecrawlWebSearchTool,),),
((module.FirecrawlExtractWebPageTool,),),
]
assert req.func_tool is not None
assert req.func_tool.get_tool("web_search_firecrawl") is search_tool
assert req.func_tool.get_tool("firecrawl_extract_web_page") is extract_tool
def test_proactive_cron_job_tools_uses_builtin_tool_manager(self, mock_context):
"""Test cron tool injection through the builtin tool manager."""
module = ama
req = ProviderRequest()
tool_mgr = MagicMock()
future_task_tool = MagicMock(spec=FunctionTool)
future_task_tool.name = "future_task"
tool_mgr.get_builtin_tool.return_value = future_task_tool
mock_context.get_llm_tool_manager.return_value = tool_mgr
module._proactive_cron_job_tools(req, mock_context)
tool_mgr.get_builtin_tool.assert_called_once_with(module.FutureTaskTool)
assert req.func_tool is not None
assert req.func_tool.get_tool("future_task") is future_task_tool
class TestApplyFileExtract:
"""Tests for _apply_file_extract function."""
@pytest.mark.asyncio
async def test_file_extract_basic(self, mock_event, sample_config):
"""Test basic file extraction."""
module = ama
mock_file = MagicMock(spec=File)
mock_file.name = "test.pdf"
mock_file.get_file = AsyncMock(return_value="/path/to/test.pdf")
mock_event.message_obj.message = [mock_file]
req = ProviderRequest(prompt="Summarize")
with patch(
"astrbot.core.astr_main_agent.extract_file_moonshotai"
) as mock_extract:
mock_extract.return_value = "File content"
await module._apply_file_extract(mock_event, req, sample_config)
assert len(req.contexts) == 1
assert "File Extract Results" in req.contexts[0]["content"]
@pytest.mark.asyncio
async def test_file_extract_no_files(self, mock_event, sample_config):
"""Test file extraction when no files present."""
module = ama
mock_event.message_obj.message = [Plain(text="Hello")]
req = ProviderRequest(prompt="Hello")
await module._apply_file_extract(mock_event, req, sample_config)
assert len(req.contexts) == 0
@pytest.mark.asyncio
async def test_file_extract_in_reply(self, mock_event, sample_config):
"""Test file extraction from reply chain."""
module = ama
mock_file = MagicMock(spec=File)
mock_file.name = "reply.pdf"
mock_file.get_file = AsyncMock(return_value="/path/to/reply.pdf")
mock_reply = MagicMock(spec=Reply)
mock_reply.chain = [mock_file]
mock_event.message_obj.message = [mock_reply]
req = ProviderRequest(prompt="Summarize")
with patch(
"astrbot.core.astr_main_agent.extract_file_moonshotai"
) as mock_extract:
mock_extract.return_value = "Reply content"
await module._apply_file_extract(mock_event, req, sample_config)
assert len(req.contexts) == 1
@pytest.mark.asyncio
async def test_file_extract_no_prompt(self, mock_event, sample_config):
"""Test file extraction when prompt is empty."""
module = ama
mock_file = MagicMock(spec=File)
mock_file.name = "test.pdf"
mock_file.get_file = AsyncMock(return_value="/path/to/test.pdf")
mock_event.message_obj.message = [mock_file]
req = ProviderRequest(prompt=None)
with patch(
"astrbot.core.astr_main_agent.extract_file_moonshotai"
) as mock_extract:
mock_extract.return_value = "Content"
await module._apply_file_extract(mock_event, req, sample_config)
assert req.prompt == "总结一下文件里面讲了什么?"
@pytest.mark.asyncio
async def test_file_extract_no_api_key(self, mock_event):
"""Test file extraction when no API key is configured."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
file_extract_enabled=True,
file_extract_msh_api_key="",
)
mock_file = MagicMock(spec=File)
mock_file.name = "test.pdf"
mock_file.get_file = AsyncMock(return_value="/path/to/test.pdf")
mock_event.message_obj.message = [mock_file]
req = ProviderRequest(prompt="Summarize")
await module._apply_file_extract(mock_event, req, config)
assert len(req.contexts) == 0
class TestEnsurePersonaAndSkills:
"""Tests for _ensure_persona_and_skills function."""
def test_filter_plugin_skills_uses_current_config_plugin_set(self, monkeypatch):
module = ama
monkeypatch.setattr(
module,
"star_registry",
[
StarMetadata(
name="allowed_plugin",
root_dir_name="astrbot_plugin_allowed",
activated=True,
),
StarMetadata(
name="blocked_plugin",
root_dir_name="astrbot_plugin_blocked",
activated=True,
),
],
)
skills = [
SkillInfo(name="local", description="", path="local/SKILL.md", active=True),
SkillInfo(
name="allowed-skill",
description="",
path="allowed/SKILL.md",
active=True,
source_type="plugin",
plugin_name="astrbot_plugin_allowed",
),
SkillInfo(
name="blocked-skill",
description="",
path="blocked/SKILL.md",
active=True,
source_type="plugin",
plugin_name="astrbot_plugin_blocked",
),
]
filtered = module._filter_skills_for_current_config(
skills,
{"plugin_set": ["allowed_plugin"]},
)
assert [skill.name for skill in filtered] == ["local", "allowed-skill"]
def test_filter_plugin_skills_skips_inactive_plugins_even_when_all_allowed(
self, monkeypatch
):
module = ama
monkeypatch.setattr(
module,
"star_registry",
[
StarMetadata(
name="inactive_plugin",
root_dir_name="astrbot_plugin_inactive",
activated=False,
)
],
)
skills = [
SkillInfo(
name="inactive-skill",
description="",
path="inactive/SKILL.md",
active=True,
source_type="plugin",
plugin_name="astrbot_plugin_inactive",
)
]
filtered = module._filter_skills_for_current_config(
skills,
{"plugin_set": ["*"]},
)
assert filtered == []
@pytest.mark.asyncio
async def test_ensure_persona_from_session(self, mock_event, mock_context):
"""Test applying persona from session service config."""
module = ama
persona = {"name": "test-persona", "prompt": "You are helpful."}
mock_context.persona_manager.personas_v3 = [persona]
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=("test-persona", persona, "test-persona", False)
)
mock_event.trace = MagicMock(record=MagicMock())
req = ProviderRequest()
req.conversation = MagicMock(persona_id=None)
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
assert "You are helpful." in req.system_prompt
@pytest.mark.asyncio
async def test_ensure_persona_from_conversation(self, mock_event, mock_context):
"""Test applying persona from conversation setting."""
module = ama
persona = {"name": "conv-persona", "prompt": "Custom persona."}
mock_context.persona_manager.personas_v3 = [persona]
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=("conv-persona", persona, None, False)
)
req = ProviderRequest()
req.conversation = MagicMock(persona_id="conv-persona")
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
assert "Custom persona." in req.system_prompt
@pytest.mark.asyncio
async def test_ensure_persona_none_explicit(self, mock_event, mock_context):
"""Test that [%None] persona is explicitly set to no persona."""
module = ama
mock_context.persona_manager.personas_v3 = []
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=("[%None]", None, None, False)
)
req = ProviderRequest()
req.conversation = MagicMock(persona_id="[%None]")
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
assert "Persona Instructions" not in req.system_prompt
@pytest.mark.asyncio
async def test_ensure_tools_from_persona(self, mock_event, mock_context):
"""Test applying tools from persona."""
module = ama
mock_tool = MagicMock()
mock_tool.name = "test_tool"
mock_tool.active = True
persona = {"name": "persona", "prompt": "Test", "tools": ["test_tool"]}
mock_context.persona_manager.personas_v3 = [persona]
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=("persona", persona, None, False)
)
tmgr = mock_context.get_llm_tool_manager.return_value
tmgr.get_func.return_value = mock_tool
req = ProviderRequest()
req.conversation = MagicMock(persona_id="persona")
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
assert req.func_tool is not None
@pytest.mark.asyncio
async def test_subagent_dedupe_uses_default_persona_tools(
self, mock_event, mock_context
):
"""Test dedupe uses resolved default persona tools in subagent mode."""
module = ama
mock_context.persona_manager.resolve_selected_persona = AsyncMock(
return_value=(None, None, None, False)
)
mock_context.persona_manager.get_persona_v3_by_id = MagicMock(
return_value={"name": "default", "tools": ["tool_a"]}
)
tool_a = FunctionTool(
name="tool_a",
parameters={"type": "object", "properties": {}},
description="tool a",
)
tool_b = FunctionTool(
name="tool_b",
parameters={"type": "object", "properties": {}},
description="tool b",
)
tmgr = mock_context.get_llm_tool_manager.return_value
tmgr.func_list = [tool_a, tool_b]
tmgr.get_full_tool_set.return_value = ToolSet([tool_a, tool_b])
tmgr.get_func.side_effect = lambda name: {
"tool_a": tool_a,
"tool_b": tool_b,
}.get(name)
handoff = MagicMock()
handoff.name = "transfer_to_planner"
mock_context.subagent_orchestrator = MagicMock(handoffs=[handoff])
mock_context.get_config.return_value = {
"subagent_orchestrator": {
"main_enable": True,
"remove_main_duplicate_tools": True,
"agents": [
{
"name": "planner",
"enabled": True,
"persona_id": "default",
}
],
}
}
req = ProviderRequest()
req.conversation = MagicMock(persona_id=None)
await module._ensure_persona_and_skills(req, {}, mock_context, mock_event)
assert req.func_tool is not None
assert "transfer_to_planner" in req.func_tool.names()
assert "tool_a" not in req.func_tool.names()
assert "tool_b" in req.func_tool.names()
class TestDecorateLlmRequest:
"""Tests for _decorate_llm_request function."""
@pytest.mark.asyncio
async def test_decorate_llm_request_basic(
self, mock_event, mock_context, sample_config
):
"""Test basic LLM request decoration."""
module = ama
req = ProviderRequest(prompt="Hello", system_prompt="System")
await module._decorate_llm_request(mock_event, req, mock_context, sample_config)
assert req.prompt == "Hello"
assert req.system_prompt == "System"
@pytest.mark.asyncio
async def test_decorate_llm_request_with_prefix(self, mock_event, mock_context):
"""Test LLM request decoration with prompt prefix."""
module = ama
req = ProviderRequest(prompt="Hello")
config = module.MainAgentBuildConfig(
tool_call_timeout=60, provider_settings={"prompt_prefix": "AI: "}
)
with patch.object(mock_context, "get_config") as mock_get_config:
mock_get_config.return_value = {}
await module._decorate_llm_request(mock_event, req, mock_context, config)
assert req.prompt == "AI: Hello"
@pytest.mark.asyncio
async def test_decorate_llm_request_prefix_with_placeholder(
self, mock_event, mock_context
):
"""Test prompt prefix with {{prompt}} placeholder."""
module = ama
req = ProviderRequest(prompt="Hello")
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
provider_settings={"prompt_prefix": "AI {{prompt}} - Please respond:"},
)
with patch.object(mock_context, "get_config") as mock_get_config:
mock_get_config.return_value = {}
await module._decorate_llm_request(mock_event, req, mock_context, config)
assert req.prompt == "AI Hello - Please respond:"
@pytest.mark.asyncio
async def test_decorate_llm_request_no_conversation(self, mock_event, mock_context):
"""Test decoration when no conversation exists."""
module = ama
req = ProviderRequest(prompt="Hello")
req.conversation = None
config = module.MainAgentBuildConfig(tool_call_timeout=60)
with patch.object(mock_context, "get_config") as mock_get_config:
mock_get_config.return_value = {}
await module._decorate_llm_request(mock_event, req, mock_context, config)
assert req.prompt == "Hello"
class TestPluginToolFix:
"""Tests for _plugin_tool_fix function."""
def test_plugin_tool_fix_none_plugins(self, mock_event):
"""Test plugin tool fix when no plugins specified."""
module = ama
req = ProviderRequest(func_tool=ToolSet())
mock_event.plugins_name = None
module._plugin_tool_fix(mock_event, req)
assert req.func_tool is not None
def test_plugin_tool_fix_filters_by_plugin(self, mock_event):
"""Test plugin tool fix filters tools by enabled plugins."""
module = ama
mcp_tool = MagicMock(spec=MCPTool)
mcp_tool.name = "mcp_tool"
plugin_tool = MagicMock()
plugin_tool.name = "plugin_tool"
plugin_tool.handler_module_path = "test_plugin"
plugin_tool.active = True
tool_set = ToolSet()
tool_set.add_tool(mcp_tool)
tool_set.add_tool(plugin_tool)
req = ProviderRequest(func_tool=tool_set)
mock_event.plugins_name = ["test_plugin"]
with patch("astrbot.core.astr_main_agent.star_map") as mock_star_map:
mock_plugin = MagicMock()
mock_plugin.name = "test_plugin"
mock_plugin.reserved = False
mock_star_map.get.return_value = mock_plugin
module._plugin_tool_fix(mock_event, req)
assert "mcp_tool" in req.func_tool.names()
assert "plugin_tool" in req.func_tool.names()
def test_plugin_tool_fix_mcp_preserved(self, mock_event):
"""Test that MCP tools are always preserved."""
module = ama
mcp_tool = MagicMock(spec=MCPTool)
mcp_tool.name = "mcp_tool"
mcp_tool.active = True
tool_set = ToolSet()
tool_set.add_tool(mcp_tool)
req = ProviderRequest(func_tool=tool_set)
mock_event.plugins_name = ["other_plugin"]
with patch("astrbot.core.astr_main_agent.star_map"):
module._plugin_tool_fix(mock_event, req)
assert "mcp_tool" in req.func_tool.names()
def test_plugin_tool_fix_preserves_tools_without_plugin_origin(self, mock_event):
"""Tools without handler_module_path should not be filtered out."""
module = ama
handoff_tool = FunctionTool(
name="transfer_to_demo_agent",
description="Delegate to demo agent",
parameters={"type": "object", "properties": {}},
handler_module_path=None,
active=True,
)
tool_set = ToolSet()
tool_set.add_tool(handoff_tool)
req = ProviderRequest(func_tool=tool_set)
mock_event.plugins_name = ["other_plugin"]
with patch("astrbot.core.astr_main_agent.star_map"):
module._plugin_tool_fix(mock_event, req)
assert "transfer_to_demo_agent" in req.func_tool.names()
class TestBuildMainAgent:
"""Tests for build_main_agent function."""
@pytest.mark.asyncio
async def test_build_main_agent_basic(
self, mock_event, mock_context, mock_provider
):
"""Test basic main agent building."""
module = ama
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.return_value = mock_provider
mock_context.get_config.return_value = {}
conv_mgr = mock_context.conversation_manager
_setup_conversation_for_build(conv_mgr)
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(tool_call_timeout=60),
)
assert result is not None
assert isinstance(result, module.MainAgentBuildResult)
@pytest.mark.asyncio
async def test_build_main_agent_no_provider(self, mock_event, mock_context):
"""Test building main agent when no provider is available."""
module = ama
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.side_effect = ValueError("No provider")
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(tool_call_timeout=60),
)
assert result is None
@pytest.mark.asyncio
async def test_build_main_agent_with_wake_prefix(
self, mock_event, mock_context, mock_provider
):
"""Test building main agent with wake prefix."""
module = ama
mock_event.message_str = "/command"
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.return_value = mock_provider
mock_context.get_config.return_value = {}
conv_mgr = mock_context.conversation_manager
_setup_conversation_for_build(conv_mgr)
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(
tool_call_timeout=60, provider_wake_prefix="/"
),
)
assert result is not None
@pytest.mark.asyncio
async def test_build_main_agent_no_wake_prefix(
self, mock_event, mock_context, mock_provider
):
"""Test building main agent without matching wake prefix."""
module = ama
mock_event.message_str = "hello"
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.return_value = mock_provider
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(
tool_call_timeout=60, provider_wake_prefix="/"
),
)
assert result is None
@pytest.mark.asyncio
async def test_build_main_agent_with_images(
self, mock_event, mock_context, mock_provider
):
"""Test building main agent with image attachments."""
module = ama
mock_image = MagicMock(spec=Image)
mock_image.convert_to_file_path = AsyncMock(return_value="/path/to/image.jpg")
mock_event.message_obj.message = [mock_image]
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.return_value = mock_provider
mock_context.get_config.return_value = {}
conv_mgr = mock_context.conversation_manager
_setup_conversation_for_build(conv_mgr)
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(tool_call_timeout=60),
)
assert result is not None
@pytest.mark.asyncio
async def test_build_main_agent_uses_image_fallback_provider(
self, mock_event, mock_context
):
"""Test image requests use a fallback provider that supports images."""
module = ama
text_provider = MagicMock(spec=Provider)
text_provider.provider_config = {
"id": "text-provider",
"modalities": ["text", "tool_use"],
"max_context_tokens": 128000,
}
text_provider.get_model.return_value = "text-model"
image_provider = MagicMock(spec=Provider)
image_provider.provider_config = {
"id": "image-provider",
"modalities": ["text", "image", "tool_use"],
"max_context_tokens": 128000,
}
image_provider.get_model.return_value = "image-model"
req = ProviderRequest(
prompt="describe this",
image_urls=["/tmp/image.jpg"],
model="text-model",
)
mock_context.get_provider_by_id.side_effect = lambda provider_id: (
image_provider if provider_id == "image-provider" else None
)
mock_context.get_config.return_value = {}
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(
tool_call_timeout=60,
llm_safety_mode=False,
computer_use_runtime="none",
add_cron_tools=False,
provider_settings={
"fallback_chat_models": ["image-provider"],
},
),
provider=text_provider,
req=req,
)
assert result is not None
assert result.provider is image_provider
assert result.provider_request.image_urls == ["/tmp/image.jpg"]
assert result.provider_request.model is None
assert mock_runner.reset.call_args.kwargs["provider"] is image_provider
assert mock_runner.reset.call_args.kwargs["fallback_providers"] == []
@pytest.mark.asyncio
async def test_build_main_agent_keeps_text_provider_without_image_fallback(
self, mock_event, mock_context
):
"""Test image requests fall back to existing sanitizing when no image provider exists."""
module = ama
text_provider = MagicMock(spec=Provider)
text_provider.provider_config = {
"id": "text-provider",
"modalities": ["text", "tool_use"],
"max_context_tokens": 128000,
}
text_provider.get_model.return_value = "text-model"
req = ProviderRequest(
prompt="describe this",
image_urls=["/tmp/image.jpg"],
)
mock_context.get_provider_by_id.return_value = None
mock_context.get_config.return_value = {}
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(
tool_call_timeout=60,
llm_safety_mode=False,
computer_use_runtime="none",
add_cron_tools=False,
provider_settings={
"fallback_chat_models": ["missing-provider"],
},
),
provider=text_provider,
req=req,
)
assert result is not None
assert result.provider is text_provider
assert result.provider_request.image_urls == ["/tmp/image.jpg"]
assert mock_runner.reset.call_args.kwargs["provider"] is text_provider
@pytest.mark.asyncio
async def test_build_main_agent_with_video_attachment(
self, mock_event, mock_context, mock_provider
):
"""Test building main agent with video attachments."""
module = ama
mock_video = Video(file="file:///path/to/video.mp4")
mock_event.message_obj.message = [mock_video]
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.return_value = mock_provider
mock_context.get_config.return_value = {}
conv_mgr = mock_context.conversation_manager
_setup_conversation_for_build(conv_mgr)
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(tool_call_timeout=60),
)
assert result is not None
assert [
part.text for part in result.provider_request.extra_user_content_parts
] == ["[Video Attachment: name video.mp4, path path/to/video.mp4]"]
@pytest.mark.asyncio
async def test_build_main_agent_with_quoted_video_attachment(
self, mock_event, mock_context, mock_provider
):
"""Test building main agent with quoted video attachments."""
module = ama
mock_video = Video(file="file:///path/to/quoted-video.mp4")
mock_reply = Reply(
id="reply-1",
chain=[mock_video],
sender_nickname="",
message_str="quoted message",
)
mock_event.message_obj.message = [Plain(text="Hello"), mock_reply]
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.return_value = mock_provider
mock_context.get_config.return_value = {}
conv_mgr = mock_context.conversation_manager
_setup_conversation_for_build(conv_mgr)
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(tool_call_timeout=60),
)
assert result is not None
assert (
"[Video Attachment in quoted message: "
"name quoted-video.mp4, path path/to/quoted-video.mp4]"
) in [part.text for part in result.provider_request.extra_user_content_parts]
@pytest.mark.asyncio
async def test_build_main_agent_skips_video_attachment_when_conversion_fails(
self, mock_event, mock_context, mock_provider
):
"""Test video attachment failures do not abort request construction."""
module = ama
mock_video = Video(file="file:///path/to/direct.mp4")
mock_quoted_video = Video(file="file:///path/to/quoted.mp4")
mock_reply = Reply(
id="reply-1",
chain=[mock_quoted_video],
sender_nickname="",
message_str="quoted message",
)
mock_event.message_obj.message = [mock_video, mock_reply]
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.return_value = mock_provider
mock_context.get_config.return_value = {}
conv_mgr = mock_context.conversation_manager
_setup_conversation_for_build(conv_mgr)
async def _raise_video_conversion_error(self):
if self.file.endswith("direct.mp4"):
raise RuntimeError("direct")
raise RuntimeError("quoted")
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
patch("astrbot.core.astr_main_agent.logger") as mock_logger,
patch.object(
Video,
"convert_to_file_path",
AsyncMock(side_effect=_raise_video_conversion_error),
),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(tool_call_timeout=60),
)
assert result is not None
assert not any(
"Video Attachment" in part.text
for part in result.provider_request.extra_user_content_parts
)
assert mock_logger.error.call_count == 2
assert (
"Error processing video attachment"
in mock_logger.error.call_args_list[0][0][0]
)
assert (
"Error processing quoted video attachment"
in mock_logger.error.call_args_list[1][0][0]
)
@pytest.mark.asyncio
async def test_build_main_agent_no_prompt_no_images(
self, mock_event, mock_context, mock_provider
):
"""Test building main agent returns None when no prompt or images."""
module = ama
mock_event.message_str = ""
mock_event.message_obj.message = []
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.return_value = mock_provider
mock_context.get_config.return_value = {}
conv_mgr = mock_context.conversation_manager
_setup_conversation_for_build(conv_mgr)
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(tool_call_timeout=60),
)
assert result is None
@pytest.mark.asyncio
async def test_build_main_agent_apply_reset_false(
self, mock_event, mock_context, mock_provider
):
"""Test building main agent without applying reset."""
module = ama
mock_context.get_provider_by_id.return_value = None
mock_context.get_using_provider.return_value = mock_provider
mock_context.get_config.return_value = {}
conv_mgr = mock_context.conversation_manager
_setup_conversation_for_build(conv_mgr)
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(tool_call_timeout=60),
apply_reset=False,
)
assert result is not None
assert result.reset_coro is not None
mock_runner.reset.assert_called_once()
result.reset_coro.close()
@pytest.mark.asyncio
async def test_build_main_agent_with_existing_request(
self, mock_event, mock_context, mock_provider
):
"""Test building main agent with existing ProviderRequest."""
module = ama
existing_req = ProviderRequest(prompt="Existing prompt")
mock_event.get_extra.side_effect = lambda k: (
existing_req if k == "provider_request" else None
)
with (
patch("astrbot.core.astr_main_agent.AgentRunner") as mock_runner_cls,
patch("astrbot.core.astr_main_agent.AstrAgentContext"),
):
mock_runner = MagicMock()
mock_runner.reset = AsyncMock()
mock_runner_cls.return_value = mock_runner
result = await module.build_main_agent(
event=mock_event,
plugin_context=mock_context,
config=module.MainAgentBuildConfig(tool_call_timeout=60),
provider=mock_provider,
req=existing_req,
)
assert result is not None
assert result.provider_request == existing_req
class TestHandleWebchat:
"""Tests for _handle_webchat function."""
@pytest.mark.asyncio
async def test_handle_webchat_generates_title(self, mock_event):
"""Test generating title for webchat session without display name."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="What is machine learning?")
prov = MagicMock(spec=Provider)
llm_response = MagicMock()
llm_response.completion_text = "Machine Learning Introduction"
prov.text_chat = AsyncMock(return_value=llm_response)
mock_session = MagicMock()
mock_session.display_name = None
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
mock_db.update_platform_session = AsyncMock()
await module._handle_webchat(mock_event, req, prov)
mock_db.get_platform_session_by_id.assert_called_once_with(
"webchat-session-123"
)
mock_db.update_platform_session.assert_called_once_with(
session_id="webchat-session-123",
display_name="Machine Learning Introduction",
)
@pytest.mark.asyncio
async def test_handle_webchat_no_user_prompt(self, mock_event):
"""Test that title generation is skipped when no user prompt."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt=None)
prov = MagicMock(spec=Provider)
mock_session = MagicMock()
mock_session.display_name = None
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
await module._handle_webchat(mock_event, req, prov)
prov.text_chat.assert_not_called()
@pytest.mark.asyncio
async def test_handle_webchat_empty_user_prompt(self, mock_event):
"""Test that title generation is skipped when user prompt is empty."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="")
prov = MagicMock(spec=Provider)
mock_session = MagicMock()
mock_session.display_name = None
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
await module._handle_webchat(mock_event, req, prov)
prov.text_chat.assert_not_called()
@pytest.mark.asyncio
async def test_handle_webchat_session_already_has_display_name(self, mock_event):
"""Test that title generation is skipped when session already has display name."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="What is AI?")
prov = MagicMock(spec=Provider)
mock_session = MagicMock()
mock_session.display_name = "Existing Title"
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
await module._handle_webchat(mock_event, req, prov)
prov.text_chat.assert_not_called()
@pytest.mark.asyncio
async def test_handle_webchat_no_session_found(self, mock_event):
"""Test that title generation is skipped when session is not found."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="What is AI?")
prov = MagicMock(spec=Provider)
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=None)
await module._handle_webchat(mock_event, req, prov)
prov.text_chat.assert_not_called()
@pytest.mark.asyncio
async def test_handle_webchat_llm_returns_none_title(self, mock_event):
"""Test that title is not updated when LLM returns <None>."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="hi")
prov = MagicMock(spec=Provider)
llm_response = MagicMock()
llm_response.completion_text = "<None>"
prov.text_chat = AsyncMock(return_value=llm_response)
mock_session = MagicMock()
mock_session.display_name = None
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
mock_db.update_platform_session = AsyncMock()
await module._handle_webchat(mock_event, req, prov)
mock_db.update_platform_session.assert_not_called()
@pytest.mark.asyncio
async def test_handle_webchat_llm_returns_empty_title(self, mock_event):
"""Test that title is not updated when LLM returns empty string."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="hello")
prov = MagicMock(spec=Provider)
llm_response = MagicMock()
llm_response.completion_text = " "
prov.text_chat = AsyncMock(return_value=llm_response)
mock_session = MagicMock()
mock_session.display_name = None
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
mock_db.update_platform_session = AsyncMock()
await module._handle_webchat(mock_event, req, prov)
mock_db.update_platform_session.assert_not_called()
@pytest.mark.asyncio
async def test_handle_webchat_llm_returns_none_response(self, mock_event):
"""Test handling when LLM returns None response."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="test question")
prov = MagicMock(spec=Provider)
prov.text_chat = AsyncMock(return_value=None)
mock_session = MagicMock()
mock_session.display_name = None
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
mock_db.update_platform_session = AsyncMock()
await module._handle_webchat(mock_event, req, prov)
mock_db.update_platform_session.assert_not_called()
@pytest.mark.asyncio
async def test_handle_webchat_llm_returns_no_completion_text(self, mock_event):
"""Test handling when LLM response has no completion_text."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="test question")
prov = MagicMock(spec=Provider)
llm_response = MagicMock()
llm_response.completion_text = None
prov.text_chat = AsyncMock(return_value=llm_response)
mock_session = MagicMock()
mock_session.display_name = None
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
mock_db.update_platform_session = AsyncMock()
await module._handle_webchat(mock_event, req, prov)
mock_db.update_platform_session.assert_not_called()
@pytest.mark.asyncio
async def test_handle_webchat_strips_title_whitespace(self, mock_event):
"""Test that generated title has whitespace stripped."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="What is Python?")
prov = MagicMock(spec=Provider)
llm_response = MagicMock()
llm_response.completion_text = " Python Programming Guide "
prov.text_chat = AsyncMock(return_value=llm_response)
mock_session = MagicMock()
mock_session.display_name = None
with patch("astrbot.core.db_helper") as mock_db:
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
mock_db.update_platform_session = AsyncMock()
await module._handle_webchat(mock_event, req, prov)
mock_db.update_platform_session.assert_called_once_with(
session_id="webchat-session-123",
display_name="Python Programming Guide",
)
@pytest.mark.asyncio
async def test_handle_webchat_provider_exception_is_handled(self, mock_event):
"""Test that provider exception during title generation is handled."""
module = ama
mock_event.session_id = "platform!webchat-session-123"
req = ProviderRequest(prompt="What is Python?")
prov = MagicMock(spec=Provider)
prov.text_chat = AsyncMock(side_effect=RuntimeError("provider failed"))
mock_session = MagicMock()
mock_session.display_name = None
with (
patch("astrbot.core.db_helper") as mock_db,
patch("astrbot.core.astr_main_agent.logger") as mock_logger,
):
mock_db.get_platform_session_by_id = AsyncMock(return_value=mock_session)
mock_db.update_platform_session = AsyncMock()
await module._handle_webchat(mock_event, req, prov)
mock_logger.exception.assert_called_once()
mock_db.update_platform_session.assert_not_called()
class TestApplyLlmSafetyMode:
"""Tests for _apply_llm_safety_mode function."""
def test_apply_llm_safety_mode_system_prompt_strategy(self):
"""Test applying safety mode with system_prompt strategy."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
llm_safety_mode=True,
safety_mode_strategy="system_prompt",
)
req = ProviderRequest(prompt="Test", system_prompt="Original prompt")
module._apply_llm_safety_mode(config, req)
assert "You are running in Safe Mode" in req.system_prompt
assert "Original prompt" in req.system_prompt
def test_apply_llm_safety_mode_prepends_safety_prompt(self):
"""Test that safety prompt is prepended before original system prompt."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
safety_mode_strategy="system_prompt",
)
req = ProviderRequest(prompt="Test", system_prompt="My custom prompt")
module._apply_llm_safety_mode(config, req)
assert req.system_prompt.startswith("You are running in Safe Mode")
assert "My custom prompt" in req.system_prompt
def test_apply_llm_safety_mode_with_none_system_prompt(self):
"""Test applying safety mode when original system_prompt is None."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
safety_mode_strategy="system_prompt",
)
req = ProviderRequest(prompt="Test", system_prompt=None)
module._apply_llm_safety_mode(config, req)
assert "You are running in Safe Mode" in req.system_prompt
def test_apply_llm_safety_mode_unsupported_strategy(self):
"""Test that unsupported strategy logs warning and does nothing."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
safety_mode_strategy="unsupported_strategy",
)
req = ProviderRequest(prompt="Test", system_prompt="Original")
with patch("astrbot.core.astr_main_agent.logger") as mock_logger:
module._apply_llm_safety_mode(config, req)
mock_logger.warning.assert_called_once()
assert (
"Unsupported llm_safety_mode strategy"
in mock_logger.warning.call_args[0][0]
)
assert req.system_prompt == "Original"
def test_apply_llm_safety_mode_empty_system_prompt(self):
"""Test applying safety mode when original system_prompt is empty."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
safety_mode_strategy="system_prompt",
)
req = ProviderRequest(prompt="Test", system_prompt="")
module._apply_llm_safety_mode(config, req)
assert "You are running in Safe Mode" in req.system_prompt
class TestApplySandboxTools:
"""Tests for _apply_sandbox_tools function."""
def test_apply_sandbox_tools_creates_toolset_if_none(self, mock_context):
"""Test that ToolSet is created when func_tool is None."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={},
)
req = ProviderRequest(prompt="Test", func_tool=None)
module._apply_sandbox_tools(config, req, "session-123")
assert req.func_tool is not None
assert isinstance(req.func_tool, ToolSet)
def test_apply_sandbox_tools_adds_required_tools(self, mock_context):
"""Test that all required sandbox tools are added."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={},
)
req = ProviderRequest(prompt="Test", func_tool=None)
module._apply_sandbox_tools(config, req, "session-123")
tool_names = req.func_tool.names()
assert "astrbot_execute_shell" in tool_names
assert "astrbot_execute_ipython" in tool_names
assert "astrbot_upload_file" in tool_names
assert "astrbot_download_file" in tool_names
def test_apply_sandbox_tools_adds_sandbox_prompt(self, mock_context):
"""Test that sandbox mode prompt is added to system_prompt."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={},
)
req = ProviderRequest(prompt="Test", system_prompt="Original prompt")
module._apply_sandbox_tools(config, req, "session-123")
assert "sandboxed environment" in req.system_prompt
def test_apply_sandbox_tools_with_cua_adds_gui_guidance(self, mock_context):
"""Test that CUA sandbox guidance nudges reliable GUI workflows."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={"booter": "cua"},
)
req = ProviderRequest(prompt="Test", system_prompt="Original prompt")
module._apply_sandbox_tools(config, req, "session-123")
assert req.func_tool is not None
tool_names = req.func_tool.names()
assert "astrbot_cua_screenshot" in tool_names
assert "astrbot_cua_mouse_click" in tool_names
assert "astrbot_cua_keyboard_type" in tool_names
assert "astrbot_cua_key_press" not in tool_names
assert "Firefox" in req.system_prompt
assert "background=true" in req.system_prompt
assert 'firefox "https://example.com"' in req.system_prompt
assert "astrbot_cua_screenshot" in req.system_prompt
assert "astrbot_cua_key_press" not in req.system_prompt
assert "return_image_to_llm" in req.system_prompt
assert "astrbot_execute_shell" in req.system_prompt
assert "\\n" in req.system_prompt
assert "send_to_user=true" in req.system_prompt
assert "focused and empty or safe to append" in req.system_prompt
def test_apply_sandbox_tools_with_shipyard_booter(self, monkeypatch, mock_context):
"""Test sandbox tools with shipyard booter configuration."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={
"booter": "shipyard",
"shipyard_endpoint": "https://shipyard.example.com",
"shipyard_access_token": "test-token",
},
)
req = ProviderRequest(prompt="Test", func_tool=None)
monkeypatch.delenv("SHIPYARD_ENDPOINT", raising=False)
monkeypatch.delenv("SHIPYARD_ACCESS_TOKEN", raising=False)
module._apply_sandbox_tools(config, req, "session-123")
assert os.environ.get("SHIPYARD_ENDPOINT") == "https://shipyard.example.com"
assert os.environ.get("SHIPYARD_ACCESS_TOKEN") == "test-token"
def test_apply_sandbox_tools_shipyard_missing_endpoint(self, mock_context):
"""Test that shipyard config is skipped when endpoint is missing."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={
"booter": "shipyard",
"shipyard_endpoint": "",
"shipyard_access_token": "test-token",
},
)
req = ProviderRequest(prompt="Test", func_tool=None)
with patch("astrbot.core.astr_main_agent.logger") as mock_logger:
module._apply_sandbox_tools(config, req, "session-123")
mock_logger.error.assert_called_once()
assert (
"Shipyard sandbox configuration is incomplete"
in mock_logger.error.call_args[0][0]
)
def test_apply_sandbox_tools_shipyard_missing_access_token(self, mock_context):
"""Test that shipyard config is skipped when access token is missing."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={
"booter": "shipyard",
"shipyard_endpoint": "https://shipyard.example.com",
"shipyard_access_token": "",
},
)
req = ProviderRequest(prompt="Test", func_tool=None)
with patch("astrbot.core.astr_main_agent.logger") as mock_logger:
module._apply_sandbox_tools(config, req, "session-123")
mock_logger.error.assert_called_once()
def test_apply_sandbox_tools_preserves_existing_toolset(self, mock_context):
"""Test that existing tools are preserved when adding sandbox tools."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={},
)
existing_toolset = ToolSet()
existing_tool = MagicMock()
existing_tool.name = "existing_tool"
existing_toolset.add_tool(existing_tool)
req = ProviderRequest(prompt="Test", func_tool=existing_toolset)
module._apply_sandbox_tools(config, req, "session-123")
assert "existing_tool" in req.func_tool.names()
assert "astrbot_execute_shell" in req.func_tool.names()
def test_apply_sandbox_tools_appends_to_existing_system_prompt(self, mock_context):
"""Test that sandbox prompt is appended to existing system prompt."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={},
)
req = ProviderRequest(prompt="Test", system_prompt="Base prompt")
module._apply_sandbox_tools(config, req, "session-123")
assert req.system_prompt.startswith("Base prompt")
assert "sandboxed environment" in req.system_prompt
def test_apply_sandbox_tools_with_none_system_prompt(self, mock_context):
"""Test that sandbox prompt is applied when system_prompt is None."""
module = ama
config = module.MainAgentBuildConfig(
tool_call_timeout=60,
computer_use_runtime="sandbox",
sandbox_cfg={},
)
req = ProviderRequest(prompt="Test", system_prompt=None)
module._apply_sandbox_tools(config, req, "session-123")
assert isinstance(req.system_prompt, str)
assert "sandboxed environment" in req.system_prompt