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"> <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 <application
android:label="social_app" android:label="social_app"
android:name="${applicationName}" android:name="${applicationName}"
@@ -82,7 +82,9 @@ class AiDecisionEngine {
} }
ForceTriggerResult? tryForceTrigger(String text) { 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; if (match == null) return null;
final toolName = match.group(1)!; final toolName = match.group(1)!;
@@ -297,6 +297,14 @@ class ToolCallResultEvent extends AgUiEvent {
if (rawUi is Map<String, dynamic>) { if (rawUi is Map<String, dynamic>) {
return UiCard.fromJson(rawUi); 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; return null;
} }
@@ -2,12 +2,12 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:math'; import 'dart:math';
import 'package:dio/dio.dart';
import 'package:social_app/core/api/i_api_client.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/api/mock_api_client.dart';
import '../ai/ai_decision_engine.dart'; import '../ai/ai_decision_engine.dart';
import '../models/ag_ui_event.dart'; import '../models/ag_ui_event.dart';
import '../models/tool_result.dart';
import '../tools/tool_registry.dart'; import '../tools/tool_registry.dart';
import 'mock_history_service.dart'; import 'mock_history_service.dart';
@@ -36,7 +36,7 @@ class AgUiService {
_decisionEngine = AiDecisionEngine(), _decisionEngine = AiDecisionEngine(),
_historyService = MockHistoryService() { _historyService = MockHistoryService() {
if (_apiClient is MockApiClient) { if (_apiClient is MockApiClient) {
_configureMockAgentApi(_apiClient as MockApiClient); _configureMockAgentApi(_apiClient);
} }
} }
@@ -77,6 +77,28 @@ class AgUiService {
onEvent(event); 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({ Future<void> approveToolCall({
required String toolCallId, required String toolCallId,
required String toolName, required String toolName,
@@ -210,11 +232,7 @@ class AgUiService {
'runId': runId, 'runId': runId,
'state': <String, dynamic>{}, 'state': <String, dynamic>{},
'messages': [ 'messages': [
{ {'id': _nextId('user_'), 'role': 'user', 'content': content},
'id': _nextId('user_'),
'role': 'user',
'content': content,
},
], ],
'tools': _buildTools(), 'tools': _buildTools(),
'context': <Map<String, dynamic>>[], 'context': <Map<String, dynamic>>[],
@@ -225,33 +243,20 @@ class AgUiService {
List<Map<String, dynamic>> _buildTools() { List<Map<String, dynamic>> _buildTools() {
return [ return [
{ {
'name': 'navigate_to_route', 'name': 'front.navigate_to_route',
'description': 'Navigate user to a route in the mobile app.', 'description': 'Navigate user to a route in the mobile app.',
'parameters': { 'parameters': {
'type': 'object', 'type': 'object',
'properties': { 'properties': {
'target': {'type': 'string', 'description': 'Route path target'}, 'target': {'type': 'string', 'description': 'Route path target'},
'replace': {'type': 'boolean', 'description': 'Use replace navigation'}, 'replace': {
'type': 'boolean',
'description': 'Use replace navigation',
},
}, },
'required': ['target'], '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('&')}'; 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() { String _newUuid() {
final random = Random(); final random = Random();
@@ -304,6 +310,15 @@ class AgUiService {
'SSE', 'SSE',
_handleMockSse, _handleMockSse,
); );
client.registerHandler(
'/api/v1/agent/transcribe',
'POST',
_handleMockTranscribe,
);
}
Map<String, dynamic> _handleMockTranscribe(MockRequest request) {
return {'transcript': '这是模拟语音转写'};
} }
Map<String, dynamic> _handleMockRun(MockRequest request) { Map<String, dynamic> _handleMockRun(MockRequest request) {
@@ -331,9 +346,9 @@ class AgUiService {
} }
Map<String, dynamic> _handleMockResume(MockRequest request) { Map<String, dynamic> _handleMockResume(MockRequest request) {
final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/resume$').firstMatch( final match = RegExp(
request.path, r'^/api/v1/agent/runs/([^/]+)/resume$',
); ).firstMatch(request.path);
final threadId = match?.group(1) ?? (_threadId ?? _newUuid()); final threadId = match?.group(1) ?? (_threadId ?? _newUuid());
final payload = request.data; final payload = request.data;
final runInput = payload is Map<String, dynamic> final runInput = payload is Map<String, dynamic>
@@ -344,7 +359,11 @@ class AgUiService {
final toolMessage = _extractLatestToolMessage(runInput); final toolMessage = _extractLatestToolMessage(runInput);
final events = <Map<String, dynamic>>[ final events = <Map<String, dynamic>>[
{'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId}, {
'type': AgUiEventTypeWire.runStarted,
'threadId': threadId,
'runId': runId,
},
{ {
'type': AgUiEventTypeWire.toolCallResult, 'type': AgUiEventTypeWire.toolCallResult,
'messageId': _nextId(_messageIdPrefix), 'messageId': _nextId(_messageIdPrefix),
@@ -365,7 +384,11 @@ class AgUiService {
'type': AgUiEventTypeWire.textMessageEnd, 'type': AgUiEventTypeWire.textMessageEnd,
'messageId': _nextId(_messageIdPrefix), 'messageId': _nextId(_messageIdPrefix),
}, },
{'type': AgUiEventTypeWire.runFinished, 'threadId': threadId, 'runId': runId}, {
'type': AgUiEventTypeWire.runFinished,
'threadId': threadId,
'runId': runId,
},
]; ];
_mockSseLinesByThread[threadId] = _toSseLines(events); _mockSseLinesByThread[threadId] = _toSseLines(events);
return { return {
@@ -398,7 +421,8 @@ class AgUiService {
final messages = targetDate == null final messages = targetDate == null
? <SnapshotMessage>[] ? <SnapshotMessage>[]
: _historyService.getHistoryForDay(targetDate); : _historyService.getHistoryForDay(targetDate);
final hasMore = targetDate != null && _historyService.hasEarlierHistory(targetDate); final hasMore =
targetDate != null && _historyService.hasEarlierHistory(targetDate);
_hasMoreHistory = hasMore; _hasMoreHistory = hasMore;
return { return {
@@ -421,9 +445,9 @@ class AgUiService {
} }
Stream<String> _handleMockSse(MockRequest request) { Stream<String> _handleMockSse(MockRequest request) {
final match = RegExp(r'^/api/v1/agent/runs/([^/]+)/events$').firstMatch( final match = RegExp(
request.path, r'^/api/v1/agent/runs/([^/]+)/events$',
); ).firstMatch(request.path);
final threadId = match?.group(1); final threadId = match?.group(1);
if (threadId == null) { if (threadId == null) {
return const Stream<String>.empty(); return const Stream<String>.empty();
@@ -441,7 +465,11 @@ class AgUiService {
required String userInput, required String userInput,
}) { }) {
final events = <Map<String, dynamic>>[ final events = <Map<String, dynamic>>[
{'type': AgUiEventTypeWire.runStarted, 'threadId': threadId, 'runId': runId}, {
'type': AgUiEventTypeWire.runStarted,
'threadId': threadId,
'runId': runId,
},
]; ];
final forceTrigger = _decisionEngine.tryForceTrigger(userInput); final forceTrigger = _decisionEngine.tryForceTrigger(userInput);
@@ -451,19 +479,13 @@ class AgUiService {
toolName = forceTrigger.toolName; toolName = forceTrigger.toolName;
args = forceTrigger.args; args = forceTrigger.args;
} else if (_looksLikeNavigationIntent(userInput)) { } else if (_looksLikeNavigationIntent(userInput)) {
toolName = 'navigate_to_route'; toolName = 'front.navigate_to_route';
args = {'target': _inferNavigationRoute(userInput), 'replace': false}; 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 != null && args != null) {
if (toolName == 'navigate_to_route') { if (toolName == 'front.navigate_to_route') {
args = { args = {...args, '__nonce': _nextId('nonce_')};
...args,
'__nonce': _nextId('nonce_'),
};
} }
final toolCallId = _nextId(_toolCallIdPrefix); final toolCallId = _nextId(_toolCallIdPrefix);
events.add({ events.add({
@@ -476,32 +498,20 @@ class AgUiService {
'toolCallId': toolCallId, 'toolCallId': toolCallId,
'delta': jsonEncode(args), '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。 // 前端工具:等待审批后由 resume 返回 TOOL_CALL_RESULT。
} else { } else {
final validation = ToolRegistry.validateArgs(toolName, args); events.add({
if (!validation.ok) { 'type': AgUiEventTypeWire.toolCallError,
events.add({ 'toolCallId': toolCallId,
'type': AgUiEventTypeWire.toolCallError, 'error': 'Unsupported frontend tool in mock mode',
'toolCallId': toolCallId, 'code': 'UNSUPPORTED_TOOL',
'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(),
}),
});
}
} }
} }
@@ -518,7 +528,10 @@ class AgUiService {
'messageId': messageId, 'messageId': messageId,
'delta': reply, 'delta': reply,
}); });
events.add({'type': AgUiEventTypeWire.textMessageEnd, 'messageId': messageId}); events.add({
'type': AgUiEventTypeWire.textMessageEnd,
'messageId': messageId,
});
} }
events.add({ events.add({
@@ -577,57 +590,14 @@ class AgUiService {
if (raw['role'] != 'tool') { if (raw['role'] != 'tool') {
continue; continue;
} }
final toolCallId = raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix); final toolCallId =
raw['toolCallId'] as String? ?? _nextId(_toolCallIdPrefix);
final content = raw['content'] as String? ?? '{}'; final content = raw['content'] as String? ?? '{}';
return (toolCallId, content); return (toolCallId, content);
} }
return (_nextId(_toolCallIdPrefix), '{}'); 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) { List<String> _generateReplies(String content) {
final intent = _decisionEngine.matchIntent(content); final intent = _decisionEngine.matchIntent(content);
switch (intent) { switch (intent) {
@@ -4,13 +4,7 @@ typedef ToolHandler =
Future<Map<String, dynamic>> Function(Map<String, dynamic> args); Future<Map<String, dynamic>> Function(Map<String, dynamic> args);
/// 工具常量 /// 工具常量
const _toolNameCreateCalendar = 'create_calendar_event'; const _toolNameNavigateRoute = 'front.navigate_to_route';
const _toolNameNavigateRoute = 'navigate_to_route';
const _defaultTimezone = 'Asia/Shanghai';
const _defaultEventColor = '#4F46E5';
const _defaultSourceType = 'agentGenerated';
const _titleMinLength = 1;
const _titleMaxLength = 100;
class ToolDefinition { class ToolDefinition {
final String name; final String name;
@@ -33,38 +27,6 @@ class ToolRegistry {
static void initialize() { static void initialize() {
if (_initialized) return; 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( _tools[_toolNameNavigateRoute] = ToolDefinition(
name: _toolNameNavigateRoute, name: _toolNameNavigateRoute,
description: '在前端执行路由跳转', description: '在前端执行路由跳转',
@@ -82,25 +44,6 @@ class ToolRegistry {
_initialized = true; _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( static Future<Map<String, dynamic>> _handleNavigateRoute(
Map<String, dynamic> args, Map<String, dynamic> args,
) async { ) async {
@@ -2,11 +2,11 @@ import 'dart:convert';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:social_app/core/api/i_api_client.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 'package:social_app/core/di/injection.dart';
import '../../data/models/ag_ui_event.dart'; import '../../data/models/ag_ui_event.dart';
import '../../data/models/chat_list_item.dart'; import '../../data/models/chat_list_item.dart';
import '../../data/models/tool_result.dart';
import '../../data/services/ag_ui_service.dart'; import '../../data/services/ag_ui_service.dart';
class ChatState { class ChatState {
@@ -57,7 +57,14 @@ class ChatBloc extends Cubit<ChatState> {
ChatBloc({AgUiService? service, IApiClient? apiClient}) ChatBloc({AgUiService? service, IApiClient? apiClient})
: _service = : _service =
service ?? AgUiService(apiClient: apiClient ?? sl<IApiClient>()), service ??
AgUiService(
apiClient:
apiClient ??
(sl.isRegistered<IApiClient>()
? sl<IApiClient>()
: MockApiClient()),
),
super(const ChatState()) { super(const ChatState()) {
_service.onEvent = _handleEvent; _service.onEvent = _handleEvent;
} }
@@ -162,13 +169,10 @@ class ChatBloc extends Cubit<ChatState> {
_toolCallArgsBuffer.remove(endEvent.toolCallId); _toolCallArgsBuffer.remove(endEvent.toolCallId);
final updatedItems = state.items.map((item) { final updatedItems = state.items.map((item) {
if (item.id == endEvent.toolCallId && item is ToolCallItem) { 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.pending
: ToolCallStatus.executing; : ToolCallStatus.executing;
return item.copyWith( return item.copyWith(args: parsedArgs, status: nextStatus);
args: parsedArgs,
status: nextStatus,
);
} }
return item; return item;
}).toList(); }).toList();
@@ -344,7 +348,10 @@ class ChatBloc extends Cubit<ChatState> {
} }
final updatedItems = state.items.map((item) { final updatedItems = state.items.map((item) {
if (item is ToolCallItem && item.callId == toolCallId) { 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; return item;
}).toList(); }).toList();
@@ -365,10 +372,20 @@ class ChatBloc extends Cubit<ChatState> {
} }
return item; return item;
}).toList(); }).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() { void clearError() {
emit(state.copyWith(error: null)); emit(state.copyWith(error: null));
} }
@@ -4,14 +4,19 @@ import '../../data/models/tool_result.dart';
/// 卡片类型常量 /// 卡片类型常量
const _calendarCardType = 'calendar_card.v1'; const _calendarCardType = 'calendar_card.v1';
const _calendarListType = 'calendar_event_list.v1';
const _calendarOperationType = 'calendar_operation.v1';
const _errorCardType = 'error_card.v1'; const _errorCardType = 'error_card.v1';
const _aiGeneratedSource = 'ai_generated'; const _aiGeneratedSource = 'ai_generated';
const _agentGeneratedSource = 'agent_generated';
const _primaryActionType = 'primary'; const _primaryActionType = 'primary';
class UiSchemaRenderer { class UiSchemaRenderer {
static Widget render(UiCard card) { static Widget render(UiCard card) {
return switch (card.cardType) { return switch (card.cardType) {
_calendarCardType => _renderCalendarCard(card), _calendarCardType => _renderCalendarCard(card),
_calendarListType => _renderCalendarList(card),
_calendarOperationType => _renderCalendarOperation(card),
_errorCardType => _renderErrorCard(card), _errorCardType => _renderErrorCard(card),
_ => _renderUnknownCard(card), _ => _renderUnknownCard(card),
}; };
@@ -22,7 +27,9 @@ class UiSchemaRenderer {
final color = data.color != null final color = data.color != null
? Color(int.parse(data.color!.replaceFirst('#', '0xFF'))) ? Color(int.parse(data.color!.replaceFirst('#', '0xFF')))
: AppColors.blue500; : AppColors.blue500;
final isAiGenerated = data.sourceType == _aiGeneratedSource; final isAiGenerated =
data.sourceType == _aiGeneratedSource ||
data.sourceType == _agentGeneratedSource;
return Container( return Container(
decoration: BoxDecoration( 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) { static Widget _renderErrorCard(UiCard card) {
final message = card.data['message'] as String? ?? '发生错误'; 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/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import '../../../../core/api/api_exception.dart';
import '../../../../core/theme/design_tokens.dart'; import '../../../../core/theme/design_tokens.dart';
import '../../../chat/data/models/chat_list_item.dart'; import '../../../chat/data/models/chat_list_item.dart';
import '../../../chat/presentation/bloc/chat_bloc.dart'; import '../../../chat/presentation/bloc/chat_bloc.dart';
import '../../../chat/data/tools/route_navigation_tool.dart'; import '../../../chat/data/tools/route_navigation_tool.dart';
import '../../data/voice_recorder.dart';
import '../../../chat/ui/widgets/ui_schema_renderer.dart'; import '../../../chat/ui/widgets/ui_schema_renderer.dart';
import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast.dart';
import '../../../../shared/widgets/toast/toast_type.dart'; import '../../../../shared/widgets/toast/toast_type.dart';
@@ -23,22 +27,42 @@ const _cornerRadius = 12.0;
const _inputMinHeight = 48.0; const _inputMinHeight = 48.0;
const _inputRadius = 24.0; const _inputRadius = 24.0;
const _scrollDurationMs = 300; const _scrollDurationMs = 300;
const _rippleDurationMs = 1200;
const _recordingDotSize = 10.0;
/// 颜色常量 /// 颜色常量
const _chatBgColor = Color(0xFFF8FAFC); const _chatBgColor = Color(0xFFF8FAFC);
const _userBubbleColor = Color(0xFFEAF1FB); const _userBubbleColor = Color(0xFFEAF1FB);
class HomeScreen extends StatefulWidget { 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 @override
State<HomeScreen> createState() => _HomeScreenState(); State<HomeScreen> createState() => _HomeScreenState();
} }
class _HomeScreenState extends State<HomeScreen> { class _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
final TextEditingController _messageController = TextEditingController(); final TextEditingController _messageController = TextEditingController();
final ScrollController _scrollController = ScrollController(); final ScrollController _scrollController = ScrollController();
late final ChatBloc _chatBloc; 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; bool get _hasMessage => _messageController.text.trim().isNotEmpty;
@@ -47,7 +71,17 @@ class _HomeScreenState extends State<HomeScreen> {
super.initState(); super.initState();
_messageController.addListener(_onMessageChanged); _messageController.addListener(_onMessageChanged);
_chatBloc = ChatBloc(); _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 @override
@@ -55,6 +89,8 @@ class _HomeScreenState extends State<HomeScreen> {
_messageController.removeListener(_onMessageChanged); _messageController.removeListener(_onMessageChanged);
_messageController.dispose(); _messageController.dispose();
_scrollController.dispose(); _scrollController.dispose();
_listeningAnimationController.dispose();
_voiceRecorder.dispose();
_chatBloc.close(); _chatBloc.close();
RouteNavigationTool.instance.clearNavigator(); RouteNavigationTool.instance.clearNavigator();
super.dispose(); super.dispose();
@@ -341,7 +377,7 @@ class _HomeScreenState extends State<HomeScreen> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(statusText, style: TextStyle(fontSize: 12, color: statusColor)), 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) ...[ item.status == ToolCallStatus.pending) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
GestureDetector( GestureDetector(
@@ -376,7 +412,9 @@ class _HomeScreenState extends State<HomeScreen> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
GestureDetector( GestureDetector(
onTap: () => _showBottomSheet(context), onTap: _isRecording
? _stopRecording
: () => _showBottomSheet(context),
child: Container( child: Container(
width: 36, width: 36,
height: 36, height: 36,
@@ -385,10 +423,10 @@ class _HomeScreenState extends State<HomeScreen> {
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: AppColors.slate300), border: Border.all(color: AppColors.slate300),
), ),
child: const Icon( child: Icon(
LucideIcons.plus, _isRecording ? LucideIcons.square : LucideIcons.plus,
size: 20, size: 20,
color: AppColors.slate500, color: _isRecording ? AppColors.red600 : AppColors.slate500,
), ),
), ),
), ),
@@ -406,32 +444,42 @@ class _HomeScreenState extends State<HomeScreen> {
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Expanded( Expanded(
child: TextField( child: _isRecording
controller: _messageController, ? _buildListeningIndicator()
minLines: 1, : TextField(
maxLines: 3, controller: _messageController,
decoration: const InputDecoration( minLines: 1,
hintText: '输入消息...', maxLines: 3,
border: InputBorder.none, decoration: const InputDecoration(
enabledBorder: InputBorder.none, hintText: '输入消息...',
focusedBorder: InputBorder.none, border: InputBorder.none,
disabledBorder: InputBorder.none, enabledBorder: InputBorder.none,
errorBorder: InputBorder.none, focusedBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none, disabledBorder: InputBorder.none,
isDense: true, errorBorder: InputBorder.none,
contentPadding: EdgeInsets.zero, focusedErrorBorder: InputBorder.none,
filled: false, isDense: true,
), contentPadding: EdgeInsets.zero,
onSubmitted: (_) => _sendMessage(context), filled: false,
), ),
onSubmitted: (_) => _sendMessage(context),
),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
GestureDetector( GestureDetector(
onTap: _hasMessage ? () => _sendMessage(context) : null, onTap: _isTranscribing
? null
: _isRecording
? () => _stopRecording(autoSendAfterTranscribe: true)
: _hasMessage
? () => _sendMessage(context)
: _startRecording,
child: Icon( child: Icon(
_hasMessage ? LucideIcons.send : LucideIcons.mic, _isRecording || _hasMessage
? LucideIcons.send
: LucideIcons.mic,
size: _iconSize, size: _iconSize,
color: _hasMessage color: _isRecording || _hasMessage
? AppColors.blue600 ? AppColors.blue600
: AppColors.slate500, : 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) { void _showBottomSheet(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
+1
View File
@@ -21,6 +21,7 @@ dependencies:
intl: ^0.19.0 intl: ^0.19.0
shared_preferences: ^2.2.2 shared_preferences: ^2.2.2
json_annotation: ^4.8.1 json_annotation: ^4.8.1
record: ^6.1.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+33 -2
View File
@@ -194,7 +194,7 @@ void main() {
final json = { final json = {
'type': 'TOOL_CALL_START', 'type': 'TOOL_CALL_START',
'toolCallId': 'tc_123', 'toolCallId': 'tc_123',
'toolCallName': 'create_calendar_event', 'toolCallName': 'back.mutate_calendar_event',
'parentMessageId': 'msg_001', 'parentMessageId': 'msg_001',
}; };
@@ -203,7 +203,7 @@ void main() {
expect(event, isA<ToolCallStartEvent>()); expect(event, isA<ToolCallStartEvent>());
final toolStart = event as ToolCallStartEvent; final toolStart = event as ToolCallStartEvent;
expect(toolStart.toolCallId, 'tc_123'); expect(toolStart.toolCallId, 'tc_123');
expect(toolStart.toolCallName, 'create_calendar_event'); expect(toolStart.toolCallName, 'back.mutate_calendar_event');
expect(toolStart.parentMessageId, 'msg_001'); expect(toolStart.parentMessageId, 'msg_001');
}); });
@@ -265,6 +265,37 @@ void main() {
expect(toolResult.result['eventId'], 'evt_001'); 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', () { test('parses ToolCallErrorEvent', () {
final json = { final json = {
'type': 'TOOL_CALL_ERROR', 'type': 'TOOL_CALL_ERROR',
+36 -36
View File
@@ -26,8 +26,6 @@ class TestableAgUiService extends AgUiService {
final forceTrigger = engine.tryForceTrigger(content); final forceTrigger = engine.tryForceTrigger(content);
if (forceTrigger != null) { if (forceTrigger != null) {
await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args); await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
} else if (engine.shouldTriggerToolCall(content)) {
await mockToolCallFlow(content, engine);
} }
final replies = generateReplies(content, engine); final replies = generateReplies(content, engine);
@@ -38,13 +36,6 @@ class TestableAgUiService extends AgUiService {
onEvent(RunFinishedEvent(threadId: threadId, runId: runId)); 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( Future<void> mockToolCallFlowWithArgs(
String toolName, String toolName,
Map<String, dynamic> args, Map<String, dynamic> args,
@@ -57,6 +48,10 @@ class TestableAgUiService extends AgUiService {
onEvent(ToolCallEndEvent(toolCallId: toolCallId)); onEvent(ToolCallEndEvent(toolCallId: toolCallId));
if (toolName == 'front.navigate_to_route') {
return;
}
final validation = ToolRegistry.validateArgs(toolName, args); final validation = ToolRegistry.validateArgs(toolName, args);
if (!validation.ok) { if (!validation.ok) {
onEvent( onEvent(
@@ -71,7 +66,7 @@ class TestableAgUiService extends AgUiService {
try { try {
ToolRegistry.initialize(); ToolRegistry.initialize();
final result = await ToolRegistry.execute(toolName, args); await ToolRegistry.execute(toolName, args);
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}'; final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
onEvent( onEvent(
@@ -157,28 +152,30 @@ void main() {
expect(types.last, AgUiEventType.runFinished); expect(types.last, AgUiEventType.runFinished);
}); });
test('creating schedule text triggers tool call events', () async { test(
await service.sendMessage('提醒我明天10点开会'); 'creating schedule text does not trigger frontend tool call events',
() async {
await service.sendMessage('提醒我明天10点开会');
final toolCallStarts = capturedEvents final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>() .whereType<ToolCallStartEvent>()
.toList(); .toList();
final toolCallEnds = capturedEvents final toolCallEnds = capturedEvents
.whereType<ToolCallEndEvent>() .whereType<ToolCallEndEvent>()
.toList(); .toList();
final toolCallResults = capturedEvents final toolCallResults = capturedEvents
.whereType<ToolCallResultEvent>() .whereType<ToolCallResultEvent>()
.toList(); .toList();
expect(toolCallStarts.isNotEmpty, true); expect(toolCallStarts.isEmpty, true);
expect(toolCallEnds.isNotEmpty, true); expect(toolCallEnds.isEmpty, true);
expect(toolCallResults.isNotEmpty, true); expect(toolCallResults.isEmpty, true);
expect(toolCallStarts.first.toolCallName, 'create_calendar_event'); },
}); );
test('force trigger with #tool syntax', () async { test('force trigger with #tool syntax', () async {
await service.sendMessage( 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 final toolCallStarts = capturedEvents
@@ -186,7 +183,7 @@ void main() {
.toList(); .toList();
expect(toolCallStarts.isNotEmpty, true); 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 { test('text message events are emitted for unknown intent', () async {
@@ -215,15 +212,18 @@ void main() {
expect(toolCallStarts.isEmpty, true); expect(toolCallStarts.isEmpty, true);
}); });
test('tool call with invalid args emits error', () async { test('frontend tool call keeps pending state before approval', () async {
await service.sendMessage('#tool:create_calendar_event {}'); await service.sendMessage('#tool:front.navigate_to_route {}');
final toolCallErrors = capturedEvents final toolCallErrors = capturedEvents
.whereType<ToolCallErrorEvent>() .whereType<ToolCallErrorEvent>()
.toList(); .toList();
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
expect(toolCallErrors.isNotEmpty, true); expect(toolCallStarts.isNotEmpty, true);
expect(toolCallErrors.first.error, contains('Missing required fields')); expect(toolCallErrors.isEmpty, true);
}); });
}); });
@@ -319,7 +319,7 @@ void main() {
await service.sendMessage('初始化会话'); await service.sendMessage('初始化会话');
await service.approveToolCall( await service.approveToolCall(
toolCallId: 'call-1', toolCallId: 'call-1',
toolName: 'navigate_to_route', toolName: 'front.navigate_to_route',
args: { args: {
'target': '/calendar/dayweek', 'target': '/calendar/dayweek',
'replace': false, 'replace': false,
@@ -349,7 +349,7 @@ void main() {
(e) => e.toolCallId == toolStart.toolCallId, (e) => e.toolCallId == toolStart.toolCallId,
); );
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>; final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
expect(toolStart.toolCallName, 'navigate_to_route'); expect(toolStart.toolCallName, 'front.navigate_to_route');
expect( expect(
events events
.whereType<ToolCallResultEvent>() .whereType<ToolCallResultEvent>()
@@ -360,7 +360,7 @@ void main() {
await realService.approveToolCall( await realService.approveToolCall(
toolCallId: toolStart.toolCallId, toolCallId: toolStart.toolCallId,
toolName: 'navigate_to_route', toolName: 'front.navigate_to_route',
args: toolArgs, args: toolArgs,
); );
@@ -387,7 +387,7 @@ void main() {
expect( expect(
() => realService.approveToolCall( () => realService.approveToolCall(
toolCallId: toolStart.toolCallId, toolCallId: toolStart.toolCallId,
toolName: 'navigate_to_route', toolName: 'front.navigate_to_route',
args: toolArgs, args: toolArgs,
), ),
throwsA(isA<StateError>()), throwsA(isA<StateError>()),
@@ -112,13 +112,18 @@ void main() {
}); });
group('tryForceTrigger', () { group('tryForceTrigger', () {
test('returns ForceTriggerResult for "#tool:create_calendar_event {}"', () { test(
final result = engine.tryForceTrigger('#tool:create_calendar_event {}'); 'returns ForceTriggerResult for "#tool:front.navigate_to_route {}"',
() {
final result = engine.tryForceTrigger(
'#tool:front.navigate_to_route {}',
);
expect(result, isNotNull); expect(result, isNotNull);
expect(result!.toolName, 'create_calendar_event'); expect(result!.toolName, 'front.navigate_to_route');
expect(result.args, isEmpty); expect(result.args, isEmpty);
}); },
);
test( test(
'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"', 'returns ForceTriggerResult with args for "#tool:custom {"key": "value"}"',
+38 -3
View File
@@ -194,7 +194,7 @@ void main() {
service.onEvent( service.onEvent(
ToolCallStartEvent( ToolCallStartEvent(
toolCallId: 'tc_1', toolCallId: 'tc_1',
toolCallName: 'create_calendar_event', toolCallName: 'back.mutate_calendar_event',
), ),
); );
}, },
@@ -203,7 +203,7 @@ void main() {
(s) { (s) {
final item = s.items.first; final item = s.items.first;
return item is ToolCallItem && return item is ToolCallItem &&
item.toolName == 'create_calendar_event' && item.toolName == 'back.mutate_calendar_event' &&
item.status == ToolCallStatus.pending; item.status == ToolCallStatus.pending;
}, },
'has pending tool call', 'has pending tool call',
@@ -220,7 +220,7 @@ void main() {
ToolCallItem( ToolCallItem(
id: 'tc_1', id: 'tc_1',
callId: 'tc_1', callId: 'tc_1',
toolName: 'navigate_to_route', toolName: 'front.navigate_to_route',
args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'}, args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'},
status: ToolCallStatus.executing, status: ToolCallStatus.executing,
timestamp: DateTime.now(), timestamp: DateTime.now(),
@@ -241,5 +241,40 @@ void main() {
isA<ChatState>().having((s) => s.items.isEmpty, 'items empty', true), 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', () { group('getTool', () {
test('returns tool definition for create_calendar_event', () { test('returns tool definition for front.navigate_to_route', () {
final tool = ToolRegistry.getTool('create_calendar_event'); final tool = ToolRegistry.getTool('front.navigate_to_route');
expect(tool, isNotNull); expect(tool, isNotNull);
expect(tool!.name, 'create_calendar_event'); expect(tool!.name, 'front.navigate_to_route');
expect(tool.description, isNotEmpty); expect(tool.description, isNotEmpty);
}); });
@@ -26,26 +26,16 @@ void main() {
}); });
group('validateArgs', () { group('validateArgs', () {
test('returns error for empty args (missing title)', () { test('returns error for empty args (missing target)', () {
final result = ToolRegistry.validateArgs('create_calendar_event', {}); final result = ToolRegistry.validateArgs('front.navigate_to_route', {});
expect(result.ok, false); expect(result.ok, false);
expect(result.error, contains('title')); expect(result.error, contains('target'));
}); });
test('returns error when missing startAt', () { test('returns ok: true for valid args', () {
final result = ToolRegistry.validateArgs('create_calendar_event', { final result = ToolRegistry.validateArgs('front.navigate_to_route', {
'title': 'Test Event', 'target': '/settings',
});
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',
}); });
expect(result.ok, true); expect(result.ok, true);
@@ -61,17 +51,6 @@ void main() {
}); });
group('execute', () { 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 { test('throws ToolNotFoundException for unknown tool', () async {
expect( expect(
() => ToolRegistry.execute('unknown_tool', {}), () => ToolRegistry.execute('unknown_tool', {}),
@@ -79,22 +58,8 @@ void main() {
); );
}); });
test('includes optional fields in result', () async { test('front.navigate_to_route rejects disallowed target', () async {
final result = await ToolRegistry.execute('create_calendar_event', { final result = await ToolRegistry.execute('front.navigate_to_route', {
'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', {
'target': '/admin', 'target': '/admin',
}); });
@@ -102,23 +67,26 @@ void main() {
expect(result['error'], contains('not allowed')); expect(result['error'], contains('not allowed'));
}); });
test('navigate_to_route executes allowed target when navigator is bound', () async { test(
String? navigatedTo; 'front.navigate_to_route executes allowed target when navigator is bound',
bool replaced = false; () async {
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) { String? navigatedTo;
navigatedTo = target; bool replaced = false;
replaced = replace; RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
}); navigatedTo = target;
replaced = replace;
});
final result = await ToolRegistry.execute('navigate_to_route', { final result = await ToolRegistry.execute('front.navigate_to_route', {
'target': '/settings', 'target': '/settings',
'replace': true, 'replace': true,
}); });
expect(result['ok'], true); expect(result['ok'], true);
expect(navigatedTo, '/settings'); expect(navigatedTo, '/settings');
expect(replaced, true); expect(replaced, true);
}); },
);
}); });
group('getAllTools', () { group('getAllTools', () {
@@ -126,7 +94,8 @@ void main() {
final tools = ToolRegistry.getAllTools(); final tools = ToolRegistry.getAllTools();
expect(tools, isNotEmpty); 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); 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 { testWidgets('error_card.v1 renders error message', (tester) async {
final card = UiCard( final card = UiCard(
cardType: 'error_card.v1', cardType: 'error_card.v1',
@@ -1,12 +1,36 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:lucide_icons/lucide_icons.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'; 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() { void main() {
group('HomeScreen Widget Tests', () { group('HomeScreen Widget Tests', () {
testWidgets('displays input field', (WidgetTester tester) async { 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(); await tester.pumpAndSettle();
expect(find.byType(TextField), findsOneWidget); expect(find.byType(TextField), findsOneWidget);
@@ -14,7 +38,9 @@ void main() {
}); });
testWidgets('displays header icons', (WidgetTester tester) async { 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(); await tester.pumpAndSettle();
expect(find.byIcon(LucideIcons.settings), findsOneWidget); expect(find.byIcon(LucideIcons.settings), findsOneWidget);
@@ -25,10 +51,116 @@ void main() {
testWidgets('displays send or mic icon based on input', ( testWidgets('displays send or mic icon based on input', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
await tester.pumpWidget(const MaterialApp(home: HomeScreen())); await tester.pumpWidget(
const MaterialApp(home: HomeScreen(autoLoadHistory: false)),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.byIcon(LucideIcons.mic), findsOneWidget); 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 @staticmethod
def _sanitize_backend_args(execution_data: dict[str, Any]) -> dict[str, object]: 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] = {} cleaned: dict[str, object] = {}
for key, value in execution_data.items(): for key, value in execution_data.items():
if not isinstance(key, str) or key in dropped: if not isinstance(key, str) or key in dropped:
continue 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: if isinstance(value, (str, int, float, bool)) or value is None:
cleaned[key] = value cleaned[key] = value
return cleaned return cleaned
@@ -170,7 +176,7 @@ class CrewAIRuntime:
): ):
return None return None
backend_names = self._backend_tool_names(execution_tools) backend_names = self._backend_tool_names(execution_tools)
if len(backend_names) != 1: if not backend_names:
return None return None
if not hasattr(execution_result, "status") or not hasattr( if not hasattr(execution_result, "status") or not hasattr(
execution_result, "execution_data" execution_result, "execution_data"
@@ -190,7 +196,39 @@ class CrewAIRuntime:
args = self._sanitize_backend_args(raw_data) args = self._sanitize_backend_args(raw_data)
if not args: if not args:
return None 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) result = self._backend_tool_handler(tool_name, args)
synthesized_call = { synthesized_call = {
"name": tool_name, "name": tool_name,
@@ -1,11 +1,13 @@
from __future__ import annotations from __future__ import annotations
from core.agent.infrastructure.crewai.tools.create_calendar_event_tool import ( 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 = { 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"] __all__ = ["REGISTERED_TOOLS"]
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import re
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from uuid import UUID from uuid import UUID
@@ -8,10 +9,18 @@ from sqlalchemy.ext.asyncio import AsyncSession
from core.auth.models import CurrentUser from core.auth.models import CurrentUser
from core.agent.infrastructure.crewai.tools.base import CrewAIToolSpec from core.agent.infrastructure.crewai.tools.base import CrewAIToolSpec
from v1.schedule_items.repository import SQLAlchemyScheduleItemRepository 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 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: def _parse_datetime(value: object) -> datetime | None:
if not isinstance(value, str) or not value: if not isinstance(value, str) or not value:
return None return None
@@ -24,10 +33,122 @@ def _parse_datetime(value: object) -> datetime | None:
return 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, session: AsyncSession,
owner_id: UUID, owner_id: UUID,
tool_args: dict[str, object], 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]: ) -> dict[str, object]:
title = str(tool_args.get("title", "新的日程")).strip() or "新的日程" title = str(tool_args.get("title", "新的日程")).strip() or "新的日程"
description = str(tool_args.get("description", "")).strip() or None description = str(tool_args.get("description", "")).strip() or None
@@ -35,15 +156,8 @@ async def _execute_create_calendar_event(
if start_at is None: if start_at is None:
start_at = datetime.now(timezone.utc) + timedelta(hours=1) start_at = datetime.now(timezone.utc) + timedelta(hours=1)
end_at = _parse_datetime(tool_args.get("endAt")) end_at = _parse_datetime(tool_args.get("endAt"))
timezone_value = str(tool_args.get("timezone", "Asia/Shanghai")) timezone_value = (
location = tool_args.get("location") str(tool_args.get("timezone", "Asia/Shanghai")).strip() or "Asia/Shanghai"
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),
) )
created = await service.create_agent_generated( created = await service.create_agent_generated(
ScheduleItemCreateRequest( ScheduleItemCreateRequest(
@@ -52,22 +166,16 @@ async def _execute_create_calendar_event(
start_at=start_at, start_at=start_at,
end_at=end_at, end_at=end_at,
timezone=timezone_value, 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 { return {
"type": "calendar_card.v1", "type": "calendar_card.v1",
"version": "v1", "version": "v1",
"data": { "data": {
"id": event_id, **event_data,
"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",
"sourceType": "agent_generated", "sourceType": "agent_generated",
"ok": True, "ok": True,
"message": "日程已创建", "message": "日程已创建",
@@ -82,8 +190,125 @@ async def _execute_create_calendar_event(
} }
CREATE_CALENDAR_EVENT_TOOL = CrewAIToolSpec( async def _execute_update(
name="back.create_calendar_event", *,
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", 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]] = { STAGE_TOOL_ALLOWLIST: dict[str, list[str]] = {
"intent": [], "intent": [],
"execution": ["back.create_calendar_event"], "execution": [
"back.list_calendar_events",
"back.mutate_calendar_event",
],
"organization": [], "organization": [],
} }
+46 -4
View File
@@ -4,7 +4,7 @@ from datetime import datetime, timezone
from typing import TYPE_CHECKING, Protocol from typing import TYPE_CHECKING, Protocol
from uuid import UUID from uuid import UUID
from sqlalchemy import select, update from sqlalchemy import func, select, update
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from core.db.base_repository import BaseRepository from core.db.base_repository import BaseRepository
@@ -33,6 +33,13 @@ class ScheduleItemRepository(Protocol):
async def list_by_date_range( async def list_by_date_range(
self, owner_id: UUID, start_at: datetime, end_at: datetime self, owner_id: UUID, start_at: datetime, end_at: datetime
) -> list[ScheduleItem]: ... ) -> 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: ... 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)) logger.exception("Schedule item list failed", owner_id=str(owner_id))
raise 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: async def create_subscription(self, data: dict) -> ScheduleSubscription:
sub = ScheduleSubscription(**data) sub = ScheduleSubscription(**data)
self._session.add(sub) self._session.add(sub)
await self._session.flush() await self._session.flush()
return sub 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] 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( async def share(
self, item_id: UUID, request: ScheduleItemShareRequest self, item_id: UUID, request: ScheduleItemShareRequest
) -> ScheduleItemShareResponse: ) -> ScheduleItemShareResponse:
+1 -1
View File
@@ -355,7 +355,7 @@ async def test_agent_live_image_calendar_tool_persistence() -> None:
else: else:
payload = json.loads(str(downloaded)) payload = json.loads(str(downloaded))
assert payload["toolName"] == "back.create_calendar_event" assert payload["toolName"] == "back.mutate_calendar_event"
finally: finally:
if uploaded_paths: if uploaded_paths:
try: 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 isinstance(tools, list)
assert any(t.get("name") == "front.navigate_to_route" for t in tools) assert any(t.get("name") == "front.navigate_to_route" for t in tools)
execution_tools = cast(list[dict[str, object]], calls[1]["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["assistant_text"] == "do it"
assert result["pending_front_tool"] == { assert result["pending_front_tool"] == {
"name": "front.navigate_to_route", "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}) calls.append({"stage": stage, "tools": tools})
if stage == "intent": if stage == "intent":
return ( 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), UsageCost(1, 1, 2, 0.01),
[], [],
None, 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"]) 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: 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 == [ assert backend_calls == [
( (
"back.create_calendar_event", "back.mutate_calendar_event",
{"title": "项目评审", "timezone": "Asia/Shanghai"}, {
"operation": "create",
"title": "项目评审",
"timezone": "Asia/Shanghai",
},
) )
] ]
tool_calls = cast(list[dict[str, object]], result["tool_calls"]) tool_calls = cast(list[dict[str, object]], result["tool_calls"])
assert any( assert any(
call.get("target") == "backend" 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 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: def test_runtime_extracts_pending_front_tool_from_approval_required_shape() -> None:
runtime = _build_runtime() 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: def test_runtime_backend_registry_check() -> None:
runtime = _build_runtime() 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 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, tool_args,
): ):
del session, owner_id del session, owner_id
assert tool_name == "back.create_calendar_event" assert tool_name == "back.mutate_calendar_event"
assert "title" in tool_args assert "title" in tool_args
return { return {
"result": {"eventId": "evt-1", "ok": True}, "result": {"eventId": "evt-1", "ok": True},
@@ -788,7 +788,7 @@ async def test_run_service_emits_frontend_tool_pending_events(
class _FakeRuntime: class _FakeRuntime:
def is_registered_backend_tool(self, tool_name: str) -> bool: 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( async def execute_backend_tool(
self, self,
@@ -799,7 +799,7 @@ async def test_run_service_emits_frontend_tool_pending_events(
tool_args, tool_args,
): ):
del session, owner_id del session, owner_id
assert tool_name == "back.create_calendar_event" assert tool_name == "back.mutate_calendar_event"
assert "title" in tool_args assert "title" in tool_args
return { return {
"result": {"eventId": "evt-1", "ok": True}, "result": {"eventId": "evt-1", "ok": True},
@@ -957,7 +957,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
class _FakeRuntime: class _FakeRuntime:
def is_registered_backend_tool(self, tool_name: str) -> bool: 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( async def execute_backend_tool(
self, self,
@@ -968,7 +968,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
tool_args, tool_args,
): ):
del session, owner_id del session, owner_id
assert tool_name == "back.create_calendar_event" assert tool_name == "back.mutate_calendar_event"
assert "title" in tool_args assert "title" in tool_args
return { return {
"result": {"eventId": "evt-1", "ok": True}, "result": {"eventId": "evt-1", "ok": True},
@@ -1043,7 +1043,7 @@ async def test_run_service_executes_backend_calendar_tool_and_emits_result(
text="请安排一个明早会议", text="请安排一个明早会议",
tools=[ tools=[
{ {
"name": "back.create_calendar_event", "name": "back.mutate_calendar_event",
"description": "create calendar", "description": "create calendar",
"parameters": {"type": "object"}, "parameters": {"type": "object"},
} }
@@ -10,7 +10,10 @@ def test_load_crewai_stage_tools_returns_expected_defaults() -> None:
assert result == { assert result == {
"intent": [], "intent": [],
"execution": ["back.create_calendar_event"], "execution": [
"back.list_calendar_events",
"back.mutate_calendar_event",
],
"organization": [], "organization": [],
} }