feat: 添加 Agent 步骤事件与图片附件功能
- 新增 stepStarted/stepFinished 事件类型支持 - 前端实现图片附件上传和预览功能 - 后端增强工具结果存储和事件处理 - 完善相关单元测试和集成测试
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
@@ -265,6 +266,71 @@ void main() {
|
||||
expect(first['content'], '只发送当前输入');
|
||||
});
|
||||
|
||||
test('sendMessage uploads images then posts binary url blocks', () async {
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: (_) {}, apiClient: client);
|
||||
client.clearMocks();
|
||||
|
||||
var uploadCalls = 0;
|
||||
final uploadedPath = 'agent-inputs/user/thread-1/upload-1.png';
|
||||
client.registerHandler('/api/v1/agent/attachments', 'POST', (request) {
|
||||
uploadCalls += 1;
|
||||
return {
|
||||
'attachment': {
|
||||
'bucket': 'bucket-test',
|
||||
'path': uploadedPath,
|
||||
'mimeType': 'image/png',
|
||||
'url': 'https://signed.example/$uploadedPath',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
Map<String, dynamic>? postedRunInput;
|
||||
client.registerHandler('/api/v1/agent/runs', 'POST', (request) {
|
||||
postedRunInput = request.data as Map<String, dynamic>;
|
||||
return {
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-1',
|
||||
'created': false,
|
||||
};
|
||||
});
|
||||
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
|
||||
return <String>[
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
];
|
||||
});
|
||||
|
||||
final image = XFile.fromData(
|
||||
Uint8List.fromList(<int>[1, 2, 3]),
|
||||
mimeType: 'image/png',
|
||||
name: 'demo.png',
|
||||
);
|
||||
|
||||
await service.sendMessage('图文消息', images: [image]);
|
||||
|
||||
expect(uploadCalls, 1);
|
||||
expect(postedRunInput, isNotNull);
|
||||
final messages = postedRunInput!['messages'] as List<dynamic>;
|
||||
final first = messages.first as Map<String, dynamic>;
|
||||
final content = first['content'] as List<dynamic>;
|
||||
expect((content.first as Map<String, dynamic>)['type'], 'text');
|
||||
expect((content[1] as Map<String, dynamic>)['type'], 'binary');
|
||||
expect(
|
||||
(content[1] as Map<String, dynamic>)['url'],
|
||||
'https://signed.example/$uploadedPath',
|
||||
);
|
||||
final forwardedProps =
|
||||
postedRunInput!['forwardedProps'] as Map<String, dynamic>;
|
||||
final attachments = forwardedProps['attachments'] as List<dynamic>;
|
||||
expect((attachments.first as Map<String, dynamic>)['path'], uploadedPath);
|
||||
});
|
||||
|
||||
test('approveToolCall posts only tool message to resume API', () async {
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: (_) {}, apiClient: client);
|
||||
@@ -482,5 +548,56 @@ void main() {
|
||||
expect(seenLastEventIds[0], isNull);
|
||||
expect(seenLastEventIds[1], '2-0');
|
||||
});
|
||||
|
||||
test('stream parses backend TOOL_CALL_RESULT payload with ui field', () async {
|
||||
final events = <AgUiEvent>[];
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: events.add, apiClient: client);
|
||||
client.clearMocks();
|
||||
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
|
||||
return {
|
||||
'taskId': 'task-1',
|
||||
'threadId': 'thread-1',
|
||||
'runId': 'run-1',
|
||||
'created': false,
|
||||
};
|
||||
});
|
||||
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
|
||||
return <String>[
|
||||
'event: RUN_STARTED',
|
||||
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
'event: TOOL_CALL_RESULT',
|
||||
'data: {"type":"TOOL_CALL_RESULT","messageId":"tool-result-1","toolCallId":"call-1","callId":"call-1","toolName":"calendar_write","result":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"ui":{"type":"calendar_operation.v1","version":"v1","data":{"ok":true,"operation":"create"},"actions":[]},"content":"已创建日程:项目评审(明天 10:00)"}',
|
||||
'',
|
||||
'event: RUN_FINISHED',
|
||||
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
|
||||
'',
|
||||
];
|
||||
});
|
||||
|
||||
await service.sendMessage('创建日程');
|
||||
|
||||
final result = events.whereType<ToolCallResultEvent>().toList();
|
||||
expect(result.length, 1);
|
||||
expect(result.first.ui?.cardType, 'calendar_operation.v1');
|
||||
});
|
||||
|
||||
test('fetchAttachmentPreview returns binary bytes', () async {
|
||||
final client = MockApiClient();
|
||||
final service = AgUiService(onEvent: (_) {}, apiClient: client);
|
||||
client.clearMocks();
|
||||
client.registerHandler(
|
||||
'/api/v1/agent/runs/t1/attachments/m1/0',
|
||||
'GET',
|
||||
(_) => <int>[1, 2, 3, 4],
|
||||
);
|
||||
|
||||
final data = await service.fetchAttachmentPreview(
|
||||
'/api/v1/agent/runs/t1/attachments/m1/0',
|
||||
);
|
||||
|
||||
expect(data, [1, 2, 3, 4]);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:bloc_test/bloc_test.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
@@ -9,8 +11,17 @@ import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
||||
class MockAgUiService extends AgUiService {
|
||||
MockAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
int previewCalls = 0;
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {}
|
||||
|
||||
@override
|
||||
Future<Uint8List> fetchAttachmentPreview(String previewPath) async {
|
||||
previewCalls += 1;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
return Uint8List.fromList(<int>[1, 2, 3]);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThrowingAgUiService extends AgUiService {
|
||||
@@ -182,6 +193,23 @@ void main() {
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'step events update currentStage',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(StepStartedEvent(stepName: 'execution'));
|
||||
service.onEvent(StepFinishedEvent(stepName: 'execution'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) => s.currentStage,
|
||||
'currentStage',
|
||||
AgentStage.execution,
|
||||
),
|
||||
isA<ChatState>().having((s) => s.currentStage, 'currentStage', isNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runError sets error message',
|
||||
build: () => chatBloc,
|
||||
@@ -325,5 +353,58 @@ void main() {
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'state snapshot user message keeps attachments',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
StateSnapshotEvent(
|
||||
snapshot: {
|
||||
'scope': 'history_day',
|
||||
'messages': [
|
||||
{
|
||||
'id': 'u1',
|
||||
'role': 'user',
|
||||
'content': '请分析这张图',
|
||||
'attachments': [
|
||||
{'bucket': 'b', 'path': 'p', 'mimeType': 'image/png'},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) {
|
||||
final item = s.items.first;
|
||||
return item is TextMessageItem && item.attachments.length == 1;
|
||||
},
|
||||
'user attachment count',
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
test(
|
||||
'loadAttachmentPreview deduplicates in-flight and caches result',
|
||||
() async {
|
||||
final mock = service as MockAgUiService;
|
||||
final results = await Future.wait<Uint8List?>([
|
||||
chatBloc.loadAttachmentPreview('/api/preview/1'),
|
||||
chatBloc.loadAttachmentPreview('/api/preview/1'),
|
||||
]);
|
||||
final secondRound = await chatBloc.loadAttachmentPreview(
|
||||
'/api/preview/1',
|
||||
);
|
||||
|
||||
expect(results.first, isNotNull);
|
||||
expect(results.last, isNotNull);
|
||||
expect(secondRound, isNotNull);
|
||||
expect(mock.previewCalls, 1);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user