mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-10-29 03:18:14 +08:00
* fix: arbitrate subscription identity and restore workflow state * fix: isolate agent sessions and retain runtime resource ownership * test: preserve legacy empty-owner subscription transactions * test: handle reaped terminal descendants during shutdown polling * fix: align shared atomic configuration method with lifecycle prerequisite * test: cover subagent cancellation and completion lifecycle boundaries --------- Co-authored-by: jxxghp <jxxghp@gmail.com>
354 lines
13 KiB
Python
354 lines
13 KiB
Python
"""
|
||
验证按钮选择工具、反馈门禁及智能体交互回调。
|
||
"""
|
||
|
||
import asyncio
|
||
from dataclasses import replace
|
||
from datetime import datetime
|
||
from unittest.mock import AsyncMock, Mock, patch
|
||
|
||
import pytest
|
||
|
||
from app.agent.prompt import prompt_manager
|
||
from app.agent.tools.factory import MoviePilotToolFactory
|
||
from app.agent.tools.impl.ask_user_choice import (
|
||
AskUserChoiceTool,
|
||
UserChoiceOptionInput,
|
||
)
|
||
from app.agent.tools.impl.send_message import SendMessageTool
|
||
from app.application.messaging.interaction import InteractionContext
|
||
from app.application.messaging.interaction.agent import (
|
||
AgentInteractionOption,
|
||
agent_interaction_manager,
|
||
)
|
||
from app.chain.message import MessageChain
|
||
from app.runtime.config import settings
|
||
from app.runtime.loop import main_loop_registry
|
||
from app.schemas.types import NotificationChannel
|
||
|
||
|
||
@pytest.fixture(autouse=True)
|
||
def _isolate_test_state():
|
||
"""
|
||
隔离测试之间共享的会话与交互状态,并在结束时清理。
|
||
"""
|
||
try:
|
||
yield
|
||
finally:
|
||
agent_interaction_manager.clear()
|
||
|
||
|
||
def test_prompt_injects_choice_tool_hint_only_for_button_channels() -> None:
|
||
"""
|
||
仅支持按钮的渠道注入选择工具的交互说明。
|
||
"""
|
||
telegram_prompt = prompt_manager.get_agent_prompt(channel=NotificationChannel.Telegram.value)
|
||
web_agent_prompt = prompt_manager.get_agent_prompt(channel=NotificationChannel.WebAgent.value)
|
||
wechat_prompt = prompt_manager.get_agent_prompt(channel=NotificationChannel.Wechat.value)
|
||
|
||
assert "ask_user_choice" in telegram_prompt
|
||
assert "ask_user_choice" in web_agent_prompt
|
||
assert "terminal interaction tool" in telegram_prompt
|
||
assert "do not write a final text reply after it" in telegram_prompt
|
||
assert "ask_user_choice" not in wechat_prompt
|
||
|
||
|
||
def test_prompt_does_not_inject_send_message_html_hint() -> None:
|
||
"""
|
||
提示词不再注入旧版 HTML 消息发送说明。
|
||
"""
|
||
telegram_prompt = prompt_manager.get_agent_prompt(channel=NotificationChannel.Telegram.value)
|
||
wechat_prompt = prompt_manager.get_agent_prompt(channel=NotificationChannel.Wechat.value)
|
||
|
||
assert 'parse_mode="HTML"' not in telegram_prompt
|
||
assert "Telegram-supported HTML tags" not in telegram_prompt
|
||
assert 'parse_mode="HTML"' not in wechat_prompt
|
||
|
||
|
||
def test_factory_injects_choice_tool_only_for_button_channels() -> None:
|
||
"""
|
||
工具工厂仅向支持按钮的渠道提供选择工具。
|
||
"""
|
||
with patch(
|
||
"app.agent.tools.factory._get_plugin_agent_tools",
|
||
return_value=[],
|
||
):
|
||
telegram_tools = MoviePilotToolFactory.create_tools(
|
||
session_id="session-1",
|
||
user_id="10001",
|
||
channel=NotificationChannel.Telegram.value,
|
||
source="telegram-test",
|
||
username="tester",
|
||
)
|
||
web_agent_tools = MoviePilotToolFactory.create_tools(
|
||
session_id="session-web",
|
||
user_id="10001",
|
||
channel=NotificationChannel.WebAgent.value,
|
||
source="web-agent",
|
||
username="tester",
|
||
)
|
||
wechat_tools = MoviePilotToolFactory.create_tools(
|
||
session_id="session-2",
|
||
user_id="10001",
|
||
channel=NotificationChannel.Wechat.value,
|
||
source="wechat-test",
|
||
username="tester",
|
||
)
|
||
|
||
assert "ask_user_choice" in [tool.name for tool in telegram_tools]
|
||
assert "ask_user_choice" in [tool.name for tool in web_agent_tools]
|
||
assert "ask_user_choice" not in [tool.name for tool in wechat_tools]
|
||
|
||
|
||
def test_choice_tool_returns_direct_after_sending_interaction() -> None:
|
||
"""
|
||
发送按钮后应结束当前 Agent 轮次,等待用户选择作为新消息进入。
|
||
"""
|
||
tool = AskUserChoiceTool(session_id="session-1", user_id="10001")
|
||
|
||
assert tool.return_direct
|
||
assert "本工具结束当前交互轮次" in tool.description
|
||
|
||
|
||
def test_send_message_tool_returns_direct_after_sending_message() -> None:
|
||
"""
|
||
发送消息工具发出用户可见消息后应结束当前 Agent 轮次。
|
||
"""
|
||
tool = SendMessageTool(session_id="session-1", user_id="10001")
|
||
|
||
assert tool.return_direct
|
||
assert "本工具直接完成用户回复" in tool.description
|
||
|
||
|
||
def test_choice_tool_sends_buttons_and_registers_pending_request() -> None:
|
||
"""
|
||
选择工具发送完整按钮并登记待处理请求。
|
||
"""
|
||
tool = AskUserChoiceTool(session_id="session-1", user_id="10001")
|
||
tool.set_message_attr(
|
||
channel=NotificationChannel.Telegram.value,
|
||
source="telegram-test",
|
||
username="tester",
|
||
)
|
||
tool.set_agent_context(agent_context={})
|
||
|
||
with patch(
|
||
"app.agent.tools.base.ToolChain.async_post_message",
|
||
new=AsyncMock(),
|
||
) as async_post_message:
|
||
result = asyncio.run(
|
||
tool.run(
|
||
message="请选择要执行的操作",
|
||
options=[
|
||
UserChoiceOptionInput(label="继续下载", value="继续下载"),
|
||
UserChoiceOptionInput(label="先看详情", value="先看详情"),
|
||
],
|
||
title="需要你的选择",
|
||
)
|
||
)
|
||
|
||
assert "等待用户选择" in result
|
||
assert tool._agent_context.get("user_reply_sent")
|
||
notification = async_post_message.await_args.args[0]
|
||
assert notification.text == "请选择要执行的操作"
|
||
assert sum((len(row) for row in notification.buttons)) == 2
|
||
assert "description" not in notification.buttons[0][0]
|
||
|
||
callback_data = notification.buttons[0][0]["callback_data"]
|
||
_, _, request_id, option_index = callback_data.split(":")
|
||
resolved = agent_interaction_manager.resolve(request_id, int(option_index), "10001")
|
||
assert resolved is not None
|
||
_, option = resolved
|
||
assert option.value == "继续下载"
|
||
|
||
|
||
def test_choice_tool_blocks_after_feedback_quality_rejection() -> None:
|
||
"""
|
||
反馈质量被拒绝后阻止继续发送选择请求。
|
||
"""
|
||
tool = AskUserChoiceTool(session_id="session-feedback", user_id="10001")
|
||
tool.set_message_attr(
|
||
channel=NotificationChannel.Telegram.value,
|
||
source="telegram-test",
|
||
username="tester",
|
||
)
|
||
tool.set_agent_context(agent_context={"feedback_issue_rejected_quality": True})
|
||
|
||
with patch(
|
||
"app.agent.tools.base.ToolChain.async_post_message",
|
||
new=AsyncMock(),
|
||
) as async_post_message:
|
||
result = asyncio.run(
|
||
tool.run(
|
||
message="测试ISSUE提交被系统质量校验拦截,请选择:",
|
||
options=[
|
||
UserChoiceOptionInput(
|
||
label="提供真实问题描述重新提交",
|
||
value="提供真实问题描述重新提交",
|
||
),
|
||
UserChoiceOptionInput(
|
||
label="取消测试,了解原因",
|
||
value="取消测试,了解原因",
|
||
),
|
||
],
|
||
)
|
||
)
|
||
|
||
assert "质量门槛拒绝" in result
|
||
async_post_message.assert_not_awaited()
|
||
|
||
|
||
def test_agent_interaction_callback_routes_selected_value_back_to_agent() -> None:
|
||
"""
|
||
按钮回调将所选值作为用户输入送回智能体。
|
||
"""
|
||
chain = MessageChain()
|
||
chain.runtime_config = replace(
|
||
chain.runtime_config,
|
||
ai_agent_enable=True,
|
||
)
|
||
request = agent_interaction_manager.create_request(
|
||
session_id="session-choice",
|
||
user_id="10001",
|
||
channel=NotificationChannel.Telegram.value,
|
||
source="telegram-test",
|
||
username="tester",
|
||
title="需要你的选择",
|
||
prompt="请选择",
|
||
options=[
|
||
AgentInteractionOption(label="电影", value="我选择电影"),
|
||
AgentInteractionOption(label="电视剧", value="我选择电视剧"),
|
||
],
|
||
)
|
||
|
||
loop = Mock(**{"is_running.return_value": True, "is_closed.return_value": False})
|
||
with (
|
||
patch.object(main_loop_registry, "require", return_value=loop),
|
||
patch.object(settings, "AI_AGENT_ENABLE", True),
|
||
patch.object(chain.messagehelper, "put") as message_put,
|
||
patch.object(chain.messageoper, "add") as message_add,
|
||
patch.object(chain, "edit_message", return_value=True) as edit_message,
|
||
patch("app.chain.message.get_running_agent_manager") as get_running_manager,
|
||
patch(
|
||
"app.chain.message.asyncio.run_coroutine_threadsafe",
|
||
side_effect=lambda coro, _loop: (coro.close(), Mock())[1],
|
||
),
|
||
):
|
||
process_message = AsyncMock()
|
||
get_running_manager.return_value.process_message = process_message
|
||
handled = chain._handle_callback(
|
||
callback_data=f"agent_interaction:choice:{request.request_id}:1",
|
||
context=InteractionContext(
|
||
channel=NotificationChannel.Telegram,
|
||
source="telegram-test",
|
||
user_id="10001",
|
||
username="tester",
|
||
original_message_id=123,
|
||
original_chat_id="456",
|
||
),
|
||
)
|
||
|
||
assert handled
|
||
edit_message.assert_called_once_with(
|
||
channel=NotificationChannel.Telegram,
|
||
source="telegram-test",
|
||
message_id=123,
|
||
chat_id="456",
|
||
title="需要你的选择",
|
||
text="请选择\n\n已选择:电影",
|
||
)
|
||
process_message.assert_called_once()
|
||
kwargs = process_message.call_args.kwargs
|
||
assert kwargs["message"] == "我选择电影"
|
||
assert kwargs["session_id"] == "session-choice"
|
||
assert kwargs["channel"] == NotificationChannel.Telegram.value
|
||
assert kwargs["source"] == "telegram-test"
|
||
assert "processing_status" not in kwargs
|
||
message_put.assert_not_called()
|
||
message_add.assert_not_called()
|
||
|
||
|
||
def test_legacy_agent_choice_callback_still_supported() -> None:
|
||
"""
|
||
旧版选择按钮回调仍按兼容路径处理。
|
||
"""
|
||
chain = MessageChain()
|
||
request = agent_interaction_manager.create_request(
|
||
session_id="session-choice",
|
||
user_id="10001",
|
||
channel=NotificationChannel.Telegram.value,
|
||
source="telegram-test",
|
||
username="tester",
|
||
title=None,
|
||
prompt="请选择",
|
||
options=[AgentInteractionOption(label="电影", value="我选择电影")],
|
||
)
|
||
|
||
with (
|
||
patch.object(chain, "_handle_ai_message") as handle_ai_message,
|
||
patch.object(chain.messagehelper, "put"),
|
||
patch.object(chain.messageoper, "add"),
|
||
):
|
||
chain._handle_callback(
|
||
callback_data=f"agent_choice:{request.request_id}:1",
|
||
context=InteractionContext(
|
||
channel=NotificationChannel.Telegram,
|
||
source="telegram-test",
|
||
user_id="10001",
|
||
username="tester",
|
||
),
|
||
)
|
||
|
||
handle_ai_message.assert_called_once()
|
||
|
||
|
||
def test_secret_confirmation_preempts_plugin_interaction_on_message_channels() -> None:
|
||
"""
|
||
TG/飞书确认必须回到已有 Agent 会话,不被其它输入会话消费。
|
||
"""
|
||
chain = MessageChain()
|
||
|
||
try:
|
||
for channel in (NotificationChannel.Telegram, NotificationChannel.Feishu):
|
||
key = chain._session_user_key("10001", channel, f"{channel.value}-test")
|
||
MessageChain._user_sessions[key] = ("session-secret", datetime.now())
|
||
manager = Mock()
|
||
manager.matches_secret_confirmation.return_value = True
|
||
with (
|
||
patch(
|
||
"app.chain.message.get_running_agent_manager",
|
||
return_value=manager,
|
||
),
|
||
patch.object(
|
||
chain,
|
||
"_handle_ai_message",
|
||
return_value=True,
|
||
) as handle_ai_message,
|
||
patch(
|
||
"app.chain.message.PluginInputInteractionHandler.handle_text",
|
||
) as handle_plugin_interaction,
|
||
patch.object(
|
||
chain,
|
||
"_mark_message_processing_started",
|
||
) as mark_processing_started,
|
||
):
|
||
chain.handle_message(
|
||
channel=channel,
|
||
source=f"{channel.value}-test",
|
||
userid="10001",
|
||
username="tester",
|
||
text="确认",
|
||
original_message_id="message-1",
|
||
original_chat_id="chat-1",
|
||
images=None,
|
||
audio_refs=None,
|
||
files=None,
|
||
)
|
||
|
||
handle_ai_message.assert_called_once()
|
||
handle_plugin_interaction.assert_not_called()
|
||
mark_processing_started.assert_not_called()
|
||
assert handle_ai_message.call_args.kwargs["session_id"] == "session-secret"
|
||
finally:
|
||
MessageChain._user_sessions.clear()
|