diff --git a/app/agent/__init__.py b/app/agent/__init__.py index 4720e0f2..73c2bc33 100644 --- a/app/agent/__init__.py +++ b/app/agent/__init__.py @@ -57,7 +57,7 @@ from app.log import logger from app.schemas import AgentLLMProviderEventData, AgentTokensUsageEventData, Notification, NotificationType from app.schemas.message import ChannelCapabilityManager, ChannelCapability from app.schemas.types import ChainEventType, EventType, MessageChannel -from app.utils.identity import SYSTEM_INTERNAL_USER_ID +from app.utils.identity import SYSTEM_INTERNAL_USER_ID, is_internal_user_id class AgentChain(ChainBase): @@ -227,6 +227,7 @@ class ReplyMode(str, Enum): CAPTURE_ONLY = "capture_only" +INTERNAL_AGENT_SESSION_PREFIX = "__agent_" HEARTBEAT_SESSION_PREFIX = "__agent_heartbeat_" UNSUPPORTED_IMAGE_INPUT_MESSAGE = "当前模型不支持图片输入,请更换支持图片输入的模型,或在系统设置中关闭图片输入支持后重试。" AGENT_EXECUTION_ERROR_PREFIX = "智能助手执行失败" @@ -313,6 +314,17 @@ class MoviePilotAgent: and self.channel not in AGENT_DISPLAY_HISTORY_SKIP_CHANNELS ) + def _should_persist_agent_chat(self) -> bool: + """ + 判断当前 Agent 是否需要写入会话历史表。 + """ + if self.is_heartbeat_session: + return False + return not ( + is_internal_user_id(self.user_id) + and self.session_id.startswith(INTERNAL_AGENT_SESSION_PREFIX) + ) + def _save_display_history_messages(self, messages: List[dict]) -> None: """ 将一组可见消息追加到 Agent 会话历史表。 @@ -372,6 +384,8 @@ class MoviePilotAgent: """ 首次对话时生成并保存会话标题。 """ + if not self._should_persist_agent_chat(): + return if self._tool_context.get("chat_title_prepared"): return self._tool_context["chat_title_prepared"] = True @@ -1373,12 +1387,12 @@ class MoviePilotAgent: break self._save_assistant_display_message_once(display_text) - # 保存消息 - memory_manager.save_agent_messages( - session_id=self.session_id, - user_id=self.user_id, - messages=agent.get_state(agent_config).values.get("messages", []), - ) + if self._should_persist_agent_chat(): + memory_manager.save_agent_messages( + session_id=self.session_id, + user_id=self.user_id, + messages=agent.get_state(agent_config).values.get("messages", []), + ) execution_success = True except asyncio.CancelledError: diff --git a/tests/test_agent_chat_history.py b/tests/test_agent_chat_history.py index 01c96d0d..8a9bfcce 100644 --- a/tests/test_agent_chat_history.py +++ b/tests/test_agent_chat_history.py @@ -1,11 +1,12 @@ import asyncio from types import SimpleNamespace -from langchain_core.messages import HumanMessage +from langchain_core.messages import AIMessage, HumanMessage -from app.agent import MoviePilotAgent +from app.agent import HEARTBEAT_SESSION_PREFIX, MoviePilotAgent from app.agent.memory import memory_manager from app.db.agentchat_oper import AgentChatOper +from app.utils.identity import SYSTEM_INTERNAL_USER_ID def test_agent_chat_oper_saves_display_messages_with_channel(): @@ -110,6 +111,99 @@ def test_agent_prepare_chat_title_generates_title(monkeypatch): assert chat.source == "web-agent" +def test_agent_prepare_chat_title_skips_internal_background_sessions(monkeypatch): + """内部后台任务和心跳会话不应生成标题或创建历史会话。""" + + async def fake_initialize_llm(self, streaming=False): + """后台会话不应初始化标题模型。""" + raise AssertionError("background title generation should be skipped") + + monkeypatch.setattr(MoviePilotAgent, "_initialize_llm", fake_initialize_llm) + + for session_id in ( + "__agent_background_title__", + f"{HEARTBEAT_SESSION_PREFIX}title__", + ): + agent = MoviePilotAgent( + session_id=session_id, + user_id=SYSTEM_INTERNAL_USER_ID, + username="admin", + ) + asyncio.run(agent.prepare_chat_title("后台任务")) + + assert AgentChatOper().get( + session_id=session_id, + user_id=SYSTEM_INTERNAL_USER_ID, + ) is None + + +def test_agent_prepare_chat_title_keeps_user_cli_sessions(monkeypatch): + """用户显式 CLI 会话即使没有渠道来源也应保留标题生成。""" + + class FakeTitleModel: + """测试用 CLI 标题模型。""" + + async def ainvoke(self, messages): + """返回固定 CLI 标题。""" + return SimpleNamespace(content="CLI 会话排查") + + async def fake_initialize_llm(self, streaming=False): + """返回测试 CLI 标题模型。""" + return FakeTitleModel() + + monkeypatch.setattr(MoviePilotAgent, "_initialize_llm", fake_initialize_llm) + agent = MoviePilotAgent( + session_id="cli-title-session", + user_id="cli", + username="admin", + ) + + asyncio.run(agent.prepare_chat_title("帮我检查配置")) + chat = AgentChatOper().get(session_id="cli-title-session", user_id="cli") + + assert chat.title == "CLI 会话排查" + + +def test_internal_background_agent_execution_does_not_persist_chat_history(monkeypatch): + """内部后台任务执行完成后不应写入 Agent 会话历史表。""" + session_id = "__agent_background_skip_persist__" + user_id = SYSTEM_INTERNAL_USER_ID + memory_manager.clear_memory(session_id, user_id) + + class FakeGraphState: + """测试用 LangGraph 状态。""" + + def __init__(self, messages): + self.values = {"messages": messages} + + class FakeAgent: + """测试用 LangGraph Agent。""" + + async def ainvoke(self, _payload, config=None): + """模拟非流式 Agent 执行。""" + return None + + def get_state(self, _config): + """返回包含最终回复的状态。""" + return FakeGraphState([AIMessage(content="后台结果")]) + + async def fake_create_agent(self, streaming=False): + """返回测试 Agent,避免真实初始化模型。""" + return FakeAgent() + + monkeypatch.setattr(MoviePilotAgent, "_create_agent", fake_create_agent) + agent = MoviePilotAgent( + session_id=session_id, + user_id=user_id, + username="admin", + ) + + asyncio.run(agent._execute_agent([])) + + assert AgentChatOper().get(session_id=session_id, user_id=user_id) is None + assert memory_manager.get_memory(session_id, user_id) is None + + def test_memory_manager_restores_agent_messages_from_database(): """内存缓存缺失时应从 Agent 会话历史表恢复原始 messages。""" session_id = "session-memory"