feat(keyword-detector): add per-keyword disable config
Adds keyword_detector.disabled_keywords config so users can opt out of
specific keyword detectors individually without disabling the entire
keyword-detector hook. Allowed values: 'ultrawork', 'search', 'analyze',
'team'. Default empty/missing -> all four detectors active (no behavior
change for existing configs).
Motivation: an audit revealed search and analyze patterns trigger on
~30-60% of normal conversational user messages (e.g. 'how to', 'why is',
'show me', '왜', '어떻게'). The disable list is the immediate kill switch
while the patterns themselves are tightened in a separate PR.
Schema follows the existing per-feature config block convention shared
by team_mode, ralph_loop, runtime_fallback, and comment_checker. The
KeywordType enum (z.enum) lives next to the config schema and is
re-imported by the detector to keep the union type in lockstep with the
schema.
Threading:
pluginConfig.keyword_detector
-> create-transform-hooks.ts (factory wiring)
-> createKeywordDetectorHook(config)
-> detectKeywordsWithType(text, agent, model, disabledKeywords)
-> Set-based filter at the source-of-truth detector
Adds 8 regression tests covering per-keyword disable, multi-keyword
disable, partial disable (one keyword off, another still firing),
ultrawork toast suppression, undefined config, and empty array.
This commit is contained in:
@@ -1041,3 +1041,237 @@ describe("keyword-detector team mode", () => {
|
||||
expect(textPart!.text).not.toContain("[team-mode]")
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector disabled_keywords config", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
_resetForTesting()
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
getMainSessionSpy?.mockRestore()
|
||||
_resetForTesting()
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: { body: { title: string } }) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as PluginInput
|
||||
}
|
||||
|
||||
test("should NOT inject search-mode when disabled_keywords includes 'search'", async () => {
|
||||
// given - keyword detector with search disabled
|
||||
const sessionID = "search-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["search"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search for the bug in the code" }],
|
||||
}
|
||||
|
||||
// when - search keyword would normally trigger
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - search-mode injection should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("search for the bug in the code")
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
})
|
||||
|
||||
test("should NOT inject analyze-mode when disabled_keywords includes 'analyze'", async () => {
|
||||
// given - keyword detector with analyze disabled
|
||||
const sessionID = "analyze-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["analyze"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "how to do this" }],
|
||||
}
|
||||
|
||||
// when - analyze keyword would normally trigger
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - analyze-mode injection should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("how to do this")
|
||||
expect(textPart!.text).not.toContain("[analyze-mode]")
|
||||
})
|
||||
|
||||
test("should NOT inject team-mode when disabled_keywords includes 'team'", async () => {
|
||||
// given - keyword detector with team disabled
|
||||
const sessionID = "team-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["team"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "let's use team mode for this" }],
|
||||
}
|
||||
|
||||
// when - team keyword would normally trigger
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - team-mode injection should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("let's use team mode for this")
|
||||
expect(textPart!.text).not.toContain("[team-mode]")
|
||||
})
|
||||
|
||||
test("should NOT inject ultrawork message AND not show toast when disabled_keywords includes 'ultrawork'", async () => {
|
||||
// given - keyword detector with ultrawork disabled
|
||||
const sessionID = "ultrawork-disabled-session"
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput({ toastCalls }),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["ultrawork"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do this task" }],
|
||||
}
|
||||
|
||||
// when - ultrawork keyword would normally trigger toast + injection
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - neither toast nor injection should occur
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("ultrawork do this task")
|
||||
expect(textPart!.text).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
|
||||
expect(toastCalls).not.toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should disable multiple keywords simultaneously when listed together", async () => {
|
||||
// given - keyword detector with both search and analyze disabled
|
||||
const sessionID = "multi-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["search", "analyze"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search and analyze the codebase" }],
|
||||
}
|
||||
|
||||
// when - both search and analyze would normally fire
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - neither mode should inject
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toBe("search and analyze the codebase")
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
expect(textPart!.text).not.toContain("[analyze-mode]")
|
||||
})
|
||||
|
||||
test("should let other keywords through when only one is disabled", async () => {
|
||||
// given - keyword detector with only search disabled, but message contains both search and analyze triggers
|
||||
const sessionID = "partial-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: ["search"] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search and analyze the codebase" }],
|
||||
}
|
||||
|
||||
// when - both keywords match but only search is disabled
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - analyze should still inject, search should be skipped
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).not.toContain("[search-mode]")
|
||||
expect(textPart!.text).toContain("[analyze-mode]")
|
||||
expect(textPart!.text).toContain("search and analyze the codebase")
|
||||
})
|
||||
|
||||
test("should behave normally (all keywords enabled) when config is undefined", async () => {
|
||||
// given - keyword detector with no config (regression test for backward compat)
|
||||
const sessionID = "no-config-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search for the answer" }],
|
||||
}
|
||||
|
||||
// when - search keyword fires with no config
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - search-mode should inject as usual
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[search-mode]")
|
||||
})
|
||||
|
||||
test("should behave normally when disabled_keywords is an empty array", async () => {
|
||||
// given - keyword detector with empty disable list
|
||||
const sessionID = "empty-disabled-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(
|
||||
createMockPluginInput(),
|
||||
undefined,
|
||||
undefined,
|
||||
{ disabled_keywords: [] },
|
||||
)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "investigate this issue" }],
|
||||
}
|
||||
|
||||
// when - analyze keyword fires with empty disable list
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// then - analyze-mode should still inject
|
||||
const textPart = output.parts.find(p => p.type === "text")
|
||||
expect(textPart).toBeDefined()
|
||||
expect(textPart!.text).toContain("[analyze-mode]")
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user