from __future__ import annotations from types import SimpleNamespace import pytest from langchain.messages import AIMessage, HumanMessage from yuxi.services import chat_service as svc def _empty_agents_prompt(_thread_id: str, _uid: str) -> str: return "" async def _fake_normalize_agent_context_config(context, **_kwargs): return dict(context or {}) class _FakeConvRepo: def __init__(self, _db): self.saved_messages: list[dict] = [] self.conversations: dict[str, SimpleNamespace] = {} def _conversation(self, thread_id: str) -> SimpleNamespace: return self.conversations.setdefault( thread_id, SimpleNamespace( id=1, uid="user-1", agent_id="test-agent", thread_id=thread_id, status="active", extra_metadata={}, ), ) async def add_message_by_thread_id( self, *, thread_id: str, role: str, content: str, message_type: str = "text", extra_metadata: dict | None = None, image_content: str | None = None, ): self.saved_messages.append( { "thread_id": thread_id, "role": role, "content": content, "message_type": message_type, "extra_metadata": extra_metadata, "image_content": image_content, } ) return SimpleNamespace(id=1) async def get_conversation_by_thread_id(self, thread_id: str): return self._conversation(thread_id) async def create_conversation(self, *, uid: str, agent_id: str, thread_id: str, metadata: dict | None = None): conversation = SimpleNamespace( id=1, uid=uid, agent_id=agent_id, thread_id=thread_id, status="active", extra_metadata=metadata or {}, ) self.conversations[thread_id] = conversation return conversation async def get_attachments_by_request_id(self, conversation_id: int, request_id: str): return [] async def bind_attachments_to_request(self, conversation_id: int, request_id: str, file_ids: list[str]): return [] @pytest.mark.asyncio async def test_agent_chat_uses_invoke_messages_and_persists_langgraph_state(monkeypatch: pytest.MonkeyPatch): calls: dict[str, object] = {} class FakeGraph: async def aget_state(self, config): calls["state_config"] = config return SimpleNamespace(values={"messages": [AIMessage(content="Hi from graph")], "todos": ["todo-1"]}) class FakeAgent: context_schema = None async def invoke_messages(self, messages, input_context=None, **kwargs): calls["invoke_messages"] = messages calls["invoke_input_context"] = input_context calls["invoke_kwargs"] = kwargs return {"messages": [messages[0], AIMessage(content="Hi from invoke")]} async def stream_messages(self, messages, input_context=None, **kwargs): raise AssertionError("stream_messages should not be used by sync chat") async def get_graph(self): return FakeGraph() async def fake_resolve_agent_runtime(**_kwargs): return SimpleNamespace(slug="test-agent", backend_id="ChatbotAgent"), FakeAgent(), {"temperature": 0.1} async def fake_save_messages_from_langgraph_state(*, agent_instance, thread_id, conv_repo, config_dict, trace_info): calls["saved_state"] = { "agent_instance": agent_instance, "thread_id": thread_id, "conv_repo": conv_repo, "config_dict": config_dict, "trace_info": trace_info, } async def fake_guard_check(_content): return False def fake_build_langfuse_run_context(**kwargs): calls["langfuse_kwargs"] = kwargs return SimpleNamespace( callbacks=["handler-1"], metadata={"langfuse_user_id": kwargs["current_user"].uid, "langfuse_session_id": kwargs["thread_id"]}, tags=["yuxi", "chat"], trace_id="trace-seeded", ) def fake_get_trace_info(_run_context): return {"langfuse_trace_id": "trace-runtime", "langfuse_session_id": "thread-1"} monkeypatch.setattr(svc, "_build_langfuse_run_context", fake_build_langfuse_run_context) monkeypatch.setattr(svc, "get_trace_info", fake_get_trace_info) monkeypatch.setattr(svc, "flush_langfuse", lambda: calls.setdefault("flushed", True)) monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt) monkeypatch.setattr(svc, "_resolve_agent_runtime", fake_resolve_agent_runtime) monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config) monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo) monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state) monkeypatch.setattr(svc.content_guard, "check", fake_guard_check) result = await svc.agent_chat( query="hello", agent_id="test-agent", thread_id="thread-1", meta={"request_id": "req-1"}, image_content=None, current_user=SimpleNamespace(id=1, uid="user-1", role="user", department_id="dept-1"), db=object(), ) assert result["status"] == "finished" assert result["response"] == "Hi from invoke" assert result["thread_id"] == "thread-1" assert result["request_id"] == "req-1" assert result["agent_state"] == {"todos": ["todo-1"], "files": {}, "artifacts": []} invoke_messages = calls["invoke_messages"] assert isinstance(invoke_messages, list) assert len(invoke_messages) == 1 assert isinstance(invoke_messages[0], HumanMessage) assert invoke_messages[0].content == "hello" assert calls["invoke_input_context"] == {"temperature": 0.1, "uid": "user-1", "thread_id": "thread-1"} assert calls["invoke_kwargs"] == { "callbacks": ["handler-1"], "metadata": {"langfuse_user_id": "user-1", "langfuse_session_id": "thread-1"}, "tags": ["yuxi", "chat"], } assert calls["saved_state"]["thread_id"] == "thread-1" assert calls["saved_state"]["config_dict"] == {"configurable": {"thread_id": "thread-1", "uid": "user-1"}} assert calls["saved_state"]["trace_info"] == { "langfuse_trace_id": "trace-runtime", "langfuse_session_id": "thread-1", } assert calls["flushed"] is True @pytest.mark.asyncio async def test_agent_chat_sync_returns_finished_even_when_state_has_interrupt(monkeypatch: pytest.MonkeyPatch): class FakeGraph: async def aget_state(self, config): return SimpleNamespace( values={ "messages": [AIMessage(content="Need input later")], "__interrupt__": [{"questions": [{"question": "继续吗?"}]}], } ) class FakeAgent: context_schema = None async def invoke_messages(self, messages, input_context=None, **kwargs): return {"messages": [messages[0], AIMessage(content="Need input later")]} async def stream_messages(self, messages, input_context=None, **kwargs): raise AssertionError("stream_messages should not be used by sync chat") async def get_graph(self): return FakeGraph() async def fake_resolve_agent_runtime(**_kwargs): return SimpleNamespace(slug="test-agent", backend_id="ChatbotAgent"), FakeAgent(), {} async def fake_save_messages_from_langgraph_state(*, agent_instance, thread_id, conv_repo, config_dict, trace_info): return None async def fake_guard_check(_content): return False monkeypatch.setattr( svc, "_build_langfuse_run_context", lambda **kwargs: SimpleNamespace(callbacks=[], metadata={}, tags=[], trace_id=None), ) monkeypatch.setattr(svc, "get_trace_info", lambda _run_context: {}) monkeypatch.setattr(svc, "flush_langfuse", lambda: None) monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt) monkeypatch.setattr(svc, "_resolve_agent_runtime", fake_resolve_agent_runtime) monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config) monkeypatch.setattr(svc, "ConversationRepository", _FakeConvRepo) monkeypatch.setattr(svc, "save_messages_from_langgraph_state", fake_save_messages_from_langgraph_state) monkeypatch.setattr(svc.content_guard, "check", fake_guard_check) result = await svc.agent_chat( query="hello", agent_id="test-agent", thread_id="thread-2", meta={"request_id": "req-2"}, image_content=None, current_user=SimpleNamespace(id=1, uid="user-1", role="user", department_id="dept-1"), db=object(), ) assert result["status"] == "finished" assert result["response"] == "Need input later" assert result["thread_id"] == "thread-2" assert result["request_id"] == "req-2" @pytest.mark.asyncio async def test_build_agent_input_context_merges_workspace_agents_prompt(monkeypatch: pytest.MonkeyPatch): def fake_agents_prompt(_thread_id: str, _uid: str) -> str: return "回答前先读取 AGENTS.md" monkeypatch.setattr(svc, "_load_workspace_agents_prompt", fake_agents_prompt) context = await svc._build_agent_input_context( {"system_prompt": "原始系统提示词", "temperature": 0.1}, thread_id="thread-1", uid="user-1", ) assert context["system_prompt"] == "原始系统提示词\n\n用户工作区 agents/AGENTS.md 内容:\n回答前先读取 AGENTS.md" assert context["temperature"] == 0.1 assert context["thread_id"] == "thread-1" assert context["uid"] == "user-1" @pytest.mark.asyncio async def test_build_agent_input_context_keeps_prompt_when_workspace_agents_prompt_empty( monkeypatch: pytest.MonkeyPatch, ): monkeypatch.setattr(svc, "_load_workspace_agents_prompt", _empty_agents_prompt) context = await svc._build_agent_input_context( {"system_prompt": "原始系统提示词"}, thread_id="thread-1", uid="user-1", ) assert context["system_prompt"] == "原始系统提示词"