diff --git a/src/hooks/keyword-detector/detector.ts b/src/hooks/keyword-detector/detector.ts index 852d04f63..99a9e2ee8 100644 --- a/src/hooks/keyword-detector/detector.ts +++ b/src/hooks/keyword-detector/detector.ts @@ -14,6 +14,12 @@ export function removeCodeBlocks(text: string): string { return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "") } +const SLASH_COMMAND_LEAD_PATTERN = /^\s*\/[a-zA-Z][\w-]*(?:\s|$)/ + +export function looksLikeSlashCommand(text: string): boolean { + return SLASH_COMMAND_LEAD_PATTERN.test(text) +} + function resolveMessage( message: string | ((agentName?: string, modelID?: string) => string), agentName?: string, diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index 85408e84c..e44c46f93 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -1,7 +1,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector" import type { DetectedKeyword } from "./detector" -import { detectKeywordsWithType, extractPromptText } from "./detector" +import { detectKeywordsWithType, extractPromptText, looksLikeSlashCommand } from "./detector" import { isPlannerAgent, isNonOmoAgent } from "./constants" import { log } from "../../shared" import { @@ -58,6 +58,11 @@ export function createKeywordDetectorHook( return } + if (looksLikeSlashCommand(promptText)) { + log(`[keyword-detector] Skipping slash command invocation`, { sessionID: input.sessionID }) + return + } + const currentAgent = getSessionAgent(input.sessionID) ?? input.agent // Skip all keyword injection for non-OMO agents (e.g., OpenCode-Builder, Plan) diff --git a/src/hooks/keyword-detector/hyperplan.test.ts b/src/hooks/keyword-detector/hyperplan.test.ts index 12e293d81..a83b8f169 100644 --- a/src/hooks/keyword-detector/hyperplan.test.ts +++ b/src/hooks/keyword-detector/hyperplan.test.ts @@ -201,6 +201,67 @@ describe("keyword-detector hyperplan keyword", () => { expect(textPart!.text).toContain("hyperplan refactor stuff") }) + test("should NOT inject hyperplan when user invokes /hyperplan slash command", async () => { + // given - main session typing the slash command form + const sessionID = "hyperplan-slash-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const toastCalls: string[] = [] + const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls })) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "/hyperplan refactor the auth module" }], + } + + // when - keyword detection runs on slash-command-prefixed text + await hook["chat.message"]({ sessionID }, output) + + // then - the slash command path owns the message; keyword detector must not double-inject + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("/hyperplan refactor the auth module") + expect(textPart!.text).not.toContain("") + expect(toastCalls).not.toContain("Hyperplan Mode Activated") + }) + + test("should NOT inject hyperplan when user invokes /hpp shorthand slash command", async () => { + // given - main session and shorthand slash command + const sessionID = "hyperplan-slash-shorthand-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "/hpp investigate the build pipeline" }], + } + + // when - keyword detection runs + await hook["chat.message"]({ sessionID }, output) + + // then - keyword detector should yield to the slash command system + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toBe("/hpp investigate the build pipeline") + expect(textPart!.text).not.toContain("") + }) + + test("should still inject hyperplan when slash appears mid-message (not a slash command)", async () => { + // given - text contains a slash later but does not start with one + const sessionID = "hyperplan-mid-slash-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput()) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "hyperplan: refactor src/auth/handler.ts" }], + } + + // when - keyword detection runs on free-form text that mentions hyperplan first + await hook["chat.message"]({ sessionID }, output) + + // then - hyperplan should still fire (this is a real keyword invocation, not a slash command) + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("") + }) + test("should skip hyperplan injection when agent name contains 'planner' token", async () => { // given - hook running with planner-named agent and a prompt that only triggers hpp const sessionID = "hyperplan-planner-session"