import 'dart:convert'; import 'package:flutter_test/flutter_test.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 sendMessage(String content) async { await mockEventStream(content); } Future 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); } else if (engine.shouldTriggerToolCall(content)) { await mockToolCallFlow(content, engine); } final replies = generateReplies(content, engine); if (replies.isNotEmpty) { await mockTextMessageStream(replies); } onEvent(RunFinishedEvent(threadId: threadId, runId: runId)); } Future mockToolCallFlow(String content, AiDecisionEngine engine) async { final args = engine.getToolCallArgs(content); if (args == null) return; await mockToolCallFlowWithArgs('create_calendar_event', args); } Future mockToolCallFlowWithArgs( String toolName, Map args, ) async { final toolCallId = 'tc_${DateTime.now().millisecondsSinceEpoch}'; onEvent(ToolCallStartEvent(toolCallId: toolCallId, toolCallName: toolName)); onEvent(ToolCallArgsEvent(toolCallId: toolCallId, delta: '{}')); onEvent(ToolCallEndEvent(toolCallId: toolCallId)); 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(); final result = 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 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 mockTextMessageStream(List 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 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()); }); test('sendMessage last emits RunFinishedEvent', () async { await service.sendMessage('你好'); expect(capturedEvents.last, isA()); }); test('sendMessage emits events in correct order', () async { await service.sendMessage('你好'); expect(capturedEvents.first, isA()); expect(capturedEvents.last, isA()); final types = capturedEvents.map((e) => e.type).toList(); expect(types.first, AgUiEventType.runStarted); expect(types.last, AgUiEventType.runFinished); }); test('creating schedule text triggers tool call events', () async { await service.sendMessage('提醒我明天10点开会'); final toolCallStarts = capturedEvents .whereType() .toList(); final toolCallEnds = capturedEvents .whereType() .toList(); final toolCallResults = capturedEvents .whereType() .toList(); expect(toolCallStarts.isNotEmpty, true); expect(toolCallEnds.isNotEmpty, true); expect(toolCallResults.isNotEmpty, true); expect(toolCallStarts.first.toolCallName, 'create_calendar_event'); }); test('force trigger with #tool syntax', () async { await service.sendMessage( '#tool:create_calendar_event {"title": "Test", "startAt": "2026-03-01T10:00:00Z"}', ); final toolCallStarts = capturedEvents .whereType() .toList(); expect(toolCallStarts.isNotEmpty, true); expect(toolCallStarts.first.toolCallName, 'create_calendar_event'); }); test('text message events are emitted for unknown intent', () async { await service.sendMessage('你好'); final textStarts = capturedEvents .whereType() .toList(); final textContents = capturedEvents .whereType() .toList(); final textEnds = capturedEvents.whereType().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() .toList(); expect(toolCallStarts.isEmpty, true); }); test('tool call with invalid args emits error', () async { await service.sendMessage('#tool:create_calendar_event {}'); final toolCallErrors = capturedEvents .whereType() .toList(); expect(toolCallErrors.isNotEmpty, true); expect(toolCallErrors.first.error, contains('Missing required fields')); }); }); 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? postedRunInput; client.registerHandler('/api/v1/agent/runs', 'POST', (request) { postedRunInput = request.data as Map; return { 'taskId': 'task-1', 'threadId': 'thread-1', 'runId': 'run-1', 'created': false, }; }); client.registerHandler('/api/v1/agent/runs/thread-1/events', 'SSE', (_) { return [ '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; expect(messages.length, 1); final first = messages.first as Map; expect(first['role'], 'user'); expect(first['content'], '只发送当前输入'); }); 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 [ '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 [ '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? postedResumeInput; client.registerHandler('/api/v1/agent/runs/thread-1/resume', 'POST', ( request, ) { postedResumeInput = request.data as Map; return { 'taskId': 'task-2', 'threadId': 'thread-1', 'runId': 'run-2', 'created': false, }; }); await service.sendMessage('初始化会话'); await service.approveToolCall( toolCallId: 'call-1', toolName: 'navigate_to_route', args: { 'target': '/calendar/dayweek', 'replace': false, '__nonce': 'nonce-1', }, ); expect(postedResumeInput, isNotNull); final messages = postedResumeInput!['messages'] as List; expect(messages.length, 1); final first = messages.first as Map; expect(first['role'], 'tool'); expect(first.containsKey('toolCallId'), true); }); test('approveToolCall resumes and emits TOOL_CALL_RESULT', () async { final events = []; final realService = AgUiService(onEvent: events.add); RouteNavigationTool.instance.bindNavigator((_, {replace = false}) { final _ = replace; }); await realService.sendMessage('打开日历页面'); final toolStart = events.whereType().first; final toolArgsEvent = events.whereType().firstWhere( (e) => e.toolCallId == toolStart.toolCallId, ); final toolArgs = jsonDecode(toolArgsEvent.delta) as Map; expect(toolStart.toolCallName, 'navigate_to_route'); expect( events .whereType() .where((e) => e.toolCallId == toolStart.toolCallId) .isEmpty, true, ); await realService.approveToolCall( toolCallId: toolStart.toolCallId, toolName: 'navigate_to_route', args: toolArgs, ); final results = events .whereType() .where((e) => e.toolCallId == toolStart.toolCallId) .toList(); expect(results.isNotEmpty, true); }); test('approveToolCall aborts when local tool execution fails', () async { final events = []; final realService = AgUiService(onEvent: events.add); await realService.sendMessage('打开日历页面'); final toolStart = events.whereType().first; final toolArgsEvent = events.whereType().firstWhere( (e) => e.toolCallId == toolStart.toolCallId, ); final toolArgs = jsonDecode(toolArgsEvent.delta) as Map; // replace navigator -> true 会失败,因为未绑定 navigator。 toolArgs['target'] = '/settings'; expect( () => realService.approveToolCall( toolCallId: toolStart.toolCallId, toolName: 'navigate_to_route', args: toolArgs, ), throwsA(isA()), ); }); test('stream ignores malformed SSE payload and continues', () async { final events = []; 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 [ '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().length, 1); expect(events.whereType().length, 1); expect(events.whereType().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 = []; 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 [ '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 [ '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'); }); }); }