feat: AG-UI 协议对齐与路由导航功能

- 前端: 添加 SSE 流式支持、stateSnapshot 事件、路由导航工具
- 前端: 实现工具调用审批流程,支持 pending 状态展示
- 后端: Agent 状态管理与会话持久化相关重构
- 文档: 新增 agent-agui-full-alignance 设计文档
- 测试: 补充相关单元测试和集成测试
This commit is contained in:
zl-q
2026-03-07 17:30:20 +08:00
parent ec33bb0cee
commit 120df903d2
52 changed files with 4305 additions and 1672 deletions
+156 -1
View File
@@ -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');
});
});
}