refactor(assistant): remove desktop local skills loader
This commit is contained in:
parent
48cb2ff517
commit
99b89f0851
@ -40,19 +40,13 @@ class _SingleAgentSkillScanRoot {
|
||||
required this.path,
|
||||
required this.source,
|
||||
required this.scope,
|
||||
this.bookmark = '',
|
||||
});
|
||||
|
||||
final String path;
|
||||
final String source;
|
||||
final String scope;
|
||||
final String bookmark;
|
||||
}
|
||||
|
||||
const String _singleAgentLocalSkillsCacheRelativePath =
|
||||
'cache/single-agent-local-skills.json';
|
||||
const int _singleAgentLocalSkillsCacheSchemaVersion = 2;
|
||||
|
||||
class AppController extends ChangeNotifier {
|
||||
static const List<_SingleAgentSkillScanRoot>
|
||||
_defaultSingleAgentGlobalSkillScanRoots = <_SingleAgentSkillScanRoot>[
|
||||
@ -77,32 +71,12 @@ class AppController extends ChangeNotifier {
|
||||
scope: 'user',
|
||||
),
|
||||
];
|
||||
static const List<_SingleAgentSkillScanRoot>
|
||||
_defaultSingleAgentWorkspaceSkillScanRoots = <_SingleAgentSkillScanRoot>[
|
||||
_SingleAgentSkillScanRoot(
|
||||
path: '.agents/skills',
|
||||
source: 'agents',
|
||||
scope: 'workspace',
|
||||
),
|
||||
_SingleAgentSkillScanRoot(
|
||||
path: '.codex/skills',
|
||||
source: 'codex',
|
||||
scope: 'workspace',
|
||||
),
|
||||
_SingleAgentSkillScanRoot(
|
||||
path: '.workbuddy/skills',
|
||||
source: 'workbuddy',
|
||||
scope: 'workspace',
|
||||
),
|
||||
];
|
||||
|
||||
AppController({
|
||||
SecureConfigStore? store,
|
||||
RuntimeCoordinator? runtimeCoordinator,
|
||||
DesktopPlatformService? desktopPlatformService,
|
||||
UiFeatureManifest? uiFeatureManifest,
|
||||
SkillDirectoryAccessService? skillDirectoryAccessService,
|
||||
List<String>? singleAgentLocalSkillScanRoots,
|
||||
List<SingleAgentProvider>? availableSingleAgentProvidersOverride,
|
||||
ArisBundleRepository? arisBundleRepository,
|
||||
SingleAgentRunner? singleAgentRunner,
|
||||
@ -147,8 +121,6 @@ class AppController extends ChangeNotifier {
|
||||
desktopPlatformService ?? createDesktopPlatformService();
|
||||
_skillDirectoryAccessService =
|
||||
skillDirectoryAccessService ?? createSkillDirectoryAccessService();
|
||||
_singleAgentLocalSkillScanRootOverrides = singleAgentLocalSkillScanRoots
|
||||
?.toList(growable: false);
|
||||
_gatewayAcpClient = GatewayAcpClient(
|
||||
endpointResolver: _resolveGatewayAcpEndpoint,
|
||||
);
|
||||
@ -192,7 +164,6 @@ class AppController extends ChangeNotifier {
|
||||
late final DerivedTasksController _tasksController;
|
||||
late final DesktopPlatformService _desktopPlatformService;
|
||||
late final SkillDirectoryAccessService _skillDirectoryAccessService;
|
||||
late final List<String>? _singleAgentLocalSkillScanRootOverrides;
|
||||
late final GatewayAcpClient _gatewayAcpClient;
|
||||
late final DirectSingleAgentAppServerClient _singleAgentAppServerClient;
|
||||
late final List<SingleAgentProvider>? _availableSingleAgentProvidersOverride;
|
||||
@ -217,9 +188,6 @@ class AppController extends ChangeNotifier {
|
||||
<String, String>{};
|
||||
final DesktopThreadArtifactService _threadArtifactService =
|
||||
DesktopThreadArtifactService();
|
||||
List<AssistantThreadSkillEntry> _singleAgentSharedImportedSkills =
|
||||
const <AssistantThreadSkillEntry>[];
|
||||
bool _singleAgentLocalSkillsHydrated = false;
|
||||
final Map<String, HttpClient> _aiGatewayStreamingClients =
|
||||
<String, HttpClient>{};
|
||||
final Set<String> _aiGatewayPendingSessionKeys = <String>{};
|
||||
@ -256,14 +224,6 @@ class AppController extends ChangeNotifier {
|
||||
Future<void> _assistantThreadPersistQueue = Future<void>.value();
|
||||
Future<void> _settingsObservationQueue = Future<void>.value();
|
||||
|
||||
List<_SingleAgentSkillScanRoot> get _singleAgentGlobalSkillScanRoots =>
|
||||
(_singleAgentLocalSkillScanRootOverrides?.map(
|
||||
_singleAgentGlobalSkillScanRootFromOverride,
|
||||
))?.toList(growable: false) ??
|
||||
settings.authorizedSkillDirectories
|
||||
.map(_singleAgentGlobalSkillScanRootFromAuthorizedDirectory)
|
||||
.toList(growable: false);
|
||||
|
||||
WorkspaceDestination get destination => _destination;
|
||||
UiFeatureManifest get uiFeatureManifest => _uiFeatureManifest;
|
||||
AppCapabilities get capabilities =>
|
||||
@ -2170,15 +2130,10 @@ class AppController extends ChangeNotifier {
|
||||
AssistantExecutionTarget.singleAgent) {
|
||||
return;
|
||||
}
|
||||
if (!_singleAgentLocalSkillsHydrated) {
|
||||
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: false);
|
||||
}
|
||||
final previousImported =
|
||||
_assistantThreadRecords[normalizedSessionKey]?.importedSkills ??
|
||||
const <AssistantThreadSkillEntry>[];
|
||||
final fallbackSkills = await _singleAgentLocalFallbackSkillsForSession(
|
||||
normalizedSessionKey,
|
||||
);
|
||||
const fallbackSkills = <AssistantThreadSkillEntry>[];
|
||||
final provider =
|
||||
singleAgentResolvedProviderForSession(normalizedSessionKey) ??
|
||||
currentSingleAgentResolvedProvider;
|
||||
@ -2238,7 +2193,6 @@ class AppController extends ChangeNotifier {
|
||||
Future<void> refreshSingleAgentLocalSkillsForSession(
|
||||
String sessionKey,
|
||||
) async {
|
||||
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
|
||||
await refreshSingleAgentSkillsForSession(sessionKey);
|
||||
}
|
||||
|
||||
@ -2907,7 +2861,6 @@ class AppController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
_disposed = true;
|
||||
unawaited(_persistSharedSingleAgentLocalSkillsCache());
|
||||
_runtimeEventsSubscription?.cancel();
|
||||
_detachChildListeners();
|
||||
_runtimeCoordinator.dispose();
|
||||
@ -2933,7 +2886,6 @@ class AppController extends ChangeNotifier {
|
||||
try {
|
||||
await _settingsController.initialize();
|
||||
_restoreAssistantThreads(await _store.loadAssistantThreadRecords());
|
||||
await _restoreSharedSingleAgentLocalSkillsCache();
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
@ -2994,7 +2946,6 @@ class AppController extends ChangeNotifier {
|
||||
);
|
||||
await _restoreInitialAssistantSessionSelection();
|
||||
await _ensureActiveAssistantThread();
|
||||
unawaited(_startupRefreshSharedSingleAgentLocalSkillsCache());
|
||||
if (isSingleAgentMode) {
|
||||
await refreshSingleAgentSkillsForSession(currentSessionKey);
|
||||
}
|
||||
@ -3144,22 +3095,6 @@ class AppController extends ChangeNotifier {
|
||||
static bool _isGatewayDraftKey(String key) =>
|
||||
key.startsWith('gateway_token_') || key.startsWith('gateway_password_');
|
||||
|
||||
bool _authorizedSkillDirectoriesChanged(
|
||||
SettingsSnapshot previous,
|
||||
SettingsSnapshot current,
|
||||
) {
|
||||
return jsonEncode(
|
||||
previous.authorizedSkillDirectories
|
||||
.map((item) => item.toJson())
|
||||
.toList(growable: false),
|
||||
) !=
|
||||
jsonEncode(
|
||||
current.authorizedSkillDirectories
|
||||
.map((item) => item.toJson())
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _persistSettingsSnapshot(SettingsSnapshot snapshot) async {
|
||||
final sanitized = _sanitizeFeatureFlagSettings(
|
||||
_sanitizeMultiAgentSettings(
|
||||
@ -3206,16 +3141,6 @@ class AppController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_authorizedSkillDirectoriesChanged(previous, current)) {
|
||||
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
if (assistantExecutionTargetForSession(currentSessionKey) ==
|
||||
AssistantExecutionTarget.singleAgent) {
|
||||
await refreshSingleAgentSkillsForSession(currentSessionKey);
|
||||
}
|
||||
}
|
||||
if (refreshAfterSave) {
|
||||
_recomputeTasks();
|
||||
}
|
||||
@ -4218,221 +4143,9 @@ class AppController extends ChangeNotifier {
|
||||
return target.promptValue;
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentSkillEntries(
|
||||
List<_SingleAgentSkillScanRoot> roots, {
|
||||
String workspaceRef = '',
|
||||
}) async {
|
||||
final dedupedByName = <String, AssistantThreadSkillEntry>{};
|
||||
for (final rootSpec in roots) {
|
||||
var resolvedRootPath = _resolveSingleAgentSkillRootPath(
|
||||
rootSpec.path,
|
||||
workspaceRef: workspaceRef,
|
||||
);
|
||||
if (resolvedRootPath.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
SkillDirectoryAccessHandle? accessHandle;
|
||||
try {
|
||||
if (rootSpec.bookmark.trim().isNotEmpty) {
|
||||
accessHandle = await _skillDirectoryAccessService.openDirectory(
|
||||
AuthorizedSkillDirectory(
|
||||
path: resolvedRootPath,
|
||||
bookmark: rootSpec.bookmark,
|
||||
),
|
||||
);
|
||||
if (accessHandle == null) {
|
||||
continue;
|
||||
}
|
||||
resolvedRootPath = normalizeAuthorizedSkillDirectoryPath(
|
||||
accessHandle.path,
|
||||
);
|
||||
}
|
||||
final root = Directory(resolvedRootPath);
|
||||
if (!await root.exists()) {
|
||||
continue;
|
||||
}
|
||||
await for (final entity in root.list(
|
||||
recursive: true,
|
||||
followLinks: false,
|
||||
)) {
|
||||
if (entity is! File || entity.uri.pathSegments.last != 'SKILL.md') {
|
||||
continue;
|
||||
}
|
||||
final entry = await _skillEntryFromFile(
|
||||
entity,
|
||||
rootSpec,
|
||||
resolvedRootPath,
|
||||
);
|
||||
final normalizedName = entry.label.trim().toLowerCase();
|
||||
if (normalizedName.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
dedupedByName[normalizedName] = entry;
|
||||
}
|
||||
} finally {
|
||||
await accessHandle?.close();
|
||||
}
|
||||
}
|
||||
final entries = dedupedByName.values.toList(growable: false);
|
||||
entries.sort((left, right) => left.label.compareTo(right.label));
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentGlobalSkillEntries() {
|
||||
return _scanSingleAgentSkillEntries(_singleAgentGlobalSkillScanRoots);
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadSkillEntry>>
|
||||
_scanSingleAgentBundledSkillEntries() async {
|
||||
try {
|
||||
final bundle = await _arisBundleRepository.ensureReady();
|
||||
final skillsRoot = Directory(bundle.resolve('skills'));
|
||||
if (!await skillsRoot.exists()) {
|
||||
return const <AssistantThreadSkillEntry>[];
|
||||
}
|
||||
return _scanSingleAgentSkillEntries(const <_SingleAgentSkillScanRoot>[
|
||||
_SingleAgentSkillScanRoot(path: '', source: 'bundle', scope: 'bundle'),
|
||||
], workspaceRef: skillsRoot.path);
|
||||
} catch (_) {
|
||||
return const <AssistantThreadSkillEntry>[];
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadSkillEntry>> _scanSingleAgentWorkspaceSkillEntries(
|
||||
String sessionKey,
|
||||
) {
|
||||
return _scanSingleAgentSkillEntries(
|
||||
_defaultSingleAgentWorkspaceSkillScanRoots,
|
||||
workspaceRef: assistantWorkspaceRefForSession(sessionKey),
|
||||
);
|
||||
}
|
||||
|
||||
_SingleAgentSkillScanRoot _singleAgentGlobalSkillScanRootFromOverride(
|
||||
String rawPath,
|
||||
) {
|
||||
final normalizedPath = rawPath.trim();
|
||||
final lowered = normalizedPath.toLowerCase();
|
||||
return _SingleAgentSkillScanRoot(
|
||||
path: normalizedPath,
|
||||
source: _sourceForSkillRootPath(lowered),
|
||||
scope: normalizedPath.startsWith('/etc/') ? 'system' : 'user',
|
||||
);
|
||||
}
|
||||
|
||||
_SingleAgentSkillScanRoot
|
||||
_singleAgentGlobalSkillScanRootFromAuthorizedDirectory(
|
||||
AuthorizedSkillDirectory directory,
|
||||
) {
|
||||
final normalizedPath = normalizeAuthorizedSkillDirectoryPath(
|
||||
directory.path,
|
||||
);
|
||||
final lowered = normalizedPath.toLowerCase();
|
||||
return _SingleAgentSkillScanRoot(
|
||||
path: normalizedPath,
|
||||
source: _sourceForSkillRootPath(lowered),
|
||||
scope: normalizedPath.startsWith('/etc/') ? 'system' : 'user',
|
||||
bookmark: directory.bookmark,
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveSingleAgentSkillRootPath(
|
||||
String rawPath, {
|
||||
String workspaceRef = '',
|
||||
}) {
|
||||
final trimmed = rawPath.trim().replaceFirst(RegExp(r'^\./'), '');
|
||||
if (trimmed.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
if (trimmed.startsWith('/')) {
|
||||
return trimmed;
|
||||
}
|
||||
if (trimmed.startsWith('~/')) {
|
||||
final home = Platform.environment['HOME']?.trim() ?? '';
|
||||
return home.isEmpty ? trimmed : '$home/${trimmed.substring(2)}';
|
||||
}
|
||||
final normalizedWorkspace = workspaceRef.trim();
|
||||
if (normalizedWorkspace.isEmpty) {
|
||||
return '';
|
||||
}
|
||||
final base = normalizedWorkspace.endsWith('/')
|
||||
? normalizedWorkspace.substring(0, normalizedWorkspace.length - 1)
|
||||
: normalizedWorkspace;
|
||||
return '$base/$trimmed';
|
||||
}
|
||||
|
||||
String _sourceForSkillRootPath(String path) {
|
||||
if (path.startsWith('/etc/skills')) {
|
||||
return 'system';
|
||||
}
|
||||
if (_pathContainsSourceToken(path, 'workbuddy')) {
|
||||
return 'workbuddy';
|
||||
}
|
||||
if (_pathContainsSourceToken(path, 'opencode')) {
|
||||
return 'opencode';
|
||||
}
|
||||
if (_pathContainsSourceToken(path, 'claude')) {
|
||||
return 'claude';
|
||||
}
|
||||
if (_pathContainsSourceToken(path, 'agents')) {
|
||||
return 'agents';
|
||||
}
|
||||
return 'codex';
|
||||
}
|
||||
|
||||
bool _pathContainsSourceToken(String path, String token) {
|
||||
final pattern = RegExp('(^|[./_-])$token([./_-]|\$)');
|
||||
return pattern.hasMatch(path);
|
||||
}
|
||||
|
||||
Future<AssistantThreadSkillEntry> _skillEntryFromFile(
|
||||
File file,
|
||||
_SingleAgentSkillScanRoot root,
|
||||
String rootPath,
|
||||
) async {
|
||||
final content = await file.readAsString();
|
||||
final nameMatch = RegExp(
|
||||
"^name:\\s*[\"']?(.+?)[\"']?\\s*\$",
|
||||
multiLine: true,
|
||||
).firstMatch(content);
|
||||
final descriptionMatch = RegExp(
|
||||
"^description:\\s*[\"']?(.+?)[\"']?\\s*\$",
|
||||
multiLine: true,
|
||||
).firstMatch(content);
|
||||
final directory = file.parent;
|
||||
final label =
|
||||
(nameMatch?.group(1) ??
|
||||
directory.uri.pathSegments
|
||||
.where((item) => item.isNotEmpty)
|
||||
.last)
|
||||
.trim();
|
||||
final relativeSource = directory.path.startsWith(rootPath)
|
||||
? directory.path
|
||||
.substring(rootPath.length)
|
||||
.replaceFirst(RegExp(r'^/'), '')
|
||||
: directory.path;
|
||||
final sourceSegments = <String>[
|
||||
root.source,
|
||||
if (root.scope != root.source) root.scope,
|
||||
].where((item) => item.trim().isNotEmpty).toList(growable: false);
|
||||
final sourceLabel = sourceSegments.join(' · ');
|
||||
return AssistantThreadSkillEntry(
|
||||
key: directory.path,
|
||||
label: label,
|
||||
description: (descriptionMatch?.group(1) ?? '').trim(),
|
||||
source: root.source,
|
||||
sourcePath: file.path,
|
||||
scope: root.scope,
|
||||
sourceLabel: relativeSource.isEmpty
|
||||
? sourceLabel
|
||||
: '$sourceLabel · $relativeSource',
|
||||
);
|
||||
}
|
||||
|
||||
void _restoreAssistantThreads(List<AssistantThreadRecord> records) {
|
||||
_assistantThreadRecords.clear();
|
||||
_assistantThreadMessages.clear();
|
||||
_singleAgentSharedImportedSkills = const <AssistantThreadSkillEntry>[];
|
||||
_singleAgentLocalSkillsHydrated = false;
|
||||
final archivedKeys = settings.assistantArchivedTaskKeys
|
||||
.map(_normalizedAssistantSessionKey)
|
||||
.toSet();
|
||||
@ -4485,132 +4198,6 @@ class AppController extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshSharedSingleAgentLocalSkillsCache({
|
||||
required bool forceRescan,
|
||||
}) async {
|
||||
if (!forceRescan && _singleAgentLocalSkillsHydrated) {
|
||||
return;
|
||||
}
|
||||
if (!forceRescan && await _restoreSharedSingleAgentLocalSkillsCache()) {
|
||||
return;
|
||||
}
|
||||
final globalSkills = await _scanSingleAgentGlobalSkillEntries();
|
||||
final availableSkills = globalSkills.isNotEmpty
|
||||
? globalSkills
|
||||
: await _scanSingleAgentBundledSkillEntries();
|
||||
_singleAgentSharedImportedSkills = availableSkills;
|
||||
_singleAgentLocalSkillsHydrated = true;
|
||||
await _persistSharedSingleAgentLocalSkillsCache();
|
||||
}
|
||||
|
||||
Future<void> ensureSharedSingleAgentLocalSkillsLoaded() async {
|
||||
if (_singleAgentLocalSkillsHydrated) {
|
||||
return;
|
||||
}
|
||||
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: false);
|
||||
}
|
||||
|
||||
Future<void> _startupRefreshSharedSingleAgentLocalSkillsCache() async {
|
||||
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
if (assistantExecutionTargetForSession(currentSessionKey) ==
|
||||
AssistantExecutionTarget.singleAgent) {
|
||||
await refreshSingleAgentSkillsForSession(currentSessionKey);
|
||||
return;
|
||||
}
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
Future<List<AssistantThreadSkillEntry>>
|
||||
_singleAgentLocalFallbackSkillsForSession(String sessionKey) async {
|
||||
final workspaceSkills = await _scanSingleAgentWorkspaceSkillEntries(
|
||||
sessionKey,
|
||||
);
|
||||
return _mergeSingleAgentLocalSkills(
|
||||
globalSkills: _singleAgentSharedImportedSkills,
|
||||
workspaceSkills: workspaceSkills,
|
||||
);
|
||||
}
|
||||
|
||||
List<AssistantThreadSkillEntry> _mergeSingleAgentLocalSkills({
|
||||
required List<AssistantThreadSkillEntry> globalSkills,
|
||||
required List<AssistantThreadSkillEntry> workspaceSkills,
|
||||
}) {
|
||||
final merged = <String, AssistantThreadSkillEntry>{};
|
||||
for (final skill in globalSkills) {
|
||||
final normalizedName = skill.label.trim().toLowerCase();
|
||||
if (normalizedName.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
merged[normalizedName] = skill;
|
||||
}
|
||||
for (final skill in workspaceSkills) {
|
||||
final normalizedName = skill.label.trim().toLowerCase();
|
||||
if (normalizedName.isEmpty || merged.containsKey(normalizedName)) {
|
||||
continue;
|
||||
}
|
||||
merged[normalizedName] = skill;
|
||||
}
|
||||
final entries = merged.values.toList(growable: false);
|
||||
entries.sort((left, right) => left.label.compareTo(right.label));
|
||||
return entries;
|
||||
}
|
||||
|
||||
Future<bool> _restoreSharedSingleAgentLocalSkillsCache() async {
|
||||
try {
|
||||
final payload = await _store.loadSupportJson(
|
||||
_singleAgentLocalSkillsCacheRelativePath,
|
||||
);
|
||||
if (payload == null) {
|
||||
return false;
|
||||
}
|
||||
final schemaVersion = int.tryParse(
|
||||
payload['schemaVersion']?.toString() ?? '',
|
||||
);
|
||||
if (schemaVersion != _singleAgentLocalSkillsCacheSchemaVersion) {
|
||||
return false;
|
||||
}
|
||||
final skills = asList(payload['skills'])
|
||||
.map(asMap)
|
||||
.map(
|
||||
(item) => AssistantThreadSkillEntry.fromJson(
|
||||
item.cast<String, dynamic>(),
|
||||
),
|
||||
)
|
||||
.where((item) => item.key.trim().isNotEmpty && item.label.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
if (skills.isEmpty) {
|
||||
_singleAgentSharedImportedSkills = const <AssistantThreadSkillEntry>[];
|
||||
_singleAgentLocalSkillsHydrated = false;
|
||||
return false;
|
||||
}
|
||||
_singleAgentSharedImportedSkills = skills;
|
||||
_singleAgentLocalSkillsHydrated = true;
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _persistSharedSingleAgentLocalSkillsCache() async {
|
||||
try {
|
||||
await _store.saveSupportJson(
|
||||
_singleAgentLocalSkillsCacheRelativePath,
|
||||
<String, dynamic>{
|
||||
'schemaVersion': _singleAgentLocalSkillsCacheSchemaVersion,
|
||||
'savedAtMs': DateTime.now().millisecondsSinceEpoch.toDouble(),
|
||||
'skills': _singleAgentSharedImportedSkills
|
||||
.map((item) => item.toJson())
|
||||
.toList(growable: false),
|
||||
},
|
||||
);
|
||||
} catch (_) {
|
||||
// Best effort only for local cache persistence.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _replaceSingleAgentThreadSkills(
|
||||
String sessionKey,
|
||||
List<AssistantThreadSkillEntry> importedSkills,
|
||||
@ -5437,16 +5024,6 @@ class AppController extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (_authorizedSkillDirectoriesChanged(previous, current)) {
|
||||
await _refreshSharedSingleAgentLocalSkillsCache(forceRescan: true);
|
||||
if (_disposed) {
|
||||
return;
|
||||
}
|
||||
if (assistantExecutionTargetForSession(currentSessionKey) ==
|
||||
AssistantExecutionTarget.singleAgent) {
|
||||
await refreshSingleAgentSkillsForSession(currentSessionKey);
|
||||
}
|
||||
}
|
||||
_notifyIfActive();
|
||||
}
|
||||
|
||||
|
||||
@ -550,230 +550,6 @@ void main() {
|
||||
expect(find.text('远程 OpenClaw Gateway'), findsWidgets);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'AssistantPage shows a persistent skill popover in single-agent mode and keeps thread selections isolated',
|
||||
(WidgetTester tester) async {
|
||||
late final Directory tempDirectory;
|
||||
late final AppController controller;
|
||||
await tester.runAsync(() async {
|
||||
tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-assistant-skills-ui-',
|
||||
);
|
||||
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
|
||||
final codexRoot = Directory('${tempDirectory.path}/codex-skills');
|
||||
final workbuddyRoot = Directory(
|
||||
'${tempDirectory.path}/workbuddy-skills',
|
||||
);
|
||||
await _writeSkill(
|
||||
agentsRoot,
|
||||
'browser',
|
||||
skillName: 'Browser Automation',
|
||||
description: 'Browse websites',
|
||||
);
|
||||
await _writeSkill(
|
||||
codexRoot,
|
||||
'ppt',
|
||||
skillName: 'PPT',
|
||||
description: 'Presentation skill',
|
||||
);
|
||||
await _writeSkill(
|
||||
workbuddyRoot,
|
||||
'wordx',
|
||||
skillName: 'WordX',
|
||||
description: 'Document skill',
|
||||
);
|
||||
|
||||
controller = await _createControllerWithThreadRecords(
|
||||
records: const <AssistantThreadRecord>[],
|
||||
useFakeGatewayRuntime: true,
|
||||
singleAgentLocalSkillScanRoots: <String>[
|
||||
agentsRoot.path,
|
||||
codexRoot.path,
|
||||
workbuddyRoot.path,
|
||||
],
|
||||
);
|
||||
});
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.view.physicalSize = const Size(1600, 1000);
|
||||
addTearDown(() {
|
||||
tester.view.resetPhysicalSize();
|
||||
tester.view.resetDevicePixelRatio();
|
||||
});
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
locale: const Locale('zh'),
|
||||
supportedLocales: const [Locale('zh'), Locale('en')],
|
||||
localizationsDelegates: GlobalMaterialLocalizations.delegates,
|
||||
theme: AppTheme.light(),
|
||||
darkTheme: AppTheme.dark(),
|
||||
home: Scaffold(
|
||||
body: AssistantPage(controller: controller, onOpenDetail: (_) {}),
|
||||
),
|
||||
),
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
await tester.runAsync(() async {
|
||||
await _waitForCondition(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
)
|
||||
.length ==
|
||||
3,
|
||||
);
|
||||
});
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-skill-picker-button')));
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(
|
||||
find.byKey(const Key('assistant-skill-picker-popover')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-skill-picker-dialog')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('assistant-skill-picker-search')),
|
||||
'browser',
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
expect(find.text('Browser Automation'), findsOneWidget);
|
||||
expect(find.text('PPT'), findsNothing);
|
||||
|
||||
final browserSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((skill) => skill.label == 'Browser Automation');
|
||||
final pptSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((skill) => skill.label == 'PPT');
|
||||
final wordxSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((skill) => skill.label == 'WordX');
|
||||
|
||||
await tester.tap(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-skill-option-${browserSkill.key}'),
|
||||
),
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-skill-picker-popover')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-selected-skill-${browserSkill.key}'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.enterText(
|
||||
find.byKey(const Key('assistant-skill-picker-search')),
|
||||
'',
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
await tester.tap(
|
||||
find.byKey(ValueKey<String>('assistant-skill-option-${pptSkill.key}')),
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-skill-picker-popover')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-selected-skill-${pptSkill.key}'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tapAt(const Offset(24, 24));
|
||||
await _pumpForUiSync(tester);
|
||||
expect(
|
||||
find.byKey(const Key('assistant-skill-picker-popover')),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:task-b',
|
||||
title: 'Task B',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
);
|
||||
await tester.runAsync(() async {
|
||||
await controller.switchSession('draft:task-b');
|
||||
});
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-selected-skill-${browserSkill.key}'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-selected-skill-${pptSkill.key}'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('assistant-skill-picker-button')));
|
||||
await _pumpForUiSync(tester);
|
||||
await tester.tap(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-skill-option-${wordxSkill.key}'),
|
||||
),
|
||||
);
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-selected-skill-${wordxSkill.key}'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.runAsync(() async {
|
||||
await controller.switchSession('main');
|
||||
});
|
||||
await _pumpForUiSync(tester);
|
||||
|
||||
expect(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-selected-skill-${browserSkill.key}'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-selected-skill-${pptSkill.key}'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(
|
||||
ValueKey<String>('assistant-selected-skill-${wordxSkill.key}'),
|
||||
),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('AssistantPage hides gated attachment and multi-agent actions', (
|
||||
WidgetTester tester,
|
||||
) async {
|
||||
@ -1231,7 +1007,6 @@ Future<AppController> _createControllerWithThreadRecords({
|
||||
WidgetTester? tester,
|
||||
required List<AssistantThreadRecord> records,
|
||||
bool useFakeGatewayRuntime = false,
|
||||
List<String>? singleAgentLocalSkillScanRoots,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
@ -1289,7 +1064,6 @@ Future<AppController> _createControllerWithThreadRecords({
|
||||
codex: _FakeCodexRuntime(),
|
||||
)
|
||||
: null,
|
||||
singleAgentLocalSkillScanRoots: singleAgentLocalSkillScanRoots,
|
||||
);
|
||||
final stopwatch = Stopwatch()..start();
|
||||
while (controller.initializing) {
|
||||
@ -1305,34 +1079,11 @@ Future<AppController> _createControllerWithThreadRecords({
|
||||
return controller;
|
||||
}
|
||||
|
||||
Future<void> _writeSkill(
|
||||
Directory root,
|
||||
String folderName, {
|
||||
required String skillName,
|
||||
required String description,
|
||||
}) 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<void> _pumpForUiSync(WidgetTester tester) async {
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 200));
|
||||
}
|
||||
|
||||
Future<void> _waitForCondition(bool Function() predicate) async {
|
||||
final deadline = DateTime.now().add(const Duration(seconds: 20));
|
||||
while (!predicate()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('Timed out waiting for condition');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeGatewayRuntime extends GatewayRuntime {
|
||||
_FakeGatewayRuntime({required super.store})
|
||||
: super(identityStore: DeviceIdentityStore(store));
|
||||
|
||||
@ -1,894 +0,0 @@
|
||||
@TestOn('vm')
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:xworkmate/app/app_controller.dart';
|
||||
import 'package:xworkmate/runtime/runtime_models.dart';
|
||||
import 'package:xworkmate/runtime/secure_config_store.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'AppController scans shared single-agent global skills on startup and shares them across providers',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-single-agent-shared-skills-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final systemRoot = Directory('${tempDirectory.path}/etc-skills');
|
||||
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
|
||||
final codexRoot = Directory('${tempDirectory.path}/codex-skills');
|
||||
final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills');
|
||||
await _writeSkill(
|
||||
systemRoot,
|
||||
'analysis',
|
||||
skillName: 'Analysis',
|
||||
description: 'System version should be overridden',
|
||||
);
|
||||
await _writeSkill(
|
||||
agentsRoot,
|
||||
'browser',
|
||||
skillName: 'Browser Automation',
|
||||
description: 'Shared browser skill',
|
||||
);
|
||||
await _writeSkill(
|
||||
codexRoot,
|
||||
'ppt',
|
||||
skillName: 'PPT',
|
||||
description: 'Presentation skill',
|
||||
);
|
||||
await _writeSkill(
|
||||
workbuddyRoot,
|
||||
'analysis',
|
||||
skillName: 'Analysis',
|
||||
description: 'WorkBuddy version wins',
|
||||
);
|
||||
await _writeSkill(
|
||||
workbuddyRoot,
|
||||
'cicd-audit',
|
||||
skillName: 'CICD Audit',
|
||||
description: 'Pipeline audit skill',
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
store: await _createStore(tempDirectory.path),
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
SingleAgentProvider.claude,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: <String>[
|
||||
systemRoot.path,
|
||||
agentsRoot.path,
|
||||
codexRoot.path,
|
||||
workbuddyRoot.path,
|
||||
],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await controller.setSingleAgentProvider(SingleAgentProvider.codex);
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
|
||||
final firstSessionKey = controller.currentSessionKey;
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(firstSessionKey)
|
||||
.map((skill) => skill.label),
|
||||
containsAll(const <String>[
|
||||
'Analysis',
|
||||
'Browser Automation',
|
||||
'PPT',
|
||||
'CICD Audit',
|
||||
]),
|
||||
);
|
||||
final analysisSkill = controller
|
||||
.assistantImportedSkillsForSession(firstSessionKey)
|
||||
.firstWhere((skill) => skill.label == 'Analysis');
|
||||
expect(analysisSkill.description, 'WorkBuddy version wins');
|
||||
expect(analysisSkill.source, 'workbuddy');
|
||||
expect(analysisSkill.scope, 'user');
|
||||
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
firstSessionKey,
|
||||
controller
|
||||
.assistantImportedSkillsForSession(firstSessionKey)
|
||||
.firstWhere((skill) => skill.label == 'PPT')
|
||||
.key,
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(firstSessionKey)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['PPT'],
|
||||
);
|
||||
|
||||
await controller.setSingleAgentProvider(SingleAgentProvider.claude);
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(firstSessionKey)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(firstSessionKey)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['PPT'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController hot reloads authorized skill directories from settings.yaml',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
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>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
);
|
||||
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>[
|
||||
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),
|
||||
<String>[agentsRoot.path],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController keeps thread-bound skills isolated and restores them after restart',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-thread-isolation-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
|
||||
final codexRoot = Directory('${tempDirectory.path}/codex-skills');
|
||||
final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills');
|
||||
await _writeSkill(
|
||||
agentsRoot,
|
||||
'browser',
|
||||
skillName: 'Browser',
|
||||
description: 'Browser tasks',
|
||||
);
|
||||
await _writeSkill(
|
||||
codexRoot,
|
||||
'ppt',
|
||||
skillName: 'PPT',
|
||||
description: 'Presentation tasks',
|
||||
);
|
||||
await _writeSkill(
|
||||
workbuddyRoot,
|
||||
'wordx',
|
||||
skillName: 'WordX',
|
||||
description: 'Document tasks',
|
||||
);
|
||||
await _writeSkill(
|
||||
workbuddyRoot,
|
||||
'cicd-audit',
|
||||
skillName: 'CICD Audit',
|
||||
description: 'Pipeline tasks',
|
||||
);
|
||||
|
||||
Future<SecureConfigStore> createStore() {
|
||||
return _createStore(tempDirectory.path);
|
||||
}
|
||||
|
||||
Future<AppController> createController() async {
|
||||
return AppController(
|
||||
store: await createStore(),
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
SingleAgentProvider.claude,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: <String>[
|
||||
agentsRoot.path,
|
||||
codexRoot.path,
|
||||
workbuddyRoot.path,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final controller = await createController();
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
final taskA = controller.currentSessionKey;
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
taskA,
|
||||
controller
|
||||
.assistantImportedSkillsForSession(taskA)
|
||||
.firstWhere((skill) => skill.label == 'PPT')
|
||||
.key,
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:task-b',
|
||||
title: 'Task B',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
singleAgentProvider: SingleAgentProvider.claude,
|
||||
);
|
||||
await controller.switchSession('draft:task-b');
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
final taskB = controller.currentSessionKey;
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
taskB,
|
||||
controller
|
||||
.assistantImportedSkillsForSession(taskB)
|
||||
.firstWhere((skill) => skill.label == 'WordX')
|
||||
.key,
|
||||
);
|
||||
|
||||
controller.initializeAssistantThreadContext(
|
||||
'draft:task-c',
|
||||
title: 'Task C',
|
||||
executionTarget: AssistantExecutionTarget.singleAgent,
|
||||
messageViewMode: AssistantMessageViewMode.rendered,
|
||||
);
|
||||
await controller.switchSession('draft:task-c');
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
final taskC = controller.currentSessionKey;
|
||||
await controller.toggleAssistantSkillForSession(
|
||||
taskC,
|
||||
controller
|
||||
.assistantImportedSkillsForSession(taskC)
|
||||
.firstWhere((skill) => skill.label == 'Browser')
|
||||
.key,
|
||||
);
|
||||
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(taskA)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['PPT'],
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(taskB)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['WordX'],
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantSelectedSkillsForSession(taskC)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['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 <String>['PPT'],
|
||||
);
|
||||
await restoredController.switchSession(taskB);
|
||||
await _waitFor(
|
||||
() =>
|
||||
restoredController
|
||||
.assistantImportedSkillsForSession(taskB)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
restoredController
|
||||
.assistantSelectedSkillsForSession(taskB)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['WordX'],
|
||||
);
|
||||
await restoredController.switchSession(taskC);
|
||||
await _waitFor(
|
||||
() =>
|
||||
restoredController
|
||||
.assistantImportedSkillsForSession(taskC)
|
||||
.length ==
|
||||
4,
|
||||
);
|
||||
expect(
|
||||
restoredController
|
||||
.assistantSelectedSkillsForSession(taskC)
|
||||
.map((skill) => skill.label),
|
||||
const <String>['Browser'],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController restores shared global skills cache on restart',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-single-agent-skills-cache-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final agentsRoot = Directory('${tempDirectory.path}/agents-skills');
|
||||
final codexRoot = Directory('${tempDirectory.path}/codex-skills');
|
||||
await _writeSkill(
|
||||
agentsRoot,
|
||||
'browser',
|
||||
skillName: 'Browser',
|
||||
description: 'Browser tasks',
|
||||
);
|
||||
await _writeSkill(
|
||||
codexRoot,
|
||||
'ppt',
|
||||
skillName: 'PPT',
|
||||
description: 'Presentation tasks',
|
||||
);
|
||||
|
||||
Future<SecureConfigStore> createStore() {
|
||||
return _createStore(tempDirectory.path);
|
||||
}
|
||||
|
||||
final firstStore = await createStore();
|
||||
final controller = AppController(
|
||||
store: firstStore,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: <String>[
|
||||
agentsRoot.path,
|
||||
codexRoot.path,
|
||||
],
|
||||
);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.length ==
|
||||
2,
|
||||
);
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.map((item) => item.label),
|
||||
containsAll(const <String>['Browser', 'PPT']),
|
||||
);
|
||||
|
||||
final cacheFile = await firstStore.supportFile(
|
||||
'cache/single-agent-local-skills.json',
|
||||
);
|
||||
expect(cacheFile, isNotNull);
|
||||
await _waitFor(() => cacheFile != null && cacheFile.existsSync());
|
||||
controller.dispose();
|
||||
|
||||
if (await agentsRoot.exists()) {
|
||||
await agentsRoot.delete(recursive: true);
|
||||
}
|
||||
if (await codexRoot.exists()) {
|
||||
await codexRoot.delete(recursive: true);
|
||||
}
|
||||
|
||||
final restoredController = AppController(
|
||||
store: await createStore(),
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: <String>[
|
||||
agentsRoot.path,
|
||||
codexRoot.path,
|
||||
],
|
||||
);
|
||||
addTearDown(restoredController.dispose);
|
||||
await _waitFor(() => !restoredController.initializing);
|
||||
await restoredController.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
await _waitFor(
|
||||
() =>
|
||||
restoredController
|
||||
.assistantImportedSkillsForSession(
|
||||
restoredController.currentSessionKey,
|
||||
)
|
||||
.length ==
|
||||
2,
|
||||
);
|
||||
|
||||
expect(
|
||||
restoredController
|
||||
.assistantImportedSkillsForSession(
|
||||
restoredController.currentSessionKey,
|
||||
)
|
||||
.map((item) => item.label),
|
||||
containsAll(const <String>['Browser', 'PPT']),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController uses thread workspaceRef for repo-local fallback',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-workspace-ref-skills-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/.codex/skills'),
|
||||
'workspace-only',
|
||||
skillName: 'Workspace Only Skill',
|
||||
description: 'Repo-local fallback',
|
||||
);
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(
|
||||
workspacePath: '${tempDirectory.path}/unused-default-workspace',
|
||||
),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
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>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: const <String>[],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.any((item) => item.label == 'Workspace Only Skill'),
|
||||
);
|
||||
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.map((item) => item.label),
|
||||
contains('Workspace Only Skill'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController keeps global roots ahead of repo-local fallback and only fills missing skills',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-global-overrides-repo-local-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills');
|
||||
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
|
||||
await _writeSkill(
|
||||
workbuddyRoot,
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Global wins',
|
||||
);
|
||||
await _writeSkill(
|
||||
workbuddyRoot,
|
||||
'global-only',
|
||||
skillName: 'Global Only',
|
||||
description: 'Only from global',
|
||||
);
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/.codex/skills'),
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Repo-local should not override',
|
||||
);
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/.codex/skills'),
|
||||
'workspace-only',
|
||||
skillName: 'Workspace Only',
|
||||
description: 'Only from repo-local',
|
||||
);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(workspacePath: tempDirectory.path),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
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>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: <String>[workbuddyRoot.path],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() =>
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.length ==
|
||||
3,
|
||||
);
|
||||
|
||||
final sharedSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((item) => item.label == 'Shared Skill');
|
||||
expect(sharedSkill.description, 'Global wins');
|
||||
expect(
|
||||
controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.map((item) => item.label),
|
||||
containsAll(const <String>[
|
||||
'Shared Skill',
|
||||
'Global Only',
|
||||
'Workspace Only',
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController scans repo-local skills directories in fixed order and skips missing roots',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-repo-local-order-',
|
||||
);
|
||||
addTearDown(() async {
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
final workspaceRoot = Directory('${tempDirectory.path}/workspace');
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/.agents/skills'),
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Agents version',
|
||||
);
|
||||
await _writeSkill(
|
||||
Directory('${workspaceRoot.path}/.codex/skills'),
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Codex version wins',
|
||||
);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async =>
|
||||
'${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
defaultSupportDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(workspacePath: tempDirectory.path),
|
||||
);
|
||||
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
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>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: const <String>[],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.isNotEmpty,
|
||||
);
|
||||
|
||||
final sharedSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((item) => item.label == 'Shared Skill');
|
||||
expect(sharedSkill.description, 'Codex version wins');
|
||||
expect(sharedSkill.source, 'codex');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController can return empty skills when neither global nor repo-local roots exist',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
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>[
|
||||
AssistantThreadRecord(
|
||||
sessionKey: 'main',
|
||||
messages: const <GatewayChatMessage>[],
|
||||
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>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: const <String>[],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await _waitFor(
|
||||
() => controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.isEmpty,
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.assistantImportedSkillsForSession(
|
||||
controller.currentSessionKey,
|
||||
),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _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<void> _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<void>.delayed(const Duration(milliseconds: 20));
|
||||
}
|
||||
}
|
||||
|
||||
Future<SecureConfigStore> _createStore(String rootPath) async {
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '$rootPath/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => rootPath,
|
||||
defaultSupportDirectoryPathResolver: () async => rootPath,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
_singleAgentTestSettings(workspacePath: rootPath),
|
||||
);
|
||||
return store;
|
||||
}
|
||||
|
||||
SettingsSnapshot _singleAgentTestSettings({required String workspacePath}) {
|
||||
final defaults = SettingsSnapshot.defaults();
|
||||
return defaults.copyWith(
|
||||
gatewayProfiles: replaceGatewayProfileAt(
|
||||
replaceGatewayProfileAt(
|
||||
defaults.gatewayProfiles,
|
||||
kGatewayLocalProfileIndex,
|
||||
defaults.primaryLocalGatewayProfile.copyWith(
|
||||
host: '127.0.0.1',
|
||||
port: 9,
|
||||
tls: false,
|
||||
),
|
||||
),
|
||||
kGatewayRemoteProfileIndex,
|
||||
defaults.primaryRemoteGatewayProfile.copyWith(
|
||||
host: '127.0.0.1',
|
||||
port: 9,
|
||||
tls: false,
|
||||
),
|
||||
),
|
||||
assistantExecutionTarget: AssistantExecutionTarget.singleAgent,
|
||||
workspacePath: workspacePath,
|
||||
);
|
||||
}
|
||||
@ -29,7 +29,6 @@ Future<AppController> createTestController(
|
||||
WidgetTester tester, {
|
||||
DesktopPlatformService? desktopPlatformService,
|
||||
UiFeatureManifest? uiFeatureManifest,
|
||||
List<String>? singleAgentLocalSkillScanRoots,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final testRoot =
|
||||
@ -42,7 +41,6 @@ Future<AppController> createTestController(
|
||||
),
|
||||
desktopPlatformService: desktopPlatformService,
|
||||
uiFeatureManifest: uiFeatureManifest,
|
||||
singleAgentLocalSkillScanRoots: singleAgentLocalSkillScanRoots,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
|
||||
Loading…
Reference in New Issue
Block a user