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
@@ -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"