Files
MoviePilot/tests/test_agent_interaction.py
Aqr-K 7584d4b9fc fix: isolate agent sessions and retain runtime resource ownership (#7149)
* 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>
2026-10-23 08:20:59 +08:00

354 lines
13 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.
"""
验证按钮选择工具、反馈门禁及智能体交互回调。
"""
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()