feat(agent): add voice input capability and standardize tool naming

- Add voice recording with transcribe endpoint (ASR) for multimodal input
- Android: add RECORD_AUDIO and INTERNET permissions
- Refactor tool naming: frontend tools use 'front.' prefix, backend tools use 'back.'
- Migrate calendar tools: create_calendar_event -> back.mutate/list/delete events
- Add calendar_event_list.v1 and calendar_operation.v1 UI card types
- Update all Flutter and Python tests to match new tool naming conventions
- Add record package dependency for voice recording
This commit is contained in:
zl-q
2026-03-09 00:10:09 +08:00
parent 6c83e35a69
commit 3ac09475ad
30 changed files with 1593 additions and 438 deletions
@@ -1,4 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<application
android:label="social_app"
android:name="${applicationName}"
@@ -82,7 +82,9 @@ class AiDecisionEngine {
}
ForceTriggerResult? tryForceTrigger(String text) {
final match = RegExp(r'#tool:(\w+)\s*(\{.*\})?').firstMatch(text);
final match = RegExp(
r'#tool:([A-Za-z0-9_.-]+)\s*(\{.*\})?',
).firstMatch(text);
if (match == null) return null;
final toolName = match.group(1)!;
@@ -297,6 +297,14 @@ class ToolCallResultEvent extends AgUiEvent {
if (rawUi is Map<String, dynamic>) {
return UiCard.fromJson(rawUi);
}
final rawResult = payload['result'];
if (rawResult is Map<String, dynamic>) {
final type = rawResult['type'];
final data = rawResult['data'];
if (type is String && data is Map<String, dynamic>) {
return UiCard.fromJson(rawResult);
}
}
return null;
}
@@ -2,12 +2,12 @@ import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:dio/dio.dart';
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/core/api/mock_api_client.dart';
import '../ai/ai_decision_engine.dart';
import '../models/ag_ui_event.dart';
import '../models/tool_result.dart';
import '../tools/tool_registry.dart';
import 'mock_history_service.dart';
@@ -36,7 +36,7 @@ class AgUiService {
_decisionEngine = AiDecisionEngine(),
_historyService = MockHistoryService() {
if (_apiClient is MockApiClient) {
_configureMockAgentApi(_apiClient as MockApiClient);
_configureMockAgentApi(_apiClient);
}
}
@@ -77,6 +77,28 @@ class AgUiService {
onEvent(event);
}
Future<String> transcribeAudio(String filePath) async {
final formData = FormData.fromMap({
'audio': await MultipartFile.fromFile(
filePath,
filename: 'recording.wav',
),
});
final response = await _apiClient.post<Map<String, dynamic>>(
'/api/v1/agent/transcribe',
data: formData,
);
final payload = response.data;
if (payload is! Map<String, dynamic>) {
throw StateError('Invalid /agent/transcribe response');
}
final transcript = payload['transcript'];
if (transcript is! String) {
throw StateError('Missing transcript in /agent/transcribe response');
}
return transcript;
}
Future<void> approveToolCall({
required String toolCallId,
required String toolName,
@@ -210,11 +232,7 @@ class AgUiService {
'runId': runId,
'state': <String, dynamic>{},
'messages': [
{
'id': _nextId('user_'),
'role': 'user',
'content': content,
},
{'id': _nextId('user_'), 'role': 'user', 'content': content},
],
'tools': _buildTools(),
'context': <Map<String, dynamic>>[],
@@ -225,33 +243,20 @@ class AgUiService {
List<Map<String, dynamic>> _buildTools() {
return [
{
'name': 'navigate_to_route',
'name': 'front.navigate_to_route',
'description': 'Navigate user to a route in the mobile app.',
'parameters': {
'type': 'object',
'properties': {
'target': {'type': 'string', 'description': 'Route path target'},
'replace': {'type': 'boolean', 'description': 'Use replace navigation'},
'replace': {
'type': 'boolean',
'description': 'Use replace navigation',
},
},
'required': ['target'],
},
},
{
'name': 'create_calendar_event',
'description': 'Create a calendar schedule event.',
'parameters': {
'type': 'object',
'properties': {
'title': {'type': 'string'},
'description': {'type': 'string'},
'startAt': {'type': 'string', 'format': 'date-time'},
'endAt': {'type': 'string', 'format': 'date-time'},
'timezone': {'type': 'string'},
'location': {'type': 'string'},
},
'required': ['title', 'startAt'],
},
},
];
}
@@ -270,7 +275,8 @@ class AgUiService {
return '/api/v1/agent/history?${query.join('&')}';
}
String _nextId(String prefix) => '$prefix${DateTime.now().millisecondsSinceEpoch}';
String _nextId(String prefix) =>
'$prefix${DateTime.now().millisecondsSinceEpoch}';
String _newUuid() {
final random = Random();
@@ -304,6 +310,15 @@ class AgUiService {
'SSE',
_handleMockSse,
);
client.registerHandler(
'/api/v1/agent/transcribe',
'POST',
_handleMockTranscribe,
);
}
Map<String, dynamic> _handleMockTranscribe(MockRequest request) {
return {'transcript': '这是模拟语音转写'};
}
Map<String, dynamic> _handleMockRun(MockRequest request) {
@@ -331,9 +346,9 @@ class AgUiService {
}
Map<String, dynamic> _handleMockResume(MockRequest request) {
final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/resume$').firstMatch(
request.path,
);
final match = RegExp(
r'^/api/v1/agent/runs/([^/]+)/resume$',
).firstMatch(request.path);
final threadId = match?.group(1) ?? (_threadId ?? _newUuid());
final payload = request.data;
final runInput = payload is Map<String, dynamic>
@@ -344,7 +359,11 @@ class AgUiService {
final toolMessage = _extractLatestToolMessage(runInput);
final events = <Map<String, dynamic>>[
{'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId},
{
'type': AgUiEventTypeWire.runStarted,
'threadId': threadId,
'runId': runId,
},
{
'type': AgUiEventTypeWire.toolCallResult,
'messageId': _nextId(_messageIdPrefix),
@@ -365,7 +384,11 @@ class AgUiService {
'type': AgUiEventTypeWire.textMessageEnd,
'messageId': _nextId(_messageIdPrefix),
},
{'type': AgUiEventTypeWire.runFinished, 'threadId': threadId, 'runId': runId},
{
'type': AgUiEventTypeWire.runFinished,
'threadId': threadId,
'runId': runId,
},
];
_mockSseLinesByThread[threadId] = _toSseLines(events);
return {
@@ -398,7 +421,8 @@ class AgUiService {
final messages = targetDate == null
? <SnapshotMessage>[]
: _historyService.getHistoryForDay(targetDate);
final hasMore = targetDate != null && _historyService.hasEarlierHistory(targetDate);
final hasMore =
targetDate != null && _historyService.hasEarlierHistory(targetDate);
_hasMoreHistory = hasMore;
return {
@@ -421,9 +445,9 @@ class AgUiService {
}
Stream<String> _handleMockSse(MockRequest request) {
final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/events$').firstMatch(
request.path,
);
final match = RegExp(
r'^/api/v1/agent/runs/([^/]+)/events$',
).firstMatch(request.path);
final threadId = match?.group(1);
if (threadId == null) {
return const Stream<String>.empty();
@@ -441,7 +465,11 @@ class AgUiService {
required String userInput,
}) {
final events = <Map<String, dynamic>>[
{'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId},
{
'type': AgUiEventTypeWire.runStarted,
'threadId': threadId,
'runId': runId,
},
];
final forceTrigger = _decisionEngine.tryForceTrigger(userInput);
@@ -451,19 +479,13 @@ class AgUiService {
toolName = forceTrigger.toolName;
args = forceTrigger.args;
} else if (_looksLikeNavigationIntent(userInput)) {
toolName = 'navigate_to_route';
toolName = 'front.navigate_to_route';
args = {'target': _inferNavigationRoute(userInput), 'replace': false};
} else if (_decisionEngine.shouldTriggerToolCall(userInput)) {
toolName = 'create_calendar_event';
args = _decisionEngine.getToolCallArgs(userInput);
}
if (toolName != null && args != null) {
if (toolName == 'navigate_to_route') {
args = {
...args,
'__nonce': _nextId('nonce_'),
};
if (toolName == 'front.navigate_to_route') {
args = {...args, '__nonce': _nextId('nonce_')};
}
final toolCallId = _nextId(_toolCallIdPrefix);
events.add({
@@ -476,32 +498,20 @@ class AgUiService {
'toolCallId': toolCallId,
'delta': jsonEncode(args),
});
events.add({'type': AgUiEventTypeWire.toolCallEnd, 'toolCallId': toolCallId});
events.add({
'type': AgUiEventTypeWire.toolCallEnd,
'toolCallId': toolCallId,
});
if (toolName == 'navigate_to_route') {
if (toolName == 'front.navigate_to_route') {
// 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。
} else {
final validation = ToolRegistry.validateArgs(toolName, args);
if (!validation.ok) {
events.add({
'type': AgUiEventTypeWire.toolCallError,
'toolCallId': toolCallId,
'error': validation.error ?? 'Validation failed',
'code': 'VALIDATION_ERROR',
});
} else {
final result = _mockCalendarResult(args);
final ui = _buildUiCard(toolName, result);
events.add({
'type': AgUiEventTypeWire.toolCallResult,
'messageId': _nextId(_messageIdPrefix),
'toolCallId': toolCallId,
'content': jsonEncode({
'result': result,
if (ui != null) 'ui': ui.toJson(),
}),
});
}
events.add({
'type': AgUiEventTypeWire.toolCallError,
'toolCallId': toolCallId,
'error': 'Unsupported frontend tool in mock mode',
'code': 'UNSUPPORTED_TOOL',
});
}
}
@@ -518,7 +528,10 @@ class AgUiService {
'messageId': messageId,
'delta': reply,
});
events.add({'type': AgUiEventTypeWire.textMessageEnd, 'messageId': messageId});
events.add({
'type': AgUiEventTypeWire.textMessageEnd,
'messageId': messageId,
});
}
events.add({
@@ -577,57 +590,14 @@ class AgUiService {
if (raw['role'] != 'tool') {
continue;
}
final toolCallId = raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
final toolCallId =
raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
final content = raw['content'] as String? ?? '{}';
return (toolCallId, content);
}
return (_nextId(_toolCallIdPrefix), '{}');
}
Map<String, dynamic> _mockCalendarResult(Map<String, dynamic> args) {
final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}';
return {
'eventId': eventId,
'ok': true,
'message': '日程已创建',
'title': args['title'],
'description': args['description'],
'startAt': args['startAt'],
'endAt': args['endAt'],
'timezone': args['timezone'] ?? 'Asia/Shanghai',
'location': args['location'],
'color': '#4F46E5',
'sourceType': 'agentGenerated',
};
}
UiCard? _buildUiCard(String toolName, Map<String, dynamic> result) {
if (toolName != 'create_calendar_event') {
return null;
}
return UiCard(
cardType: 'calendar_card.v1',
data: CalendarCardData(
id: result['eventId'] ?? '',
title: result['title'] ?? '',
description: result['description'],
startAt: result['startAt'] ?? '',
endAt: result['endAt'],
timezone: result['timezone'],
location: result['location'],
color: result['color'],
sourceType: result['sourceType'],
).toJson(),
actions: [
CardAction(
type: 'link',
label: '查看详情',
target: '/calendar/events/${result['eventId']}',
),
],
);
}
List<String> _generateReplies(String content) {
final intent = _decisionEngine.matchIntent(content);
switch (intent) {
@@ -4,13 +4,7 @@ typedef ToolHandler =
Future<Map<String, dynamic>> Function(Map<String, dynamic> args);
/// 工具常量
const _toolNameCreateCalendar = 'create_calendar_event';
const _toolNameNavigateRoute = 'navigate_to_route';
const _defaultTimezone = 'Asia/Shanghai';
const _defaultEventColor = '#4F46E5';
const _defaultSourceType = 'agentGenerated';
const _titleMinLength = 1;
const _titleMaxLength = 100;
const _toolNameNavigateRoute = 'front.navigate_to_route';
class ToolDefinition {
final String name;
@@ -33,38 +27,6 @@ class ToolRegistry {
static void initialize() {
if (_initialized) return;
_tools[_toolNameCreateCalendar] = ToolDefinition(
name: _toolNameCreateCalendar,
description: '创建一个日历事件或待办事项',
parameters: {
'type': 'object',
'properties': {
'title': {
'type': 'string',
'description': '事件标题',
'minLength': _titleMinLength,
'maxLength': _titleMaxLength,
},
'description': {'type': 'string', 'description': '事件描述'},
'startAt': {
'type': 'string',
'format': 'date-time',
'description': '开始时间 (ISO8601)',
},
'endAt': {
'type': 'string',
'format': 'date-time',
'description': '结束时间 (ISO8601)',
},
'timezone': {'type': 'string', 'default': _defaultTimezone},
'location': {'type': 'string'},
'notes': {'type': 'string'},
},
'required': ['title', 'startAt'],
},
handler: _handleCreateCalendarEvent,
);
_tools[_toolNameNavigateRoute] = ToolDefinition(
name: _toolNameNavigateRoute,
description: '在前端执行路由跳转',
@@ -82,25 +44,6 @@ class ToolRegistry {
_initialized = true;
}
static Future<Map<String, dynamic>> _handleCreateCalendarEvent(
Map<String, dynamic> args,
) async {
final eventId = 'evt_${DateTime.now().millisecondsSinceEpoch}';
return {
'eventId': eventId,
'ok': true,
'message': '日程已创建',
'title': args['title'],
'description': args['description'],
'startAt': args['startAt'],
'endAt': args['endAt'],
'timezone': args['timezone'] ?? _defaultTimezone,
'location': args['location'],
'color': _defaultEventColor,
'sourceType': _defaultSourceType,
};
}
static Future<Map<String, dynamic>> _handleNavigateRoute(
Map<String, dynamic> args,
) async {
@@ -2,11 +2,11 @@ import 'dart:convert';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:social_app/core/api/i_api_client.dart';
import 'package:social_app/core/api/mock_api_client.dart';
import 'package:social_app/core/di/injection.dart';
import '../../data/models/ag_ui_event.dart';
import '../../data/models/chat_list_item.dart';
import '../../data/models/tool_result.dart';
import '../../data/services/ag_ui_service.dart';
class ChatState {
@@ -57,7 +57,14 @@ class ChatBloc extends Cubit<ChatState> {
ChatBloc({AgUiService? service, IApiClient? apiClient})
: _service =
service ?? AgUiService(apiClient: apiClient ?? sl<IApiClient>()),
service ??
AgUiService(
apiClient:
apiClient ??
(sl.isRegistered<IApiClient>()
? sl<IApiClient>()
: MockApiClient()),
),
super(const ChatState()) {
_service.onEvent = _handleEvent;
}
@@ -162,13 +169,10 @@ class ChatBloc extends Cubit<ChatState> {
_toolCallArgsBuffer.remove(endEvent.toolCallId);
final updatedItems = state.items.map((item) {
if (item.id == endEvent.toolCallId && item is ToolCallItem) {
final nextStatus = item.toolName == 'navigate_to_route'
final nextStatus = item.toolName == 'front.navigate_to_route'
? ToolCallStatus.pending
: ToolCallStatus.executing;
return item.copyWith(
args: parsedArgs,
status: nextStatus,
);
return item.copyWith(args: parsedArgs, status: nextStatus);
}
return item;
}).toList();
@@ -344,7 +348,10 @@ class ChatBloc extends Cubit<ChatState> {
}
final updatedItems = state.items.map((item) {
if (item is ToolCallItem && item.callId == toolCallId) {
return item.copyWith(status: ToolCallStatus.executing, errorMessage: null);
return item.copyWith(
status: ToolCallStatus.executing,
errorMessage: null,
);
}
return item;
}).toList();
@@ -365,10 +372,20 @@ class ChatBloc extends Cubit<ChatState> {
}
return item;
}).toList();
emit(state.copyWith(items: failedItems, isLoading: false, error: error.toString()));
emit(
state.copyWith(
items: failedItems,
isLoading: false,
error: error.toString(),
),
);
}
}
Future<String> transcribeAudioFile(String filePath) {
return _service.transcribeAudio(filePath);
}
void clearError() {
emit(state.copyWith(error: null));
}
@@ -4,14 +4,19 @@ import '../../data/models/tool_result.dart';
/// 卡片类型常量
const _calendarCardType = 'calendar_card.v1';
const _calendarListType = 'calendar_event_list.v1';
const _calendarOperationType = 'calendar_operation.v1';
const _errorCardType = 'error_card.v1';
const _aiGeneratedSource = 'ai_generated';
const _agentGeneratedSource = 'agent_generated';
const _primaryActionType = 'primary';
class UiSchemaRenderer {
static Widget render(UiCard card) {
return switch (card.cardType) {
_calendarCardType => _renderCalendarCard(card),
_calendarListType => _renderCalendarList(card),
_calendarOperationType => _renderCalendarOperation(card),
_errorCardType => _renderErrorCard(card),
_ => _renderUnknownCard(card),
};
@@ -22,7 +27,9 @@ class UiSchemaRenderer {
final color = data.color != null
? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
: AppColors.blue500;
final isAiGenerated = data.sourceType == _aiGeneratedSource;
final isAiGenerated =
data.sourceType == _aiGeneratedSource ||
data.sourceType == _agentGeneratedSource;
return Container(
decoration: BoxDecoration(
@@ -152,6 +159,103 @@ class UiSchemaRenderer {
);
}
static Widget _renderCalendarList(UiCard card) {
final rawItems = card.data['items'];
final items = rawItems is List ? rawItems : const [];
final paginationRaw = card.data['pagination'];
final pagination = paginationRaw is Map<String, dynamic>
? paginationRaw
: const <String, dynamic>{};
final page = pagination['page'];
final total = pagination['total'];
return Container(
padding: EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: AppColors.messageCardBg,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(color: AppColors.messageCardBorder),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'日程列表',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.slate900,
),
),
if (page != null || total != null) ...[
SizedBox(height: AppSpacing.xs),
Text(
'${page ?? '-'}页 · 共${total ?? '-'}',
style: TextStyle(fontSize: 12, color: AppColors.slate500),
),
],
SizedBox(height: AppSpacing.sm),
if (items.isEmpty)
Text(
'暂无日程',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
for (final item in items)
if (item is Map<String, dynamic>)
Padding(
padding: EdgeInsets.only(bottom: AppSpacing.xs),
child: Text(
item['title']?.toString() ?? '未命名日程',
style: TextStyle(fontSize: 14, color: AppColors.slate700),
),
),
],
),
);
}
static Widget _renderCalendarOperation(UiCard card) {
final ok = card.data['ok'] == true;
final operation = card.data['operation']?.toString() ?? 'operation';
final message = card.data['message']?.toString() ?? (ok ? '操作成功' : '操作失败');
return Container(
padding: EdgeInsets.all(AppSpacing.lg),
decoration: BoxDecoration(
color: ok ? AppColors.messageCardBg : AppColors.warningBackground,
borderRadius: BorderRadius.circular(AppRadius.lg),
border: Border.all(
color: ok ? AppColors.messageCardBorder : AppColors.red400,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'日程$operation结果',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: ok ? AppColors.slate900 : AppColors.red600,
),
),
SizedBox(height: AppSpacing.xs),
Text(
message,
style: TextStyle(
fontSize: 13,
color: ok ? AppColors.slate600 : AppColors.red600,
),
),
if (card.actions != null && card.actions!.isNotEmpty) ...[
SizedBox(height: AppSpacing.md),
_buildActions(card.actions!),
],
],
),
);
}
static Widget _renderErrorCard(UiCard card) {
final message = card.data['message'] as String? ?? '发生错误';
@@ -0,0 +1,64 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:record/record.dart';
abstract class VoiceRecorder {
Future<void> start();
Future<String?> stop();
Future<void> dispose();
}
class RecordVoiceRecorder implements VoiceRecorder {
final AudioRecorder _recorder;
String? _currentPath;
RecordVoiceRecorder({AudioRecorder? recorder})
: _recorder = recorder ?? AudioRecorder();
@override
Future<void> start() async {
bool hasPermission;
try {
hasPermission = await _recorder.hasPermission();
} on MissingPluginException catch (_) {
throw StateError('录音组件未加载,请完全重启 App 后重试');
}
if (!hasPermission) {
throw StateError('录音权限未授权');
}
final fileName =
'voice_${DateTime.now().millisecondsSinceEpoch}_${DateTime.now().microsecond}.wav';
final path = '${Directory.systemTemp.path}/$fileName';
_currentPath = path;
try {
await _recorder.start(
const RecordConfig(
encoder: AudioEncoder.wav,
sampleRate: 16000,
numChannels: 1,
),
path: path,
);
} on MissingPluginException catch (_) {
throw StateError('录音组件未加载,请完全重启 App 后重试');
}
}
@override
Future<String?> stop() async {
String? stoppedPath;
try {
stoppedPath = await _recorder.stop();
} on MissingPluginException catch (_) {
throw StateError('录音组件未加载,请完全重启 App 后重试');
}
return stoppedPath ?? _currentPath;
}
@override
Future<void> dispose() async {
await _recorder.dispose();
}
}
@@ -1,11 +1,15 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/api/api_exception.dart';
import '../../../../core/theme/design_tokens.dart';
import '../../../chat/data/models/chat_list_item.dart';
import '../../../chat/presentation/bloc/chat_bloc.dart';
import '../../../chat/data/tools/route_navigation_tool.dart';
import '../../data/voice_recorder.dart';
import '../../../chat/ui/widgets/ui_schema_renderer.dart';
import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart';
@@ -23,22 +27,42 @@ const _cornerRadius = 12.0;
const _inputMinHeight = 48.0;
const _inputRadius = 24.0;
const _scrollDurationMs = 300;
const _rippleDurationMs = 1200;
const _recordingDotSize = 10.0;
/// 颜色常量
const _chatBgColor = Color(0xFFF8FAFC);
const _userBubbleColor = Color(0xFFEAF1FB);
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
final VoiceRecorder? voiceRecorder;
final Future<String> Function(String filePath)? onTranscribeAudio;
final Future<void> Function(String transcript)? onAutoSendTranscript;
final bool autoLoadHistory;
const HomeScreen({
super.key,
this.voiceRecorder,
this.onTranscribeAudio,
this.onAutoSendTranscript,
this.autoLoadHistory = true,
});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController();
late final ChatBloc _chatBloc;
late final VoiceRecorder _voiceRecorder;
late final Future<String> Function(String filePath) _transcribeAudio;
late final Future<void> Function(String transcript) _autoSendTranscript;
late final AnimationController _listeningAnimationController;
bool _isRecording = false;
bool _isTranscribing = false;
bool get _hasMessage => _messageController.text.trim().isNotEmpty;
@@ -47,7 +71,17 @@ class _HomeScreenState extends State<HomeScreen> {
super.initState();
_messageController.addListener(_onMessageChanged);
_chatBloc = ChatBloc();
_chatBloc.loadHistory();
_voiceRecorder = widget.voiceRecorder ?? RecordVoiceRecorder();
_transcribeAudio =
widget.onTranscribeAudio ?? _chatBloc.transcribeAudioFile;
_autoSendTranscript = widget.onAutoSendTranscript ?? _chatBloc.sendMessage;
_listeningAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: _rippleDurationMs),
);
if (widget.autoLoadHistory) {
_chatBloc.loadHistory();
}
}
@override
@@ -55,6 +89,8 @@ class _HomeScreenState extends State<HomeScreen> {
_messageController.removeListener(_onMessageChanged);
_messageController.dispose();
_scrollController.dispose();
_listeningAnimationController.dispose();
_voiceRecorder.dispose();
_chatBloc.close();
RouteNavigationTool.instance.clearNavigator();
super.dispose();
@@ -341,7 +377,7 @@ class _HomeScreenState extends State<HomeScreen> {
),
const SizedBox(width: 8),
Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)),
if (item.toolName == 'navigate_to_route' &&
if (item.toolName == 'front.navigate_to_route' &&
item.status == ToolCallStatus.pending) ...[
const SizedBox(width: 8),
GestureDetector(
@@ -376,7 +412,9 @@ class _HomeScreenState extends State<HomeScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () => _showBottomSheet(context),
onTap: _isRecording
? _stopRecording
: () => _showBottomSheet(context),
child: Container(
width: 36,
height: 36,
@@ -385,10 +423,10 @@ class _HomeScreenState extends State<HomeScreen> {
shape: BoxShape.circle,
border: Border.all(color: AppColors.slate300),
),
child: const Icon(
LucideIcons.plus,
child: Icon(
_isRecording ? LucideIcons.square : LucideIcons.plus,
size: 20,
color: AppColors.slate500,
color: _isRecording ? AppColors.red600 : AppColors.slate500,
),
),
),
@@ -406,32 +444,42 @@ class _HomeScreenState extends State<HomeScreen> {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: TextField(
controller: _messageController,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText: '输入消息...',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
filled: false,
),
onSubmitted: (_) => _sendMessage(context),
),
child: _isRecording
? _buildListeningIndicator()
: TextField(
controller: _messageController,
minLines: 1,
maxLines: 3,
decoration: const InputDecoration(
hintText: '输入消息...',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
isDense: true,
contentPadding: EdgeInsets.zero,
filled: false,
),
onSubmitted: (_) => _sendMessage(context),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _hasMessage ? () => _sendMessage(context) : null,
onTap: _isTranscribing
? null
: _isRecording
? () => _stopRecording(autoSendAfterTranscribe: true)
: _hasMessage
? () => _sendMessage(context)
: _startRecording,
child: Icon(
_hasMessage ? LucideIcons.send : LucideIcons.mic,
_isRecording || _hasMessage
? LucideIcons.send
: LucideIcons.mic,
size: _iconSize,
color: _hasMessage
color: _isRecording || _hasMessage
? AppColors.blue600
: AppColors.slate500,
),
@@ -462,6 +510,134 @@ class _HomeScreenState extends State<HomeScreen> {
});
}
Widget _buildListeningIndicator() {
return SizedBox(
height: _inputMinHeight,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
AnimatedBuilder(
animation: _listeningAnimationController,
builder: (context, _) {
final t = _listeningAnimationController.value;
final waveA =
0.4 + 0.6 * (1 - ((t - 0.2).abs() * 2).clamp(0.0, 1.0));
final waveB =
0.4 + 0.6 * (1 - ((t - 0.5).abs() * 2).clamp(0.0, 1.0));
final waveC =
0.4 + 0.6 * (1 - ((t - 0.8).abs() * 2).clamp(0.0, 1.0));
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_buildWaveDot(scale: waveA),
const SizedBox(width: 6),
_buildWaveDot(scale: waveB),
const SizedBox(width: 6),
_buildWaveDot(scale: waveC),
],
);
},
),
const SizedBox(width: 10),
const Text(
'正在聆听...',
style: TextStyle(fontSize: 14, color: AppColors.slate500),
),
],
),
);
}
Widget _buildWaveDot({required double scale}) {
return Transform.scale(
scale: scale,
child: Container(
width: _recordingDotSize,
height: _recordingDotSize,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.red600,
),
),
);
}
Future<void> _startRecording() async {
try {
await _voiceRecorder.start();
_listeningAnimationController.repeat();
if (!mounted) {
return;
}
setState(() {
_isRecording = true;
});
} catch (error) {
if (!mounted) {
return;
}
Toast.show(context, _readableError(error), type: ToastType.error);
}
}
Future<void> _stopRecording({bool autoSendAfterTranscribe = false}) async {
String? audioPath;
try {
audioPath = await _voiceRecorder.stop();
_listeningAnimationController.stop();
if (!mounted) {
return;
}
setState(() {
_isRecording = false;
_isTranscribing = true;
});
if (audioPath == null || audioPath.isEmpty) {
throw StateError('录音失败,请重试');
}
final transcript = await _transcribeAudio(audioPath);
if (!mounted) {
return;
}
_messageController.text = transcript;
_messageController.selection = TextSelection.fromPosition(
TextPosition(offset: transcript.length),
);
if (autoSendAfterTranscribe && transcript.trim().isNotEmpty) {
_messageController.clear();
await _autoSendTranscript(transcript.trim());
}
} catch (error) {
if (!mounted) {
return;
}
Toast.show(context, _readableError(error), type: ToastType.error);
} finally {
if (audioPath != null) {
final file = File(audioPath);
if (await file.exists()) {
await file.delete();
}
}
if (mounted) {
setState(() {
_isTranscribing = false;
});
}
}
}
String _readableError(Object error) {
if (error is ApiException) {
return error.message;
}
final raw = error.toString();
if (raw.startsWith('Instance of')) {
return '请求失败,请稍后重试';
}
return raw.replaceFirst('Bad state: ', '');
}
void _showBottomSheet(BuildContext context) {
showModalBottomSheet(
context: context,
+1
View File
@@ -21,6 +21,7 @@ dependencies:
intl: ^0.19.0
shared_preferences: ^2.2.2
json_annotation: ^4.8.1
record: ^6.1.1
dev_dependencies:
flutter_test:
+33 -2
View File
@@ -194,7 +194,7 @@ void main() {
final json = {
'type': 'TOOL_CALL_START',
'toolCallId': 'tc_123',
'toolCallName': 'create_calendar_event',
'toolCallName': 'back.mutate_calendar_event',
'parentMessageId': 'msg_001',
};
@@ -203,7 +203,7 @@ void main() {
expect(event, isA<ToolCallStartEvent>());
final toolStart = event as ToolCallStartEvent;
expect(toolStart.toolCallId, 'tc_123');
expect(toolStart.toolCallName, 'create_calendar_event');
expect(toolStart.toolCallName, 'back.mutate_calendar_event');
expect(toolStart.parentMessageId, 'msg_001');
});
@@ -265,6 +265,37 @@ void main() {
expect(toolResult.result['eventId'], 'evt_001');
});
test('ToolCallResultEvent.ui parses from payload.ui', () {
final json = {
'type': 'TOOL_CALL_RESULT',
'messageId': 'msg_123',
'toolCallId': 'tc_123',
'content':
'{"ui":{"type":"calendar_card.v1","version":"v1","data":{"id":"evt_1","title":"会议","startAt":"2026-03-01T10:00:00Z"},"actions":[]}}',
};
final event = AgUiEvent.fromJson(json) as ToolCallResultEvent;
expect(event.ui, isNotNull);
expect(event.ui!.cardType, 'calendar_card.v1');
});
test(
'ToolCallResultEvent.ui parses from payload.result when result is UiCard',
() {
final json = {
'type': 'TOOL_CALL_RESULT',
'messageId': 'msg_123',
'toolCallId': 'tc_123',
'content':
'{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true},"actions":[]}}',
};
final event = AgUiEvent.fromJson(json) as ToolCallResultEvent;
expect(event.ui, isNotNull);
expect(event.ui!.cardType, 'calendar_operation.v1');
},
);
test('parses ToolCallErrorEvent', () {
final json = {
'type': 'TOOL_CALL_ERROR',
+36 -36
View File
@@ -26,8 +26,6 @@ class TestableAgUiService extends AgUiService {
final forceTrigger = engine.tryForceTrigger(content);
if (forceTrigger != null) {
await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
} else if (engine.shouldTriggerToolCall(content)) {
await mockToolCallFlow(content, engine);
}
final replies = generateReplies(content, engine);
@@ -38,13 +36,6 @@ class TestableAgUiService extends AgUiService {
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
}
Future<void> mockToolCallFlow(String content, AiDecisionEngine engine) async {
final args = engine.getToolCallArgs(content);
if (args == null) return;
await mockToolCallFlowWithArgs('create_calendar_event', args);
}
Future<void> mockToolCallFlowWithArgs(
String toolName,
Map<String, dynamic> args,
@@ -57,6 +48,10 @@ class TestableAgUiService extends AgUiService {
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
if (toolName == 'front.navigate_to_route') {
return;
}
final validation = ToolRegistry.validateArgs(toolName, args);
if (!validation.ok) {
onEvent(
@@ -71,7 +66,7 @@ class TestableAgUiService extends AgUiService {
try {
ToolRegistry.initialize();
final result = await ToolRegistry.execute(toolName, args);
await ToolRegistry.execute(toolName, args);
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
onEvent(
@@ -157,28 +152,30 @@ void main() {
expect(types.last, AgUiEventType.runFinished);
});
test('creating schedule text triggers tool call events', () async {
await service.sendMessage('提醒我明天10点开会');
test(
'creating schedule text does not trigger frontend tool call events',
() async {
await service.sendMessage('提醒我明天10点开会');
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
final toolCallEnds = capturedEvents
.whereType<ToolCallEndEvent>()
.toList();
final toolCallResults = capturedEvents
.whereType<ToolCallResultEvent>()
.toList();
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
final toolCallEnds = capturedEvents
.whereType<ToolCallEndEvent>()
.toList();
final toolCallResults = capturedEvents
.whereType<ToolCallResultEvent>()
.toList();
expect(toolCallStarts.isNotEmpty, true);
expect(toolCallEnds.isNotEmpty, true);
expect(toolCallResults.isNotEmpty, true);
expect(toolCallStarts.first.toolCallName, 'create_calendar_event');
});
expect(toolCallStarts.isEmpty, true);
expect(toolCallEnds.isEmpty, true);
expect(toolCallResults.isEmpty, true);
},
);
test('force trigger with #tool syntax', () async {
await service.sendMessage(
'#tool:create_calendar_event {"title": "Test", "startAt": "2026-03-01T10:00:00Z"}',
'#tool:front.navigate_to_route {"target": "/calendar/dayweek"}',
);
final toolCallStarts = capturedEvents
@@ -186,7 +183,7 @@ void main() {
.toList();
expect(toolCallStarts.isNotEmpty, true);
expect(toolCallStarts.first.toolCallName, 'create_calendar_event');
expect(toolCallStarts.first.toolCallName, 'front.navigate_to_route');
});
test('text message events are emitted for unknown intent', () async {
@@ -215,15 +212,18 @@ void main() {
expect(toolCallStarts.isEmpty, true);
});
test('tool call with invalid args emits error', () async {
await service.sendMessage('#tool:create_calendar_event {}');
test('frontend tool call keeps pending state before approval', () async {
await service.sendMessage('#tool:front.navigate_to_route {}');
final toolCallErrors = capturedEvents
.whereType<ToolCallErrorEvent>()
.toList();
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
expect(toolCallErrors.isNotEmpty, true);
expect(toolCallErrors.first.error, contains('Missing required fields'));
expect(toolCallStarts.isNotEmpty, true);
expect(toolCallErrors.isEmpty, true);
});
});
@@ -319,7 +319,7 @@ void main() {
await service.sendMessage('初始化会话');
await service.approveToolCall(
toolCallId: 'call-1',
toolName: 'navigate_to_route',
toolName: 'front.navigate_to_route',
args: {
'target': '/calendar/dayweek',
'replace': false,
@@ -349,7 +349,7 @@ void main() {
(e) => e.toolCallId == toolStart.toolCallId,
);
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
expect(toolStart.toolCallName, 'navigate_to_route');
expect(toolStart.toolCallName, 'front.navigate_to_route');
expect(
events
.whereType<ToolCallResultEvent>()
@@ -360,7 +360,7 @@ void main() {
await realService.approveToolCall(
toolCallId: toolStart.toolCallId,
toolName: 'navigate_to_route',
toolName: 'front.navigate_to_route',
args: toolArgs,
);
@@ -387,7 +387,7 @@ void main() {
expect(
() => realService.approveToolCall(
toolCallId: toolStart.toolCallId,
toolName: 'navigate_to_route',
toolName: 'front.navigate_to_route',
args: toolArgs,
),
throwsA(isA<StateError>()),
@@ -112,13 +112,18 @@ void main() {
});
group('tryForceTrigger', () {
test('returns ForceTriggerResult for "#tool:create_calendar_event {}"', () {
final result = engine.tryForceTrigger('#tool:create_calendar_event {}');
test(
'returns ForceTriggerResult for "#tool:front.navigate_to_route {}"',
() {
final result = engine.tryForceTrigger(
'#tool:front.navigate_to_route {}',
);
expect(result, isNotNull);
expect(result!.toolName, 'create_calendar_event');
expect(result.args, isEmpty);
});
expect(result, isNotNull);
expect(result!.toolName, 'front.navigate_to_route');
expect(result.args, isEmpty);
},
);
test(
'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"',
+38 -3
View File
@@ -194,7 +194,7 @@ void main() {
service.onEvent(
ToolCallStartEvent(
toolCallId: 'tc_1',
toolCallName: 'create_calendar_event',
toolCallName: 'back.mutate_calendar_event',
),
);
},
@@ -203,7 +203,7 @@ void main() {
(s) {
final item = s.items.first;
return item is ToolCallItem &&
item.toolName == 'create_calendar_event' &&
item.toolName == 'back.mutate_calendar_event' &&
item.status == ToolCallStatus.pending;
},
'has pending tool call',
@@ -220,7 +220,7 @@ void main() {
ToolCallItem(
id: 'tc_1',
callId: 'tc_1',
toolName: 'navigate_to_route',
toolName: 'front.navigate_to_route',
args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'},
status: ToolCallStatus.executing,
timestamp: DateTime.now(),
@@ -241,5 +241,40 @@ void main() {
isA<ChatState>().having((s) => s.items.isEmpty, 'items empty', true),
],
);
blocTest<ChatBloc, ChatState>(
'toolCallResult with ui in payload.result adds ToolResultItem',
build: () => chatBloc,
seed: () => ChatState(
items: [
ToolCallItem(
id: 'tc_2',
callId: 'tc_2',
toolName: 'back.mutate_calendar_event',
args: {'operation': 'create'},
status: ToolCallStatus.executing,
timestamp: DateTime.now(),
sender: MessageSender.ai,
),
],
),
act: (bloc) {
service.onEvent(
ToolCallResultEvent(
messageId: 'msg_tool_2',
toolCallId: 'tc_2',
content:
'{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true,"message":"done"},"actions":[]}}',
),
);
},
expect: () => [
isA<ChatState>().having(
(s) => s.items.first is ToolResultItem,
'first item is ToolResultItem',
true,
),
],
);
});
}
+31 -62
View File
@@ -12,11 +12,11 @@ void main() {
});
group('getTool', () {
test('returns tool definition for create_calendar_event', () {
final tool = ToolRegistry.getTool('create_calendar_event');
test('returns tool definition for front.navigate_to_route', () {
final tool = ToolRegistry.getTool('front.navigate_to_route');
expect(tool, isNotNull);
expect(tool!.name, 'create_calendar_event');
expect(tool!.name, 'front.navigate_to_route');
expect(tool.description, isNotEmpty);
});
@@ -26,26 +26,16 @@ void main() {
});
group('validateArgs', () {
test('returns error for empty args (missing title)', () {
final result = ToolRegistry.validateArgs('create_calendar_event', {});
test('returns error for empty args (missing target)', () {
final result = ToolRegistry.validateArgs('front.navigate_to_route', {});
expect(result.ok, false);
expect(result.error, contains('title'));
expect(result.error, contains('target'));
});
test('returns error when missing startAt', () {
final result = ToolRegistry.validateArgs('create_calendar_event', {
'title': 'Test Event',
});
expect(result.ok, false);
expect(result.error, contains('startAt'));
});
test('returns ok: true for valid args with title and startAt', () {
final result = ToolRegistry.validateArgs('create_calendar_event', {
'title': 'x',
'startAt': 'x',
test('returns ok: true for valid args', () {
final result = ToolRegistry.validateArgs('front.navigate_to_route', {
'target': '/settings',
});
expect(result.ok, true);
@@ -61,17 +51,6 @@ void main() {
});
group('execute', () {
test('returns eventId on success', () async {
final result = await ToolRegistry.execute('create_calendar_event', {
'title': 'Test Meeting',
'startAt': '2026-03-01T10:00:00Z',
});
expect(result['eventId'], isNotNull);
expect(result['ok'], true);
expect(result['title'], 'Test Meeting');
});
test('throws ToolNotFoundException for unknown tool', () async {
expect(
() => ToolRegistry.execute('unknown_tool', {}),
@@ -79,22 +58,8 @@ void main() {
);
});
test('includes optional fields in result', () async {
final result = await ToolRegistry.execute('create_calendar_event', {
'title': 'Test',
'startAt': '2026-03-01T10:00:00Z',
'description': 'Description',
'location': 'Room A',
'endAt': '2026-03-01T11:00:00Z',
});
expect(result['description'], 'Description');
expect(result['location'], 'Room A');
expect(result['endAt'], '2026-03-01T11:00:00Z');
});
test('navigate_to_route rejects disallowed target', () async {
final result = await ToolRegistry.execute('navigate_to_route', {
test('front.navigate_to_route rejects disallowed target', () async {
final result = await ToolRegistry.execute('front.navigate_to_route', {
'target': '/admin',
});
@@ -102,23 +67,26 @@ void main() {
expect(result['error'], contains('not allowed'));
});
test('navigate_to_route executes allowed target when navigator is bound', () async {
String? navigatedTo;
bool replaced = false;
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
navigatedTo = target;
replaced = replace;
});
test(
'front.navigate_to_route executes allowed target when navigator is bound',
() async {
String? navigatedTo;
bool replaced = false;
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
navigatedTo = target;
replaced = replace;
});
final result = await ToolRegistry.execute('navigate_to_route', {
'target': '/settings',
'replace': true,
});
final result = await ToolRegistry.execute('front.navigate_to_route', {
'target': '/settings',
'replace': true,
});
expect(result['ok'], true);
expect(navigatedTo, '/settings');
expect(replaced, true);
});
expect(result['ok'], true);
expect(navigatedTo, '/settings');
expect(replaced, true);
},
);
});
group('getAllTools', () {
@@ -126,7 +94,8 @@ void main() {
final tools = ToolRegistry.getAllTools();
expect(tools, isNotEmpty);
expect(tools.any((t) => t.name == 'create_calendar_event'), true);
expect(tools.any((t) => t.name == 'front.navigate_to_route'), true);
expect(tools.any((t) => t.name == 'create_calendar_event'), false);
});
});
}
@@ -94,6 +94,61 @@ void main() {
expect(find.text('AI生成'), findsOneWidget);
});
testWidgets('calendar_card.v1 renders agent generated tag', (tester) async {
final card = UiCard(
cardType: 'calendar_card.v1',
data: CalendarCardData(
id: 'evt_001',
title: 'Meeting',
startAt: '2026-03-01T10:00:00Z',
sourceType: 'agent_generated',
).toJson(),
);
await tester.pumpWidget(
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
);
expect(find.text('AI生成'), findsOneWidget);
});
testWidgets('calendar_event_list.v1 renders list items', (tester) async {
final card = UiCard(
cardType: 'calendar_event_list.v1',
data: {
'items': [
{'id': 'evt_1', 'title': '晨会'},
{'id': 'evt_2', 'title': '评审'},
],
'pagination': {'page': 1, 'total': 2},
},
);
await tester.pumpWidget(
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
);
expect(find.text('日程列表'), findsOneWidget);
expect(find.text('晨会'), findsOneWidget);
expect(find.text('评审'), findsOneWidget);
});
testWidgets('calendar_operation.v1 renders operation message', (
tester,
) async {
final card = UiCard(
cardType: 'calendar_operation.v1',
data: {'operation': 'delete', 'ok': true, 'message': '日程已删除'},
);
await tester.pumpWidget(
MaterialApp(home: Scaffold(body: UiSchemaRenderer.render(card))),
);
expect(find.text('日程delete结果'), findsOneWidget);
expect(find.text('日程已删除'), findsOneWidget);
});
testWidgets('error_card.v1 renders error message', (tester) async {
final card = UiCard(
cardType: 'error_card.v1',
@@ -1,12 +1,36 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons/lucide_icons.dart';
import 'package:social_app/core/api/api_exception.dart';
import 'package:social_app/features/home/data/voice_recorder.dart';
import 'package:social_app/features/home/ui/screens/home_screen.dart';
class _FakeVoiceRecorder implements VoiceRecorder {
bool started = false;
String? stoppedPath;
@override
Future<void> start() async {
started = true;
}
@override
Future<String?> stop() async {
started = false;
stoppedPath = '/tmp/test-audio.wav';
return stoppedPath;
}
@override
Future<void> dispose() async {}
}
void main() {
group('HomeScreen Widget Tests', () {
testWidgets('displays input field', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
await tester.pumpWidget(
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
);
await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget);
@@ -14,7 +38,9 @@ void main() {
});
testWidgets('displays header icons', (WidgetTester tester) async {
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
await tester.pumpWidget(
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
);
await tester.pumpAndSettle();
expect(find.byIcon(LucideIcons.settings), findsOneWidget);
@@ -25,10 +51,116 @@ void main() {
testWidgets('displays send or mic icon based on input', (
WidgetTester tester,
) async {
await tester.pumpWidget(const MaterialApp(home: HomeScreen()));
await tester.pumpWidget(
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
);
await tester.pumpAndSettle();
expect(find.byIcon(LucideIcons.mic), findsOneWidget);
});
testWidgets('tap mic starts recording and shows listening state', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(voiceRecorder: fakeRecorder, autoLoadHistory: false),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump();
expect(fakeRecorder.started, true);
expect(find.text('正在聆听...'), findsOneWidget);
expect(find.byIcon(LucideIcons.square), findsOneWidget);
expect(find.byIcon(LucideIcons.send), findsOneWidget);
});
testWidgets('tap send while recording transcribes and auto sends message', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
String? sentTranscript;
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (filePath) async {
expect(filePath, '/tmp/test-audio.wav');
return '语音自动发送';
},
onAutoSendTranscript: (transcript) async {
sentTranscript = transcript;
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.send));
await tester.pumpAndSettle();
expect(sentTranscript, '语音自动发送');
expect(find.byIcon(LucideIcons.plus), findsOneWidget);
});
testWidgets('tap stop transcribes audio and fills input', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (filePath) async {
expect(filePath, '/tmp/test-audio.wav');
return '语音转文字结果';
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square));
await tester.pumpAndSettle();
expect(find.text('语音转文字结果'), findsOneWidget);
expect(find.byIcon(LucideIcons.plus), findsOneWidget);
});
testWidgets('tap stop shows readable unauthorized message', (
WidgetTester tester,
) async {
final fakeRecorder = _FakeVoiceRecorder();
await tester.pumpWidget(
MaterialApp(
home: HomeScreen(
voiceRecorder: fakeRecorder,
autoLoadHistory: false,
onTranscribeAudio: (_) async {
throw const UnauthorizedException();
},
),
),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(LucideIcons.mic));
await tester.pump();
await tester.tap(find.byIcon(LucideIcons.square));
await tester.pumpAndSettle();
expect(find.text('请重新登录'), findsOneWidget);
await tester.pump(const Duration(seconds: 3));
});
});
}
@@ -141,11 +141,17 @@ class CrewAIRuntime:
@staticmethod
def _sanitize_backend_args(execution_data: dict[str, Any]) -> dict[str, object]:
dropped = {"event_id", "id", "message", "status", "result"}
dropped = {"event_id", "id", "message", "result"}
cleaned: dict[str, object] = {}
for key, value in execution_data.items():
if not isinstance(key, str) or key in dropped:
continue
if (
key == "status"
and isinstance(value, str)
and value.upper() in {"SUCCESS", "PARTIAL", "FAILED"}
):
continue
if isinstance(value, (str, int, float, bool)) or value is None:
cleaned[key] = value
return cleaned
@@ -170,7 +176,7 @@ class CrewAIRuntime:
):
return None
backend_names = self._backend_tool_names(execution_tools)
if len(backend_names) != 1:
if not backend_names:
return None
if not hasattr(execution_result, "status") or not hasattr(
execution_result, "execution_data"
@@ -190,7 +196,39 @@ class CrewAIRuntime:
args = self._sanitize_backend_args(raw_data)
if not args:
return None
tool_name = backend_names[0]
if len(backend_names) == 1:
tool_name = backend_names[0]
else:
mutate_name = "back.mutate_calendar_event"
list_name = "back.list_calendar_events"
write_keys = {
"operation",
"eventId",
"title",
"description",
"startAt",
"endAt",
"timezone",
"location",
"color",
"status",
}
list_keys = {"page", "pageSize"}
has_write_keys = any(key in args for key in write_keys)
has_event_id = "eventId" in args
if mutate_name in backend_names and has_write_keys:
tool_name = mutate_name
if "operation" not in args:
if has_event_id:
return None
args = {"operation": "create", **args}
elif list_name in backend_names and (
any(key in args for key in list_keys)
or not any(key in args for key in write_keys)
):
tool_name = list_name
else:
return None
result = self._backend_tool_handler(tool_name, args)
synthesized_call = {
"name": tool_name,
@@ -1,11 +1,13 @@
from __future__ import annotations
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import (
CREATE_CALENDAR_EVENT_TOOL,
LIST_CALENDAR_EVENTS_TOOL,
MUTATE_CALENDAR_EVENT_TOOL,
)
REGISTERED_TOOLS = {
CREATE_CALENDAR_EVENT_TOOL.name: CREATE_CALENDAR_EVENT_TOOL,
LIST_CALENDAR_EVENTS_TOOL.name: LIST_CALENDAR_EVENTS_TOOL,
MUTATE_CALENDAR_EVENT_TOOL.name: MUTATE_CALENDAR_EVENT_TOOL,
}
__all__ = ["REGISTERED_TOOLS"]
@@ -1,5 +1,6 @@
from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone
from uuid import UUID
@@ -8,10 +9,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.models import CurrentUser
from core.agent.infrastructure.crewai.tools.base import CrewAIToolSpec
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository
from v1.schedule_items.schemas import ScheduleItemCreateRequest, ScheduleItemMetadata
from v1.schedule_items.schemas import (
ScheduleItemCreateRequest,
ScheduleItemMetadata,
ScheduleItemStatus,
ScheduleItemUpdateRequest,
)
from v1.schedule_items.service import ScheduleItemService
_HEX_COLOR_PATTERN = re.compile(r"^#[0-9A-Fa-f]{6}$")
def _parse_datetime(value: object) -> datetime | None:
if not isinstance(value, str) or not value:
return None
@@ -24,10 +33,122 @@ def _parse_datetime(value: object) -> datetime | None:
return None
async def _execute_create_calendar_event(
def _parse_positive_int(
value: object,
*,
default: int,
minimum: int,
maximum: int,
) -> int:
if isinstance(value, bool):
return default
candidate: int | float | str
if isinstance(value, (int, float, str)):
candidate = value
else:
return default
if isinstance(candidate, str):
candidate = candidate.strip()
try:
parsed = int(candidate)
except (TypeError, ValueError):
return default
if parsed < minimum:
return minimum
if parsed > maximum:
return maximum
return parsed
def _parse_event_id(value: object) -> UUID:
if not isinstance(value, str) or not value.strip():
raise ValueError("eventId is required")
try:
return UUID(value)
except ValueError as exc:
raise ValueError("eventId must be a valid UUID") from exc
def _service(session: AsyncSession, owner_id: UUID) -> ScheduleItemService:
return ScheduleItemService(
repository=SQLAlchemyScheduleItemRepository(session),
session=session,
current_user=CurrentUser(id=owner_id),
)
def _resolve_metadata(tool_args: dict[str, object]) -> ScheduleItemMetadata:
location = tool_args.get("location")
location_value = location.strip() if isinstance(location, str) else None
color = tool_args.get("color")
raw_color = color.strip() if isinstance(color, str) and color.strip() else "#4F46E5"
color_value = raw_color if _HEX_COLOR_PATTERN.match(raw_color) else "#4F46E5"
return ScheduleItemMetadata(location=location_value, color=color_value)
def _event_payload(event: object) -> dict[str, object]:
event_id = str(getattr(event, "id"))
metadata = getattr(event, "metadata", None)
location_value = getattr(metadata, "location", None)
color_value = getattr(metadata, "color", None) or "#4F46E5"
return {
"id": event_id,
"title": getattr(event, "title"),
"description": getattr(event, "description"),
"startAt": getattr(event, "start_at").isoformat(),
"endAt": (
getattr(event, "end_at").isoformat()
if getattr(event, "end_at") is not None
else None
),
"timezone": getattr(event, "timezone"),
"location": location_value,
"color": color_value,
}
async def _execute_list_calendar_events(
session: AsyncSession,
owner_id: UUID,
tool_args: dict[str, object],
) -> dict[str, object]:
page = _parse_positive_int(
tool_args.get("page"),
default=1,
minimum=1,
maximum=100000,
)
page_size = _parse_positive_int(
tool_args.get("pageSize"),
default=20,
minimum=1,
maximum=100,
)
service = _service(session, owner_id)
items, total = await service.list_paginated(page=page, page_size=page_size)
total_pages = max(1, (total + page_size - 1) // page_size) if total else 0
return {
"type": "calendar_event_list.v1",
"version": "v1",
"data": {
"items": [_event_payload(item) for item in items],
"pagination": {
"page": page,
"pageSize": page_size,
"total": total,
"totalPages": total_pages,
},
"ok": True,
"message": "已获取日程列表",
},
"actions": [],
}
async def _execute_create(
*,
service: ScheduleItemService,
tool_args: dict[str, object],
) -> dict[str, object]:
title = str(tool_args.get("title", "新的日程")).strip() or "新的日程"
description = str(tool_args.get("description", "")).strip() or None
@@ -35,15 +156,8 @@ async def _execute_create_calendar_event(
if start_at is None:
start_at = datetime.now(timezone.utc) + timedelta(hours=1)
end_at = _parse_datetime(tool_args.get("endAt"))
timezone_value = str(tool_args.get("timezone", "Asia/Shanghai"))
location = tool_args.get("location")
location_value = str(location) if isinstance(location, str) else None
metadata = ScheduleItemMetadata(location=location_value, color="#4F46E5")
service = ScheduleItemService(
repository=SQLAlchemyScheduleItemRepository(session),
session=session,
current_user=CurrentUser(id=owner_id),
timezone_value = (
str(tool_args.get("timezone", "Asia/Shanghai")).strip() or "Asia/Shanghai"
)
created = await service.create_agent_generated(
ScheduleItemCreateRequest(
@@ -52,22 +166,16 @@ async def _execute_create_calendar_event(
start_at=start_at,
end_at=end_at,
timezone=timezone_value,
metadata=metadata,
metadata=_resolve_metadata(tool_args),
)
)
event_id = str(created.id)
event_data = _event_payload(created)
event_id = str(event_data["id"])
return {
"type": "calendar_card.v1",
"version": "v1",
"data": {
"id": event_id,
"title": created.title,
"description": created.description,
"startAt": created.start_at.isoformat(),
"endAt": created.end_at.isoformat() if created.end_at is not None else None,
"timezone": created.timezone,
"location": location_value,
"color": "#4F46E5",
**event_data,
"sourceType": "agent_generated",
"ok": True,
"message": "日程已创建",
@@ -82,8 +190,125 @@ async def _execute_create_calendar_event(
}
CREATE_CALENDAR_EVENT_TOOL = CrewAIToolSpec(
name="back.create_calendar_event",
async def _execute_update(
*,
service: ScheduleItemService,
tool_args: dict[str, object],
) -> dict[str, object]:
event_id = _parse_event_id(tool_args.get("eventId"))
update_data: dict[str, object] = {}
for source_key, target_key in (
("title", "title"),
("description", "description"),
("timezone", "timezone"),
):
value = tool_args.get(source_key)
if isinstance(value, str):
update_data[target_key] = value.strip()
start_at = _parse_datetime(tool_args.get("startAt"))
if start_at is not None:
update_data["start_at"] = start_at
end_at = _parse_datetime(tool_args.get("endAt"))
if end_at is not None:
update_data["end_at"] = end_at
status_value = tool_args.get("status")
if isinstance(status_value, str) and status_value.strip():
try:
update_data["status"] = ScheduleItemStatus(status_value.strip().lower())
except ValueError as exc:
raise ValueError(
"status must be one of: active, completed, canceled, archived"
) from exc
has_location = isinstance(tool_args.get("location"), str)
has_color = isinstance(tool_args.get("color"), str)
if has_location or has_color:
existing = await service.get_by_id(event_id)
metadata_dump = (
existing.metadata.model_dump() if existing.metadata is not None else {}
)
if has_location:
metadata_dump["location"] = str(tool_args.get("location")).strip() or None
if has_color:
color = str(tool_args.get("color")).strip()
if not color:
metadata_dump["color"] = None
elif _HEX_COLOR_PATTERN.match(color):
metadata_dump["color"] = color
else:
raise ValueError("color must be a hex string like #RRGGBB")
update_data["metadata"] = ScheduleItemMetadata.model_validate(metadata_dump)
updated = await service.update(
event_id,
ScheduleItemUpdateRequest.model_validate(update_data),
)
event_data = _event_payload(updated)
return {
"type": "calendar_card.v1",
"version": "v1",
"data": {
**event_data,
"sourceType": "agent_generated",
"ok": True,
"message": "日程已更新",
},
"actions": [
{
"type": "link",
"label": "查看详情",
"target": f"/calendar/events/{event_data['id']}",
}
],
}
async def _execute_delete(
*,
service: ScheduleItemService,
tool_args: dict[str, object],
) -> dict[str, object]:
event_id = _parse_event_id(tool_args.get("eventId"))
await service.delete(event_id)
return {
"type": "calendar_operation.v1",
"version": "v1",
"data": {
"operation": "delete",
"id": str(event_id),
"ok": True,
"message": "日程已删除",
},
"actions": [],
}
async def _execute_mutate_calendar_event(
session: AsyncSession,
owner_id: UUID,
tool_args: dict[str, object],
) -> dict[str, object]:
operation_raw = tool_args.get("operation")
if not isinstance(operation_raw, str) or not operation_raw.strip():
raise ValueError("operation is required")
operation = operation_raw.strip().lower()
service = _service(session, owner_id)
if operation == "create":
return await _execute_create(service=service, tool_args=tool_args)
if operation == "update":
return await _execute_update(service=service, tool_args=tool_args)
if operation == "delete":
return await _execute_delete(service=service, tool_args=tool_args)
raise ValueError("operation must be one of: create, update, delete")
LIST_CALENDAR_EVENTS_TOOL = CrewAIToolSpec(
name="back.list_calendar_events",
target="backend",
executor=_execute_create_calendar_event,
executor=_execute_list_calendar_events,
)
MUTATE_CALENDAR_EVENT_TOOL = CrewAIToolSpec(
name="back.mutate_calendar_event",
target="backend",
executor=_execute_mutate_calendar_event,
)
@@ -4,7 +4,10 @@ from core.agent.infrastructure.crewai.tools import REGISTERED_TOOLS
STAGE_TOOL_ALLOWLIST: dict[str, list[str]] = {
"intent": [],
"execution": ["back.create_calendar_event"],
"execution": [
"back.list_calendar_events",
"back.mutate_calendar_event",
],
"organization": [],
}
+46 -4
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, Protocol
from uuid import UUID
from sqlalchemy import select, update
from sqlalchemy import func, select, update
from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository
@@ -33,6 +33,13 @@ class ScheduleItemRepository(Protocol):
async def list_by_date_range(
self, owner_id: UUID, start_at: datetime, end_at: datetime
) -> list[ScheduleItem]: ...
async def list_paginated(
self,
owner_id: UUID,
*,
page: int,
page_size: int,
) -> tuple[list[ScheduleItem], int]: ...
async def create_subscription(self, data: dict) -> ScheduleSubscription: ...
@@ -131,11 +138,46 @@ class SQLAlchemyScheduleItemRepository(BaseRepository[ScheduleItem]):
logger.exception("Schedule item list failed", owner_id=str(owner_id))
raise
async def list_paginated(
self,
owner_id: UUID,
*,
page: int,
page_size: int,
) -> tuple[list[ScheduleItem], int]:
offset = (page - 1) * page_size
try:
count_stmt = (
select(func.count())
.select_from(ScheduleItem)
.where(ScheduleItem.owner_id == owner_id)
.where(ScheduleItem.deleted_at.is_(None))
)
count_result = await self._session.execute(count_stmt)
total = int(count_result.scalar_one() or 0)
items_stmt = (
select(ScheduleItem)
.where(ScheduleItem.owner_id == owner_id)
.where(ScheduleItem.deleted_at.is_(None))
.order_by(ScheduleItem.start_at.asc(), ScheduleItem.id.asc())
.offset(offset)
.limit(page_size)
)
items_result = await self._session.execute(items_stmt)
items = list(items_result.scalars().all())
return items, total
except SQLAlchemyError:
logger.exception(
"Schedule item paginated list failed",
owner_id=str(owner_id),
page=page,
page_size=page_size,
)
raise
async def create_subscription(self, data: dict) -> ScheduleSubscription:
sub = ScheduleSubscription(**data)
self._session.add(sub)
await self._session.flush()
return sub
async def get_by_id(self, entity_id: UUID) -> ScheduleItem | None:
return await super().get_by_id(entity_id)
+28
View File
@@ -202,6 +202,34 @@ class ScheduleItemService(BaseService):
return [self._to_response(item) for item in items]
async def list_paginated(
self,
*,
page: int,
page_size: int,
) -> tuple[list[ScheduleItemResponse], int]:
user_id = self.require_user_id()
if page < 1:
raise HTTPException(status_code=400, detail="page must be >= 1")
if page_size < 1 or page_size > 100:
raise HTTPException(status_code=400, detail="page_size must be 1..100")
try:
items, total = await self._repository.list_paginated(
user_id,
page=page,
page_size=page_size,
)
except SQLAlchemyError:
logger.exception(
"Failed to list schedule items with pagination",
page=page,
page_size=page_size,
)
raise HTTPException(
status_code=503, detail="Schedule item store unavailable"
)
return [self._to_response(item) for item in items], total
async def share(
self, item_id: UUID, request: ScheduleItemShareRequest
) -> ScheduleItemShareResponse:
+1 -1
View File
@@ -355,7 +355,7 @@ async def test_agent_live_image_calendar_tool_persistence() -> None:
else:
payload = json.loads(str(downloaded))
assert payload["toolName"] == "back.create_calendar_event"
assert payload["toolName"] == "back.mutate_calendar_event"
finally:
if uploaded_paths:
try:
@@ -1,65 +0,0 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agent.infrastructure.crewai.tools.backend.create_calendar_event_tool import (
_execute_create_calendar_event,
)
@pytest.mark.asyncio
async def test_create_calendar_event_tool_returns_ui_schema_v1_top_level(
monkeypatch: pytest.MonkeyPatch,
) -> None:
event_id = uuid4()
created = SimpleNamespace(
id=event_id,
title="晨会",
description="同步计划",
start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc),
timezone="Asia/Shanghai",
)
class _FakeService:
def __init__(self, **kwargs) -> None:
del kwargs
async def create_agent_generated(self, payload):
del payload
return created
class _FakeRepository:
def __init__(self, session) -> None:
del session
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.backend.create_calendar_event_tool.ScheduleItemService",
_FakeService,
)
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.backend.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
_FakeRepository,
)
result = cast(
dict[str, object],
await _execute_create_calendar_event(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
tool_args={"title": "晨会"},
),
)
assert result["type"] == "calendar_card.v1"
assert result["version"] == "v1"
data = cast(dict[str, object], result["data"])
actions = cast(list[dict[str, object]], result["actions"])
assert data["id"] == str(event_id)
assert actions
@@ -119,7 +119,8 @@ def test_runtime_needs_execution_and_collects_front_tool_call() -> None:
assert isinstance(tools, list)
assert any(t.get("name") == "front.navigate_to_route" for t in tools)
execution_tools = cast(list[dict[str, object]], calls[1]["tools"])
assert any(t.get("name") == "back.create_calendar_event" for t in execution_tools)
assert any(t.get("name") == "back.list_calendar_events" for t in execution_tools)
assert any(t.get("name") == "back.mutate_calendar_event" for t in execution_tools)
assert result["assistant_text"] == "do it"
assert result["pending_front_tool"] == {
"name": "front.navigate_to_route",
@@ -191,7 +192,7 @@ def test_runtime_multimodal_intent_receives_execution_tool_awareness() -> None:
calls.append({"stage": stage, "tools": tools})
if stage == "intent":
return (
'{"route":"NEEDS_EXECUTION","intent_summary":"need tool","execution_brief":"call back.create_calendar_event","safety_flags":[]}',
'{"route":"NEEDS_EXECUTION","intent_summary":"need tool","execution_brief":"call back.mutate_calendar_event","safety_flags":[]}',
UsageCost(1, 1, 2, 0.01),
[],
None,
@@ -218,7 +219,8 @@ def test_runtime_multimodal_intent_receives_execution_tool_awareness() -> None:
)
intent_tools = cast(list[dict[str, object]], calls[0]["tools"])
assert any(t.get("name") == "back.create_calendar_event" for t in intent_tools)
assert any(t.get("name") == "back.list_calendar_events" for t in intent_tools)
assert any(t.get("name") == "back.mutate_calendar_event" for t in intent_tools)
def test_runtime_synthesizes_backend_call_when_model_skips_react_tool_call() -> None:
@@ -267,18 +269,78 @@ def test_runtime_synthesizes_backend_call_when_model_skips_react_tool_call() ->
assert backend_calls == [
(
"back.create_calendar_event",
{"title": "项目评审", "timezone": "Asia/Shanghai"},
"back.mutate_calendar_event",
{
"operation": "create",
"title": "项目评审",
"timezone": "Asia/Shanghai",
},
)
]
tool_calls = cast(list[dict[str, object]], result["tool_calls"])
assert any(
call.get("target") == "backend"
and call.get("name") == "back.create_calendar_event"
and call.get("name") == "back.mutate_calendar_event"
for call in tool_calls
)
def test_runtime_does_not_synthesize_mutate_create_when_event_id_without_operation() -> (
None
):
runtime = _build_runtime()
backend_calls: list[tuple[str, dict[str, object]]] = []
def _backend_handler(
tool_name: str, tool_args: dict[str, object]
) -> dict[str, object]:
backend_calls.append((tool_name, tool_args))
return {"type": "ok", "version": "v1", "data": {}, "actions": []}
runtime.set_backend_tool_handler(_backend_handler)
def _fake_run_stage(self, **kwargs):
stage = kwargs["stage"]
if stage == "intent":
return (
'{"route":"NEEDS_EXECUTION","intent_summary":"update event","execution_brief":"update via backend tool","safety_flags":[]}',
UsageCost(1, 1, 2, 0.01),
[],
None,
)
if stage == "execution":
return (
'{"status":"SUCCESS","execution_summary":"updated","execution_data":{"eventId":"1c7e85f6-a2b4-4da3-a143-7f9af8ea1a3d","title":"修正标题"},"report_brief":"done"}',
UsageCost(2, 2, 4, 0.02),
[],
None,
)
return (
'{"assistant_text":"ok","response_metadata":{}}',
UsageCost(1, 1, 2, 0.01),
[],
None,
)
runtime._run_stage_with_crewai = MethodType(_fake_run_stage, runtime) # type: ignore[method-assign]
runtime.execute(user_input="更新日程", tools=[])
assert backend_calls == []
def test_runtime_sanitize_backend_args_keeps_business_status() -> None:
payload = {
"status": "completed",
"title": "日程",
"result": "ignore",
"id": "ignore",
}
assert CrewAIRuntime._sanitize_backend_args(payload) == {
"status": "completed",
"title": "日程",
}
def test_runtime_extracts_pending_front_tool_from_approval_required_shape() -> None:
runtime = _build_runtime()
@@ -423,7 +485,8 @@ def test_run_stage_with_crewai_uses_output_pydantic_for_stage(
def test_runtime_backend_registry_check() -> None:
runtime = _build_runtime()
assert runtime.is_registered_backend_tool("back.create_calendar_event") is True
assert runtime.is_registered_backend_tool("back.list_calendar_events") is True
assert runtime.is_registered_backend_tool("back.mutate_calendar_event") is True
assert runtime.is_registered_backend_tool("back.unknown") is False
@@ -0,0 +1,128 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import (
_execute_list_calendar_events,
)
@pytest.mark.asyncio
async def test_list_calendar_events_tool_returns_paginated_payload_v1(
monkeypatch: pytest.MonkeyPatch,
) -> None:
first_id = uuid4()
second_id = uuid4()
items = [
SimpleNamespace(
id=first_id,
title="晨会",
description="同步",
start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc),
timezone="Asia/Shanghai",
metadata=SimpleNamespace(location="会议室A", color="#4F46E5"),
),
SimpleNamespace(
id=second_id,
title="评审",
description=None,
start_at=datetime(2026, 3, 8, 3, 0, tzinfo=timezone.utc),
end_at=None,
timezone="Asia/Shanghai",
metadata=None,
),
]
class _FakeService:
def __init__(self, **kwargs) -> None:
del kwargs
async def list_paginated(self, *, page: int, page_size: int):
assert page == 2
assert page_size == 10
return items, 37
class _FakeRepository:
def __init__(self, session) -> None:
del session
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
_FakeService,
)
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
_FakeRepository,
)
result = cast(
dict[str, object],
await _execute_list_calendar_events(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
tool_args={"page": 2, "pageSize": 10},
),
)
assert result["type"] == "calendar_event_list.v1"
assert result["version"] == "v1"
data = cast(dict[str, object], result["data"])
pagination = cast(dict[str, object], data["pagination"])
events = cast(list[dict[str, object]], data["items"])
assert pagination == {
"page": 2,
"pageSize": 10,
"total": 37,
"totalPages": 4,
}
assert events[0]["id"] == str(first_id)
assert events[0]["title"] == "晨会"
assert events[1]["id"] == str(second_id)
@pytest.mark.asyncio
async def test_list_calendar_events_tool_uses_default_pagination_when_missing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
class _FakeService:
def __init__(self, **kwargs) -> None:
del kwargs
async def list_paginated(self, *, page: int, page_size: int):
assert page == 1
assert page_size == 20
return [], 0
class _FakeRepository:
def __init__(self, session) -> None:
del session
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
_FakeService,
)
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
_FakeRepository,
)
result = cast(
dict[str, object],
await _execute_list_calendar_events(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
tool_args={},
),
)
data = cast(dict[str, object], result["data"])
pagination = cast(dict[str, object], data["pagination"])
assert pagination["page"] == 1
assert pagination["pageSize"] == 20
@@ -0,0 +1,173 @@
from __future__ import annotations
from datetime import datetime, timezone
from types import SimpleNamespace
from typing import cast
from uuid import uuid4
import pytest
from sqlalchemy.ext.asyncio import AsyncSession
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import (
_execute_mutate_calendar_event,
)
@pytest.mark.asyncio
async def test_mutate_calendar_event_create_returns_calendar_card_v1(
monkeypatch: pytest.MonkeyPatch,
) -> None:
created_id = uuid4()
class _FakeService:
def __init__(self, **kwargs) -> None:
del kwargs
async def create_agent_generated(self, payload):
assert payload.title == "晨会"
return SimpleNamespace(
id=created_id,
title="晨会",
description="同步计划",
start_at=datetime(2026, 3, 8, 1, 0, tzinfo=timezone.utc),
end_at=datetime(2026, 3, 8, 2, 0, tzinfo=timezone.utc),
timezone="Asia/Shanghai",
metadata=SimpleNamespace(location="会议室A", color="#4F46E5"),
)
class _FakeRepository:
def __init__(self, session) -> None:
del session
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
_FakeService,
)
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
_FakeRepository,
)
result = cast(
dict[str, object],
await _execute_mutate_calendar_event(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
tool_args={
"operation": "create",
"title": "晨会",
"description": "同步计划",
"startAt": "2026-03-08T09:00:00+08:00",
"endAt": "2026-03-08T10:00:00+08:00",
"timezone": "Asia/Shanghai",
"location": "会议室A",
},
),
)
assert result["type"] == "calendar_card.v1"
data = cast(dict[str, object], result["data"])
assert data["id"] == str(created_id)
assert data["ok"] is True
@pytest.mark.asyncio
async def test_mutate_calendar_event_update_requires_event_id() -> None:
with pytest.raises(ValueError, match="eventId is required"):
await _execute_mutate_calendar_event(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
tool_args={"operation": "update", "title": "新标题"},
)
@pytest.mark.asyncio
async def test_mutate_calendar_event_delete_returns_ack(
monkeypatch: pytest.MonkeyPatch,
) -> None:
deleted_id = uuid4()
class _FakeService:
def __init__(self, **kwargs) -> None:
del kwargs
async def delete(self, item_id):
assert item_id == deleted_id
return None
class _FakeRepository:
def __init__(self, session) -> None:
del session
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
_FakeService,
)
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
_FakeRepository,
)
result = cast(
dict[str, object],
await _execute_mutate_calendar_event(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
tool_args={"operation": "delete", "eventId": str(deleted_id)},
),
)
assert result["type"] == "calendar_operation.v1"
data = cast(dict[str, object], result["data"])
assert data["operation"] == "delete"
assert data["id"] == str(deleted_id)
assert data["ok"] is True
@pytest.mark.asyncio
async def test_mutate_calendar_event_rejects_invalid_operation() -> None:
with pytest.raises(ValueError, match="operation"):
await _execute_mutate_calendar_event(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
tool_args={"operation": "upsert"},
)
@pytest.mark.asyncio
async def test_mutate_calendar_event_update_rejects_invalid_color(
monkeypatch: pytest.MonkeyPatch,
) -> None:
event_id = uuid4()
class _FakeService:
def __init__(self, **kwargs) -> None:
del kwargs
async def get_by_id(self, item_id):
assert item_id == event_id
return SimpleNamespace(metadata=None)
class _FakeRepository:
def __init__(self, session) -> None:
del session
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.ScheduleItemService",
_FakeService,
)
monkeypatch.setattr(
"core.agent.infrastructure.crewai.tools.create_calendar_event_tool.SQLAlchemyScheduleItemRepository",
_FakeRepository,
)
with pytest.raises(ValueError, match="color"):
await _execute_mutate_calendar_event(
session=cast(AsyncSession, SimpleNamespace()),
owner_id=uuid4(),
tool_args={
"operation": "update",
"eventId": str(event_id),
"color": "blue",
},
)
@@ -646,7 +646,7 @@ async def test_run_service_passes_user_context_system_prompt_to_runtime(
tool_args,
):
del session, owner_id
assert tool_name == "back.create_calendar_event"
assert tool_name == "back.mutate_calendar_event"
assert "title" in tool_args
return {
"result": {"eventId": "evt-1", "ok": True},
@@ -788,7 +788,7 @@ async def test_run_service_emits_frontend_tool_pending_events(
class _FakeRuntime:
def is_registered_backend_tool(self, tool_name: str) -> bool:
return tool_name == "back.create_calendar_event"
return tool_name == "back.mutate_calendar_event"
async def execute_backend_tool(
self,
@@ -799,7 +799,7 @@ async def test_run_service_emits_frontend_tool_pending_events(
tool_args,
):
del session, owner_id
assert tool_name == "back.create_calendar_event"
assert tool_name == "back.mutate_calendar_event"
assert "title" in tool_args
return {
"result": {"eventId": "evt-1", "ok": True},
@@ -957,7 +957,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
class _FakeRuntime:
def is_registered_backend_tool(self, tool_name: str) -> bool:
return tool_name == "back.create_calendar_event"
return tool_name == "back.mutate_calendar_event"
async def execute_backend_tool(
self,
@@ -968,7 +968,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
tool_args,
):
del session, owner_id
assert tool_name == "back.create_calendar_event"
assert tool_name == "back.mutate_calendar_event"
assert "title" in tool_args
return {
"result": {"eventId": "evt-1", "ok": True},
@@ -1043,7 +1043,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
text="请安排一个明早会议",
tools=[
{
"name": "back.create_calendar_event",
"name": "back.mutate_calendar_event",
"description": "create calendar",
"parameters": {"type": "object"},
}
@@ -10,7 +10,10 @@ def test_load_crewai_stage_tools_returns_expected_defaults() -> None:
assert result == {
"intent": [],
"execution": ["back.create_calendar_event"],
"execution": [
"back.list_calendar_events",
"back.mutate_calendar_event",
],
"organization": [],
}