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:
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user