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
+6 -4
View File
@@ -114,7 +114,6 @@
DCDE481F29A6AC188DDBFB70 /* Pods-RunnerTests.release.xcconfig */,
E891B130134FBA205ED3C2E4 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
@@ -471,13 +470,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = NHCUQ772U3;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp;
PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
@@ -653,13 +653,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = NHCUQ772U3;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp;
PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -675,13 +676,14 @@
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
CLANG_ENABLE_MODULES = YES;
CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)";
DEVELOPMENT_TEAM = NHCUQ772U3;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
);
PRODUCT_BUNDLE_IDENTIFIER = com.social.socialApp;
PRODUCT_BUNDLE_IDENTIFIER = "com.zl-q.socialApp";
PRODUCT_NAME = "$(TARGET_NAME)";
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
SWIFT_VERSION = 5.0;
+34 -8
View File
@@ -2,6 +2,8 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
@@ -24,6 +26,38 @@
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>需要使用相机来拍摄照片</string>
<key>NSLocalNetworkUsageDescription</key>
<string>需要访问局域网以连接开发服务器</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册来选择照片</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>NSAllowsLocalNetworking</key>
<true/>
<key>NSExceptionDomains</key>
<dict>
<key>192.168.66.57</key>
<dict>
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSTemporaryExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
</dict>
<key>localhost</key>
<dict>
<key>NSTemporaryExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSTemporaryExceptionMinimumTLSVersion</key>
<string>TLSv1.0</string>
</dict>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
@@ -41,13 +75,5 @@
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>需要使用相机来拍摄照片</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册来选择照片</string>
</dict>
</plist>
+16 -1
View File
@@ -22,7 +22,7 @@ abstract class ApiException implements Exception {
(data['detail'] ?? data['message'] ?? data['error'])?.toString() ??
'请求失败';
} else {
detail = '请求失败';
detail = _networkErrorMessage(error);
}
final localized = _localizeError(detail, statusCode);
@@ -57,6 +57,21 @@ abstract class ApiException implements Exception {
}
return detail;
}
static String _networkErrorMessage(DioException error) {
if (error.type == DioExceptionType.connectionTimeout ||
error.type == DioExceptionType.sendTimeout ||
error.type == DioExceptionType.receiveTimeout) {
return '网络超时,请确认手机与服务端在同一网络后重试';
}
if (error.type == DioExceptionType.connectionError ||
error.type == DioExceptionType.unknown) {
return '无法连接服务器。请在 iPhone 设置中为本应用开启“无线数据(WLAN与蜂窝网络)”,并确认本地网络权限已开启。';
}
return '请求失败';
}
}
class ServerException extends ApiException {
@@ -94,18 +94,18 @@ class UiSchemaRenderer {
final status = _asString(node['status']);
final style = switch (role) {
'title' => const TextStyle(
fontSize: 22,
fontSize: 18,
fontWeight: FontWeight.w700,
color: AppColors.slate900,
height: 1.2,
),
'subtitle' => const TextStyle(
fontSize: 16,
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.slate800,
),
'caption' => const TextStyle(
fontSize: 12,
fontSize: 11,
color: AppColors.slate500,
height: 1.4,
),
@@ -115,9 +115,9 @@ class UiSchemaRenderer {
fontFamily: 'monospace',
),
_ => const TextStyle(
fontSize: 15,
fontSize: 13,
color: AppColors.slate700,
height: 1.45,
height: 1.35,
),
};
return Text(
@@ -131,7 +131,7 @@ class UiSchemaRenderer {
static Widget _renderIcon(Map<String, dynamic> node) {
final value = _asString(node['value']);
if (_asString(node['source']) == 'emoji' && value.isNotEmpty) {
return Text(value, style: const TextStyle(fontSize: 20));
return Text(value, style: const TextStyle(fontSize: 18));
}
return Icon(Icons.bubble_chart_rounded, color: _statusTextColor('', null));
}
@@ -179,7 +179,7 @@ class UiSchemaRenderer {
elevation: 0,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.md,
vertical: AppSpacing.sm,
),
backgroundColor: style == 'primary'
? AppColors.authPrimaryButton
@@ -196,7 +196,7 @@ class UiSchemaRenderer {
),
child: Text(
_asString(node['label'], fallback: '操作'),
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600),
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
),
);
},
@@ -223,7 +223,7 @@ class UiSchemaRenderer {
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: AppColors.surfaceSecondary,
@@ -237,7 +237,7 @@ class UiSchemaRenderer {
child: Text(
label,
style: const TextStyle(
fontSize: 12,
fontSize: 11,
color: AppColors.slate500,
),
),
@@ -248,7 +248,7 @@ class UiSchemaRenderer {
child: Text(
value,
style: const TextStyle(
fontSize: 13,
fontSize: 12,
color: AppColors.slate800,
fontWeight: FontWeight.w600,
),
@@ -290,16 +290,16 @@ class UiSchemaRenderer {
};
return Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.xl),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(AppRadius.xl),
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: borderColor),
boxShadow: [
BoxShadow(
color: AppColors.slate200.withValues(alpha: 0.6),
blurRadius: 20,
offset: const Offset(0, 10),
color: AppColors.slate200.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 6),
),
],
),
@@ -41,7 +41,9 @@ const _transcribingStrokeWidth = 2.0;
const _attachmentPreviewSize = 88.0;
const _attachmentPreviewRadius = 10.0;
const _attachmentPreviewGap = 8.0;
const _bottomStackReservedHeight = 140.0;
const _bottomStackReservedHeight = 116.0;
const _toolResultWidthFactor = 0.9;
const _pullRefreshMinVisibleMs = 450;
const homeConversationStageKey = ValueKey('home_conversation_stage');
const homeBottomInputStackKey = ValueKey('home_bottom_input_stack');
@@ -92,7 +94,7 @@ class _HomeScreenState extends State<HomeScreen>
late final Future<void> Function(String transcript) _autoSendTranscript;
late final AnimationController _listeningAnimationController;
bool _isRecording = false;
bool _isHoldToSpeakMode = false;
bool _isHoldToSpeakMode = true;
bool _isTranscribing = false;
bool _isCancelGestureActive = false;
bool _isSendingMessage = false;
@@ -356,12 +358,29 @@ class _HomeScreenState extends State<HomeScreen>
if (_isPullRefreshing) {
return;
}
final chatBloc = context.read<ChatBloc>();
if (chatBloc.state.isLoadingHistory) {
return;
}
final hasEarlierHistory = chatBloc.state.hasEarlierHistory;
if (mounted) {
setState(() => _isPullRefreshing = true);
}
final startedAt = DateTime.now();
try {
await context.read<ChatBloc>().loadMoreHistory();
if (hasEarlierHistory) {
await chatBloc.loadMoreHistory();
} else {
Toast.show(context, '没有更早的历史记录了', type: ToastType.info);
}
} finally {
final elapsed = DateTime.now().difference(startedAt);
final minDuration = const Duration(
milliseconds: _pullRefreshMinVisibleMs,
);
if (elapsed < minDuration) {
await Future.delayed(minDuration - elapsed);
}
if (mounted) {
setState(() => _isPullRefreshing = false);
}
@@ -585,7 +604,32 @@ class _HomeScreenState extends State<HomeScreen>
}
Widget _buildToolResultItem(ToolResultItem item) {
return UiSchemaRenderer.renderSchema(item.uiSchema);
final rootNode = item.uiSchema['root'];
final appearance = rootNode is Map<String, dynamic>
? rootNode['appearance'] as String?
: null;
final needsOuterCard = appearance == null || appearance == 'plain';
final schemaContent = UiSchemaRenderer.renderSchema(item.uiSchema);
final wrappedContent = needsOuterCard
? Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: AppColors.white,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.homeConversationBorder),
),
child: schemaContent,
)
: schemaContent;
return Align(
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: _toolResultWidthFactor,
child: wrappedContent,
),
);
}
Widget _buildBottomInputStack(BuildContext context, ChatState state) {
@@ -733,8 +777,7 @@ class _HomeScreenState extends State<HomeScreen>
}
void _onHoldToSpeakStart() {
HapticFeedback.heavyImpact();
HapticFeedback.vibrate();
HapticFeedback.selectionClick();
setState(() {
_isCancelGestureActive = false;
});
@@ -747,7 +790,7 @@ class _HomeScreenState extends State<HomeScreen>
_cancelRecording(showToast: false);
return;
}
HapticFeedback.mediumImpact();
HapticFeedback.selectionClick();
_stopRecording(autoSendAfterTranscribe: true);
}
@@ -59,24 +59,26 @@ class _HomeBottomGlow extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.bottomCenter,
child: IgnorePointer(
child: Container(
key: homeBottomGlowKey,
width: double.infinity,
height: AppSpacing.xxl * 6,
margin: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
decoration: BoxDecoration(
color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
boxShadow: [
BoxShadow(
color: AppColors.homeBackgroundGlow.withValues(alpha: 0.12),
blurRadius: AppSpacing.xxl * 2,
spreadRadius: AppSpacing.md,
),
],
return IgnorePointer(
child: Align(
alignment: Alignment.bottomCenter,
child: Transform.translate(
offset: const Offset(0, AppSpacing.lg),
child: Container(
key: homeBottomGlowKey,
width: AppSpacing.xxl * 12,
height: AppSpacing.xxl * 3,
decoration: BoxDecoration(
color: AppColors.homeBackgroundGlowSoft.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(AppSpacing.xxl * 2),
boxShadow: [
BoxShadow(
color: AppColors.homeBackgroundGlow.withValues(alpha: 0.1),
blurRadius: AppSpacing.xxl,
spreadRadius: AppSpacing.sm,
),
],
),
),
),
),
@@ -26,9 +26,9 @@ class HomeFloatingHeader extends StatelessWidget {
key: homeFloatingHeaderKey,
padding: const EdgeInsets.fromLTRB(
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.xs,
AppSpacing.lg,
AppSpacing.sm,
AppSpacing.xs,
),
decoration: const BoxDecoration(
color: AppColors.homeToolbarSurface,
@@ -93,6 +93,11 @@ class _HeaderIconButton extends StatelessWidget {
Widget build(BuildContext context) {
return IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(AppSpacing.xs),
constraints: const BoxConstraints(
minWidth: AppSpacing.xxl + AppSpacing.lg,
minHeight: AppSpacing.xxl + AppSpacing.lg,
),
onPressed: onPressed,
icon: Icon(icon, size: AppSpacing.xxl, color: AppColors.slate900),
);
@@ -109,6 +114,11 @@ class _MessagesButton extends StatelessWidget {
Widget build(BuildContext context) {
return IconButton(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.all(AppSpacing.xs),
constraints: const BoxConstraints(
minWidth: AppSpacing.xxl + AppSpacing.lg,
minHeight: AppSpacing.xxl + AppSpacing.lg,
),
onPressed: onPressed,
icon: Stack(
clipBehavior: Clip.none,
@@ -59,6 +59,64 @@ void main() {
expect(find.text('评审会'), findsOneWidget);
});
testWidgets('renders batch result list items in one card', (tester) async {
final schema = {
'version': '2.0',
'root': {
'type': 'stack',
'direction': 'vertical',
'appearance': 'card',
'status': 'warning',
'children': [
{'type': 'text', 'role': 'title', 'content': '日历操作完成'},
{
'type': 'stack',
'direction': 'vertical',
'gap': 8,
'children': [
{
'type': 'stack',
'direction': 'vertical',
'appearance': 'card',
'children': [
{'type': 'text', 'role': 'body', 'content': '#1 create'},
{'type': 'text', 'role': 'caption', 'content': '成功'},
{'type': 'text', 'role': 'caption', 'content': '日程「晨会」已创建'},
],
},
{
'type': 'stack',
'direction': 'vertical',
'appearance': 'card',
'children': [
{'type': 'text', 'role': 'body', 'content': '#2 delete'},
{'type': 'text', 'role': 'caption', 'content': '失败'},
{
'type': 'text',
'role': 'caption',
'content': 'Schedule item not found',
},
],
},
],
},
],
},
};
await tester.pumpWidget(
MaterialApp(
home: Scaffold(body: UiSchemaRenderer.renderSchema(schema)),
),
);
expect(find.text('日历操作完成'), findsOneWidget);
expect(find.text('#1 create'), findsOneWidget);
expect(find.text('#2 delete'), findsOneWidget);
expect(find.text('成功'), findsOneWidget);
expect(find.text('失败'), findsOneWidget);
});
testWidgets('renders fallback for invalid schema', (tester) async {
await tester.pumpWidget(
MaterialApp(
@@ -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",
@@ -0,0 +1,285 @@
# Calendar Timezone Unification Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Eliminate calendar time mismatches by enforcing one end-to-end timezone policy across App input, Agent runtime context, tool execution, and UTC database storage.
**Architecture:** Keep database schema unchanged (`start_at/end_at TIMESTAMPTZ + timezone`) and enforce strict runtime normalization. Device timezone is injected from `RunAgentInput.forwardedProps`, resolved into a single `effective_timezone`, then written explicitly into tool arguments and persisted as event timezone while timestamps are stored in UTC. Calendar read responses include deterministic event-timezone-rendered values so frontend rendering is stable and no implicit `toLocal()` conversion remains.
**Tech Stack:** FastAPI, Pydantic v2, AgentScope runtime/tooling, Flutter (Dart), PostgreSQL TIMESTAMPTZ, pytest, Flutter test.
---
## Chunk 1: Protocol and Backend Runtime Normalization
### Task 1: Freeze protocol and timezone precedence contract
**Files:**
- Modify: `docs/protocols/agent/run-agent-input.md`
- Create: `docs/protocols/calendar/timezone-policy.md`
- [ ] **Step 1: Write protocol delta checklist in docs first**
Document the exact policy:
- `event_timezone > device_timezone > profile.timezone > UTC`
- `event_timezone` must be present in final tool call
- `start_at/end_at` must be timezone-aware
- DB stores UTC timestamps and IANA timezone string
- [ ] **Step 2: Update RunAgentInput protocol with forwardedProps contract**
Add canonical payload example:
```json
{
"forwardedProps": {
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
"client_epoch_ms": 1773658353000
}
}
}
```
- [ ] **Step 3: Add calendar timezone policy protocol doc**
Include:
- accepted datetime formats
- explicit error codes
- write/read response semantics
- DST handling rule
- [ ] **Step 4: Verify docs consistency**
Run: `cd backend && uv run python -m pytest tests/unit/core/agentscope/test_system_prompt.py -q`
Expected: PASS (no protocol-breaking prompt assumptions)
### Task 2: Parse forwarded device time and compute effective timezone
**Files:**
- Modify: `backend/src/core/agentscope/schemas/agui_input.py`
- Modify: `backend/src/core/agentscope/runtime/runner.py`
- Modify: `backend/src/core/agentscope/prompts/system_prompt.py`
- Test: `backend/tests/unit/core/agentscope/test_system_prompt.py`
- [ ] **Step 1: Write failing tests for effective timezone resolution**
Add tests covering:
- forwarded `device_timezone` present -> selected
- missing forwarded timezone -> fallback profile timezone
- invalid forwarded timezone -> fallback profile timezone
- [ ] **Step 2: Run tests to confirm RED**
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py -k timezone -v`
Expected: FAIL on new assertions
- [ ] **Step 3: Implement minimal runtime context extraction**
Implement a typed helper in runner path to read:
- `run_input.forwarded_props.client_time.device_timezone`
- `client_now_iso`
- `client_epoch_ms`
Compute `effective_timezone` using fixed precedence and pass it into `build_system_prompt(...)`.
- [ ] **Step 4: Inject effective_timezone into ENV section**
Update `build_system_prompt` env payload to include:
- `timezone_profile`
- `timezone_device`
- `timezone_effective`
Update guidance sentence to resolve ambiguous time with `timezone_effective`.
- [ ] **Step 5: Run tests to confirm GREEN**
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py -v`
Expected: PASS
### Task 3: Remove timezone ambiguity and hidden fallbacks from calendar write
**Files:**
- Modify: `backend/src/core/agentscope/tools/utils/calendar_domain.py`
- Modify: `backend/src/core/agentscope/tools/custom/calendar.py`
- Modify: `backend/src/v1/schedule_items/schemas.py`
- Modify: `backend/src/v1/schedule_items/service.py`
- Test: `backend/tests/unit/core/agentscope/test_calendar_tools.py`
- Test: `backend/tests/unit/v1/schedule_items/test_schemas.py`
- Test: `backend/tests/unit/v1/schedule_items/test_service.py`
- [ ] **Step 1: Write failing tests for forbidden naive datetime and required timezone**
Add tests for:
- naive `start_at` rejected
- missing `event_timezone` rejected in tool path
- parse failure does not fallback to `now + 1h`
- [ ] **Step 2: Run tests to confirm RED**
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_calendar_tools.py tests/unit/v1/schedule_items/test_schemas.py -v`
Expected: FAIL on new constraints
- [ ] **Step 3: Implement strict parsing and normalization**
Implementation requirements:
- `parse_iso_datetime` rejects naive input
- remove default `Asia/Shanghai` in tool
- remove fallback auto-generated start time
- validate IANA timezone and normalize `start_at/end_at` to UTC before persistence
- [ ] **Step 4: Enforce service-level invariants**
Service invariant set:
- timezone non-empty and valid IANA
- `end_at is None or end_at >= start_at`
- [ ] **Step 5: Run backend tests**
Run: `cd backend && uv run pytest tests/unit/core/agentscope/test_calendar_tools.py tests/unit/v1/schedule_items/test_schemas.py tests/unit/v1/schedule_items/test_service.py tests/integration/test_schedule_items_routes.py -v`
Expected: PASS
### Task 4: Keep DB schema, add non-breaking constraint migration only
**Files:**
- Create: `backend/alembic/versions/20260316_000x_schedule_items_time_constraints.py`
- Test: `backend/tests/integration/test_schedule_items_routes.py`
- [ ] **Step 1: Write migration test expectation first**
Add/extend integration assertion for invalid `end_at < start_at` returning 422.
- [ ] **Step 2: Run integration test to confirm RED**
Run: `cd backend && uv run pytest tests/integration/test_schedule_items_routes.py -k end_at -v`
Expected: FAIL
- [ ] **Step 3: Implement migration with CHECK only (no new columns)**
Migration includes:
- `CHECK (end_at IS NULL OR end_at >= start_at)`
- [ ] **Step 4: Run migration + integration test**
Run: `cd backend && uv run alembic upgrade head && uv run pytest tests/integration/test_schedule_items_routes.py -v`
Expected: PASS
---
## Chunk 2: Frontend Deterministic Display and Agent Input Wiring
### Task 5: Wire device timezone into RunAgentInput forwardedProps
**Files:**
- Modify: `apps/lib/features/chat/data/services/ag_ui_service.dart`
- Modify: `apps/lib/features/chat/data/models/ag_ui_event.dart` (only if serialization helper is needed)
- Test: `apps/test/features/chat/ag_ui_event_test.dart`
- [ ] **Step 1: Write failing test for forwarded client_time payload**
Assert outgoing run request contains:
- `forwardedProps.client_time.device_timezone`
- `client_now_iso`
- `client_epoch_ms`
- [ ] **Step 2: Run test to confirm RED**
Run: `cd apps && flutter test test/features/chat/ag_ui_event_test.dart`
Expected: FAIL
- [ ] **Step 3: Implement payload injection in one place**
Add a single helper to build client time context and attach it to run input requests.
- [ ] **Step 4: Run test to confirm GREEN**
Run: `cd apps && flutter test test/features/chat/ag_ui_event_test.dart`
Expected: PASS
### Task 6: Remove implicit local-time rendering and render by event timezone
**Files:**
- Modify: `apps/lib/features/calendar/data/models/schedule_item_model.dart`
- Modify: `apps/lib/features/calendar/ui/screens/calendar_event_detail_screen.dart`
- Modify: `apps/lib/features/calendar/ui/screens/calendar_dayweek_screen.dart`
- Modify: `apps/lib/features/calendar/ui/screens/calendar_month_screen.dart`
- Modify: `apps/lib/features/messages/ui/widgets/calendar_message_card.dart`
- Modify: `apps/lib/features/calendar/ui/widgets/create_event_sheet.dart`
- Test: `apps/test/features/calendar/ui/calendar_time_utils_test.dart`
- Test: `apps/test/features/calendar/ui/create_event_sheet_time_align_test.dart`
- [ ] **Step 1: Write failing tests for timezone-specific rendering**
Cover cases:
- same UTC event shows different local clock time under different `event.timezone`
- list/day/week/month are consistent for one event
- create sheet sends explicit timezone in payload
- [ ] **Step 2: Run tests to confirm RED**
Run: `cd apps && flutter test test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
Expected: FAIL
- [ ] **Step 3: Implement deterministic time conversion utility**
Implement one utility used by all calendar UI surfaces:
- input: UTC datetime + IANA timezone
- output: event-local datetime
Replace direct `.toLocal()` usage in calendar model/view with this utility.
- [ ] **Step 4: Enforce explicit timezone on create/update payload**
Create/update must always include `timezone` field from selected event timezone.
- [ ] **Step 5: Run Flutter tests**
Run: `cd apps && flutter test test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
Expected: PASS
### Task 7: End-to-end verification matrix and release checklist
**Files:**
- Modify: `docs/plans/timezone-e2e-checklist.md`
- Test: `backend/tests/integration/test_schedule_items_routes.py`
- Test: `apps/test/features/calendar/ui/create_event_sheet_time_align_test.dart`
- [ ] **Step 1: Add reproducible matrix**
Matrix axes:
- device timezone: `America/Los_Angeles`, `Asia/Shanghai`
- profile timezone: `Asia/Shanghai`, `Europe/Paris`
- explicit event timezone: `Asia/Tokyo`
- [ ] **Step 2: Run backend + frontend verification commands**
Run:
- `cd backend && uv run pytest tests/unit/core/agentscope/test_system_prompt.py tests/unit/core/agentscope/test_calendar_tools.py tests/integration/test_schedule_items_routes.py -v`
- `cd apps && flutter test test/features/chat/ag_ui_event_test.dart test/features/calendar/ui/calendar_time_utils_test.dart test/features/calendar/ui/create_event_sheet_time_align_test.dart`
Expected: all PASS
- [ ] **Step 3: Manual scenario check**
Manual script:
1. device timezone set to Los Angeles
2. profile timezone set to Shanghai
3. ask agent create "明天上午9点开会"
4. verify assistant text, tool card, DB UTC value, and calendar detail all align to chosen event timezone semantics
- [ ] **Step 4: Capture release notes**
Record:
- removed hidden timezone defaults
- deterministic precedence
- no schema expansion
---
Plan complete and saved to `docs/superpowers/plans/2026-03-16-calendar-timezone-unification.md`. Ready to execute?
+75
View File
@@ -185,6 +185,44 @@ interface Context {
---
## forwardedProps.client_time Schema
`RunAgentInput.forwardedProps` 支持透传客户端时间上下文。日历相关能力必须使用以下结构:
```typescript
interface ForwardedProps {
client_time?: {
device_timezone: string; // IANA 时区,例如 "America/Los_Angeles"
client_now_iso: string; // RFC3339 带偏移时间,例如 "2026-03-16T09:12:33-07:00"
client_epoch_ms: number; // Unix epoch 毫秒
};
}
```
### 时间来源优先级(固定)
后端在运行时按以下顺序解析事件时区:
1. `event_timezone`(工具调用显式传参)
2. `forwardedProps.client_time.device_timezone`
3. `users.profile.settings.timezone`
4. `UTC`
### 约束
- `device_timezone` 必须是有效 IANA 时区。
- `client_now_iso` 必须是 RFC3339 且包含时区偏移。
- `client_epoch_ms` 必须是整数毫秒时间戳。
- 业务代码不得使用服务器本地时区作为事件语义时区。
### 说明
- `forwardedProps` 是透传字段,不改变 AG-UI 主体协议结构。
-`forwardedProps.client_time` 缺失或非法时,运行时回退到 `users.profile.settings.timezone`
- 日历写入必须在最终工具调用中带上 `event_timezone`,不得依赖工具默认值。
---
## Validation Rules
Backend 实现了以下验证规则:
@@ -203,6 +241,16 @@ Backend 实现了以下验证规则:
| binary 不允许使用 data | `binary content data is not allowed` |
| 单条消息最多 3 张附件 | `Too many attachments` |
### forwardedProps.client_time Validation
建议在后端校验层返回以下错误(按业务实现映射到 4xx):
| Rule | Error Message |
|------|---------------|
| `device_timezone` 非 IANA 时区 | `invalid client_time.device_timezone` |
| `client_now_iso` 无法解析或缺少时区 | `invalid client_time.client_now_iso` |
| `client_epoch_ms` 非整数毫秒值 | `invalid client_time.client_epoch_ms` |
---
## Request Example
@@ -292,6 +340,32 @@ Backend 实现了以下验证规则:
}
```
### 带 forwardedProps.client_time 的请求
```json
{
"threadId": "550e8400-e29b-41d4-a716-446655440000",
"runId": "run-004",
"state": {},
"messages": [
{
"id": "msg-001",
"role": "user",
"content": "帮我明天早上9点创建一个日历"
}
],
"tools": [],
"context": [],
"forwardedProps": {
"client_time": {
"device_timezone": "America/Los_Angeles",
"client_now_iso": "2026-03-16T09:12:33-07:00",
"client_epoch_ms": 1773658353000
}
}
}
```
---
## Response
@@ -454,3 +528,4 @@ interface UiSchemaRenderer {
- backend 验证通过后,会将 binary url 转换为内部存储路径
- `tools` 是前端工具通道字段;当前后端运行时不基于该字段构造后端工具 prompt
- `RunAgentInput` 同时接受 camelCase 与 snake_case 别名输入(推荐统一使用 camelCase)
- 日历能力依赖 `forwardedProps.client_time` 透传设备时间上下文;缺失时回退用户 profile 时区
@@ -0,0 +1,87 @@
# Calendar Timezone Policy Protocol
## Version
- Current: `1.0`
- Status: Active
---
## Goal
统一日历事件在 App、Agent、工具、数据库之间的时间语义,消除时区不一致导致的显示和落库偏差。
---
## Canonical Rules
1. 数据库存储基准为 UTC。
2. 事件语义时区使用 IANA 时区字符串(`event_timezone` / `timezone`)。
3. 禁止无时区时间(naive datetime)进入日历写入链路。
4. 日历写入必须显式确定事件时区,不允许工具层硬编码默认时区。
---
## Timezone Resolution Priority
运行时事件时区解析顺序固定如下:
1. `event_timezone`(工具调用显式传参)
2. `forwardedProps.client_time.device_timezone`
3. `users.profile.settings.timezone`
4. `UTC`
---
## Write Contract
### Required fields
- `start_at`: RFC3339 且必须包含时区偏移
- `timezone`: IANA 时区
### Optional fields
- `end_at`: RFC3339 且必须包含时区偏移(若提供)
### Validation
- `timezone` 非法 -> 拒绝请求
- `start_at`/`end_at` 无时区 -> 拒绝请求
- `end_at < start_at` -> 拒绝请求
---
## Read Contract
读接口最小语义要求:
- 返回 UTC 时间字段(`start_at`, `end_at`
- 返回事件时区字段(`timezone`
- 前端展示必须以 `timezone` 作为事件本地时间转换基准,不允许直接按设备本地时区隐式渲染
---
## Error Codes
推荐错误码(由后端映射为 4xx):
- `INVALID_DATETIME_FORMAT`
- `NAIVE_DATETIME_FORBIDDEN`
- `INVALID_TIMEZONE`
- `TIMEZONE_REQUIRED`
- `INVALID_TIME_RANGE`
---
## DST Rule
- 夏令时切换期间,时间解释以 IANA 时区数据库为准。
- 对于歧义本地时间(例如回拨重复小时),由后端统一按标准库解析策略处理并返回确定 UTC 结果。
---
## Non-Goals
- 本协议不引入新的数据库时间列。
- 本协议不改变 `schedule_items` 现有 `TIMESTAMPTZ + timezone` 存储结构。