refactor(assistant): remove desktop local skills loader

This commit is contained in:
Haitao Pan 2026-03-25 21:44:54 +08:00
parent 48cb2ff517
commit 99b89f0851
4 changed files with 1 additions and 1569 deletions

View File

@ -40,19 +40,13 @@ class _SingleAgentSkillScanRoot {
required this.path,
required this.source,
required this.scope,
this.bookmark = '',
});
final String path;
final String source;
final String scope;
final String bookmark;
}
const String _singleAgentLocalSkillsCacheRelativePath =
'cache/single-agent-local-skills.json';
const int _singleAgentLocalSkillsCacheSchemaVersion = 2;
class AppController extends ChangeNotifier {
static const List<_SingleAgentSkillScanRoot>
_defaultSingleAgentGlobalSkillScanRoots = <_SingleAgentSkillScanRoot>[
@ -77,32 +71,12 @@ class AppController extends ChangeNotifier {
scope: 'user',
),
];
static const List<_SingleAgentSkillScanRoot>
_defaultSingleAgentWorkspaceSkillScanRoots = <_SingleAgentSkillScanRoot>[
_SingleAgentSkillScanRoot(
path: '.agents/skills',
source: 'agents',
scope: 'workspace',
),
_SingleAgentSkillScanRoot(
path: '.codex/skills',
source: 'codex',
scope: 'workspace',
),
_SingleAgentSkillScanRoot(
path: '.workbuddy/skills',
source: 'workbuddy',
scope: 'workspace',
),
];
AppController({
SecureConfigStore? store,
RuntimeCoordinator? runtimeCoordinator,
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
SkillDirectoryAccessService? skillDirectoryAccessService,
List<String>? singleAgentLocalSkillScanRoots,
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
ArisBundleRepository? arisBundleRepository,
SingleAgentRunner? singleAgentRunner,
@ -147,8 +121,6 @@ class AppController extends ChangeNotifier {
desktopPlatformService ?? createDesktopPlatformService();
_skillDirectoryAccessService =
skillDirectoryAccessService ?? createSkillDirectoryAccessService();
_singleAgentLocalSkillScanRootOverrides = singleAgentLocalSkillScanRoots
?.toList(growable: false);
_gatewayAcpClient = GatewayAcpClient(
endpointResolver: _resolveGatewayAcpEndpoint,
);
@ -192,7 +164,6 @@ class AppController extends ChangeNotifier {
late final DerivedTasksController _tasksController;
late final DesktopPlatformService _desktopPlatformService;
late final SkillDirectoryAccessService _skillDirectoryAccessService;
late final List<String>? _singleAgentLocalSkillScanRootOverrides;
late final GatewayAcpClient _gatewayAcpClient;
late final DirectSingleAgentAppServerClient _singleAgentAppServerClient;
late final List<SingleAgentProvider>? _availableSingleAgentProvidersOverride;
@ -217,9 +188,6 @@ class AppController extends ChangeNotifier {
<String, String>{};
final DesktopThreadArtifactService _threadArtifactService =
DesktopThreadArtifactService();
List<AssistantThreadSkillEntry> _singleAgentSharedImportedSkills =
const <AssistantThreadSkillEntry>[];
bool _singleAgentLocalSkillsHydrated = false;
final Map<String, HttpClient> _aiGatewayStreamingClients =
<String, HttpClient>{};
final Set<String> _aiGatewayPendingSessionKeys = <String>{};
@ -256,14 +224,6 @@ class AppController extends ChangeNotifier {
Future<void> _assistantThreadPersistQueue = Future<void>.value();
Future<void> _settingsObservationQueue = Future<void>.value();
List<_SingleAgentSkillScanRoot> get _singleAgentGlobalSkillScanRoots =>
(_singleAgentLocalSkillScanRootOverrides?.map(
_singleAgentGlobalSkillScanRootFromOverride,
))?.toList(growable: false) ??
settings.authorizedSkillDirectories
.map(_singleAgentGlobalSkillScanRootFromAuthorizedDirectory)
.toList(growable: false);
WorkspaceDestination get destination => _destination;
UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest;
AppCapabilities get capabilities =>
@ -2170,15 +2130,10 @@ class AppController extends ChangeNotifier {
AssistantExecutionTarget.singleAgent) {
return;
}
if (!_singleAgentLocalSkillsHydrated) {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: false);
}
final previousImported =
_assistantThreadRecords[normalizedSessionKey]?.importedSkills ??
const <AssistantThreadSkillEntry>[];
final fallbackSkills = await _singleAgentLocalFallbackSkillsForSession(
normalizedSessionKey,
);
const fallbackSkills = <AssistantThreadSkillEntry>[];
final provider =
singleAgentResolvedProviderForSession(normalizedSessionKey) ??
currentSingleAgentResolvedProvider;
@ -2238,7 +2193,6 @@ class AppController extends ChangeNotifier {
Future<void> refreshSingleAgentLocalSkillsForSession(
String sessionKey,
) async {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
await refreshSingleAgentSkillsForSession(sessionKey);
}
@ -2907,7 +2861,6 @@ class AppController extends ChangeNotifier {
return;
}
_disposed = true;
unawaited(_persistSharedSingleAgentLocalSkillsCache());
_runtimeEventsSubscription?.cancel();
_detachChildListeners();
_runtimeCoordinator.dispose();
@ -2933,7 +2886,6 @@ class AppController extends ChangeNotifier {
try {
await _settingsController.initialize();
_restoreAssistantThreads(await _store.loadAssistantThreadRecords());
await _restoreSharedSingleAgentLocalSkillsCache();
if (_disposed) {
return;
}
@ -2994,7 +2946,6 @@ class AppController extends ChangeNotifier {
);
await _restoreInitialAssistantSessionSelection();
await _ensureActiveAssistantThread();
unawaited(_startupRefreshSharedSingleAgentLocalSkillsCache());
if (isSingleAgentMode) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
}
@ -3144,22 +3095,6 @@ class AppController extends ChangeNotifier {
static bool _isGatewayDraftKey(String key) =>
key.startsWith('gateway_token_') || key.startsWith('gateway_password_');
bool _authorizedSkillDirectoriesChanged(
SettingsSnapshot previous,
SettingsSnapshot current,
) {
return jsonEncode(
previous.authorizedSkillDirectories
.map((item) => item.toJson())
.toList(growable: false),
) !=
jsonEncode(
current.authorizedSkillDirectories
.map((item) => item.toJson())
.toList(growable: false),
);
}
Future<void> _persistSettingsSnapshot(SettingsSnapshot snapshot) async {
final sanitized = _sanitizeFeatureFlagSettings(
_sanitizeMultiAgentSettings(
@ -3206,16 +3141,6 @@ class AppController extends ChangeNotifier {
return;
}
}
if (_authorizedSkillDirectoriesChanged(previous, current)) {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
if (_disposed) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
}
}
if (refreshAfterSave) {
_recomputeTasks();
}
@ -4218,221 +4143,9 @@ class AppController extends ChangeNotifier {
return target.promptValue;
}
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentSkillEntries(
List<_SingleAgentSkillScanRoot> roots, {
String workspaceRef = '',
}) async {
final dedupedByName = <String, AssistantThreadSkillEntry>{};
for (final rootSpec in roots) {
var resolvedRootPath = _resolveSingleAgentSkillRootPath(
rootSpec.path,
workspaceRef: workspaceRef,
);
if (resolvedRootPath.isEmpty) {
continue;
}
SkillDirectoryAccessHandle? accessHandle;
try {
if (rootSpec.bookmark.trim().isNotEmpty) {
accessHandle = await _skillDirectoryAccessService.openDirectory(
AuthorizedSkillDirectory(
path: resolvedRootPath,
bookmark: rootSpec.bookmark,
),
);
if (accessHandle == null) {
continue;
}
resolvedRootPath = normalizeAuthorizedSkillDirectoryPath(
accessHandle.path,
);
}
final root = Directory(resolvedRootPath);
if (!await root.exists()) {
continue;
}
await for (final entity in root.list(
recursive: true,
followLinks: false,
)) {
if (entity is! File || entity.uri.pathSegments.last != 'SKILL.md') {
continue;
}
final entry = await _skillEntryFromFile(
entity,
rootSpec,
resolvedRootPath,
);
final normalizedName = entry.label.trim().toLowerCase();
if (normalizedName.isEmpty) {
continue;
}
dedupedByName[normalizedName] = entry;
}
} finally {
await accessHandle?.close();
}
}
final entries = dedupedByName.values.toList(growable: false);
entries.sort((left, right) => left.label.compareTo(right.label));
return entries;
}
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentGlobalSkillEntries() {
return _scanSingleAgentSkillEntries(_singleAgentGlobalSkillScanRoots);
}
Future<List<AssistantThreadSkillEntry>>
_scanSingleAgentBundledSkillEntries() async {
try {
final bundle = await _arisBundleRepository.ensureReady();
final skillsRoot = Directory(bundle.resolve('skills'));
if (!await skillsRoot.exists()) {
return const <AssistantThreadSkillEntry>[];
}
return _scanSingleAgentSkillEntries(const <_SingleAgentSkillScanRoot>[
_SingleAgentSkillScanRoot(path: '', source: 'bundle', scope: 'bundle'),
], workspaceRef: skillsRoot.path);
} catch (_) {
return const <AssistantThreadSkillEntry>[];
}
}
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentWorkspaceSkillEntries(
String sessionKey,
) {
return _scanSingleAgentSkillEntries(
_defaultSingleAgentWorkspaceSkillScanRoots,
workspaceRef: assistantWorkspaceRefForSession(sessionKey),
);
}
_SingleAgentSkillScanRoot _singleAgentGlobalSkillScanRootFromOverride(
String rawPath,
) {
final normalizedPath = rawPath.trim();
final lowered = normalizedPath.toLowerCase();
return _SingleAgentSkillScanRoot(
path: normalizedPath,
source: _sourceForSkillRootPath(lowered),
scope: normalizedPath.startsWith('/etc/') ? 'system' : 'user',
);
}
_SingleAgentSkillScanRoot
_singleAgentGlobalSkillScanRootFromAuthorizedDirectory(
AuthorizedSkillDirectory directory,
) {
final normalizedPath = normalizeAuthorizedSkillDirectoryPath(
directory.path,
);
final lowered = normalizedPath.toLowerCase();
return _SingleAgentSkillScanRoot(
path: normalizedPath,
source: _sourceForSkillRootPath(lowered),
scope: normalizedPath.startsWith('/etc/') ? 'system' : 'user',
bookmark: directory.bookmark,
);
}
String _resolveSingleAgentSkillRootPath(
String rawPath, {
String workspaceRef = '',
}) {
final trimmed = rawPath.trim().replaceFirst(RegExp(r'^\./'), '');
if (trimmed.isEmpty) {
return '';
}
if (trimmed.startsWith('/')) {
return trimmed;
}
if (trimmed.startsWith('~/')) {
final home = Platform.environment['HOME']?.trim() ?? '';
return home.isEmpty ? trimmed : '$home/${trimmed.substring(2)}';
}
final normalizedWorkspace = workspaceRef.trim();
if (normalizedWorkspace.isEmpty) {
return '';
}
final base = normalizedWorkspace.endsWith('/')
? normalizedWorkspace.substring(0, normalizedWorkspace.length - 1)
: normalizedWorkspace;
return '$base/$trimmed';
}
String _sourceForSkillRootPath(String path) {
if (path.startsWith('/etc/skills')) {
return 'system';
}
if (_pathContainsSourceToken(path, 'workbuddy')) {
return 'workbuddy';
}
if (_pathContainsSourceToken(path, 'opencode')) {
return 'opencode';
}
if (_pathContainsSourceToken(path, 'claude')) {
return 'claude';
}
if (_pathContainsSourceToken(path, 'agents')) {
return 'agents';
}
return 'codex';
}
bool _pathContainsSourceToken(String path, String token) {
final pattern = RegExp('(^|[./_-])$token([./_-]|\$)');
return pattern.hasMatch(path);
}
Future<AssistantThreadSkillEntry> _skillEntryFromFile(
File file,
_SingleAgentSkillScanRoot root,
String rootPath,
) async {
final content = await file.readAsString();
final nameMatch = RegExp(
"^name:\\s*[\"']?(.+?)[\"']?\\s*\$",
multiLine: true,
).firstMatch(content);
final descriptionMatch = RegExp(
"^description:\\s*[\"']?(.+?)[\"']?\\s*\$",
multiLine: true,
).firstMatch(content);
final directory = file.parent;
final label =
(nameMatch?.group(1) ??
directory.uri.pathSegments
.where((item) => item.isNotEmpty)
.last)
.trim();
final relativeSource = directory.path.startsWith(rootPath)
? directory.path
.substring(rootPath.length)
.replaceFirst(RegExp(r'^/'), '')
: directory.path;
final sourceSegments = <String>[
root.source,
if (root.scope != root.source) root.scope,
].where((item) => item.trim().isNotEmpty).toList(growable: false);
final sourceLabel = sourceSegments.join(' · ');
return AssistantThreadSkillEntry(
key: directory.path,
label: label,
description: (descriptionMatch?.group(1) ?? '').trim(),
source: root.source,
sourcePath: file.path,
scope: root.scope,
sourceLabel: relativeSource.isEmpty
? sourceLabel
: '$sourceLabel · $relativeSource',
);
}
void _restoreAssistantThreads(List<AssistantThreadRecord> records) {
_assistantThreadRecords.clear();
_assistantThreadMessages.clear();
_singleAgentSharedImportedSkills = const <AssistantThreadSkillEntry>[];
_singleAgentLocalSkillsHydrated = false;
final archivedKeys = settings.assistantArchivedTaskKeys
.map(_normalizedAssistantSessionKey)
.toSet();
@ -4485,132 +4198,6 @@ class AppController extends ChangeNotifier {
}
}
Future<void> _refreshSharedSingleAgentLocalSkillsCache({
required bool forceRescan,
}) async {
if (!forceRescan && _singleAgentLocalSkillsHydrated) {
return;
}
if (!forceRescan && await _restoreSharedSingleAgentLocalSkillsCache()) {
return;
}
final globalSkills = await _scanSingleAgentGlobalSkillEntries();
final availableSkills = globalSkills.isNotEmpty
? globalSkills
: await _scanSingleAgentBundledSkillEntries();
_singleAgentSharedImportedSkills = availableSkills;
_singleAgentLocalSkillsHydrated = true;
await _persistSharedSingleAgentLocalSkillsCache();
}
Future<void> ensureSharedSingleAgentLocalSkillsLoaded() async {
if (_singleAgentLocalSkillsHydrated) {
return;
}
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: false);
}
Future<void> _startupRefreshSharedSingleAgentLocalSkillsCache() async {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
if (_disposed) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
return;
}
_notifyIfActive();
}
Future<List<AssistantThreadSkillEntry>>
_singleAgentLocalFallbackSkillsForSession(String sessionKey) async {
final workspaceSkills = await _scanSingleAgentWorkspaceSkillEntries(
sessionKey,
);
return _mergeSingleAgentLocalSkills(
globalSkills: _singleAgentSharedImportedSkills,
workspaceSkills: workspaceSkills,
);
}
List<AssistantThreadSkillEntry> _mergeSingleAgentLocalSkills({
required List<AssistantThreadSkillEntry> globalSkills,
required List<AssistantThreadSkillEntry> workspaceSkills,
}) {
final merged = <String, AssistantThreadSkillEntry>{};
for (final skill in globalSkills) {
final normalizedName = skill.label.trim().toLowerCase();
if (normalizedName.isEmpty) {
continue;
}
merged[normalizedName] = skill;
}
for (final skill in workspaceSkills) {
final normalizedName = skill.label.trim().toLowerCase();
if (normalizedName.isEmpty || merged.containsKey(normalizedName)) {
continue;
}
merged[normalizedName] = skill;
}
final entries = merged.values.toList(growable: false);
entries.sort((left, right) => left.label.compareTo(right.label));
return entries;
}
Future<bool> _restoreSharedSingleAgentLocalSkillsCache() async {
try {
final payload = await _store.loadSupportJson(
_singleAgentLocalSkillsCacheRelativePath,
);
if (payload == null) {
return false;
}
final schemaVersion = int.tryParse(
payload['schemaVersion']?.toString() ?? '',
);
if (schemaVersion != _singleAgentLocalSkillsCacheSchemaVersion) {
return false;
}
final skills = asList(payload['skills'])
.map(asMap)
.map(
(item) => AssistantThreadSkillEntry.fromJson(
item.cast<String, dynamic>(),
),
)
.where((item) => item.key.trim().isNotEmpty && item.label.isNotEmpty)
.toList(growable: false);
if (skills.isEmpty) {
_singleAgentSharedImportedSkills = const <AssistantThreadSkillEntry>[];
_singleAgentLocalSkillsHydrated = false;
return false;
}
_singleAgentSharedImportedSkills = skills;
_singleAgentLocalSkillsHydrated = true;
return true;
} catch (_) {
return false;
}
}
Future<void> _persistSharedSingleAgentLocalSkillsCache() async {
try {
await _store.saveSupportJson(
_singleAgentLocalSkillsCacheRelativePath,
<String, dynamic>{
'schemaVersion': _singleAgentLocalSkillsCacheSchemaVersion,
'savedAtMs': DateTime.now().millisecondsSinceEpoch.toDouble(),
'skills': _singleAgentSharedImportedSkills
.map((item) => item.toJson())
.toList(growable: false),
},
);
} catch (_) {
// Best effort only for local cache persistence.
}
}
Future<void> _replaceSingleAgentThreadSkills(
String sessionKey,
List<AssistantThreadSkillEntry> importedSkills,
@ -5437,16 +5024,6 @@ class AppController extends ChangeNotifier {
return;
}
}
if (_authorizedSkillDirectoriesChanged(previous, current)) {
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
if (_disposed) {
return;
}
if (assistantExecutionTargetForSession(currentSessionKey) ==
AssistantExecutionTarget.singleAgent) {
await refreshSingleAgentSkillsForSession(currentSessionKey);
}
}
_notifyIfActive();
}

View File

@ -550,230 +550,6 @@ void main() {
expect(find.text('远程 OpenClaw Gateway'), findsWidgets);
});
testWidgets(
'AssistantPage shows a persistent skill popover in single-agent mode and keeps thread selections isolated',
(WidgetTester tester) async {
late final Directory tempDirectory;
late final AppController controller;
await tester.runAsync(() async {
tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-assistant-skills-ui-',
);
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
final codexRoot = Directory('${tempDirectory.path}/codex-skills');
final workbuddyRoot = Directory(
'${tempDirectory.path}/workbuddy-skills',
);
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser Automation',
description: 'Browse websites',
);
await _writeSkill(
codexRoot,
'ppt',
skillName: 'PPT',
description: 'Presentation skill',
);
await _writeSkill(
workbuddyRoot,
'wordx',
skillName: 'WordX',
description: 'Document skill',
);
controller = await _createControllerWithThreadRecords(
records: const <AssistantThreadRecord>[],
useFakeGatewayRuntime: true,
singleAgentLocalSkillScanRoots: <String>[
agentsRoot.path,
codexRoot.path,
workbuddyRoot.path,
],
);
});
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
addTearDown(controller.dispose);
tester.view.devicePixelRatio = 1;
tester.view.physicalSize = const Size(1600, 1000);
addTearDown(() {
tester.view.resetPhysicalSize();
tester.view.resetDevicePixelRatio();
});
await tester.pumpWidget(
MaterialApp(
locale: const Locale('zh'),
supportedLocales: const [Locale('zh'), Locale('en')],
localizationsDelegates: GlobalMaterialLocalizations.delegates,
theme: AppTheme.light(),
darkTheme: AppTheme.dark(),
home: Scaffold(
body: AssistantPage(controller: controller, onOpenDetail: (_) {}),
),
),
);
await _pumpForUiSync(tester);
await tester.runAsync(() async {
await _waitForCondition(
() =>
controller
.assistantImportedSkillsForSession(
controller.currentSessionKey,
)
.length ==
3,
);
});
await _pumpForUiSync(tester);
await tester.tap(find.byKey(const Key('assistant-skill-picker-button')));
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-skill-picker-popover')),
findsOneWidget,
);
expect(
find.byKey(const Key('assistant-skill-picker-dialog')),
findsNothing,
);
await tester.enterText(
find.byKey(const Key('assistant-skill-picker-search')),
'browser',
);
await _pumpForUiSync(tester);
expect(find.text('Browser Automation'), findsOneWidget);
expect(find.text('PPT'), findsNothing);
final browserSkill = controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.firstWhere((skill) => skill.label == 'Browser Automation');
final pptSkill = controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.firstWhere((skill) => skill.label == 'PPT');
final wordxSkill = controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.firstWhere((skill) => skill.label == 'WordX');
await tester.tap(
find.byKey(
ValueKey<String>('assistant-skill-option-${browserSkill.key}'),
),
);
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-skill-picker-popover')),
findsOneWidget,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${browserSkill.key}'),
),
findsOneWidget,
);
await tester.enterText(
find.byKey(const Key('assistant-skill-picker-search')),
'',
);
await _pumpForUiSync(tester);
await tester.tap(
find.byKey(ValueKey<String>('assistant-skill-option-${pptSkill.key}')),
);
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-skill-picker-popover')),
findsOneWidget,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${pptSkill.key}'),
),
findsOneWidget,
);
await tester.tapAt(const Offset(24, 24));
await _pumpForUiSync(tester);
expect(
find.byKey(const Key('assistant-skill-picker-popover')),
findsNothing,
);
controller.initializeAssistantThreadContext(
'draft:task-b',
title: 'Task B',
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
);
await tester.runAsync(() async {
await controller.switchSession('draft:task-b');
});
await _pumpForUiSync(tester);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${browserSkill.key}'),
),
findsNothing,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${pptSkill.key}'),
),
findsNothing,
);
await tester.tap(find.byKey(const Key('assistant-skill-picker-button')));
await _pumpForUiSync(tester);
await tester.tap(
find.byKey(
ValueKey<String>('assistant-skill-option-${wordxSkill.key}'),
),
);
await _pumpForUiSync(tester);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${wordxSkill.key}'),
),
findsOneWidget,
);
await tester.runAsync(() async {
await controller.switchSession('main');
});
await _pumpForUiSync(tester);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${browserSkill.key}'),
),
findsOneWidget,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${pptSkill.key}'),
),
findsOneWidget,
);
expect(
find.byKey(
ValueKey<String>('assistant-selected-skill-${wordxSkill.key}'),
),
findsNothing,
);
},
);
testWidgets('AssistantPage hides gated attachment and multi-agent actions', (
WidgetTester tester,
) async {
@ -1231,7 +1007,6 @@ Future<AppController> _createControllerWithThreadRecords({
WidgetTester? tester,
required List<AssistantThreadRecord> records,
bool useFakeGatewayRuntime = false,
List<String>? singleAgentLocalSkillScanRoots,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
@ -1289,7 +1064,6 @@ Future<AppController> _createControllerWithThreadRecords({
codex: _FakeCodexRuntime(),
)
: null,
singleAgentLocalSkillScanRoots: singleAgentLocalSkillScanRoots,
);
final stopwatch = Stopwatch()..start();
while (controller.initializing) {
@ -1305,34 +1079,11 @@ Future<AppController> _createControllerWithThreadRecords({
return controller;
}
Future<void> _writeSkill(
Directory root,
String folderName, {
required String skillName,
required String description,
}) async {
final directory = Directory('${root.path}/$folderName');
await directory.create(recursive: true);
await File(
'${directory.path}/SKILL.md',
).writeAsString('---\nname: $skillName\ndescription: $description\n---\n');
}
Future<void> _pumpForUiSync(WidgetTester tester) async {
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
}
Future<void> _waitForCondition(bool Function() predicate) async {
final deadline = DateTime.now().add(const Duration(seconds: 20));
while (!predicate()) {
if (DateTime.now().isAfter(deadline)) {
fail('Timed out waiting for condition');
}
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
class _FakeGatewayRuntime extends GatewayRuntime {
_FakeGatewayRuntime({required super.store})
: super(identityStore: DeviceIdentityStore(store));

View File

@ -1,894 +0,0 @@
@TestOn('vm')
library;
import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:xworkmate/app/app_controller.dart';
import 'package:xworkmate/runtime/runtime_models.dart';
import 'package:xworkmate/runtime/secure_config_store.dart';
void main() {
test(
'AppController scans shared single-agent global skills on startup and shares them across providers',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-single-agent-shared-skills-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final systemRoot = Directory('${tempDirectory.path}/etc-skills');
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
final codexRoot = Directory('${tempDirectory.path}/codex-skills');
final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills');
await _writeSkill(
systemRoot,
'analysis',
skillName: 'Analysis',
description: 'System version should be overridden',
);
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser Automation',
description: 'Shared browser skill',
);
await _writeSkill(
codexRoot,
'ppt',
skillName: 'PPT',
description: 'Presentation skill',
);
await _writeSkill(
workbuddyRoot,
'analysis',
skillName: 'Analysis',
description: 'WorkBuddy version wins',
);
await _writeSkill(
workbuddyRoot,
'cicd-audit',
skillName: 'CICD Audit',
description: 'Pipeline audit skill',
);
final controller = AppController(
store: await _createStore(tempDirectory.path),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.claude,
],
singleAgentLocalSkillScanRoots: <String>[
systemRoot.path,
agentsRoot.path,
codexRoot.path,
workbuddyRoot.path,
],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
await _waitFor(
() =>
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.length ==
4,
);
final firstSessionKey = controller.currentSessionKey;
expect(
controller
.assistantImportedSkillsForSession(firstSessionKey)
.map((skill) => skill.label),
containsAll(const <String>[
'Analysis',
'Browser Automation',
'PPT',
'CICD Audit',
]),
);
final analysisSkill = controller
.assistantImportedSkillsForSession(firstSessionKey)
.firstWhere((skill) => skill.label == 'Analysis');
expect(analysisSkill.description, 'WorkBuddy version wins');
expect(analysisSkill.source, 'workbuddy');
expect(analysisSkill.scope, 'user');
await controller.toggleAssistantSkillForSession(
firstSessionKey,
controller
.assistantImportedSkillsForSession(firstSessionKey)
.firstWhere((skill) => skill.label == 'PPT')
.key,
);
expect(
controller
.assistantSelectedSkillsForSession(firstSessionKey)
.map((skill) => skill.label),
const <String>['PPT'],
);
await controller.setSingleAgentProvider(SingleAgentProvider.claude);
await _waitFor(
() =>
controller
.assistantImportedSkillsForSession(firstSessionKey)
.length ==
4,
);
expect(
controller
.assistantSelectedSkillsForSession(firstSessionKey)
.map((skill) => skill.label),
const <String>['PPT'],
);
},
);
test(
'AppController hot reloads authorized skill directories from settings.yaml',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-skill-directory-hot-reload-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser',
description: 'Browser tasks',
);
final store = await _createStore(tempDirectory.path);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.where((skill) => skill.label == 'Browser'),
isEmpty,
);
final updatedSnapshot =
_singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith(
authorizedSkillDirectories: <AuthorizedSkillDirectory>[
AuthorizedSkillDirectory(path: agentsRoot.path),
],
);
final settingsFile = File('${tempDirectory.path}/config/settings.yaml');
await settingsFile.writeAsString(
encodeYamlDocument(updatedSnapshot.toJson()),
flush: true,
);
await _waitFor(
() => controller.authorizedSkillDirectories
.map((item) => item.path)
.contains(agentsRoot.path),
);
await _waitFor(
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.any((skill) => skill.label == 'Browser'),
);
expect(
controller.authorizedSkillDirectories.map((item) => item.path),
<String>[agentsRoot.path],
);
},
);
test(
'AppController keeps thread-bound skills isolated and restores them after restart',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-thread-isolation-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
final codexRoot = Directory('${tempDirectory.path}/codex-skills');
final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills');
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser',
description: 'Browser tasks',
);
await _writeSkill(
codexRoot,
'ppt',
skillName: 'PPT',
description: 'Presentation tasks',
);
await _writeSkill(
workbuddyRoot,
'wordx',
skillName: 'WordX',
description: 'Document tasks',
);
await _writeSkill(
workbuddyRoot,
'cicd-audit',
skillName: 'CICD Audit',
description: 'Pipeline tasks',
);
Future<SecureConfigStore> createStore() {
return _createStore(tempDirectory.path);
}
Future<AppController> createController() async {
return AppController(
store: await createStore(),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.claude,
],
singleAgentLocalSkillScanRoots: <String>[
agentsRoot.path,
codexRoot.path,
workbuddyRoot.path,
],
);
}
final controller = await createController();
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await _waitFor(
() =>
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.length ==
4,
);
final taskA = controller.currentSessionKey;
await controller.toggleAssistantSkillForSession(
taskA,
controller
.assistantImportedSkillsForSession(taskA)
.firstWhere((skill) => skill.label == 'PPT')
.key,
);
controller.initializeAssistantThreadContext(
'draft:task-b',
title: 'Task B',
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
singleAgentProvider: SingleAgentProvider.claude,
);
await controller.switchSession('draft:task-b');
await _waitFor(
() =>
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.length ==
4,
);
final taskB = controller.currentSessionKey;
await controller.toggleAssistantSkillForSession(
taskB,
controller
.assistantImportedSkillsForSession(taskB)
.firstWhere((skill) => skill.label == 'WordX')
.key,
);
controller.initializeAssistantThreadContext(
'draft:task-c',
title: 'Task C',
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
);
await controller.switchSession('draft:task-c');
await _waitFor(
() =>
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.length ==
4,
);
final taskC = controller.currentSessionKey;
await controller.toggleAssistantSkillForSession(
taskC,
controller
.assistantImportedSkillsForSession(taskC)
.firstWhere((skill) => skill.label == 'Browser')
.key,
);
expect(
controller
.assistantSelectedSkillsForSession(taskA)
.map((skill) => skill.label),
const <String>['PPT'],
);
expect(
controller
.assistantSelectedSkillsForSession(taskB)
.map((skill) => skill.label),
const <String>['WordX'],
);
expect(
controller
.assistantSelectedSkillsForSession(taskC)
.map((skill) => skill.label),
const <String>['Browser'],
);
controller.dispose();
final restoredController = await createController();
addTearDown(restoredController.dispose);
await _waitFor(() => !restoredController.initializing);
await restoredController.switchSession(taskA);
await _waitFor(
() =>
restoredController
.assistantImportedSkillsForSession(taskA)
.length ==
4,
);
expect(
restoredController
.assistantSelectedSkillsForSession(taskA)
.map((skill) => skill.label),
const <String>['PPT'],
);
await restoredController.switchSession(taskB);
await _waitFor(
() =>
restoredController
.assistantImportedSkillsForSession(taskB)
.length ==
4,
);
expect(
restoredController
.assistantSelectedSkillsForSession(taskB)
.map((skill) => skill.label),
const <String>['WordX'],
);
await restoredController.switchSession(taskC);
await _waitFor(
() =>
restoredController
.assistantImportedSkillsForSession(taskC)
.length ==
4,
);
expect(
restoredController
.assistantSelectedSkillsForSession(taskC)
.map((skill) => skill.label),
const <String>['Browser'],
);
},
);
test(
'AppController restores shared global skills cache on restart',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-single-agent-skills-cache-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
final codexRoot = Directory('${tempDirectory.path}/codex-skills');
await _writeSkill(
agentsRoot,
'browser',
skillName: 'Browser',
description: 'Browser tasks',
);
await _writeSkill(
codexRoot,
'ppt',
skillName: 'PPT',
description: 'Presentation tasks',
);
Future<SecureConfigStore> createStore() {
return _createStore(tempDirectory.path);
}
final firstStore = await createStore();
final controller = AppController(
store: firstStore,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentLocalSkillScanRoots: <String>[
agentsRoot.path,
codexRoot.path,
],
);
await _waitFor(() => !controller.initializing);
await controller.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await _waitFor(
() =>
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.length ==
2,
);
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.map((item) => item.label),
containsAll(const <String>['Browser', 'PPT']),
);
final cacheFile = await firstStore.supportFile(
'cache/single-agent-local-skills.json',
);
expect(cacheFile, isNotNull);
await _waitFor(() => cacheFile != null && cacheFile.existsSync());
controller.dispose();
if (await agentsRoot.exists()) {
await agentsRoot.delete(recursive: true);
}
if (await codexRoot.exists()) {
await codexRoot.delete(recursive: true);
}
final restoredController = AppController(
store: await createStore(),
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentLocalSkillScanRoots: <String>[
agentsRoot.path,
codexRoot.path,
],
);
addTearDown(restoredController.dispose);
await _waitFor(() => !restoredController.initializing);
await restoredController.setAssistantExecutionTarget(
AssistantExecutionTarget.singleAgent,
);
await _waitFor(
() =>
restoredController
.assistantImportedSkillsForSession(
restoredController.currentSessionKey,
)
.length ==
2,
);
expect(
restoredController
.assistantImportedSkillsForSession(
restoredController.currentSessionKey,
)
.map((item) => item.label),
containsAll(const <String>['Browser', 'PPT']),
);
},
);
test(
'AppController uses thread workspaceRef for repo-local fallback',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-workspace-ref-skills-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
await _writeSkill(
Directory('${workspaceRoot.path}/.codex/skills'),
'workspace-only',
skillName: 'Workspace Only Skill',
description: 'Repo-local fallback',
);
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async =>
'${tempDirectory.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
await store.initialize();
await store.saveSettingsSnapshot(
_singleAgentTestSettings(
workspacePath: '${tempDirectory.path}/unused-default-workspace',
),
);
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'main',
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: '',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: workspaceRoot.path,
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentLocalSkillScanRoots: const <String>[],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await _waitFor(
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.any((item) => item.label == 'Workspace Only Skill'),
);
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.map((item) => item.label),
contains('Workspace Only Skill'),
);
},
);
test(
'AppController keeps global roots ahead of repo-local fallback and only fills missing skills',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-global-overrides-repo-local-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills');
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
await _writeSkill(
workbuddyRoot,
'shared-skill',
skillName: 'Shared Skill',
description: 'Global wins',
);
await _writeSkill(
workbuddyRoot,
'global-only',
skillName: 'Global Only',
description: 'Only from global',
);
await _writeSkill(
Directory('${workspaceRoot.path}/.codex/skills'),
'shared-skill',
skillName: 'Shared Skill',
description: 'Repo-local should not override',
);
await _writeSkill(
Directory('${workspaceRoot.path}/.codex/skills'),
'workspace-only',
skillName: 'Workspace Only',
description: 'Only from repo-local',
);
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async =>
'${tempDirectory.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
await store.initialize();
await store.saveSettingsSnapshot(
_singleAgentTestSettings(workspacePath: tempDirectory.path),
);
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'main',
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: '',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: workspaceRoot.path,
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentLocalSkillScanRoots: <String>[workbuddyRoot.path],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await _waitFor(
() =>
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.length ==
3,
);
final sharedSkill = controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.firstWhere((item) => item.label == 'Shared Skill');
expect(sharedSkill.description, 'Global wins');
expect(
controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.map((item) => item.label),
containsAll(const <String>[
'Shared Skill',
'Global Only',
'Workspace Only',
]),
);
},
);
test(
'AppController scans repo-local skills directories in fixed order and skips missing roots',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-repo-local-order-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
await _writeSkill(
Directory('${workspaceRoot.path}/.agents/skills'),
'shared-skill',
skillName: 'Shared Skill',
description: 'Agents version',
);
await _writeSkill(
Directory('${workspaceRoot.path}/.codex/skills'),
'shared-skill',
skillName: 'Shared Skill',
description: 'Codex version wins',
);
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async =>
'${tempDirectory.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
await store.initialize();
await store.saveSettingsSnapshot(
_singleAgentTestSettings(workspacePath: tempDirectory.path),
);
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'main',
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: '',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: workspaceRoot.path,
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentLocalSkillScanRoots: const <String>[],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await _waitFor(
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.isNotEmpty,
);
final sharedSkill = controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.firstWhere((item) => item.label == 'Shared Skill');
expect(sharedSkill.description, 'Codex version wins');
expect(sharedSkill.source, 'codex');
},
);
test(
'AppController can return empty skills when neither global nor repo-local roots exist',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final tempDirectory = await Directory.systemTemp.createTemp(
'xworkmate-empty-relative-skills-',
);
addTearDown(() async {
if (await tempDirectory.exists()) {
try {
await tempDirectory.delete(recursive: true);
} catch (_) {}
}
});
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async =>
'${tempDirectory.path}/settings.sqlite3',
fallbackDirectoryPathResolver: () async => tempDirectory.path,
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
);
await store.initialize();
await store.saveSettingsSnapshot(
_singleAgentTestSettings(
workspacePath: '${tempDirectory.path}/missing-workspace',
),
);
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'main',
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: '',
archived: false,
executionTarget: AssistantExecutionTarget.singleAgent,
messageViewMode: AssistantMessageViewMode.rendered,
workspaceRef: '${tempDirectory.path}/missing-workspace',
workspaceRefKind: WorkspaceRefKind.localPath,
),
]);
final controller = AppController(
store: store,
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
SingleAgentProvider.codex,
],
singleAgentLocalSkillScanRoots: const <String>[],
);
addTearDown(controller.dispose);
await _waitFor(() => !controller.initializing);
await _waitFor(
() => controller
.assistantImportedSkillsForSession(controller.currentSessionKey)
.isEmpty,
);
expect(
controller.assistantImportedSkillsForSession(
controller.currentSessionKey,
),
isEmpty,
);
},
);
}
Future<void> _writeSkill(
Directory root,
String folderName, {
required String description,
required String skillName,
}) async {
final directory = Directory('${root.path}/$folderName');
await directory.create(recursive: true);
await File(
'${directory.path}/SKILL.md',
).writeAsString('---\nname: $skillName\ndescription: $description\n---\n');
}
Future<void> _waitFor(bool Function() predicate) async {
final deadline = DateTime.now().add(const Duration(seconds: 20));
while (!predicate()) {
if (DateTime.now().isAfter(deadline)) {
fail('Timed out waiting for condition');
}
await Future<void>.delayed(const Duration(milliseconds: 20));
}
}
Future<SecureConfigStore> _createStore(String rootPath) async {
final store = SecureConfigStore(
enableSecureStorage: false,
databasePathResolver: () async => '$rootPath/settings.sqlite3',
fallbackDirectoryPathResolver: () async => rootPath,
defaultSupportDirectoryPathResolver: () async => rootPath,
);
await store.initialize();
await store.saveSettingsSnapshot(
_singleAgentTestSettings(workspacePath: rootPath),
);
return store;
}
SettingsSnapshot _singleAgentTestSettings({required String workspacePath}) {
final defaults = SettingsSnapshot.defaults();
return defaults.copyWith(
gatewayProfiles: replaceGatewayProfileAt(
replaceGatewayProfileAt(
defaults.gatewayProfiles,
kGatewayLocalProfileIndex,
defaults.primaryLocalGatewayProfile.copyWith(
host: '127.0.0.1',
port: 9,
tls: false,
),
),
kGatewayRemoteProfileIndex,
defaults.primaryRemoteGatewayProfile.copyWith(
host: '127.0.0.1',
port: 9,
tls: false,
),
),
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
workspacePath: workspacePath,
);
}

View File

@ -29,7 +29,6 @@ Future<AppController> createTestController(
WidgetTester tester, {
DesktopPlatformService? desktopPlatformService,
UiFeatureManifest? uiFeatureManifest,
List<String>? singleAgentLocalSkillScanRoots,
}) async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final testRoot =
@ -42,7 +41,6 @@ Future<AppController> createTestController(
),
desktopPlatformService: desktopPlatformService,
uiFeatureManifest: uiFeatureManifest,
singleAgentLocalSkillScanRoots: singleAgentLocalSkillScanRoots,
);
addTearDown(controller.dispose);
await tester.pump(const Duration(milliseconds: 100));