Complete local hybrid search and improve agent reliability
This commit is contained in:
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:flutter_app/core/network/api_repository.dart';
|
||||
import 'package:flutter_app/core/settings/local_settings_store.dart';
|
||||
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
|
||||
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
|
||||
|
||||
@@ -13,16 +14,81 @@ class _VersionTestApi extends ApiRepository {
|
||||
['الجواب الثاني'],
|
||||
];
|
||||
SavedConversation? lastSaved;
|
||||
String? indexedWorkspace;
|
||||
List<String> indexedFiles = const [];
|
||||
List<String> deletedFiles = const [];
|
||||
|
||||
@override
|
||||
Future<String> getModelName() async => 'gemma4:e2b';
|
||||
|
||||
@override
|
||||
Future<List<String>> getAvailableModels() async => ['gemma4:e2b'];
|
||||
Future<List<ModelDescriptor>> getAvailableModels() async => const [
|
||||
ModelDescriptor(
|
||||
id: 'gemma4:e2b',
|
||||
capabilities: ['completion', 'vision', 'audio', 'tools'],
|
||||
capabilitiesVerified: true,
|
||||
),
|
||||
ModelDescriptor(
|
||||
id: 'granite-embedding:278m',
|
||||
capabilities: ['embedding'],
|
||||
capabilitiesVerified: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<AgentSkillDescriptor>> getAgentSkills() async => const [
|
||||
AgentSkillDescriptor(
|
||||
id: 'code_explain',
|
||||
name: 'شرح الكود',
|
||||
description: 'شرح المصدر.',
|
||||
),
|
||||
AgentSkillDescriptor(
|
||||
id: 'code_review',
|
||||
name: 'مراجعة الكود',
|
||||
description: 'مراجعة المصدر.',
|
||||
),
|
||||
AgentSkillDescriptor(
|
||||
id: 'test_plan',
|
||||
name: 'خطة اختبارات',
|
||||
description: 'اقتراح الاختبارات.',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<SavedConversation>> listConversations() async => [];
|
||||
|
||||
@override
|
||||
Future<List<String>> listWorkspaceFiles(String workspacePath) async => [
|
||||
'README.md',
|
||||
];
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> indexWorkspaceKnowledge(
|
||||
String workspacePath,
|
||||
List<String> files,
|
||||
) async {
|
||||
indexedWorkspace = workspacePath;
|
||||
indexedFiles = files;
|
||||
return {
|
||||
'indexed': [
|
||||
for (final file in files) {'path': file, 'chunks': 2},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> deleteWorkspaceKnowledge(
|
||||
String workspacePath,
|
||||
List<String> files,
|
||||
) async {
|
||||
deletedFiles = files;
|
||||
return {
|
||||
'deleted': [
|
||||
for (final file in files) {'path': file, 'deleted': true},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveConversation(SavedConversation conversation) async {
|
||||
lastSaved = conversation;
|
||||
@@ -65,10 +131,58 @@ class _CancellableTestApi extends _VersionTestApi {
|
||||
}
|
||||
}
|
||||
|
||||
class _MemorySettingsStore extends LocalSettingsStore {
|
||||
final values = <String, Object?>{};
|
||||
|
||||
@override
|
||||
bool get persistenceAvailable => true;
|
||||
|
||||
@override
|
||||
Future<Map<String, Object?>> read() async => Map.of(values);
|
||||
|
||||
@override
|
||||
Future<bool> update(Map<String, Object?> updates) async {
|
||||
values.addAll(updates);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('embedding-only models are not selectable for chat', () async {
|
||||
final api = _VersionTestApi();
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(cubit.state.availableModels.map((model) => model.id), [
|
||||
'gemma4:e2b',
|
||||
]);
|
||||
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('selected workspace files can be indexed as local knowledge', () async {
|
||||
final api = _VersionTestApi();
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await cubit.selectWorkspace(r'C:\Projects\sample');
|
||||
cubit.selectWorkspaceFiles(['README.md']);
|
||||
final message = await cubit.indexSelectedWorkspaceKnowledge();
|
||||
|
||||
expect(api.indexedWorkspace, r'C:\Projects\sample');
|
||||
expect(api.indexedFiles, ['README.md']);
|
||||
expect(message, contains('2 مقطعًا'));
|
||||
expect(cubit.state.agentProgress, isNull);
|
||||
|
||||
final removal = await cubit.removeSelectedWorkspaceKnowledge();
|
||||
expect(api.deletedFiles, ['README.md']);
|
||||
expect(removal, contains('أُزيل 1 ملف'));
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test('regeneration keeps prior answers and selection is persisted', () async {
|
||||
final api = _VersionTestApi();
|
||||
final cubit = ChatCubit(api);
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
await cubit.send('ما معنى API؟');
|
||||
@@ -97,11 +211,52 @@ void main() {
|
||||
await cubit.close();
|
||||
});
|
||||
|
||||
test(
|
||||
'local API, model, and notification settings survive Cubit restart',
|
||||
() async {
|
||||
final settings = _MemorySettingsStore();
|
||||
final firstApi = _VersionTestApi();
|
||||
final first = ChatCubit(firstApi, settingsStore: settings);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(await first.setApiBaseUrl('file:///unsafe'), isFalse);
|
||||
expect(firstApi.baseUrl, 'http://127.0.0.1:8000');
|
||||
expect(await first.setApiBaseUrl('http://127.0.0.1:9123'), isTrue);
|
||||
await first.setNotificationsEnabled(false);
|
||||
await first.setDarkMode(true);
|
||||
first.selectModel('gemma4:e2b');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await first.close();
|
||||
|
||||
final restoredApi = _VersionTestApi();
|
||||
final restored = ChatCubit(restoredApi, settingsStore: settings);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(restored.state.apiBaseUrl, 'http://127.0.0.1:9123');
|
||||
expect(restored.state.notificationsEnabled, isFalse);
|
||||
expect(restored.state.isDarkMode, isTrue);
|
||||
expect(restored.state.selectedModel, 'gemma4:e2b');
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
restored.selectAgentSkill('code_review');
|
||||
expect(restored.state.selectedSkillId, 'code_review');
|
||||
restored.selectAgentSkill('unknown');
|
||||
expect(restored.state.selectedSkillId, 'code_review');
|
||||
restored.selectAgentSkill(null);
|
||||
expect(restored.state.selectedSkillId, isNull);
|
||||
await restored.close();
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'cancelling generation preserves partial text and records duration',
|
||||
() async {
|
||||
final api = _CancellableTestApi();
|
||||
final cubit = ChatCubit(api);
|
||||
final cubit = ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
final send = cubit.send('اكتب فقرة قصيرة');
|
||||
@@ -127,7 +282,10 @@ void main() {
|
||||
'cancelling regeneration before its first token keeps saved answer',
|
||||
() async {
|
||||
final api = _CancellableTestApi();
|
||||
final cubit = ChatCubit(api);
|
||||
final cubit = ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
);
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
await cubit.openConversation(api.storedConversation);
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:record/record.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_app/core/network/api_repository.dart';
|
||||
import 'package:flutter_app/core/settings/local_settings_store.dart';
|
||||
import 'package:flutter_app/features/chat/presentation/cubit/chat_cubit.dart';
|
||||
import 'package:flutter_app/features/chat/presentation/cubit/chat_state.dart';
|
||||
import 'package:flutter_app/features/audio/presentation/cubit/voice_cubit.dart';
|
||||
@@ -17,7 +18,37 @@ class _TestApiRepository extends ApiRepository {
|
||||
Future<String> getModelName() async => 'gemma4:e2b';
|
||||
|
||||
@override
|
||||
Future<List<String>> getAvailableModels() async => ['gemma4:e2b'];
|
||||
Future<List<ModelDescriptor>> getAvailableModels() async => const [
|
||||
ModelDescriptor(
|
||||
id: 'gemma4:e2b',
|
||||
capabilities: ['completion', 'vision', 'audio', 'tools'],
|
||||
capabilitiesVerified: true,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<AgentSkillDescriptor>> getAgentSkills() async => const [
|
||||
AgentSkillDescriptor(
|
||||
id: 'code_explain',
|
||||
name: 'شرح الكود',
|
||||
description: 'شرح المصدر.',
|
||||
),
|
||||
AgentSkillDescriptor(
|
||||
id: 'code_review',
|
||||
name: 'مراجعة الكود',
|
||||
description: 'مراجعة المصدر.',
|
||||
),
|
||||
AgentSkillDescriptor(
|
||||
id: 'test_plan',
|
||||
name: 'خطة اختبارات',
|
||||
description: 'اقتراح الاختبارات.',
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<String>> listWorkspaceFiles(String workspacePath) async => [
|
||||
'lib/main.dart',
|
||||
];
|
||||
|
||||
@override
|
||||
Future<List<SavedConversation>> listConversations() async => [];
|
||||
@@ -39,6 +70,63 @@ class _CancellableWidgetApi extends _TestApiRepository {
|
||||
}
|
||||
}
|
||||
|
||||
class _MarkdownWidgetApi extends _TestApiRepository {
|
||||
@override
|
||||
Future<void> saveConversation(SavedConversation conversation) async {}
|
||||
|
||||
@override
|
||||
Stream<String> completeStream(List<ChatMessage> messages, {String? model}) =>
|
||||
Stream.value('''| Feature | Status |
|
||||
| --- | --- |
|
||||
| Markdown | **ready** |
|
||||
|
||||
1. Parent item
|
||||
- Nested item
|
||||
- [x] GFM task''');
|
||||
}
|
||||
|
||||
class _ProposalWidgetApi extends _TestApiRepository {
|
||||
int proposalCount = 0;
|
||||
int applyCount = 0;
|
||||
|
||||
@override
|
||||
Future<void> saveConversation(SavedConversation conversation) async {}
|
||||
|
||||
@override
|
||||
Future<List<String>> listWorkspaceFiles(String workspacePath) async => [
|
||||
'src/hello.py',
|
||||
];
|
||||
|
||||
@override
|
||||
Future<AgentRunResult> runAgent(
|
||||
String task, {
|
||||
String? model,
|
||||
String? workspacePath,
|
||||
List<String> workspaceFiles = const [],
|
||||
String? skillId,
|
||||
void Function(String message)? onProgress,
|
||||
}) async {
|
||||
onProgress?.call('يبني معاينة diff دون الكتابة إلى الملف.');
|
||||
proposalCount++;
|
||||
return AgentRunResult(
|
||||
answer: 'حضّرت معاينة، ولم أكتب الملف.',
|
||||
proposal: FileChangeProposal(
|
||||
token: 'proposal-$proposalCount',
|
||||
path: 'src/hello.py',
|
||||
operation: 'create',
|
||||
diff: '--- /dev/null\n+++ b/src/hello.py\n+print("hello")',
|
||||
expiresInSeconds: 600,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String> applyFileChange(FileChangeProposal proposal) async {
|
||||
applyCount++;
|
||||
return 'أُنشئ الملف ${proposal.path} بعد موافقتك.';
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('chat page fits a compact Windows window', (tester) async {
|
||||
tester.view.physicalSize = const Size(800, 600);
|
||||
@@ -47,12 +135,16 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _TestApiRepository();
|
||||
final chatCubit = ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(create: (_) => ChatCubit(api)),
|
||||
BlocProvider.value(value: chatCubit),
|
||||
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
|
||||
],
|
||||
child: const SovereignApp(),
|
||||
@@ -63,6 +155,57 @@ void main() {
|
||||
|
||||
expect(find.text('مساعدك المحلي'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
|
||||
await chatCubit.setDarkMode(true);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<MaterialApp>(find.byType(MaterialApp)).themeMode,
|
||||
ThemeMode.dark,
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
await chatCubit.close();
|
||||
});
|
||||
|
||||
testWidgets('model selector displays capabilities reported by Ollama', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1000, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _TestApiRepository();
|
||||
final chatCubit = ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider.value(value: chatCubit),
|
||||
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
|
||||
],
|
||||
child: const SovereignApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await chatCubit.selectWorkspace('C:/sample-project');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byTooltip('اختيار مهارة للوكيل'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('خطة اختبارات'), findsOneWidget);
|
||||
await tester.tap(find.text('مراجعة الكود'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(chatCubit.state.selectedSkillId, 'code_review');
|
||||
await tester.tap(find.byType(DropdownButton<String>));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('نص · صور · صوت · أدوات'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
await chatCubit.close();
|
||||
});
|
||||
|
||||
testWidgets('stop button cancels an active answer', (tester) async {
|
||||
@@ -72,7 +215,7 @@ void main() {
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _CancellableWidgetApi();
|
||||
final cubit = ChatCubit(api);
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
@@ -103,4 +246,101 @@ void main() {
|
||||
await cubit.close();
|
||||
await api.controller.close();
|
||||
});
|
||||
|
||||
testWidgets('assistant renders tables and nested GFM task lists', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1000, 700);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _MarkdownWidgetApi();
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider(
|
||||
create:
|
||||
(_) => ChatCubit(
|
||||
api,
|
||||
settingsStore: LocalSettingsStore.inMemory(),
|
||||
),
|
||||
),
|
||||
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
|
||||
],
|
||||
child: const SovereignApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.enterText(find.byType(TextField), 'اختبار Markdown');
|
||||
await tester.tap(find.byTooltip('إرسال'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Table), findsOneWidget);
|
||||
expect(find.text('Feature'), findsOneWidget);
|
||||
expect(
|
||||
find.byWidgetPredicate(
|
||||
(widget) =>
|
||||
widget is RichText &&
|
||||
widget.text.toPlainText().contains('Nested item'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('☑'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('file changes require explicit review before apply', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(1100, 800);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
final api = _ProposalWidgetApi();
|
||||
final cubit = ChatCubit(api, settingsStore: LocalSettingsStore.inMemory());
|
||||
await cubit.selectWorkspace(r'C:\Projects\sample');
|
||||
cubit.selectWorkspaceFiles(['src/hello.py']);
|
||||
await tester.pumpWidget(
|
||||
MultiRepositoryProvider(
|
||||
providers: [RepositoryProvider<ApiRepository>.value(value: api)],
|
||||
child: MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<ChatCubit>.value(value: cubit),
|
||||
BlocProvider(create: (_) => VoiceCubit(api, AudioRecorder())),
|
||||
],
|
||||
child: const SovereignApp(),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
Future<void> requestProposal() async {
|
||||
await tester.enterText(find.byType(TextField), 'أنشئ ملف Python');
|
||||
await tester.tap(find.byTooltip('إرسال'));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
await requestProposal();
|
||||
expect(find.text('مراجعة إنشاء الملف'), findsOneWidget);
|
||||
expect(find.text('src/hello.py'), findsOneWidget);
|
||||
expect(api.applyCount, 0);
|
||||
|
||||
await tester.tap(find.text('إلغاء'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(api.applyCount, 0);
|
||||
|
||||
await requestProposal();
|
||||
expect(find.text('مراجعة إنشاء الملف'), findsOneWidget);
|
||||
await tester.tap(find.text('إنشاء الملف'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(api.applyCount, 1);
|
||||
expect(tester.takeException(), isNull);
|
||||
await cubit.close();
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user