Merge pull request #1554 from code-yeongyu/fix/1187-dynamic-skill-reminder

Fix category-skill-reminder to prioritize user-installed skills
This commit is contained in:
YeonGyu-Kim 2026-02-06 19:05:49 +09:00 committed by GitHub
commit 8961026285
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 133 additions and 44 deletions

View File

@ -1,6 +1,7 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { createCategorySkillReminderHook } from "./index" import { createCategorySkillReminderHook } from "./index"
import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state" import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import * as sharedModule from "../../shared" import * as sharedModule from "../../shared"
describe("category-skill-reminder hook", () => { describe("category-skill-reminder hook", () => {
@ -29,10 +30,14 @@ describe("category-skill-reminder hook", () => {
} as any } as any
} }
function createHook(availableSkills: AvailableSkill[] = []) {
return createCategorySkillReminderHook(createMockPluginInput(), availableSkills)
}
describe("target agent detection", () => { describe("target agent detection", () => {
test("should inject reminder for sisyphus agent after 3 tool calls", async () => { test("should inject reminder for sisyphus agent after 3 tool calls", async () => {
// given - sisyphus agent session with multiple tool calls // given - sisyphus agent session with multiple tool calls
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "sisyphus-session" const sessionID = "sisyphus-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -52,7 +57,7 @@ describe("category-skill-reminder hook", () => {
test("should inject reminder for atlas agent", async () => { test("should inject reminder for atlas agent", async () => {
// given - atlas agent session // given - atlas agent session
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "atlas-session" const sessionID = "atlas-session"
updateSessionAgent(sessionID, "Atlas") updateSessionAgent(sessionID, "Atlas")
@ -71,7 +76,7 @@ describe("category-skill-reminder hook", () => {
test("should inject reminder for sisyphus-junior agent", async () => { test("should inject reminder for sisyphus-junior agent", async () => {
// given - sisyphus-junior agent session // given - sisyphus-junior agent session
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "junior-session" const sessionID = "junior-session"
updateSessionAgent(sessionID, "sisyphus-junior") updateSessionAgent(sessionID, "sisyphus-junior")
@ -90,7 +95,7 @@ describe("category-skill-reminder hook", () => {
test("should NOT inject reminder for non-target agents", async () => { test("should NOT inject reminder for non-target agents", async () => {
// given - librarian agent session (not a target) // given - librarian agent session (not a target)
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "librarian-session" const sessionID = "librarian-session"
updateSessionAgent(sessionID, "librarian") updateSessionAgent(sessionID, "librarian")
@ -109,7 +114,7 @@ describe("category-skill-reminder hook", () => {
test("should detect agent from input.agent when session state is empty", async () => { test("should detect agent from input.agent when session state is empty", async () => {
// given - no session state, agent provided in input // given - no session state, agent provided in input
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "input-agent-session" const sessionID = "input-agent-session"
const output = { title: "", output: "result", metadata: {} } const output = { title: "", output: "result", metadata: {} }
@ -127,7 +132,7 @@ describe("category-skill-reminder hook", () => {
describe("delegation tool tracking", () => { describe("delegation tool tracking", () => {
test("should NOT inject reminder if delegate_task is used", async () => { test("should NOT inject reminder if delegate_task is used", async () => {
// given - sisyphus agent that uses delegate_task // given - sisyphus agent that uses delegate_task
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "delegation-session" const sessionID = "delegation-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -147,7 +152,7 @@ describe("category-skill-reminder hook", () => {
test("should NOT inject reminder if call_omo_agent is used", async () => { test("should NOT inject reminder if call_omo_agent is used", async () => {
// given - sisyphus agent that uses call_omo_agent // given - sisyphus agent that uses call_omo_agent
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "omo-agent-session" const sessionID = "omo-agent-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -167,7 +172,7 @@ describe("category-skill-reminder hook", () => {
test("should NOT inject reminder if task tool is used", async () => { test("should NOT inject reminder if task tool is used", async () => {
// given - sisyphus agent that uses task tool // given - sisyphus agent that uses task tool
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "task-session" const sessionID = "task-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -189,7 +194,7 @@ describe("category-skill-reminder hook", () => {
describe("tool call counting", () => { describe("tool call counting", () => {
test("should NOT inject reminder before 3 tool calls", async () => { test("should NOT inject reminder before 3 tool calls", async () => {
// given - sisyphus agent with only 2 tool calls // given - sisyphus agent with only 2 tool calls
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "few-calls-session" const sessionID = "few-calls-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -207,7 +212,7 @@ describe("category-skill-reminder hook", () => {
test("should only inject reminder once per session", async () => { test("should only inject reminder once per session", async () => {
// given - sisyphus agent session // given - sisyphus agent session
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "once-session" const sessionID = "once-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -231,7 +236,7 @@ describe("category-skill-reminder hook", () => {
test("should only count delegatable work tools", async () => { test("should only count delegatable work tools", async () => {
// given - sisyphus agent with mixed tool calls // given - sisyphus agent with mixed tool calls
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "mixed-tools-session" const sessionID = "mixed-tools-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -252,7 +257,7 @@ describe("category-skill-reminder hook", () => {
describe("event handling", () => { describe("event handling", () => {
test("should reset state on session.deleted event", async () => { test("should reset state on session.deleted event", async () => {
// given - sisyphus agent with reminder already shown // given - sisyphus agent with reminder already shown
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "delete-session" const sessionID = "delete-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -278,7 +283,7 @@ describe("category-skill-reminder hook", () => {
test("should reset state on session.compacted event", async () => { test("should reset state on session.compacted event", async () => {
// given - sisyphus agent with reminder already shown // given - sisyphus agent with reminder already shown
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "compact-session" const sessionID = "compact-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -306,7 +311,7 @@ describe("category-skill-reminder hook", () => {
describe("case insensitivity", () => { describe("case insensitivity", () => {
test("should handle tool names case-insensitively", async () => { test("should handle tool names case-insensitively", async () => {
// given - sisyphus agent with mixed case tool names // given - sisyphus agent with mixed case tool names
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "case-session" const sessionID = "case-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -325,7 +330,7 @@ describe("category-skill-reminder hook", () => {
test("should handle delegation tool names case-insensitively", async () => { test("should handle delegation tool names case-insensitively", async () => {
// given - sisyphus agent using DELEGATE_TASK in uppercase // given - sisyphus agent using DELEGATE_TASK in uppercase
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "case-delegate-session" const sessionID = "case-delegate-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@ -343,4 +348,71 @@ describe("category-skill-reminder hook", () => {
clearSessionAgent(sessionID) clearSessionAgent(sessionID)
}) })
}) })
describe("dynamic skills reminder message", () => {
test("shows built-in skills when only built-in skills are available", async () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "frontend-ui-ux", description: "Frontend UI/UX work", location: "plugin" },
{ name: "git-master", description: "Git operations", location: "plugin" },
{ name: "playwright", description: "Browser automation", location: "plugin" },
]
const hook = createHook(availableSkills)
const sessionID = "builtins-only"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// when
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
// then
expect(output.output).toContain("**Built-in**:")
expect(output.output).toContain("frontend-ui-ux")
expect(output.output).toContain("**⚡ YOUR SKILLS (PRIORITY)**")
expect(output.output).toContain("load_skills=[\"frontend-ui-ux\"")
})
test("emphasizes user skills with PRIORITY and uses first user skill in example", async () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "frontend-ui-ux", description: "Frontend UI/UX work", location: "plugin" },
{ name: "react-19", description: "React 19 expertise", location: "user" },
{ name: "web-designer", description: "Visual design", location: "user" },
]
const hook = createHook(availableSkills)
const sessionID = "user-skills"
updateSessionAgent(sessionID, "Atlas")
const output = { title: "", output: "result", metadata: {} }
// when
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "3" }, output)
// then
expect(output.output).toContain("**⚡ YOUR SKILLS (PRIORITY)**")
expect(output.output).toContain("react-19")
expect(output.output).toContain("> User-installed skills OVERRIDE")
expect(output.output).toContain("load_skills=[\"react-19\"")
})
test("still injects a generic reminder when no skills are provided", async () => {
// given
const hook = createHook([])
const sessionID = "no-skills"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// when
await hook["tool.execute.after"]({ tool: "read", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "read", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "read", sessionID, callID: "3" }, output)
// then
expect(output.output).toContain("[Category+Skill Reminder]")
expect(output.output).toContain("load_skills=[]")
})
})
}) })

View File

@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { getSessionAgent } from "../../features/claude-code-session-state" import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared" import { log } from "../../shared"
@ -34,33 +35,41 @@ const DELEGATION_TOOLS = new Set([
"task", "task",
]) ])
const REMINDER_MESSAGE = ` function formatSkillNames(skills: AvailableSkill[], limit: number): string {
[Category+Skill Reminder] if (skills.length === 0) return "(none)"
const shown = skills.slice(0, limit).map((s) => s.name)
const remaining = skills.length - shown.length
const suffix = remaining > 0 ? ` (+${remaining} more)` : ""
return shown.join(", ") + suffix
}
You are an orchestrator agent. Consider whether this work should be delegated: function buildReminderMessage(availableSkills: AvailableSkill[]): string {
const builtinSkills = availableSkills.filter((s) => s.location === "plugin")
const customSkills = availableSkills.filter((s) => s.location !== "plugin")
**DELEGATE when:** const builtinText = formatSkillNames(builtinSkills, 8)
- UI/Frontend work category: "visual-engineering", skills: ["frontend-ui-ux"] const customText = formatSkillNames(customSkills, 8)
- Complex logic/architecture category: "ultrabrain"
- Quick/trivial tasks category: "quick"
- Git operations skills: ["git-master"]
- Browser automation skills: ["playwright"] or ["agent-browser"]
**DO IT YOURSELF when:** const exampleSkillName = customSkills[0]?.name ?? builtinSkills[0]?.name
- Gathering context/exploring codebase const loadSkills = exampleSkillName ? `["${exampleSkillName}"]` : "[]"
- Simple edits that are part of a larger task you're coordinating
- Tasks requiring your full context understanding
Example delegation: const lines = [
\`\`\` "",
delegate_task( "[Category+Skill Reminder]",
category="visual-engineering", "",
load_skills=["frontend-ui-ux"], `**Built-in**: ${builtinText}`,
description="Implement responsive navbar with animations", `**⚡ YOUR SKILLS (PRIORITY)**: ${customText}`,
run_in_background=true "",
) "> User-installed skills OVERRIDE built-in defaults. ALWAYS prefer YOUR SKILLS when domain matches.",
\`\`\` "",
` "```typescript",
`delegate_task(category=\"visual-engineering\", load_skills=${loadSkills}, run_in_background=true)`,
"```",
"",
]
return lines.join("\n")
}
interface ToolExecuteInput { interface ToolExecuteInput {
tool: string tool: string
@ -81,8 +90,12 @@ interface SessionState {
toolCallCount: number toolCallCount: number
} }
export function createCategorySkillReminderHook(_ctx: PluginInput) { export function createCategorySkillReminderHook(
_ctx: PluginInput,
availableSkills: AvailableSkill[] = []
) {
const sessionStates = new Map<string, SessionState>() const sessionStates = new Map<string, SessionState>()
const reminderMessage = buildReminderMessage(availableSkills)
function getOrCreateState(sessionID: string): SessionState { function getOrCreateState(sessionID: string): SessionState {
if (!sessionStates.has(sessionID)) { if (!sessionStates.has(sessionID)) {
@ -130,7 +143,7 @@ export function createCategorySkillReminderHook(_ctx: PluginInput) {
state.toolCallCount++ state.toolCallCount++
if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) { if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) {
output.output += REMINDER_MESSAGE output.output += reminderMessage
state.reminderShown = true state.reminderShown = true
log("[category-skill-reminder] Reminder injected", { log("[category-skill-reminder] Reminder injected", {
sessionID, sessionID,

View File

@ -1,4 +1,5 @@
import type { Plugin, ToolDefinition } from "@opencode-ai/plugin"; import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder";
import { import {
createTodoContinuationEnforcer, createTodoContinuationEnforcer,
createContextWindowMonitorHook, createContextWindowMonitorHook,
@ -59,7 +60,6 @@ import {
import type { SkillScope } from "./features/opencode-skill-loader/types"; import type { SkillScope } from "./features/opencode-skill-loader/types";
import { createBuiltinSkills } from "./features/builtin-skills"; import { createBuiltinSkills } from "./features/builtin-skills";
import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader"; import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader";
import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder";
import { import {
setMainSession, setMainSession,
getMainSessionID, getMainSessionID,
@ -122,6 +122,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const pluginConfig = loadPluginConfig(ctx.directory, ctx); const pluginConfig = loadPluginConfig(ctx.directory, ctx);
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
const firstMessageVariantGate = createFirstMessageVariantGate(); const firstMessageVariantGate = createFirstMessageVariantGate();
const tmuxConfig = { const tmuxConfig = {
@ -249,9 +250,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createThinkingBlockValidatorHook() ? createThinkingBlockValidatorHook()
: null; : null;
const categorySkillReminder = isHookEnabled("category-skill-reminder") let categorySkillReminder: ReturnType<typeof createCategorySkillReminderHook> | null = null;
? createCategorySkillReminderHook(ctx)
: null;
const ralphLoop = isHookEnabled("ralph-loop") const ralphLoop = isHookEnabled("ralph-loop")
? createRalphLoopHook(ctx, { ? createRalphLoopHook(ctx, {
@ -483,6 +482,11 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
}); });
}, },
}); });
categorySkillReminder = isHookEnabled("category-skill-reminder")
? createCategorySkillReminderHook(ctx, availableSkills)
: null;
const skillMcpManager = new SkillMcpManager(); const skillMcpManager = new SkillMcpManager();
const getSessionIDForMcp = () => getMainSessionID() || ""; const getSessionIDForMcp = () => getMainSessionID() || "";
const skillTool = createSkillTool({ const skillTool = createSkillTool({