088693697a
Skills with an `agent` frontmatter field are intended for a specific agent. Previously they still appeared in: - every agent's system prompt (via `buildAvailableSkills`) - the `skill` tool's `<available_items>` description visible to all agents This wasted tokens and could mislead agents into attempting calls that would be rejected at execution time. Changes: - `buildAvailableSkills`: new optional `agentName` parameter; when provided, skills whose `definition.agent` does not match are excluded - `builtin-agents.ts`: pass per-agent name to `buildAvailableSkills` for sisyphus, hephaestus, and atlas, so each agent's prompt only lists the skills it is allowed to use - `createSkillTool` (`tools.ts`): exclude agent-restricted skills from both the eager and lazy description builds, keeping the shared tool description free of skills the current agent cannot access Execution-time enforcement (throwing on mismatch) is unchanged; this change adds the earlier, description-level visibility gate. Tests: new `available-skills.test.ts` (5 cases) + 3 new cases in `tools.test.ts` covering the description-filter and execute paths. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
891 lines
30 KiB
TypeScript
891 lines
30 KiB
TypeScript
/// <reference types="bun-types" />
|
|
|
|
declare const require: NodeJS.Require
|
|
|
|
import { afterAll, beforeEach, describe, expect, it, mock, spyOn } from "bun:test"
|
|
import type { ToolContext } from "@opencode-ai/plugin/tool"
|
|
import * as fs from "node:fs"
|
|
import { tmpdir } from "node:os"
|
|
import { join } from "node:path"
|
|
import { SkillMcpManager } from "../../../features/skill-mcp-manager"
|
|
import { clearSkillCache } from "../../../features/opencode-skill-loader/skill-content"
|
|
import type { LoadedSkill } from "../../../features/opencode-skill-loader/types"
|
|
import type { CommandInfo } from "../../slashcommand/types"
|
|
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
|
|
import { unsafeTestValue } from "../../../../test-support/unsafe-test-value"
|
|
|
|
const originalReadFileSync = fs.readFileSync.bind(fs)
|
|
|
|
let createSkillTool: typeof import("../tools").createSkillTool
|
|
|
|
function clearRequireCache(modulePath: string): void {
|
|
const resolvedPath = require.resolve(modulePath)
|
|
if (require.cache?.[resolvedPath]) {
|
|
delete require.cache[resolvedPath]
|
|
}
|
|
}
|
|
|
|
function requireFresh<TModule>(modulePath: string): TModule {
|
|
clearRequireCache(modulePath)
|
|
return require(modulePath) as TModule
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mock.restore()
|
|
clearRequireCache("../tools")
|
|
clearRequireCache("../../../features/opencode-skill-loader/skill-content")
|
|
clearRequireCache("../../slashcommand/command-discovery")
|
|
|
|
mock.module("node:fs", () => ({
|
|
...fs,
|
|
readFileSync: (path: string, encoding?: string) => {
|
|
if (typeof path === "string" && path.includes("/skills/")) {
|
|
return `---
|
|
description: Test skill description
|
|
---
|
|
Test skill body content`
|
|
}
|
|
return originalReadFileSync(path, encoding as BufferEncoding)
|
|
},
|
|
}))
|
|
|
|
createSkillTool = requireFresh<typeof import("../tools")>("../tools").createSkillTool
|
|
})
|
|
|
|
afterAll(() => {
|
|
mock.restore()
|
|
})
|
|
|
|
function createMockSkill(name: string, options: { agent?: string } = {}): LoadedSkill {
|
|
return {
|
|
name,
|
|
path: `/test/skills/${name}/SKILL.md`,
|
|
resolvedPath: `/test/skills/${name}`,
|
|
definition: {
|
|
name,
|
|
description: `Test skill ${name}`,
|
|
template: "Test template",
|
|
agent: options.agent,
|
|
},
|
|
scope: "opencode-project",
|
|
}
|
|
}
|
|
|
|
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
|
|
return {
|
|
name,
|
|
path: `/test/skills/${name}/SKILL.md`,
|
|
resolvedPath: `/test/skills/${name}`,
|
|
definition: {
|
|
name,
|
|
description: `Test skill ${name}`,
|
|
template: "Test template",
|
|
},
|
|
scope: "opencode-project",
|
|
mcpConfig: mcpServers as LoadedSkill["mcpConfig"],
|
|
}
|
|
}
|
|
|
|
const mockContext: ToolContext = {
|
|
sessionID: "test-session",
|
|
messageID: "msg-1",
|
|
agent: "test-agent",
|
|
directory: "/test",
|
|
worktree: "/test",
|
|
abort: new AbortController().signal,
|
|
metadata: () => {},
|
|
ask: async () => {},
|
|
}
|
|
|
|
describe("skill tool - synchronous description", () => {
|
|
it("includes available_items immediately when skills are pre-provided", () => {
|
|
// given
|
|
const loadedSkills = [createMockSkill("test-skill")]
|
|
|
|
// when
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
|
|
// then
|
|
expect(tool.description).toContain("<available_items>")
|
|
expect(tool.description).toContain("test-skill")
|
|
})
|
|
|
|
it("includes all pre-provided skills in available_items immediately", () => {
|
|
// given
|
|
const loadedSkills = [
|
|
createMockSkill("playwright"),
|
|
createMockSkill("frontend-ui-ux"),
|
|
createMockSkill("git-master"),
|
|
]
|
|
|
|
// when
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
|
|
// then
|
|
expect(tool.description).toContain("<available_items>")
|
|
expect(tool.description).toContain("playwright")
|
|
expect(tool.description).toContain("frontend-ui-ux")
|
|
expect(tool.description).toContain("git-master")
|
|
})
|
|
|
|
it("shows no-skills message immediately when empty skills are pre-provided", () => {
|
|
// given / #when
|
|
const tool = createSkillTool({ skills: [] })
|
|
|
|
// then
|
|
expect(tool.description).toContain("No skills are currently available")
|
|
})
|
|
})
|
|
|
|
describe("skill tool - agent restriction", () => {
|
|
it("allows skill without agent restriction to any agent", async () => {
|
|
// given
|
|
const loadedSkills = [createMockSkill("public-skill")]
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
const context = { ...mockContext, agent: "any-agent" }
|
|
|
|
// when
|
|
const result = await tool.execute({ name: "public-skill" }, context)
|
|
|
|
// then
|
|
expect(result).toContain("public-skill")
|
|
})
|
|
|
|
it("requests host skill permission before loading the skill", async () => {
|
|
// given
|
|
const loadedSkills = [createMockSkill("review-work")]
|
|
const askCalls: Array<Parameters<ToolContext["ask"]>[0]> = []
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
const context: ToolContext = {
|
|
...mockContext,
|
|
ask: async (input) => {
|
|
askCalls.push(input)
|
|
},
|
|
}
|
|
|
|
// when
|
|
await tool.execute({ name: "review-work" }, context)
|
|
|
|
// then
|
|
expect(askCalls).toEqual([
|
|
{
|
|
permission: "skill",
|
|
patterns: ["review-work"],
|
|
always: ["review-work"],
|
|
metadata: { skill: "review-work" },
|
|
},
|
|
])
|
|
})
|
|
|
|
it("allows skill when agent matches restriction", async () => {
|
|
// given
|
|
const loadedSkills = [createMockSkill("restricted-skill", { agent: "sisyphus" })]
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
const context = { ...mockContext, agent: "sisyphus" }
|
|
|
|
// when
|
|
const result = await tool.execute({ name: "restricted-skill" }, context)
|
|
|
|
// then
|
|
expect(result).toContain("restricted-skill")
|
|
})
|
|
|
|
it("throws error when agent does not match restriction", async () => {
|
|
// given
|
|
const loadedSkills = [createMockSkill("sisyphus-only-skill", { agent: "sisyphus" })]
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
const context = { ...mockContext, agent: "oracle" }
|
|
|
|
// when / #then
|
|
return expect(tool.execute({ name: "sisyphus-only-skill" }, context)).rejects.toThrow(
|
|
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
|
|
)
|
|
})
|
|
|
|
it("throws error when context agent is undefined for restricted skill", async () => {
|
|
// given
|
|
const loadedSkills = [createMockSkill("sisyphus-only-skill", { agent: "sisyphus" })]
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
const contextWithoutAgent = { ...mockContext, agent: unsafeTestValue<string>(undefined) }
|
|
|
|
// when / #then
|
|
return expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow(
|
|
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
|
|
)
|
|
})
|
|
|
|
})
|
|
|
|
describe("skill tool - MCP schema display", () => {
|
|
let manager: SkillMcpManager
|
|
let loadedSkills: LoadedSkill[]
|
|
let sessionID: string
|
|
|
|
beforeEach(() => {
|
|
manager = new SkillMcpManager()
|
|
loadedSkills = []
|
|
sessionID = "test-session-1"
|
|
})
|
|
|
|
describe("formatMcpCapabilities with inputSchema", () => {
|
|
it("uses the tool context sessionID when the fallback getter is empty", async () => {
|
|
// given
|
|
loadedSkills = [
|
|
createMockSkillWithMcp("test-skill", {
|
|
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
|
|
}),
|
|
]
|
|
|
|
const listToolsSpy = spyOn(manager, "listTools").mockResolvedValue([])
|
|
spyOn(manager, "listResources").mockResolvedValue([])
|
|
spyOn(manager, "listPrompts").mockResolvedValue([])
|
|
|
|
const tool = createSkillTool({
|
|
skills: loadedSkills,
|
|
mcpManager: manager,
|
|
getSessionID: () => "",
|
|
})
|
|
|
|
// when
|
|
await tool.execute({ name: "test-skill" }, mockContext)
|
|
|
|
// then
|
|
expect(listToolsSpy).toHaveBeenCalledWith(
|
|
expect.objectContaining({ sessionID: mockContext.sessionID }),
|
|
expect.any(Object),
|
|
)
|
|
})
|
|
|
|
it("displays tool inputSchema when available", async () => {
|
|
// given
|
|
const mockToolsWithSchema: McpTool[] = [
|
|
{
|
|
name: "browser_type",
|
|
description: "Type text into an element",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
element: { type: "string", description: "Human-readable element description" },
|
|
ref: { type: "string", description: "Element reference from page snapshot" },
|
|
text: { type: "string", description: "Text to type into the element" },
|
|
submit: { type: "boolean", description: "Submit form after typing" },
|
|
},
|
|
required: ["element", "ref", "text"],
|
|
},
|
|
},
|
|
]
|
|
|
|
loadedSkills = [
|
|
createMockSkillWithMcp("test-skill", {
|
|
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
|
|
}),
|
|
]
|
|
|
|
// Mock manager.listTools to return our mock tools
|
|
spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema)
|
|
spyOn(manager, "listResources").mockResolvedValue([])
|
|
spyOn(manager, "listPrompts").mockResolvedValue([])
|
|
|
|
const tool = createSkillTool({
|
|
skills: loadedSkills,
|
|
mcpManager: manager,
|
|
getSessionID: () => sessionID,
|
|
})
|
|
|
|
// when
|
|
const result = await tool.execute({ name: "test-skill" }, mockContext)
|
|
|
|
// then
|
|
// Should include inputSchema details
|
|
expect(result).toContain("browser_type")
|
|
expect(result).toContain("inputSchema")
|
|
expect(result).toContain("element")
|
|
expect(result).toContain("ref")
|
|
expect(result).toContain("text")
|
|
expect(result).toContain("submit")
|
|
expect(result).toContain("required")
|
|
})
|
|
|
|
it("displays multiple tools with their schemas", async () => {
|
|
// given
|
|
const mockToolsWithSchema: McpTool[] = [
|
|
{
|
|
name: "browser_navigate",
|
|
description: "Navigate to a URL",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
url: { type: "string", description: "URL to navigate to" },
|
|
},
|
|
required: ["url"],
|
|
},
|
|
},
|
|
{
|
|
name: "browser_click",
|
|
description: "Click an element",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
element: { type: "string" },
|
|
ref: { type: "string" },
|
|
},
|
|
required: ["element", "ref"],
|
|
},
|
|
},
|
|
]
|
|
|
|
loadedSkills = [
|
|
createMockSkillWithMcp("playwright-skill", {
|
|
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
|
|
}),
|
|
]
|
|
|
|
spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema)
|
|
spyOn(manager, "listResources").mockResolvedValue([])
|
|
spyOn(manager, "listPrompts").mockResolvedValue([])
|
|
|
|
const tool = createSkillTool({
|
|
skills: loadedSkills,
|
|
mcpManager: manager,
|
|
getSessionID: () => sessionID,
|
|
})
|
|
|
|
// when
|
|
const result = await tool.execute({ name: "playwright-skill" }, mockContext)
|
|
|
|
// then
|
|
expect(result).toContain("browser_navigate")
|
|
expect(result).toContain("browser_click")
|
|
expect(result).toContain("url")
|
|
expect(result).toContain("Navigate to a URL")
|
|
})
|
|
|
|
it("handles tools without inputSchema gracefully", async () => {
|
|
// given
|
|
const mockToolsMinimal: McpTool[] = [
|
|
{
|
|
name: "simple_tool",
|
|
inputSchema: { type: "object" },
|
|
},
|
|
]
|
|
|
|
loadedSkills = [
|
|
createMockSkillWithMcp("simple-skill", {
|
|
simple: { command: "echo", args: ["test"] },
|
|
}),
|
|
]
|
|
|
|
spyOn(manager, "listTools").mockResolvedValue(mockToolsMinimal)
|
|
spyOn(manager, "listResources").mockResolvedValue([])
|
|
spyOn(manager, "listPrompts").mockResolvedValue([])
|
|
|
|
const tool = createSkillTool({
|
|
skills: loadedSkills,
|
|
mcpManager: manager,
|
|
getSessionID: () => sessionID,
|
|
})
|
|
|
|
// when
|
|
const result = await tool.execute({ name: "simple-skill" }, mockContext)
|
|
|
|
// then
|
|
expect(result).toContain("simple_tool")
|
|
// Should not throw, should handle gracefully
|
|
})
|
|
|
|
it("formats schema in a way LLM can understand for skill_mcp calls", async () => {
|
|
// given
|
|
const mockTools: McpTool[] = [
|
|
{
|
|
name: "query",
|
|
description: "Execute SQL query",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: {
|
|
sql: { type: "string", description: "SQL query to execute" },
|
|
params: { type: "array", description: "Query parameters" },
|
|
},
|
|
required: ["sql"],
|
|
},
|
|
},
|
|
]
|
|
|
|
loadedSkills = [
|
|
createMockSkillWithMcp("db-skill", {
|
|
sqlite: { command: "uvx", args: ["mcp-server-sqlite"] },
|
|
}),
|
|
]
|
|
|
|
spyOn(manager, "listTools").mockResolvedValue(mockTools)
|
|
spyOn(manager, "listResources").mockResolvedValue([])
|
|
spyOn(manager, "listPrompts").mockResolvedValue([])
|
|
|
|
const tool = createSkillTool({
|
|
skills: loadedSkills,
|
|
mcpManager: manager,
|
|
getSessionID: () => sessionID,
|
|
})
|
|
|
|
// when
|
|
const result = await tool.execute({ name: "db-skill" }, mockContext)
|
|
|
|
// then
|
|
// Should provide enough info for LLM to construct valid skill_mcp call
|
|
expect(result).toContain("sqlite")
|
|
expect(result).toContain("query")
|
|
expect(result).toContain("sql")
|
|
expect(result).toContain("required")
|
|
expect(result).toMatch(/sql[\s\S]*string/i)
|
|
})
|
|
})
|
|
})
|
|
|
|
|
|
describe("skill tool - ordering and priority", () => {
|
|
function createMockSkillWithScope(name: string, scope: string): LoadedSkill {
|
|
return {
|
|
name,
|
|
path: `/test/skills/${name}/SKILL.md`,
|
|
resolvedPath: `/test/skills/${name}`,
|
|
definition: {
|
|
name,
|
|
description: `Test skill ${name}`,
|
|
template: "Test template",
|
|
},
|
|
scope: scope as LoadedSkill["scope"],
|
|
}
|
|
}
|
|
|
|
function createMockCommand(name: string, scope: string) {
|
|
return {
|
|
name,
|
|
path: `/test/commands/${name}.md`,
|
|
metadata: {
|
|
name,
|
|
description: `Test command ${name}`,
|
|
},
|
|
scope: scope as CommandInfo["scope"],
|
|
}
|
|
}
|
|
|
|
it("shows skills as command items with slash prefix in available_items", () => {
|
|
//#given: mix of skills and commands
|
|
const skills = [
|
|
createMockSkillWithScope("builtin-skill", "builtin"),
|
|
createMockSkillWithScope("project-skill", "project"),
|
|
]
|
|
const commands = [
|
|
createMockCommand("project-cmd", "project"),
|
|
createMockCommand("builtin-cmd", "builtin"),
|
|
]
|
|
|
|
//#when: creating tool with both
|
|
const tool = createSkillTool({ skills, commands })
|
|
|
|
//#then: skills should appear as <command> items with / prefix, listed before regular commands
|
|
const desc = tool.description
|
|
expect(desc).toContain("<name>/builtin-skill</name>")
|
|
expect(desc).toContain("<name>/project-skill</name>")
|
|
expect(desc).not.toContain("<skill>")
|
|
const skillCmdIndex = desc.indexOf("/project-skill")
|
|
const regularCmdIndex = desc.indexOf("/project-cmd")
|
|
expect(skillCmdIndex).toBeLessThan(regularCmdIndex)
|
|
})
|
|
|
|
it("sorts skill-commands by priority: project > user > opencode > builtin", () => {
|
|
//#given: skills in random order
|
|
const skills = [
|
|
createMockSkillWithScope("builtin-skill", "builtin"),
|
|
createMockSkillWithScope("opencode-skill", "opencode"),
|
|
createMockSkillWithScope("project-skill", "project"),
|
|
createMockSkillWithScope("user-skill", "user"),
|
|
]
|
|
|
|
//#when: creating tool
|
|
const tool = createSkillTool({ skills })
|
|
|
|
//#then: should be sorted by priority
|
|
const desc = tool.description
|
|
const projectIndex = desc.indexOf("/project-skill")
|
|
const userIndex = desc.indexOf("/user-skill")
|
|
const opencodeIndex = desc.indexOf("/opencode-skill")
|
|
const builtinIndex = desc.indexOf("/builtin-skill")
|
|
|
|
expect(projectIndex).toBeLessThan(userIndex)
|
|
expect(userIndex).toBeLessThan(opencodeIndex)
|
|
expect(opencodeIndex).toBeLessThan(builtinIndex)
|
|
})
|
|
|
|
it("sorts commands by priority: project > user > opencode > builtin", () => {
|
|
//#given: commands in random order
|
|
const commands = [
|
|
createMockCommand("builtin-cmd", "builtin"),
|
|
createMockCommand("opencode-cmd", "opencode"),
|
|
createMockCommand("project-cmd", "project"),
|
|
createMockCommand("user-cmd", "user"),
|
|
]
|
|
|
|
//#when: creating tool
|
|
const tool = createSkillTool({ commands })
|
|
|
|
//#then: should be sorted by priority
|
|
const desc = tool.description
|
|
const projectIndex = desc.indexOf("project-cmd")
|
|
const userIndex = desc.indexOf("user-cmd")
|
|
const opencodeIndex = desc.indexOf("opencode-cmd")
|
|
const builtinIndex = desc.indexOf("builtin-cmd")
|
|
|
|
expect(projectIndex).toBeLessThan(userIndex)
|
|
expect(userIndex).toBeLessThan(opencodeIndex)
|
|
expect(opencodeIndex).toBeLessThan(builtinIndex)
|
|
})
|
|
|
|
it("includes priority documentation in description", () => {
|
|
//#given: some skills and commands
|
|
const skills = [createMockSkillWithScope("test-skill", "project")]
|
|
const commands = [createMockCommand("test-cmd", "project")]
|
|
|
|
//#when: creating tool
|
|
const tool = createSkillTool({ skills, commands })
|
|
|
|
//#then: should include priority info
|
|
expect(tool.description).toContain("Priority: project > user > opencode > builtin/plugin")
|
|
expect(tool.description).toContain("Skills listed before commands")
|
|
})
|
|
|
|
it("uses <available_items> wrapper with unified command format", () => {
|
|
//#given: mix of skills and commands
|
|
const skills = [createMockSkillWithScope("test-skill", "project")]
|
|
const commands = [createMockCommand("test-cmd", "project")]
|
|
|
|
//#when: creating tool
|
|
const tool = createSkillTool({ skills, commands })
|
|
|
|
//#then: should use unified wrapper with all items as commands
|
|
expect(tool.description).toContain("<available_items>")
|
|
expect(tool.description).toContain("</available_items>")
|
|
expect(tool.description).not.toContain("<skill>")
|
|
expect(tool.description).toContain("<command>")
|
|
expect(tool.description).toContain("/test-skill")
|
|
expect(tool.description).toContain("/test-cmd")
|
|
})
|
|
})
|
|
|
|
describe("skill tool - dynamic discovery", () => {
|
|
it("caches discovered skills across tool instances until the shared cache resets", async () => {
|
|
// given
|
|
clearSkillCache()
|
|
const originalDirectory = process.cwd()
|
|
const temporaryDirectory = fs.mkdtempSync(join(tmpdir(), "skill-tool-cache-"))
|
|
const initialSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "initial-skill")
|
|
const secondSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "second-skill")
|
|
|
|
fs.mkdirSync(initialSkillDirectory, { recursive: true })
|
|
fs.writeFileSync(join(initialSkillDirectory, "SKILL.md"), "---\ndescription: Initial skill\n---\nInitial skill body")
|
|
process.chdir(temporaryDirectory)
|
|
|
|
try {
|
|
const firstTool = createSkillTool({})
|
|
|
|
// when
|
|
const initialResult = await firstTool.execute({ name: "initial-skill" }, mockContext)
|
|
|
|
fs.mkdirSync(secondSkillDirectory, { recursive: true })
|
|
fs.writeFileSync(join(secondSkillDirectory, "SKILL.md"), "---\ndescription: Second skill\n---\nSecond skill body")
|
|
|
|
const cachedTool = createSkillTool({})
|
|
|
|
// then
|
|
expect(initialResult).toContain("Skill: initial-skill")
|
|
let cachedError: Error | undefined
|
|
try {
|
|
await cachedTool.execute({ name: "second-skill" }, mockContext)
|
|
} catch (error) {
|
|
cachedError = error instanceof Error ? error : new Error(String(error))
|
|
}
|
|
expect(cachedError?.message).toContain('Skill or command "second-skill" not found.')
|
|
} finally {
|
|
process.chdir(originalDirectory)
|
|
clearSkillCache()
|
|
fs.rmSync(temporaryDirectory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
|
|
it("merges pre-provided skills with dynamically discovered ones", async () => {
|
|
// given: tool with a synthetic skill not on disk
|
|
const syntheticSkill = createMockSkill("synthetic-only")
|
|
const tool = createSkillTool({ skills: [syntheticSkill] })
|
|
|
|
// when: looking up the synthetic skill
|
|
const result = await tool.execute({ name: "synthetic-only" }, mockContext)
|
|
|
|
// then: synthetic skill is still accessible via merge
|
|
expect(result).toContain("Skill: synthetic-only")
|
|
})
|
|
|
|
it("prefers disk-discovered skills over pre-provided ones", async () => {
|
|
// given: tool with a pre-provided skill that also exists on disk (builtin)
|
|
const overrideSkill = createMockSkill("playwright")
|
|
overrideSkill.definition.description = "SHOULD_BE_OVERRIDDEN"
|
|
const tool = createSkillTool({ skills: [overrideSkill] })
|
|
|
|
// when: executing with the builtin skill name
|
|
const result = await tool.execute({ name: "playwright" }, mockContext)
|
|
|
|
// then: disk version wins (not the pre-provided override)
|
|
expect(result).not.toContain("SHOULD_BE_OVERRIDDEN")
|
|
})
|
|
})
|
|
describe("skill tool - agent-restricted skill visibility in description", () => {
|
|
it("excludes agent-restricted skill from description <available_items>", () => {
|
|
// given: a skill restricted to oracle, and a public skill
|
|
const loadedSkills = [
|
|
createMockSkill("public-skill"),
|
|
createMockSkill("oracle-only-skill", { agent: "oracle" }),
|
|
]
|
|
|
|
// when: tool is created with these skills (as tool-registry would inject them)
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
|
|
// then: oracle-only skill must NOT appear in the description
|
|
expect(tool.description).toContain("public-skill")
|
|
expect(tool.description).not.toContain("oracle-only-skill")
|
|
})
|
|
|
|
it("includes public skill (no agent field) in description regardless of context", () => {
|
|
// given
|
|
const loadedSkills = [createMockSkill("public-skill")]
|
|
|
|
// when
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
|
|
// then
|
|
expect(tool.description).toContain("public-skill")
|
|
})
|
|
|
|
it("execute still works for agent-restricted skill when called with correct agent context", async () => {
|
|
// given: tool created WITHOUT the restricted skill in description list,
|
|
// but the full skill list is available for execute via getSkills()
|
|
// (simulating what tool-registry does: description uses filtered list,
|
|
// but execute discovers from disk / full list)
|
|
const restrictedSkill = createMockSkill("oracle-only-skill", { agent: "oracle" })
|
|
const tool = createSkillTool({ skills: [restrictedSkill] })
|
|
const oracleContext = { ...mockContext, agent: "oracle" }
|
|
|
|
// when: oracle agent explicitly calls the skill
|
|
const result = await tool.execute({ name: "oracle-only-skill" }, oracleContext)
|
|
|
|
// then: execution succeeds
|
|
expect(result).toContain("oracle-only-skill")
|
|
})
|
|
})
|
|
|
|
describe("skill tool - dynamic description cache invalidation", () => {
|
|
it("keeps description available after execute misses a skill", async () => {
|
|
// given
|
|
const tool = createSkillTool({})
|
|
|
|
// when
|
|
const initialDescription = tool.description
|
|
expect(initialDescription).toBeString()
|
|
|
|
try {
|
|
await tool.execute({ name: "nonexistent-skill-12345" }, mockContext)
|
|
} catch {
|
|
}
|
|
|
|
// then
|
|
expect(tool.description).toBeDefined()
|
|
expect(typeof tool.description).toBe("string")
|
|
})
|
|
|
|
it("picks up new disk skills only after the shared skill cache resets", async () => {
|
|
// given
|
|
clearSkillCache()
|
|
const originalDirectory = process.cwd()
|
|
const temporaryDirectory = fs.mkdtempSync(join(tmpdir(), "skill-tool-refresh-"))
|
|
const initialSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "initial-skill")
|
|
const secondSkillDirectory = join(temporaryDirectory, ".opencode", "skills", "second-skill")
|
|
|
|
fs.mkdirSync(initialSkillDirectory, { recursive: true })
|
|
fs.writeFileSync(join(initialSkillDirectory, "SKILL.md"), "---\ndescription: Initial skill\n---\nInitial skill body")
|
|
process.chdir(temporaryDirectory)
|
|
|
|
try {
|
|
const initialTool = createSkillTool({})
|
|
await initialTool.execute({ name: "initial-skill" }, mockContext)
|
|
|
|
fs.mkdirSync(secondSkillDirectory, { recursive: true })
|
|
fs.writeFileSync(join(secondSkillDirectory, "SKILL.md"), "---\ndescription: Second skill\n---\nSecond skill body")
|
|
|
|
const cachedTool = createSkillTool({})
|
|
let cachedError: Error | undefined
|
|
try {
|
|
await cachedTool.execute({ name: "second-skill" }, mockContext)
|
|
} catch (error) {
|
|
cachedError = error instanceof Error ? error : new Error(String(error))
|
|
}
|
|
expect(cachedError?.message).toContain('Skill or command "second-skill" not found.')
|
|
|
|
clearSkillCache()
|
|
const refreshedTool = createSkillTool({})
|
|
|
|
// when
|
|
const refreshedResult = await refreshedTool.execute({ name: "second-skill" }, mockContext)
|
|
|
|
// then
|
|
expect(refreshedResult).toContain("Skill: second-skill")
|
|
expect(refreshedTool.description).toContain("second-skill")
|
|
} finally {
|
|
process.chdir(originalDirectory)
|
|
clearSkillCache()
|
|
fs.rmSync(temporaryDirectory, { recursive: true, force: true })
|
|
}
|
|
})
|
|
})
|
|
|
|
|
|
|
|
describe("skill tool - browserProvider forwarding", () => {
|
|
it("passes browserProvider to getAllSkills during execution", async () => {
|
|
// given: a skill tool configured with agent-browser as browserProvider
|
|
// and a pre-provided agent-browser skill (simulating what skill-context provides)
|
|
const agentBrowserSkill = createMockSkill("agent-browser")
|
|
const tool = createSkillTool({
|
|
skills: [agentBrowserSkill],
|
|
browserProvider: "agent-browser",
|
|
})
|
|
|
|
// when: executing skill("agent-browser")
|
|
const result = await tool.execute({ name: "agent-browser" }, mockContext)
|
|
|
|
// then: skill should resolve successfully (not filtered out)
|
|
expect(result).toContain("Skill: agent-browser")
|
|
})
|
|
|
|
it("description includes agent-browser when browserProvider is agent-browser", () => {
|
|
// given
|
|
const agentBrowserSkill = createMockSkill("agent-browser")
|
|
|
|
// when
|
|
const tool = createSkillTool({
|
|
skills: [agentBrowserSkill],
|
|
browserProvider: "agent-browser",
|
|
})
|
|
|
|
// then
|
|
expect(tool.description).toContain("agent-browser")
|
|
})
|
|
})
|
|
|
|
describe("skill tool - nativeSkills integration", () => {
|
|
it("includes native skills in the description even when skills are pre-seeded", async () => {
|
|
//#given
|
|
const tool = createSkillTool({
|
|
skills: [createMockSkill("seeded-skill")],
|
|
nativeSkills: {
|
|
all() {
|
|
return [{
|
|
name: "native-visible-skill",
|
|
description: "Native skill exposed from config",
|
|
location: "/external/skills/native-visible-skill/SKILL.md",
|
|
content: "Native visible skill body",
|
|
}]
|
|
},
|
|
get() { return undefined },
|
|
dirs() { return [] },
|
|
},
|
|
})
|
|
|
|
//#when
|
|
expect(tool.description).toContain("seeded-skill")
|
|
expect(tool.description).toContain("native-visible-skill")
|
|
await tool.execute({ name: "native-visible-skill" }, mockContext)
|
|
|
|
//#then
|
|
expect(tool.description).toContain("seeded-skill")
|
|
expect(tool.description).toContain("native-visible-skill")
|
|
})
|
|
|
|
it("merges native skills exposed by PluginInput.skills.all()", async () => {
|
|
//#given
|
|
const tool = createSkillTool({
|
|
skills: [],
|
|
nativeSkills: {
|
|
async all() {
|
|
return [{
|
|
name: "external-plugin-skill",
|
|
description: "Skill from config.skills.paths",
|
|
location: "/external/skills/external-plugin-skill/SKILL.md",
|
|
content: "External plugin skill body",
|
|
}]
|
|
},
|
|
async get() { return undefined },
|
|
async dirs() { return [] },
|
|
},
|
|
})
|
|
|
|
//#when
|
|
const result = await tool.execute({ name: "external-plugin-skill" }, mockContext)
|
|
|
|
//#then
|
|
expect(result).toContain("external-plugin-skill")
|
|
expect(result).toContain("External plugin skill body")
|
|
})
|
|
})
|
|
|
|
describe("skill tool - short name resolution", () => {
|
|
it("resolves namespaced skill by short name when unambiguous", async () => {
|
|
// given
|
|
const loadedSkills = [createMockSkill("superpowers/systematic-debugging")]
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
|
|
// when
|
|
const result = await tool.execute({ name: "systematic-debugging" }, mockContext)
|
|
|
|
// then
|
|
expect(result).toContain("superpowers/systematic-debugging")
|
|
})
|
|
|
|
it("still resolves by exact full name", async () => {
|
|
// given
|
|
const loadedSkills = [createMockSkill("superpowers/systematic-debugging")]
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
|
|
// when
|
|
const result = await tool.execute({ name: "superpowers/systematic-debugging" }, mockContext)
|
|
|
|
// then
|
|
expect(result).toContain("superpowers/systematic-debugging")
|
|
})
|
|
|
|
it("does not resolve short name when ambiguous (multiple matches)", async () => {
|
|
// given
|
|
const loadedSkills = [
|
|
createMockSkill("superpowers/debugging"),
|
|
createMockSkill("utils/debugging"),
|
|
]
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
|
|
// when / then, should not resolve (ambiguous), should suggest both
|
|
return expect(tool.execute({ name: "debugging" }, mockContext)).rejects.toThrow(
|
|
"not found"
|
|
)
|
|
})
|
|
|
|
it("prefers exact match over short name match", async () => {
|
|
// given, "debugging" exists as both exact and as part of a namespace
|
|
const loadedSkills = [
|
|
createMockSkill("debugging"),
|
|
createMockSkill("superpowers/debugging"),
|
|
]
|
|
const tool = createSkillTool({ skills: loadedSkills })
|
|
|
|
// when
|
|
const result = await tool.execute({ name: "debugging" }, mockContext)
|
|
|
|
// then, should match "debugging" exactly, not "superpowers/debugging"
|
|
expect(result).toContain("## Skill: debugging")
|
|
})
|
|
})
|