@TestOn('vm') library; import 'dart:io'; import 'dart:async'; import 'dart:convert'; 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'; import 'package:xworkmate/runtime/skill_directory_access.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 customRootA = Directory('${tempDirectory.path}/custom-skills-a'); final customRootB = Directory('${tempDirectory.path}/custom-skills-b'); 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( customRootA, 'ppt', skillName: 'PPT', description: 'Presentation skill', ); await _writeSkill( customRootB, 'analysis', skillName: 'Analysis', description: 'Custom version wins', ); await _writeSkill( customRootB, '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, customRootA.path, customRootB.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, 'Custom version wins'); expect(analysisSkill.source, 'custom'); 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 scans skills inside symlinked directories under shared roots', () async { if (Platform.isWindows) { return; } SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( 'xworkmate-skill-directory-symlink-', ); addTearDown(() async { if (await tempDirectory.exists()) { try { await tempDirectory.delete(recursive: true); } catch (_) {} } }); final sharedRoot = Directory('${tempDirectory.path}/shared-root'); final actualSkillRoot = Directory('${tempDirectory.path}/actual-skills'); await sharedRoot.create(recursive: true); await _writeSkill( actualSkillRoot, 'linked-browser', skillName: 'Linked Browser', description: 'Loaded through a symlinked directory', ); await Link('${sharedRoot.path}/linked-pack').create(actualSkillRoot.path); final controller = AppController( store: await _createStore(tempDirectory.path), availableSingleAgentProvidersOverride: const [ SingleAgentProvider.codex, ], singleAgentSharedSkillScanRootOverrides: [sharedRoot.path], ); addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.singleAgent, ); await _waitFor( () => controller .assistantImportedSkillsForSession(controller.currentSessionKey) .any((skill) => skill.label == 'Linked Browser'), ); final linkedSkill = controller .assistantImportedSkillsForSession(controller.currentSessionKey) .firstWhere((skill) => skill.label == 'Linked Browser'); expect(linkedSkill.description, 'Loaded through a symlinked directory'); expect(linkedSkill.source, 'custom'); expect(linkedSkill.sourceLabel, contains('linked-pack/linked-browser')); }, ); test( 'AppController resolves preset shared roots against the access service home directory', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( 'xworkmate-skill-directory-home-', ); 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, ), 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.userHomeDirectory, userHome.path); expect( controller .assistantImportedSkillsForSession(controller.currentSessionKey) .map((item) => item.label), contains('Browser'), ); }, ); test( 'AppController accepts authorized single skill package paths and keeps fixed-root scanning intact', () async { SharedPreferences.setMockInitialValues({}); final tempDirectory = await Directory.systemTemp.createTemp( 'xworkmate-single-skill-package-path-', ); addTearDown(() async { if (await tempDirectory.exists()) { try { await tempDirectory.delete(recursive: true); } catch (_) {} } }); final fixedRoot = Directory('${tempDirectory.path}/fixed-root'); final externalRepoSkill = Directory( '${tempDirectory.path}/ai-workflow-craft/skills/docx', ); await _writeSkill( fixedRoot, 'docx', skillName: 'docx', description: 'Fixed root version', ); await _writeSkill( externalRepoSkill.parent, 'docx', skillName: 'docx', description: 'Imported package version', ); final store = await _createStore(tempDirectory.path); await store.saveSettingsSnapshot( _singleAgentTestSettings(workspacePath: tempDirectory.path).copyWith( authorizedSkillDirectories: [ AuthorizedSkillDirectory( path: '${externalRepoSkill.path}/SKILL.md', ), ], ), ); final controller = AppController( store: store, availableSingleAgentProvidersOverride: const [ SingleAgentProvider.codex, ], singleAgentSharedSkillScanRootOverrides: [fixedRoot.path], ); addTearDown(controller.dispose); await _waitFor(() => !controller.initializing); await controller.setAssistantExecutionTarget( AssistantExecutionTarget.singleAgent, ); await _waitFor( () => controller .assistantImportedSkillsForSession(controller.currentSessionKey) .any((item) => item.label == 'docx'), ); final docxSkill = controller .assistantImportedSkillsForSession(controller.currentSessionKey) .firstWhere((item) => item.label == 'docx'); expect(docxSkill.description, 'Imported package version'); expect(docxSkill.source, 'custom'); expect( controller.authorizedSkillDirectories.map((item) => item.path), ['${tempDirectory.path}/ai-workflow-craft/skills/docx'], ); }, ); 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 customRootA = Directory('${tempDirectory.path}/custom-skills-a'); final customRootB = Directory('${tempDirectory.path}/custom-skills-b'); await _writeSkill( agentsRoot, 'browser', skillName: 'Browser', description: 'Browser tasks', ); await _writeSkill( customRootA, 'ppt', skillName: 'PPT', description: 'Presentation tasks', ); await _writeSkill( customRootB, 'wordx', skillName: 'WordX', description: 'Document tasks', ); await _writeSkill( customRootB, '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, customRootA.path, customRootB.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}/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 customRoot = Directory( '${tempDirectory.path}/custom-shared-skills', ); final workspaceRoot = Directory('${tempDirectory.path}/workspace'); await _writeSkill( customRoot, 'shared-skill', skillName: 'Shared Skill', description: 'Global wins', ); await _writeSkill( customRoot, 'global-only', skillName: 'Global Only', description: 'Only from global', ); await _writeSkill( Directory('${workspaceRoot.path}/skills'), 'shared-skill', skillName: 'Shared Skill', description: 'Repo-local should not override', ); await _writeSkill( Directory('${workspaceRoot.path}/skills'), 'workspace-only', skillName: 'Workspace Only', description: 'Only from workspace', ); 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: [customRoot.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 from workspace skills directory only', () 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}/skills'), 'shared-skill', skillName: 'Shared Skill', description: 'Workspace version wins', ); await _writeSkill( Directory('${workspaceRoot.path}/.codex/skills'), 'legacy-only', skillName: 'Legacy Only', description: 'Deprecated workspace root should be ignored', ); 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, 'Workspace version wins'); expect(sharedSkill.source, 'workspace'); expect( controller .assistantImportedSkillsForSession(controller.currentSessionKey) .where((item) => item.label == 'Legacy Only'), isEmpty, ); }, ); 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 { 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, int gatewayPort = 9, }) { final defaults = SettingsSnapshot.defaults(); return defaults.copyWith( gatewayProfiles: replaceGatewayProfileAt( replaceGatewayProfileAt( defaults.gatewayProfiles, kGatewayLocalProfileIndex, defaults.primaryLocalGatewayProfile.copyWith( host: '127.0.0.1', port: gatewayPort, tls: false, ), ), kGatewayRemoteProfileIndex, defaults.primaryRemoteGatewayProfile.copyWith( host: '127.0.0.1', port: gatewayPort, tls: false, ), ), assistantExecutionTarget: AssistantExecutionTarget.singleAgent, workspacePath: workspacePath, ); } class _FakeSkillDirectoryAccessService implements SkillDirectoryAccessService { _FakeSkillDirectoryAccessService({required this.userHomeDirectory}); final String userHomeDirectory; @override bool get isSupported => true; @override Future resolveUserHomeDirectory() async { return userHomeDirectory; } @override Future> authorizeDirectories({ List suggestedPaths = const [], }) async { return const []; } @override Future authorizeDirectory({ String suggestedPath = '', }) async { final normalized = normalizeAuthorizedSkillDirectoryPath(suggestedPath); if (normalized.isEmpty) { return null; } return AuthorizedSkillDirectory(path: normalized); } @override Future openDirectory( AuthorizedSkillDirectory directory, ) async { final normalized = normalizeAuthorizedSkillDirectoryPath(directory.path); if (normalized.isEmpty) { return null; } 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(); } }