From 0c39358ebe15525c8cbf6adf1efc90bf76760e97 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 1 May 2026 17:18:51 +0900 Subject: [PATCH] fix(keyword-detector): require skill evaluation in analyze mode Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- src/hooks/keyword-detector/constants.ts | 2 +- src/hooks/keyword-detector/index.test.ts | 99 +++++++++++++----------- 2 files changed, 54 insertions(+), 47 deletions(-) diff --git a/src/hooks/keyword-detector/constants.ts b/src/hooks/keyword-detector/constants.ts index 5f11717e0..5ae4568fe 100644 --- a/src/hooks/keyword-detector/constants.ts +++ b/src/hooks/keyword-detector/constants.ts @@ -38,7 +38,7 @@ IF COMPLEX - DO NOT STRUGGLE ALONE. Consult specialists: SYNTHESIZE findings before proceeding. --- -MANDATORY delegate_task params: ALWAYS include load_skills=[] and run_in_background when calling delegate_task. +MANDATORY delegate_task params: ALWAYS include load_skills and run_in_background when calling delegate_task. Evaluate available skills before dispatch - pass task-appropriate skills when relevant, pass [] ONLY when no skill matches the task domain. Example: delegate_task(subagent_type="explore", prompt="...", run_in_background=true, load_skills=[])`, }, ] diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index dba360400..95596c233 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -1,3 +1,5 @@ +/// + import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" import type { PluginInput } from "@opencode-ai/plugin" import { createKeywordDetectorHook } from "./index" @@ -6,6 +8,26 @@ import { ContextCollector } from "../../features/context-injector" import * as sharedModule from "../../shared" import * as sessionState from "../../features/claude-code-session-state" +type ToastOptions = { body: { title: string } } + +function createPluginInputWithToast(showToast: (options: ToastOptions) => Promise): PluginInput { + const client = {} as PluginInput["client"] + Object.assign(client, { tui: { showToast } }) + + return { + client, + project: { + id: "keyword-detector-test-project", + worktree: "/tmp/keyword-detector-test", + time: { created: 0 }, + }, + directory: "/tmp/keyword-detector-test", + worktree: "/tmp/keyword-detector-test", + serverUrl: new URL("http://localhost"), + $: {} as PluginInput["$"], + } +} + describe("keyword-detector message transform", () => { let logCalls: Array<{ msg: string; data?: unknown }> let logSpy: ReturnType @@ -26,13 +48,7 @@ describe("keyword-detector message transform", () => { }) function createMockPluginInput() { - return { - client: { - tui: { - showToast: async () => {}, - }, - }, - } as unknown as PluginInput + return createPluginInputWithToast(async () => {}) } test("should prepend ultrawork message to text part", async () => { @@ -78,6 +94,27 @@ describe("keyword-detector message transform", () => { expect(textPart!.text).toContain("[search-mode]") }) + test("should tell analyze-mode agents to evaluate skills before delegating", async () => { + // given - analyze mode keyword detection runs on a user investigation request + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "analyze-skill-guidance-session" + const output = { + message: {} as Record, + parts: [{ type: "text", text: "investigate why subagents miss recovery skills" }], + } + + // when - analyze mode is injected + await hook["chat.message"]({ sessionID }, output) + + // then - guidance should require evaluating skills, not hard-code an empty skill list + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("Evaluate available skills before dispatch") + expect(textPart!.text).toContain("pass [] ONLY when no skill matches") + expect(textPart!.text).not.toContain("ALWAYS include load_skills=[]") + }) + test("should NOT transform when no keywords detected", async () => { // given - no keywords in message const collector = new ContextCollector() @@ -117,15 +154,9 @@ describe("keyword-detector session filtering", () => { function createMockPluginInput(options: { toastCalls?: string[] } = {}) { const toastCalls = options.toastCalls ?? [] - return { - client: { - tui: { - showToast: async (opts: { body: { title: string } }) => { - toastCalls.push(opts.body.title) - }, - }, - }, - } as unknown as PluginInput + return createPluginInputWithToast(async (options) => { + toastCalls.push(options.body.title) + }) } test("should skip non-ultrawork keywords in non-main session (using mainSessionID check)", async () => { @@ -262,15 +293,9 @@ describe("keyword-detector word boundary", () => { function createMockPluginInput(options: { toastCalls?: string[] } = {}) { const toastCalls = options.toastCalls ?? [] - return { - client: { - tui: { - showToast: async (opts: { body: { title: string } }) => { - toastCalls.push(opts.body.title) - }, - }, - }, - } as unknown as PluginInput + return createPluginInputWithToast(async (options) => { + toastCalls.push(options.body.title) + }) } test("should NOT trigger ultrawork on partial matches like 'StatefulWidget' containing 'ulw'", async () => { @@ -358,13 +383,7 @@ describe("keyword-detector system-reminder filtering", () => { }) function createMockPluginInput() { - return { - client: { - tui: { - showToast: async () => {}, - }, - }, - } as unknown as PluginInput + return createPluginInputWithToast(async () => {}) } test("should NOT trigger search mode from keywords inside tags", async () => { @@ -549,13 +568,7 @@ describe("keyword-detector agent-specific ultrawork messages", () => { }) function createMockPluginInput() { - return { - client: { - tui: { - showToast: async () => {}, - }, - }, - } as unknown as PluginInput + return createPluginInputWithToast(async () => {}) } test("should skip ultrawork injection when agent is prometheus", async () => { @@ -766,13 +779,7 @@ describe("keyword-detector non-OMO agent skipping", () => { }) function createMockPluginInput() { - return { - client: { - tui: { - showToast: async () => {}, - }, - }, - } as unknown as PluginInput + return createPluginInputWithToast(async () => {}) } test("should skip all keyword injection for OpenCode-Builder agent", async () => {