opencode/packages/core/test/tool-skill.test.ts

181 lines
7.3 KiB
TypeScript

import fs from "fs/promises"
import path from "path"
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { Permission } from "@opencode-ai/core/permission"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Session } from "@opencode-ai/core/session"
import { Skill } from "@opencode-ai/core/skill"
import { SkillTool } from "@opencode-ai/core/tool/plugin/skill"
import { Tool } from "@opencode-ai/core/tool"
import { tmpdir } from "./fixture/tmpdir"
import { Image } from "@opencode-ai/core/image"
import { it } from "./lib/effect"
import { imagePassthrough } from "./lib/image"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { toolIdentity, executeTool, registerToolPlugin, toolDefinitions } from "./lib/tool"
const skillToolNode = makeLocationNode({
name: "test/skill-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(SkillTool.Plugin)),
deps: [Tool.node, FSUtil.node, Skill.node, Permission.node],
})
const sessionID = Session.ID.make("ses_skill_tool_test")
describe("SkillTool", () => {
it.live("lists available skills, authorizes the selected ID, and loads model-facing content", () =>
Effect.acquireRelease(
Effect.promise(() => tmpdir()),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(
Effect.flatMap((tmp) =>
Effect.gen(function* () {
const directory = path.join(tmp.path, "effect")
const location = path.join(directory, "SKILL.md")
const reference = path.join(directory, "reference.md")
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* Effect.promise(() =>
Promise.all([fs.writeFile(location, "unused"), fs.writeFile(reference, "reference")]),
)
const info: Skill.Info = {
id: Skill.ID.make("effect"),
name: Skill.Name.make("Effect"),
description: "Use Effect",
location: AbsolutePath.make(location),
content: "# Effect\n\nGuidance",
}
let current = [info]
const assertions: Permission.AssertInput[] = []
let deny = false
const permission = Layer.succeed(
Permission.Service,
Permission.Service.of({
assert: (input) =>
Effect.sync(() => assertions.push(input)).pipe(
Effect.andThen(
deny
? Effect.fail(
new Permission.BlockedError({
rules: [],
permission: input.action,
resources: input.resources,
}),
)
: Effect.void,
),
),
ask: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
get: () => Effect.die("unused"),
forSession: () => Effect.die("unused"),
list: () => Effect.die("unused"),
}),
)
const skills = Layer.succeed(
Skill.Service,
Skill.Service.of({
transform: (_transform) => Effect.die("unused"),
reload: () => Effect.die("unused"),
sources: () => Effect.die("unused"),
list: () => Effect.succeed(current),
}),
)
const skillToolLayer = AppNodeBuilder.build(
LayerNode.group([Tool.node, skillToolNode]),
[
[Permission.node, permission],
[Skill.node, skills],
[Image.node, imagePassthrough],
],
)
return yield* Effect.gen(function* () {
const registry = yield* Tool.Service
expect((yield* toolDefinitions(registry))[0]).toMatchObject({
name: "skill",
description: SkillTool.description,
})
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill", name: "skill", input: { id: "effect" } },
}),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
})
expect(SkillTool.toModelOutput(info, [reference])).toContain(`Base directory for this skill: ${directory}`)
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-skill-overflow", name: "skill", input: { id: "effect" } },
}),
).toEqual({
status: "completed",
output: { name: "Effect", directory, output: SkillTool.toModelOutput(info, [reference]) },
content: [{ type: "text", text: SkillTool.toModelOutput(info, [reference]) }],
metadata: { name: "Effect", directory },
})
expect(assertions).toMatchObject([
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
{ sessionID, action: "skill", resources: ["effect"], save: ["effect"] },
])
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-missing-skill", name: "skill", input: { id: "missing" } },
}),
).toEqual({
status: "error",
error: { type: "tool.execution", message: "Unable to load skill missing" },
})
deny = true
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-denied-skill", name: "skill", input: { id: "effect" } },
}),
).toEqual({
status: "error",
error: { type: "permission.rejected", message: "Permission denied: skill" },
})
deny = false
const flat = Skill.Info.make({
id: Skill.ID.make("public"),
name: Skill.Name.make("Public"),
description: "Public guidance",
location: AbsolutePath.make(path.join(tmp.path, "public.md")),
content: "Public",
})
yield* Effect.promise(() =>
Promise.all([
fs.writeFile(flat.location, "public"),
fs.writeFile(path.join(tmp.path, "secret.md"), "secret"),
]),
)
current = [flat]
expect(
yield* executeTool(registry, {
sessionID,
...toolIdentity,
call: { type: "tool-call", id: "call-flat-skill", name: "skill", input: { id: "public" } },
}),
).toMatchObject({
status: "completed",
content: [{ type: "text", text: SkillTool.toModelOutput(flat, []) }],
})
}).pipe(Effect.provide(skillToolLayer))
}),
),
),
)
})