mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
test: dedupe skills and model config coverage
This commit is contained in:
@@ -20,6 +20,7 @@ async function withAdcCredentialsFile(
|
||||
try {
|
||||
await run({ agentDir, credentialsPath });
|
||||
} finally {
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
rmSync(adcDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
@@ -27,14 +28,18 @@ async function withAdcCredentialsFile(
|
||||
describe("anthropic-vertex implicit provider", () => {
|
||||
it("does not auto-enable from GOOGLE_CLOUD_PROJECT_ID alone", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env: {
|
||||
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
|
||||
GOOGLE_CLOUD_PROJECT_ID: "vertex-project",
|
||||
},
|
||||
});
|
||||
expect(providers?.["anthropic-vertex"]).toBeUndefined();
|
||||
try {
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env: {
|
||||
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
|
||||
GOOGLE_CLOUD_PROJECT_ID: "vertex-project",
|
||||
},
|
||||
});
|
||||
expect(providers?.["anthropic-vertex"]).toBeUndefined();
|
||||
} finally {
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts ADC credentials when the file includes a project_id", async () => {
|
||||
@@ -60,18 +65,14 @@ describe("anthropic-vertex implicit provider", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts ADC credentials when the file only includes a quota_project_id", async () => {
|
||||
it("accepts explicit metadata auth opt-in without local credential files", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
|
||||
const credentialsPath = join(adcDir, "application_default_credentials.json");
|
||||
writeFileSync(credentialsPath, JSON.stringify({ quota_project_id: "vertex-quota" }), "utf8");
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env: {
|
||||
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
|
||||
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
|
||||
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
},
|
||||
});
|
||||
@@ -79,120 +80,39 @@ describe("anthropic-vertex implicit provider", () => {
|
||||
"https://us-east5-aiplatform.googleapis.com",
|
||||
);
|
||||
} finally {
|
||||
rmSync(adcDir, { recursive: true, force: true });
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts ADC credentials when project_id is resolved at runtime", async () => {
|
||||
it("merges the bundled catalog into explicit anthropic-vertex provider overrides", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const adcDir = mkdtempSync(join(tmpdir(), "openclaw-adc-"));
|
||||
const credentialsPath = join(adcDir, "application_default_credentials.json");
|
||||
writeFileSync(credentialsPath, "{}", "utf8");
|
||||
|
||||
try {
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env: {
|
||||
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
|
||||
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
|
||||
GOOGLE_CLOUD_LOCATION: "europe-west4",
|
||||
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
},
|
||||
explicitProviders: {
|
||||
"anthropic-vertex": {
|
||||
baseUrl: "https://europe-west4-aiplatform.googleapis.com",
|
||||
headers: { "x-test-header": "1" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
|
||||
"https://europe-west4-aiplatform.googleapis.com",
|
||||
);
|
||||
expect(providers?.["anthropic-vertex"]?.headers).toEqual({ "x-test-header": "1" });
|
||||
expect(providers?.["anthropic-vertex"]?.models?.map((model) => model.id)).toEqual([
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
]);
|
||||
} finally {
|
||||
rmSync(adcDir, { recursive: true, force: true });
|
||||
rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to the default region when GOOGLE_CLOUD_LOCATION is invalid", async () => {
|
||||
await withAdcCredentialsFile(
|
||||
{ project_id: "vertex-project" },
|
||||
async ({ agentDir, credentialsPath }) => {
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env: {
|
||||
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
|
||||
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
|
||||
GOOGLE_CLOUD_LOCATION: "us-central1.attacker.example",
|
||||
},
|
||||
});
|
||||
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the Vertex global endpoint when GOOGLE_CLOUD_LOCATION=global", async () => {
|
||||
await withAdcCredentialsFile(
|
||||
{ project_id: "vertex-project" },
|
||||
async ({ agentDir, credentialsPath }) => {
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env: {
|
||||
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
|
||||
GOOGLE_APPLICATION_CREDENTIALS: credentialsPath,
|
||||
GOOGLE_CLOUD_LOCATION: "global",
|
||||
},
|
||||
});
|
||||
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe("https://aiplatform.googleapis.com");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts explicit metadata auth opt-in without local credential files", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env: {
|
||||
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
|
||||
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
},
|
||||
});
|
||||
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
|
||||
"https://us-east5-aiplatform.googleapis.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("merges the bundled catalog into explicit anthropic-vertex provider overrides", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env: {
|
||||
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
|
||||
ANTHROPIC_VERTEX_USE_GCP_METADATA: "true",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
},
|
||||
explicitProviders: {
|
||||
"anthropic-vertex": {
|
||||
baseUrl: "https://europe-west4-aiplatform.googleapis.com",
|
||||
headers: { "x-test-header": "1" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers?.["anthropic-vertex"]?.baseUrl).toBe(
|
||||
"https://europe-west4-aiplatform.googleapis.com",
|
||||
);
|
||||
expect(providers?.["anthropic-vertex"]?.headers).toEqual({ "x-test-header": "1" });
|
||||
expect(providers?.["anthropic-vertex"]?.models?.map((model) => model.id)).toEqual([
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not accept generic Kubernetes env without a GCP ADC signal", async () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = await resolveImplicitProvidersForTest({
|
||||
agentDir,
|
||||
env: {
|
||||
...ANTHROPIC_VERTEX_DISCOVERY_ENV,
|
||||
KUBERNETES_SERVICE_HOST: "10.0.0.1",
|
||||
GOOGLE_CLOUD_LOCATION: "us-east5",
|
||||
},
|
||||
});
|
||||
expect(providers?.["anthropic-vertex"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeProviders } from "./models-config.providers.normalize.js";
|
||||
import { normalizeProviderSpecificConfig } from "./models-config.providers.policy.js";
|
||||
import type { ProviderConfig } from "./models-config.providers.secrets.js";
|
||||
|
||||
function buildModel(id: string): NonNullable<ProviderConfig["models"]>[number] {
|
||||
@@ -30,9 +27,21 @@ function buildProvider(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProviderMap(
|
||||
providers: Record<string, ProviderConfig>,
|
||||
): Record<string, ProviderConfig> {
|
||||
let changed = false;
|
||||
const next: Record<string, ProviderConfig> = {};
|
||||
for (const [providerKey, provider] of Object.entries(providers)) {
|
||||
const normalized = normalizeProviderSpecificConfig(providerKey, provider);
|
||||
next[providerKey] = normalized;
|
||||
changed ||= normalized !== provider;
|
||||
}
|
||||
return changed ? next : providers;
|
||||
}
|
||||
|
||||
describe("google-antigravity provider normalization", () => {
|
||||
it("normalizes bare gemini pro IDs only for google-antigravity providers", () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = {
|
||||
"google-antigravity": buildProvider([
|
||||
"gemini-3-pro",
|
||||
@@ -44,7 +53,7 @@ describe("google-antigravity provider normalization", () => {
|
||||
openai: buildProvider(["gpt-5"]),
|
||||
};
|
||||
|
||||
const normalized = normalizeProviders({ providers, agentDir });
|
||||
const normalized = normalizeProviderMap(providers);
|
||||
|
||||
expect(normalized).not.toBe(providers);
|
||||
expect(normalized?.["google-antigravity"]?.models.map((model) => model.id)).toEqual([
|
||||
@@ -58,12 +67,11 @@ describe("google-antigravity provider normalization", () => {
|
||||
});
|
||||
|
||||
it("returns original providers object when no antigravity IDs need normalization", () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = {
|
||||
"google-antigravity": buildProvider(["gemini-3-pro-low", "claude-opus-4-6-thinking"]),
|
||||
};
|
||||
|
||||
const normalized = normalizeProviders({ providers, agentDir });
|
||||
const normalized = normalizeProviderMap(providers);
|
||||
|
||||
expect(normalized).toBe(providers);
|
||||
});
|
||||
@@ -71,7 +79,6 @@ describe("google-antigravity provider normalization", () => {
|
||||
|
||||
describe("google-vertex provider normalization", () => {
|
||||
it("normalizes gemini flash-lite IDs for google-vertex providers", () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = {
|
||||
"google-vertex": buildProvider(["gemini-3.1-flash-lite", "gemini-3-flash-preview"], {
|
||||
api: undefined,
|
||||
@@ -79,7 +86,7 @@ describe("google-vertex provider normalization", () => {
|
||||
openai: buildProvider(["gpt-5"]),
|
||||
};
|
||||
|
||||
const normalized = normalizeProviders({ providers, agentDir });
|
||||
const normalized = normalizeProviderMap(providers);
|
||||
|
||||
expect(normalized).not.toBe(providers);
|
||||
expect(normalized?.["google-vertex"]?.models.map((model) => model.id)).toEqual([
|
||||
@@ -90,14 +97,13 @@ describe("google-vertex provider normalization", () => {
|
||||
});
|
||||
|
||||
it("returns original providers object when no google-vertex IDs need normalization", () => {
|
||||
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
const providers = {
|
||||
"google-vertex": buildProvider(["gemini-3.1-flash-lite-preview", "gemini-3-flash-preview"], {
|
||||
api: undefined,
|
||||
}),
|
||||
};
|
||||
|
||||
const normalized = normalizeProviders({ providers, agentDir });
|
||||
const normalized = normalizeProviderMap(providers);
|
||||
|
||||
expect(normalized).toBe(providers);
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js";
|
||||
import {
|
||||
installModelsConfigTestHooks,
|
||||
@@ -36,17 +36,17 @@ vi.mock("./models-config.providers.js", async () => {
|
||||
|
||||
installModelsConfigTestHooks();
|
||||
|
||||
let clearConfigCache: typeof import("../config/config.js").clearConfigCache;
|
||||
let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot;
|
||||
let loadConfig: typeof import("../config/config.js").loadConfig;
|
||||
let setRuntimeConfigSnapshot: typeof import("../config/config.js").setRuntimeConfigSnapshot;
|
||||
let clearConfigCache: typeof import("../config/io.js").clearConfigCache;
|
||||
let clearRuntimeConfigSnapshot: typeof import("../config/io.js").clearRuntimeConfigSnapshot;
|
||||
let loadConfig: typeof import("../config/io.js").loadConfig;
|
||||
let setRuntimeConfigSnapshot: typeof import("../config/io.js").setRuntimeConfigSnapshot;
|
||||
let ensureOpenClawModelsJson: typeof import("./models-config.js").ensureOpenClawModelsJson;
|
||||
let resetModelsJsonReadyCacheForTest: typeof import("./models-config.js").resetModelsJsonReadyCacheForTest;
|
||||
let readGeneratedModelsJson: typeof import("./models-config.test-utils.js").readGeneratedModelsJson;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ clearConfigCache, clearRuntimeConfigSnapshot, loadConfig, setRuntimeConfigSnapshot } =
|
||||
await import("../config/config.js"));
|
||||
await import("../config/io.js"));
|
||||
({ ensureOpenClawModelsJson, resetModelsJsonReadyCacheForTest } =
|
||||
await import("./models-config.js"));
|
||||
({ readGeneratedModelsJson } = await import("./models-config.test-utils.js"));
|
||||
@@ -132,6 +132,22 @@ function createOpenAiHeaderRuntimeConfig(): OpenClawConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function createOpenAiSourceConfigWithHeadersAndApiKey(): OpenClawConfig {
|
||||
const config = createOpenAiHeaderSourceConfig();
|
||||
config.models!.providers!.openai.apiKey = {
|
||||
source: "env",
|
||||
provider: "default",
|
||||
id: "OPENAI_API_KEY", // pragma: allowlist secret
|
||||
};
|
||||
return config;
|
||||
}
|
||||
|
||||
function createOpenAiRuntimeConfigWithHeadersAndApiKey(): OpenClawConfig {
|
||||
const config = createOpenAiHeaderRuntimeConfig();
|
||||
config.models!.providers!.openai.apiKey = "sk-runtime-resolved"; // pragma: allowlist secret
|
||||
return config;
|
||||
}
|
||||
|
||||
function withGatewayTokenMode(config: OpenClawConfig): OpenClawConfig {
|
||||
return {
|
||||
...config,
|
||||
@@ -187,20 +203,10 @@ describe("models-config runtime source snapshot", () => {
|
||||
it("uses runtime source snapshot markers when passed the active runtime config", async () => {
|
||||
await withGeneratedModelsFromRuntimeSource(
|
||||
{
|
||||
sourceConfig: createOpenAiApiKeySourceConfig(),
|
||||
runtimeConfig: createOpenAiApiKeyRuntimeConfig(),
|
||||
},
|
||||
async () => expectGeneratedProviderApiKey("openai", "OPENAI_API_KEY"), // pragma: allowlist secret
|
||||
);
|
||||
});
|
||||
|
||||
it("uses non-env marker from runtime source snapshot for file refs", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => {
|
||||
unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS);
|
||||
const sourceConfig: OpenClawConfig = {
|
||||
sourceConfig: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: createOpenAiApiKeySourceConfig().models!.providers!.openai,
|
||||
moonshot: {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
apiKey: { source: "file", provider: "vault", id: "/moonshot/apiKey" },
|
||||
@@ -209,10 +215,11 @@ describe("models-config runtime source snapshot", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const runtimeConfig: OpenClawConfig = {
|
||||
},
|
||||
runtimeConfig: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: createOpenAiApiKeyRuntimeConfig().models!.providers!.openai,
|
||||
moonshot: {
|
||||
baseUrl: "https://api.moonshot.ai/v1",
|
||||
apiKey: "sk-runtime-moonshot", // pragma: allowlist secret
|
||||
@@ -221,22 +228,13 @@ describe("models-config runtime source snapshot", () => {
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
await ensureOpenClawModelsJson(loadConfig());
|
||||
|
||||
const parsed = await readGeneratedModelsJson<{
|
||||
providers: Record<string, { apiKey?: string }>;
|
||||
}>();
|
||||
expect(parsed.providers.moonshot?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
},
|
||||
},
|
||||
async () => {
|
||||
await expectGeneratedProviderApiKey("openai", "OPENAI_API_KEY"); // pragma: allowlist secret
|
||||
await expectGeneratedProviderApiKey("moonshot", NON_ENV_SECRETREF_MARKER);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("projects cloned runtime configs onto source snapshot when preserving provider auth", async () => {
|
||||
@@ -347,37 +345,16 @@ describe("models-config runtime source snapshot", () => {
|
||||
await withTempHome(async () => {
|
||||
await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => {
|
||||
unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS);
|
||||
const sourceConfig = withGatewayTokenMode(createOpenAiApiKeySourceConfig());
|
||||
const runtimeConfig = withGatewayTokenMode(createOpenAiApiKeyRuntimeConfig());
|
||||
const sourceConfig = withGatewayTokenMode(createOpenAiSourceConfigWithHeadersAndApiKey());
|
||||
const runtimeConfig = withGatewayTokenMode(createOpenAiRuntimeConfigWithHeadersAndApiKey());
|
||||
const incompatibleCandidate: OpenClawConfig = {
|
||||
...createOpenAiApiKeyRuntimeConfig(),
|
||||
...createOpenAiRuntimeConfigWithHeadersAndApiKey(),
|
||||
};
|
||||
|
||||
try {
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
await ensureOpenClawModelsJson(incompatibleCandidate);
|
||||
await expectGeneratedProviderApiKey("openai", "OPENAI_API_KEY"); // pragma: allowlist secret
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
clearConfigCache();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps source header markers when runtime projection is skipped for incompatible top-level shape", async () => {
|
||||
await withTempHome(async () => {
|
||||
await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => {
|
||||
unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS);
|
||||
const sourceConfig = withGatewayTokenMode(createOpenAiHeaderSourceConfig());
|
||||
const runtimeConfig = withGatewayTokenMode(createOpenAiHeaderRuntimeConfig());
|
||||
const incompatibleCandidate: OpenClawConfig = {
|
||||
...createOpenAiHeaderRuntimeConfig(),
|
||||
};
|
||||
|
||||
try {
|
||||
setRuntimeConfigSnapshot(runtimeConfig, sourceConfig);
|
||||
await ensureOpenClawModelsJson(incompatibleCandidate);
|
||||
await expectGeneratedOpenAiHeaderMarkers();
|
||||
} finally {
|
||||
clearRuntimeConfigSnapshot();
|
||||
|
||||
@@ -83,10 +83,10 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
expect(entries).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes plugin-shipped skills when the plugin is enabled", async () => {
|
||||
it("filters plugin-shipped skills through plugin config", async () => {
|
||||
const { workspaceDir, managedDir, bundledDir } = await setupWorkspaceWithProsePlugin();
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
const enabledEntries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
config: {
|
||||
plugins: {
|
||||
entries: { "open-prose": { enabled: true } },
|
||||
@@ -96,13 +96,9 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
bundledSkillsDir: bundledDir,
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).toContain("prose");
|
||||
});
|
||||
expect(enabledEntries.map((entry) => entry.skill.name)).toContain("prose");
|
||||
|
||||
it("excludes plugin-shipped skills when the plugin is not allowed", async () => {
|
||||
const { workspaceDir, managedDir, bundledDir } = await setupWorkspaceWithProsePlugin();
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
const blockedEntries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
config: {
|
||||
plugins: {
|
||||
allow: ["something-else"],
|
||||
@@ -112,10 +108,10 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
bundledSkillsDir: bundledDir,
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).not.toContain("prose");
|
||||
expect(blockedEntries.map((entry) => entry.skill.name)).not.toContain("prose");
|
||||
});
|
||||
|
||||
it("falls back to the skill directory name when frontmatter omits name", async () => {
|
||||
it("loads frontmatter edge cases in one workspace", async () => {
|
||||
const workspaceDir = await createTempWorkspaceDir();
|
||||
const skillDir = path.join(workspaceDir, "skills", "fallback-name");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
@@ -124,17 +120,6 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
["---", "description: Skill without explicit name", "---", "", "# Fallback"].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).toContain("fallback-name");
|
||||
});
|
||||
|
||||
it("marks disable-model-invocation skills as hidden in exposure metadata for newly loaded entries", async () => {
|
||||
const workspaceDir = await createTempWorkspaceDir();
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "hidden-skill"),
|
||||
name: "hidden-skill",
|
||||
@@ -147,13 +132,14 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).toContain("fallback-name");
|
||||
const hiddenEntry = entries.find((entry) => entry.skill.name === "hidden-skill");
|
||||
|
||||
expect(hiddenEntry?.invocation?.disableModelInvocation).toBe(true);
|
||||
expect(hiddenEntry?.exposure?.includeInAvailableSkillsPrompt).toBe(false);
|
||||
});
|
||||
|
||||
it("inherits agents.defaults.skills when an agent omits skills", async () => {
|
||||
it("applies agent skill filters and replacement semantics", async () => {
|
||||
const workspaceDir = await createTempWorkspaceDir();
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "github"),
|
||||
@@ -165,8 +151,13 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
name: "weather",
|
||||
description: "Weather",
|
||||
});
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "docs-search"),
|
||||
name: "docs-search",
|
||||
description: "Docs",
|
||||
});
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
const defaultEntries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -180,23 +171,9 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).toEqual(["github"]);
|
||||
});
|
||||
expect(defaultEntries.map((entry) => entry.skill.name)).toEqual(["github"]);
|
||||
|
||||
it("uses agents.list[].skills as a full replacement for defaults", async () => {
|
||||
const workspaceDir = await createTempWorkspaceDir();
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "github"),
|
||||
name: "github",
|
||||
description: "GitHub",
|
||||
});
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "docs-search"),
|
||||
name: "docs-search",
|
||||
description: "Docs",
|
||||
});
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
const replacementEntries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
config: {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -210,7 +187,7 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).toEqual(["docs-search"]);
|
||||
expect(replacementEntries.map((entry) => entry.skill.name)).toEqual(["docs-search"]);
|
||||
});
|
||||
|
||||
it("keeps remote-eligible skills when agent filtering is active", async () => {
|
||||
@@ -367,7 +344,7 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"skips workspace skill files that resolve outside the workspace root",
|
||||
"skips symlinked skill files outside the root or through file links",
|
||||
async () => {
|
||||
const workspaceDir = await createTempWorkspaceDir();
|
||||
const outsideDir = await createTempWorkspaceDir();
|
||||
@@ -379,20 +356,6 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
const skillDir = path.join(workspaceDir, "skills", "escaped-file");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.symlink(path.join(outsideDir, "SKILL.md"), path.join(skillDir, "SKILL.md"));
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-file-skill");
|
||||
},
|
||||
);
|
||||
|
||||
it.runIf(process.platform !== "win32")(
|
||||
"skips symlinked SKILL.md even when the target stays inside the workspace root",
|
||||
async () => {
|
||||
const workspaceDir = await createTempWorkspaceDir();
|
||||
const targetDir = path.join(workspaceDir, "safe-target");
|
||||
await writeSkill({
|
||||
dir: targetDir,
|
||||
@@ -400,15 +363,16 @@ describe("loadWorkspaceSkillEntries", () => {
|
||||
description: "Target skill",
|
||||
});
|
||||
|
||||
const skillDir = path.join(workspaceDir, "skills", "symlinked");
|
||||
await fs.mkdir(skillDir, { recursive: true });
|
||||
await fs.symlink(path.join(targetDir, "SKILL.md"), path.join(skillDir, "SKILL.md"));
|
||||
const symlinkedSkillDir = path.join(workspaceDir, "skills", "symlinked");
|
||||
await fs.mkdir(symlinkedSkillDir, { recursive: true });
|
||||
await fs.symlink(path.join(targetDir, "SKILL.md"), path.join(symlinkedSkillDir, "SKILL.md"));
|
||||
|
||||
const entries = loadWorkspaceSkillEntries(workspaceDir, {
|
||||
managedSkillsDir: path.join(workspaceDir, ".managed"),
|
||||
bundledSkillsDir: path.join(workspaceDir, ".bundled"),
|
||||
});
|
||||
|
||||
expect(entries.map((entry) => entry.skill.name)).not.toContain("outside-file-skill");
|
||||
expect(entries.map((entry) => entry.skill.name)).not.toContain("symlink-target");
|
||||
},
|
||||
);
|
||||
|
||||
@@ -174,7 +174,7 @@ describe("buildWorkspaceSkillCommandSpecs", () => {
|
||||
expect(commands.find((entry) => entry.skillName === "hidden-skill")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("truncates descriptions longer than 100 characters for Discord compatibility", async () => {
|
||||
it("truncates descriptions and preserves tool-dispatch metadata", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const longDescription =
|
||||
"This is a very long description that exceeds Discord's 100 character limit for slash command descriptions and should be truncated";
|
||||
@@ -188,22 +188,6 @@ describe("buildWorkspaceSkillCommandSpecs", () => {
|
||||
name: "short-desc",
|
||||
description: "Short description",
|
||||
});
|
||||
|
||||
const commands = buildWorkspaceSkillCommandSpecs(
|
||||
workspaceDir,
|
||||
resolveTestSkillDirs(workspaceDir),
|
||||
);
|
||||
|
||||
const longCmd = commands.find((entry) => entry.skillName === "long-desc");
|
||||
const shortCmd = commands.find((entry) => entry.skillName === "short-desc");
|
||||
|
||||
expect(longCmd?.description.length).toBeLessThanOrEqual(100);
|
||||
expect(longCmd?.description.endsWith("…")).toBe(true);
|
||||
expect(shortCmd?.description).toBe("Short description");
|
||||
});
|
||||
|
||||
it("includes tool-dispatch metadata from frontmatter", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
await writeSkill({
|
||||
dir: path.join(workspaceDir, "skills", "tool-dispatch"),
|
||||
name: "tool-dispatch",
|
||||
@@ -215,7 +199,14 @@ describe("buildWorkspaceSkillCommandSpecs", () => {
|
||||
workspaceDir,
|
||||
resolveTestSkillDirs(workspaceDir),
|
||||
);
|
||||
|
||||
const longCmd = commands.find((entry) => entry.skillName === "long-desc");
|
||||
const shortCmd = commands.find((entry) => entry.skillName === "short-desc");
|
||||
const cmd = commands.find((entry) => entry.skillName === "tool-dispatch");
|
||||
|
||||
expect(longCmd?.description.length).toBeLessThanOrEqual(100);
|
||||
expect(longCmd?.description.endsWith("…")).toBe(true);
|
||||
expect(shortCmd?.description).toBe("Short description");
|
||||
expect(cmd?.dispatch).toEqual({ kind: "tool", toolName: "sessions_send", argMode: "raw" });
|
||||
});
|
||||
|
||||
@@ -460,9 +451,10 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
||||
expect(prompt).not.toContain("Extra version");
|
||||
});
|
||||
|
||||
it("loads skills from workspace skills/", async () => {
|
||||
it("loads workspace skills while omitting disable-model-invocation entries", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const skillDir = path.join(workspaceDir, "skills", "demo-skill");
|
||||
const hiddenSkillDir = path.join(workspaceDir, "skills", "hidden-skill");
|
||||
|
||||
await writeSkill({
|
||||
dir: skillDir,
|
||||
@@ -470,19 +462,8 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
||||
description: "Does demo things",
|
||||
body: "# Demo Skill\n",
|
||||
});
|
||||
|
||||
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, resolveTestSkillDirs(workspaceDir));
|
||||
expect(prompt).toContain("demo-skill");
|
||||
expect(prompt).toContain("Does demo things");
|
||||
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
|
||||
});
|
||||
|
||||
it("omits disable-model-invocation skills from available_skills for freshly loaded entries", async () => {
|
||||
const workspaceDir = await makeWorkspace();
|
||||
const skillDir = path.join(workspaceDir, "skills", "hidden-skill");
|
||||
|
||||
await writeSkill({
|
||||
dir: skillDir,
|
||||
dir: hiddenSkillDir,
|
||||
name: "hidden-skill",
|
||||
description: "Hidden from the prompt",
|
||||
frontmatterExtra: "disable-model-invocation: true",
|
||||
@@ -490,9 +471,12 @@ describe("buildWorkspaceSkillsPrompt", () => {
|
||||
|
||||
const prompt = buildWorkspaceSkillsPrompt(workspaceDir, resolveTestSkillDirs(workspaceDir));
|
||||
|
||||
expect(prompt).toContain("demo-skill");
|
||||
expect(prompt).toContain("Does demo things");
|
||||
expect(prompt).toContain(path.join(skillDir, "SKILL.md"));
|
||||
expect(prompt).not.toContain("hidden-skill");
|
||||
expect(prompt).not.toContain("Hidden from the prompt");
|
||||
expect(prompt).not.toContain(path.join(skillDir, "SKILL.md"));
|
||||
expect(prompt).not.toContain(path.join(hiddenSkillDir, "SKILL.md"));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user