diff --git a/lib/app/app_controller_desktop.dart b/lib/app/app_controller_desktop.dart index 841c3178..4aba8eae 100644 --- a/lib/app/app_controller_desktop.dart +++ b/lib/app/app_controller_desktop.dart @@ -66,7 +66,7 @@ class _SingleAgentSkillScanRoot { const String _singleAgentLocalSkillsCacheRelativePath = 'cache/single-agent-local-skills.json'; -const int _singleAgentLocalSkillsCacheSchemaVersion = 3; +const int _singleAgentLocalSkillsCacheSchemaVersion = 4; class AppController extends ChangeNotifier { static const List<_SingleAgentSkillScanRoot> @@ -258,8 +258,6 @@ class AppController extends ChangeNotifier { _singleAgentSharedSkillScanRootFromOverride, ))?.toList(growable: false) ?? _defaultSingleAgentGlobalSkillScanRoots; - final requiresAuthorizedSharedRoots = - _skillDirectoryAccessService.requiresAuthorizedSharedRoots; final authorizedByPath = { for (final directory in settings.authorizedSkillDirectories) normalizeAuthorizedSkillDirectoryPath(directory.path): directory, @@ -273,15 +271,9 @@ class AppController extends ChangeNotifier { } final authorizedDirectory = authorizedByPath.remove(resolvedPath); final bookmark = authorizedDirectory?.bookmark.trim() ?? ''; - if (requiresAuthorizedSharedRoots && bookmark.isEmpty) { - continue; - } resolvedRoots.add(root.copyWith(bookmark: bookmark)); } for (final directory in authorizedByPath.values) { - if (requiresAuthorizedSharedRoots && directory.bookmark.trim().isEmpty) { - continue; - } resolvedRoots.add( _singleAgentSharedSkillScanRootFromAuthorizedDirectory(directory), ); @@ -2195,10 +2187,7 @@ class AppController extends ChangeNotifier { AssistantExecutionTarget.singleAgent) { return; } - final previousImported = - _assistantThreadRecords[normalizedSessionKey]?.importedSkills ?? - const []; - final localSkills = await _singleAgentLocalFallbackSkillsForSession( + final localSkills = await _singleAgentLocalSkillsForSession( normalizedSessionKey, ); final provider = @@ -2240,19 +2229,9 @@ class AppController extends ChangeNotifier { ); return; } - if (localSkills.isNotEmpty || previousImported.isEmpty) { - await _replaceSingleAgentThreadSkills( - normalizedSessionKey, - localSkills, - ); - } + await _replaceSingleAgentThreadSkills(normalizedSessionKey, localSkills); } catch (_) { - if (localSkills.isNotEmpty || previousImported.isEmpty) { - await _replaceSingleAgentThreadSkills( - normalizedSessionKey, - localSkills, - ); - } + await _replaceSingleAgentThreadSkills(normalizedSessionKey, localSkills); } } @@ -4300,6 +4279,8 @@ class AppController extends ChangeNotifier { } dedupedByName[normalizedName] = entry; } + } catch (_) { + continue; } finally { await accessHandle?.close(); } @@ -4382,27 +4363,13 @@ class AppController extends ChangeNotifier { } String _sourceForSkillRootPath(String path) { - if (path.startsWith('/etc/skills')) { + if (path == '/etc/skills' || 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')) { + if (path == '~/.agents/skills' || path.endsWith('/.agents/skills')) { return 'agents'; } - return 'codex'; - } - - bool _pathContainsSourceToken(String path, String token) { - final pattern = RegExp('(^|[./_-])$token([./_-]|\$)'); - return pattern.hasMatch(path); + return 'custom'; } Future _skillEntryFromFile( @@ -4559,8 +4526,9 @@ class AppController extends ChangeNotifier { _notifyIfActive(); } - Future> - _singleAgentLocalFallbackSkillsForSession(String sessionKey) async { + Future> _singleAgentLocalSkillsForSession( + String sessionKey, + ) async { final workspaceSkills = await _scanSingleAgentWorkspaceSkillEntries( sessionKey, ); diff --git a/lib/features/settings/skill_directory_authorization_card.dart b/lib/features/settings/skill_directory_authorization_card.dart index 9e611415..ec1b2e38 100644 --- a/lib/features/settings/skill_directory_authorization_card.dart +++ b/lib/features/settings/skill_directory_authorization_card.dart @@ -53,8 +53,8 @@ class _SkillDirectoryAuthorizationCardState const SizedBox(height: 8), Text( appText( - '预设扫描目录固定为 /etc/skills 和 ~/.agents/skills;其他目录可批量添加为自定义目录。只有在这里显式授权的目录才会被扫描为单机智能体 skills,设置中心修改会写入 settings.yaml。', - 'Preset scan roots are fixed to /etc/skills and ~/.agents/skills. Other locations can be added in batches as custom directories. Only directories explicitly granted here are scanned as single-agent skills, and Settings Center writes changes back to settings.yaml.', + '预设扫描目录固定为 /etc/skills 和 ~/.agents/skills;其他目录可批量添加为自定义目录扩展扫描列表。设置中心修改会写入 settings.yaml。', + 'Preset scan roots are fixed to /etc/skills and ~/.agents/skills. Other locations can be added in batches as custom directories to extend the scan list. Settings Center writes changes back to settings.yaml.', ), style: theme.textTheme.bodyMedium, ), diff --git a/lib/runtime/skill_directory_access.dart b/lib/runtime/skill_directory_access.dart index 0b66935d..597efa97 100644 --- a/lib/runtime/skill_directory_access.dart +++ b/lib/runtime/skill_directory_access.dart @@ -9,7 +9,6 @@ import 'runtime_models.dart'; abstract class SkillDirectoryAccessService { bool get isSupported; - bool get requiresAuthorizedSharedRoots; Future resolveUserHomeDirectory(); Future> authorizeDirectories({ @@ -61,9 +60,6 @@ class UnsupportedSkillDirectoryAccessService @override bool get isSupported => false; - @override - bool get requiresAuthorizedSharedRoots => false; - @override Future resolveUserHomeDirectory() async { return _fallbackUserHomeDirectory(); @@ -96,9 +92,6 @@ class FileSelectorSkillDirectoryAccessService @override bool get isSupported => true; - @override - bool get requiresAuthorizedSharedRoots => false; - @override Future resolveUserHomeDirectory() async { return _fallbackUserHomeDirectory(); @@ -168,9 +161,6 @@ class MacOsSkillDirectoryAccessService implements SkillDirectoryAccessService { @override bool get isSupported => true; - @override - bool get requiresAuthorizedSharedRoots => true; - @override Future resolveUserHomeDirectory() async { try { diff --git a/test/features/assistant_page_suite.dart b/test/features/assistant_page_suite.dart index 9d8a353f..74eb90e6 100644 --- a/test/features/assistant_page_suite.dart +++ b/test/features/assistant_page_suite.dart @@ -560,10 +560,8 @@ void main() { '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', - ); + final customRootA = Directory('${tempDirectory.path}/custom-skills-a'); + final customRootB = Directory('${tempDirectory.path}/custom-skills-b'); await _writeSkill( agentsRoot, 'browser', @@ -571,13 +569,13 @@ void main() { description: 'Browse websites', ); await _writeSkill( - codexRoot, + customRootA, 'ppt', skillName: 'PPT', description: 'Presentation skill', ); await _writeSkill( - workbuddyRoot, + customRootB, 'wordx', skillName: 'WordX', description: 'Document skill', @@ -588,8 +586,8 @@ void main() { useFakeGatewayRuntime: true, singleAgentSharedSkillScanRootOverrides: [ agentsRoot.path, - codexRoot.path, - workbuddyRoot.path, + customRootA.path, + customRootB.path, ], ); }); diff --git a/test/features/settings_page_suite.dart b/test/features/settings_page_suite.dart index 5ed76828..cf7377db 100644 --- a/test/features/settings_page_suite.dart +++ b/test/features/settings_page_suite.dart @@ -95,9 +95,6 @@ class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService { @override bool get isSupported => true; - @override - bool get requiresAuthorizedSharedRoots => false; - @override Future resolveUserHomeDirectory() async { return userHomeDirectory; @@ -377,9 +374,11 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.text('批量添加自定义目录')); await tester.pump(); - for (var attempt = 0; - attempt < 10 && controller.authorizedSkillDirectories.length < 2; - attempt += 1) { + for ( + var attempt = 0; + attempt < 10 && controller.authorizedSkillDirectories.length < 2; + attempt += 1 + ) { await tester.pump(const Duration(milliseconds: 100)); } diff --git a/test/runtime/app_controller_thread_skills_suite.dart b/test/runtime/app_controller_thread_skills_suite.dart index cf8f20b4..a6873de1 100644 --- a/test/runtime/app_controller_thread_skills_suite.dart +++ b/test/runtime/app_controller_thread_skills_suite.dart @@ -2,6 +2,8 @@ library; import 'dart:io'; +import 'dart:async'; +import 'dart:convert'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -27,8 +29,8 @@ void main() { }); 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'); + final customRootA = Directory('${tempDirectory.path}/custom-skills-a'); + final customRootB = Directory('${tempDirectory.path}/custom-skills-b'); await _writeSkill( systemRoot, 'analysis', @@ -42,19 +44,19 @@ void main() { description: 'Shared browser skill', ); await _writeSkill( - codexRoot, + customRootA, 'ppt', skillName: 'PPT', description: 'Presentation skill', ); await _writeSkill( - workbuddyRoot, + customRootB, 'analysis', skillName: 'Analysis', - description: 'WorkBuddy version wins', + description: 'Custom version wins', ); await _writeSkill( - workbuddyRoot, + customRootB, 'cicd-audit', skillName: 'CICD Audit', description: 'Pipeline audit skill', @@ -69,8 +71,8 @@ void main() { singleAgentSharedSkillScanRootOverrides: [ systemRoot.path, agentsRoot.path, - codexRoot.path, - workbuddyRoot.path, + customRootA.path, + customRootB.path, ], ); addTearDown(controller.dispose); @@ -102,8 +104,8 @@ void main() { final analysisSkill = controller .assistantImportedSkillsForSession(firstSessionKey) .firstWhere((skill) => skill.label == 'Analysis'); - expect(analysisSkill.description, 'WorkBuddy version wins'); - expect(analysisSkill.source, 'workbuddy'); + expect(analysisSkill.description, 'Custom version wins'); + expect(analysisSkill.source, 'custom'); expect(analysisSkill.scope, 'user'); await controller.toggleAssistantSkillForSession( @@ -264,184 +266,6 @@ void main() { }, ); - test( - 'AppController skips preset shared roots without bookmarks when the access service requires authorization', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-skill-directory-macos-preset-unauthorized-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - try { - await tempDirectory.delete(recursive: true); - } catch (_) {} - } - }); - final userHome = Directory('${tempDirectory.path}/real-home'); - final agentsRoot = Directory('${userHome.path}/.agents/skills'); - await _writeSkill( - agentsRoot, - 'browser', - skillName: 'Browser', - description: 'Browser tasks', - ); - - final controller = AppController( - store: await _createStore(tempDirectory.path), - skillDirectoryAccessService: _FakeSkillDirectoryAccessService( - userHomeDirectory: userHome.path, - requiresAuthorizedSharedRoots: true, - ), - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.codex, - ], - singleAgentSharedSkillScanRootOverrides: const [ - '~/.agents/skills', - ], - ); - addTearDown(controller.dispose); - await _waitFor(() => !controller.initializing); - await controller.setAssistantExecutionTarget( - AssistantExecutionTarget.singleAgent, - ); - await Future.delayed(const Duration(milliseconds: 100)); - - expect( - controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .where((item) => item.label == 'Browser'), - isEmpty, - ); - }, - ); - - test( - 'AppController scans preset shared roots with bookmarks when the access service requires authorization', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-skill-directory-macos-preset-authorized-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - try { - await tempDirectory.delete(recursive: true); - } catch (_) {} - } - }); - final userHome = Directory('${tempDirectory.path}/real-home'); - final agentsRoot = Directory('${userHome.path}/.agents/skills'); - await _writeSkill( - agentsRoot, - 'browser', - skillName: 'Browser', - description: 'Browser tasks', - ); - - final store = await _createStore(tempDirectory.path); - await store.saveSettingsSnapshot( - _singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith( - authorizedSkillDirectories: [ - AuthorizedSkillDirectory( - path: agentsRoot.path, - bookmark: 'bookmark-1', - ), - ], - ), - ); - final controller = AppController( - store: store, - skillDirectoryAccessService: _FakeSkillDirectoryAccessService( - userHomeDirectory: userHome.path, - requiresAuthorizedSharedRoots: true, - ), - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.codex, - ], - singleAgentSharedSkillScanRootOverrides: const [ - '~/.agents/skills', - ], - ); - addTearDown(controller.dispose); - await _waitFor(() => !controller.initializing); - await controller.setAssistantExecutionTarget( - AssistantExecutionTarget.singleAgent, - ); - await _waitFor( - () => controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .any((item) => item.label == 'Browser'), - ); - - expect( - controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .map((item) => item.label), - contains('Browser'), - ); - }, - ); - - test( - 'AppController skips custom shared directories without bookmarks when the access service requires authorization', - () async { - SharedPreferences.setMockInitialValues({}); - final tempDirectory = await Directory.systemTemp.createTemp( - 'xworkmate-skill-directory-macos-custom-unauthorized-', - ); - addTearDown(() async { - if (await tempDirectory.exists()) { - try { - await tempDirectory.delete(recursive: true); - } catch (_) {} - } - }); - final customRoot = Directory( - '${tempDirectory.path}/custom-shared-skills', - ); - await _writeSkill( - customRoot, - 'browser', - skillName: 'Browser', - description: 'Browser tasks', - ); - - final store = await _createStore(tempDirectory.path); - await store.saveSettingsSnapshot( - _singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith( - authorizedSkillDirectories: [ - AuthorizedSkillDirectory(path: customRoot.path), - ], - ), - ); - final controller = AppController( - store: store, - skillDirectoryAccessService: _FakeSkillDirectoryAccessService( - userHomeDirectory: tempDirectory.path, - requiresAuthorizedSharedRoots: true, - ), - availableSingleAgentProvidersOverride: const [ - SingleAgentProvider.codex, - ], - singleAgentSharedSkillScanRootOverrides: const [], - ); - addTearDown(controller.dispose); - await _waitFor(() => !controller.initializing); - await controller.setAssistantExecutionTarget( - AssistantExecutionTarget.singleAgent, - ); - await Future.delayed(const Duration(milliseconds: 100)); - - expect( - controller - .assistantImportedSkillsForSession(controller.currentSessionKey) - .where((item) => item.label == 'Browser'), - isEmpty, - ); - }, - ); - test( 'AppController keeps thread-bound skills isolated and restores them after restart', () async { @@ -457,8 +281,8 @@ void main() { } }); final agentsRoot = Directory('${tempDirectory.path}/agents-skills'); - final codexRoot = Directory('${tempDirectory.path}/codex-skills'); - final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills'); + final customRootA = Directory('${tempDirectory.path}/custom-skills-a'); + final customRootB = Directory('${tempDirectory.path}/custom-skills-b'); await _writeSkill( agentsRoot, 'browser', @@ -466,19 +290,19 @@ void main() { description: 'Browser tasks', ); await _writeSkill( - codexRoot, + customRootA, 'ppt', skillName: 'PPT', description: 'Presentation tasks', ); await _writeSkill( - workbuddyRoot, + customRootB, 'wordx', skillName: 'WordX', description: 'Document tasks', ); await _writeSkill( - workbuddyRoot, + customRootB, 'cicd-audit', skillName: 'CICD Audit', description: 'Pipeline tasks', @@ -497,8 +321,8 @@ void main() { ], singleAgentSharedSkillScanRootOverrides: [ agentsRoot.path, - codexRoot.path, - workbuddyRoot.path, + customRootA.path, + customRootB.path, ], ); } @@ -726,16 +550,18 @@ void main() { } catch (_) {} } }); - final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills'); + final customRoot = Directory( + '${tempDirectory.path}/custom-shared-skills', + ); final workspaceRoot = Directory('${tempDirectory.path}/workspace'); await _writeSkill( - workbuddyRoot, + customRoot, 'shared-skill', skillName: 'Shared Skill', description: 'Global wins', ); await _writeSkill( - workbuddyRoot, + customRoot, 'global-only', skillName: 'Global Only', description: 'Only from global', @@ -783,7 +609,7 @@ void main() { availableSingleAgentProvidersOverride: const [ SingleAgentProvider.codex, ], - singleAgentSharedSkillScanRootOverrides: [workbuddyRoot.path], + singleAgentSharedSkillScanRootOverrides: [customRoot.path], ); addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); @@ -894,6 +720,223 @@ void main() { }, ); + test( + 'AppController merges ACP skills after shared roots and workspace skills', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-acp-skill-merge-', + ); + final acpServer = await _AcpSkillsStatusServer.start( + skills: const >[ + { + 'skillKey': 'acp-shared', + 'name': 'Shared Skill', + 'description': 'ACP should not override shared', + 'source': 'acp', + }, + { + 'skillKey': 'acp-workspace', + 'name': 'Workspace Skill', + 'description': 'ACP should not override workspace', + 'source': 'acp', + }, + { + 'skillKey': 'acp-only', + 'name': 'ACP Only', + 'description': 'Only from ACP', + 'source': 'acp', + }, + ], + ); + addTearDown(acpServer.close); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + + final customRoot = Directory( + '${tempDirectory.path}/custom-shared-skills', + ); + final workspaceRoot = Directory('${tempDirectory.path}/workspace'); + await _writeSkill( + customRoot, + 'shared-skill', + skillName: 'Shared Skill', + description: 'Shared root wins', + ); + await _writeSkill( + Directory('${workspaceRoot.path}/skills'), + 'workspace-skill', + skillName: 'Workspace Skill', + description: 'Workspace 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, + gatewayPort: acpServer.port, + ), + ); + 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: [customRoot.path], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + await _waitFor( + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .any((item) => item.label == 'ACP Only'), + ); + + final importedSkills = controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ); + expect( + importedSkills.map((item) => item.label), + containsAll(const [ + 'Shared Skill', + 'Workspace Skill', + 'ACP Only', + ]), + ); + expect( + importedSkills.firstWhere((item) => item.label == 'Shared Skill'), + isA() + .having( + (item) => item.description, + 'description', + 'Shared root wins', + ) + .having((item) => item.source, 'source', 'custom'), + ); + expect( + importedSkills.firstWhere((item) => item.label == 'Workspace Skill'), + isA() + .having((item) => item.description, 'description', 'Workspace wins') + .having((item) => item.source, 'source', 'workspace'), + ); + expect( + importedSkills.firstWhere((item) => item.label == 'ACP Only'), + isA() + .having((item) => item.description, 'description', 'Only from ACP') + .having((item) => item.source, 'source', 'acp'), + ); + }, + ); + + test( + 'AppController clears stale ACP-only skills when ACP refresh fails', + () async { + SharedPreferences.setMockInitialValues({}); + final tempDirectory = await Directory.systemTemp.createTemp( + 'xworkmate-acp-skill-error-', + ); + final acpServer = await _AcpSkillsStatusServer.start( + skills: const >[ + { + 'skillKey': 'acp-only', + 'name': 'ACP Only', + 'description': 'Only from ACP', + 'source': 'acp', + }, + ], + ); + addTearDown(acpServer.close); + addTearDown(() async { + if (await tempDirectory.exists()) { + try { + await tempDirectory.delete(recursive: true); + } catch (_) {} + } + }); + + final customRoot = Directory( + '${tempDirectory.path}/custom-shared-skills', + ); + await _writeSkill( + customRoot, + 'local-only', + skillName: 'Local Only', + description: 'Only from local scan', + ); + + 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, + gatewayPort: acpServer.port, + ), + ); + + final controller = AppController( + store: store, + availableSingleAgentProvidersOverride: const [ + SingleAgentProvider.codex, + ], + singleAgentSharedSkillScanRootOverrides: [customRoot.path], + ); + addTearDown(controller.dispose); + await _waitFor(() => !controller.initializing); + await _waitFor( + () => controller + .assistantImportedSkillsForSession(controller.currentSessionKey) + .any((item) => item.label == 'ACP Only'), + ); + + acpServer.skillsError = { + 'code': -32001, + 'message': 'skills refresh failed', + }; + await controller.refreshSingleAgentSkillsForSession( + controller.currentSessionKey, + ); + + final importedSkills = controller.assistantImportedSkillsForSession( + controller.currentSessionKey, + ); + expect(importedSkills.map((item) => item.label), const [ + 'Local Only', + ]); + }, + ); + test( 'AppController can return empty skills when neither public nor repo-local roots exist', () async { @@ -998,7 +1041,10 @@ Future _createStore(String rootPath) async { return store; } -SettingsSnapshot _singleAgentTestSettings({required String workspacePath}) { +SettingsSnapshot _singleAgentTestSettings({ + required String workspacePath, + int gatewayPort = 9, +}) { final defaults = SettingsSnapshot.defaults(); return defaults.copyWith( gatewayProfiles: replaceGatewayProfileAt( @@ -1007,14 +1053,14 @@ SettingsSnapshot _singleAgentTestSettings({required String workspacePath}) { kGatewayLocalProfileIndex, defaults.primaryLocalGatewayProfile.copyWith( host: '127.0.0.1', - port: 9, + port: gatewayPort, tls: false, ), ), kGatewayRemoteProfileIndex, defaults.primaryRemoteGatewayProfile.copyWith( host: '127.0.0.1', - port: 9, + port: gatewayPort, tls: false, ), ), @@ -1024,14 +1070,9 @@ SettingsSnapshot _singleAgentTestSettings({required String workspacePath}) { } class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService { - _FakeSkillDirectoryAccessService({ - required this.userHomeDirectory, - this.requiresAuthorizedSharedRoots = false, - }); + _FakeSkillDirectoryAccessService({required this.userHomeDirectory}); final String userHomeDirectory; - @override - final bool requiresAuthorizedSharedRoots; @override bool get isSupported => true; @@ -1070,3 +1111,105 @@ class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService { return SkillDirectoryAccessHandle(path: normalized, onClose: () async {}); } } + +class _AcpSkillsStatusServer { + _AcpSkillsStatusServer._(this._server, {required this.skills}); + + final HttpServer _server; + List> skills; + Map? skillsError; + + int get port => _server.port; + + static Future<_AcpSkillsStatusServer> start({ + required List> skills, + }) async { + final server = await HttpServer.bind(InternetAddress.loopbackIPv4, 0); + final fake = _AcpSkillsStatusServer._( + server, + skills: skills.map((item) => Map.from(item)).toList(), + ); + unawaited(fake._listen()); + return fake; + } + + Future close() async { + await _server.close(force: true); + } + + Future _listen() async { + await for (final request in _server) { + if (request.uri.path == '/acp/rpc' && request.method == 'POST') { + await _handleRpc(request); + continue; + } + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + } + + Future _handleRpc(HttpRequest request) async { + final body = await utf8.decodeStream(request); + final envelope = jsonDecode(body) as Map; + final id = envelope['id']; + final method = envelope['method']?.toString().trim() ?? ''; + + request.response.headers.set( + HttpHeaders.contentTypeHeader, + 'text/event-stream', + ); + request.response.headers.set(HttpHeaders.cacheControlHeader, 'no-cache'); + + switch (method) { + case 'acp.capabilities': + await _writeSse(request, { + 'jsonrpc': '2.0', + 'id': id, + 'result': { + 'singleAgent': true, + 'multiAgent': true, + 'providers': const ['codex'], + 'capabilities': { + 'single_agent': true, + 'multi_agent': true, + 'providers': const ['codex'], + }, + }, + }); + return; + case 'skills.status': + if (skillsError != null) { + await _writeSse(request, { + 'jsonrpc': '2.0', + 'id': id, + 'error': skillsError, + }); + return; + } + await _writeSse(request, { + 'jsonrpc': '2.0', + 'id': id, + 'result': {'skills': skills}, + }); + return; + default: + await _writeSse(request, { + 'jsonrpc': '2.0', + 'id': id, + 'error': { + 'code': -32601, + 'message': 'unknown method: $method', + }, + }); + } + } + + Future _writeSse( + HttpRequest request, + Map payload, + ) async { + request.response.write('data: ${jsonEncode(payload)}\n\n'); + await request.response.flush(); + await request.response.close(); + } +} diff --git a/test/runtime/secure_config_store_suite.dart b/test/runtime/secure_config_store_suite.dart index b270cb91..57972bc8 100644 --- a/test/runtime/secure_config_store_suite.dart +++ b/test/runtime/secure_config_store_suite.dart @@ -666,7 +666,8 @@ void main() { ManagedSkillEntry( key: 'calm_compact_workspace_system', label: 'Calm Compact Workspace System', - source: '/Users/test/.codex/skills/calm_compact_workspace_system', + source: + '/Users/test/.agents/skills/calm_compact_workspace_system', selected: true, ), ], @@ -744,7 +745,7 @@ void main() { label: 'Imported Skill', description: 'confirmed import', sourcePath: '/tmp/imported-skill', - sourceLabel: 'workbuddy/imported', + sourceLabel: 'custom/imported', ), ], selectedSkillKeys: ['/tmp/imported-skill'], @@ -832,7 +833,7 @@ void main() { authorizedSkillDirectories: const [ AuthorizedSkillDirectory(path: '/etc/skills'), AuthorizedSkillDirectory( - path: '/Users/test/.codex/skills', + path: '/Users/test/.agents/skills', bookmark: 'bookmark-data', ), ], @@ -842,7 +843,7 @@ void main() { expect( decoded.authorizedSkillDirectories.map((item) => item.path), - const ['/Users/test/.codex/skills', '/etc/skills'], + const ['/Users/test/.agents/skills', '/etc/skills'], ); expect(decoded.authorizedSkillDirectories.first.bookmark, 'bookmark-data'); });