Add unit tests for skill capabilities, skill planner, and UV setup
- Implement tests for random ID generation, ensuring preference for crypto.randomUUID. - Create tests for runtime context capabilities, validating the injection of enabled skill capabilities. - Add tests for skill capability parsing, including classification and command example extraction. - Introduce tests for the skill planner, verifying tool call planning based on user requests and attachment requirements. - Establish tests for UV setup, ensuring proper handling of Python installation scenarios and environment checks.
This commit is contained in:
228
electron/gateway/skill-capability-registry.ts
Normal file
228
electron/gateway/skill-capability-registry.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import {
|
||||
getAllSkillConfigs,
|
||||
type SkillConfigRecord,
|
||||
} from '@electron/utils/skill-config';
|
||||
import { getOpenClawConfigDir } from '@electron/utils/paths';
|
||||
import {
|
||||
parseSkillCapability,
|
||||
type SkillCapability,
|
||||
} from './skill-capability-parser';
|
||||
|
||||
type SkillConfigEntry = {
|
||||
enabled?: boolean;
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
type RegistrySnapshot = {
|
||||
capabilities: SkillCapability[];
|
||||
loadedAt: string;
|
||||
source: 'disk-sync' | 'skill-config';
|
||||
};
|
||||
|
||||
const OPENCLAW_CONFIG_FILE = 'openclaw.json';
|
||||
const OPENCLAW_SKILLS_DIR = 'skills';
|
||||
const SKILL_MANIFEST_FILE = 'SKILL.md';
|
||||
|
||||
let registrySnapshot: RegistrySnapshot | null = null;
|
||||
let refreshPromise: Promise<SkillCapability[]> | null = null;
|
||||
|
||||
function uniq(values: string[]): string[] {
|
||||
return Array.from(new Set(values));
|
||||
}
|
||||
|
||||
function cloneCapability(capability: SkillCapability): SkillCapability {
|
||||
return {
|
||||
...capability,
|
||||
allowedTools: [...capability.allowedTools],
|
||||
operationHints: [...capability.operationHints],
|
||||
triggerHints: [...capability.triggerHints],
|
||||
inputExtensions: [...capability.inputExtensions],
|
||||
requiredEnvVars: [...capability.requiredEnvVars],
|
||||
commandExamples: capability.commandExamples ? [...capability.commandExamples] : undefined,
|
||||
renderHints: capability.renderHints
|
||||
? {
|
||||
...capability.renderHints,
|
||||
metadata: capability.renderHints.metadata
|
||||
? { ...capability.renderHints.metadata }
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function sortCapabilities(capabilities: SkillCapability[]): SkillCapability[] {
|
||||
return [...capabilities].sort((left, right) => {
|
||||
if (left.enabled !== right.enabled) {
|
||||
return left.enabled ? -1 : 1;
|
||||
}
|
||||
|
||||
return left.slug.localeCompare(right.slug);
|
||||
});
|
||||
}
|
||||
|
||||
function readManifestContent(manifestPath?: string): string {
|
||||
if (!manifestPath || !existsSync(manifestPath)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return readFileSync(manifestPath, 'utf-8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function setRegistrySnapshot(
|
||||
capabilities: SkillCapability[],
|
||||
source: RegistrySnapshot['source'],
|
||||
): SkillCapability[] {
|
||||
registrySnapshot = {
|
||||
capabilities: sortCapabilities(capabilities),
|
||||
loadedAt: new Date().toISOString(),
|
||||
source,
|
||||
};
|
||||
|
||||
return registrySnapshot.capabilities.map(cloneCapability);
|
||||
}
|
||||
|
||||
function buildCapabilityFromConfig(
|
||||
skillKey: string,
|
||||
config: Partial<SkillConfigRecord> & {
|
||||
enabled?: boolean;
|
||||
apiKey?: string;
|
||||
env?: Record<string, string>;
|
||||
},
|
||||
): SkillCapability {
|
||||
const manifestPath = config.filePath || (config.baseDir ? join(config.baseDir, SKILL_MANIFEST_FILE) : undefined);
|
||||
const manifestContent = readManifestContent(manifestPath);
|
||||
|
||||
return parseSkillCapability({
|
||||
skillKey,
|
||||
config: {
|
||||
...config,
|
||||
filePath: manifestPath,
|
||||
},
|
||||
manifestContent,
|
||||
});
|
||||
}
|
||||
|
||||
function readSkillConfigEntriesSync(): Record<string, SkillConfigEntry> {
|
||||
const configPath = join(getOpenClawConfigDir(), OPENCLAW_CONFIG_FILE);
|
||||
if (!existsSync(configPath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = readFileSync(configPath, 'utf-8');
|
||||
const parsed = JSON.parse(raw) as {
|
||||
skills?: {
|
||||
entries?: Record<string, SkillConfigEntry>;
|
||||
};
|
||||
};
|
||||
return parsed.skills?.entries || {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function readInstalledSkillsSync(): Record<string, Partial<SkillConfigRecord>> {
|
||||
const skillsDir = join(getOpenClawConfigDir(), OPENCLAW_SKILLS_DIR);
|
||||
if (!existsSync(skillsDir)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const result: Record<string, Partial<SkillConfigRecord>> = {};
|
||||
|
||||
for (const entry of readdirSync(skillsDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const baseDir = join(skillsDir, entry.name);
|
||||
const manifestPath = join(baseDir, SKILL_MANIFEST_FILE);
|
||||
if (!existsSync(manifestPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
result[entry.name] = {
|
||||
slug: entry.name,
|
||||
name: entry.name,
|
||||
baseDir,
|
||||
filePath: manifestPath,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildSynchronousSnapshot(): SkillCapability[] {
|
||||
const installedSkills = readInstalledSkillsSync();
|
||||
const configEntries = readSkillConfigEntriesSync();
|
||||
const skillKeys = uniq([
|
||||
...Object.keys(installedSkills),
|
||||
...Object.keys(configEntries),
|
||||
]);
|
||||
|
||||
const capabilities = skillKeys.map((skillKey) => {
|
||||
const installedSkill = installedSkills[skillKey] || {};
|
||||
const configEntry = configEntries[skillKey] || {};
|
||||
return buildCapabilityFromConfig(skillKey, {
|
||||
...installedSkill,
|
||||
enabled: configEntry.enabled,
|
||||
apiKey: configEntry.apiKey,
|
||||
env: configEntry.env,
|
||||
});
|
||||
});
|
||||
|
||||
return setRegistrySnapshot(capabilities, 'disk-sync');
|
||||
}
|
||||
|
||||
function ensureRegistrySnapshot(): RegistrySnapshot {
|
||||
if (!registrySnapshot) {
|
||||
buildSynchronousSnapshot();
|
||||
}
|
||||
|
||||
return registrySnapshot || {
|
||||
capabilities: [],
|
||||
loadedAt: new Date().toISOString(),
|
||||
source: 'disk-sync',
|
||||
};
|
||||
}
|
||||
|
||||
export function hydrateSkillCapabilityRegistry(
|
||||
configs: Record<string, SkillConfigRecord>,
|
||||
): SkillCapability[] {
|
||||
const capabilities = Object.entries(configs).map(([skillKey, config]) =>
|
||||
buildCapabilityFromConfig(skillKey, config),
|
||||
);
|
||||
|
||||
return setRegistrySnapshot(capabilities, 'skill-config');
|
||||
}
|
||||
|
||||
export async function refreshSkillCapabilityRegistry(): Promise<SkillCapability[]> {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = getAllSkillConfigs()
|
||||
.then((configs) => hydrateSkillCapabilityRegistry(configs))
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
return refreshPromise;
|
||||
}
|
||||
|
||||
export function listSkillCapabilities(options?: { enabledOnly?: boolean }): SkillCapability[] {
|
||||
const snapshot = ensureRegistrySnapshot();
|
||||
const capabilities = options?.enabledOnly
|
||||
? snapshot.capabilities.filter((capability) => capability.enabled)
|
||||
: snapshot.capabilities;
|
||||
|
||||
return capabilities.map(cloneCapability);
|
||||
}
|
||||
|
||||
export function getEnabledSkillCapabilities(): SkillCapability[] {
|
||||
return listSkillCapabilities({ enabledOnly: true });
|
||||
}
|
||||
Reference in New Issue
Block a user