import path from "path" import { describe, expect } from "bun:test" import { Effect, Layer, LayerMap } from "effect" import { Database } from "@opencode-ai/core/database/database" import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/util/effect/layer-node" import { makeGlobalNode } from "@opencode-ai/util/effect/app-node" import { FSUtil } from "@opencode-ai/util/fs-util" import { Bus } from "@opencode-ai/core/bus" import { Image } from "@opencode-ai/core/image" import { Location } from "@opencode-ai/core/location" import { LocationServiceMap } from "@opencode-ai/core/location-service-map" import type { LocationServices } from "@opencode-ai/core/location-services" import { Project } from "@opencode-ai/core/project" import { PluginSupervisor } from "@opencode-ai/core/plugin/supervisor-service" import { PluginHooks } from "@opencode-ai/core/plugin/hooks" import { AbsolutePath } from "@opencode-ai/core/schema" import { Session } from "@opencode-ai/core/session" import { SessionExecution } from "@opencode-ai/core/session/execution" import { SessionEvent } from "@opencode-ai/core/session/event" import { SessionMessage } from "@opencode-ai/core/session/message" import { SessionPrompt } from "@opencode-ai/core/session/prompt" import { SessionProjector } from "@opencode-ai/core/session/projector" import { SessionStore } from "@opencode-ai/core/session/store" import { SessionInbox } from "@opencode-ai/core/session/inbox" import { Skill } from "@opencode-ai/core/skill" import { Reference } from "@opencode-ai/core/reference" import { Event } from "@opencode-ai/schema/event" import { testEffect } from "./lib/effect" import { globalProjectNode } from "./lib/project" const location = Location.Ref.make({ directory: AbsolutePath.make("/project") }) const info = Skill.Info.make({ id: Skill.ID.make("effect"), name: Skill.Name.make("Effect"), description: "Effect guidance", location: AbsolutePath.make(path.resolve("/skills/effect.md")), content: " Use Effect\n", }) const locations = makeGlobalNode({ service: LocationServiceMap.Service, layer: Layer.effect( LocationServiceMap.Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const skills = SessionPrompt.layer.pipe( Layer.provideMerge( Layer.mergeAll( LayerNode.compile(LayerNode.group([PluginHooks.node, Image.node])), Layer.succeed(FSUtil.Service, fs), Layer.mock(Skill.Service, { get: (id) => Effect.succeed(id === info.id ? info : undefined), list: () => Effect.succeed([info]), }), Layer.succeed(PluginSupervisor.Service, { flush: Effect.void }), Layer.mock(Reference.Service, { refresh: () => Effect.void }), ), ), ) return yield* LayerMap.make( (_ref: Location.Ref) => // These tests need skill activation and prompt preparation from the same location services. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion skills as unknown as Layer.Layer, ) }), ), deps: [FSUtil.node], }) const it = testEffect( AppNodeBuilder.build( LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node, Session.node]), [ LocationServiceMap.node.replace(locations), Project.node.replace(globalProjectNode), SessionExecution.node.replace(SessionExecution.noopLayer), ], ), ) describe("Session.skill", () => { it.effect("materializes mentioned skills on their owning prompt", () => Effect.gen(function* () { const sessions = yield* Session.Service const database = yield* Database.Service const bus = yield* Bus.Service const session = yield* sessions.create({ location }) const id = SessionMessage.ID.make("msg_skill_attachment") yield* sessions.prompt({ id, sessionID: session.id, text: "Apply @effect and @effect", skills: [ { id: Skill.ID.make("effect"), mention: { start: 6, end: 13, text: "@effect" } }, { id: Skill.ID.make("effect"), mention: { start: 18, end: 25, text: "@effect" } }, ], resume: false, }) expect(yield* sessions.messages({ sessionID: session.id })).toEqual([]) yield* SessionInbox.promote(database.db, bus, session.id, "steer") expect(yield* sessions.messages({ sessionID: session.id })).toEqual([ expect.objectContaining({ id, type: "user", text: "Apply @effect and @effect", skills: [ { id: "effect", name: "Effect", text: Skill.toModelOutput(info, []), mention: { start: 6, end: 13, text: "@effect" }, }, { id: "effect", name: "Effect", mention: { start: 18, end: 25, text: "@effect" }, }, ], }), ]) }), ) it.effect("excludes mentioned skills when forking before their prompt", () => Effect.gen(function* () { const sessions = yield* Session.Service const database = yield* Database.Service const bus = yield* Bus.Service const session = yield* sessions.create({ location }) const initial = SessionMessage.ID.make("msg_before_skill_attachment") const selected = SessionMessage.ID.make("msg_fork_skill_attachment") yield* sessions.prompt({ id: initial, sessionID: session.id, text: "Before the skill", resume: false }) yield* SessionInbox.promote(database.db, bus, session.id, "steer") yield* sessions.prompt({ id: selected, sessionID: session.id, text: "Apply @effect", skills: [{ id: info.id, mention: { start: 6, end: 13, text: "@effect" } }], resume: false, }) yield* SessionInbox.promote(database.db, bus, session.id, "steer") const forked = yield* sessions.fork({ sessionID: session.id, boundary: { type: "before", messageID: selected } }) expect(yield* sessions.messages({ sessionID: forked.id })).toEqual([ expect.objectContaining({ type: "user", text: "Before the skill" }), ]) }), ) it.effect("publishes raw standalone content under the caller-supplied ID without inbox admission", () => Effect.gen(function* () { const sessions = yield* Session.Service const bus = yield* Bus.Service const session = yield* sessions.create({ location }) const id = SessionMessage.ID.make("msg_caller_skill") const events: Event.Payload[] = [] yield* bus.listen((event) => Effect.sync(() => { events.push(event) }), ) yield* sessions.skill({ id, sessionID: session.id, skill: Skill.ID.make("effect"), resume: false }) expect(events).toEqual([ expect.objectContaining({ id: "evt_caller_skill", type: SessionEvent.Skill.Activated.type, data: { sessionID: session.id, id: info.id, name: info.name, text: info.content }, }), ]) expect(yield* sessions.messages({ sessionID: session.id })).toEqual([ expect.objectContaining({ id, type: "skill", skill: "effect", name: "Effect", text: info.content }), ]) expect(yield* sessions.inbox(session.id)).toEqual([]) }), ) })