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, "")
|
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(
|
function resolveMessage(
|
||||||
message: string | ((agentName?: string, modelID?: string) => string),
|
message: string | ((agentName?: string, modelID?: string) => string),
|
||||||
agentName?: string,
|
agentName?: string,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { PluginInput } from "@opencode-ai/plugin"
|
import type { PluginInput } from "@opencode-ai/plugin"
|
||||||
import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector"
|
import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector"
|
||||||
import type { DetectedKeyword } from "./detector"
|
import type { DetectedKeyword } from "./detector"
|
||||||
import { detectKeywordsWithType, extractPromptText } from "./detector"
|
import { detectKeywordsWithType, extractPromptText, looksLikeSlashCommand } from "./detector"
|
||||||
import { isPlannerAgent, isNonOmoAgent } from "./constants"
|
import { isPlannerAgent, isNonOmoAgent } from "./constants"
|
||||||
import { log } from "../../shared"
|
import { log } from "../../shared"
|
||||||
import {
|
import {
|
||||||
@@ -58,6 +58,11 @@ export function createKeywordDetectorHook(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (looksLikeSlashCommand(promptText)) {
|
||||||
|
log(`[keyword-detector] Skipping slash command invocation`, { sessionID: input.sessionID })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
|
const currentAgent = getSessionAgent(input.sessionID) ?? input.agent
|
||||||
|
|
||||||
// Skip all keyword injection for non-OMO agents (e.g., OpenCode-Builder, Plan)
|
// 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")
|
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 () => {
|
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
|
// given - hook running with planner-named agent and a prompt that only triggers hpp
|
||||||
const sessionID = "hyperplan-planner-session"
|
const sessionID = "hyperplan-planner-session"
|
||||||
|
|||||||
Reference in New Issue
Block a user