diff --git a/lib/app/app_controller_desktop_core.dart b/lib/app/app_controller_desktop_core.dart index 31bf99a8..32a46445 100644 --- a/lib/app/app_controller_desktop_core.dart +++ b/lib/app/app_controller_desktop_core.dart @@ -184,9 +184,8 @@ class AppController extends ChangeNotifier { bridgeGatewayProviderCatalogInternal = normalizeSingleAgentProviderList( initialGatewayProviderCatalog ?? const [], ); - bridgeAvailableExecutionTargetsInternal = compactAssistantExecutionTargets( - initialAvailableExecutionTargets ?? const [], - ); + bridgeAvailableExecutionTargetsInternal = + initialAvailableExecutionTargets ?? const []; bridgeCapabilitiesRefreshAttemptedInternal = bridgeAgentProviderCatalogInternal.isNotEmpty || bridgeGatewayProviderCatalogInternal.isNotEmpty; @@ -503,7 +502,9 @@ class AppController extends ChangeNotifier { !targets.contains(AssistantExecutionTarget.gateway)) { targets.add(AssistantExecutionTarget.gateway); } - return compactAssistantExecutionTargets(targets); + return AssistantExecutionTarget.values + .where(targets.contains) + .toList(growable: false); } List providerCatalogForExecutionTarget( diff --git a/lib/app/app_controller_desktop_runtime_coordination_impl.dart b/lib/app/app_controller_desktop_runtime_coordination_impl.dart index d66e5a06..46d98247 100644 --- a/lib/app/app_controller_desktop_runtime_coordination_impl.dart +++ b/lib/app/app_controller_desktop_runtime_coordination_impl.dart @@ -67,9 +67,7 @@ Future refreshAcpCapabilitiesRuntimeInternal( controller.bridgeGatewayProviderCatalogInternal = normalizeSingleAgentProviderList(capabilities.gatewayProviderCatalog); controller.bridgeAvailableExecutionTargetsInternal = - compactAssistantExecutionTargets( - capabilities.availableExecutionTargets, - ); + capabilities.availableExecutionTargets; } else if (refreshError != null) { controller.bridgeCapabilitiesRefreshErrorInternal = refreshError .toString() @@ -106,20 +104,12 @@ Future refreshSingleAgentCapabilitiesRuntimeInternal( controller.bridgeGatewayProviderCatalogInternal = normalizeSingleAgentProviderList(capabilities.gatewayProviderCatalog); controller.bridgeAvailableExecutionTargetsInternal = - compactAssistantExecutionTargets( - capabilities.availableExecutionTargets, - ); + capabilities.availableExecutionTargets; controller.bridgeCapabilitiesRefreshAttemptedInternal = true; controller.bridgeCapabilitiesRefreshErrorInternal = ''; } catch (error) { controller.bridgeCapabilitiesRefreshAttemptedInternal = true; controller.bridgeCapabilitiesRefreshErrorInternal = error.toString().trim(); - controller.bridgeAgentProviderCatalogInternal = - const []; - controller.bridgeGatewayProviderCatalogInternal = - const []; - controller.bridgeAvailableExecutionTargetsInternal = - const []; } if (!controller.disposedInternal) { controller.notifyListeners(); diff --git a/lib/app/app_controller_desktop_skill_permissions.dart b/lib/app/app_controller_desktop_skill_permissions.dart index f7e138ca..221d9de7 100644 --- a/lib/app/app_controller_desktop_skill_permissions.dart +++ b/lib/app/app_controller_desktop_skill_permissions.dart @@ -45,32 +45,6 @@ import 'app_controller_desktop_runtime_helpers.dart'; // ignore_for_file: invalid_use_of_visible_for_testing_member, invalid_use_of_protected_member extension AppControllerDesktopSkillPermissions on AppController { - Future replaceSingleAgentThreadSkillsInternal( - String sessionKey, - List importedSkills, - ) async { - final normalizedSessionKey = normalizedAssistantSessionKeyInternal( - sessionKey, - ); - final importedKeys = importedSkills.map((item) => item.key).toSet(); - final nextSelected = - (assistantThreadRecordsInternal[normalizedSessionKey] - ?.selectedSkillKeys ?? - const []) - .where(importedKeys.contains) - .toList(growable: false); - upsertTaskThreadInternal( - normalizedSessionKey, - importedSkills: importedSkills, - selectedSkillKeys: nextSelected, - selectedSkillsSource: assistantThreadRecordsInternal[normalizedSessionKey] - ?.contextState - .selectedSkillsSource, - updatedAtMs: DateTime.now().millisecondsSinceEpoch.toDouble(), - ); - notifyIfActiveInternal(); - } - void upsertTaskThreadInternal( String sessionKey, { ThreadOwnerScope? ownerScope, @@ -84,7 +58,6 @@ extension AppControllerDesktopSkillPermissions on AppController { bool? archived, AssistantExecutionTarget? executionTarget, AssistantMessageViewMode? messageViewMode, - List? importedSkills, List? selectedSkillKeys, String? assistantModelId, SingleAgentProvider? selectedProvider, @@ -120,15 +93,19 @@ extension AppControllerDesktopSkillPermissions on AppController { ThreadExecutionMode.gateway => AssistantExecutionTarget.gateway, null => AssistantExecutionTarget.agent, }; - final nextImportedSkills = - importedSkills ?? - existing?.importedSkills ?? - const []; - final importedKeys = nextImportedSkills.map((item) => item.key).toSet(); - final nextSelectedSkillKeys = - (selectedSkillKeys ?? existing?.selectedSkillKeys ?? const []) - .where(importedKeys.contains) - .toList(growable: false); + final bridgeSkillKeys = skills + .map((item) => item.skillKey.trim()) + .where((item) => item.isNotEmpty) + .toSet(); + final selectedSkillCandidates = + selectedSkillKeys ?? existing?.selectedSkillKeys ?? const []; + final nextSelectedSkillKeys = selectedSkillCandidates + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) + .where( + (item) => bridgeSkillKeys.isEmpty || bridgeSkillKeys.contains(item), + ) + .toList(growable: false); final nextMessages = messages ?? existing?.messages ?? @@ -213,7 +190,6 @@ extension AppControllerDesktopSkillPermissions on AppController { nextExecutionTarget, ), selectedSkillKeys: const [], - importedSkills: const [], permissionLevel: AssistantPermissionLevel.defaultAccess, messageViewMode: AssistantMessageViewMode.rendered, latestResolvedRuntimeModel: '', @@ -230,7 +206,6 @@ extension AppControllerDesktopSkillPermissions on AppController { .copyWith( messages: nextMessages, messageViewMode: messageViewMode, - importedSkills: nextImportedSkills, selectedSkillKeys: nextSelectedSkillKeys, selectedModelId: assistantModelId ?? diff --git a/lib/app/app_controller_desktop_thread_storage.dart b/lib/app/app_controller_desktop_thread_storage.dart index 6b27e322..a4417761 100644 --- a/lib/app/app_controller_desktop_thread_storage.dart +++ b/lib/app/app_controller_desktop_thread_storage.dart @@ -532,9 +532,8 @@ extension AppControllerDesktopThreadStorage on AppController { archived: record.archived, messageViewMode: record.messageViewMode, selectedSkillKeys: record.selectedSkillKeys - .where( - (item) => record.importedSkills.any((skill) => skill.key == item), - ) + .map((item) => item.trim()) + .where((item) => item.isNotEmpty) .toList(growable: false), assistantModelId: record.assistantModelId.trim().isEmpty ? resolvedAssistantModelForTargetInternal(normalizedExecutionTarget) diff --git a/lib/features/assistant/assistant_page_composer_skill_models.dart b/lib/features/assistant/assistant_page_composer_skill_models.dart index 41787f90..49d12623 100644 --- a/lib/features/assistant/assistant_page_composer_skill_models.dart +++ b/lib/features/assistant/assistant_page_composer_skill_models.dart @@ -45,6 +45,7 @@ ComposerSkillOptionInternal skillOptionFromGatewayInternal( : skill.skillKey.trim(); final label = skill.name.trim().isEmpty ? key : skill.name.trim(); final sourceLabel = skill.source.trim().isEmpty ? 'Gateway' : skill.source; + final group = skillGroupForSourceInternal(skill.source); final description = skill.description.trim().isEmpty ? appText('可在当前任务中调用的技能。', 'Skill available in the current task.') : skill.description.trim(); @@ -54,16 +55,62 @@ ComposerSkillOptionInternal skillOptionFromGatewayInternal( label: label, description: description, sourceLabel: sourceLabel, + groupLabel: group.label, + groupSortOrder: group.sortOrder, icon: Icons.key_rounded, ); } +ComposerSkillGroupInternal skillGroupForSourceInternal(String source) { + final normalized = source.trim().toLowerCase(); + if (normalized == 'openclaw-workspace') { + return const ComposerSkillGroupInternal( + label: 'Workspace Skills', + sortOrder: 0, + ); + } + if (normalized.startsWith('agents-skills-') || + normalized == 'agent' || + normalized.startsWith('agent-') || + normalized.contains('personal')) { + return const ComposerSkillGroupInternal( + label: 'Agent Skills', + sortOrder: 1, + ); + } + if (normalized == 'bridge' || normalized == 'gateway') { + return const ComposerSkillGroupInternal( + label: 'Gateway Skills', + sortOrder: 2, + ); + } + if (normalized.isEmpty) { + return const ComposerSkillGroupInternal( + label: 'Gateway Skills', + sortOrder: 2, + ); + } + return const ComposerSkillGroupInternal(label: 'Other Skills', sortOrder: 3); +} + +class ComposerSkillGroupInternal { + const ComposerSkillGroupInternal({ + required this.label, + required this.sortOrder, + }); + + final String label; + final int sortOrder; +} + class ComposerSkillOptionInternal { const ComposerSkillOptionInternal({ required this.key, required this.label, required this.description, required this.sourceLabel, + required this.groupLabel, + required this.groupSortOrder, required this.icon, }); @@ -71,5 +118,7 @@ class ComposerSkillOptionInternal { final String label; final String description; final String sourceLabel; + final String groupLabel; + final int groupSortOrder; final IconData icon; } diff --git a/lib/features/assistant/assistant_page_composer_skill_picker.dart b/lib/features/assistant/assistant_page_composer_skill_picker.dart index 8788a0f1..74ae5af4 100644 --- a/lib/features/assistant/assistant_page_composer_skill_picker.dart +++ b/lib/features/assistant/assistant_page_composer_skill_picker.dart @@ -94,6 +94,7 @@ class SkillPickerPopoverInternal extends StatelessWidget { Widget build(BuildContext context) { final palette = context.palette; final theme = Theme.of(context); + final groupedSkills = skillPickerSectionsInternal(filteredSkills); return Material( key: const Key('assistant-skill-picker-popover'), color: Colors.transparent, @@ -174,19 +175,32 @@ class SkillPickerPopoverInternal extends StatelessWidget { ), ), ) - : ListView.separated( + : ListView.builder( padding: const EdgeInsets.fromLTRB(12, 12, 12, 12), - itemCount: filteredSkills.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), + itemCount: groupedSkills.length, itemBuilder: (context, index) { - final skill = filteredSkills[index]; - return SkillPickerTileInternal( - key: ValueKey( - 'assistant-skill-option-${skill.key}', + final row = groupedSkills[index]; + if (row.headerLabel != null) { + return SkillPickerGroupHeaderInternal( + key: ValueKey( + 'assistant-skill-group-${row.headerLabel}', + ), + label: row.headerLabel!, + ); + } + final skill = row.skill!; + return Padding( + padding: EdgeInsets.only( + bottom: index == groupedSkills.length - 1 ? 0 : 8, + ), + child: SkillPickerTileInternal( + key: ValueKey( + 'assistant-skill-option-${skill.key}', + ), + option: skill, + selected: selectedSkillKeys.contains(skill.key), + onTap: () => onToggleSkill(skill.key), ), - option: skill, - selected: selectedSkillKeys.contains(skill.key), - onTap: () => onToggleSkill(skill.key), ); }, ), @@ -199,6 +213,68 @@ class SkillPickerPopoverInternal extends StatelessWidget { } } +List skillPickerSectionsInternal( + List skills, +) { + final groupsByLabel = >{}; + final groupSortOrders = {}; + for (final skill in skills) { + groupsByLabel.putIfAbsent(skill.groupLabel, () => []).add(skill); + groupSortOrders[skill.groupLabel] = skill.groupSortOrder; + } + final labels = groupsByLabel.keys.toList(growable: false) + ..sort((a, b) { + final orderCompare = (groupSortOrders[a] ?? 999).compareTo( + groupSortOrders[b] ?? 999, + ); + if (orderCompare != 0) { + return orderCompare; + } + return a.compareTo(b); + }); + + final rows = []; + for (final label in labels) { + rows.add(SkillPickerRowInternal.header(label)); + for (final skill in groupsByLabel[label]!) { + rows.add(SkillPickerRowInternal.skill(skill)); + } + } + return rows; +} + +class SkillPickerRowInternal { + const SkillPickerRowInternal.header(this.headerLabel) : skill = null; + const SkillPickerRowInternal.skill(this.skill) : headerLabel = null; + + final String? headerLabel; + final ComposerSkillOptionInternal? skill; +} + +class SkillPickerGroupHeaderInternal extends StatelessWidget { + const SkillPickerGroupHeaderInternal({super.key, required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final palette = context.palette; + return Padding( + padding: const EdgeInsets.fromLTRB(4, 6, 4, 8), + child: Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.labelMedium?.copyWith( + color: palette.textSecondary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + class SkillPickerTileInternal extends StatelessWidget { const SkillPickerTileInternal({ super.key, diff --git a/lib/features/assistant/assistant_page_task_dialog_controls.dart b/lib/features/assistant/assistant_page_task_dialog_controls.dart index 96b19c9c..b3afed79 100644 --- a/lib/features/assistant/assistant_page_task_dialog_controls.dart +++ b/lib/features/assistant/assistant_page_task_dialog_controls.dart @@ -23,9 +23,8 @@ class AssistantTaskDialogModeControlsInternal extends StatelessWidget { final uiFeatures = controller.featuresFor( resolveUiFeaturePlatformFromContext(context), ); - final supportedExecutionTargets = compactAssistantExecutionTargets( - uiFeatures.availableExecutionTargets, - ); + final supportedExecutionTargets = controller + .visibleAssistantExecutionTargets(uiFeatures.availableExecutionTargets); if (supportedExecutionTargets.isEmpty) { return const SizedBox.shrink(); } diff --git a/lib/runtime/runtime_models_runtime_payloads.dart b/lib/runtime/runtime_models_runtime_payloads.dart index 41314d26..7c3b2212 100644 --- a/lib/runtime/runtime_models_runtime_payloads.dart +++ b/lib/runtime/runtime_models_runtime_payloads.dart @@ -388,70 +388,6 @@ class GatewayChatMessage { } } -class AssistantThreadSkillEntry { - const AssistantThreadSkillEntry({ - required this.key, - required this.label, - required this.description, - this.source = '', - required this.sourcePath, - this.scope = '', - required this.sourceLabel, - }); - - final String key; - final String label; - final String description; - final String source; - final String sourcePath; - final String scope; - final String sourceLabel; - - AssistantThreadSkillEntry copyWith({ - String? key, - String? label, - String? description, - String? source, - String? sourcePath, - String? scope, - String? sourceLabel, - }) { - return AssistantThreadSkillEntry( - key: key ?? this.key, - label: label ?? this.label, - description: description ?? this.description, - source: source ?? this.source, - sourcePath: sourcePath ?? this.sourcePath, - scope: scope ?? this.scope, - sourceLabel: sourceLabel ?? this.sourceLabel, - ); - } - - Map toJson() { - return { - 'key': key, - 'label': label, - 'description': description, - 'source': source, - 'sourcePath': sourcePath, - 'scope': scope, - 'sourceLabel': sourceLabel, - }; - } - - factory AssistantThreadSkillEntry.fromJson(Map json) { - return AssistantThreadSkillEntry( - key: json['key']?.toString() ?? '', - label: json['label']?.toString() ?? '', - description: json['description']?.toString() ?? '', - source: json['source']?.toString() ?? '', - sourcePath: json['sourcePath']?.toString() ?? '', - scope: json['scope']?.toString() ?? '', - sourceLabel: json['sourceLabel']?.toString() ?? '', - ); - } -} - const int taskThreadSchemaVersion = 20260411; enum ThreadRealm { local, remote } @@ -729,7 +665,6 @@ class ThreadContextState { required this.messages, required this.selectedModelId, required this.selectedSkillKeys, - required this.importedSkills, required this.permissionLevel, required this.messageViewMode, required this.latestResolvedRuntimeModel, @@ -747,7 +682,6 @@ class ThreadContextState { final List messages; final String selectedModelId; final List selectedSkillKeys; - final List importedSkills; final AssistantPermissionLevel permissionLevel; final AssistantMessageViewMode messageViewMode; final String latestResolvedRuntimeModel; @@ -765,7 +699,6 @@ class ThreadContextState { List? messages, String? selectedModelId, List? selectedSkillKeys, - List? importedSkills, AssistantPermissionLevel? permissionLevel, AssistantMessageViewMode? messageViewMode, String? latestResolvedRuntimeModel, @@ -784,7 +717,6 @@ class ThreadContextState { messages: messages ?? this.messages, selectedModelId: selectedModelId ?? this.selectedModelId, selectedSkillKeys: selectedSkillKeys ?? this.selectedSkillKeys, - importedSkills: importedSkills ?? this.importedSkills, permissionLevel: permissionLevel ?? this.permissionLevel, messageViewMode: messageViewMode ?? this.messageViewMode, latestResolvedRuntimeModel: @@ -814,9 +746,6 @@ class ThreadContextState { 'messages': messages.map((item) => item.toJson()).toList(growable: false), 'selectedModelId': selectedModelId, 'selectedSkillKeys': selectedSkillKeys, - 'importedSkills': importedSkills - .map((item) => item.toJson()) - .toList(growable: false), 'permissionLevel': permissionLevel.name, 'messageViewMode': messageViewMode.name, 'latestResolvedRuntimeModel': latestResolvedRuntimeModel, @@ -850,18 +779,6 @@ class ThreadContextState { ) .toList(growable: false) : const []; - final rawImportedSkills = json['importedSkills']; - final importedSkills = rawImportedSkills is List - ? rawImportedSkills - .whereType() - .map( - (item) => AssistantThreadSkillEntry.fromJson( - item.cast(), - ), - ) - .where((item) => item.key.trim().isNotEmpty) - .toList(growable: false) - : const []; final rawSelectedSkillKeys = json['selectedSkillKeys']; final selectedSkillKeys = rawSelectedSkillKeys is List ? rawSelectedSkillKeys @@ -874,7 +791,6 @@ class ThreadContextState { messages: messages, selectedModelId: json['selectedModelId']?.toString() ?? '', selectedSkillKeys: selectedSkillKeys, - importedSkills: importedSkills, permissionLevel: AssistantPermissionLevelCopy.fromJsonValue( json['permissionLevel']?.toString(), ), @@ -1002,7 +918,6 @@ class TaskThread { List? messages, bool? archived, AssistantMessageViewMode? messageViewMode, - List? importedSkills, List? selectedSkillKeys, String? assistantModelId, String? gatewayEntryState, @@ -1041,8 +956,6 @@ class TaskThread { messages: messages ?? const [], selectedModelId: assistantModelId?.trim() ?? '', selectedSkillKeys: selectedSkillKeys ?? const [], - importedSkills: - importedSkills ?? const [], permissionLevel: permissionLevel ?? AssistantPermissionLevel.defaultAccess, messageViewMode: @@ -1090,8 +1003,6 @@ class TaskThread { String get sessionKey => threadId; List get messages => contextState.messages; - List get importedSkills => - contextState.importedSkills; List get selectedSkillKeys => contextState.selectedSkillKeys; String get assistantModelId => contextState.selectedModelId; AssistantMessageViewMode get messageViewMode => contextState.messageViewMode; @@ -1134,7 +1045,6 @@ class TaskThread { List? messages, bool? archived, AssistantMessageViewMode? messageViewMode, - List? importedSkills, List? selectedSkillKeys, String? assistantModelId, ThreadSelectionSource? assistantModelSource, @@ -1158,7 +1068,6 @@ class TaskThread { contextState: (contextState ?? this.contextState).copyWith( messages: messages, messageViewMode: messageViewMode, - importedSkills: importedSkills, selectedSkillKeys: selectedSkillKeys, selectedModelId: assistantModelId, selectedModelSource: assistantModelSource, @@ -1276,7 +1185,6 @@ class TaskThread { 'messages': json['messages'], 'selectedModelId': json['assistantModelId'], 'selectedSkillKeys': json['selectedSkillKeys'], - 'importedSkills': json['importedSkills'], 'permissionLevel': json['permissionLevel'], 'messageViewMode': json['messageViewMode'], 'latestResolvedRuntimeModel': json['latestResolvedRuntimeModel'], diff --git a/test/features/assistant/assistant_lower_pane_test.dart b/test/features/assistant/assistant_lower_pane_test.dart index 527a27bd..7c35aaee 100644 --- a/test/features/assistant/assistant_lower_pane_test.dart +++ b/test/features/assistant/assistant_lower_pane_test.dart @@ -3,6 +3,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:xworkmate/app/app_controller.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_clipboard.dart'; import 'package:xworkmate/features/assistant/assistant_page_composer_skill_models.dart'; +import 'package:xworkmate/features/assistant/assistant_page_composer_skill_picker.dart'; import 'package:xworkmate/features/assistant/assistant_page_main.dart'; import 'package:xworkmate/runtime/runtime_models.dart'; import 'package:xworkmate/theme/app_theme.dart'; @@ -320,7 +321,7 @@ void main() { }); testWidgets( - 'keeps task dialog modes selectable when only OpenClaw is live', + 'does not fabricate agent mode when only OpenClaw gateway is live', (tester) async { final controller = AppController( environmentOverride: const {}, @@ -355,13 +356,89 @@ void main() { ); await tester.pumpAndSettle(); - final agentItem = tester - .widget>( - find.byKey( - const Key('assistant-execution-target-menu-item-agent'), - ), - ); - expect(agentItem.enabled, isTrue); + expect( + find.byKey(const Key('assistant-execution-target-menu-item-agent')), + findsNothing, + ); + expect( + find.byKey(const Key('assistant-execution-target-menu-item-gateway')), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'allows switching from Gateway back to Agent when bridge reports both', + (tester) async { + final controller = AppController( + environmentOverride: const {}, + initialBridgeProviderCatalog: const [ + SingleAgentProvider.codex, + SingleAgentProvider.opencode, + ], + initialGatewayProviderCatalog: const [ + SingleAgentProvider.openclaw, + ], + initialAvailableExecutionTargets: const [ + AssistantExecutionTarget.agent, + AssistantExecutionTarget.gateway, + ], + ); + addTearDown(controller.dispose); + + await controller.sessionsController.switchSession( + 'unit-fixture-task-a', + ); + controller.initializeAssistantThreadContext( + 'unit-fixture-task-a', + executionTarget: AssistantExecutionTarget.gateway, + messageViewMode: controller.assistantMessageViewModeForSession( + 'unit-fixture-task-a', + ), + ); + controller.notifyListeners(); + + await tester.pumpWidget( + _buildTestApp(child: _buildLowerPane(controller: controller)), + ); + await tester.pumpAndSettle(); + + expect(controller.currentAssistantExecutionTarget.isGateway, isTrue); + + await tester.tap( + find.byKey(const Key('assistant-execution-target-button')), + ); + await tester.pumpAndSettle(); + + expect( + find.byKey(const Key('assistant-execution-target-menu-item-agent')), + findsOneWidget, + ); + expect( + find.byKey(const Key('assistant-execution-target-menu-item-gateway')), + findsOneWidget, + ); + + await tester.tap( + find.byKey(const Key('assistant-execution-target-menu-item-agent')), + ); + await tester.pumpAndSettle(); + + expect(controller.currentAssistantExecutionTarget.isAgent, isTrue); + expect( + controller + .providerCatalogForExecutionTarget(AssistantExecutionTarget.agent) + .map((provider) => provider.providerId), + const ['codex', 'opencode'], + ); + expect( + controller + .providerCatalogForExecutionTarget( + AssistantExecutionTarget.gateway, + ) + .map((provider) => provider.providerId), + const [kCanonicalGatewayProviderId], + ); }, ); @@ -396,6 +473,113 @@ void main() { expect(sendCount, 1); }); + + testWidgets('groups all visible skills by source', (tester) async { + final toggledKeys = []; + final searchController = TextEditingController(); + final focusNode = FocusNode(); + addTearDown(searchController.dispose); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + _buildTestApp( + child: SkillPickerPopoverInternal( + maxHeight: 360, + searchController: searchController, + searchFocusNode: focusNode, + selectedSkillKeys: const [], + filteredSkills: const [ + ComposerSkillOptionInternal( + key: 'pdf', + label: 'PDF', + description: 'Create PDF files', + sourceLabel: 'openclaw-workspace', + groupLabel: 'Workspace Skills', + groupSortOrder: 0, + icon: Icons.key_rounded, + ), + ComposerSkillOptionInternal( + key: 'browser-automation', + label: 'Browser Automation', + description: 'Automate browsers', + sourceLabel: 'agents-skills-personal', + groupLabel: 'Agent Skills', + groupSortOrder: 1, + icon: Icons.key_rounded, + ), + ComposerSkillOptionInternal( + key: 'gateway-search', + label: 'Gateway Search', + description: 'Search through the gateway', + sourceLabel: 'gateway', + groupLabel: 'Gateway Skills', + groupSortOrder: 2, + icon: Icons.key_rounded, + ), + ], + isLoading: false, + hasQuery: false, + onQueryChanged: (_) {}, + onToggleSkill: toggledKeys.add, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Workspace Skills'), findsOneWidget); + expect(find.text('Agent Skills'), findsOneWidget); + expect(find.text('Gateway Skills'), findsOneWidget); + + await tester.tap( + find.byKey( + const ValueKey('assistant-skill-option-browser-automation'), + ), + ); + await tester.pump(); + + expect(toggledKeys, const ['browser-automation']); + }); + + testWidgets('search results only keep groups with matching skills', ( + tester, + ) async { + final searchController = TextEditingController(text: 'gateway'); + final focusNode = FocusNode(); + addTearDown(searchController.dispose); + addTearDown(focusNode.dispose); + + await tester.pumpWidget( + _buildTestApp( + child: SkillPickerPopoverInternal( + maxHeight: 360, + searchController: searchController, + searchFocusNode: focusNode, + selectedSkillKeys: const [], + filteredSkills: const [ + ComposerSkillOptionInternal( + key: 'gateway-search', + label: 'Gateway Search', + description: 'Search through the gateway', + sourceLabel: 'gateway', + groupLabel: 'Gateway Skills', + groupSortOrder: 2, + icon: Icons.key_rounded, + ), + ], + isLoading: false, + hasQuery: true, + onQueryChanged: (_) {}, + onToggleSkill: (_) {}, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Gateway Skills'), findsOneWidget); + expect(find.text('Gateway Search'), findsOneWidget); + expect(find.text('Workspace Skills'), findsNothing); + expect(find.text('Agent Skills'), findsNothing); + }); }); } diff --git a/test/runtime/assistant_execution_target_test.dart b/test/runtime/assistant_execution_target_test.dart index 0b257639..929b1062 100644 --- a/test/runtime/assistant_execution_target_test.dart +++ b/test/runtime/assistant_execution_target_test.dart @@ -476,10 +476,90 @@ void main() { expect(option.key, 'browser-fetch'); expect(option.label, 'Browser Fetch'); expect(option.description, 'Bridge-managed browser skill'); + expect(option.groupLabel, 'Gateway Skills'); expect(option.icon, Icons.key_rounded); }, ); + test('all bridge skill sources remain selectable and routed', () async { + final fakeGoTaskService = _RecordingGoTaskServiceClient(); + final controller = _connectedGatewayController(fakeGoTaskService); + addTearDown(controller.dispose); + controller.skillsControllerInternal.itemsInternal = + const [ + GatewaySkillSummary( + name: 'Workspace PDF', + description: 'Write PDF documents', + source: 'openclaw-workspace', + skillKey: 'pdf', + primaryEnv: null, + eligible: true, + disabled: false, + missingBins: [], + missingEnv: [], + missingConfig: [], + ), + GatewaySkillSummary( + name: 'Browser Automation', + description: 'Use browser automation', + source: 'agents-skills-personal', + skillKey: 'browser-automation', + primaryEnv: null, + eligible: true, + disabled: false, + missingBins: [], + missingEnv: [], + missingConfig: [], + ), + GatewaySkillSummary( + name: 'Gateway Search', + description: 'Search through the gateway', + source: 'gateway', + skillKey: 'gateway-search', + primaryEnv: null, + eligible: true, + disabled: false, + missingBins: [], + missingEnv: [], + missingConfig: [], + ), + ]; + await _selectGatewaySession(controller, 'unit-skill-source-groups-task'); + + await controller.toggleAssistantSkillForSession( + 'unit-skill-source-groups-task', + 'browser-automation', + ); + + final routing = controller.buildExternalAcpRoutingForSessionInternal( + 'unit-skill-source-groups-task', + ); + expect(routing.availableSkills.map((item) => item.id), const [ + 'pdf', + 'browser-automation', + 'gateway-search', + ]); + expect(routing.explicitSkills, const ['browser-automation']); + expect( + controller.assistantSelectedSkillKeysForSession( + 'unit-skill-source-groups-task', + ), + const ['browser-automation'], + ); + + await controller.sendChatMessage( + '打开网页完成检查', + selectedSkillLabels: const [ + 'Browser Automation (browser-automation)', + ], + ); + + expect(fakeGoTaskService.requests, hasLength(1)); + expect(fakeGoTaskService.requests.single.selectedSkills, const [ + 'Browser Automation (browser-automation)', + ]); + }); + test( 'selected bridge skill is passed to task context with stable key', () async { @@ -520,7 +600,7 @@ void main() { }, ); - test('skill selection ignores stale local imported skills', () { + test('skill selection ignores stale non-bridge skill keys', () { final controller = AppController( environmentOverride: const {}, ); @@ -532,16 +612,7 @@ void main() { ); controller.upsertTaskThreadInternal( 'unit-skill-source-task', - importedSkills: const [ - AssistantThreadSkillEntry( - key: '/tmp/local-only/SKILL.md', - label: 'Local Only', - description: 'stale local skill', - sourcePath: '/tmp/local-only/SKILL.md', - sourceLabel: 'local', - ), - ], - selectedSkillKeys: const ['/tmp/local-only/SKILL.md'], + selectedSkillKeys: const ['stale-non-bridge-skill'], selectedSkillsSource: ThreadSelectionSource.explicit, ); controller.skillsControllerInternal.itemsInternal = diff --git a/test/runtime/gateway_runtime_bridge_skills_test.dart b/test/runtime/gateway_runtime_bridge_skills_test.dart index a2db23c3..a2272db2 100644 --- a/test/runtime/gateway_runtime_bridge_skills_test.dart +++ b/test/runtime/gateway_runtime_bridge_skills_test.dart @@ -59,6 +59,8 @@ void main() { 'payload': { 'workspaceDir': '/home/ubuntu/.openclaw/workspace', 'managedSkillsDir': '/home/ubuntu/.openclaw/skills', + 'agentId': 'main', + 'agentSkillFilter': ['it-infra-continuous-png'], 'skills': >[ { 'name': 'it-infra-continuous-png', @@ -67,6 +69,25 @@ void main() { 'skillKey': 'it-infra-continuous-png', 'eligible': true, 'disabled': false, + 'blockedByAgentFilter': false, + 'modelVisible': true, + 'commandVisible': true, + 'missing': { + 'bins': [], + 'env': [], + 'config': [], + }, + }, + { + 'name': 'blocked-for-agent', + 'description': 'Visible in status, hidden from model.', + 'source': 'openclaw-workspace', + 'skillKey': 'blocked-for-agent', + 'eligible': true, + 'disabled': false, + 'blockedByAgentFilter': true, + 'modelVisible': false, + 'commandVisible': false, 'missing': { 'bins': [], 'env': [], @@ -133,9 +154,12 @@ void main() { (observedGatewayRequests.single['params'] as Map)['agentId'], 'main', ); - expect(controller.items, hasLength(1)); - expect(controller.items.single.skillKey, 'it-infra-continuous-png'); - expect(controller.items.single.eligible, isTrue); + expect(controller.items, hasLength(2)); + expect(controller.items.map((item) => item.skillKey), const [ + 'it-infra-continuous-png', + 'blocked-for-agent', + ]); + expect(controller.items.first.eligible, isTrue); }, ); }