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
+39 -1
View File
@@ -68,6 +68,13 @@ void main() {
);
});
test('maps STATE_SNAPSHOT correctly', () {
expect(
agUiEventTypeFromWire('STATE_SNAPSHOT'),
AgUiEventType.stateSnapshot,
);
});
test('returns unknown for unknown type', () {
expect(agUiEventTypeFromWire('UNKNOWN_TYPE'), AgUiEventType.unknown);
});
@@ -228,7 +235,7 @@ void main() {
'type': 'TOOL_CALL_RESULT',
'messageId': 'msg_123',
'toolCallId': 'tc_123',
'result': {'ok': true, 'eventId': 'evt_001'},
'content': '{"result":{"ok":true,"eventId":"evt_001"}}',
};
final event = AgUiEvent.fromJson(json);
@@ -240,6 +247,24 @@ void main() {
expect(toolResult.result['ok'], true);
});
test('parses ToolCallResultEvent content payload', () {
final json = {
'type': 'TOOL_CALL_RESULT',
'messageId': 'msg_123',
'toolCallId': 'tc_123',
'content': '{"result":{"ok":true,"eventId":"evt_001"}}',
};
final event = AgUiEvent.fromJson(json);
expect(event, isA<ToolCallResultEvent>());
final toolResult = event as ToolCallResultEvent;
expect(toolResult.messageId, 'msg_123');
expect(toolResult.toolCallId, 'tc_123');
expect(toolResult.result['ok'], true);
expect(toolResult.result['eventId'], 'evt_001');
});
test('parses ToolCallErrorEvent', () {
final json = {
'type': 'TOOL_CALL_ERROR',
@@ -257,6 +282,19 @@ void main() {
expect(toolError.code, 'EXEC_ERROR');
});
test('parses StateSnapshotEvent', () {
final json = {
'type': 'STATE_SNAPSHOT',
'snapshot': {'scope': 'history_day', 'hasMore': false, 'messages': []},
};
final event = AgUiEvent.fromJson(json);
expect(event, isA<StateSnapshotEvent>());
final stateSnapshot = event as StateSnapshotEvent;
expect(stateSnapshot.snapshot['scope'], 'history_day');
});
test('returns UnknownAgUiEvent for unknown type', () {
final json = {'type': 'UNKNOWN_TYPE', 'someField': 'someValue'};
+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');
});
});
}
@@ -211,5 +211,35 @@ void main() {
),
],
);
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: '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),
],
);
});
}
@@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:social_app/features/chat/data/tools/route_navigation_tool.dart';
import 'package:social_app/features/chat/data/tools/tool_registry.dart';
void main() {
@@ -6,6 +7,10 @@ void main() {
ToolRegistry.initialize();
});
tearDown(() {
RouteNavigationTool.instance.clearNavigator();
});
group('getTool', () {
test('returns tool definition for create_calendar_event', () {
final tool = ToolRegistry.getTool('create_calendar_event');
@@ -87,6 +92,33 @@ void main() {
expect(result['location'], 'Room A');
expect(result['endAt'], '2026-03-01T11:00:00Z');
});
test('navigate_to_route rejects disallowed target', () async {
final result = await ToolRegistry.execute('navigate_to_route', {
'target': '/admin',
});
expect(result['ok'], false);
expect(result['error'], contains('not allowed'));
});
test('navigate_to_route executes allowed target when navigator is bound', () async {
String? navigatedTo;
bool replaced = false;
RouteNavigationTool.instance.bindNavigator((target, {replace = false}) {
navigatedTo = target;
replaced = replace;
});
final result = await ToolRegistry.execute('navigate_to_route', {
'target': '/settings',
'replace': true,
});
expect(result['ok'], true);
expect(navigatedTo, '/settings');
expect(replaced, true);
});
});
group('getAllTools', () {