feat(skill): merge skills and commands into unified available_items with priority sorting
- Merge <available_skills> and <available_commands> into single <available_items> - Sort by priority: project > user > opencode > builtin - List skills before commands - Add priority documentation to description - Add 5 tests for ordering and priority Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
3adade46e3
commit
acc28a89c1
@ -4,6 +4,7 @@ import * as fs from "node:fs"
|
||||
import { createSkillTool } from "./tools"
|
||||
import { SkillMcpManager } from "../../features/skill-mcp-manager"
|
||||
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"
|
||||
|
||||
const originalReadFileSync = fs.readFileSync.bind(fs)
|
||||
@ -67,7 +68,7 @@ const mockContext: ToolContext = {
|
||||
}
|
||||
|
||||
describe("skill tool - synchronous description", () => {
|
||||
it("includes available_skills immediately when skills are pre-provided", () => {
|
||||
it("includes available_items immediately when skills are pre-provided", () => {
|
||||
// given
|
||||
const loadedSkills = [createMockSkill("test-skill")]
|
||||
|
||||
@ -75,11 +76,11 @@ describe("skill tool - synchronous description", () => {
|
||||
const tool = createSkillTool({ skills: loadedSkills })
|
||||
|
||||
// then
|
||||
expect(tool.description).toContain("<available_skills>")
|
||||
expect(tool.description).toContain("<available_items>")
|
||||
expect(tool.description).toContain("test-skill")
|
||||
})
|
||||
|
||||
it("includes all pre-provided skills in available_skills immediately", () => {
|
||||
it("includes all pre-provided skills in available_items immediately", () => {
|
||||
// given
|
||||
const loadedSkills = [
|
||||
createMockSkill("playwright"),
|
||||
@ -91,6 +92,7 @@ describe("skill tool - synchronous description", () => {
|
||||
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")
|
||||
@ -353,3 +355,131 @@ describe("skill tool - MCP schema display", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
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("lists skills before commands 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 before commands
|
||||
const desc = tool.description
|
||||
const skillIndex = desc.indexOf("<skill>")
|
||||
const commandIndex = desc.indexOf("<command>")
|
||||
expect(skillIndex).toBeGreaterThan(0)
|
||||
expect(commandIndex).toBeGreaterThan(0)
|
||||
expect(skillIndex).toBeLessThan(commandIndex)
|
||||
})
|
||||
|
||||
it("sorts skills 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")
|
||||
expect(tool.description).toContain("Skills listed before commands")
|
||||
})
|
||||
|
||||
it("uses <available_items> wrapper with unified 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
|
||||
expect(tool.description).toContain("<available_items>")
|
||||
expect(tool.description).toContain("</available_items>")
|
||||
expect(tool.description).toContain("<skill>")
|
||||
expect(tool.description).toContain("<command>")
|
||||
})
|
||||
})
|
||||
|
||||
@ -31,8 +31,26 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[])
|
||||
return TOOL_DESCRIPTION_NO_SKILLS
|
||||
}
|
||||
|
||||
// Priority: project > user > opencode/opencode-project > builtin/config
|
||||
const scopePriority: Record<string, number> = {
|
||||
project: 4,
|
||||
user: 3,
|
||||
opencode: 2,
|
||||
"opencode-project": 2,
|
||||
config: 1,
|
||||
builtin: 1,
|
||||
}
|
||||
|
||||
const allItems: string[] = []
|
||||
|
||||
// Sort and add skills first (skills before commands)
|
||||
if (skills.length > 0) {
|
||||
const skillsXml = skills.map(skill => {
|
||||
const sortedSkills = [...skills].sort((a, b) => {
|
||||
const priorityA = scopePriority[a.scope] || 0
|
||||
const priorityB = scopePriority[b.scope] || 0
|
||||
return priorityB - priorityA // Higher priority first
|
||||
})
|
||||
sortedSkills.forEach(skill => {
|
||||
const parts = [
|
||||
" <skill>",
|
||||
` <name>${skill.name}</name>`,
|
||||
@ -42,17 +60,35 @@ function formatCombinedDescription(skills: SkillInfo[], commands: CommandInfo[])
|
||||
parts.push(` <compatibility>${skill.compatibility}</compatibility>`)
|
||||
}
|
||||
parts.push(" </skill>")
|
||||
return parts.join("\n")
|
||||
}).join("\n")
|
||||
lines.push(`\n<available_skills>\n${skillsXml}\n</available_skills>`)
|
||||
allItems.push(parts.join("\n"))
|
||||
})
|
||||
}
|
||||
|
||||
// Sort and add commands second (commands after skills)
|
||||
if (commands.length > 0) {
|
||||
const commandLines = commands.map(cmd => {
|
||||
const sortedCommands = [...commands].sort((a, b) => {
|
||||
const priorityA = scopePriority[a.scope] || 0
|
||||
const priorityB = scopePriority[b.scope] || 0
|
||||
return priorityB - priorityA // Higher priority first
|
||||
})
|
||||
sortedCommands.forEach(cmd => {
|
||||
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
|
||||
return ` - /${cmd.name}${hint}: ${cmd.metadata.description || "(no description)"} (${cmd.scope})`
|
||||
}).join("\n")
|
||||
lines.push(`\n<available_commands>\n${commandLines}\n</available_commands>`)
|
||||
const parts = [
|
||||
" <command>",
|
||||
` <name>/${cmd.name}</name>`,
|
||||
` <description>${cmd.metadata.description || "(no description)"}</description>`,
|
||||
` <scope>${cmd.scope}</scope>`,
|
||||
]
|
||||
if (hint) {
|
||||
parts.push(` <argument>${hint.trim()}</argument>`)
|
||||
}
|
||||
parts.push(" </command>")
|
||||
allItems.push(parts.join("\n"))
|
||||
})
|
||||
}
|
||||
|
||||
if (allItems.length > 0) {
|
||||
lines.push(`\n<available_items>\nPriority: project > user > opencode > builtin | Skills listed before commands\nInvoke via: skill(name="item-name") — omit leading slash for commands.\n${allItems.join("\n")}\n</available_items>`)
|
||||
}
|
||||
|
||||
return TOOL_DESCRIPTION_PREFIX + lines.join("")
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user