diff --git a/src/agents/models-config.providers.anthropic-vertex.test.ts b/src/agents/models-config.providers.anthropic-vertex.test.ts index 37f9387d6af..fe15ecdc05e 100644 --- a/src/agents/models-config.providers.anthropic-vertex.test.ts +++ b/src/agents/models-config.providers.anthropic-vertex.test.ts @@ -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(); - }); }); diff --git a/src/agents/models-config.providers.google-antigravity.test.ts b/src/agents/models-config.providers.google-antigravity.test.ts index 4d01934b9dc..717e27ba748 100644 --- a/src/agents/models-config.providers.google-antigravity.test.ts +++ b/src/agents/models-config.providers.google-antigravity.test.ts @@ -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[number] { @@ -30,9 +27,21 @@ function buildProvider( }; } +function normalizeProviderMap( + providers: Record, +): Record { + let changed = false; + const next: Record = {}; + 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); }); diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts index 422946a4fb7..64e2bd403f8 100644 --- a/src/agents/models-config.runtime-source-snapshot.test.ts +++ b/src/agents/models-config.runtime-source-snapshot.test.ts @@ -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; - }>(); - 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(); diff --git a/src/agents/skills.loadworkspaceskillentries.test.ts b/src/agents/skills.loadworkspaceskillentries.test.ts index 69d6c1dd852..4462bd99d0e 100644 --- a/src/agents/skills.loadworkspaceskillentries.test.ts +++ b/src/agents/skills.loadworkspaceskillentries.test.ts @@ -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"); }, ); diff --git a/src/agents/skills.test.ts b/src/agents/skills.test.ts index 2376bae8467..1e4e28427cf 100644 --- a/src/agents/skills.test.ts +++ b/src/agents/skills.test.ts @@ -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")); }); });