3ac09475ad
- 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
486 lines
16 KiB
Dart
486 lines
16 KiB
Dart
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<void> sendMessage(String content) 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('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');
|
|
});
|
|
});
|
|
}
|