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 + }) +}