From 01500f1ebe11a7c14a96acc1780fb020edbb11e9 Mon Sep 17 00:00:00 2001 From: TheEpTic Date: Wed, 28 Jan 2026 07:26:37 +0000 Subject: [PATCH] Fix: prevent system-reminder tags from triggering mode keywords (#1155) Automated system messages with tags were incorrectly triggering [search-mode], [analyze-mode], and other keyword modes when they contained words like "search", "find", "explore", etc. Changes: - Add removeSystemReminders() to strip content before keyword detection - Add hasSystemReminder() utility function - Update keyword-detector to clean text before pattern matching - Add comprehensive test coverage for system-reminder filtering Fixes issue where automated system notifications caused agents to incorrectly enter MAXIMUM SEARCH EFFORT mode. Co-authored-by: TheEpTic --- src/hooks/keyword-detector/index.test.ts | 191 +++++++++++++++++++++++ src/hooks/keyword-detector/index.ts | 7 +- src/shared/system-directive.test.ts | 191 +++++++++++++++++++++++ src/shared/system-directive.ts | 20 +++ 4 files changed, 407 insertions(+), 2 deletions(-) create mode 100644 src/shared/system-directive.test.ts diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index c05f9f75a..7c28bab0e 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -338,6 +338,197 @@ describe("keyword-detector word boundary", () => { }) }) +describe("keyword-detector system-reminder filtering", () => { + let logCalls: Array<{ msg: string; data?: unknown }> + let logSpy: ReturnType + + beforeEach(() => { + setMainSession(undefined) + logCalls = [] + logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => { + logCalls.push({ msg, data }) + }) + }) + + afterEach(() => { + logSpy?.mockRestore() + setMainSession(undefined) + }) + + function createMockPluginInput() { + return { + client: { + tui: { + showToast: async () => {}, + }, + }, + } as any + } + + test("should NOT trigger search mode from keywords inside tags", async () => { + // #given - message contains search keywords only inside system-reminder tags + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "test-session" + const output = { + message: {} as Record, + parts: [{ + type: "text", + text: ` +The system will search for the file and find all occurrences. +Please locate and scan the directory. +` + }], + } + + // #when - keyword detection runs on system-reminder content + await hook["chat.message"]({ sessionID }, output) + + // #then - should NOT trigger search mode (text should remain unchanged) + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).not.toContain("[search-mode]") + expect(textPart!.text).toContain("") + }) + + test("should NOT trigger analyze mode from keywords inside tags", async () => { + // #given - message contains analyze keywords only inside system-reminder tags + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "test-session" + const output = { + message: {} as Record, + parts: [{ + type: "text", + text: ` +You should investigate and examine the code carefully. +Research the implementation details. +` + }], + } + + // #when - keyword detection runs on system-reminder content + await hook["chat.message"]({ sessionID }, output) + + // #then - should NOT trigger analyze mode + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).not.toContain("[analyze-mode]") + expect(textPart!.text).toContain("") + }) + + test("should detect keywords in user text even when system-reminder is present", async () => { + // #given - message contains both system-reminder and user search keyword + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "test-session" + const output = { + message: {} as Record, + parts: [{ + type: "text", + text: ` +System will find and locate files. + + +Please search for the bug in the code.` + }], + } + + // #when - keyword detection runs on mixed content + await hook["chat.message"]({ sessionID }, output) + + // #then - should trigger search mode from user text only + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).toContain("[search-mode]") + expect(textPart!.text).toContain("Please search for the bug in the code.") + }) + + test("should handle multiple system-reminder tags in message", async () => { + // #given - message contains multiple system-reminder blocks with keywords + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "test-session" + const output = { + message: {} as Record, + parts: [{ + type: "text", + text: ` +First reminder with search and find keywords. + + +User message without keywords. + + +Second reminder with investigate and examine keywords. +` + }], + } + + // #when - keyword detection runs on message with multiple system-reminders + await hook["chat.message"]({ sessionID }, output) + + // #then - should NOT trigger any mode (only user text exists, no keywords) + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).not.toContain("[search-mode]") + expect(textPart!.text).not.toContain("[analyze-mode]") + }) + + test("should handle case-insensitive system-reminder tags", async () => { + // #given - message contains system-reminder with different casing + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "test-session" + const output = { + message: {} as Record, + parts: [{ + type: "text", + text: ` +System will search and find files. +` + }], + } + + // #when - keyword detection runs on uppercase system-reminder + await hook["chat.message"]({ sessionID }, output) + + // #then - should NOT trigger search mode + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).not.toContain("[search-mode]") + }) + + test("should handle multiline system-reminder content with search keywords", async () => { + // #given - system-reminder with multiline content containing various search keywords + const collector = new ContextCollector() + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const sessionID = "test-session" + const output = { + message: {} as Record, + parts: [{ + type: "text", + text: ` +Commands executed: +- find: searched for pattern +- grep: located file +- scan: completed + +Please explore the codebase and discover patterns. +` + }], + } + + // #when - keyword detection runs on multiline system-reminder + await hook["chat.message"]({ sessionID }, output) + + // #then - should NOT trigger search mode + const textPart = output.parts.find(p => p.type === "text") + expect(textPart).toBeDefined() + expect(textPart!.text).not.toContain("[search-mode]") + }) +}) + describe("keyword-detector agent-specific ultrawork messages", () => { let logCalls: Array<{ msg: string; data?: unknown }> let logSpy: ReturnType diff --git a/src/hooks/keyword-detector/index.ts b/src/hooks/keyword-detector/index.ts index 9bb1e3425..67b8597ac 100644 --- a/src/hooks/keyword-detector/index.ts +++ b/src/hooks/keyword-detector/index.ts @@ -2,7 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin" import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector" import { isPlannerAgent } from "./constants" import { log } from "../../shared" -import { isSystemDirective } from "../../shared/system-directive" +import { hasSystemReminder, isSystemDirective, removeSystemReminders } from "../../shared/system-directive" import { getMainSessionID, getSessionAgent, subagentSessions } from "../../features/claude-code-session-state" import type { ContextCollector } from "../../features/context-injector" @@ -32,7 +32,10 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC } const currentAgent = getSessionAgent(input.sessionID) ?? input.agent - let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), currentAgent) + + // Remove system-reminder content to prevent automated system messages from triggering mode keywords + const cleanText = removeSystemReminders(promptText) + let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(cleanText), currentAgent) if (isPlannerAgent(currentAgent)) { detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork") diff --git a/src/shared/system-directive.test.ts b/src/shared/system-directive.test.ts new file mode 100644 index 000000000..9da4c9563 --- /dev/null +++ b/src/shared/system-directive.test.ts @@ -0,0 +1,191 @@ +import { describe, expect, test } from "bun:test" +import { + hasSystemReminder, + removeSystemReminders, + isSystemDirective, + createSystemDirective, +} from "./system-directive" + +describe("system-directive utilities", () => { + describe("hasSystemReminder", () => { + test("should return true for messages containing tags", () => { + const text = ` +Some system content +` + expect(hasSystemReminder(text)).toBe(true) + }) + + test("should return false for messages without system-reminder tags", () => { + const text = "Just a normal user message" + expect(hasSystemReminder(text)).toBe(false) + }) + + test("should be case-insensitive for tag names", () => { + const text = `content` + expect(hasSystemReminder(text)).toBe(true) + }) + + test("should detect system-reminder in mixed content", () => { + const text = `User text here + +System content + +More user text` + expect(hasSystemReminder(text)).toBe(true) + }) + + test("should handle empty system-reminder tags", () => { + const text = `` + expect(hasSystemReminder(text)).toBe(true) + }) + + test("should handle multiline system-reminder content", () => { + const text = ` +Line 1 +Line 2 +Line 3 +` + expect(hasSystemReminder(text)).toBe(true) + }) + }) + + describe("removeSystemReminders", () => { + test("should remove system-reminder tags and content", () => { + const text = ` +System content that should be removed +` + expect(removeSystemReminders(text)).toBe("") + }) + + test("should preserve user text outside system-reminder tags", () => { + const text = `User message here + +System content to remove + +More user text` + const result = removeSystemReminders(text) + expect(result).toContain("User message here") + expect(result).toContain("More user text") + expect(result).not.toContain("System content to remove") + }) + + test("should remove multiple system-reminder blocks", () => { + const text = `First block +User text +Second block` + const result = removeSystemReminders(text) + expect(result).toContain("User text") + expect(result).not.toContain("First block") + expect(result).not.toContain("Second block") + }) + + test("should be case-insensitive for tag names", () => { + const text = `Content` + expect(removeSystemReminders(text)).toBe("") + }) + + test("should handle nested tags correctly", () => { + const text = ` +Outer content +Some inner tag +` + expect(removeSystemReminders(text)).toBe("") + }) + + test("should trim whitespace from result", () => { + const text = ` +Remove this + +User text + +` + const result = removeSystemReminders(text) + expect(result).toBe("User text") + }) + + test("should handle empty string input", () => { + expect(removeSystemReminders("")).toBe("") + }) + + test("should handle text with no system-reminder tags", () => { + const text = "Just normal user text without any system reminders" + expect(removeSystemReminders(text)).toBe(text) + }) + + test("should preserve code blocks in user text", () => { + const text = `Here's some code: +\`\`\`javascript +const x = 1; +\`\`\` +System info` + const result = removeSystemReminders(text) + expect(result).toContain("Here's some code:") + expect(result).toContain("```javascript") + expect(result).not.toContain("System info") + }) + }) + + describe("isSystemDirective", () => { + test("should return true for OH-MY-OPENCODE system directives", () => { + const directive = createSystemDirective("TEST") + expect(isSystemDirective(directive)).toBe(true) + }) + + test("should return false for system-reminder tags", () => { + const text = `content` + expect(isSystemDirective(text)).toBe(false) + }) + + test("should return false for normal user messages", () => { + expect(isSystemDirective("Just a normal message")).toBe(false) + }) + + test("should handle leading whitespace", () => { + const directive = ` ${createSystemDirective("TEST")}` + expect(isSystemDirective(directive)).toBe(true) + }) + }) + + describe("integration with keyword detection", () => { + test("should prevent search keywords in system-reminders from triggering mode", () => { + const text = ` +The system will search for the file and find all occurrences. +Please locate and scan the directory. +` + + // After removing system reminders, no search keywords should remain + const cleanText = removeSystemReminders(text) + expect(cleanText).not.toMatch(/\b(search|find|locate|scan)\b/i) + }) + + test("should preserve search keywords in user text while removing system-reminder keywords", () => { + const text = ` +System will find and locate files. + + +Please search for the bug in the code.` + + const cleanText = removeSystemReminders(text) + expect(cleanText).toContain("search") + expect(cleanText).not.toContain("find and locate") + }) + + test("should handle complex mixed content with multiple modes", () => { + const text = ` +System will search and investigate. + + +User wants to explore the codebase and analyze the implementation. + + +Another system reminder with research keyword. +` + + const cleanText = removeSystemReminders(text) + expect(cleanText).toContain("explore") + expect(cleanText).toContain("analyze") + expect(cleanText).not.toContain("search and investigate") + expect(cleanText).not.toContain("research") + }) + }) +}) diff --git a/src/shared/system-directive.ts b/src/shared/system-directive.ts index 2252dddf2..f2ae8c602 100644 --- a/src/shared/system-directive.ts +++ b/src/shared/system-directive.ts @@ -26,6 +26,26 @@ export function isSystemDirective(text: string): boolean { return text.trimStart().startsWith(SYSTEM_DIRECTIVE_PREFIX) } +/** + * Checks if a message contains system-generated content that should be excluded + * from keyword detection and mode triggering. + * @param text - The message text to check + * @returns true if the message contains system-reminder tags + */ +export function hasSystemReminder(text: string): boolean { + return /[\s\S]*?<\/system-reminder>/i.test(text) +} + +/** + * Removes system-reminder tag content from text. + * This prevents automated system messages from triggering mode keywords. + * @param text - The message text to clean + * @returns text with system-reminder content removed + */ +export function removeSystemReminders(text: string): string { + return text.replace(/[\s\S]*?<\/system-reminder>/gi, "").trim() +} + export const SystemDirectiveTypes = { TODO_CONTINUATION: "TODO CONTINUATION", RALPH_LOOP: "RALPH LOOP",