feat: AG-UI 协议对齐与路由导航功能
- 前端: 添加 SSE 流式支持、stateSnapshot 事件、路由导航工具 - 前端: 实现工具调用审批流程,支持 pending 状态展示 - 后端: Agent 状态管理与会话持久化相关重构 - 文档: 新增 agent-agui-full-alignance 设计文档 - 测试: 补充相关单元测试和集成测试
This commit is contained in:
@@ -1,6 +1,10 @@
|
||||
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';
|
||||
|
||||
@@ -74,7 +78,7 @@ class TestableAgUiService extends AgUiService {
|
||||
ToolCallResultEvent(
|
||||
messageId: messageId,
|
||||
toolCallId: toolCallId,
|
||||
result: result,
|
||||
content: '{"result":{"ok":true}}',
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
@@ -121,6 +125,7 @@ void main() {
|
||||
setUp(() {
|
||||
capturedEvents = [];
|
||||
ToolRegistry.initialize();
|
||||
RouteNavigationTool.instance.clearNavigator();
|
||||
service = TestableAgUiService(
|
||||
onEvent: (event) {
|
||||
capturedEvents.add(event);
|
||||
@@ -221,4 +226,154 @@ void main() {
|
||||
expect(toolCallErrors.first.error, contains('Missing required fields'));
|
||||
});
|
||||
});
|
||||
|
||||
group('AgUiService real api-path mock', () {
|
||||
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, 'navigate_to_route');
|
||||
expect(
|
||||
events.whereType<ToolCallResultEvent>().where((e) => e.toolCallId == toolStart.toolCallId).isEmpty,
|
||||
true,
|
||||
);
|
||||
|
||||
await realService.approveToolCall(
|
||||
toolCallId: toolStart.toolCallId,
|
||||
toolName: '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: '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');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user