d060962a5f
- Replace command/subcommand/args with module/method/input envelope - Calendar handler uses discriminated union (mode) for read operations - Strict Pydantic models with extra='forbid' for all calendar methods - Worker max_iters=7, router prompt simplified (removed project_cli_defaults) - Skill index cards + per-action files for progressive disclosure - Frontend/AG-UI aligned to module/method dispatch - Protocol docs updated to module/method/input contract WIP: action cards need envelope fix, 2 tests need update, memory handler needs Pydantic models.
505 lines
14 KiB
Dart
505 lines
14 KiB
Dart
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:flutter/widgets.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:social_app/core/chat/agent_stage.dart';
|
|
import 'package:social_app/core/chat/ag_ui_event.dart';
|
|
import 'package:social_app/core/chat/ag_ui_service.dart';
|
|
import 'package:social_app/core/chat/chat_api.dart';
|
|
import 'package:social_app/core/chat/chat_list_item.dart';
|
|
import 'package:social_app/core/l10n/l10n.dart';
|
|
import 'package:social_app/features/chat/presentation/bloc/chat_bloc.dart';
|
|
|
|
class _NoopChatApi implements ChatApi {
|
|
@override
|
|
Future<void> cancelRun({required String threadId, required String runId}) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> createRun(Map<String, dynamic> runInput) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> fetchHistory({
|
|
String? threadId,
|
|
DateTime? beforeDate,
|
|
}) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<Uint8List> fetchAttachmentPreview(String previewPath) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<Stream<String>> streamRunEvents(
|
|
String threadId, {
|
|
required String runId,
|
|
String? lastEventId,
|
|
}) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<String> transcribeAudio(String filePath) {
|
|
throw UnimplementedError();
|
|
}
|
|
|
|
@override
|
|
Future<Map<String, dynamic>> uploadAttachment({
|
|
required String threadId,
|
|
required String filename,
|
|
required String mimeType,
|
|
required Uint8List bytes,
|
|
}) {
|
|
throw UnimplementedError();
|
|
}
|
|
}
|
|
|
|
class _FakeAgUiService extends AgUiService {
|
|
_FakeAgUiService() : super(chatApi: _NoopChatApi(), onEvent: (_) {});
|
|
|
|
Future<SendMessageResult> Function(
|
|
String content,
|
|
List<AttachmentUploadInput>? attachments,
|
|
)?
|
|
sendMessageHandler;
|
|
|
|
Future<HistorySnapshot> Function({DateTime? beforeDate, bool forceRefresh})?
|
|
loadHistoryHandler;
|
|
|
|
int loadHistoryCalls = 0;
|
|
final List<String?> setUserContextCalls = <String?>[];
|
|
|
|
void emitEventForTest(AgUiEvent event) {
|
|
onEvent(event);
|
|
}
|
|
|
|
@override
|
|
Future<SendMessageResult> sendMessage(
|
|
String content, {
|
|
List<AttachmentUploadInput>? attachments,
|
|
}) async {
|
|
final handler = sendMessageHandler;
|
|
if (handler == null) {
|
|
throw UnimplementedError();
|
|
}
|
|
return handler(content, attachments);
|
|
}
|
|
|
|
@override
|
|
Future<HistorySnapshot> loadHistory({
|
|
DateTime? beforeDate,
|
|
bool forceRefresh = false,
|
|
}) async {
|
|
loadHistoryCalls += 1;
|
|
final handler = loadHistoryHandler;
|
|
if (handler == null) {
|
|
throw UnimplementedError();
|
|
}
|
|
return handler(beforeDate: beforeDate, forceRefresh: forceRefresh);
|
|
}
|
|
|
|
@override
|
|
Future<void> setUserContext(String? userId) async {
|
|
setUserContextCalls.add(userId);
|
|
}
|
|
}
|
|
|
|
HistorySnapshot _snapshot(
|
|
List<HistoryMessage> messages, {
|
|
bool hasMore = false,
|
|
}) {
|
|
return HistorySnapshot(
|
|
scope: 'history_day',
|
|
threadId: 'thread-1',
|
|
day: '2026-03-30',
|
|
hasMore: hasMore,
|
|
messages: messages,
|
|
);
|
|
}
|
|
|
|
HistoryMessage _historyMessage({
|
|
required String id,
|
|
required int seq,
|
|
required String role,
|
|
required String content,
|
|
required DateTime timestamp,
|
|
}) {
|
|
return HistoryMessage(
|
|
id: id,
|
|
seq: seq,
|
|
role: role,
|
|
content: content,
|
|
timestamp: timestamp,
|
|
);
|
|
}
|
|
|
|
void main() {
|
|
setUp(() {
|
|
L10n.setLocale(const Locale('zh'));
|
|
});
|
|
|
|
test(
|
|
'loadHistory ignores stale result after switchUser epoch change',
|
|
() async {
|
|
final service = _FakeAgUiService();
|
|
final completer = Completer<HistorySnapshot>();
|
|
var loadCall = 0;
|
|
service.loadHistoryHandler =
|
|
({DateTime? beforeDate, bool forceRefresh = false}) {
|
|
loadCall += 1;
|
|
if (loadCall == 1) {
|
|
return completer.future;
|
|
}
|
|
return Future<HistorySnapshot>.value(_snapshot(const []));
|
|
};
|
|
|
|
final bloc = ChatBloc(
|
|
service: service,
|
|
chatApi: _NoopChatApi(),
|
|
recoveryPollInterval: const Duration(milliseconds: 1),
|
|
recoveryTimeout: const Duration(milliseconds: 80),
|
|
);
|
|
|
|
final pendingLoad = bloc.loadHistory();
|
|
await bloc.switchUser('user-b');
|
|
completer.complete(
|
|
_snapshot([
|
|
_historyMessage(
|
|
id: 'old-1',
|
|
seq: 1,
|
|
role: 'assistant',
|
|
content: 'old session data',
|
|
timestamp: DateTime.now(),
|
|
),
|
|
]),
|
|
);
|
|
await pendingLoad;
|
|
|
|
expect(bloc.state.items, isEmpty);
|
|
expect(bloc.state.isLoadingHistory, isFalse);
|
|
},
|
|
);
|
|
|
|
test('switchUser loads history after resetting state', () async {
|
|
final service = _FakeAgUiService();
|
|
service.loadHistoryHandler =
|
|
({DateTime? beforeDate, bool forceRefresh = false}) async {
|
|
final now = DateTime.now();
|
|
return _snapshot([
|
|
_historyMessage(
|
|
id: 'history-1',
|
|
seq: 1,
|
|
role: 'assistant',
|
|
content: 'welcome back',
|
|
timestamp: now,
|
|
),
|
|
]);
|
|
};
|
|
|
|
final bloc = ChatBloc(service: service, chatApi: _NoopChatApi());
|
|
await bloc.switchUser('user-a');
|
|
|
|
expect(service.setUserContextCalls, ['user-a']);
|
|
expect(service.loadHistoryCalls, 1);
|
|
expect(bloc.state.items, hasLength(1));
|
|
expect(
|
|
bloc.state.items.single,
|
|
isA<TextMessageItem>().having(
|
|
(item) => item.content,
|
|
'content',
|
|
'welcome back',
|
|
),
|
|
);
|
|
});
|
|
|
|
test('switchUser keeps flow when history load fails', () async {
|
|
final service = _FakeAgUiService();
|
|
service.loadHistoryHandler =
|
|
({DateTime? beforeDate, bool forceRefresh = false}) {
|
|
throw StateError('history unavailable');
|
|
};
|
|
|
|
final bloc = ChatBloc(service: service, chatApi: _NoopChatApi());
|
|
await bloc.switchUser('user-a');
|
|
|
|
expect(service.setUserContextCalls, ['user-a']);
|
|
expect(service.loadHistoryCalls, 1);
|
|
expect(bloc.state.error, contains('history unavailable'));
|
|
});
|
|
|
|
test(
|
|
'calendar mutation tool result triggers calendar refresh callback',
|
|
() async {
|
|
final service = _FakeAgUiService();
|
|
var refreshCalls = 0;
|
|
final bloc = ChatBloc(
|
|
service: service,
|
|
chatApi: _NoopChatApi(),
|
|
onCalendarMutated: () async {
|
|
refreshCalls += 1;
|
|
},
|
|
);
|
|
|
|
service.emitEventForTest(
|
|
ToolCallResultEvent(
|
|
messageId: 'msg-1',
|
|
toolCallId: 'call-1',
|
|
toolName: 'project_cli',
|
|
toolCallArgs: const {
|
|
'skill': 'calendar',
|
|
'action': 'create_event',
|
|
},
|
|
result: const {'ok': true},
|
|
status: 'success',
|
|
uiSchema: null,
|
|
),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(refreshCalls, 1);
|
|
expect(bloc.state.isLoading, isFalse);
|
|
},
|
|
);
|
|
|
|
test('calendar read tool result does not trigger calendar refresh callback', () async {
|
|
final service = _FakeAgUiService();
|
|
var refreshCalls = 0;
|
|
final bloc = ChatBloc(
|
|
service: service,
|
|
chatApi: _NoopChatApi(),
|
|
onCalendarMutated: () async {
|
|
refreshCalls += 1;
|
|
},
|
|
);
|
|
|
|
service.emitEventForTest(
|
|
ToolCallResultEvent(
|
|
messageId: 'msg-1',
|
|
toolCallId: 'call-1',
|
|
toolName: 'project_cli',
|
|
toolCallArgs: const {
|
|
'skill': 'calendar',
|
|
'action': 'list_day',
|
|
},
|
|
result: const {'ok': true},
|
|
status: 'success',
|
|
uiSchema: null,
|
|
),
|
|
);
|
|
await Future<void>.delayed(Duration.zero);
|
|
|
|
expect(refreshCalls, 0);
|
|
});
|
|
|
|
test(
|
|
'sendMessage recovers from premature SSE close with polled history',
|
|
() async {
|
|
final service = _FakeAgUiService();
|
|
service.sendMessageHandler = (content, attachments) async {
|
|
throw StateError('SSE closed before terminal event for run');
|
|
};
|
|
|
|
var loadAttempt = 0;
|
|
service.loadHistoryHandler =
|
|
({DateTime? beforeDate, bool forceRefresh = false}) async {
|
|
loadAttempt += 1;
|
|
final now = DateTime.now();
|
|
if (loadAttempt == 1) {
|
|
return _snapshot([
|
|
_historyMessage(
|
|
id: 'db-user-1',
|
|
seq: 1,
|
|
role: 'user',
|
|
content: 'hello',
|
|
timestamp: now,
|
|
),
|
|
]);
|
|
}
|
|
return _snapshot([
|
|
_historyMessage(
|
|
id: 'db-user-1',
|
|
seq: 1,
|
|
role: 'user',
|
|
content: 'hello',
|
|
timestamp: now,
|
|
),
|
|
_historyMessage(
|
|
id: 'db-assistant-1',
|
|
seq: 2,
|
|
role: 'assistant',
|
|
content: 'world',
|
|
timestamp: now.add(const Duration(seconds: 1)),
|
|
),
|
|
]);
|
|
};
|
|
|
|
final bloc = ChatBloc(
|
|
service: service,
|
|
chatApi: _NoopChatApi(),
|
|
recoveryPollInterval: const Duration(milliseconds: 1),
|
|
recoveryTimeout: const Duration(milliseconds: 50),
|
|
);
|
|
await bloc.sendMessage('hello');
|
|
|
|
final userMessages = bloc.state.items
|
|
.whereType<TextMessageItem>()
|
|
.where((item) => item.sender == MessageSender.user)
|
|
.toList();
|
|
expect(userMessages.length, 1);
|
|
expect(userMessages.first.id, 'db-user-1');
|
|
expect(
|
|
bloc.state.items.any(
|
|
(item) =>
|
|
item is TextMessageItem &&
|
|
item.sender == MessageSender.ai &&
|
|
item.content == 'world',
|
|
),
|
|
isTrue,
|
|
);
|
|
expect(bloc.state.error, isNull);
|
|
expect(service.loadHistoryCalls, 2);
|
|
},
|
|
);
|
|
|
|
test('sendMessage reports error after recovery attempts exhausted', () async {
|
|
final service = _FakeAgUiService();
|
|
service.sendMessageHandler = (content, attachments) async {
|
|
throw StateError('SSE closed before terminal event for run');
|
|
};
|
|
service.loadHistoryHandler =
|
|
({DateTime? beforeDate, bool forceRefresh = false}) async {
|
|
final now = DateTime.now();
|
|
return _snapshot([
|
|
_historyMessage(
|
|
id: 'db-user-1',
|
|
seq: 1,
|
|
role: 'user',
|
|
content: 'hello',
|
|
timestamp: now,
|
|
),
|
|
]);
|
|
};
|
|
|
|
final bloc = ChatBloc(
|
|
service: service,
|
|
chatApi: _NoopChatApi(),
|
|
recoveryPollInterval: const Duration(milliseconds: 1),
|
|
recoveryTimeout: const Duration(milliseconds: 15),
|
|
);
|
|
await bloc.sendMessage('hello');
|
|
|
|
expect(bloc.state.error, L10n.current.chatSseInterruptedRetry);
|
|
expect(service.loadHistoryCalls, greaterThanOrEqualTo(1));
|
|
});
|
|
|
|
test(
|
|
'tracks hasSeenStep to distinguish requesting vs processing stage',
|
|
() async {
|
|
final service = _FakeAgUiService();
|
|
final bloc = ChatBloc(service: service, chatApi: _NoopChatApi());
|
|
|
|
service.emitEventForTest(
|
|
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
|
|
);
|
|
expect(bloc.state.isWaitingFirstToken, isTrue);
|
|
expect(bloc.state.hasSeenStep, isFalse);
|
|
expect(bloc.state.currentStage, isNull);
|
|
|
|
service.emitEventForTest(StepStartedEvent(stepName: 'router'));
|
|
expect(bloc.state.hasSeenStep, isTrue);
|
|
expect(bloc.state.currentStage, AgentStage.routing);
|
|
|
|
service.emitEventForTest(StepFinishedEvent(stepName: 'router'));
|
|
expect(bloc.state.hasSeenStep, isTrue);
|
|
expect(bloc.state.currentStage, isNull);
|
|
},
|
|
);
|
|
|
|
test('chat completed analytics triggers once only on RUN_FINISHED', () async {
|
|
final service = _FakeAgUiService();
|
|
var completedCalls = 0;
|
|
String? lastConversationId;
|
|
int? lastMessageCount;
|
|
int? lastResponseTimeMs;
|
|
|
|
final bloc = ChatBloc(
|
|
service: service,
|
|
chatApi: _NoopChatApi(),
|
|
onChatCompleted:
|
|
({
|
|
required String conversationId,
|
|
required int messageCount,
|
|
required int responseTimeMs,
|
|
}) {
|
|
completedCalls += 1;
|
|
lastConversationId = conversationId;
|
|
lastMessageCount = messageCount;
|
|
lastResponseTimeMs = responseTimeMs;
|
|
},
|
|
);
|
|
|
|
service.emitEventForTest(
|
|
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
|
|
);
|
|
service.emitEventForTest(
|
|
TextMessageEndEvent(
|
|
messageId: 'msg-1',
|
|
answer: 'hello',
|
|
role: 'assistant',
|
|
status: 'success',
|
|
),
|
|
);
|
|
|
|
expect(completedCalls, 0);
|
|
|
|
service.emitEventForTest(
|
|
RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'),
|
|
);
|
|
service.emitEventForTest(
|
|
RunFinishedEvent(threadId: 'thread-1', runId: 'run-1'),
|
|
);
|
|
|
|
expect(completedCalls, 1);
|
|
expect(lastConversationId, 'thread-1');
|
|
expect(lastMessageCount, 1);
|
|
expect(lastResponseTimeMs, isNotNull);
|
|
expect(lastResponseTimeMs, greaterThanOrEqualTo(0));
|
|
|
|
bloc.close();
|
|
});
|
|
|
|
test('chat completed analytics does not trigger on RUN_ERROR', () async {
|
|
final service = _FakeAgUiService();
|
|
var completedCalls = 0;
|
|
|
|
final bloc = ChatBloc(
|
|
service: service,
|
|
chatApi: _NoopChatApi(),
|
|
onChatCompleted:
|
|
({
|
|
required String conversationId,
|
|
required int messageCount,
|
|
required int responseTimeMs,
|
|
}) {
|
|
completedCalls += 1;
|
|
},
|
|
);
|
|
|
|
service.emitEventForTest(
|
|
RunStartedEvent(threadId: 'thread-1', runId: 'run-1'),
|
|
);
|
|
service.emitEventForTest(
|
|
RunErrorEvent(message: 'run failed', code: 'RUN_FAILED'),
|
|
);
|
|
|
|
expect(completedCalls, 0);
|
|
bloc.close();
|
|
});
|
|
}
|