From a202b31be0073cf07f253456e5535dd52c90409e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jason=20K=C3=B6lker?= Date: Tue, 6 Jan 2026 03:16:14 +0000 Subject: [PATCH] fix(hooks): prevent notifications triggering loops Background task completion messages were injected as new user prompts and matched keyword-detector search/analyze triggers (e.g., "Explore"), causing recursive background fan-out and repeated repo cloning. This is a problem because it creates runaway subagent sessions, saturates tool usage, and overwhelms the system with redundant clones. The fix tags completion prompts with origin metadata and centralizes origin detection so keyword injection/detection is skipped for `background-notification` parts while still allowing immediate continuation. Added tests for origin detection and keyword-detector behavior. --- src/features/background-agent/manager.ts | 8 ++- src/hooks/claude-code-hooks/index.ts | 6 +- src/hooks/keyword-detector/index.test.ts | 71 ++++++++++++++++++++++++ src/hooks/keyword-detector/index.ts | 6 +- src/shared/index.ts | 1 + src/shared/message-origin.test.ts | 30 ++++++++++ src/shared/message-origin.ts | 11 ++++ 7 files changed, 129 insertions(+), 4 deletions(-) create mode 100644 src/hooks/keyword-detector/index.test.ts create mode 100644 src/shared/message-origin.test.ts create mode 100644 src/shared/message-origin.ts diff --git a/src/features/background-agent/manager.ts b/src/features/background-agent/manager.ts index c1483ebe8..676f26ee6 100644 --- a/src/features/background-agent/manager.ts +++ b/src/features/background-agent/manager.ts @@ -343,7 +343,13 @@ export class BackgroundManager { body: { agent: prevMessage?.agent, model: modelField, - parts: [{ type: "text", text: message }], + parts: [ + { + type: "text", + text: message, + metadata: { origin: "background-notification" }, + }, + ], }, query: { directory: this.directory }, }) diff --git a/src/hooks/claude-code-hooks/index.ts b/src/hooks/claude-code-hooks/index.ts index acfc3ee45..557dcc7d2 100644 --- a/src/hooks/claude-code-hooks/index.ts +++ b/src/hooks/claude-code-hooks/index.ts @@ -26,7 +26,7 @@ import { import { cacheToolInput, getToolInput } from "./tool-input-cache" import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript" import type { PluginConfig } from "./types" -import { log, isHookDisabled } from "../../shared" +import { hasPartOrigin, log, isHookDisabled } from "../../shared" import { injectHookMessage } from "../../features/hook-message-injector" import { detectKeywordsWithType, removeCodeBlocks } from "../keyword-detector" @@ -138,7 +138,9 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig return } - const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt), input.agent) + const detectedKeywords = hasPartOrigin(textParts, "background-notification") + ? [] + : detectKeywordsWithType(removeCodeBlocks(prompt), input.agent) const keywordMessages = detectedKeywords.map((k) => k.message) if (keywordMessages.length > 0) { diff --git a/src/hooks/keyword-detector/index.test.ts b/src/hooks/keyword-detector/index.test.ts new file mode 100644 index 000000000..753070671 --- /dev/null +++ b/src/hooks/keyword-detector/index.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test" + +import { createKeywordDetectorHook } from "./index" + +describe("keyword-detector hook", () => { + function createMockInput() { + const toastCalls: Array<{ title: string; message: string }> = [] + + const ctx = { + client: { + tui: { + showToast: async (opts: any) => { + toastCalls.push({ + title: opts.body.title, + message: opts.body.message, + }) + return {} + }, + }, + }, + } as any + + return { ctx, toastCalls } + } + + test("skips keyword detection for background notifications", async () => { + // #given + const { ctx, toastCalls } = createMockInput() + const hook = createKeywordDetectorHook(ctx) + const output = { + message: {} as Record, + parts: [ + { + type: "text", + text: "ultrawork", + metadata: { origin: "background-notification" }, + }, + ], + } + + // #when + await hook["chat.message"]( + { sessionID: "ses-1", agent: "sisyphus" }, + output, + ) + + // #then + expect(output.message.variant).toBeUndefined() + expect(toastCalls).toHaveLength(0) + }) + + test("applies ultrawork behavior for regular user messages", async () => { + // #given + const { ctx, toastCalls } = createMockInput() + const hook = createKeywordDetectorHook(ctx) + const output = { + message: {} as Record, + parts: [{ type: "text", text: "ultrawork" }], + } + + // #when + await hook["chat.message"]( + { sessionID: "ses-2", agent: "sisyphus" }, + output, + ) + + // #then + expect(output.message.variant).toBe("max") + expect(toastCalls).toHaveLength(1) + }) +}) diff --git a/src/hooks/keyword-detector/index.ts b/src/hooks/keyword-detector/index.ts index 462fd398e..0fc28e5d1 100644 --- a/src/hooks/keyword-detector/index.ts +++ b/src/hooks/keyword-detector/index.ts @@ -1,6 +1,6 @@ import type { PluginInput } from "@opencode-ai/plugin" import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector" -import { log } from "../../shared" +import { hasPartOrigin, log } from "../../shared" export * from "./detector" export * from "./constants" @@ -20,6 +20,10 @@ export function createKeywordDetectorHook(ctx: PluginInput) { parts: Array<{ type: string; text?: string; [key: string]: unknown }> } ): Promise => { + if (hasPartOrigin(output.parts, "background-notification")) { + return + } + const promptText = extractPromptText(output.parts) const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), input.agent) diff --git a/src/shared/index.ts b/src/shared/index.ts index 3c3f25e7f..835080651 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -19,3 +19,4 @@ export * from "./migration" export * from "./opencode-config-dir" export * from "./opencode-version" export * from "./permission-compat" +export * from "./message-origin" diff --git a/src/shared/message-origin.test.ts b/src/shared/message-origin.test.ts new file mode 100644 index 000000000..ad37565e1 --- /dev/null +++ b/src/shared/message-origin.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test" + +import { hasPartOrigin } from "./message-origin" + +describe("hasPartOrigin", () => { + test("returns true when a part matches origin", () => { + // #given + const parts = [ + { metadata: { origin: "background-notification" } }, + { metadata: { origin: "user" } }, + ] + + // #when + const result = hasPartOrigin(parts, "background-notification") + + // #then + expect(result).toBe(true) + }) + + test("returns false when no part matches origin", () => { + // #given + const parts = [{ metadata: { origin: "user" } }, {}] + + // #when + const result = hasPartOrigin(parts, "background-notification") + + // #then + expect(result).toBe(false) + }) +}) diff --git a/src/shared/message-origin.ts b/src/shared/message-origin.ts new file mode 100644 index 000000000..2bb9800ad --- /dev/null +++ b/src/shared/message-origin.ts @@ -0,0 +1,11 @@ +export type PartWithMetadata = Record & { + metadata?: Record +} + +export function hasPartOrigin(parts: PartWithMetadata[], origin: string): boolean { + return parts.some((part) => { + const metadata = part.metadata + if (!metadata) return false + return metadata.origin === origin + }) +}