Merge branch 'codex/adjust-workspace-path-semantics'
This commit is contained in:
commit
3c3f8a420a
@ -4107,24 +4107,34 @@ class AppController extends ChangeNotifier {
|
||||
Future<List<AssistantThreadSkillEntry>>
|
||||
_scanSingleAgentLocalSkillEntries() async {
|
||||
final dedupedByName = <String, AssistantThreadSkillEntry>{};
|
||||
final dedupedPriorityByName = <String, int>{};
|
||||
for (final rootSpec in _singleAgentLocalSkillScanRoots) {
|
||||
final root = Directory(_resolveSingleAgentSkillRootPath(rootSpec.path));
|
||||
if (!await root.exists()) {
|
||||
continue;
|
||||
}
|
||||
await for (final entity in root.list(
|
||||
recursive: true,
|
||||
followLinks: false,
|
||||
final rootPriority = _singleAgentSkillRootPriority(rootSpec);
|
||||
for (final resolvedRootPath in _resolveSingleAgentSkillRootPaths(
|
||||
rootSpec.path,
|
||||
)) {
|
||||
if (entity is! File || entity.uri.pathSegments.last != 'SKILL.md') {
|
||||
final root = Directory(resolvedRootPath);
|
||||
if (!await root.exists()) {
|
||||
continue;
|
||||
}
|
||||
final entry = await _skillEntryFromFile(entity, rootSpec);
|
||||
final normalizedName = entry.label.trim().toLowerCase();
|
||||
if (normalizedName.isEmpty) {
|
||||
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);
|
||||
final normalizedName = entry.label.trim().toLowerCase();
|
||||
if (normalizedName.isEmpty) {
|
||||
continue;
|
||||
}
|
||||
final existingPriority = dedupedPriorityByName[normalizedName];
|
||||
if (existingPriority == null || rootPriority >= existingPriority) {
|
||||
dedupedByName[normalizedName] = entry;
|
||||
dedupedPriorityByName[normalizedName] = rootPriority;
|
||||
}
|
||||
}
|
||||
dedupedByName[normalizedName] = entry;
|
||||
}
|
||||
}
|
||||
final entries = dedupedByName.values.toList(growable: false);
|
||||
@ -4132,6 +4142,13 @@ class AppController extends ChangeNotifier {
|
||||
return entries;
|
||||
}
|
||||
|
||||
int _singleAgentSkillRootPriority(_SingleAgentSkillScanRoot root) {
|
||||
return switch (root.scope) {
|
||||
'workspace' => 0,
|
||||
_ => 1,
|
||||
};
|
||||
}
|
||||
|
||||
List<_SingleAgentSkillScanRoot> _resolveDefaultSingleAgentSkillScanRoots() {
|
||||
return <_SingleAgentSkillScanRoot>[
|
||||
..._defaultGatewayOnlySkillScanRoots,
|
||||
@ -4156,25 +4173,29 @@ class AppController extends ChangeNotifier {
|
||||
_SingleAgentSkillScanRoot _singleAgentSkillScanRootFromOverride(
|
||||
String rawPath,
|
||||
) {
|
||||
final normalizedPath = rawPath.trim();
|
||||
final normalizedPath = rawPath.trim().replaceFirst(RegExp(r'^\./'), '');
|
||||
final lowered = normalizedPath.toLowerCase();
|
||||
final workspacePath = settings.workspacePath.trim();
|
||||
final normalizedWorkspace = workspacePath.endsWith('/')
|
||||
? workspacePath
|
||||
: '$workspacePath/';
|
||||
final workspaceBases = _singleAgentRelativeSkillRootBasePaths();
|
||||
final inferredWorkspace =
|
||||
lowered.contains('/workspace/.agents/') ||
|
||||
lowered.contains('/workspace/.claude/') ||
|
||||
lowered.contains('/workspace/.codex/');
|
||||
lowered.contains('/workspace/.codex/') ||
|
||||
lowered.contains('/workspace/.workbuddy/');
|
||||
final explicitWorkspaceRoot =
|
||||
lowered.startsWith('.agents/') ||
|
||||
lowered.startsWith('.claude/') ||
|
||||
lowered.startsWith('.codex/') ||
|
||||
lowered.startsWith('.workbuddy/');
|
||||
final scopedToWorkspace = workspaceBases.any((basePath) {
|
||||
final normalizedBase = basePath.endsWith('/')
|
||||
? basePath
|
||||
: '$basePath/';
|
||||
return normalizedPath == basePath ||
|
||||
normalizedPath.startsWith(normalizedBase);
|
||||
});
|
||||
final scope = normalizedPath.startsWith('/etc/')
|
||||
? 'system'
|
||||
: (workspacePath.isNotEmpty &&
|
||||
(normalizedPath == workspacePath ||
|
||||
normalizedPath.startsWith(normalizedWorkspace)) ||
|
||||
inferredWorkspace ||
|
||||
lowered.startsWith('.agents/') ||
|
||||
lowered.startsWith('.claude/') ||
|
||||
lowered.startsWith('.codex/'))
|
||||
: (scopedToWorkspace || inferredWorkspace || explicitWorkspaceRoot)
|
||||
? 'workspace'
|
||||
: 'user';
|
||||
return _SingleAgentSkillScanRoot(
|
||||
@ -4184,24 +4205,48 @@ class AppController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveSingleAgentSkillRootPath(String rawPath) {
|
||||
final trimmed = rawPath.trim();
|
||||
List<String> _resolveSingleAgentSkillRootPaths(String rawPath) {
|
||||
final trimmed = rawPath.trim().replaceFirst(RegExp(r'^\./'), '');
|
||||
if (trimmed.isEmpty) {
|
||||
return trimmed;
|
||||
return const <String>[];
|
||||
}
|
||||
if (trimmed.startsWith('/')) {
|
||||
return trimmed;
|
||||
return <String>[trimmed];
|
||||
}
|
||||
if (trimmed.startsWith('~/')) {
|
||||
final home = Platform.environment['HOME']?.trim() ?? '';
|
||||
return home.isEmpty ? trimmed : '$home/${trimmed.substring(2)}';
|
||||
return <String>[home.isEmpty ? trimmed : '$home/${trimmed.substring(2)}'];
|
||||
}
|
||||
final workspacePath = settings.workspacePath.trim();
|
||||
if (workspacePath.isNotEmpty) {
|
||||
return '$workspacePath/$trimmed';
|
||||
return _singleAgentRelativeSkillRootBasePaths()
|
||||
.map((basePath) => '$basePath/$trimmed')
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
List<String> _singleAgentRelativeSkillRootBasePaths() {
|
||||
final paths = <String>[];
|
||||
final seen = <String>{};
|
||||
|
||||
void addCandidate(String rawPath) {
|
||||
final trimmed = rawPath.trim();
|
||||
if (trimmed.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final normalized = trimmed.endsWith('/')
|
||||
? trimmed.substring(0, trimmed.length - 1)
|
||||
: trimmed;
|
||||
if (normalized.isEmpty || !seen.add(normalized)) {
|
||||
return;
|
||||
}
|
||||
paths.add(normalized);
|
||||
}
|
||||
final home = Platform.environment['HOME']?.trim() ?? '';
|
||||
return home.isEmpty ? trimmed : '$home/$trimmed';
|
||||
|
||||
addCandidate(settings.workspacePath);
|
||||
try {
|
||||
addCandidate(Directory.current.path);
|
||||
} catch (_) {
|
||||
// Best effort only for current workspace fallback discovery.
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
String _sourceForSkillRootPath(String path) {
|
||||
@ -4248,7 +4293,10 @@ class AppController extends ChangeNotifier {
|
||||
.where((item) => item.isNotEmpty)
|
||||
.last)
|
||||
.trim();
|
||||
final rootPath = _resolveSingleAgentSkillRootPath(root.path);
|
||||
final rootPath = _resolveBestSingleAgentSkillRootPath(
|
||||
directory.path,
|
||||
root.path,
|
||||
);
|
||||
final relativeSource = directory.path.startsWith(rootPath)
|
||||
? directory.path
|
||||
.substring(rootPath.length)
|
||||
@ -4272,6 +4320,20 @@ class AppController extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
|
||||
String _resolveBestSingleAgentSkillRootPath(
|
||||
String targetPath,
|
||||
String rawRootPath,
|
||||
) {
|
||||
final candidates = _resolveSingleAgentSkillRootPaths(rawRootPath)
|
||||
..sort((left, right) => right.length.compareTo(left.length));
|
||||
for (final candidate in candidates) {
|
||||
if (targetPath.startsWith(candidate)) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return candidates.isNotEmpty ? candidates.first : rawRootPath.trim();
|
||||
}
|
||||
|
||||
void _restoreAssistantThreads(List<AssistantThreadRecord> records) {
|
||||
_assistantThreadRecords.clear();
|
||||
_assistantThreadMessages.clear();
|
||||
|
||||
@ -453,25 +453,36 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController resolves relative single-agent skill roots against workspace path',
|
||||
'AppController uses settings.workspacePath as fallback for relative single-agent skill roots',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-workspace-local-skills-',
|
||||
);
|
||||
final currentWorkspace = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-empty-current-workspace-',
|
||||
);
|
||||
final originalCurrentDirectory = Directory.current;
|
||||
addTearDown(() async {
|
||||
Directory.current = originalCurrentDirectory;
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (await currentWorkspace.exists()) {
|
||||
try {
|
||||
await currentWorkspace.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
Directory.current = currentWorkspace.path;
|
||||
|
||||
await _writeSkill(
|
||||
Directory('${tempDirectory.path}/.codex/skills'),
|
||||
'workspace-only',
|
||||
skillName: 'Workspace Only Skill',
|
||||
description: 'Repo-local skill should be discovered by default roots',
|
||||
description: 'Default workspace fallback should be discovered',
|
||||
);
|
||||
|
||||
final store = SecureConfigStore(
|
||||
@ -505,6 +516,202 @@ void main() {
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController keeps high-priority user roots ahead of workspace fallbacks',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-priority-relative-skills-',
|
||||
);
|
||||
final currentWorkspace = Directory('${tempDirectory.path}/workspace');
|
||||
await currentWorkspace.create(recursive: true);
|
||||
final workbuddyRoot = Directory('${tempDirectory.path}/workbuddy-skills');
|
||||
await _writeSkill(
|
||||
workbuddyRoot,
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'High-priority user root wins',
|
||||
);
|
||||
await _writeSkill(
|
||||
Directory('${currentWorkspace.path}/.codex/skills'),
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Workspace fallback should not override user roots',
|
||||
);
|
||||
|
||||
final originalCurrentDirectory = Directory.current;
|
||||
addTearDown(() async {
|
||||
Directory.current = originalCurrentDirectory;
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
Directory.current = currentWorkspace.path;
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(workspacePath: currentWorkspace.path),
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: <String>[
|
||||
workbuddyRoot.path,
|
||||
'.codex/skills',
|
||||
],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
final sharedSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((item) => item.label == 'Shared Skill');
|
||||
expect(sharedSkill.description, 'High-priority user root wins');
|
||||
expect(sharedSkill.source, 'workbuddy');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController prefers current workspace roots over settings.workspacePath fallback',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-current-workspace-skills-',
|
||||
);
|
||||
final defaultWorkspace = Directory(
|
||||
'${tempDirectory.path}/default-workspace',
|
||||
);
|
||||
final currentWorkspace = Directory(
|
||||
'${tempDirectory.path}/current-workspace',
|
||||
);
|
||||
await currentWorkspace.create(recursive: true);
|
||||
await _writeSkill(
|
||||
Directory('${defaultWorkspace.path}/.codex/skills'),
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Default workspace fallback',
|
||||
);
|
||||
await _writeSkill(
|
||||
Directory('${currentWorkspace.path}/.codex/skills'),
|
||||
'shared-skill',
|
||||
skillName: 'Shared Skill',
|
||||
description: 'Current workspace wins',
|
||||
);
|
||||
|
||||
final originalCurrentDirectory = Directory.current;
|
||||
addTearDown(() async {
|
||||
Directory.current = originalCurrentDirectory;
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
Directory.current = currentWorkspace.path;
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
workspacePath: defaultWorkspace.path,
|
||||
),
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: const <String>['.codex/skills'],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
final sharedSkill = controller
|
||||
.assistantImportedSkillsForSession(controller.currentSessionKey)
|
||||
.firstWhere((item) => item.label == 'Shared Skill');
|
||||
expect(sharedSkill.description, 'Current workspace wins');
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'AppController can return empty skills when relative roots have no matching workspace roots',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{});
|
||||
final tempDirectory = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-empty-relative-skills-',
|
||||
);
|
||||
final emptyWorkspace = await Directory.systemTemp.createTemp(
|
||||
'xworkmate-empty-relative-current-workspace-',
|
||||
);
|
||||
final originalCurrentDirectory = Directory.current;
|
||||
addTearDown(() async {
|
||||
Directory.current = originalCurrentDirectory;
|
||||
if (await tempDirectory.exists()) {
|
||||
try {
|
||||
await tempDirectory.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
if (await emptyWorkspace.exists()) {
|
||||
try {
|
||||
await emptyWorkspace.delete(recursive: true);
|
||||
} catch (_) {}
|
||||
}
|
||||
});
|
||||
Directory.current = emptyWorkspace.path;
|
||||
|
||||
final store = SecureConfigStore(
|
||||
enableSecureStorage: false,
|
||||
databasePathResolver: () async => '${tempDirectory.path}/settings.sqlite3',
|
||||
fallbackDirectoryPathResolver: () async => tempDirectory.path,
|
||||
);
|
||||
await store.initialize();
|
||||
await store.saveSettingsSnapshot(
|
||||
SettingsSnapshot.defaults().copyWith(
|
||||
workspacePath: '',
|
||||
),
|
||||
);
|
||||
|
||||
final controller = AppController(
|
||||
store: store,
|
||||
availableSingleAgentProvidersOverride: const <SingleAgentProvider>[
|
||||
SingleAgentProvider.codex,
|
||||
],
|
||||
singleAgentLocalSkillScanRoots: const <String>['.codex/skills'],
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await _waitFor(() => !controller.initializing);
|
||||
await controller.setAssistantExecutionTarget(
|
||||
AssistantExecutionTarget.singleAgent,
|
||||
);
|
||||
|
||||
expect(
|
||||
controller.assistantImportedSkillsForSession(controller.currentSessionKey),
|
||||
isEmpty,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _writeSkill(
|
||||
|
||||
Loading…
Reference in New Issue
Block a user