feat: 增强日历功能并集成 AgentScope 代理服务

This commit is contained in:
qzl
2026-03-11 15:28:29 +08:00
parent e55e445906
commit e20e7d2a02
85 changed files with 5175 additions and 885 deletions
@@ -0,0 +1,191 @@
from __future__ import annotations
import json
import os
from datetime import datetime, timezone
from uuid import UUID, uuid4
import pytest
from core.agent.domain.system_agent_config import SystemAgentLLMConfig
from core.agent.domain.user_context import UserAgentContext, parse_profile_settings
from core.agentscope.runtime.config_loader import RuntimeStageConfig
from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator
from core.db.session import AsyncSessionLocal
def _build_user_context(owner_id: UUID) -> UserAgentContext:
return UserAgentContext(
user_id=owner_id,
username="smoke-user",
bio=None,
settings=parse_profile_settings(
{
"version": 1,
"preferences": {
"interface_language": "zh-CN",
"ai_language": "zh-CN",
"timezone": "Asia/Shanghai",
"country": "CN",
},
}
),
)
def _runtime_stage_config() -> dict[str, RuntimeStageConfig]:
llm = SystemAgentLLMConfig(temperature=0.1, max_tokens=256, timeout_seconds=30)
return {
"intent": RuntimeStageConfig("intent", "qwen3.5-flash", "dashscope", llm),
"execution": RuntimeStageConfig("execution", "qwen3.5-flash", "dashscope", llm),
"report": RuntimeStageConfig("report", "qwen3.5-flash", "dashscope", llm),
}
async def _invoke_tool(
toolkit: object,
*,
tool_name: str,
tool_input: dict[str, object],
) -> dict[str, object]:
tool_call = {
"type": "tool_use",
"id": f"smoke-{tool_name}-{uuid4()}",
"name": tool_name,
"input": tool_input,
}
call_tool_function = getattr(toolkit, "call_tool_function")
async_gen = await call_tool_function(tool_call=tool_call)
last_chunk = None
async for chunk in async_gen:
last_chunk = chunk
assert last_chunk is not None
content = getattr(last_chunk, "content", None)
assert isinstance(content, list) and content
first = content[0]
if isinstance(first, dict):
text = first.get("text")
else:
text = getattr(first, "text", None)
assert isinstance(text, str)
payload = json.loads(text)
assert isinstance(payload, dict)
return payload
class _SmokeRunner:
async def run_json_stage(
self,
*,
stage_config: RuntimeStageConfig,
agent_name: str,
system_prompt: str,
user_prompt: str,
toolkit: object | None,
) -> dict[str, object]:
del agent_name, system_prompt, user_prompt
if stage_config.stage == "intent":
return {
"route": "TASK_EXECUTION",
"intent_summary": "run calendar smoke flow",
"direct_response": None,
"tasks": [
{
"task_id": "smoke-task-1",
"title": "calendar create-read-delete",
"objective": "verify toolkit calendar write/read/delete calls",
}
],
"complexity": "complex",
}
if stage_config.stage == "execution":
assert toolkit is not None
created = await _invoke_tool(
toolkit,
tool_name="calendar.write",
tool_input={
"operation": "create",
"title": "agentscope smoke event",
"description": "agentscope runtime smoke",
"start_at": datetime.now(timezone.utc).isoformat(),
"timezone": "Asia/Shanghai",
},
)
created_data = created.get("data")
assert isinstance(created_data, dict)
created_id = created_data.get("id")
assert isinstance(created_id, str) and created_id
read_payload = await _invoke_tool(
toolkit,
tool_name="calendar.read",
tool_input={"page": 1, "page_size": 10},
)
read_data = read_payload.get("data")
assert isinstance(read_data, dict)
items = read_data.get("items")
assert isinstance(items, list)
deleted = await _invoke_tool(
toolkit,
tool_name="calendar.write",
tool_input={"operation": "delete", "event_id": created_id},
)
deleted_data = deleted.get("data")
assert isinstance(deleted_data, dict)
assert deleted_data.get("ok") is True
return {
"task_id": "smoke-task-1",
"status": "SUCCESS",
"execution_summary": "calendar create-read-delete succeeded",
"execution_data": {
"created_id": created_id,
"read_item_count": len(items),
},
"user_feedback_needs": [],
}
return {
"assistant_text": "agentscope smoke completed",
"response_metadata": {"source": "smoke-runner"},
}
@pytest.mark.asyncio
@pytest.mark.live
async def test_agentscope_runtime_calendar_smoke() -> None:
if os.getenv("AGENTSCOPE_RUNTIME_SMOKE") != "1":
pytest.skip("set AGENTSCOPE_RUNTIME_SMOKE=1 to run live smoke test")
user_id_raw = os.getenv("AGENTSCOPE_SMOKE_USER_ID", "").strip()
user_token = os.getenv("AGENTSCOPE_SMOKE_USER_TOKEN", "").strip()
if not user_id_raw or not user_token:
pytest.fail(
"AGENTSCOPE_RUNTIME_SMOKE=1 requires AGENTSCOPE_SMOKE_USER_ID and AGENTSCOPE_SMOKE_USER_TOKEN"
)
owner_id = UUID(user_id_raw)
async def _fake_config_loader(_session: object) -> dict[str, RuntimeStageConfig]:
return _runtime_stage_config()
orchestrator = AgentScopeRuntimeOrchestrator(
runner=_SmokeRunner(),
config_loader=_fake_config_loader,
)
async with AsyncSessionLocal() as session:
result = await orchestrator.run(
session=session,
owner_id=owner_id,
user_token=user_token,
user_context=_build_user_context(owner_id),
user_input="run smoke",
)
assert result.intent.route == "TASK_EXECUTION"
assert result.execution is not None
assert result.execution.overall_status == "SUCCESS"
assert result.report.assistant_text == "agentscope smoke completed"
@@ -10,8 +10,6 @@ from fastapi.testclient import TestClient
from app import app
from v1.inbox_messages.dependencies import get_inbox_message_service
from v1.inbox_messages.schemas import (
InboxMessageAcceptRequest,
InboxMessageListRequest,
InboxMessageResponse,
InboxMessageStatus,
InboxMessageType,
@@ -23,37 +21,22 @@ class FakeInboxMessageService:
def __init__(
self,
messages: list[InboxMessageResponse],
accepted: InboxMessageResponse,
dismissed: InboxMessageResponse,
read_message: InboxMessageResponse,
) -> None:
self._messages = messages
self._accepted = accepted
self._dismissed = dismissed
self._read_message = read_message
async def list_messages(
self, request: InboxMessageListRequest
self, is_read: bool | None = None
) -> list[InboxMessageResponse]:
if request.status is None:
if is_read is None:
return self._messages
return [
message for message in self._messages if message.status == request.status
]
return [message for message in self._messages if message.is_read is is_read]
async def accept_invitation(
self,
message_id: UUID,
request: InboxMessageAcceptRequest,
) -> InboxMessageResponse:
if message_id != self._accepted.id:
async def mark_as_read(self, message_id: UUID) -> InboxMessageResponse:
if message_id != self._read_message.id:
raise HTTPException(status_code=404, detail="Inbox message not found")
if not request.permission_view:
raise HTTPException(status_code=400, detail="permission_view is required")
return self._accepted
async def dismiss_invitation(self, message_id: UUID) -> InboxMessageResponse:
if message_id != self._dismissed.id:
raise HTTPException(status_code=404, detail="Inbox message not found")
return self._dismissed
return self._read_message
def _override_inbox_message_service(
@@ -84,11 +67,11 @@ def _build_message(
def test_list_inbox_messages_returns_200() -> None:
pending_message = _build_message(uuid4(), InboxMessageStatus.PENDING)
accepted_message = _build_message(uuid4(), InboxMessageStatus.ACCEPTED)
read_message = _build_message(uuid4(), InboxMessageStatus.ACCEPTED)
read_message = read_message.model_copy(update={"is_read": True})
service = FakeInboxMessageService(
messages=[pending_message, accepted_message],
accepted=accepted_message,
dismissed=_build_message(uuid4(), InboxMessageStatus.DISMISSED),
messages=[pending_message, read_message],
read_message=read_message,
)
app.dependency_overrides[get_inbox_message_service] = (
_override_inbox_message_service(service)
@@ -96,21 +79,21 @@ def test_list_inbox_messages_returns_200() -> None:
client = TestClient(app)
try:
response = client.get("/api/v1/inbox/messages", params={"status": "pending"})
response = client.get("/api/v1/inbox/messages", params={"is_read": "false"})
assert response.status_code == 200
body = response.json()
assert len(body) == 1
assert body[0]["status"] == "pending"
assert body[0]["is_read"] is False
finally:
app.dependency_overrides = {}
def test_accept_inbox_message_returns_200() -> None:
accepted_message = _build_message(uuid4(), InboxMessageStatus.ACCEPTED)
def test_mark_as_read_returns_200() -> None:
read_message = _build_message(uuid4(), InboxMessageStatus.PENDING)
read_message = read_message.model_copy(update={"is_read": True})
service = FakeInboxMessageService(
messages=[accepted_message],
accepted=accepted_message,
dismissed=_build_message(uuid4(), InboxMessageStatus.DISMISSED),
messages=[read_message],
read_message=read_message,
)
app.dependency_overrides[get_inbox_message_service] = (
_override_inbox_message_service(service)
@@ -118,39 +101,10 @@ def test_accept_inbox_message_returns_200() -> None:
client = TestClient(app)
try:
response = client.post(
f"/api/v1/inbox/messages/{accepted_message.id}/accept",
json={
"permission_view": True,
"permission_edit": True,
"permission_invite": False,
},
)
response = client.patch(f"/api/v1/inbox/messages/{read_message.id}/read")
assert response.status_code == 200
body = response.json()
assert body["id"] == str(accepted_message.id)
assert body["status"] == "accepted"
finally:
app.dependency_overrides = {}
def test_dismiss_inbox_message_returns_200() -> None:
dismissed_message = _build_message(uuid4(), InboxMessageStatus.DISMISSED)
service = FakeInboxMessageService(
messages=[dismissed_message],
accepted=_build_message(uuid4(), InboxMessageStatus.ACCEPTED),
dismissed=dismissed_message,
)
app.dependency_overrides[get_inbox_message_service] = (
_override_inbox_message_service(service)
)
client = TestClient(app)
try:
response = client.post(f"/api/v1/inbox/messages/{dismissed_message.id}/dismiss")
assert response.status_code == 200
body = response.json()
assert body["id"] == str(dismissed_message.id)
assert body["status"] == "dismissed"
assert body["id"] == str(read_message.id)
assert body["is_read"] is True
finally:
app.dependency_overrides = {}
@@ -0,0 +1,223 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agent.domain.system_agent_config import SystemAgentLLMConfig
from core.agent.domain.user_context import UserAgentContext, parse_profile_settings
from core.agentscope.runtime.config_loader import RuntimeStageConfig
from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator
def _ctx() -> UserAgentContext:
return UserAgentContext(
user_id=uuid4(),
username="alice",
bio=None,
settings=parse_profile_settings(
{
"version": 1,
"preferences": {
"interface_language": "zh-CN",
"ai_language": "zh-CN",
"timezone": "Asia/Shanghai",
"country": "CN",
},
}
),
)
def _stage_config() -> dict[str, RuntimeStageConfig]:
llm = SystemAgentLLMConfig(temperature=0.1, max_tokens=256, timeout_seconds=30)
return {
"intent": RuntimeStageConfig("intent", "qwen3.5-flash", "dashscope", llm),
"execution": RuntimeStageConfig("execution", "deepseek-chat", "deepseek", llm),
"report": RuntimeStageConfig("report", "deepseek-chat", "deepseek", llm),
}
class _FakeRunner:
def __init__(self) -> None:
self.intent_calls = 0
self.execution_calls = 0
self.report_calls = 0
async def run_json_stage(
self,
*,
stage_config: RuntimeStageConfig,
agent_name: str,
system_prompt: str,
user_prompt: str,
toolkit: Any | None,
) -> dict[str, Any]:
del agent_name, system_prompt, user_prompt, toolkit
if stage_config.stage == "intent":
self.intent_calls += 1
return {
"route": "DIRECT_RESPONSE",
"intent_summary": "直接问候",
"direct_response": "你好",
"tasks": [],
"complexity": "simple",
}
self.report_calls += 1
return {
"assistant_text": "已完成",
"response_metadata": {"source": "report-agent"},
}
class _ComplexRunner(_FakeRunner):
async def run_json_stage(
self,
*,
stage_config: RuntimeStageConfig,
agent_name: str,
system_prompt: str,
user_prompt: str,
toolkit: Any | None,
) -> dict[str, Any]:
del agent_name, system_prompt, user_prompt, toolkit
if stage_config.stage == "intent":
self.intent_calls += 1
return {
"route": "TASK_EXECUTION",
"intent_summary": "需要写入日历",
"direct_response": None,
"tasks": [
{"task_id": "t1", "title": "创建事件", "objective": "写入明天会议"}
],
"complexity": "complex",
}
if stage_config.stage == "execution":
self.execution_calls += 1
return {
"task_id": "t1",
"status": "SUCCESS",
"execution_summary": "done",
"execution_data": {},
"user_feedback_needs": [],
}
self.report_calls += 1
return {
"assistant_text": "任务执行完成",
"response_metadata": {"source": "report-agent"},
}
@pytest.mark.asyncio
async def test_runtime_direct_response_skips_execution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_runner = _FakeRunner()
async def _fake_config_loader(
_session: AsyncSession,
) -> dict[str, RuntimeStageConfig]:
return _stage_config()
class _FakeToolkit:
def get_json_schemas(self) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "calendar.read",
"description": "read",
"parameters": {"type": "object", "properties": {}},
},
}
]
async def call_tool_function(self, tool_call: dict[str, Any]):
del tool_call
if False:
yield None
monkeypatch.setattr(
"core.agentscope.runtime.orchestrator.build_stage_toolkit",
lambda **_: _FakeToolkit(),
)
orchestrator = AgentScopeRuntimeOrchestrator(
runner=fake_runner,
config_loader=_fake_config_loader,
)
result = await orchestrator.run(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token",
user_context=_ctx(),
user_input="你好",
)
assert result.intent.route == "DIRECT_RESPONSE"
assert result.execution is None
assert result.report.assistant_text == "已完成"
assert fake_runner.execution_calls == 0
@pytest.mark.asyncio
async def test_runtime_complex_route_runs_execution(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_runner = _ComplexRunner()
async def _fake_config_loader(
_session: AsyncSession,
) -> dict[str, RuntimeStageConfig]:
return _stage_config()
class _FakeToolkit:
def get_json_schemas(self) -> list[dict[str, Any]]:
return [
{
"type": "function",
"function": {
"name": "calendar.read",
"description": "read",
"parameters": {"type": "object", "properties": {}},
},
},
{
"type": "function",
"function": {
"name": "calendar.write",
"description": "write",
"parameters": {"type": "object", "properties": {}},
},
},
]
async def call_tool_function(self, tool_call: dict[str, Any]):
del tool_call
if False:
yield None
monkeypatch.setattr(
"core.agentscope.runtime.orchestrator.build_stage_toolkit",
lambda **_: _FakeToolkit(),
)
orchestrator = AgentScopeRuntimeOrchestrator(
runner=fake_runner,
config_loader=_fake_config_loader,
)
result = await orchestrator.run(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token",
user_context=_ctx(),
user_input="帮我安排明天会议",
)
assert result.intent.route == "TASK_EXECUTION"
assert result.execution is not None
assert result.execution.overall_status == "SUCCESS"
assert fake_runner.execution_calls == 1
@@ -0,0 +1,115 @@
from __future__ import annotations
import json
from types import SimpleNamespace
import pytest
from core.agent.domain.system_agent_config import SystemAgentLLMConfig
from core.agentscope.runtime.config_loader import RuntimeStageConfig
from core.agentscope.runtime.react_runner import (
AgentScopeReActRunner,
_parse_json_text,
_to_litellm_model,
)
def _stage_config() -> RuntimeStageConfig:
return RuntimeStageConfig(
stage="intent",
model_code="qwen3.5-flash",
provider_name="dashscope",
llm_config=SystemAgentLLMConfig(
temperature=0.1, max_tokens=128, timeout_seconds=30
),
)
def test_to_litellm_model_keeps_prefixed_model() -> None:
assert (
_to_litellm_model(provider_name="dashscope", model_code="openai/gpt-4o")
== "openai/gpt-4o"
)
def test_to_litellm_model_builds_prefixed_model() -> None:
assert (
_to_litellm_model(provider_name="dashscope", model_code="qwen3.5-flash")
== "dashscope/qwen3.5-flash"
)
def test_parse_json_text_supports_fenced_json() -> None:
parsed = _parse_json_text('```json\n{"route":"DIRECT_RESPONSE"}\n```')
assert parsed["route"] == "DIRECT_RESPONSE"
def test_parse_json_text_rejects_non_json() -> None:
with pytest.raises(json.JSONDecodeError):
_parse_json_text("not-json")
@pytest.mark.asyncio
async def test_run_json_stage_wraps_json_decode_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("agentscope")
import agentscope.agent as agent_module
import agentscope.formatter as formatter_module
import agentscope.memory as memory_module
class _FakeAgent:
def __init__(self, **kwargs: object) -> None:
del kwargs
async def __call__(self, _msg: object) -> object:
return SimpleNamespace(get_text_content=lambda: "not-json")
monkeypatch.setattr(agent_module, "ReActAgent", _FakeAgent)
monkeypatch.setattr(formatter_module, "OpenAIChatFormatter", lambda: object())
monkeypatch.setattr(memory_module, "InMemoryMemory", lambda: object())
runner = AgentScopeReActRunner()
monkeypatch.setattr(runner, "_build_model", lambda **_: object())
with pytest.raises(RuntimeError, match="agent output format invalid"):
await runner.run_json_stage(
stage_config=_stage_config(),
agent_name="intent-agent",
system_prompt="sys",
user_prompt="user",
toolkit=None,
)
@pytest.mark.asyncio
async def test_run_json_stage_wraps_runtime_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
pytest.importorskip("agentscope")
import agentscope.agent as agent_module
import agentscope.formatter as formatter_module
import agentscope.memory as memory_module
class _FakeAgent:
def __init__(self, **kwargs: object) -> None:
del kwargs
async def __call__(self, _msg: object) -> object:
raise ValueError("boom")
monkeypatch.setattr(agent_module, "ReActAgent", _FakeAgent)
monkeypatch.setattr(formatter_module, "OpenAIChatFormatter", lambda: object())
monkeypatch.setattr(memory_module, "InMemoryMemory", lambda: object())
runner = AgentScopeReActRunner()
monkeypatch.setattr(runner, "_build_model", lambda **_: object())
with pytest.raises(RuntimeError, match="agent execution failed"):
await runner.run_json_stage(
stage_config=_stage_config(),
agent_name="intent-agent",
system_prompt="sys",
user_prompt="user",
toolkit=None,
)
@@ -0,0 +1,133 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import Any, cast
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.custom import calendar as calendar_module
@pytest.mark.asyncio
async def test_calendar_read_returns_list_payload(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
del kwargs
return {"type": "calendar_event_list.v1", "version": "v1", "data": {}}
monkeypatch.setattr(
calendar_module,
"_execute_list_calendar_events",
_fake_execute,
)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.calendar_read(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
)
assert result["type"] == "calendar_event_list.v1"
@pytest.mark.asyncio
async def test_calendar_read_requires_valid_user_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: False)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.calendar_read(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="bad-token",
)
assert result["data"]["ok"] is False
assert result["data"]["code"] == "UNAUTHORIZED"
@pytest.mark.asyncio
async def test_calendar_write_maps_event_id_for_update(
monkeypatch: pytest.MonkeyPatch,
) -> None:
captured: dict[str, object] = {}
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
captured.update(cast(dict[str, object], kwargs["tool_args"]))
return {"type": "calendar_card.v1", "version": "v1", "data": {"ok": True}}
monkeypatch.setattr(
calendar_module,
"_execute_mutate_calendar_event",
_fake_execute,
)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="update",
event_id=str(uuid4()),
title="新标题",
)
assert result["type"] == "calendar_card.v1"
assert captured["operation"] == "update"
assert "eventId" in captured
@pytest.mark.asyncio
async def test_calendar_write_requires_preset_user_token(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: False)
result = await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="bad-token",
operation="create",
)
assert result["data"]["ok"] is False
assert result["data"]["code"] == "UNAUTHORIZED"
@pytest.mark.asyncio
async def test_calendar_write_rejects_missing_event_id_for_update(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="update",
)
assert result["data"]["ok"] is False
assert result["data"]["code"] == "INVALID_ARGUMENT"
@pytest.mark.asyncio
async def test_calendar_write_rejects_event_id_for_create(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
result = await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="create",
event_id=str(uuid4()),
)
assert result["data"]["ok"] is False
assert result["data"]["code"] == "INVALID_ARGUMENT"
@@ -0,0 +1,106 @@
from __future__ import annotations
from typing import Any, AsyncGenerator
import pytest
from core.agentscope.tools.hitl_middleware import create_hitl_middleware
from core.agentscope.tools.tool_meta import TOOL_META, ToolMeta
async def _next_handler(**kwargs: Any) -> AsyncGenerator[dict[str, object], None]:
async def _generator() -> AsyncGenerator[dict[str, object], None]:
yield {"ok": True, "tool_call": kwargs.get("tool_call")}
return _generator()
@pytest.mark.asyncio
async def test_hitl_middleware_default_write_does_not_require_approval() -> None:
middleware = create_hitl_middleware(meta_by_name=TOOL_META)
responses = []
async for chunk in middleware(
{"tool_call": {"name": "calendar.write", "input": {"operation": "create"}}},
_next_handler,
):
responses.append(chunk)
assert responses[0]["ok"] is True
@pytest.mark.asyncio
async def test_hitl_middleware_pending_when_tool_requires_approval(
monkeypatch: pytest.MonkeyPatch,
) -> None:
middleware = create_hitl_middleware(
meta_by_name={
"calendar.write": ToolMeta(name="calendar.write", requires_approval=True)
}
)
monkeypatch.setattr(
"core.agentscope.tools.hitl_middleware.build_tool_response",
lambda payload: payload,
)
responses = []
async for chunk in middleware(
{"tool_call": {"name": "calendar.write", "input": {"operation": "create"}}},
_next_handler,
):
responses.append(chunk)
assert responses[0]["data"]["status"] == "pending"
@pytest.mark.asyncio
async def test_hitl_middleware_passes_when_write_approved() -> None:
middleware = create_hitl_middleware(
meta_by_name={
"calendar.write": ToolMeta(name="calendar.write", requires_approval=True)
},
approval_resolver=lambda _name, _args: "approved",
)
responses = []
async for chunk in middleware(
{
"tool_call": {
"name": "calendar.write",
"input": {
"operation": "create",
},
}
},
_next_handler,
):
responses.append(chunk)
assert responses[0]["ok"] is True
sanitized_input = responses[0]["tool_call"]["input"]
assert "_hitl" not in sanitized_input
@pytest.mark.asyncio
async def test_hitl_middleware_rejected_short_circuits(
monkeypatch: pytest.MonkeyPatch,
) -> None:
middleware = create_hitl_middleware(
meta_by_name={
"calendar.write": ToolMeta(name="calendar.write", requires_approval=True)
},
approval_resolver=lambda _name, _args: "rejected",
)
monkeypatch.setattr(
"core.agentscope.tools.hitl_middleware.build_tool_response",
lambda payload: payload,
)
responses = []
async for chunk in middleware(
{"tool_call": {"name": "calendar.write", "input": {"operation": "create"}}},
_next_handler,
):
responses.append(chunk)
assert responses[0]["data"]["status"] == "rejected"
@@ -0,0 +1,65 @@
from __future__ import annotations
from datetime import datetime, timezone
from uuid import uuid4
from core.agent.domain.user_context import UserAgentContext, parse_profile_settings
from core.agentscope.prompts.system_prompt import build_system_prompt
def _build_user_context(*, timezone_name: str = "Asia/Shanghai") -> UserAgentContext:
return UserAgentContext(
user_id=uuid4(),
username="alice",
bio="focus on calendars",
settings=parse_profile_settings(
{
"version": 1,
"preferences": {
"interface_language": "zh-CN",
"ai_language": "zh-CN",
"timezone": timezone_name,
"country": "CN",
},
}
),
)
def test_build_system_prompt_includes_agent_role_user_context_and_time() -> None:
prompt = build_system_prompt(
stage="execution",
user_context=_build_user_context(),
tools=[
{
"name": "calendar.read",
"description": "读取日程",
"parameters": {"type": "object"},
},
{
"name": "calendar.write",
"description": "写入日程",
"parameters": {"type": "object"},
},
],
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
)
assert "Execution Agent" in prompt
assert '"timezone":"Asia/Shanghai"' in prompt
assert '"local_time":"2026-03-11T08:00:00+08:00"' in prompt
assert "calendar.read" in prompt
assert "calendar.write" in prompt
assert "<!-- ENV_START -->" in prompt
assert "<!-- TOOLS_START -->" in prompt
def test_build_system_prompt_rejects_unknown_stage() -> None:
try:
build_system_prompt(
stage="unknown",
user_context=_build_user_context(),
)
except ValueError as exc:
assert "unknown stage" in str(exc)
else:
raise AssertionError("expected ValueError")
@@ -0,0 +1,22 @@
from __future__ import annotations
from core.agentscope.prompts.tool_prompt import build_tools_prompt
def test_build_tools_prompt_wraps_section_and_schema() -> None:
prompt = build_tools_prompt(
tools=[
{
"name": "calendar.read",
"description": "读取日程",
"parameters": {
"type": "object",
"properties": {"page": {"type": "integer"}},
},
}
]
)
assert "<!-- TOOLS_START -->" in prompt
assert "calendar.read" in prompt
assert '"page":{"type":"integer"}' in prompt
@@ -0,0 +1,32 @@
from __future__ import annotations
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agentscope.tools.toolkit import build_toolkit
@pytest.mark.asyncio
async def test_build_toolkit_registers_calendar_tools() -> None:
pytest.importorskip("agentscope")
toolkit = build_toolkit(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-123",
)
schemas = toolkit.get_json_schemas()
names = {item["function"]["name"] for item in schemas}
assert "calendar.read" in names
assert "calendar.write" in names
write_schema = next(
item for item in schemas if item["function"]["name"] == "calendar.write"
)
params = write_schema["function"]["parameters"]["properties"]
assert "user_token" not in params
assert "session" not in params
assert "owner_id" not in params
@@ -78,7 +78,7 @@ def test_verify_rejects_invalid_issuer() -> None:
issuer="https://wrong-issuer.example.com/auth/v1",
)
with pytest.raises(TokenValidationError):
with pytest.raises(TokenValidationError, match="Token issuer mismatch"):
verifier.verify(token)
@@ -94,7 +94,7 @@ def test_verify_rejects_missing_audience() -> None:
audience=None,
)
with pytest.raises(TokenValidationError):
with pytest.raises(TokenValidationError, match="Token validation failed"):
verifier.verify(token)
@@ -146,7 +146,7 @@ def test_verify_rejects_rs256_token() -> None:
issuer="https://example.supabase.co/auth/v1",
)
with pytest.raises(TokenValidationError):
with pytest.raises(TokenValidationError, match="Token algorithm invalid"):
verifier.verify(token)
@@ -168,7 +168,7 @@ def test_verify_rejects_expired_token() -> None:
algorithm="HS256",
)
with pytest.raises(TokenValidationError):
with pytest.raises(TokenValidationError, match="Token expired"):
verifier.verify(token)
@@ -10,45 +10,6 @@ from models.friendships import Friendship, FriendshipStatus
from models.inbox_messages import InboxMessage, InboxMessageStatus, InboxMessageType
class FakeFriendshipRepository:
"""Fake implementation for testing."""
def __init__(self) -> None:
self.friendships: dict[uuid.UUID, Friendship] = {}
self.inbox_messages: dict[uuid.UUID, InboxMessage] = {}
async def create_request(
self,
initiator_id: uuid.UUID,
recipient_id: uuid.UUID,
) -> tuple[Friendship, InboxMessage]:
raise NotImplementedError
async def get_friendship_between_users(
self, user_id_1: uuid.UUID, user_id_2: uuid.UUID
) -> Friendship | None:
raise NotImplementedError
async def get_pending_inbox_for_recipient(
self, recipient_id: uuid.UUID, friendship_id: uuid.UUID
) -> InboxMessage | None:
raise NotImplementedError
async def get_friendship_by_id(self, friendship_id: uuid.UUID) -> Friendship | None:
raise NotImplementedError
async def get_inbox_messages_for_user(
self, user_id: uuid.UUID, status: InboxMessageStatus | None = None
) -> list[InboxMessage]:
raise NotImplementedError
async def get_outgoing_requests(self, user_id: uuid.UUID) -> list[Friendship]:
raise NotImplementedError
async def get_friends_list(self, user_id: uuid.UUID) -> list[Friendship]:
raise NotImplementedError
class TestFriendshipRepository:
"""Tests for FriendshipRepository."""
@@ -112,12 +73,18 @@ class TestFriendshipRepository:
mock_session.execute = AsyncMock(side_effect=mock_execute_func)
friendship, inbox = await repository.create_request(initiator_id, recipient_id)
content = "你好,我是测试用户"
friendship, inbox = await repository.create_request(
initiator_id,
recipient_id,
content,
)
assert friendship is not None
assert inbox is not None
assert friendship.initiator_id == initiator_id
assert inbox.recipient_id == recipient_id
assert inbox.content == content
@pytest.mark.asyncio
async def test_get_friendship_between_users_returns_friendship(
@@ -44,7 +44,10 @@ class FakeFriendshipRepo:
self._inbox_messages = inbox_messages or []
async def create_request(
self, initiator_id: UUID, recipient_id: UUID
self,
initiator_id: UUID,
recipient_id: UUID,
content: str | None = None,
) -> tuple[Friendship, InboxMessage]:
friendship = MagicMock(spec=Friendship)
friendship.id = uuid4()
@@ -62,7 +65,34 @@ class FakeFriendshipRepo:
inbox.status = InboxMessageStatus.PENDING
inbox.message_type = InboxMessageType.FRIEND_REQUEST
inbox.friendship_id = friendship.id
inbox.content = None
inbox.content = content
self._inbox_messages.append(inbox)
return friendship, inbox
async def reactivate_request(
self,
friendship: Friendship,
initiator_id: UUID,
content: str | None = None,
) -> tuple[Friendship, InboxMessage]:
friendship.status = FriendshipStatus.PENDING
friendship.initiator_id = initiator_id
recipient_id = (
friendship.user_low_id
if initiator_id == friendship.user_high_id
else friendship.user_high_id
)
inbox = MagicMock(spec=InboxMessage)
inbox.id = uuid4()
inbox.recipient_id = recipient_id
inbox.sender_id = initiator_id
inbox.status = InboxMessageStatus.PENDING
inbox.message_type = InboxMessageType.FRIEND_REQUEST
inbox.friendship_id = friendship.id
inbox.content = content
self._inbox_messages.append(inbox)
return friendship, inbox
@@ -124,12 +154,6 @@ class FakeUserRepo:
async def get_by_user_id(self, user_id: UUID) -> MagicMock | None:
return self._profiles.get(user_id)
async def get_by_username(self, username: str) -> MagicMock | None:
for profile in self._profiles.values():
if profile.username == username:
return profile
return None
_repo_check: FriendshipRepository = FakeFriendshipRepo()
_user_repo_check: UserRepository = FakeUserRepo()
@@ -189,6 +213,28 @@ class TestSendRequest:
assert result is not None
mock_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_send_request_persists_content_to_inbox(
self,
mock_session: AsyncMock,
mock_friendship_repo: FakeFriendshipRepo,
mock_user_repo: FakeUserRepo,
current_user: CurrentUser,
) -> None:
service = FriendshipService(
repository=mock_friendship_repo,
user_repository=mock_user_repo,
session=mock_session,
current_user=current_user,
)
content = "你好,我是张三"
result = await service.send_request(
FriendRequestCreate(target_user_id=USER_B, content=content)
)
assert result.content == content
@pytest.mark.asyncio
async def test_send_request_to_self_raises_400(
self,
@@ -56,14 +56,14 @@ async def test_list_by_recipient_returns_messages() -> None:
execute_result.scalars.return_value.all.return_value = [message_one, message_two]
session.execute.return_value = execute_result
result = await repository.list_by_recipient(uuid4(), "pending")
result = await repository.list_by_recipient(uuid4(), False)
assert result == [message_one, message_two]
session.execute.assert_awaited_once()
@pytest.mark.asyncio
async def test_update_status_returns_updated_message_and_flushes() -> None:
async def test_mark_as_read_returns_updated_message_and_flushes() -> None:
session = AsyncMock()
repository = SQLAlchemyInboxMessageRepository(session)
updated = MagicMock()
@@ -71,7 +71,7 @@ async def test_update_status_returns_updated_message_and_flushes() -> None:
execute_result.scalar_one_or_none.return_value = updated
session.execute.return_value = execute_result
result = await repository.update_status(uuid4(), uuid4(), "dismissed")
result = await repository.mark_as_read(uuid4(), uuid4())
assert result is updated
session.execute.assert_awaited_once()
@@ -2,7 +2,6 @@ from datetime import datetime, timezone
from uuid import uuid4
from v1.inbox_messages.schemas import (
InboxMessageAcceptRequest,
InboxMessageResponse,
InboxMessageStatus,
InboxMessageType,
@@ -25,14 +24,3 @@ def test_inbox_message_response_schema() -> None:
assert response.message_type.value == "calendar"
assert response.status.value == "pending"
def test_inbox_message_accept_request_schema() -> None:
request = InboxMessageAcceptRequest(
permission_view=True,
permission_edit=False,
permission_invite=False,
)
assert request.permission_view is True
assert request.permission_edit is False
@@ -4,6 +4,7 @@ from uuid import UUID, uuid4
import pytest
from fastapi import HTTPException
from sqlalchemy.exc import SQLAlchemyError
from core.auth.models import CurrentUser
from models.inbox_messages import (
@@ -11,8 +12,6 @@ from models.inbox_messages import (
InboxMessageStatus as InboxMessageModelStatus,
InboxMessageType as InboxMessageModelType,
)
from models.schedule_subscriptions import ScheduleSubscription, SubscriptionStatus
from v1.inbox_messages.schemas import InboxMessageAcceptRequest, InboxMessageListRequest
from v1.inbox_messages.service import InboxMessageService
@@ -31,6 +30,7 @@ def _build_message(
message.sender_id = uuid4()
message.message_type = message_type
message.schedule_item_id = schedule_item_id
message.friendship_id = None
message.content = content
message.is_read = False
message.status = status
@@ -56,7 +56,7 @@ async def test_list_messages_returns_messages() -> None:
current_user=CurrentUser(id=user_id),
)
result = await service.list_messages(InboxMessageListRequest())
result = await service.list_messages()
assert len(result) == 1
assert result[0].recipient_id == user_id
@@ -65,28 +65,21 @@ async def test_list_messages_returns_messages() -> None:
@pytest.mark.asyncio
async def test_accept_invitation_creates_subscription() -> None:
async def test_mark_as_read_updates_message() -> None:
user_id = uuid4()
message_id = uuid4()
item_id = uuid4()
pending_message = _build_message(
updated_message = _build_message(
message_id=message_id,
recipient_id=user_id,
schedule_item_id=item_id,
)
accepted_message = _build_message(
message_id=message_id,
recipient_id=user_id,
status=InboxMessageModelStatus.ACCEPTED,
schedule_item_id=item_id,
status=InboxMessageModelStatus.PENDING,
schedule_item_id=uuid4(),
)
updated_message.is_read = True
repo = AsyncMock()
repo.get_by_id.return_value = pending_message
repo.update_status.return_value = accepted_message
repo.mark_as_read.return_value = updated_message
session = AsyncMock()
session.add = MagicMock()
service = InboxMessageService(
repository=repo,
@@ -94,46 +87,20 @@ async def test_accept_invitation_creates_subscription() -> None:
current_user=CurrentUser(id=user_id),
)
result = await service.accept_invitation(
message_id,
InboxMessageAcceptRequest(
permission_view=True,
permission_edit=True,
permission_invite=False,
),
)
result = await service.mark_as_read(message_id)
session.add.assert_called_once()
subscription = session.add.call_args.args[0]
assert isinstance(subscription, ScheduleSubscription)
assert subscription.item_id == item_id
assert subscription.subscriber_id == user_id
assert subscription.permission == 5 # view(1) + edit(4) = 5
assert subscription.status == SubscriptionStatus.ACTIVE
repo.update_status.assert_awaited_once_with(message_id, user_id, "accepted")
repo.mark_as_read.assert_awaited_once_with(message_id, user_id)
session.commit.assert_awaited_once()
assert result.status.value == "accepted"
assert result.is_read is True
@pytest.mark.asyncio
async def test_dismiss_invitation_updates_status() -> None:
async def test_mark_as_read_raises_404_when_message_missing() -> None:
user_id = uuid4()
message_id = uuid4()
pending_message = _build_message(
message_id=message_id,
recipient_id=user_id,
schedule_item_id=uuid4(),
)
dismissed_message = _build_message(
message_id=message_id,
recipient_id=user_id,
status=InboxMessageModelStatus.DISMISSED,
schedule_item_id=uuid4(),
)
repo = AsyncMock()
repo.get_by_id.return_value = pending_message
repo.update_status.return_value = dismissed_message
repo.mark_as_read.return_value = None
session = AsyncMock()
service = InboxMessageService(
@@ -142,29 +109,23 @@ async def test_dismiss_invitation_updates_status() -> None:
current_user=CurrentUser(id=user_id),
)
result = await service.dismiss_invitation(message_id)
with pytest.raises(HTTPException) as exc_info:
await service.mark_as_read(message_id)
repo.update_status.assert_awaited_once_with(message_id, user_id, "dismissed")
session.commit.assert_awaited_once()
assert result.status.value == "dismissed"
assert exc_info.value.status_code == 404
assert exc_info.value.detail == "Inbox message not found"
session.commit.assert_not_awaited()
@pytest.mark.asyncio
async def test_accept_noncalendar_message_fails() -> None:
async def test_mark_as_read_store_error_returns_503() -> None:
user_id = uuid4()
message_id = uuid4()
non_calendar_message = _build_message(
message_id=message_id,
recipient_id=user_id,
message_type=InboxMessageModelType.FRIEND_REQUEST,
schedule_item_id=None,
)
repo = AsyncMock()
repo.get_by_id.return_value = non_calendar_message
repo.mark_as_read.side_effect = SQLAlchemyError("boom")
session = AsyncMock()
session.add = MagicMock()
service = InboxMessageService(
repository=repo,
@@ -173,9 +134,8 @@ async def test_accept_noncalendar_message_fails() -> None:
)
with pytest.raises(HTTPException) as exc_info:
await service.accept_invitation(message_id, InboxMessageAcceptRequest())
await service.mark_as_read(message_id)
assert exc_info.value.status_code == 400
assert exc_info.value.detail == "Message is not a calendar invitation"
session.add.assert_not_called()
session.commit.assert_not_awaited()
assert exc_info.value.status_code == 503
assert exc_info.value.detail == "Inbox message store unavailable"
session.rollback.assert_awaited_once()
@@ -86,3 +86,30 @@ def test_metadata_attachment_reminder() -> None:
)
assert attachment.type == AttachmentType.REMINDER
assert attachment.content == "Don't forget!"
def test_metadata_rejects_invalid_color() -> None:
with pytest.raises(ValidationError):
ScheduleItemMetadata(color="blue")
def test_metadata_rejects_invalid_version() -> None:
with pytest.raises(ValidationError):
ScheduleItemMetadata(version=2)
def test_metadata_rejects_unknown_field() -> None:
with pytest.raises(ValidationError):
ScheduleItemMetadata.model_validate({"color": "#FF6B6B", "unknown": True})
def test_metadata_attachment_rejects_unknown_field() -> None:
with pytest.raises(ValidationError):
ScheduleItemMetadataAttachment.model_validate(
{
"name": "memo",
"type": "document",
"url": "https://example.com",
"unexpected": "x",
}
)
@@ -13,6 +13,7 @@ from models.schedule_items import (
)
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemMetadata,
ScheduleItemUpdateRequest,
)
from v1.schedule_items.service import ScheduleItemService
@@ -50,6 +51,11 @@ class FakeRepo:
return self._item
return None
async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None:
if self._item and entity_id == self._item.id:
return self._item
return None
async def create(self, data: dict) -> ScheduleItem:
return _create_mock_schedule_item(
owner_id=data["owner_id"],
@@ -77,6 +83,20 @@ class FakeRepo:
) -> list[ScheduleItem]:
return [self._item] if self._item else []
async def list_paginated(
self,
owner_id: UUID,
*,
page: int,
page_size: int,
) -> tuple[list[ScheduleItem], int]:
del owner_id, page, page_size
return ([self._item] if self._item else [], 1 if self._item else 0)
async def create_subscription(self, data: dict):
del data
return MagicMock()
@pytest.fixture
def mock_session() -> AsyncMock:
@@ -183,3 +203,70 @@ async def test_delete_success(mock_session: AsyncMock) -> None:
await service.delete(item.id)
mock_session.commit.assert_awaited_once()
@pytest.mark.asyncio
async def test_create_maps_metadata_to_extra_metadata(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
captured: dict | None = None
class CaptureRepo(FakeRepo):
async def create(self, data: dict) -> ScheduleItem:
nonlocal captured
captured = data
return _create_mock_schedule_item(
owner_id=data["owner_id"], title=data["title"]
)
request = ScheduleItemCreateRequest(
title="Roadmap",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
metadata=ScheduleItemMetadata(location="会议室A", color="#4F46E5", version=1),
)
service = ScheduleItemService(
repository=CaptureRepo(None),
session=mock_session,
current_user=CurrentUser(id=user_id),
)
await service.create(request)
assert captured is not None
assert "extra_metadata" in captured
assert captured["extra_metadata"]["location"] == "会议室A"
assert "metadata" not in captured
@pytest.mark.asyncio
async def test_update_maps_metadata_to_extra_metadata(mock_session: AsyncMock) -> None:
user_id = UUID("00000000-0000-0000-0000-000000000001")
item = _create_mock_schedule_item()
captured: dict | None = None
class CaptureRepo(FakeRepo):
async def update_by_item_id(
self, item_id: UUID, owner_id: UUID, data: dict
) -> ScheduleItem | None:
nonlocal captured
captured = data
return await super().update_by_item_id(item_id, owner_id, data)
service = ScheduleItemService(
repository=CaptureRepo(item),
session=mock_session,
current_user=CurrentUser(id=user_id),
)
await service.update(
item.id,
ScheduleItemUpdateRequest(
metadata=ScheduleItemMetadata(
location="线上会议", color="#3B82F6", version=1
)
),
)
assert captured is not None
assert "extra_metadata" in captured
assert captured["extra_metadata"]["location"] == "线上会议"
assert "metadata" not in captured