mirror of
https://github.com/sst/opencode.git
synced 2025-12-23 10:11:41 +00:00
sync
This commit is contained in:
parent
cd9eeb7d41
commit
9a5dd18c49
7 changed files with 78 additions and 260 deletions
6
.opencode/skill/test-skill/SKILL.md
Normal file
6
.opencode/skill/test-skill/SKILL.md
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
---
|
||||||
|
name: test-skill
|
||||||
|
description: use this when asked to test skill
|
||||||
|
---
|
||||||
|
|
||||||
|
woah this is a test skill
|
||||||
|
|
@ -6,6 +6,7 @@ import { FileCommand } from "./file"
|
||||||
import { LSPCommand } from "./lsp"
|
import { LSPCommand } from "./lsp"
|
||||||
import { RipgrepCommand } from "./ripgrep"
|
import { RipgrepCommand } from "./ripgrep"
|
||||||
import { ScrapCommand } from "./scrap"
|
import { ScrapCommand } from "./scrap"
|
||||||
|
import { SkillCommand } from "./skill"
|
||||||
import { SnapshotCommand } from "./snapshot"
|
import { SnapshotCommand } from "./snapshot"
|
||||||
|
|
||||||
export const DebugCommand = cmd({
|
export const DebugCommand = cmd({
|
||||||
|
|
@ -17,6 +18,7 @@ export const DebugCommand = cmd({
|
||||||
.command(RipgrepCommand)
|
.command(RipgrepCommand)
|
||||||
.command(FileCommand)
|
.command(FileCommand)
|
||||||
.command(ScrapCommand)
|
.command(ScrapCommand)
|
||||||
|
.command(SkillCommand)
|
||||||
.command(SnapshotCommand)
|
.command(SnapshotCommand)
|
||||||
.command(PathsCommand)
|
.command(PathsCommand)
|
||||||
.command({
|
.command({
|
||||||
|
|
|
||||||
15
packages/opencode/src/cli/cmd/debug/skill.ts
Normal file
15
packages/opencode/src/cli/cmd/debug/skill.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { EOL } from "os"
|
||||||
|
import { Skill } from "../../../skill"
|
||||||
|
import { bootstrap } from "../../bootstrap"
|
||||||
|
import { cmd } from "../cmd"
|
||||||
|
|
||||||
|
export const SkillCommand = cmd({
|
||||||
|
command: "skill",
|
||||||
|
builder: (yargs) => yargs,
|
||||||
|
async handler() {
|
||||||
|
await bootstrap(process.cwd(), async () => {
|
||||||
|
const skills = await Skill.all()
|
||||||
|
process.stdout.write(JSON.stringify(skills, null, 2) + EOL)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
@ -40,6 +40,8 @@ export namespace SessionCompaction {
|
||||||
export const PRUNE_MINIMUM = 20_000
|
export const PRUNE_MINIMUM = 20_000
|
||||||
export const PRUNE_PROTECT = 40_000
|
export const PRUNE_PROTECT = 40_000
|
||||||
|
|
||||||
|
const PRUNE_PROTECTED_TOOLS = ["skill"]
|
||||||
|
|
||||||
// goes backwards through parts until there are 40_000 tokens worth of tool
|
// goes backwards through parts until there are 40_000 tokens worth of tool
|
||||||
// calls. then erases output of previous tool calls. idea is to throw away old
|
// calls. then erases output of previous tool calls. idea is to throw away old
|
||||||
// tool calls that are no longer relevant.
|
// tool calls that are no longer relevant.
|
||||||
|
|
@ -61,8 +63,7 @@ export namespace SessionCompaction {
|
||||||
const part = msg.parts[partIndex]
|
const part = msg.parts[partIndex]
|
||||||
if (part.type === "tool")
|
if (part.type === "tool")
|
||||||
if (part.state.status === "completed") {
|
if (part.state.status === "completed") {
|
||||||
// Skip skill tool responses - they contain important instructions
|
if (PRUNE_PROTECTED_TOOLS.includes(part.tool)) continue
|
||||||
if (part.tool === "skill") continue
|
|
||||||
|
|
||||||
if (part.state.time.compacted) break loop
|
if (part.state.time.compacted) break loop
|
||||||
const estimate = Token.estimate(part.state.output)
|
const estimate = Token.estimate(part.state.output)
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,16 @@
|
||||||
import path from "path"
|
|
||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { Config } from "../config/config"
|
import { Config } from "../config/config"
|
||||||
import { Filesystem } from "../util/filesystem"
|
|
||||||
import { Instance } from "../project/instance"
|
import { Instance } from "../project/instance"
|
||||||
import { NamedError } from "@opencode-ai/util/error"
|
import { NamedError } from "@opencode-ai/util/error"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
import { Log } from "../util/log"
|
|
||||||
|
|
||||||
export namespace Skill {
|
export namespace Skill {
|
||||||
const log = Log.create({ service: "skill" })
|
export const Info = z.object({
|
||||||
|
name: z.string(),
|
||||||
// Name: 1-64 chars, lowercase alphanumeric and hyphens, no consecutive hyphens, can't start/end with hyphen
|
description: z.string(),
|
||||||
const NAME_REGEX = /^[a-z0-9]+(-[a-z0-9]+)*$/
|
location: z.string(),
|
||||||
|
|
||||||
export const Frontmatter = z.object({
|
|
||||||
name: z
|
|
||||||
.string()
|
|
||||||
.min(1)
|
|
||||||
.max(64)
|
|
||||||
.refine((val) => NAME_REGEX.test(val), {
|
|
||||||
message:
|
|
||||||
"Name must be lowercase alphanumeric with hyphens, no consecutive hyphens, cannot start or end with hyphen",
|
|
||||||
}),
|
|
||||||
description: z.string().min(1).max(1024),
|
|
||||||
license: z.string().optional(),
|
|
||||||
compatibility: z.string().max(500).optional(),
|
|
||||||
metadata: z.record(z.string(), z.string()).optional(),
|
|
||||||
})
|
})
|
||||||
|
export type Info = z.infer<typeof Info>
|
||||||
export type Frontmatter = z.infer<typeof Frontmatter>
|
|
||||||
|
|
||||||
export interface Info {
|
|
||||||
id: string // Path-based identifier (e.g., "code-review" or "docs/api-guide")
|
|
||||||
name: string
|
|
||||||
description: string
|
|
||||||
location: string
|
|
||||||
license?: string
|
|
||||||
compatibility?: string
|
|
||||||
metadata?: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
export const InvalidError = NamedError.create(
|
export const InvalidError = NamedError.create(
|
||||||
"SkillInvalidError",
|
"SkillInvalidError",
|
||||||
|
|
@ -59,110 +31,41 @@ export namespace Skill {
|
||||||
)
|
)
|
||||||
|
|
||||||
const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
|
const SKILL_GLOB = new Bun.Glob("skill/**/SKILL.md")
|
||||||
// const CLAUDE_SKILL_GLOB = new Bun.Glob("*/SKILL.md")
|
|
||||||
|
|
||||||
interface DiscoveredSkill {
|
export const state = Instance.state(async () => {
|
||||||
path: string
|
|
||||||
baseDir: string // The skill/ or .claude/skills/ directory
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discover(): Promise<DiscoveredSkill[]> {
|
|
||||||
const directories = await Config.directories()
|
const directories = await Config.directories()
|
||||||
|
const skills: Record<string, Info> = {}
|
||||||
|
|
||||||
const results: DiscoveredSkill[] = []
|
|
||||||
|
|
||||||
// Scan skill/ subdirectory in config directories (.opencode/, ~/.opencode/, etc.)
|
|
||||||
for (const dir of directories) {
|
for (const dir of directories) {
|
||||||
const baseDir = path.join(dir, "skill")
|
|
||||||
for await (const match of SKILL_GLOB.scan({
|
for await (const match of SKILL_GLOB.scan({
|
||||||
cwd: dir,
|
cwd: dir,
|
||||||
absolute: true,
|
absolute: true,
|
||||||
onlyFiles: true,
|
onlyFiles: true,
|
||||||
followSymlinks: true,
|
followSymlinks: true,
|
||||||
})) {
|
})) {
|
||||||
results.push({ path: match, baseDir })
|
const md = await ConfigMarkdown.parse(match)
|
||||||
|
if (!md) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Info.pick({ name: true, description: true }).safeParse(md.data)
|
||||||
|
if (!parsed.success) continue
|
||||||
|
skills[parsed.data.name] = {
|
||||||
|
name: parsed.data.name,
|
||||||
|
description: parsed.data.description,
|
||||||
|
location: match,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Also scan .claude/skills/ walking up from cwd to worktree
|
|
||||||
// for await (const dir of Filesystem.up({
|
|
||||||
// targets: [".claude/skills"],
|
|
||||||
// start: Instance.directory,
|
|
||||||
// stop: Instance.worktree,
|
|
||||||
// })) {
|
|
||||||
// for await (const match of CLAUDE_SKILL_GLOB.scan({
|
|
||||||
// cwd: dir,
|
|
||||||
// absolute: true,
|
|
||||||
// onlyFiles: true,
|
|
||||||
// followSymlinks: true,
|
|
||||||
// })) {
|
|
||||||
// paths.push(match)
|
|
||||||
// }
|
|
||||||
// }
|
|
||||||
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
|
|
||||||
async function load(discovered: DiscoveredSkill): Promise<Info> {
|
|
||||||
const skillMdPath = discovered.path
|
|
||||||
const md = await ConfigMarkdown.parse(skillMdPath)
|
|
||||||
if (!md.data) {
|
|
||||||
throw new InvalidError({
|
|
||||||
path: skillMdPath,
|
|
||||||
message: "SKILL.md must have YAML frontmatter",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsed = Frontmatter.safeParse(md.data)
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new InvalidError({
|
|
||||||
path: skillMdPath,
|
|
||||||
issues: parsed.error.issues,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const frontmatter = parsed.data
|
|
||||||
const skillDir = path.dirname(skillMdPath)
|
|
||||||
const dirName = path.basename(skillDir)
|
|
||||||
|
|
||||||
if (frontmatter.name !== dirName) {
|
|
||||||
throw new NameMismatchError({
|
|
||||||
path: skillMdPath,
|
|
||||||
expected: dirName,
|
|
||||||
actual: frontmatter.name,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate path-based ID from relative path
|
|
||||||
// e.g., baseDir=/path/skill, skillDir=/path/skill/docs/api-guide → id=docs/api-guide
|
|
||||||
const relativePath = path.relative(discovered.baseDir, skillDir)
|
|
||||||
const id = relativePath.split(path.sep).join("/") // Normalize to forward slashes
|
|
||||||
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: frontmatter.name,
|
|
||||||
description: frontmatter.description,
|
|
||||||
location: skillMdPath,
|
|
||||||
license: frontmatter.license,
|
|
||||||
compatibility: frontmatter.compatibility,
|
|
||||||
metadata: frontmatter.metadata,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const state = Instance.state(async () => {
|
|
||||||
const discovered = await discover()
|
|
||||||
const skills: Info[] = []
|
|
||||||
|
|
||||||
for (const item of discovered) {
|
|
||||||
const info = await load(item)
|
|
||||||
log.info("loaded skill", { id: info.id, name: info.name, location: info.location })
|
|
||||||
skills.push(info)
|
|
||||||
}
|
|
||||||
|
|
||||||
return skills
|
return skills
|
||||||
})
|
})
|
||||||
|
|
||||||
export async function all(): Promise<Info[]> {
|
export async function get(name: string) {
|
||||||
return state()
|
return state().then((x) => x[name])
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function all() {
|
||||||
|
return state().then((x) => Object.values(x))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,69 +8,75 @@ import { Wildcard } from "../util/wildcard"
|
||||||
import { ConfigMarkdown } from "../config/markdown"
|
import { ConfigMarkdown } from "../config/markdown"
|
||||||
|
|
||||||
export const SkillTool = Tool.define("skill", async () => {
|
export const SkillTool = Tool.define("skill", async () => {
|
||||||
const allSkills = await Skill.all()
|
const skills = await Skill.all()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
description: [
|
description: [
|
||||||
"Load a skill to get detailed instructions for a specific task.",
|
"Load a skill to get detailed instructions for a specific task.",
|
||||||
"Skills provide specialized knowledge and step-by-step guidance.",
|
"Skills provide specialized knowledge and step-by-step guidance.",
|
||||||
"Use this when a task matches an available skill's description.",
|
"Use this when a task matches an available skill's description.",
|
||||||
|
"<available_skills>",
|
||||||
|
...skills.flatMap((skill) => [
|
||||||
|
` <skill>`,
|
||||||
|
` <name>${skill.name}</name>`,
|
||||||
|
` <description>${skill.description}</description>`,
|
||||||
|
` </skill>`,
|
||||||
|
]),
|
||||||
|
"</available_skills>",
|
||||||
].join(" "),
|
].join(" "),
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
id: z.string().describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
|
name: z
|
||||||
|
.string()
|
||||||
|
.describe("The skill identifier from available_skills (e.g., 'code-review' or 'category/helper')"),
|
||||||
}),
|
}),
|
||||||
async execute(params, ctx) {
|
async execute(params, ctx) {
|
||||||
const agent = await Agent.get(ctx.agent)
|
const agent = await Agent.get(ctx.agent)
|
||||||
// Look up by id (path-based identifier)
|
|
||||||
const skill = allSkills.find((s) => s.id === params.id)
|
const skill = await Skill.get(params.name)
|
||||||
|
|
||||||
if (!skill) {
|
if (!skill) {
|
||||||
const available = allSkills.map((s) => s.id).join(", ")
|
const available = Skill.all().then((x) => Object.keys(x).join(", "))
|
||||||
throw new Error(`Skill "${params.id}" not found. Available skills: ${available || "none"}`)
|
throw new Error(`Skill "${params.name}" not found. Available skills: ${available || "none"}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check permission using Wildcard.all on the skill ID
|
// Check permission using Wildcard.all on the skill ID
|
||||||
const permissions = agent.permission.skill
|
const permissions = agent.permission.skill
|
||||||
const action = Wildcard.all(params.id, permissions)
|
const action = Wildcard.all(params.name, permissions)
|
||||||
|
|
||||||
if (action === "deny") {
|
if (action === "deny") {
|
||||||
throw new Permission.RejectedError(
|
throw new Permission.RejectedError(
|
||||||
ctx.sessionID,
|
ctx.sessionID,
|
||||||
"skill",
|
"skill",
|
||||||
ctx.callID,
|
ctx.callID,
|
||||||
{ skill: params.id },
|
{ skill: params.name },
|
||||||
`Access to skill "${params.id}" is denied for agent "${agent.name}".`,
|
`Access to skill "${params.name}" is denied for agent "${agent.name}".`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (action === "ask") {
|
if (action === "ask") {
|
||||||
await Permission.ask({
|
await Permission.ask({
|
||||||
type: "skill",
|
type: "skill",
|
||||||
pattern: params.id,
|
pattern: params.name,
|
||||||
sessionID: ctx.sessionID,
|
sessionID: ctx.sessionID,
|
||||||
messageID: ctx.messageID,
|
messageID: ctx.messageID,
|
||||||
callID: ctx.callID,
|
callID: ctx.callID,
|
||||||
title: `Load skill: ${skill.name}`,
|
title: `Load skill: ${skill.name}`,
|
||||||
metadata: { id: params.id, name: skill.name, description: skill.description },
|
metadata: { id: params.name, name: skill.name, description: skill.description },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load and parse skill content
|
// Load and parse skill content
|
||||||
const parsed = await ConfigMarkdown.parse(skill.location)
|
const parsed = await ConfigMarkdown.parse(skill.location)
|
||||||
const baseDir = path.dirname(skill.location)
|
const dir = path.dirname(skill.location)
|
||||||
|
|
||||||
// Format output similar to plugin pattern
|
// Format output similar to plugin pattern
|
||||||
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${baseDir}`, "", parsed.content.trim()].join(
|
const output = [`## Skill: ${skill.name}`, "", `**Base directory**: ${dir}`, "", parsed.content.trim()].join("\n")
|
||||||
"\n",
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `Loaded skill: ${skill.name}`,
|
title: `Loaded skill: ${skill.name}`,
|
||||||
output,
|
output,
|
||||||
metadata: {
|
metadata: {
|
||||||
id: skill.id,
|
|
||||||
name: skill.name,
|
name: skill.name,
|
||||||
baseDir,
|
dir,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -65,55 +65,7 @@ description: Another test skill.
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test("throws error for invalid skill name format", async () => {
|
test("skips skills with missing frontmatter", async () => {
|
||||||
await using tmp = await tmpdir({
|
|
||||||
git: true,
|
|
||||||
init: async (dir) => {
|
|
||||||
const skillDir = path.join(dir, ".opencode", "skill", "InvalidName")
|
|
||||||
await Bun.write(
|
|
||||||
path.join(skillDir, "SKILL.md"),
|
|
||||||
`---
|
|
||||||
name: InvalidName
|
|
||||||
description: A skill with invalid name.
|
|
||||||
---
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await expect(Skill.all()).rejects.toThrow()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws error when name doesn't match directory", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
git: true,
|
|
||||||
init: async (dir) => {
|
|
||||||
const skillDir = path.join(dir, ".opencode", "skill", "dir-name")
|
|
||||||
await Bun.write(
|
|
||||||
path.join(skillDir, "SKILL.md"),
|
|
||||||
`---
|
|
||||||
name: different-name
|
|
||||||
description: A skill with mismatched name.
|
|
||||||
---
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await expect(Skill.all()).rejects.toThrow("SkillNameMismatchError")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("throws error for missing frontmatter", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
await using tmp = await tmpdir({
|
||||||
git: true,
|
git: true,
|
||||||
init: async (dir) => {
|
init: async (dir) => {
|
||||||
|
|
@ -128,78 +80,11 @@ Just some content without YAML frontmatter.
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
await expect(Skill.all()).rejects.toThrow("SkillInvalidError")
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("parses optional fields", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
git: true,
|
|
||||||
init: async (dir) => {
|
|
||||||
const skillDir = path.join(dir, ".opencode", "skill", "full-skill")
|
|
||||||
await Bun.write(
|
|
||||||
path.join(skillDir, "SKILL.md"),
|
|
||||||
`---
|
|
||||||
name: full-skill
|
|
||||||
description: A skill with all optional fields.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires Node.js 18+
|
|
||||||
metadata:
|
|
||||||
author: test-author
|
|
||||||
version: "1.0"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Full Skill
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
await Instance.provide({
|
||||||
directory: tmp.path,
|
directory: tmp.path,
|
||||||
fn: async () => {
|
fn: async () => {
|
||||||
const skills = await Skill.all()
|
const skills = await Skill.all()
|
||||||
expect(skills.length).toBe(1)
|
expect(skills).toEqual([])
|
||||||
expect(skills[0].license).toBe("MIT")
|
|
||||||
expect(skills[0].compatibility).toBe("Requires Node.js 18+")
|
|
||||||
expect(skills[0].metadata).toEqual({
|
|
||||||
author: "test-author",
|
|
||||||
version: "1.0",
|
|
||||||
})
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
test("ignores unknown frontmatter fields", async () => {
|
|
||||||
await using tmp = await tmpdir({
|
|
||||||
git: true,
|
|
||||||
init: async (dir) => {
|
|
||||||
const skillDir = path.join(dir, ".opencode", "skill", "extra-fields")
|
|
||||||
await Bun.write(
|
|
||||||
path.join(skillDir, "SKILL.md"),
|
|
||||||
`---
|
|
||||||
name: extra-fields
|
|
||||||
description: A skill with extra unknown fields.
|
|
||||||
allowed-tools: Bash Read Write
|
|
||||||
some-other-field: ignored
|
|
||||||
---
|
|
||||||
|
|
||||||
# Extra Fields Skill
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
await Instance.provide({
|
|
||||||
directory: tmp.path,
|
|
||||||
fn: async () => {
|
|
||||||
const skills = await Skill.all()
|
|
||||||
expect(skills.length).toBe(1)
|
|
||||||
expect(skills[0].name).toBe("extra-fields")
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
@ -252,7 +137,7 @@ description: An example skill for testing XML output.
|
||||||
const result = await SystemPrompt.skills()
|
const result = await SystemPrompt.skills()
|
||||||
expect(result.length).toBe(1)
|
expect(result.length).toBe(1)
|
||||||
expect(result[0]).toContain("<available_skills>")
|
expect(result[0]).toContain("<available_skills>")
|
||||||
expect(result[0]).toContain("<id>example-skill</id>")
|
|
||||||
expect(result[0]).toContain("<name>example-skill</name>")
|
expect(result[0]).toContain("<name>example-skill</name>")
|
||||||
expect(result[0]).toContain("<description>An example skill for testing XML output.</description>")
|
expect(result[0]).toContain("<description>An example skill for testing XML output.</description>")
|
||||||
expect(result[0]).toContain("</available_skills>")
|
expect(result[0]).toContain("</available_skills>")
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue