585 lines
20 KiB
Python
585 lines
20 KiB
Python
from __future__ import annotations
|
|
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from fastapi import HTTPException
|
|
from langchain.messages import AIMessage, HumanMessage
|
|
|
|
from yuxi.agents import context as agent_context
|
|
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 {})
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_resolve_agent_runtime_includes_subagents_only_when_requested(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
calls: list[str] = []
|
|
|
|
class FakeAgentRepository:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_visible_by_slug(self, *, slug: str, user, kind="main"):
|
|
del user
|
|
assert slug == "worker"
|
|
calls.append(kind)
|
|
if kind == "subagent":
|
|
return SimpleNamespace(slug="worker", backend_id="SubAgentBackend", config_json={"context": {}})
|
|
return None
|
|
|
|
class FakeConversationRepository:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_conversation_by_thread_id(self, thread_id: str):
|
|
return SimpleNamespace(uid="user-1", agent_id="worker", thread_id=thread_id, status="subagent")
|
|
|
|
monkeypatch.setattr(svc, "AgentRepository", FakeAgentRepository)
|
|
monkeypatch.setattr(svc, "ConversationRepository", FakeConversationRepository)
|
|
monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config)
|
|
monkeypatch.setattr(
|
|
svc.agent_manager,
|
|
"get_agent",
|
|
lambda backend_id: SimpleNamespace(context_schema=None) if backend_id == "SubAgentBackend" else None,
|
|
)
|
|
|
|
user = SimpleNamespace(uid="user-1")
|
|
|
|
with pytest.raises(ValueError, match="智能体不存在或无权限访问"):
|
|
await svc._resolve_agent_runtime(
|
|
db=object(),
|
|
user=user,
|
|
requested_agent_slug="worker",
|
|
thread_id="child-thread",
|
|
)
|
|
|
|
agent_item, backend, agent_config = await svc._resolve_agent_runtime(
|
|
db=object(),
|
|
user=user,
|
|
requested_agent_slug="worker",
|
|
thread_id="child-thread",
|
|
agent_kind="subagent",
|
|
)
|
|
|
|
assert calls == ["main", "subagent"]
|
|
assert agent_item.slug == "worker"
|
|
assert backend.context_schema is None
|
|
assert agent_config == {}
|
|
|
|
|
|
class _FakeConvRepo:
|
|
def __init__(self, _db):
|
|
self.db = _db
|
|
self.saved_messages: list[dict] = []
|
|
self.tool_calls: 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,
|
|
run_id: str | None = None,
|
|
request_id: 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,
|
|
"run_id": run_id,
|
|
"request_id": request_id,
|
|
}
|
|
)
|
|
return SimpleNamespace(id=1)
|
|
|
|
async def get_conversation_by_thread_id(self, thread_id: str):
|
|
return self._conversation(thread_id)
|
|
|
|
async def get_messages_by_thread_id(self, _thread_id: str):
|
|
return []
|
|
|
|
async def add_tool_call(
|
|
self,
|
|
*,
|
|
message_id: int,
|
|
tool_name: str,
|
|
tool_input: dict | None = None,
|
|
status: str = "pending",
|
|
langgraph_tool_call_id: str | None = None,
|
|
):
|
|
self.tool_calls.append(
|
|
{
|
|
"message_id": message_id,
|
|
"tool_name": tool_name,
|
|
"tool_input": tool_input,
|
|
"status": status,
|
|
"langgraph_tool_call_id": langgraph_tool_call_id,
|
|
}
|
|
)
|
|
return SimpleNamespace(id=len(self.tool_calls))
|
|
|
|
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_save_messages_from_langgraph_state_handles_dict_tool_call_blocks() -> None:
|
|
class FakeGraph:
|
|
async def aget_state(self, _config):
|
|
return SimpleNamespace(
|
|
values={
|
|
"messages": [
|
|
{
|
|
"id": "ai-tool-call",
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "tool_call",
|
|
"id": "call-task-1",
|
|
"name": "task",
|
|
"args": {"description": "write file", "subagent_slug": "worker"},
|
|
}
|
|
],
|
|
}
|
|
]
|
|
}
|
|
)
|
|
|
|
class FakeAgent:
|
|
async def get_graph(self, *, context):
|
|
assert context is fake_context
|
|
return FakeGraph()
|
|
|
|
conv_repo = _FakeConvRepo(None)
|
|
fake_context = object()
|
|
|
|
await svc.save_messages_from_langgraph_state(
|
|
agent_instance=FakeAgent(),
|
|
thread_id="thread-1",
|
|
conv_repo=conv_repo,
|
|
config_dict={"configurable": {"thread_id": "thread-1", "uid": "user-1"}},
|
|
context=fake_context,
|
|
trace_info=None,
|
|
)
|
|
|
|
assert conv_repo.saved_messages[0]["content"] == ""
|
|
assert conv_repo.saved_messages[0]["extra_metadata"]["content"][0]["id"] == "call-task-1"
|
|
assert conv_repo.tool_calls == [
|
|
{
|
|
"message_id": 1,
|
|
"tool_name": "task",
|
|
"tool_input": {"description": "write file", "subagent_slug": "worker"},
|
|
"status": "pending",
|
|
"langgraph_tool_call_id": "call-task-1",
|
|
}
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_save_messages_from_langgraph_state_backfills_run_output_message(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
class FakeDB:
|
|
def __init__(self):
|
|
self.commit_count = 0
|
|
|
|
async def commit(self):
|
|
self.commit_count += 1
|
|
|
|
class FakeGraph:
|
|
async def aget_state(self, _config):
|
|
return SimpleNamespace(values={"messages": [HumanMessage(content="question"), AIMessage(content="answer")]})
|
|
|
|
class FakeAgent:
|
|
async def get_graph(self, *, context):
|
|
assert context is fake_context
|
|
return FakeGraph()
|
|
|
|
fake_db = FakeDB()
|
|
conv_repo = _FakeConvRepo(fake_db)
|
|
fake_context = object()
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeRunRepo:
|
|
def __init__(self, db):
|
|
assert db is fake_db
|
|
|
|
async def set_output_message(self, run_id: str, message_id: int):
|
|
captured["run_id"] = run_id
|
|
captured["message_id"] = message_id
|
|
|
|
monkeypatch.setattr(svc, "AgentRunRepository", FakeRunRepo)
|
|
|
|
await svc.save_messages_from_langgraph_state(
|
|
agent_instance=FakeAgent(),
|
|
thread_id="thread-1",
|
|
conv_repo=conv_repo,
|
|
config_dict={"configurable": {"thread_id": "thread-1", "uid": "user-1"}},
|
|
context=fake_context,
|
|
trace_info={"langfuse_trace_id": "trace-1"},
|
|
run_id="run-1",
|
|
request_id="req-1",
|
|
)
|
|
|
|
assert conv_repo.saved_messages[0]["content"] == "answer"
|
|
assert conv_repo.saved_messages[0]["run_id"] == "run-1"
|
|
assert conv_repo.saved_messages[0]["request_id"] == "req-1"
|
|
assert conv_repo.saved_messages[0]["extra_metadata"]["langfuse_trace_id"] == "trace-1"
|
|
assert captured == {"run_id": "run-1", "message_id": 1}
|
|
assert fake_db.commit_count == 1
|
|
|
|
|
|
@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(agent_context, "_load_workspace_agents_prompt", fake_agents_prompt)
|
|
|
|
context = await agent_context.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_get_agent_state_view_rejects_async_subagent_without_child_conversation(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
child_thread_id = "missing-child-conversation"
|
|
|
|
class ConvRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_conversation_by_thread_id(self, thread_id: str):
|
|
del thread_id
|
|
return None
|
|
|
|
class RunRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_latest_subagent_run_by_thread_for_user(self, thread_id: str, uid: str):
|
|
assert thread_id == child_thread_id
|
|
assert uid == "user-1"
|
|
return SimpleNamespace(
|
|
id="child-run",
|
|
conversation_thread_id=child_thread_id,
|
|
agent_slug="worker",
|
|
status="running",
|
|
created_by_run_id="parent-run",
|
|
subagent_thread_relation_id=77,
|
|
input_payload={"runtime": {"tool_call_id": "tool-1"}},
|
|
)
|
|
|
|
async def get_run_for_user(self, run_id: str, uid: str):
|
|
del run_id, uid
|
|
raise AssertionError("async subagent state must be loaded through child conversation relation")
|
|
|
|
monkeypatch.setattr(svc, "ConversationRepository", ConvRepo)
|
|
monkeypatch.setattr(svc, "AgentRunRepository", RunRepo)
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await svc.get_agent_state_view(
|
|
thread_id=child_thread_id,
|
|
current_user=SimpleNamespace(uid="user-1"),
|
|
db=object(),
|
|
include_messages=True,
|
|
)
|
|
|
|
assert exc.value.status_code == 404
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_agent_state_view_includes_subagent_thread_relation(monkeypatch: pytest.MonkeyPatch):
|
|
child_thread_id = "child-thread"
|
|
|
|
class ConvRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_conversation_by_thread_id(self, thread_id: str):
|
|
if thread_id == child_thread_id:
|
|
return SimpleNamespace(id=20, uid="user-1", agent_id="worker", status="subagent")
|
|
return None
|
|
|
|
async def get_conversation_by_id(self, conversation_id: int):
|
|
assert conversation_id == 11
|
|
return SimpleNamespace(id=11, thread_id="parent-thread", uid="user-1", status="active")
|
|
|
|
class AgentRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_by_slug(self, slug: str):
|
|
assert slug == "worker"
|
|
return SimpleNamespace(
|
|
backend_id="SubAgentBackend",
|
|
config_json={"context": {}},
|
|
)
|
|
|
|
class ThreadRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_by_child_conversation_for_user(self, child_conversation_id: int, uid: str):
|
|
assert child_conversation_id == 20
|
|
assert uid == "user-1"
|
|
return SimpleNamespace(
|
|
id=77,
|
|
parent_conversation_id=11,
|
|
child_conversation_id=20,
|
|
child_thread_id=child_thread_id,
|
|
subagent_slug="worker",
|
|
to_dict=lambda: {
|
|
"id": 77,
|
|
"parent_conversation_id": 11,
|
|
"child_conversation_id": 20,
|
|
"child_thread_id": child_thread_id,
|
|
"subagent_slug": "worker",
|
|
},
|
|
)
|
|
|
|
class RunRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_latest_run_by_thread_for_user(self, thread_id: str, uid: str):
|
|
assert thread_id == child_thread_id
|
|
assert uid == "user-1"
|
|
return SimpleNamespace(input_payload={"model_spec": "provider:run-model"})
|
|
|
|
async def get_latest_subagent_run_by_thread_for_user(self, thread_id: str, uid: str):
|
|
assert thread_id == child_thread_id
|
|
assert uid == "user-1"
|
|
return SimpleNamespace(
|
|
id="child-run",
|
|
conversation_thread_id=child_thread_id,
|
|
agent_slug="worker",
|
|
uid="user-1",
|
|
status="running",
|
|
created_by_run_id="parent-run",
|
|
subagent_thread_relation_id=77,
|
|
input_payload={
|
|
"runtime": {
|
|
"tool_call_id": "tool-1",
|
|
"subagent_name": "Worker",
|
|
"description": "do work",
|
|
},
|
|
},
|
|
error_message=None,
|
|
created_at=None,
|
|
finished_at=None,
|
|
to_dict=lambda: {"created_at": "2026-06-21T01:00:00Z", "finished_at": None},
|
|
)
|
|
|
|
class Graph:
|
|
async def aget_state(self, config):
|
|
assert config["configurable"]["thread_id"] == child_thread_id
|
|
return SimpleNamespace(
|
|
values={
|
|
"messages": [HumanMessage(content="do work"), AIMessage(content="working")],
|
|
"artifacts": ["out.txt"],
|
|
}
|
|
)
|
|
|
|
class Context:
|
|
def __init__(self, *, thread_id="", uid=""):
|
|
self.thread_id = thread_id
|
|
self.uid = uid
|
|
self.model = ""
|
|
|
|
def update(self, data: dict):
|
|
for key, value in data.items():
|
|
setattr(self, key, value)
|
|
|
|
class Agent:
|
|
context_schema = Context
|
|
|
|
async def get_graph(self, *, context):
|
|
assert context.thread_id == child_thread_id
|
|
assert context.uid == "user-1"
|
|
assert context.model == "provider:run-model"
|
|
return Graph()
|
|
|
|
monkeypatch.setattr(svc, "ConversationRepository", ConvRepo)
|
|
monkeypatch.setattr(svc, "AgentRepository", AgentRepo)
|
|
monkeypatch.setattr(svc, "SubagentThreadRepository", ThreadRepo)
|
|
monkeypatch.setattr(svc, "AgentRunRepository", RunRepo)
|
|
monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config)
|
|
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda backend_id: Agent())
|
|
|
|
result = await svc.get_agent_state_view(
|
|
thread_id=child_thread_id,
|
|
current_user=SimpleNamespace(uid="user-1"),
|
|
db=object(),
|
|
include_messages=True,
|
|
)
|
|
|
|
assert result["parent_thread_id"] == "parent-thread"
|
|
assert result["subagent_thread"]["id"] == 77
|
|
assert result["subagent_run"]["run_id"] == "child-run"
|
|
assert result["agent_state"]["artifacts"] == ["out.txt"]
|
|
assert [message["type"] for message in result["messages"]] == ["human", "ai"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_agent_state_view_reports_malformed_subagent_run_as_server_error(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
child_thread_id = "child-thread"
|
|
|
|
class ConvRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_conversation_by_thread_id(self, thread_id: str):
|
|
assert thread_id == child_thread_id
|
|
return SimpleNamespace(id=20, uid="user-1", agent_id="worker", status="subagent")
|
|
|
|
async def get_conversation_by_id(self, conversation_id: int):
|
|
assert conversation_id == 11
|
|
return SimpleNamespace(id=11, thread_id="parent-thread", uid="user-1", status="active")
|
|
|
|
class AgentRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_by_slug(self, slug: str):
|
|
assert slug == "worker"
|
|
return SimpleNamespace(backend_id="SubAgentBackend", config_json={"context": {}})
|
|
|
|
class ThreadRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_by_child_conversation_for_user(self, child_conversation_id: int, uid: str):
|
|
assert child_conversation_id == 20
|
|
assert uid == "user-1"
|
|
return SimpleNamespace(
|
|
id=77,
|
|
parent_conversation_id=11,
|
|
to_dict=lambda: {"id": 77},
|
|
)
|
|
|
|
class RunRepo:
|
|
def __init__(self, _db):
|
|
pass
|
|
|
|
async def get_latest_run_by_thread_for_user(self, thread_id: str, uid: str):
|
|
assert thread_id == child_thread_id
|
|
assert uid == "user-1"
|
|
return None
|
|
|
|
async def get_latest_subagent_run_by_thread_for_user(self, thread_id: str, uid: str):
|
|
assert thread_id == child_thread_id
|
|
assert uid == "user-1"
|
|
return SimpleNamespace(
|
|
id="child-run",
|
|
conversation_thread_id=child_thread_id,
|
|
agent_slug="worker",
|
|
status="running",
|
|
input_payload={"runtime": {}},
|
|
)
|
|
|
|
class Graph:
|
|
async def aget_state(self, _config):
|
|
return SimpleNamespace(values={})
|
|
|
|
class Context:
|
|
def __init__(self, *, thread_id="", uid=""):
|
|
self.thread_id = thread_id
|
|
self.uid = uid
|
|
|
|
def update(self, data: dict):
|
|
for key, value in data.items():
|
|
setattr(self, key, value)
|
|
|
|
class Agent:
|
|
context_schema = Context
|
|
|
|
async def get_graph(self, *, context):
|
|
assert context.thread_id == child_thread_id
|
|
assert context.uid == "user-1"
|
|
return Graph()
|
|
|
|
monkeypatch.setattr(svc, "ConversationRepository", ConvRepo)
|
|
monkeypatch.setattr(svc, "AgentRepository", AgentRepo)
|
|
monkeypatch.setattr(svc, "SubagentThreadRepository", ThreadRepo)
|
|
monkeypatch.setattr(svc, "AgentRunRepository", RunRepo)
|
|
monkeypatch.setattr(svc, "normalize_agent_context_config", _fake_normalize_agent_context_config)
|
|
monkeypatch.setattr(svc.agent_manager, "get_agent", lambda _backend_id: Agent())
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
await svc.get_agent_state_view(
|
|
thread_id=child_thread_id,
|
|
current_user=SimpleNamespace(uid="user-1"),
|
|
db=object(),
|
|
)
|
|
|
|
assert exc.value.status_code == 500
|
|
assert exc.value.detail == "子智能体运行记录格式异常"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_build_agent_input_context_keeps_prompt_when_workspace_agents_prompt_empty(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
):
|
|
monkeypatch.setattr(agent_context, "_load_workspace_agents_prompt", _empty_agents_prompt)
|
|
|
|
context = await agent_context.build_agent_input_context(
|
|
{"system_prompt": "原始系统提示词"},
|
|
thread_id="thread-1",
|
|
uid="user-1",
|
|
)
|
|
|
|
assert context["system_prompt"] == "原始系统提示词"
|