refactor: 移除前端 Mock API,新增共享组件,优化认证流程
- 删除 mock_api_client、mock_calendar_service、mock_history_service - 新增 fixed_length_code_input、link_button、message_composer 共享组件 - 优化登录/注册/密码重置页面使用新组件 - 简化 injection.dart 移除 mock 分支 - 更新 env.dart 配置(BACKEND_URL 替换 API_URL) - 后端 agentscope 工具和测试更新 - 重构 AGENTS.md 文档结构 - 新增 deploy/ 目录和 protocol 文档
This commit is contained in:
@@ -1,603 +0,0 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
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';
|
||||
import 'package:social_app/features/chat/data/models/ag_ui_event.dart';
|
||||
import 'package:social_app/features/chat/data/models/chat_list_item.dart';
|
||||
import 'package:social_app/features/chat/data/services/ag_ui_service.dart';
|
||||
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 {
|
||||
_ThrowingAgUiService() : super(onEvent: (_) {});
|
||||
|
||||
@override
|
||||
Future<void> sendMessage(String content, {List<XFile>? images}) async {
|
||||
throw StateError('network down');
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
late ChatBloc chatBloc;
|
||||
late AgUiService service;
|
||||
|
||||
setUp(() {
|
||||
service = MockAgUiService();
|
||||
chatBloc = ChatBloc(service: service);
|
||||
});
|
||||
|
||||
tearDown(() {
|
||||
chatBloc.close();
|
||||
});
|
||||
|
||||
group('ChatBloc', () {
|
||||
test('initial state is empty', () {
|
||||
expect(chatBloc.state.items, isEmpty);
|
||||
expect(chatBloc.state.isLoading, false);
|
||||
expect(chatBloc.state.isSending, false);
|
||||
expect(chatBloc.state.isWaitingFirstToken, false);
|
||||
expect(chatBloc.state.isStreaming, false);
|
||||
expect(chatBloc.state.currentMessageId, isNull);
|
||||
expect(chatBloc.state.error, isNull);
|
||||
});
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'sendMessage adds user message to items',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) => bloc.sendMessage('Hello'),
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((state) => state.items.length, 'items length', 1)
|
||||
.having((state) => state.isSending, 'isSending', true)
|
||||
.having(
|
||||
(state) => state.isWaitingFirstToken,
|
||||
'isWaitingFirstToken',
|
||||
true,
|
||||
)
|
||||
.having(
|
||||
(state) => state.items.first,
|
||||
'first item',
|
||||
isA<TextMessageItem>().having(
|
||||
(item) => item.content,
|
||||
'content',
|
||||
'Hello',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'textMessageStart event adds AI message with streaming',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
bloc.emit(chatBloc.state.copyWith(isStreaming: true));
|
||||
service.onEvent(
|
||||
TextMessageStartEvent(messageId: 'msg_1', role: 'assistant'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having((s) => s.isStreaming, 'isStreaming', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.items.length, 'items length', 1)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', 'msg_1')
|
||||
.having(
|
||||
(s) => s.items.first,
|
||||
'first item',
|
||||
isA<TextMessageItem>()
|
||||
.having((item) => item.isStreaming, 'isStreaming', true)
|
||||
.having((item) => item.sender, 'sender', MessageSender.ai),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'textMessageContent event appends content',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
TextMessageItem(
|
||||
id: 'msg_1',
|
||||
content: '',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
),
|
||||
],
|
||||
currentMessageId: 'msg_1',
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
TextMessageContentEvent(messageId: 'msg_1', delta: 'Hello'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) => (s.items.first as TextMessageItem).content,
|
||||
'content',
|
||||
'Hello',
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'textMessageEnd event sets isStreaming to false',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
TextMessageItem(
|
||||
id: 'msg_1',
|
||||
content: 'Hello World',
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
isStreaming: true,
|
||||
),
|
||||
],
|
||||
currentMessageId: 'msg_1',
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(TextMessageEndEvent(messageId: 'msg_1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
|
||||
.having((s) => s.isStreaming, 'isStreaming', false)
|
||||
.having(
|
||||
(s) => (s.items.first as TextMessageItem).isStreaming,
|
||||
'isStreaming',
|
||||
false,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runStarted sets isLoading to true',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(RunStartedEvent(threadId: 't1', runId: 'r1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', true)
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true)
|
||||
.having((s) => s.error, 'error', isNull),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'runFinished sets isLoading to false',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isWaitingFirstToken: true),
|
||||
act: (bloc) {
|
||||
service.onEvent(RunFinishedEvent(threadId: 't1', runId: 'r1'));
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull),
|
||||
],
|
||||
);
|
||||
|
||||
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,
|
||||
seed: () => const ChatState(isWaitingFirstToken: true),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
RunErrorEvent(message: 'Something went wrong', code: 'ERR'),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isLoading, 'isLoading', false)
|
||||
.having((s) => s.currentMessageId, 'currentMessageId', isNull)
|
||||
.having((s) => s.error, 'error', 'Something went wrong'),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'cancelCurrentRun exits waiting states',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(isWaitingFirstToken: true),
|
||||
act: (bloc) => bloc.cancelCurrentRun(),
|
||||
expect: () => [
|
||||
isA<ChatState>().having((s) => s.isCancelling, 'isCancelling', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
|
||||
.having((s) => s.isStreaming, 'isStreaming', false)
|
||||
.having((s) => s.isCancelling, 'isCancelling', false),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'sendMessage failure emits error and exits waiting state',
|
||||
build: () => ChatBloc(service: _ThrowingAgUiService()),
|
||||
act: (bloc) => bloc.sendMessage('hello'),
|
||||
expect: () => [
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isSending, 'isSending', true)
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', true),
|
||||
isA<ChatState>()
|
||||
.having((s) => s.isSending, 'isSending', false)
|
||||
.having((s) => s.isWaitingFirstToken, 'isWaitingFirstToken', false)
|
||||
.having((s) => s.error, 'error', contains('network down')),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'clearError removes error',
|
||||
build: () => chatBloc,
|
||||
seed: () => const ChatState(error: 'Some error'),
|
||||
act: (bloc) => bloc.clearError(),
|
||||
expect: () => [isA<ChatState>().having((s) => s.error, 'error', isNull)],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'toolCallStart adds ToolCallItem',
|
||||
build: () => chatBloc,
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
ToolCallStartEvent(
|
||||
toolCallId: 'tc_1',
|
||||
toolCallName: 'back.mutate_calendar_event',
|
||||
),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) {
|
||||
final item = s.items.first;
|
||||
return item is ToolCallItem &&
|
||||
item.toolName == 'back.mutate_calendar_event' &&
|
||||
item.status == ToolCallStatus.pending;
|
||||
},
|
||||
'has pending tool call',
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'toolCallResult without ui removes pending tool call and does not add empty card',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
ToolCallItem(
|
||||
id: 'tc_1',
|
||||
callId: 'tc_1',
|
||||
toolName: 'front.navigate_to_route',
|
||||
args: {'target': '/calendar/dayweek', '__nonce': 'nonce_1'},
|
||||
status: ToolCallStatus.executing,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
],
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: 'msg_tool_1',
|
||||
toolCallId: 'tc_1',
|
||||
content: '{"result":{"ok":true}}',
|
||||
),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having((s) => s.items.isEmpty, 'items empty', true),
|
||||
],
|
||||
);
|
||||
|
||||
blocTest<ChatBloc, ChatState>(
|
||||
'toolCallResult with ui in payload.result adds ToolResultItem',
|
||||
build: () => chatBloc,
|
||||
seed: () => ChatState(
|
||||
items: [
|
||||
ToolCallItem(
|
||||
id: 'tc_2',
|
||||
callId: 'tc_2',
|
||||
toolName: 'back.mutate_calendar_event',
|
||||
args: {'operation': 'create'},
|
||||
status: ToolCallStatus.executing,
|
||||
timestamp: DateTime.now(),
|
||||
sender: MessageSender.ai,
|
||||
),
|
||||
],
|
||||
),
|
||||
act: (bloc) {
|
||||
service.onEvent(
|
||||
ToolCallResultEvent(
|
||||
messageId: 'msg_tool_2',
|
||||
toolCallId: 'tc_2',
|
||||
content:
|
||||
'{"result":{"type":"calendar_operation.v1","version":"v1","data":{"operation":"delete","ok":true,"message":"done"},"actions":[]}}',
|
||||
),
|
||||
);
|
||||
},
|
||||
expect: () => [
|
||||
isA<ChatState>().having(
|
||||
(s) => s.items.first is ToolResultItem,
|
||||
'first item is ToolResultItem',
|
||||
true,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
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