| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181 |
- 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))
- }),
- ),
- ),
- )
- })
|