feat: 添加日历批量操作与客户端时区感知功能,优化前端 UI 交互体验
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 [],
|
||||
)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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?
|
||||
@@ -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` 存储结构。
|
||||
Reference in New Issue
Block a user