Files
social-app/apps/test/features/chat/ag_ui_service_test.dart
T
zl-q 7b8865e256 feat: 添加 Agent 步骤事件与图片附件功能
- 新增 stepStarted/stepFinished 事件类型支持
- 前端实现图片附件上传和预览功能
- 后端增强工具结果存储和事件处理
- 完善相关单元测试和集成测试
2026-03-12 09:29:57 +08:00

604 lines
20 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:image_picker/image_picker.dart';
import 'package:social_app/core/api/mock_api_client.dart';
import 'package:social_app/features/chat/data/ai/ai_decision_engine.dart';
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
import 'package:social_app/features/chat/data/tools/route_navigation_tool.dart';
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
class TestableAgUiService extends AgUiService {
TestableAgUiService({super.onEvent});
@override
Future<void> sendMessage(String content, {List<XFile>? images}) async {
await mockEventStream(content);
}
Future<void> mockEventStream(String content) async {
final threadId = 'thread_${DateTime.now().millisecondsSinceEpoch}';
final runId = 'run_${DateTime.now().millisecondsSinceEpoch}';
final engine = AiDecisionEngine();
onEvent(RunStartedEvent(threadId: threadId, runId: runId));
final forceTrigger = engine.tryForceTrigger(content);
if (forceTrigger != null) {
await mockToolCallFlowWithArgs(forceTrigger.toolName, forceTrigger.args);
}
final replies = generateReplies(content, engine);
if (replies.isNotEmpty) {
await mockTextMessageStream(replies);
}
onEvent(RunFinishedEvent(threadId: threadId, runId: runId));
}
Future<void> mockToolCallFlowWithArgs(
String toolName,
Map<String, dynamic> args,
) async {
final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}';
onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName));
onEvent(ToolCallArgsEvent(toolCallId: toolCallId, delta: '{}'));
onEvent(ToolCallEndEvent(toolCallId: toolCallId));
if (toolName == 'front.navigate_to_route') {
return;
}
final validation = ToolRegistry.validateArgs(toolName, args);
if (!validation.ok) {
onEvent(
ToolCallErrorEvent(
toolCallId: toolCallId,
error: validation.error ?? 'Validation failed',
code: 'VALIDATION_ERROR',
),
);
return;
}
try {
ToolRegistry.initialize();
await ToolRegistry.execute(toolName, args);
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
onEvent(
ToolCallResultEvent(
messageId: messageId,
toolCallId: toolCallId,
content: '{"result":{"ok":true}}',
),
);
} catch (e) {
onEvent(
ToolCallErrorEvent(
toolCallId: toolCallId,
error: e.toString(),
code: 'EXECUTION_ERROR',
),
);
}
}
List<String> generateReplies(String content, AiDecisionEngine engine) {
final intent = engine.matchIntent(content);
switch (intent) {
case Intent.createEvent:
return ['好的,我已经为您创建了日程安排。'];
case Intent.searchEvent:
return ['您今天有以下日程:\n- 10:00 团队会议\n- 14:00 产品评审'];
case Intent.unknown:
return ['我理解了您的问题,让我来帮您处理。'];
}
}
Future<void> mockTextMessageStream(List<String> replies) async {
for (final reply in replies) {
final messageId = 'msg_${DateTime.now().millisecondsSinceEpoch}';
onEvent(TextMessageStartEvent(messageId: messageId, role: 'assistant'));
onEvent(TextMessageContentEvent(messageId: messageId, delta: reply));
onEvent(TextMessageEndEvent(messageId: messageId));
}
}
}
void main() {
late TestableAgUiService service;
late List<AgUiEvent> capturedEvents;
setUp(() {
capturedEvents = [];
ToolRegistry.initialize();
RouteNavigationTool.instance.clearNavigator();
service = TestableAgUiService(
onEvent: (event) {
capturedEvents.add(event);
},
);
});
group('AgUiService', () {
test('sendMessage first emits RunStartedEvent', () async {
await service.sendMessage('你好');
expect(capturedEvents.first, isA<RunStartedEvent>());
});
test('sendMessage last emits RunFinishedEvent', () async {
await service.sendMessage('你好');
expect(capturedEvents.last, isA<RunFinishedEvent>());
});
test('sendMessage emits events in correct order', () async {
await service.sendMessage('你好');
expect(capturedEvents.first, isA<RunStartedEvent>());
expect(capturedEvents.last, isA<RunFinishedEvent>());
final types = capturedEvents.map((e) => e.type).toList();
expect(types.first, AgUiEventType.runStarted);
expect(types.last, AgUiEventType.runFinished);
});
test(
'creating schedule text does not trigger frontend tool call events',
() async {
await service.sendMessage('提醒我明天10点开会');
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
final toolCallEnds = capturedEvents
.whereType<ToolCallEndEvent>()
.toList();
final toolCallResults = capturedEvents
.whereType<ToolCallResultEvent>()
.toList();
expect(toolCallStarts.isEmpty, true);
expect(toolCallEnds.isEmpty, true);
expect(toolCallResults.isEmpty, true);
},
);
test('force trigger with #tool syntax', () async {
await service.sendMessage(
'#tool:front.navigate_to_route {"target": "/calendar/dayweek"}',
);
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
expect(toolCallStarts.isNotEmpty, true);
expect(toolCallStarts.first.toolCallName, 'front.navigate_to_route');
});
test('text message events are emitted for unknown intent', () async {
await service.sendMessage('你好');
final textStarts = capturedEvents
.whereType<TextMessageStartEvent>()
.toList();
final textContents = capturedEvents
.whereType<TextMessageContentEvent>()
.toList();
final textEnds = capturedEvents.whereType<TextMessageEndEvent>().toList();
expect(textStarts.isNotEmpty, true);
expect(textContents.isNotEmpty, true);
expect(textEnds.isNotEmpty, true);
});
test('search intent does not trigger tool calls', () async {
await service.sendMessage('今天有什么日程');
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
expect(toolCallStarts.isEmpty, true);
});
test('frontend tool call keeps pending state before approval', () async {
await service.sendMessage('#tool:front.navigate_to_route {}');
final toolCallErrors = capturedEvents
.whereType<ToolCallErrorEvent>()
.toList();
final toolCallStarts = capturedEvents
.whereType<ToolCallStartEvent>()
.toList();
expect(toolCallStarts.isNotEmpty, true);
expect(toolCallErrors.isEmpty, true);
});
});
group('AgUiService real api-path mock', () {
test('sendMessage posts only current user message to run API', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
client.clearMocks();
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"}',
'',
];
});
await service.sendMessage('只发送当前输入');
expect(postedRunInput, isNotNull);
final messages = postedRunInput!['messages'] as List<dynamic>;
expect(messages.length, 1);
final first = messages.first as Map<String, dynamic>;
expect(first['role'], 'user');
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);
RouteNavigationTool.instance.bindNavigator((_, {replace = false}) {
final _ = replace;
});
client.clearMocks();
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
return {
'taskId': 'task-1',
'threadId': 'thread-1',
'runId': 'run-1',
'created': false,
};
});
var eventCallCount = 0;
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) {
eventCallCount += 1;
if (eventCallCount == 1) {
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"}',
'',
];
}
return <String>[
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
'',
];
});
Map<String, dynamic>? postedResumeInput;
client.registerHandler('/api/v1/agent/runs/thread-1/resume', 'POST', (
request,
) {
postedResumeInput = request.data as Map<String, dynamic>;
return {
'taskId': 'task-2',
'threadId': 'thread-1',
'runId': 'run-2',
'created': false,
};
});
await service.sendMessage('初始化会话');
await service.approveToolCall(
toolCallId: 'call-1',
toolName: 'front.navigate_to_route',
args: {
'target': '/calendar/dayweek',
'replace': false,
'__nonce': 'nonce-1',
},
);
expect(postedResumeInput, isNotNull);
final messages = postedResumeInput!['messages'] as List<dynamic>;
expect(messages.length, 1);
final first = messages.first as Map<String, dynamic>;
expect(first['role'], 'tool');
expect(first.containsKey('toolCallId'), true);
});
test('approveToolCall resumes and emits TOOL_CALL_RESULT', () async {
final events = <AgUiEvent>[];
final realService = AgUiService(onEvent: events.add);
RouteNavigationTool.instance.bindNavigator((_, {replace = false}) {
final _ = replace;
});
await realService.sendMessage('打开日历页面');
final toolStart = events.whereType<ToolCallStartEvent>().first;
final toolArgsEvent = events.whereType<ToolCallArgsEvent>().firstWhere(
(e) => e.toolCallId == toolStart.toolCallId,
);
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
expect(toolStart.toolCallName, 'front.navigate_to_route');
expect(
events
.whereType<ToolCallResultEvent>()
.where((e) => e.toolCallId == toolStart.toolCallId)
.isEmpty,
true,
);
await realService.approveToolCall(
toolCallId: toolStart.toolCallId,
toolName: 'front.navigate_to_route',
args: toolArgs,
);
final results = events
.whereType<ToolCallResultEvent>()
.where((e) => e.toolCallId == toolStart.toolCallId)
.toList();
expect(results.isNotEmpty, true);
});
test('approveToolCall aborts when local tool execution fails', () async {
final events = <AgUiEvent>[];
final realService = AgUiService(onEvent: events.add);
await realService.sendMessage('打开日历页面');
final toolStart = events.whereType<ToolCallStartEvent>().first;
final toolArgsEvent = events.whereType<ToolCallArgsEvent>().firstWhere(
(e) => e.toolCallId == toolStart.toolCallId,
);
final toolArgs = jsonDecode(toolArgsEvent.delta) as Map<String, dynamic>;
// replace navigator -> true 会失败,因为未绑定 navigator。
toolArgs['target'] = '/settings';
expect(
() => realService.approveToolCall(
toolCallId: toolStart.toolCallId,
toolName: 'front.navigate_to_route',
args: toolArgs,
),
throwsA(isA<StateError>()),
);
});
test('stream ignores malformed SSE payload and continues', () 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: TEXT_MESSAGE_CONTENT',
'data: {bad-json',
'',
'event: TEXT_MESSAGE_CONTENT',
'data: {"type":"TEXT_MESSAGE_CONTENT","messageId":"m1","delta":"ok"}',
'',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
});
await service.sendMessage('hi');
expect(events.whereType<RunStartedEvent>().length, 1);
expect(events.whereType<TextMessageContentEvent>().length, 1);
expect(events.whereType<RunFinishedEvent>().length, 1);
});
test('subsequent SSE requests carry Last-Event-ID header', () async {
final client = MockApiClient();
final service = AgUiService(onEvent: (_) {}, apiClient: client);
client.clearMocks();
var runCount = 0;
final seenLastEventIds = <String?>[];
client.registerHandler('/api/v1/agent/runs', 'POST', (_) {
runCount += 1;
return {
'taskId': 'task-$runCount',
'threadId': 'thread-1',
'runId': 'run-$runCount',
'created': false,
};
});
client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (
request,
) {
seenLastEventIds.add(request.headers?['Last-Event-ID']);
if (runCount == 1) {
return <String>[
'id: 1-0',
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-1"}',
'',
'id: 2-0',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-1"}',
'',
];
}
return <String>[
'id: 3-0',
'event: RUN_STARTED',
'data: {"type":"RUN_STARTED","threadId":"thread-1","runId":"run-2"}',
'',
'id: 4-0',
'event: RUN_FINISHED',
'data: {"type":"RUN_FINISHED","threadId":"thread-1","runId":"run-2"}',
'',
];
});
await service.sendMessage('first');
await service.sendMessage('second');
expect(seenLastEventIds.length, 2);
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]);
});
});
}