fix(keyword-detector): yield to slash commands so /hyperplan executes its template

When user types /hyperplan something, two pipelines race for the message:
  1. keyword-detector hook (chat.message): the regex \b(hyperplan|hpp)\b
     matches /hyperplan because \b is satisfied by the / boundary, so the
     hook prepends <hyperplan-mode> to the text part.
  2. auto-slash-command hook (chat.message, runs immediately after):
     detectSlashCommand() checks `text.trimStart().startsWith("/")`. After
     keyword-detector's prepend, the part now starts with <hyperplan-mode>
     and the slash check fails, so the builtin command template
     (with $ARGUMENTS substituted) is never injected.

The visible symptom: /hyperplan refactor X never runs the actual
HYPERPLAN_TEMPLATE - the user sees only the keyword-detector wrapper, which
is similar but not identical, and the slash command's $ARGUMENTS payload
is silently lost.

Fix: at the top of the keyword-detector hook, after isSystemDirective() but
before any keyword scan, bail out if the prompt text starts with a slash
command (^\s*\/[a-zA-Z][\w-]*\b). Slash commands are explicit invocations
and own their own mode-injection path; the keyword detector must not race
them. Free-form mentions like "hyperplan: refactor X" still trigger
keyword detection - only the leading-slash form is suppressed.

Lock the contract with three regression tests:
  - /hyperplan refactor ... must NOT inject <hyperplan-mode>
  - /hpp investigate ... must NOT inject (shorthand slash command)
  - "hyperplan: refactor src/auth/handler.ts" still injects (free-form)

Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode).
This commit is contained in:
YeonGyu-Kim
2026-04-30 16:08:15 +09:00
parent d8f89b6965
commit f6643e7e77
3 changed files with 73 additions and 1 deletions
+6
View File
@@ -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,
+6 -1
View File
@@ -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)
@@ -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<string, unknown>,
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("<hyperplan-mode>")
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<string, unknown>,
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("<hyperplan-mode>")
})
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<string, unknown>,
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("<hyperplan-mode>")
})
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"