From 672f5d6e9b358d0cf473d7875ffee7d043100d05 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 15 May 2026 22:49:17 +0900 Subject: [PATCH] fix(keyword-detector): skip synthetic turns --- src/hooks/keyword-detector/hook.ts | 30 ++++++++++++++++-------- src/hooks/keyword-detector/index.test.ts | 25 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) diff --git a/src/hooks/keyword-detector/hook.ts b/src/hooks/keyword-detector/hook.ts index e44c46f93..60166f693 100644 --- a/src/hooks/keyword-detector/hook.ts +++ b/src/hooks/keyword-detector/hook.ts @@ -1,20 +1,20 @@ import type { PluginInput } from "@opencode-ai/plugin" import type { KeywordDetectorConfig } from "../../config/schema/keyword-detector" -import type { DetectedKeyword } from "./detector" -import { detectKeywordsWithType, extractPromptText, looksLikeSlashCommand } from "./detector" -import { isPlannerAgent, isNonOmoAgent } from "./constants" -import { log } from "../../shared" -import { - isSystemDirective, - removeSystemReminders, -} from "../../shared/system-directive" import { getMainSessionID, getSessionAgent, subagentSessions, } from "../../features/claude-code-session-state" import type { ContextCollector } from "../../features/context-injector" +import { log } from "../../shared" +import { + isSystemDirective, + removeSystemReminders, +} from "../../shared/system-directive" import type { RalphLoopHook } from "../ralph-loop" +import { isNonOmoAgent, isPlannerAgent } from "./constants" +import type { DetectedKeyword } from "./detector" +import { detectKeywordsWithType, extractPromptText, looksLikeSlashCommand } from "./detector" function suppressComboStandalones(detected: DetectedKeyword[]): DetectedKeyword[] { const hasCombo = detected.some((k) => k.type === "hyperplan-ultrawork") @@ -22,6 +22,11 @@ function suppressComboStandalones(detected: DetectedKeyword[]): DetectedKeyword[ return detected.filter((k) => k.type !== "ultrawork" && k.type !== "hyperplan") } +function isSyntheticTextMessage(parts: Array<{ type: string; text?: string; [key: string]: unknown }>): boolean { + const textParts = parts.filter((part) => part.type === "text" && part.text !== undefined) + return textParts.length > 0 && textParts.every((part) => part.synthetic === true) +} + export function createKeywordDetectorHook( ctx: PluginInput, _collector?: ContextCollector, @@ -30,8 +35,8 @@ export function createKeywordDetectorHook( ) { const disabledKeywords = config?.disabled_keywords function getRuntimeVariant(input: { variant?: string }, message: Record): string | undefined { - if (typeof message["variant"] === "string") { - return message["variant"] + if (typeof message.variant === "string") { + return message.variant } return typeof input.variant === "string" ? input.variant : undefined @@ -51,6 +56,11 @@ export function createKeywordDetectorHook( parts: Array<{ type: string; text?: string; [key: string]: unknown }> } ): Promise => { + if (isSyntheticTextMessage(output.parts)) { + log(`[keyword-detector] Skipping synthetic text message`, { sessionID: input.sessionID }) + return + } + const promptText = extractPromptText(output.parts) if (isSystemDirective(promptText)) { diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts index d1cf485b4..38f3b3be5 100644 --- a/src/hooks/keyword-detector/index.test.ts +++ b/src/hooks/keyword-detector/index.test.ts @@ -134,6 +134,31 @@ describe("keyword-detector message transform", () => { expect(textPart).toBeDefined() expect(textPart!.text).toBe("just a normal message") }) + + test("should not prepend mode instructions to synthetic team peer messages", async () => { + // given - team mailbox injection created a synthetic peer message containing search keywords + const collector = new ContextCollector() + const sessionID = "synthetic-peer-message-session" + getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID) + const hook = createKeywordDetectorHook(createMockPluginInput(), collector) + const output = { + message: {} as Record, + parts: [{ + type: "text", + synthetic: true, + text: 'search the issue thread and report findings', + }], + } + + // when - keyword detection sees the synthetic peer message + await hook["chat.message"]({ sessionID }, output) + + // then - peer message content is preserved without search-mode becoming part of the user turn + const textPart = output.parts.find((part) => part.type === "text") + expect(textPart).toBeDefined() + expect(textPart?.text).toBe('search the issue thread and report findings') + expect(textPart?.text).not.toContain("[search-mode]") + }) }) describe("keyword-detector session filtering", () => {