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
@@ -11,6 +11,7 @@ from core.agentscope.prompts.agent_prompt import (
)
from core.agentscope.prompts.tool_prompt import build_tools_prompt
from schemas.agent.system_agent import AgentType
from schemas.agent.forwarded_props import ClientTimeContext
from schemas.user.context import UserContext
@@ -102,10 +103,14 @@ def _build_env_section(
*,
user_context: UserContext,
now_utc: datetime,
runtime_client_time: ClientTimeContext | None,
extra_context: str | None,
) -> str:
settings = _get_attr(user_context, "settings")
preferences = _get_user_preferences(user_context)
timezone_profile = preferences["timezone"]
timezone_device = runtime_client_time.device_timezone if runtime_client_time else ""
timezone_effective = timezone_device or timezone_profile
privacy = _get_attr(settings, "privacy")
notification = _get_attr(settings, "notification")
user_id = _get_attr(user_context, "id") or _get_attr(user_context, "user_id")
@@ -117,14 +122,17 @@ def _build_env_section(
),
"interface_language": preferences["interface_language"],
"ai_language": preferences["ai_language"],
"timezone": preferences["timezone"],
"timezone": timezone_effective,
"timezone_profile": timezone_profile,
"timezone_device": timezone_device,
"timezone_effective": timezone_effective,
"country": preferences["country"],
"system_time_utc": (now_utc or datetime.now(timezone.utc))
.astimezone(timezone.utc)
.isoformat(),
"system_time_local": _resolve_local_time(
now_utc=now_utc,
timezone_name=preferences["timezone"],
timezone_name=timezone_effective,
),
}
@@ -138,7 +146,7 @@ def _build_env_section(
"- Latest explicit user request overrides defaults.",
f"- Response language default: ai_language={preferences['ai_language']}.",
f"- UI labels and short actions default: interface_language={preferences['interface_language']}.",
f"- Resolve ambiguous dates/times with timezone={preferences['timezone']} and system_time_local.",
f"- Resolve ambiguous dates/times with timezone_effective={timezone_effective} and system_time_local.",
f"- Use country={preferences['country']} only when locale is unspecified.",
]
@@ -190,6 +198,7 @@ def build_system_prompt(
agent_type: AgentType,
user_context: UserContext,
now_utc: datetime,
runtime_client_time: ClientTimeContext | None = None,
extra_context: str | None = None,
tools: Sequence[Tool | dict[str, Any]] | None = None,
) -> str:
@@ -198,6 +207,7 @@ def build_system_prompt(
_build_env_section(
user_context=user_context,
now_utc=now_utc,
runtime_client_time=runtime_client_time,
extra_context=extra_context,
),
_build_safety_section(),
@@ -30,6 +30,10 @@ from schemas.agent.runtime_models import (
WorkerAgentOutputLite,
resolve_worker_output_model,
)
from schemas.agent.forwarded_props import (
ClientTimeContext,
parse_forwarded_props_client_time,
)
from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig
from schemas.user import UserContext
from services.litellm.service import LiteLLMService
@@ -70,6 +74,7 @@ class AgentScopeRunner:
run_input: RunAgentInput,
) -> dict[str, Any]:
owner_id = UUID(user_context.id)
runtime_client_time = self._resolve_runtime_client_time(run_input=run_input)
async with AsyncSessionLocal() as session:
worker_toolkit = self._build_worker_toolkit(
@@ -86,6 +91,7 @@ class AgentScopeRunner:
user_context=user_context,
context_messages=context_messages,
stage_config=router_config,
runtime_client_time=runtime_client_time,
)
worker_output = await self._execute_worker_step(
pipeline=pipeline,
@@ -94,6 +100,7 @@ class AgentScopeRunner:
router_output=router_output,
toolkit=worker_toolkit,
stage_config=worker_config,
runtime_client_time=runtime_client_time,
)
return {
@@ -137,6 +144,7 @@ class AgentScopeRunner:
user_context: UserContext,
context_messages: list[Msg],
stage_config: SystemAgentRuntimeConfig,
runtime_client_time: ClientTimeContext | None,
) -> RouterAgentOutput:
await self._emit_step_event(
pipeline=pipeline,
@@ -149,6 +157,7 @@ class AgentScopeRunner:
context_messages=context_messages,
run_input=run_input,
stage_config=stage_config,
runtime_client_time=runtime_client_time,
)
router_output = RouterAgentOutput.model_validate(router_result.payload)
await persist_router_message(
@@ -177,6 +186,7 @@ class AgentScopeRunner:
router_output: RouterAgentOutput,
toolkit: Any,
stage_config: SystemAgentRuntimeConfig,
runtime_client_time: ClientTimeContext | None,
) -> WorkerAgentOutputLite:
worker_output_model = resolve_worker_output_model(router_output.ui.ui_mode)
await self._emit_step_event(
@@ -193,6 +203,7 @@ class AgentScopeRunner:
stage_config=stage_config,
worker_output_model=worker_output_model,
pipeline=pipeline,
runtime_client_time=runtime_client_time,
)
worker_output = worker_output_model.model_validate(worker_result.payload)
await self._emit_step_event(
@@ -234,12 +245,14 @@ class AgentScopeRunner:
context_messages: list[Msg],
run_input: RunAgentInput,
stage_config: SystemAgentRuntimeConfig,
runtime_client_time: ClientTimeContext | None,
) -> StageExecutionResult:
tracking_model = self._build_model(stage_config=stage_config)
system_prompt = build_system_prompt(
agent_type=AgentType.ROUTER,
user_context=user_context,
now_utc=datetime.now(timezone.utc),
runtime_client_time=runtime_client_time,
tools=None,
)
response, payload = await finalize_json_response(
@@ -281,6 +294,7 @@ class AgentScopeRunner:
stage_config: SystemAgentRuntimeConfig,
worker_output_model: type[WorkerAgentOutputLite],
pipeline: PipelineLike,
runtime_client_time: ClientTimeContext | None,
) -> StageExecutionResult:
worker_input = self._build_worker_input_messages(router_output=router_output)
tracking_model = self._build_model(stage_config=stage_config)
@@ -298,6 +312,7 @@ class AgentScopeRunner:
agent_type=AgentType.WORKER,
user_context=user_context,
now_utc=datetime.now(timezone.utc),
runtime_client_time=runtime_client_time,
tools=None,
),
toolkit=toolkit,
@@ -392,5 +407,12 @@ class AgentScopeRunner:
},
)
def _resolve_runtime_client_time(
self, *, run_input: RunAgentInput
) -> ClientTimeContext | None:
return parse_forwarded_props_client_time(
getattr(run_input, "forwarded_props", None)
)
AgentScopeReActRunner = AgentScopeRunner
@@ -6,6 +6,7 @@ from uuid import UUID
from ag_ui.core import RunAgentInput
from pydantic import ValidationError
from schemas.agent.forwarded_props import parse_forwarded_props_client_time
MAX_RUN_INPUT_BYTES = 256_000
MAX_RUN_ID_LENGTH = 128
@@ -101,6 +102,7 @@ def parse_run_input(payload: dict[str, Any]) -> RunAgentInput:
raise ValueError("RunAgentInput.messages exceeds limit")
if _user_text_chars(run_input) > MAX_TEXT_CHARS:
raise ValueError("RunAgentInput user message text exceeds limit")
parse_forwarded_props_client_time(getattr(run_input, "forwarded_props", None))
return run_input
@@ -1,4 +1,3 @@
from datetime import datetime, timedelta, timezone
from typing import Annotated, Any, Literal, cast
from uuid import UUID
@@ -22,7 +21,8 @@ from core.agentscope.tools.utils.calendar_ui import (
calendar_write_hints,
dump_tool_output,
)
from schemas.agent.runtime_models import ToolAgentOutput, ToolStatus
from schemas.agent.runtime_models import ErrorInfo, ToolAgentOutput, ToolStatus
from schemas.agent.ui_hints import UiHintListItem, UiHintStatus
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemShareRequest,
@@ -146,85 +146,126 @@ async def calendar_read(
async def calendar_write(
operation: Annotated[
Literal["create", "update", "delete"],
Field(description="Write operation: create, update, or delete."),
],
event_id: Annotated[
str | None,
Field(description="Required event ID for update/delete operations."),
] = None,
title: Annotated[
str | None,
Field(description="Event title.", max_length=255),
] = None,
description: Annotated[
str | None,
Field(description="Event description.", max_length=2000),
] = None,
start_at: Annotated[
str | None,
Field(description="Event start time in ISO 8601 format."),
] = None,
end_at: Annotated[
str | None,
Field(description="Event end time in ISO 8601 format."),
] = None,
event_timezone: Annotated[
str | None,
Field(description="IANA timezone name for the event.", max_length=50),
] = None,
location: Annotated[str | None, Field(description="Event location.")] = None,
color: Annotated[
str | None,
Field(description="Event color value, for example #4F46E5."),
] = None,
reminder_minutes: Annotated[
int | None,
operations: Annotated[
list[Literal["create", "update", "delete"]],
Field(
description="Minutes before start time to trigger reminder (0-10080).",
ge=0,
le=10080,
description=(
"Batch operations list. Each item must be create, update, or delete."
),
min_length=1,
max_length=20,
),
],
event_ids: Annotated[
list[str | None] | None,
Field(
description=(
"Optional event id list aligned with operations. "
"Required for update/delete item."
)
),
] = None,
status: Annotated[
Literal["active", "completed", "canceled", "archived"] | None,
Field(description="Event status: active, completed, canceled, or archived."),
titles: Annotated[
list[str | None] | None,
Field(description="Optional title list aligned with operations."),
] = None,
descriptions: Annotated[
list[str | None] | None,
Field(description="Optional description list aligned with operations."),
] = None,
start_ats: Annotated[
list[str | None] | None,
Field(
description=(
"Optional start time list aligned with operations, ISO 8601 with timezone."
)
),
] = None,
end_ats: Annotated[
list[str | None] | None,
Field(
description=(
"Optional end time list aligned with operations, ISO 8601 with timezone."
)
),
] = None,
event_timezones: Annotated[
list[str | None] | None,
Field(
description=(
"Optional event timezone list aligned with operations, IANA timezone."
)
),
] = None,
locations: Annotated[
list[str | None] | None,
Field(description="Optional location list aligned with operations."),
] = None,
colors: Annotated[
list[str | None] | None,
Field(description="Optional color list aligned with operations."),
] = None,
reminder_minutes_list: Annotated[
list[int | None] | None,
Field(
description=(
"Optional reminder minutes list aligned with operations, value range 0-10080."
)
),
] = None,
statuses: Annotated[
list[Literal["active", "completed", "canceled", "archived"] | None] | None,
Field(description="Optional status list aligned with operations."),
] = None,
session: Any = None,
owner_id: Any = None,
) -> ToolResponse:
"""Create, update, or delete a calendar event.
"""Batch create/update/delete calendar events using aligned list parameters.
Args:
operation: Write operation type, one of create, update, delete.
event_id: Target event id for update and delete operations.
title: Event title.
description: Event description.
start_at: Event start time in ISO 8601 format.
end_at: Event end time in ISO 8601 format.
event_timezone: IANA timezone string.
location: Event location.
color: Event color in hex format, for example #4F46E5.
reminder_minutes: Reminder lead time in minutes.
status: Event status value.
operations: Operation list. Length defines batch size.
event_ids: Optional event id list aligned with operations.
titles: Optional title list aligned with operations.
descriptions: Optional description list aligned with operations.
start_ats: Optional start time list aligned with operations.
end_ats: Optional end time list aligned with operations.
event_timezones: Optional event timezone list aligned with operations.
locations: Optional location list aligned with operations.
colors: Optional color list aligned with operations.
reminder_minutes_list: Optional reminder minute list aligned with operations.
statuses: Optional status list aligned with operations.
Constraints:
- All provided list parameters must have the same length as operations.
- create item requires start_ats[i] and event_timezones[i].
- update/delete item requires event_ids[i].
- start/end datetime must include timezone offset.
Returns:
ToolResponse with serialized ToolAgentOutput payload.
"""
tool_name = "calendar_write"
def _align_list(name: str, values: list[Any] | None, size: int) -> list[Any | None]:
if values is None:
return [None] * size
if len(values) != size:
raise ValueError(f"{name} 长度必须与 operations 一致")
return list(values)
batch_size = len(operations)
tool_call_args = {
"operation": operation,
"event_id": event_id,
"title": title,
"description": description,
"start_at": start_at,
"end_at": end_at,
"event_timezone": event_timezone,
"location": location,
"color": color,
"reminder_minutes": reminder_minutes,
"status": status,
"operations": operations,
"event_ids": event_ids,
"titles": titles,
"descriptions": descriptions,
"start_ats": start_ats,
"end_ats": end_ats,
"event_timezones": event_timezones,
"locations": locations,
"colors": colors,
"reminder_minutes_list": reminder_minutes_list,
"statuses": statuses,
}
runtime_error = _validate_runtime_context(
tool_name=tool_name,
@@ -239,143 +280,235 @@ async def calendar_write(
service = create_schedule_service(
cast(AsyncSession, session), cast(UUID, owner_id)
)
aligned_event_ids = _align_list("event_ids", event_ids, batch_size)
aligned_titles = _align_list("titles", titles, batch_size)
aligned_descriptions = _align_list("descriptions", descriptions, batch_size)
aligned_start_ats = _align_list("start_ats", start_ats, batch_size)
aligned_end_ats = _align_list("end_ats", end_ats, batch_size)
aligned_event_timezones = _align_list(
"event_timezones", event_timezones, batch_size
)
aligned_locations = _align_list("locations", locations, batch_size)
aligned_colors = _align_list("colors", colors, batch_size)
aligned_reminders = _align_list(
"reminder_minutes_list", reminder_minutes_list, batch_size
)
aligned_statuses = _align_list("statuses", statuses, batch_size)
if operation == "create":
parsed_start = parse_iso_datetime(start_at) if start_at else None
if parsed_start is None:
parsed_start = datetime.now(timezone.utc) + timedelta(hours=1)
parsed_end = parse_iso_datetime(end_at) if end_at else None
tz = (
event_timezone.strip()
if event_timezone and event_timezone.strip()
else "Asia/Shanghai"
)
success_count = 0
failed_count = 0
result_items: list[dict[str, Any]] = []
created = await service.create_agent_generated(
ScheduleItemCreateRequest(
title=title.strip() if title and title.strip() else "新的日程",
description=description.strip()
if description and description.strip()
else None,
start_at=parsed_start,
end_at=parsed_end,
timezone=tz,
metadata=build_schedule_metadata(location, color, reminder_minutes),
)
)
event_dict = schedule_event_to_dict(created)
summary = f"日程「{created.title}」已创建"
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=calendar_write_hints(
operation="create",
message=summary,
event=event_dict,
event_id=event_id,
),
)
)
for idx, operation in enumerate(operations):
event_id = aligned_event_ids[idx]
title = aligned_titles[idx]
description = aligned_descriptions[idx]
start_at = aligned_start_ats[idx]
end_at = aligned_end_ats[idx]
event_timezone = aligned_event_timezones[idx]
location = aligned_locations[idx]
color = aligned_colors[idx]
reminder_minutes = aligned_reminders[idx]
status = aligned_statuses[idx]
if operation == "update":
if not event_id:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="INVALID_ARGUMENT",
message="更新日程需要提供 event_id",
retryable=False,
)
parsed_event_id = UUID(event_id)
update_data: dict[str, Any] = {}
if title:
update_data["title"] = title.strip()
if description:
update_data["description"] = description.strip()
if start_at:
update_data["start_at"] = parse_iso_datetime(start_at)
if end_at:
update_data["end_at"] = parse_iso_datetime(end_at)
if event_timezone:
update_data["timezone"] = event_timezone.strip()
if status:
try:
update_data["status"] = ScheduleItemStatus(status)
except ValueError:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="INVALID_ARGUMENT",
message="status 必须是 active, completed, canceled, archived 之一",
retryable=False,
try:
if operation == "create":
if start_at is None or not start_at.strip():
raise ValueError(
"创建日程需要提供 start_at,且必须包含时区偏移"
)
if event_timezone is None or not event_timezone.strip():
raise ValueError("创建日程需要提供 event_timezone")
parsed_start = parse_iso_datetime(start_at)
if parsed_start is None:
raise ValueError(
"创建日程需要提供 start_at,且必须包含时区偏移"
)
parsed_end = parse_iso_datetime(end_at) if end_at else None
created = await service.create_agent_generated(
ScheduleItemCreateRequest(
title=title.strip()
if title and title.strip()
else "新的日程",
description=description.strip()
if description and description.strip()
else None,
start_at=parsed_start,
end_at=parsed_end,
timezone=event_timezone.strip(),
metadata=build_schedule_metadata(
location,
color,
cast(int | None, reminder_minutes),
),
)
)
if location or color or reminder_minutes is not None:
existing = await service.get_by_id(parsed_event_id)
update_data["metadata"] = merge_schedule_metadata_for_update(
existing_metadata=existing.metadata,
location=location,
color=color,
reminder_minutes=reminder_minutes,
success_count += 1
result_items.append(
{
"index": idx,
"operation": operation,
"status": "success",
"eventId": str(created.id),
"message": f"日程「{created.title}」已创建",
}
)
continue
if operation == "update":
if event_id is None or not event_id.strip():
raise ValueError("更新日程需要提供 event_id")
parsed_event_id = UUID(event_id)
update_data: dict[str, Any] = {}
if title is not None:
update_data["title"] = title.strip()
if description is not None:
update_data["description"] = description.strip()
if start_at:
update_data["start_at"] = parse_iso_datetime(start_at)
if end_at:
update_data["end_at"] = parse_iso_datetime(end_at)
if event_timezone is not None:
timezone_value = event_timezone.strip()
if not timezone_value:
raise ValueError("event_timezone 不能为空")
update_data["timezone"] = timezone_value
if status:
update_data["status"] = ScheduleItemStatus(status)
if location or color or reminder_minutes is not None:
existing = await service.get_by_id(parsed_event_id)
update_data["metadata"] = merge_schedule_metadata_for_update(
existing_metadata=existing.metadata,
location=cast(str | None, location),
color=cast(str | None, color),
reminder_minutes=cast(int | None, reminder_minutes),
)
updated = await service.update(
parsed_event_id,
ScheduleItemUpdateRequest.model_validate(update_data),
)
success_count += 1
result_items.append(
{
"index": idx,
"operation": operation,
"status": "success",
"eventId": str(updated.id),
"message": f"日程「{updated.title}」已更新",
}
)
continue
if operation == "delete":
if event_id is None or not event_id.strip():
raise ValueError("删除日程需要提供 event_id")
await service.delete(UUID(event_id))
success_count += 1
result_items.append(
{
"index": idx,
"operation": operation,
"status": "success",
"eventId": event_id,
"message": f"日程 {event_id} 已删除",
}
)
continue
except Exception as exc:
code, message, _ = map_calendar_exception(exc)
failed_count += 1
result_items.append(
{
"index": idx,
"operation": operation,
"status": "failure",
"eventId": event_id,
"code": code,
"message": message,
}
)
updated = await service.update(
parsed_event_id, ScheduleItemUpdateRequest.model_validate(update_data)
if failed_count == 0:
final_status = ToolStatus.SUCCESS
ui_status = UiHintStatus.SUCCESS
summary = f"日程批量操作完成,共 {batch_size} 条,成功 {success_count}"
elif success_count == 0:
final_status = ToolStatus.FAILURE
ui_status = UiHintStatus.ERROR
summary = f"日程批量操作失败,共 {batch_size} 条,失败 {failed_count}"
else:
final_status = ToolStatus.PARTIAL
ui_status = UiHintStatus.WARNING
summary = f"日程批量操作部分成功,共 {batch_size} 条,成功 {success_count} 条,失败 {failed_count}"
error_info: ErrorInfo | None = None
if final_status == ToolStatus.FAILURE:
first_failure = next(
(
item
for item in result_items
if isinstance(item, dict) and item.get("status") == "failure"
),
None,
)
event_dict = schedule_event_to_dict(updated)
summary = f"日程「{updated.title}」已更新"
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=calendar_write_hints(
operation="update",
message=summary,
event=event_dict,
event_id=event_id,
),
)
error_info = ErrorInfo(
code=str(
first_failure.get("code") if first_failure else "BATCH_FAILED"
),
message=str(first_failure.get("message") if first_failure else summary),
retryable=False,
details={"results": result_items},
)
if operation == "delete":
if not event_id:
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="INVALID_ARGUMENT",
message="删除日程需要提供 event_id",
retryable=False,
)
await service.delete(UUID(event_id))
summary = f"日程 {event_id} 已删除"
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=ToolStatus.SUCCESS,
result_summary=summary,
ui_hints=calendar_write_hints(
operation="delete",
message=summary,
event=None,
event_id=event_id,
),
)
result_list_items = [
UiHintListItem(
id=(
str(item.get("eventId"))
if isinstance(item, dict) and item.get("eventId") is not None
else None
),
title=(
f"#{int(item.get('index', 0)) + 1} {str(item.get('operation', 'unknown'))}"
if isinstance(item, dict)
else "unknown"
),
subtitle=(
"成功"
if isinstance(item, dict) and item.get("status") == "success"
else "失败"
),
description=(
str(item.get("message") or "") if isinstance(item, dict) else ""
),
)
for item in result_items
]
return calendar_error_output(
tool_name=tool_name,
tool_call_args=tool_call_args,
code="INVALID_ARGUMENT",
message="无效的操作类型",
retryable=False,
return dump_tool_output(
ToolAgentOutput(
tool_name=tool_name,
tool_call_id=f"{tool_name}-call",
tool_call_args=tool_call_args,
status=final_status,
result_summary=summary,
error=error_info,
ui_hints=calendar_write_hints(
operation="batch",
message=summary,
event=None,
event_id=None,
status=ui_status,
).model_copy(
update={
"list_items": result_list_items,
"meta": {
"total": batch_size,
"success": success_count,
"failed": failed_count,
},
}
),
)
)
except Exception as exc:
@@ -118,11 +118,11 @@ def parse_iso_datetime(value: str | None) -> datetime | None:
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.astimezone(timezone.utc)
except ValueError:
return None
except ValueError as exc:
raise ValueError("时间格式必须是 ISO8601 且包含时区偏移") from exc
if parsed.tzinfo is None:
raise ValueError("时间必须包含时区信息")
return parsed.astimezone(timezone.utc)
def resolve_share_target_email_map(invitee_user_ids: list[str]) -> dict[str, str]:
@@ -79,7 +79,7 @@ def calendar_read_hints(
UiHintKvItem(key="page_size", label="每页", value=page_size),
UiHintKvItem(key="total_pages", label="总页数", value=total_pages),
],
list_items=event_items,
listItems=event_items,
actions=[
UiHintAction(
label="打开日历",
@@ -97,6 +97,7 @@ def calendar_write_hints(
message: str,
event: dict[str, Any] | None,
event_id: str | None,
status: UiHintStatus = UiHintStatus.SUCCESS,
) -> UiHintsPayload:
kv_items: list[UiHintKvItem] = []
@@ -126,10 +127,10 @@ def calendar_write_hints(
return UiHintsPayload(
intent=UiHintIntent.STATUS,
status=UiHintStatus.SUCCESS,
status=status,
title="日历操作完成",
body=message,
items=kv_items if kv_items else None,
items=kv_items,
actions=[
UiHintAction(
label="查看日历",
@@ -159,7 +160,5 @@ def calendar_share_hints(
UiHintKvItem(key="event_id", label="日程ID", value=event_id, copyable=True),
UiHintKvItem(key="permission", label="权限", value=permission_text),
],
list_items=[UiHintListItem(title=email) for email in invited]
if invited
else [],
listItems=[UiHintListItem(title=email) for email in invited] if invited else [],
)
+6
View File
@@ -1,3 +1,7 @@
from schemas.agent.forwarded_props import (
ClientTimeContext,
parse_forwarded_props_client_time,
)
from schemas.agent.runtime_models import (
ResultType,
RouterAgentOutput,
@@ -22,6 +26,7 @@ from schemas.agent.ui_hints import (
__all__ = [
"AgentType",
"ClientTimeContext",
"ResultType",
"RouterAgentOutput",
"RouterUiDecision",
@@ -39,4 +44,5 @@ __all__ = [
"WorkerAgentOutputRich",
"WorkerAgentOutput",
"resolve_worker_output_model",
"parse_forwarded_props_client_time",
]
@@ -0,0 +1,78 @@
from __future__ import annotations
from datetime import datetime
import re
from typing import Any
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import (
BaseModel,
ConfigDict,
Field,
StrictInt,
ValidationError,
field_validator,
)
_RFC3339_WITH_TZ_PATTERN = re.compile(
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$"
)
class ClientTimeContext(BaseModel):
model_config = ConfigDict(extra="forbid")
device_timezone: str = Field(
...,
description="IANA timezone from client device, e.g. America/Los_Angeles.",
)
client_now_iso: str = Field(
...,
description="RFC3339 datetime with timezone offset from client device.",
)
client_epoch_ms: StrictInt = Field(
...,
ge=0,
description="Unix epoch milliseconds from client device.",
)
@field_validator("device_timezone")
@classmethod
def validate_device_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("invalid client_time.device_timezone") from exc
return value
@field_validator("client_now_iso")
@classmethod
def validate_client_now_iso(cls, value: str) -> str:
if not _RFC3339_WITH_TZ_PATTERN.fullmatch(value):
raise ValueError("invalid client_time.client_now_iso")
normalized = value.replace("Z", "+00:00")
try:
parsed = datetime.fromisoformat(normalized)
except ValueError as exc:
raise ValueError("invalid client_time.client_now_iso") from exc
if parsed.tzinfo is None:
raise ValueError("invalid client_time.client_now_iso")
return value
class ForwardedPropsPayload(BaseModel):
model_config = ConfigDict(extra="forbid")
client_time: ClientTimeContext | None = None
def parse_forwarded_props_client_time(
forwarded_props: Any,
) -> ClientTimeContext | None:
if not isinstance(forwarded_props, dict):
return None
try:
payload = ForwardedPropsPayload.model_validate(forwarded_props)
except ValidationError as exc:
raise ValueError("invalid RunAgentInput.forwardedProps") from exc
return payload.client_time
+48 -2
View File
@@ -3,8 +3,9 @@ from __future__ import annotations
from datetime import datetime
from typing import ClassVar
from uuid import UUID
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from pydantic import BaseModel, ConfigDict, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator
from schemas.inbox.messages import (
CalendarContent,
@@ -49,9 +50,27 @@ class ScheduleItemCreateRequest(BaseModel):
description: str | None = Field(default=None, max_length=2000)
start_at: datetime
end_at: datetime | None = None
timezone: str = Field(default="UTC", max_length=50)
timezone: str = Field(..., min_length=1, max_length=50)
metadata: ScheduleItemMetadata | None = None
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str) -> str:
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("timezone must be a valid IANA timezone") from exc
return value
@field_validator("start_at", "end_at")
@classmethod
def validate_datetime_tzinfo(cls, value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
raise ValueError("datetime must include timezone offset")
return value
class ScheduleItemUpdateRequest(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@@ -64,6 +83,26 @@ class ScheduleItemUpdateRequest(BaseModel):
metadata: ScheduleItemMetadata | None = None
status: ScheduleItemStatus | None = None
@field_validator("timezone")
@classmethod
def validate_timezone(cls, value: str | None) -> str | None:
if value is None:
return None
try:
ZoneInfo(value)
except ZoneInfoNotFoundError as exc:
raise ValueError("timezone must be a valid IANA timezone") from exc
return value
@field_validator("start_at", "end_at")
@classmethod
def validate_datetime_tzinfo(cls, value: datetime | None) -> datetime | None:
if value is None:
return None
if value.tzinfo is None:
raise ValueError("datetime must include timezone offset")
return value
class ScheduleItemResponse(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(from_attributes=True)
@@ -99,6 +138,13 @@ class ScheduleItemListRequest(BaseModel):
start_at: datetime
end_at: datetime
@field_validator("start_at", "end_at")
@classmethod
def validate_datetime_tzinfo(cls, value: datetime) -> datetime:
if value.tzinfo is None:
raise ValueError("datetime must include timezone offset")
return value
_PERMISSION_VIEW = 1
_PERMISSION_INVITE = 2
+42 -9
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Protocol, Literal
from uuid import UUID
@@ -83,15 +84,18 @@ class ScheduleItemService(BaseService):
) -> ScheduleItemResponse:
user_id = self.require_user_id()
if request.end_at and request.end_at <= request.start_at:
normalized_start_at = self._to_utc_required(request.start_at)
normalized_end_at = self._to_utc(request.end_at)
if normalized_end_at and normalized_end_at <= normalized_start_at:
raise HTTPException(status_code=400, detail="end_at must be after start_at")
data = {
"owner_id": user_id,
"title": request.title,
"description": request.description,
"start_at": request.start_at,
"end_at": request.end_at,
"start_at": normalized_start_at,
"end_at": normalized_end_at,
"timezone": request.timezone,
"extra_metadata": request.metadata.model_dump() if request.metadata else {},
"source_type": source_type,
@@ -168,10 +172,21 @@ class ScheduleItemService(BaseService):
# Validate time range
next_start = update_data.get("start_at", existing.start_at)
next_end = update_data.get("end_at", existing.end_at)
if next_end is not None and next_end <= next_start:
raise HTTPException(
status_code=400, detail="end_at must be after start_at"
)
if isinstance(next_start, datetime):
next_start = self._to_utc_required(next_start)
update_data["start_at"] = next_start
if isinstance(next_end, datetime):
next_end = self._to_utc(next_end)
update_data["end_at"] = next_end
if next_end is not None:
if not isinstance(next_start, datetime):
raise HTTPException(
status_code=400, detail="start_at must include timezone"
)
if next_end <= next_start:
raise HTTPException(
status_code=400, detail="end_at must be after start_at"
)
if not update_data:
return self._to_response(existing)
@@ -218,13 +233,16 @@ class ScheduleItemService(BaseService):
) -> list[ScheduleItemResponse]:
user_id = self.require_user_id()
if request.end_at <= request.start_at:
normalized_start_at = self._to_utc_required(request.start_at)
normalized_end_at = self._to_utc_required(request.end_at)
if normalized_end_at <= normalized_start_at:
raise HTTPException(status_code=400, detail="end_at must be after start_at")
try:
subscribed_items = (
await self._repository.list_subscribed_items_by_date_range(
user_id, request.start_at, request.end_at
user_id, normalized_start_at, normalized_end_at
)
)
@@ -518,3 +536,18 @@ class ScheduleItemService(BaseService):
if subscriptions:
await self._session.commit()
def _to_utc(self, dt: datetime | None) -> datetime | None:
if dt is None:
return None
if dt.tzinfo is None:
raise HTTPException(
status_code=400, detail="datetime must include timezone"
)
return dt.astimezone(timezone.utc)
def _to_utc_required(self, dt: datetime) -> datetime:
normalized = self._to_utc(dt)
if normalized is None:
raise HTTPException(status_code=400, detail="datetime is required")
return normalized
@@ -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",