From 69cb4431ee693806010e25b6c876d032b418647d Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Wed, 25 Mar 2026 21:55:37 +0800 Subject: [PATCH] feat(assistant): rebuild desktop local skills loading rules --- lib/app/app_controller_desktop.dart | 462 ++++++++++- test/features/assistant_page_suite.dart | 250 ++++++ .../app_controller_thread_skills_suite.dart | 783 ++++++++++++++++++ test/test_support.dart | 3 + 4 files changed, 1497 insertions(+), 1 deletion(-) create mode 100644 test/runtime/app_controller_thread_skills_suite.dart diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 6a4123f7..a5cd31b6 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -40,13 +40,33 @@ 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; + + _SingleAgentSkillScanRoot copyWith({ + String? path, + String? source, + String? scope, + String? bookmark, + }) { + return _SingleAgentSkillScanRoot( + path: path ?? this.path, + source: source ?? this.source, + scope: scope ?? this.scope, + bookmark: bookmark ?? this.bookmark, + ); + } } +const String _singleAgentLocalSkillsCacheRelativePath = + 'cache/single-agent-local-skills.json'; +const int _singleAgentLocalSkillsCacheSchemaVersion = 3; + class AppController extends ChangeNotifier { static const List<_SingleAgentSkillScanRoot> _defaultSingleAgentGlobalSkillScanRoots = <_SingleAgentSkillScanRoot>[ @@ -71,12 +91,31 @@ 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? singleAgentSharedSkillScanRootOverrides, List? availableSingleAgentProvidersOverride, ArisBundleRepository? arisBundleRepository, SingleAgentRunner? singleAgentRunner, @@ -121,6 +160,8 @@ class AppController extends ChangeNotifier { desktopPlatformService ?? createDesktopPlatformService(); _skillDirectoryAccessService = skillDirectoryAccessService ?? createSkillDirectoryAccessService(); + _singleAgentSharedSkillScanRootOverrides = + singleAgentSharedSkillScanRootOverrides?.toList(growable: false); _gatewayAcpClient = GatewayAcpClient( endpointResolver: _resolveGatewayAcpEndpoint, ); @@ -164,6 +205,7 @@ class AppController extends ChangeNotifier { late final DerivedTasksController _tasksController; late final DesktopPlatformService _desktopPlatformService; late final SkillDirectoryAccessService _skillDirectoryAccessService; + late final List? _singleAgentSharedSkillScanRootOverrides; late final GatewayAcpClient _gatewayAcpClient; late final DirectSingleAgentAppServerClient _singleAgentAppServerClient; late final List? _availableSingleAgentProvidersOverride; @@ -188,6 +230,10 @@ class AppController extends ChangeNotifier { {}; final DesktopThreadArtifactService _threadArtifactService = DesktopThreadArtifactService(); + List _singleAgentSharedImportedSkills = + const []; + bool _singleAgentLocalSkillsHydrated = false; + Future? _singleAgentSharedSkillsRefreshInFlight; final Map _aiGatewayStreamingClients = {}; final Set _aiGatewayPendingSessionKeys = {}; @@ -224,6 +270,36 @@ class AppController extends ChangeNotifier { Future _assistantThreadPersistQueue = Future.value(); Future _settingsObservationQueue = Future.value(); + List<_SingleAgentSkillScanRoot> get _singleAgentSharedSkillScanRoots { + final configuredRoots = + (_singleAgentSharedSkillScanRootOverrides?.map( + _singleAgentSharedSkillScanRootFromOverride, + ))?.toList(growable: false) ?? + _defaultSingleAgentGlobalSkillScanRoots; + final authorizedByPath = { + for (final directory in settings.authorizedSkillDirectories) + normalizeAuthorizedSkillDirectoryPath(directory.path): directory, + }; + final resolvedRoots = <_SingleAgentSkillScanRoot>[]; + final seenPaths = {}; + for (final root in configuredRoots) { + final resolvedPath = _resolveSingleAgentSkillRootPath(root.path); + if (resolvedPath.isEmpty || !seenPaths.add(resolvedPath)) { + continue; + } + final authorizedDirectory = authorizedByPath.remove(resolvedPath); + resolvedRoots.add( + root.copyWith(bookmark: authorizedDirectory?.bookmark ?? ''), + ); + } + for (final directory in authorizedByPath.values) { + resolvedRoots.add( + _singleAgentSharedSkillScanRootFromAuthorizedDirectory(directory), + ); + } + return resolvedRoots; + } + WorkspaceDestination get destination => _destination; UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest; AppCapabilities get capabilities => @@ -2133,7 +2209,9 @@ class AppController extends ChangeNotifier { final previousImported = _assistantThreadRecords[normalizedSessionKey]?.importedSkills ?? const []; - const fallbackSkills = []; + final fallbackSkills = await _singleAgentLocalFallbackSkillsForSession( + normalizedSessionKey, + ); final provider = singleAgentResolvedProviderForSession(normalizedSessionKey) ?? currentSingleAgentResolvedProvider; @@ -2193,6 +2271,7 @@ class AppController extends ChangeNotifier { Future refreshSingleAgentLocalSkillsForSession( String sessionKey, ) async { + await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true); await refreshSingleAgentSkillsForSession(sessionKey); } @@ -2861,6 +2940,7 @@ class AppController extends ChangeNotifier { return; } _disposed = true; + unawaited(_persistSharedSingleAgentLocalSkillsCache()); _runtimeEventsSubscription?.cancel(); _detachChildListeners(); _runtimeCoordinator.dispose(); @@ -2886,6 +2966,7 @@ class AppController extends ChangeNotifier { try { await _settingsController.initialize(); _restoreAssistantThreads(await _store.loadAssistantThreadRecords()); + await _restoreSharedSingleAgentLocalSkillsCache(); if (_disposed) { return; } @@ -2946,6 +3027,7 @@ class AppController extends ChangeNotifier { ); await _restoreInitialAssistantSessionSelection(); await _ensureActiveAssistantThread(); + unawaited(_startupRefreshSharedSingleAgentLocalSkillsCache()); if (isSingleAgentMode) { await refreshSingleAgentSkillsForSession(currentSessionKey); } @@ -3095,6 +3177,22 @@ 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 _persistSettingsSnapshot(SettingsSnapshot snapshot) async { final sanitized = _sanitizeFeatureFlagSettings( _sanitizeMultiAgentSettings( @@ -3141,6 +3239,16 @@ 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(); } @@ -4143,9 +4251,210 @@ class AppController extends ChangeNotifier { return target.promptValue; } + Future> _scanSingleAgentSkillEntries( + List<_SingleAgentSkillScanRoot> roots, { + String workspaceRef = '', + }) async { + final dedupedByName = {}; + 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> _scanSingleAgentSharedSkillEntries() { + return _scanSingleAgentSkillEntries(_singleAgentSharedSkillScanRoots); + } + + Future> _scanSingleAgentWorkspaceSkillEntries( + String sessionKey, + ) { + if (assistantWorkspaceRefKindForSession(sessionKey) != + WorkspaceRefKind.localPath) { + return Future>.value( + const [], + ); + } + return _scanSingleAgentSkillEntries( + _defaultSingleAgentWorkspaceSkillScanRoots, + workspaceRef: assistantWorkspaceRefForSession(sessionKey), + ); + } + + _SingleAgentSkillScanRoot _singleAgentSharedSkillScanRootFromOverride( + String rawPath, + ) { + final normalizedPath = rawPath.trim(); + final lowered = normalizedPath.toLowerCase(); + return _SingleAgentSkillScanRoot( + path: normalizedPath, + source: _sourceForSkillRootPath(lowered), + scope: normalizedPath.startsWith('/etc/') ? 'system' : 'user', + ); + } + + _SingleAgentSkillScanRoot _singleAgentSharedSkillScanRootFromAuthorizedDirectory( + 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 _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 = [ + 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 records) { _assistantThreadRecords.clear(); _assistantThreadMessages.clear(); + _singleAgentSharedImportedSkills = const []; + _singleAgentLocalSkillsHydrated = false; final archivedKeys = settings.assistantArchivedTaskKeys .map(_normalizedAssistantSessionKey) .toSet(); @@ -4198,6 +4507,147 @@ class AppController extends ChangeNotifier { } } + Future _refreshSharedSingleAgentLocalSkillsCache({ + required bool forceRescan, + }) async { + if (!forceRescan && _singleAgentLocalSkillsHydrated) { + return; + } + if (!forceRescan && await _restoreSharedSingleAgentLocalSkillsCache()) { + return; + } + final existingRefresh = _singleAgentSharedSkillsRefreshInFlight; + if (existingRefresh != null) { + await existingRefresh; + if (!forceRescan) { + return; + } + } + late final Future refreshFuture; + refreshFuture = () async { + final sharedSkills = await _scanSingleAgentSharedSkillEntries(); + _singleAgentSharedImportedSkills = sharedSkills; + _singleAgentLocalSkillsHydrated = true; + await _persistSharedSingleAgentLocalSkillsCache(); + }(); + _singleAgentSharedSkillsRefreshInFlight = refreshFuture; + try { + await refreshFuture; + } finally { + if (identical(_singleAgentSharedSkillsRefreshInFlight, refreshFuture)) { + _singleAgentSharedSkillsRefreshInFlight = null; + } + } + } + + Future ensureSharedSingleAgentLocalSkillsLoaded() async { + if (_singleAgentLocalSkillsHydrated) { + return; + } + await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: false); + } + + Future _startupRefreshSharedSingleAgentLocalSkillsCache() async { + await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true); + if (_disposed) { + return; + } + if (assistantExecutionTargetForSession(currentSessionKey) == + AssistantExecutionTarget.singleAgent) { + await refreshSingleAgentSkillsForSession(currentSessionKey); + return; + } + _notifyIfActive(); + } + + Future> + _singleAgentLocalFallbackSkillsForSession(String sessionKey) async { + final workspaceSkills = await _scanSingleAgentWorkspaceSkillEntries( + sessionKey, + ); + return _mergeSingleAgentLocalSkills( + globalSkills: _singleAgentSharedImportedSkills, + workspaceSkills: workspaceSkills, + ); + } + + List _mergeSingleAgentLocalSkills({ + required List globalSkills, + required List workspaceSkills, + }) { + final merged = {}; + 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 _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(), + ), + ) + .where((item) => item.key.trim().isNotEmpty && item.label.isNotEmpty) + .toList(growable: false); + if (skills.isEmpty) { + _singleAgentSharedImportedSkills = const []; + _singleAgentLocalSkillsHydrated = false; + return false; + } + _singleAgentSharedImportedSkills = skills; + _singleAgentLocalSkillsHydrated = true; + return true; + } catch (_) { + return false; + } + } + + Future _persistSharedSingleAgentLocalSkillsCache() async { + try { + await _store.saveSupportJson( + _singleAgentLocalSkillsCacheRelativePath, + { + '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 _replaceSingleAgentThreadSkills( String sessionKey, List importedSkills, @@ -5024,6 +5474,16 @@ 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(); } diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index c18afa11..9d8a353f 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -550,6 +550,230 @@ 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 [], + useFakeGatewayRuntime: true, + singleAgentSharedSkillScanRootOverrides: [ + 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('assistant-skill-option-${browserSkill.key}'), + ), + ); + await _pumpForUiSync(tester); + expect( + find.byKey(const Key('assistant-skill-picker-popover')), + findsOneWidget, + ); + expect( + find.byKey( + ValueKey('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('assistant-skill-option-${pptSkill.key}')), + ); + await _pumpForUiSync(tester); + expect( + find.byKey(const Key('assistant-skill-picker-popover')), + findsOneWidget, + ); + expect( + find.byKey( + ValueKey('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('assistant-selected-skill-${browserSkill.key}'), + ), + findsNothing, + ); + expect( + find.byKey( + ValueKey('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('assistant-skill-option-${wordxSkill.key}'), + ), + ); + await _pumpForUiSync(tester); + + expect( + find.byKey( + ValueKey('assistant-selected-skill-${wordxSkill.key}'), + ), + findsOneWidget, + ); + + await tester.runAsync(() async { + await controller.switchSession('main'); + }); + await _pumpForUiSync(tester); + + expect( + find.byKey( + ValueKey('assistant-selected-skill-${browserSkill.key}'), + ), + findsOneWidget, + ); + expect( + find.byKey( + ValueKey('assistant-selected-skill-${pptSkill.key}'), + ), + findsOneWidget, + ); + expect( + find.byKey( + ValueKey('assistant-selected-skill-${wordxSkill.key}'), + ), + findsNothing, + ); + }, + ); + testWidgets('AssistantPage hides gated attachment and multi-agent actions', ( WidgetTester tester, ) async { @@ -1007,6 +1231,7 @@ Future _createControllerWithThreadRecords({ WidgetTester? tester, required List records, bool useFakeGatewayRuntime = false, + List? singleAgentSharedSkillScanRootOverrides, }) async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( @@ -1064,6 +1289,8 @@ Future _createControllerWithThreadRecords({ codex: _FakeCodexRuntime(), ) : null, + singleAgentSharedSkillScanRootOverrides: + singleAgentSharedSkillScanRootOverrides, ); final stopwatch = Stopwatch()..start(); while (controller.initializing) { @@ -1079,11 +1306,34 @@ Future _createControllerWithThreadRecords({ return controller; } +Future _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 _pumpForUiSync(WidgetTester tester) async { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); } +Future _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.delayed(const Duration(milliseconds: 20)); + } +} + class _FakeGatewayRuntime extends GatewayRuntime { _FakeGatewayRuntime({required super.store}) : super(identityStore: DeviceIdentityStore(store)); diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart new file mode 100644 index 00000000..87097dc7 --- /dev/null +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -0,0 +1,783 @@ +@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 public roots on startup and shares them across providers', + () async { + SharedPreferences.setMockInitialValues({}); + 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.codex, + SingleAgentProvider.claude, + ], + singleAgentSharedSkillScanRootOverrides: [ + 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 [ + '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 ['PPT'], + ); + + await controller.setSingleAgentProvider(SingleAgentProvider.claude); + await _waitFor( + () => + controller + .assistantImportedSkillsForSession(firstSessionKey) + .length == + 4, + ); + expect( + controller + .assistantSelectedSkillsForSession(firstSessionKey) + .map((skill) => skill.label), + const ['PPT'], + ); + }, + ); + + test( + 'AppController hot reloads authorized custom skill directories from settings.yaml', + () async { + SharedPreferences.setMockInitialValues({}); + 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.codex, + ], + singleAgentSharedSkillScanRootOverrides: const [], + ); + 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(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), + [agentsRoot.path], + ); + }, + ); + + test( + 'AppController keeps thread-bound skills isolated and restores them after restart', + () async { + SharedPreferences.setMockInitialValues({}); + 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 createStore() { + return _createStore(tempDirectory.path); + } + + Future createController() async { + return AppController( + store: await createStore(), + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + SingleAgentProvider.claude, + ], + singleAgentSharedSkillScanRootOverrides: [ + 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 ['PPT'], + ); + expect( + controller + .assistantSelectedSkillsForSession(taskB) + .map((skill) => skill.label), + const ['WordX'], + ); + expect( + controller + .assistantSelectedSkillsForSession(taskC) + .map((skill) => skill.label), + const ['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 ['PPT'], + ); + await restoredController.switchSession(taskB); + await _waitFor( + () => + restoredController + .assistantImportedSkillsForSession(taskB) + .length == + 4, + ); + expect( + restoredController + .assistantSelectedSkillsForSession(taskB) + .map((skill) => skill.label), + const ['WordX'], + ); + await restoredController.switchSession(taskC); + await _waitFor( + () => + restoredController + .assistantImportedSkillsForSession(taskC) + .length == + 4, + ); + expect( + restoredController + .assistantSelectedSkillsForSession(taskC) + .map((skill) => skill.label), + const ['Browser'], + ); + }, + ); + + test( + 'AppController uses thread workspaceRef for repo-local fallback', + () async { + SharedPreferences.setMockInitialValues({}); + 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( + sessionKey: 'main', + messages: const [], + 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.codex, + ], + singleAgentSharedSkillScanRootOverrides: const [], + ); + 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 public roots ahead of repo-local fallback and only fills missing skills', + () async { + SharedPreferences.setMockInitialValues({}); + 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( + sessionKey: 'main', + messages: const [], + 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.codex, + ], + singleAgentSharedSkillScanRootOverrides: [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 [ + 'Shared Skill', + 'Global Only', + 'Workspace Only', + ]), + ); + }, + ); + + test( + 'AppController scans repo-local skills directories in fixed order and skips missing roots', + () async { + SharedPreferences.setMockInitialValues({}); + 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( + sessionKey: 'main', + messages: const [], + 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.codex, + ], + singleAgentSharedSkillScanRootOverrides: const [], + ); + 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 public nor repo-local roots exist', + () async { + SharedPreferences.setMockInitialValues({}); + 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( + sessionKey: 'main', + messages: const [], + 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.codex, + ], + singleAgentSharedSkillScanRootOverrides: const [], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + await _waitFor( + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .isEmpty, + ); + + expect( + controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ), + isEmpty, + ); + }, + ); +} + +Future _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 _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.delayed(const Duration(milliseconds: 20)); + } +} + +Future _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, + ); +} diff --git a/test/test_support.dart b/test/test_support.dart index 9bec7f4b..a98e84f7 100644 --- a/test/test_support.dart +++ b/test/test_support.dart @@ -29,6 +29,7 @@ Future createTestController( WidgetTester tester, { DesktopPlatformService? desktopPlatformService, UiFeatureManifest? uiFeatureManifest, + List? singleAgentSharedSkillScanRootOverrides, }) async { SharedPreferences.setMockInitialValues({}); final testRoot = @@ -41,6 +42,8 @@ Future createTestController( ), desktopPlatformService: desktopPlatformService, uiFeatureManifest: uiFeatureManifest, + singleAgentSharedSkillScanRootOverrides: + singleAgentSharedSkillScanRootOverrides, ); addTearDown(controller.dispose); await tester.pump(const Duration(milliseconds: 100));