test: dedupe skills and model config coverage

This commit is contained in:
Peter Steinberger
2026-04-18 18:32:53 +01:00
parent 6d776593ea
commit 4180e7cd59
5 changed files with 125 additions and 274 deletions

View File

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

View File

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

View File

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

View File

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

View File

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