refactor: unify skills+cli runtime and streamline ag-ui flow
This commit is contained in:
@@ -226,6 +226,7 @@ class ToolCallResultEvent extends AgUiEvent {
|
||||
required this.toolName,
|
||||
required this.resultSummary,
|
||||
required this.status,
|
||||
required this.uiSchema,
|
||||
}) : super(type: AgUiEventType.toolCallResult);
|
||||
|
||||
final String messageId;
|
||||
@@ -233,6 +234,7 @@ class ToolCallResultEvent extends AgUiEvent {
|
||||
final String toolName;
|
||||
final String resultSummary;
|
||||
final String status;
|
||||
final Map<String, dynamic>? uiSchema;
|
||||
|
||||
factory ToolCallResultEvent.fromJson(Map<String, dynamic> json) =>
|
||||
ToolCallResultEvent(
|
||||
@@ -241,6 +243,7 @@ class ToolCallResultEvent extends AgUiEvent {
|
||||
toolName: _asString(json['tool_name']),
|
||||
resultSummary: _asString(json['result']),
|
||||
status: _asString(json['status'], fallback: 'success'),
|
||||
uiSchema: _asMap(json['ui_schema']),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -210,16 +210,42 @@ extension _ChatBlocEvents on ChatBloc {
|
||||
if (_shouldRefreshCalendarForTool(event)) {
|
||||
unawaited(_refreshCalendarAfterToolMutation());
|
||||
}
|
||||
emit(
|
||||
state.copyWith(
|
||||
items: state.items.map((item) {
|
||||
if (item is ToolCallItem && item.id == event.toolCallId) {
|
||||
return item.copyWith(status: ToolCallStatus.completed);
|
||||
}
|
||||
return item;
|
||||
}).toList(),
|
||||
),
|
||||
final timestamp = DateTime.now();
|
||||
final items = state.items.map((item) {
|
||||
if (item is ToolCallItem && item.id == event.toolCallId) {
|
||||
return item.copyWith(status: ToolCallStatus.completed);
|
||||
}
|
||||
return item;
|
||||
}).toList();
|
||||
|
||||
final uiSchema = event.uiSchema;
|
||||
if (uiSchema != null) {
|
||||
_upsertToolResultUi(items, event.toolCallId, uiSchema, timestamp);
|
||||
}
|
||||
|
||||
emit(state.copyWith(items: items));
|
||||
}
|
||||
|
||||
void _upsertToolResultUi(
|
||||
List<ChatListItem> items,
|
||||
String toolCallId,
|
||||
Map<String, dynamic> uiSchema,
|
||||
DateTime timestamp,
|
||||
) {
|
||||
final uiItemId = '$toolCallId-ui';
|
||||
final uiItem = ToolResultItem(
|
||||
id: uiItemId,
|
||||
callId: toolCallId,
|
||||
uiSchema: uiSchema,
|
||||
timestamp: timestamp,
|
||||
sender: MessageSender.ai,
|
||||
);
|
||||
final existingIndex = items.indexWhere((item) => item.id == uiItemId);
|
||||
if (existingIndex >= 0) {
|
||||
items[existingIndex] = uiItem;
|
||||
return;
|
||||
}
|
||||
items.add(uiItem);
|
||||
}
|
||||
|
||||
void _handleToolCallError(ToolCallErrorEvent event) {
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:social_app/core/chat/ag_ui_event.dart';
|
||||
|
||||
void main() {
|
||||
group('ToolCallResultEvent', () {
|
||||
test('parses ui_schema from json', () {
|
||||
final json = <String, dynamic>{
|
||||
'type': 'TOOL_CALL_RESULT',
|
||||
'messageId': 'msg_1',
|
||||
'tool_call_id': 'call_1',
|
||||
'tool_name': 'calendar_read',
|
||||
'result': '{"total": 5}',
|
||||
'status': 'success',
|
||||
'ui_schema': {
|
||||
'version': '2.0',
|
||||
'status': 'success',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'children': [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
final event = ToolCallResultEvent.fromJson(json);
|
||||
|
||||
expect(event.messageId, 'msg_1');
|
||||
expect(event.toolCallId, 'call_1');
|
||||
expect(event.toolName, 'calendar_read');
|
||||
expect(event.status, 'success');
|
||||
expect(event.uiSchema, isNotNull);
|
||||
expect(event.uiSchema!['version'], '2.0');
|
||||
expect(event.uiSchema!['status'], 'success');
|
||||
});
|
||||
|
||||
test('handles missing ui_schema gracefully', () {
|
||||
final json = <String, dynamic>{
|
||||
'type': 'TOOL_CALL_RESULT',
|
||||
'messageId': 'msg_1',
|
||||
'tool_call_id': 'call_1',
|
||||
'tool_name': 'calendar_read',
|
||||
'result': '{"total": 5}',
|
||||
'status': 'success',
|
||||
};
|
||||
|
||||
final event = ToolCallResultEvent.fromJson(json);
|
||||
|
||||
expect(event.uiSchema, isNull);
|
||||
});
|
||||
|
||||
test('parses partial status', () {
|
||||
final json = <String, dynamic>{
|
||||
'type': 'TOOL_CALL_RESULT',
|
||||
'messageId': 'msg_1',
|
||||
'tool_call_id': 'call_1',
|
||||
'tool_name': 'calendar_write',
|
||||
'result': '{"success": 1, "failed": 1}',
|
||||
'status': 'partial',
|
||||
'ui_schema': {
|
||||
'version': '2.0',
|
||||
'status': 'partial',
|
||||
'root': {'type': 'stack', 'children': []},
|
||||
},
|
||||
};
|
||||
|
||||
final event = ToolCallResultEvent.fromJson(json);
|
||||
|
||||
expect(event.status, 'partial');
|
||||
expect(event.uiSchema!['status'], 'partial');
|
||||
});
|
||||
});
|
||||
|
||||
group('TextMessageEndEvent', () {
|
||||
test('no longer includes ui_schema field', () {
|
||||
final json = <String, dynamic>{
|
||||
'type': 'TEXT_MESSAGE_END',
|
||||
'messageId': 'msg_1',
|
||||
'answer': '日程查询完成',
|
||||
'role': 'assistant',
|
||||
'status': 'success',
|
||||
};
|
||||
|
||||
final event = TextMessageEndEvent.fromJson(json);
|
||||
|
||||
expect(event.messageId, 'msg_1');
|
||||
expect(event.answer, '日程查询完成');
|
||||
});
|
||||
|
||||
test('ignores legacy ui_schema if present', () {
|
||||
final json = <String, dynamic>{
|
||||
'type': 'TEXT_MESSAGE_END',
|
||||
'messageId': 'msg_1',
|
||||
'answer': '日程查询完成',
|
||||
'role': 'assistant',
|
||||
'status': 'success',
|
||||
'ui_schema': {'version': '2.0'},
|
||||
};
|
||||
|
||||
final event = TextMessageEndEvent.fromJson(json);
|
||||
|
||||
expect(event.answer, '日程查询完成');
|
||||
});
|
||||
});
|
||||
|
||||
group('HistoryMessage', () {
|
||||
test('parses uiSchema from tool message metadata', () {
|
||||
final json = <String, dynamic>{
|
||||
'id': 'm1',
|
||||
'seq': 1,
|
||||
'role': 'tool',
|
||||
'content': '{"total": 5}',
|
||||
'timestamp': '2026-04-21T10:00:00+08:00',
|
||||
'attachments': const [],
|
||||
'ui_schema': {
|
||||
'version': '2.0',
|
||||
'status': 'success',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'children': [
|
||||
{'type': 'text', 'content': '找到 5 个日程', 'role': 'body'},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
final message = HistoryMessage.fromJson(json);
|
||||
|
||||
expect(message.uiSchema, isNotNull);
|
||||
expect(message.uiSchema!['version'], '2.0');
|
||||
});
|
||||
|
||||
test('handles missing uiSchema gracefully', () {
|
||||
final json = <String, dynamic>{
|
||||
'id': 'm1',
|
||||
'seq': 1,
|
||||
'role': 'assistant',
|
||||
'content': 'hello',
|
||||
'timestamp': '2026-04-21T10:00:00+08:00',
|
||||
'attachments': const [],
|
||||
};
|
||||
|
||||
final message = HistoryMessage.fromJson(json);
|
||||
|
||||
expect(message.uiSchema, isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -253,6 +253,7 @@ void main() {
|
||||
toolName: 'calendar_write',
|
||||
resultSummary: 'ok',
|
||||
status: 'success',
|
||||
uiSchema: null,
|
||||
),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:social_app/l10n/app_localizations.dart';
|
||||
import 'package:social_app/shared/widgets/ui_schema/ui_schema_renderer.dart';
|
||||
|
||||
Map<String, dynamic> _toolResultSchema({
|
||||
required String status,
|
||||
required List<Map<String, dynamic>> children,
|
||||
}) {
|
||||
return {
|
||||
'version': '2.0',
|
||||
'status': status,
|
||||
'locale': 'zh-CN',
|
||||
'root': {
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'gap': 12,
|
||||
'appearance': 'card',
|
||||
'children': children,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildRendererHost(Map<String, dynamic> schema, Locale locale) {
|
||||
return MaterialApp(
|
||||
locale: locale,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
home: Scaffold(
|
||||
body: Builder(
|
||||
builder: (context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return UiSchemaRenderer(context, colorScheme).renderSchema(schema);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('renders tool result with success status', (tester) async {
|
||||
final schema = _toolResultSchema(
|
||||
status: 'success',
|
||||
children: [
|
||||
{'type': 'text', 'content': '日程创建成功', 'role': 'title'},
|
||||
{
|
||||
'type': 'kv',
|
||||
'items': [
|
||||
{'key': 'title', 'label': '主题', 'value': '项目周会'},
|
||||
{'key': 'time', 'label': '时间', 'value': '2026-04-22 15:00'},
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh')));
|
||||
|
||||
expect(find.text('日程创建成功'), findsOneWidget);
|
||||
expect(find.text('主题'), findsOneWidget);
|
||||
expect(find.text('项目周会'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders tool result with partial status badge', (tester) async {
|
||||
final schema = _toolResultSchema(
|
||||
status: 'partial',
|
||||
children: [
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'horizontal',
|
||||
'gap': 8,
|
||||
'children': [
|
||||
{'type': 'text', 'content': '批量操作结果', 'role': 'title'},
|
||||
{'type': 'badge', 'label': 'ui.status.warning', 'status': 'warning'},
|
||||
],
|
||||
},
|
||||
{'type': 'text', 'content': '成功 2 项,失败 1 项', 'role': 'body'},
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh')));
|
||||
|
||||
expect(find.text('批量操作结果'), findsOneWidget);
|
||||
expect(find.text('提醒'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders tool result with action buttons', (tester) async {
|
||||
final schema = _toolResultSchema(
|
||||
status: 'success',
|
||||
children: [
|
||||
{'type': 'text', 'content': '日程已创建', 'role': 'title'},
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'horizontal',
|
||||
'gap': 8,
|
||||
'children': [
|
||||
{
|
||||
'type': 'button',
|
||||
'label': '查看详情',
|
||||
'style': 'primary',
|
||||
'action': {'type': 'navigation', 'path': '/calendar/evt_123'},
|
||||
},
|
||||
{
|
||||
'type': 'button',
|
||||
'label': '分享',
|
||||
'style': 'secondary',
|
||||
'action': {'type': 'tool', 'toolId': 'calendar.share', 'params': {}},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh')));
|
||||
|
||||
expect(find.text('日程已创建'), findsOneWidget);
|
||||
expect(find.text('查看详情'), findsOneWidget);
|
||||
expect(find.text('分享'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders error status tool result', (tester) async {
|
||||
final schema = _toolResultSchema(
|
||||
status: 'error',
|
||||
children: [
|
||||
{'type': 'text', 'content': '操作失败', 'role': 'title', 'status': 'error'},
|
||||
{'type': 'text', 'content': '您没有权限执行此操作', 'role': 'body', 'status': 'error'},
|
||||
{
|
||||
'type': 'button',
|
||||
'label': '重试',
|
||||
'style': 'primary',
|
||||
'action': {'type': 'tool', 'toolId': 'calendar.read', 'params': {}},
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh')));
|
||||
|
||||
expect(find.text('操作失败'), findsOneWidget);
|
||||
expect(find.text('您没有权限执行此操作'), findsOneWidget);
|
||||
expect(find.text('重试'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('renders calendar event list from tool result', (tester) async {
|
||||
final schema = _toolResultSchema(
|
||||
status: 'success',
|
||||
children: [
|
||||
{'type': 'text', 'content': '今日日程 (3)', 'role': 'title'},
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'gap': 12,
|
||||
'children': [
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'gap': 4,
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'content': '项目周会', 'role': 'subtitle'},
|
||||
{'type': 'text', 'content': '15:00 - 16:00', 'role': 'caption'},
|
||||
],
|
||||
},
|
||||
{
|
||||
'type': 'stack',
|
||||
'direction': 'vertical',
|
||||
'gap': 4,
|
||||
'appearance': 'card',
|
||||
'children': [
|
||||
{'type': 'text', 'content': '客户演示', 'role': 'subtitle'},
|
||||
{'type': 'text', 'content': '17:00 - 18:00', 'role': 'caption'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await tester.pumpWidget(_buildRendererHost(schema, const Locale('zh')));
|
||||
|
||||
expect(find.text('今日日程 (3)'), findsOneWidget);
|
||||
expect(find.text('项目周会'), findsOneWidget);
|
||||
expect(find.text('客户演示'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('handles null schema gracefully', (tester) async {
|
||||
await tester.pumpWidget(_buildRendererHost({}, const Locale('zh')));
|
||||
|
||||
expect(find.byType(SizedBox), findsWidgets);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user