fix: group gateway skills and clean imports

This commit is contained in:
Haitao Pan 2026-05-23 12:02:45 +08:00
parent b439d06892
commit c9dcb5bcbc
11 changed files with 460 additions and 184 deletions

View File

@ -184,9 +184,8 @@ class AppController extends ChangeNotifier {
bridgeGatewayProviderCatalogInternal = normalizeSingleAgentProviderList(
initialGatewayProviderCatalog ?? const <SingleAgentProvider>[],
);
bridgeAvailableExecutionTargetsInternal = compactAssistantExecutionTargets(
initialAvailableExecutionTargets ?? const <AssistantExecutionTarget>[],
);
bridgeAvailableExecutionTargetsInternal =
initialAvailableExecutionTargets ?? const <AssistantExecutionTarget>[];
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<SingleAgentProvider> providerCatalogForExecutionTarget(

View File

@ -67,9 +67,7 @@ Future<void> 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<void> 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 <SingleAgentProvider>[];
controller.bridgeGatewayProviderCatalogInternal =
const <SingleAgentProvider>[];
controller.bridgeAvailableExecutionTargetsInternal =
const <AssistantExecutionTarget>[];
}
if (!controller.disposedInternal) {
controller.notifyListeners();

View File

@ -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<void> replaceSingleAgentThreadSkillsInternal(
String sessionKey,
List<AssistantThreadSkillEntry> importedSkills,
) async {
final normalizedSessionKey = normalizedAssistantSessionKeyInternal(
sessionKey,
);
final importedKeys = importedSkills.map((item) => item.key).toSet();
final nextSelected =
(assistantThreadRecordsInternal[normalizedSessionKey]
?.selectedSkillKeys ??
const <String>[])
.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<AssistantThreadSkillEntry>? importedSkills,
List<String>? 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 <AssistantThreadSkillEntry>[];
final importedKeys = nextImportedSkills.map((item) => item.key).toSet();
final nextSelectedSkillKeys =
(selectedSkillKeys ?? existing?.selectedSkillKeys ?? const <String>[])
.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 <String>[];
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 <String>[],
importedSkills: const <AssistantThreadSkillEntry>[],
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 ??

View File

@ -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)

View File

@ -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;
}

View File

@ -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<String>(
'assistant-skill-option-${skill.key}',
final row = groupedSkills[index];
if (row.headerLabel != null) {
return SkillPickerGroupHeaderInternal(
key: ValueKey<String>(
'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<String>(
'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<SkillPickerRowInternal> skillPickerSectionsInternal(
List<ComposerSkillOptionInternal> skills,
) {
final groupsByLabel = <String, List<ComposerSkillOptionInternal>>{};
final groupSortOrders = <String, int>{};
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 = <SkillPickerRowInternal>[];
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,

View File

@ -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();
}

View File

@ -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<String, dynamic> toJson() {
return {
'key': key,
'label': label,
'description': description,
'source': source,
'sourcePath': sourcePath,
'scope': scope,
'sourceLabel': sourceLabel,
};
}
factory AssistantThreadSkillEntry.fromJson(Map<String, dynamic> 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<GatewayChatMessage> messages;
final String selectedModelId;
final List<String> selectedSkillKeys;
final List<AssistantThreadSkillEntry> importedSkills;
final AssistantPermissionLevel permissionLevel;
final AssistantMessageViewMode messageViewMode;
final String latestResolvedRuntimeModel;
@ -765,7 +699,6 @@ class ThreadContextState {
List<GatewayChatMessage>? messages,
String? selectedModelId,
List<String>? selectedSkillKeys,
List<AssistantThreadSkillEntry>? 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 <GatewayChatMessage>[];
final rawImportedSkills = json['importedSkills'];
final importedSkills = rawImportedSkills is List
? rawImportedSkills
.whereType<Map>()
.map(
(item) => AssistantThreadSkillEntry.fromJson(
item.cast<String, dynamic>(),
),
)
.where((item) => item.key.trim().isNotEmpty)
.toList(growable: false)
: const <AssistantThreadSkillEntry>[];
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<GatewayChatMessage>? messages,
bool? archived,
AssistantMessageViewMode? messageViewMode,
List<AssistantThreadSkillEntry>? importedSkills,
List<String>? selectedSkillKeys,
String? assistantModelId,
String? gatewayEntryState,
@ -1041,8 +956,6 @@ class TaskThread {
messages: messages ?? const <GatewayChatMessage>[],
selectedModelId: assistantModelId?.trim() ?? '',
selectedSkillKeys: selectedSkillKeys ?? const <String>[],
importedSkills:
importedSkills ?? const <AssistantThreadSkillEntry>[],
permissionLevel:
permissionLevel ?? AssistantPermissionLevel.defaultAccess,
messageViewMode:
@ -1090,8 +1003,6 @@ class TaskThread {
String get sessionKey => threadId;
List<GatewayChatMessage> get messages => contextState.messages;
List<AssistantThreadSkillEntry> get importedSkills =>
contextState.importedSkills;
List<String> get selectedSkillKeys => contextState.selectedSkillKeys;
String get assistantModelId => contextState.selectedModelId;
AssistantMessageViewMode get messageViewMode => contextState.messageViewMode;
@ -1134,7 +1045,6 @@ class TaskThread {
List<GatewayChatMessage>? messages,
bool? archived,
AssistantMessageViewMode? messageViewMode,
List<AssistantThreadSkillEntry>? importedSkills,
List<String>? 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'],

View File

@ -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 <String, String>{},
@ -355,13 +356,89 @@ void main() {
);
await tester.pumpAndSettle();
final agentItem = tester
.widget<PopupMenuItem<AssistantExecutionTarget>>(
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 <String, String>{},
initialBridgeProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.codex,
SingleAgentProvider.opencode,
],
initialGatewayProviderCatalog: const <SingleAgentProvider>[
SingleAgentProvider.openclaw,
],
initialAvailableExecutionTargets: const <AssistantExecutionTarget>[
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 <String>['codex', 'opencode'],
);
expect(
controller
.providerCatalogForExecutionTarget(
AssistantExecutionTarget.gateway,
)
.map((provider) => provider.providerId),
const <String>[kCanonicalGatewayProviderId],
);
},
);
@ -396,6 +473,113 @@ void main() {
expect(sendCount, 1);
});
testWidgets('groups all visible skills by source', (tester) async {
final toggledKeys = <String>[];
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 <String>[],
filteredSkills: const <ComposerSkillOptionInternal>[
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<String>('assistant-skill-option-browser-automation'),
),
);
await tester.pump();
expect(toggledKeys, const <String>['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 <String>[],
filteredSkills: const <ComposerSkillOptionInternal>[
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);
});
});
}

View File

@ -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>[
GatewaySkillSummary(
name: 'Workspace PDF',
description: 'Write PDF documents',
source: 'openclaw-workspace',
skillKey: 'pdf',
primaryEnv: null,
eligible: true,
disabled: false,
missingBins: <String>[],
missingEnv: <String>[],
missingConfig: <String>[],
),
GatewaySkillSummary(
name: 'Browser Automation',
description: 'Use browser automation',
source: 'agents-skills-personal',
skillKey: 'browser-automation',
primaryEnv: null,
eligible: true,
disabled: false,
missingBins: <String>[],
missingEnv: <String>[],
missingConfig: <String>[],
),
GatewaySkillSummary(
name: 'Gateway Search',
description: 'Search through the gateway',
source: 'gateway',
skillKey: 'gateway-search',
primaryEnv: null,
eligible: true,
disabled: false,
missingBins: <String>[],
missingEnv: <String>[],
missingConfig: <String>[],
),
];
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 <String>[
'pdf',
'browser-automation',
'gateway-search',
]);
expect(routing.explicitSkills, const <String>['browser-automation']);
expect(
controller.assistantSelectedSkillKeysForSession(
'unit-skill-source-groups-task',
),
const <String>['browser-automation'],
);
await controller.sendChatMessage(
'打开网页完成检查',
selectedSkillLabels: const <String>[
'Browser Automation (browser-automation)',
],
);
expect(fakeGoTaskService.requests, hasLength(1));
expect(fakeGoTaskService.requests.single.selectedSkills, const <String>[
'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 <String, String>{},
);
@ -532,16 +612,7 @@ void main() {
);
controller.upsertTaskThreadInternal(
'unit-skill-source-task',
importedSkills: const <AssistantThreadSkillEntry>[
AssistantThreadSkillEntry(
key: '/tmp/local-only/SKILL.md',
label: 'Local Only',
description: 'stale local skill',
sourcePath: '/tmp/local-only/SKILL.md',
sourceLabel: 'local',
),
],
selectedSkillKeys: const <String>['/tmp/local-only/SKILL.md'],
selectedSkillKeys: const <String>['stale-non-bridge-skill'],
selectedSkillsSource: ThreadSelectionSource.explicit,
);
controller.skillsControllerInternal.itemsInternal =

View File

@ -59,6 +59,8 @@ void main() {
'payload': <String, dynamic>{
'workspaceDir': '/home/ubuntu/.openclaw/workspace',
'managedSkillsDir': '/home/ubuntu/.openclaw/skills',
'agentId': 'main',
'agentSkillFilter': <String>['it-infra-continuous-png'],
'skills': <Map<String, dynamic>>[
<String, dynamic>{
'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': <String, dynamic>{
'bins': <String>[],
'env': <String>[],
'config': <String>[],
},
},
<String, dynamic>{
'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': <String, dynamic>{
'bins': <String>[],
'env': <String>[],
@ -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 <String>[
'it-infra-continuous-png',
'blocked-for-agent',
]);
expect(controller.items.first.eligible, isTrue);
},
);
}