diff --git a/packages/cli/src/tool/skill.ts b/packages/cli/src/tool/skill.ts index 47f742d..d76b0af 100644 --- a/packages/cli/src/tool/skill.ts +++ b/packages/cli/src/tool/skill.ts @@ -9,21 +9,30 @@ import { iife } from "@/util/iife" import { Plugin } from "../plugin" import { Bus } from "../bus" import { Session } from "../session" +import { Instance } from "../project/instance" -export const SkillTool = Tool.define("skill", async (ctx) => { - const skills = await Skill.all() +const discovered = Instance.state(() => new Set()) - // Emit per-skill discovery events when descriptions are registered in tool schema - if (ctx?.sessionID) { - for (const skill of skills) { - Bus.publish(Session.Event.SkillDiscovered, { - sessionID: ctx.sessionID, - name: skill.name, - description: skill.description, - location: skill.location, - }) - } +function publish(ctx?: Tool.InitContext, skills?: Awaited>) { + if (!ctx?.sessionID || !skills?.length) return + + const seen = discovered() + for (const skill of skills) { + const key = `${ctx.sessionID}:${skill.name}` + if (seen.has(key)) continue + seen.add(key) + Bus.publish(Session.Event.SkillDiscovered, { + sessionID: ctx.sessionID, + name: skill.name, + description: skill.description, + location: skill.location, + }) } +} + +export const SkillTool = Tool.define("skill", async (ctx) => { + const skills = await Skill.all() + publish(ctx, skills) // Filter skills by agent permissions if agent provided const agent = ctx?.agent diff --git a/packages/cli/test/tool/skill-events.test.ts b/packages/cli/test/tool/skill-events.test.ts index 195beff..da5cdbe 100644 --- a/packages/cli/test/tool/skill-events.test.ts +++ b/packages/cli/test/tool/skill-events.test.ts @@ -25,7 +25,7 @@ const noopCtx: Tool.Context = { } describe("skill events", () => { - test("SkillDiscovered emitted per skill on init with sessionID", async () => { + test("SkillDiscovered emitted once per skill for a session", async () => { await using tmp = await tmpdir({ git: true, init: async (dir) => { @@ -66,6 +66,7 @@ description: Second skill. events.push(evt.properties) }) + await SkillTool.init({ sessionID: "test-session" }) await SkillTool.init({ sessionID: "test-session" }) unsub() @@ -126,6 +127,51 @@ description: A skill. } }) + test("SkillDiscovered emitted again for a different session", async () => { + await using tmp = await tmpdir({ + git: true, + init: async (dir) => { + const skillDir = path.join(dir, ".aictrl", "skill", "gamma") + await Bun.write( + path.join(skillDir, "SKILL.md"), + `--- +name: gamma +description: A skill. +--- + +# Gamma +`, + ) + }, + }) + + const home = process.env.AICTRL_TEST_HOME + process.env.AICTRL_TEST_HOME = tmp.path + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const events: Array<{ name: string; sessionID: string }> = [] + const unsub = Bus.subscribe(Session.Event.SkillDiscovered, (evt) => { + events.push(evt.properties) + }) + + await SkillTool.init({ sessionID: "session-a" }) + await SkillTool.init({ sessionID: "session-b" }) + + unsub() + + expect(events.length).toBe(2) + expect(events.map((evt) => evt.sessionID).sort()).toEqual(["session-a", "session-b"]) + expect(events.every((evt) => evt.name === "gamma")).toBe(true) + }, + }) + } finally { + process.env.AICTRL_TEST_HOME = home + } + }) + test("SkillLoaded emitted when skill tool is executed", async () => { await using tmp = await tmpdir({ git: true,