diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index b31d9cf9..deded7a6 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -34,7 +34,65 @@ import '../runtime/single_agent_runner.dart'; enum CodexCooperationState { notStarted, bridgeOnly, registered } +class _SingleAgentSkillScanRoot { + const _SingleAgentSkillScanRoot({ + required this.path, + required this.source, + required this.scope, + }); + + final String path; + final String source; + final String scope; +} + +const String _singleAgentLocalSkillsCacheRelativePath = + 'cache/single-agent-local-skills.json'; +const int _singleAgentLocalSkillsCacheSchemaVersion = 2; + class AppController extends ChangeNotifier { + static const List<_SingleAgentSkillScanRoot> + _defaultSingleAgentGlobalSkillScanRoots = <_SingleAgentSkillScanRoot>[ + _SingleAgentSkillScanRoot( + path: '/etc/skills', + source: 'system', + scope: 'system', + ), + _SingleAgentSkillScanRoot( + path: '~/.agents/skills', + source: 'agents', + scope: 'user', + ), + _SingleAgentSkillScanRoot( + path: '~/.codex/skills', + source: 'codex', + scope: 'user', + ), + _SingleAgentSkillScanRoot( + path: '~/.workbuddy/skills', + source: 'workbuddy', + 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, @@ -82,6 +140,10 @@ class AppController extends ChangeNotifier { _tasksController = DerivedTasksController(); _desktopPlatformService = desktopPlatformService ?? createDesktopPlatformService(); + _singleAgentLocalSkillScanRootOverrides = + (singleAgentLocalSkillScanRoots ?? + (_isFlutterTestEnvironment ? const [] : null)) + ?.toList(growable: false); _gatewayAcpClient = GatewayAcpClient( endpointResolver: _resolveGatewayAcpEndpoint, ); @@ -124,6 +186,7 @@ class AppController extends ChangeNotifier { late final DevicesController _devicesController; late final DerivedTasksController _tasksController; late final DesktopPlatformService _desktopPlatformService; + late final List? _singleAgentLocalSkillScanRootOverrides; late final GatewayAcpClient _gatewayAcpClient; late final DirectSingleAgentAppServerClient _singleAgentAppServerClient; late final List? _availableSingleAgentProvidersOverride; @@ -148,6 +211,9 @@ class AppController extends ChangeNotifier { {}; final DesktopThreadArtifactService _threadArtifactService = DesktopThreadArtifactService(); + List _singleAgentSharedImportedSkills = + const []; + bool _singleAgentLocalSkillsHydrated = false; final Map _aiGatewayStreamingClients = {}; final Set _aiGatewayPendingSessionKeys = {}; @@ -180,8 +246,17 @@ class AppController extends ChangeNotifier { String? _bootstrapError; StreamSubscription? _runtimeEventsSubscription; bool _disposed = false; + static bool get _isFlutterTestEnvironment => + Platform.environment.containsKey('FLUTTER_TEST'); Future _assistantThreadPersistQueue = Future.value(); + List<_SingleAgentSkillScanRoot> get _singleAgentGlobalSkillScanRoots => + (_singleAgentLocalSkillScanRootOverrides?.map( + _singleAgentGlobalSkillScanRootFromOverride, + )) + ?.toList(growable: false) ?? + _defaultSingleAgentGlobalSkillScanRoots; + WorkspaceDestination get destination => _destination; UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest; AppCapabilities get capabilities => @@ -2080,15 +2155,20 @@ class AppController extends ChangeNotifier { AssistantExecutionTarget.singleAgent) { return; } + if (!_singleAgentLocalSkillsHydrated) { + await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: false); + } final previousImported = _assistantThreadRecords[normalizedSessionKey]?.importedSkills ?? const []; - const emptySkills = []; + final fallbackSkills = await _singleAgentLocalFallbackSkillsForSession( + normalizedSessionKey, + ); final provider = singleAgentResolvedProviderForSession(normalizedSessionKey) ?? currentSingleAgentResolvedProvider; if (provider == null) { - await _replaceSingleAgentThreadSkills(normalizedSessionKey, emptySkills); + await _replaceSingleAgentThreadSkills(normalizedSessionKey, fallbackSkills); return; } try { @@ -2111,19 +2191,28 @@ class AppController extends ChangeNotifier { .toList(growable: false); await _replaceSingleAgentThreadSkills( normalizedSessionKey, - skills.isNotEmpty ? skills : emptySkills, + skills.isNotEmpty ? skills : fallbackSkills, ); } on GatewayAcpException catch (error) { if (_unsupportedAcpSkillsStatus(error)) { - await _replaceSingleAgentThreadSkills(normalizedSessionKey, emptySkills); + await _replaceSingleAgentThreadSkills( + normalizedSessionKey, + fallbackSkills, + ); return; } if (previousImported.isEmpty) { - await _replaceSingleAgentThreadSkills(normalizedSessionKey, emptySkills); + await _replaceSingleAgentThreadSkills( + normalizedSessionKey, + fallbackSkills, + ); } } catch (_) { if (previousImported.isEmpty) { - await _replaceSingleAgentThreadSkills(normalizedSessionKey, emptySkills); + await _replaceSingleAgentThreadSkills( + normalizedSessionKey, + fallbackSkills, + ); } } } @@ -2131,6 +2220,7 @@ class AppController extends ChangeNotifier { Future refreshSingleAgentLocalSkillsForSession( String sessionKey, ) async { + await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true); await refreshSingleAgentSkillsForSession(sessionKey); } @@ -2163,6 +2253,7 @@ class AppController extends ChangeNotifier { updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), ); _notifyIfActive(); + await _flushAssistantThreadPersistence(); } Future saveAssistantTaskTitle(String sessionKey, String title) async { @@ -2742,6 +2833,7 @@ class AppController extends ChangeNotifier { return; } _disposed = true; + unawaited(_persistSharedSingleAgentLocalSkillsCache()); _runtimeEventsSubscription?.cancel(); _detachChildListeners(); _runtimeCoordinator.dispose(); @@ -2767,6 +2859,7 @@ class AppController extends ChangeNotifier { try { await _settingsController.initialize(); _restoreAssistantThreads(await _store.loadAssistantThreadRecords()); + await _restoreSharedSingleAgentLocalSkillsCache(); if (_disposed) { return; } @@ -2826,6 +2919,7 @@ class AppController extends ChangeNotifier { ); await _restoreInitialAssistantSessionSelection(); await _ensureActiveAssistantThread(); + unawaited(_startupRefreshSharedSingleAgentLocalSkillsCache()); if (isSingleAgentMode) { await refreshSingleAgentSkillsForSession(currentSessionKey); } @@ -4021,9 +4115,170 @@ class AppController extends ChangeNotifier { return target.promptValue; } + Future> _scanSingleAgentSkillEntries( + List<_SingleAgentSkillScanRoot> roots, { + String workspaceRef = '', + }) async { + final dedupedByName = {}; + for (final rootSpec in roots) { + final resolvedRootPath = _resolveSingleAgentSkillRootPath( + rootSpec.path, + workspaceRef: workspaceRef, + ); + if (resolvedRootPath.isEmpty) { + continue; + } + 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; + } + } + final entries = dedupedByName.values.toList(growable: false); + entries.sort((left, right) => left.label.compareTo(right.label)); + return entries; + } + + Future> _scanSingleAgentGlobalSkillEntries() { + return _scanSingleAgentSkillEntries(_singleAgentGlobalSkillScanRoots); + } + + Future> _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', + ); + } + + 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(); @@ -4076,6 +4331,125 @@ class AppController extends ChangeNotifier { } } + Future _refreshSharedSingleAgentLocalSkillsCache({ + required bool forceRescan, + }) async { + if (!forceRescan && _singleAgentLocalSkillsHydrated) { + return; + } + if (!forceRescan && await _restoreSharedSingleAgentLocalSkillsCache()) { + return; + } + final availableSkills = await _scanSingleAgentGlobalSkillEntries(); + _singleAgentSharedImportedSkills = availableSkills; + _singleAgentLocalSkillsHydrated = true; + await _persistSharedSingleAgentLocalSkillsCache(); + } + + 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); + _singleAgentSharedImportedSkills = skills; + _singleAgentLocalSkillsHydrated = true; + return true; + } catch (_) { + return false; + } + } + + Future _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 _replaceSingleAgentThreadSkills( String sessionKey, List importedSkills, diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index a7a714bc..ae5453b0 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -563,6 +563,16 @@ void main() { ), ); 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); @@ -1171,6 +1181,7 @@ Future _createControllerWithThreadRecords({ enableSecureStorage: false, databasePathResolver: () async => '${tempDirectory.path}/settings.db', fallbackDirectoryPathResolver: () async => tempDirectory.path, + defaultSupportDirectoryPathResolver: () async => tempDirectory.path, ); addTearDown(() async { if (await tempDirectory.exists()) { @@ -1206,6 +1217,7 @@ Future _createControllerWithThreadRecords({ ), assistantExecutionTarget: AssistantExecutionTarget.singleAgent, defaultModel: 'qwen2.5-coder:latest', + workspacePath: tempDirectory.path, ), ); await store.saveAssistantThreadRecords(records); @@ -1251,6 +1263,16 @@ Future _pumpForUiSync(WidgetTester tester) async { 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..f9f8792e --- /dev/null +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -0,0 +1,809 @@ +@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({}); + 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, + ], + singleAgentLocalSkillScanRoots: [ + 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 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, + ], + singleAgentLocalSkillScanRoots: [ + 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 restores shared global skills cache on restart', + () async { + SharedPreferences.setMockInitialValues({}); + 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 createStore() { + return _createStore(tempDirectory.path); + } + + final firstStore = await createStore(); + final controller = AppController( + store: firstStore, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + singleAgentLocalSkillScanRoots: [ + 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 ['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.codex, + ], + singleAgentLocalSkillScanRoots: [ + 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 ['Browser', 'PPT']), + ); + }, + ); + + 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, + ], + singleAgentLocalSkillScanRoots: 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 global 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, + ], + singleAgentLocalSkillScanRoots: [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, + ], + singleAgentLocalSkillScanRoots: 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 global 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, + ], + singleAgentLocalSkillScanRoots: 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, + ); +}