feat: AG-UI 协议对齐与路由导航功能
- 前端: 添加 SSE 流式支持、stateSnapshot 事件、路由导航工具 - 前端: 实现工具调用审批流程,支持 pending 状态展示 - 后端: Agent 状态管理与会话持久化相关重构 - 文档: 新增 agent-agui-full-alignance 设计文档 - 测试: 补充相关单元测试和集成测试
This commit is contained in:
@@ -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'};
|
||||
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user