feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验

This commit is contained in:
zl-q
2026-03-17 00:13:41 +08:00
parent d3783522e6
commit c26cdbbc27
27 changed files with 1532 additions and 412 deletions
@@ -1,5 +1,7 @@
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from ag_ui.core import RunAgentInput
from agentscope.message import Msg
@@ -208,3 +210,89 @@ async def test_execute_uses_router_ui_mode_to_select_worker_output_model(
]
assert result["router"]["ui"]["ui_mode"] == "rich"
assert result["worker"]["answer"] == "done"
@pytest.mark.asyncio
async def test_execute_passes_runtime_client_time_to_router_and_worker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
runner = AgentScopeRunner()
pipeline = _FakePipeline()
captured: dict[str, object] = {}
class _CommitSession:
async def commit(self) -> None:
return None
monkeypatch.setattr(
"core.agentscope.runtime.runner.AsyncSessionLocal",
lambda: _FakeSessionCtx(_CommitSession()),
)
async def _load_system_agent_config(**kwargs):
return SystemAgentRuntimeConfig(
agent_type=kwargs["agent_type"],
model_code="model-a",
llm_config=SystemAgentLLMConfig(
temperature=0.1, max_tokens=256, timeout_seconds=30
),
)
monkeypatch.setattr(runner, "_load_system_agent_config", _load_system_agent_config)
async def _run_router_stage(**kwargs):
captured["router_timezone"] = kwargs["runtime_client_time"].device_timezone
return StageExecutionResult(
message=Msg(name="router", content="", role="assistant"),
payload=_router_output(ui_mode=UiMode.NONE).model_dump(mode="json"),
response_metadata={},
)
async def _run_worker_stage(**kwargs):
captured["worker_timezone"] = kwargs["runtime_client_time"].device_timezone
return StageExecutionResult(
message=Msg(name="worker", content="ok", role="assistant"),
payload={
"status": "success",
"answer": "ok",
"key_points": [],
"result_type": "direct_answer",
"suggested_actions": [],
"error": None,
},
response_metadata={},
)
monkeypatch.setattr(runner, "_run_router_stage", _run_router_stage)
monkeypatch.setattr(runner, "_run_worker_stage", _run_worker_stage)
monkeypatch.setattr(
"core.agentscope.runtime.runner.persist_router_message", AsyncMock()
)
run_input = RunAgentInput.model_validate(
{
"threadId": "00000000-0000-0000-0000-000000000010",
"runId": "run-client-time",
"state": {},
"messages": [{"id": "u1", "role": "user", "content": "hello"}],
"tools": [],
"context": [],
"forwardedProps": {
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
"client_epoch_ms": 1773658353000,
}
},
}
)
await runner.execute(
user_context=_user_context(),
context_messages=[],
pipeline=pipeline,
run_input=run_input,
)
assert captured["router_timezone"] == "America/Los_Angeles"
assert captured["worker_timezone"] == "America/Los_Angeles"
@@ -157,3 +157,75 @@ def test_parse_run_input_accepts_snake_case_aliases() -> None:
assert run_input.thread_id == "00000000-0000-0000-0000-000000000001"
assert run_input.run_id == "run-1"
validate_run_request_messages_contract(run_input)
def test_parse_run_input_accepts_client_time_forwarded_props() -> None:
payload = _base_payload()
payload["forwardedProps"] = {
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
"client_epoch_ms": 1773658353000,
}
}
run_input = parse_run_input(payload)
assert run_input.forwarded_props is not None
def test_parse_run_input_rejects_invalid_client_time_timezone() -> None:
payload = _base_payload()
payload["forwardedProps"] = {
"client_time": {
"device_timezone": "Mars/OlympusMons",
"client_now_iso": "2026-03-16T09:12:33-07:00",
"client_epoch_ms": 1773658353000,
}
}
with pytest.raises(ValueError, match="invalid RunAgentInput.forwardedProps"):
parse_run_input(payload)
def test_parse_run_input_rejects_invalid_client_time_now_iso() -> None:
payload = _base_payload()
payload["forwardedProps"] = {
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16 09:12:33",
"client_epoch_ms": 1773658353000,
}
}
with pytest.raises(ValueError, match="invalid RunAgentInput.forwardedProps"):
parse_run_input(payload)
def test_parse_run_input_rejects_invalid_client_time_epoch_type() -> None:
payload = _base_payload()
payload["forwardedProps"] = {
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
"client_epoch_ms": "1773658353000",
}
}
with pytest.raises(ValueError, match="invalid RunAgentInput.forwardedProps"):
parse_run_input(payload)
def test_parse_run_input_rejects_unknown_forwarded_props_key() -> None:
payload = _base_payload()
payload["forwardedProps"] = {
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
"client_epoch_ms": 1773658353000,
},
"unexpected": {"foo": "bar"},
}
with pytest.raises(ValueError, match="invalid RunAgentInput.forwardedProps"):
parse_run_input(payload)
@@ -1,189 +1,166 @@
from __future__ import annotations
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import Any, cast
from typing import Any
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from agentscope.tool import ToolResponse
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": {"ok": True}}
def _decode_tool_response(response: ToolResponse) -> dict[str, Any]:
assert response.content
first = response.content[0]
if isinstance(first, dict):
text = str(first.get("text", ""))
else:
text = str(getattr(first, "text", ""))
return json.loads(text)
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"
@dataclass
class _FakeService:
created_request: Any = None
async def create_agent_generated(self, request):
self.created_request = request
return SimpleNamespace(
id=uuid4(),
title=request.title,
description=request.description,
start_at=request.start_at,
end_at=request.end_at,
timezone=request.timezone,
metadata=request.metadata,
)
@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)
async def test_calendar_write_requires_runtime_context() -> None:
result = await calendar_module.calendar_write(operations=["create"])
payload = _decode_tool_response(result)
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"
assert payload["status"] == "failure"
assert payload["error"]["code"] == "MISSING_RUNTIME_ARGS"
@pytest.mark.asyncio
async def test_calendar_write_maps_event_id_for_update(
async def test_calendar_write_create_requires_start_at(
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}}
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "_execute_mutate_calendar_event", _fake_execute
calendar_module, "create_schedule_service", lambda *_: fake_service
)
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()),
operations=["create"],
event_timezones=["Asia/Shanghai"],
session=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
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "start_at" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_write_maps_reminder_minutes(
async def test_calendar_write_create_requires_event_timezone(
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}}
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "_execute_mutate_calendar_event", _fake_execute
calendar_module, "create_schedule_service", lambda *_: fake_service
)
monkeypatch.setattr(calendar_module, "_verify_user_token", lambda **_: True)
monkeypatch.setattr(calendar_module, "build_tool_response", lambda payload: payload)
await calendar_module.calendar_write(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
operation="create",
reminder_minutes=15,
)
assert captured["reminderMinutes"] == 15
@pytest.mark.asyncio
async def test_calendar_write_returns_failed_tool_response_on_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_execute(**kwargs: Any) -> dict[str, object]:
del kwargs
raise ValueError("eventId is required")
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()),
operations=["create"],
start_ats=["2026-03-16T09:00:00+08:00"],
session=SimpleNamespace(),
owner_id=uuid4(),
user_token="token-abc",
operation="update",
)
payload = _decode_tool_response(result)
assert result["type"] == "calendar_operation.v1"
assert result["data"]["ok"] is False
assert result["data"]["code"] == "INVALID_ARGUMENT"
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "event_timezone" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_share_maps_arguments(
async def test_calendar_write_rejects_naive_start_at(
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_operation.v1",
"version": "v1",
"data": {"operation": "share", "ok": True},
}
monkeypatch.setattr(calendar_module, "_execute_share_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_share(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="token-abc",
event_id=str(uuid4()),
invite_user_emails=["a@example.com"],
invite_user_names=["alice"],
invite_user_ids=[str(uuid4())],
invite_permission_view=True,
invite_permission_edit=True,
invite_permission_invite=True,
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
assert result["type"] == "calendar_operation.v1"
assert captured["eventId"]
assert captured["inviteUserEmails"] == ["a@example.com"]
assert captured["inviteUserNames"] == ["alice"]
assert isinstance(captured["inviteUserIds"], list)
assert captured["invitePermissionView"] is True
assert captured["invitePermissionEdit"] is True
assert captured["invitePermissionInvite"] is True
result = await calendar_module.calendar_write(
operations=["create"],
start_ats=["2026-03-16T09:00:00"],
event_timezones=["Asia/Shanghai"],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "时区" in payload["error"]["message"]
@pytest.mark.asyncio
async def test_calendar_share_requires_valid_user_token(
async def test_calendar_write_create_normalizes_to_utc(
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_share(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
user_token="bad-token",
event_id=str(uuid4()),
invite_user_emails=["a@example.com"],
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
assert result["data"]["ok"] is False
assert result["data"]["code"] == "UNAUTHORIZED"
result = await calendar_module.calendar_write(
operations=["create"],
titles=["晨会"],
start_ats=["2026-03-16T09:00:00+08:00"],
end_ats=["2026-03-16T10:00:00+08:00"],
event_timezones=["Asia/Shanghai"],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "success"
assert fake_service.created_request is not None
request = fake_service.created_request
assert request.timezone == "Asia/Shanghai"
assert request.start_at == datetime(2026, 3, 16, 1, 0, tzinfo=timezone.utc)
assert request.end_at == datetime(2026, 3, 16, 2, 0, tzinfo=timezone.utc)
@pytest.mark.asyncio
async def test_calendar_write_rejects_misaligned_batch_lists(
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_service = _FakeService()
monkeypatch.setattr(
calendar_module, "create_schedule_service", lambda *_: fake_service
)
result = await calendar_module.calendar_write(
operations=["create", "delete"],
start_ats=["2026-03-16T09:00:00+08:00"],
event_timezones=["Asia/Shanghai", "Asia/Shanghai"],
session=SimpleNamespace(),
owner_id=uuid4(),
)
payload = _decode_tool_response(result)
assert payload["status"] == "failure"
assert payload["error"]["code"] == "INVALID_ARGUMENT"
assert "长度必须与 operations 一致" in payload["error"]["message"]
@@ -7,6 +7,7 @@ from core.agentscope.prompts.system_prompt import (
_build_env_section,
build_system_prompt,
)
from schemas.agent.forwarded_props import ClientTimeContext
from schemas.agent.system_agent import AgentType
from schemas.user.context import UserContext, parse_profile_settings
@@ -35,6 +36,7 @@ def test_build_env_section_uses_balanced_runtime_context_structure() -> None:
section = _build_env_section(
user_context=_build_user_context(),
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
runtime_client_time=None,
extra_context=None,
)
@@ -49,7 +51,7 @@ def test_build_env_section_uses_balanced_runtime_context_structure() -> None:
assert "Response language default: ai_language=zh-CN." in section
assert "UI labels and short actions default: interface_language=zh-CN." in section
assert (
"Resolve ambiguous dates/times with timezone=Asia/Shanghai and system_time_local."
"Resolve ambiguous dates/times with timezone_effective=Asia/Shanghai and system_time_local."
in section
)
assert "Use country=CN only when locale is unspecified." in section
@@ -59,6 +61,7 @@ def test_build_env_section_omits_removed_redundant_contract_phrasing() -> None:
section = _build_env_section(
user_context=_build_user_context(),
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
runtime_client_time=None,
extra_context=None,
)
@@ -91,6 +94,7 @@ def test_build_env_section_includes_optional_privacy_and_notification_hints() ->
section = _build_env_section(
user_context=user_context,
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
runtime_client_time=None,
extra_context="runtime flag: mobile-client",
)
@@ -105,6 +109,27 @@ def test_build_env_section_includes_optional_privacy_and_notification_hints() ->
assert '"system_time_local":"2026-03-11T01:00:00+01:00"' in section
def test_build_env_section_prefers_device_timezone_when_present() -> None:
section = _build_env_section(
user_context=_build_user_context(timezone_name="Asia/Shanghai"),
now_utc=datetime(2026, 3, 11, 0, 0, tzinfo=timezone.utc),
runtime_client_time=ClientTimeContext(
device_timezone="America/Los_Angeles",
client_now_iso="2026-03-10T17:00:00-07:00",
client_epoch_ms=1773658353000,
),
extra_context=None,
)
assert '"timezone_profile":"Asia/Shanghai"' in section
assert '"timezone_device":"America/Los_Angeles"' in section
assert '"timezone_effective":"America/Los_Angeles"' in section
assert (
"Resolve ambiguous dates/times with timezone_effective=America/Los_Angeles"
in section
)
def test_build_system_prompt_keeps_sections_focused_without_language_duplication() -> (
None
):
@@ -16,6 +16,7 @@ def test_create_request_valid() -> None:
request = ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
)
assert request.title == "Test Event"
assert request.timezone == "UTC"
@@ -26,6 +27,7 @@ def test_create_request_with_end_at() -> None:
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 2, 28, 17, 30, 0, tzinfo=timezone.utc),
timezone="UTC",
)
assert request.end_at is not None
@@ -35,6 +37,7 @@ def test_create_request_invalid_title_empty() -> None:
ScheduleItemCreateRequest(
title="",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
)
@@ -43,6 +46,7 @@ def test_create_request_invalid_title_too_long() -> None:
ScheduleItemCreateRequest(
title="x" * 256,
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
)
@@ -56,6 +60,7 @@ def test_create_request_with_metadata() -> None:
request = ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
metadata=metadata,
)
assert request.metadata is not None
@@ -68,6 +73,24 @@ def test_update_request_partial() -> None:
assert request.description is None
def test_create_request_rejects_naive_datetime() -> None:
with pytest.raises(ValidationError):
ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0),
timezone="UTC",
)
def test_create_request_rejects_invalid_timezone() -> None:
with pytest.raises(ValidationError):
ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="Mars/OlympusMons",
)
def test_metadata_attachment_document() -> None:
attachment = ScheduleItemMetadataAttachment(
name="document.pdf",
@@ -95,7 +118,7 @@ def test_metadata_rejects_invalid_color() -> None:
def test_metadata_rejects_invalid_version() -> None:
with pytest.raises(ValidationError):
ScheduleItemMetadata(version=2)
ScheduleItemMetadata.model_validate({"version": 2})
def test_metadata_rejects_unknown_field() -> None:
@@ -148,6 +148,7 @@ async def test_create_success(
request = ScheduleItemCreateRequest(
title="Test Event",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
)
service = ScheduleItemService(
repository=FakeRepo(None),
@@ -171,6 +172,7 @@ async def test_create_invalid_end_at(
title="Test Event",
start_at=datetime(2026, 2, 28, 17, 0, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
)
service = ScheduleItemService(
repository=FakeRepo(None),
@@ -275,6 +277,7 @@ async def test_create_maps_metadata_to_extra_metadata(
request = ScheduleItemCreateRequest(
title="Roadmap",
start_at=datetime(2026, 2, 28, 16, 0, 0, tzinfo=timezone.utc),
timezone="UTC",
metadata=ScheduleItemMetadata(
location="会议室A",
color="#4F46E5",