diff --git a/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts b/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts index 927e6b96d..482d59dc6 100644 --- a/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts +++ b/src/hooks/claude-code-hooks/handlers/chat-message-handler.ts @@ -50,6 +50,7 @@ export function createChatMessageHandler( }) const messageParts: MessagePart[] = textParts.map((p) => ({ + ...p, type: "text", text: p.text, })) diff --git a/src/hooks/claude-code-hooks/user-prompt-submit.test.ts b/src/hooks/claude-code-hooks/user-prompt-submit.test.ts index 334164fbe..a99411720 100644 --- a/src/hooks/claude-code-hooks/user-prompt-submit.test.ts +++ b/src/hooks/claude-code-hooks/user-prompt-submit.test.ts @@ -1,10 +1,16 @@ -import { describe, it, expect } from "bun:test" +import { afterEach, describe, expect, it, mock, spyOn } from "bun:test" +import { OMO_INTERNAL_INITIATOR_MARKER } from "../../shared/internal-initiator-marker" +import * as dispatchHookModule from "./dispatch-hook" import { executeUserPromptSubmitHooks, type UserPromptSubmitContext, } from "./user-prompt-submit" describe("executeUserPromptSubmitHooks", () => { + afterEach(() => { + mock.restore() + }) + it("returns early when no config provided", async () => { // given const ctx: UserPromptSubmitContext = { @@ -104,4 +110,65 @@ describe("executeUserPromptSubmitHooks", () => { expect(result1.block).toBe(false) expect(result2.block).toBe(false) }) + + it("#given synthetic hook context only #when prompt submit runs #then hook command is not dispatched", async () => { + // given + const dispatchSpy = spyOn(dispatchHookModule, "dispatchHook").mockResolvedValue({ + exitCode: 0, + stdout: "hook output", + stderr: "", + }) + const ctx: UserPromptSubmitContext = { + sessionId: "test-session-synthetic", + prompt: "synthetic hook message", + parts: [{ type: "text", text: "synthetic hook message", synthetic: true }], + cwd: "/tmp", + } + const config = { + UserPromptSubmit: [ + { matcher: "*", hooks: [{ type: "command" as const, command: "echo hook" }] }, + ], + } + + // when + const result = await executeUserPromptSubmitHooks(ctx, config) + + // then + expect(result.block).toBe(false) + expect(result.messages).toEqual([]) + expect(dispatchSpy).toHaveBeenCalledTimes(0) + }) + + it("#given internal prompt marker only #when prompt submit runs #then hook command is not dispatched", async () => { + // given + const dispatchSpy = spyOn(dispatchHookModule, "dispatchHook").mockResolvedValue({ + exitCode: 0, + stdout: "hook output", + stderr: "", + }) + const ctx: UserPromptSubmitContext = { + sessionId: "test-session-internal", + prompt: `internal hook message\n${OMO_INTERNAL_INITIATOR_MARKER}`, + parts: [ + { + type: "text", + text: `internal hook message\n${OMO_INTERNAL_INITIATOR_MARKER}`, + }, + ], + cwd: "/tmp", + } + const config = { + UserPromptSubmit: [ + { matcher: "*", hooks: [{ type: "command" as const, command: "echo hook" }] }, + ], + } + + // when + const result = await executeUserPromptSubmitHooks(ctx, config) + + // then + expect(result.block).toBe(false) + expect(result.messages).toEqual([]) + expect(dispatchSpy).toHaveBeenCalledTimes(0) + }) }) diff --git a/src/hooks/claude-code-hooks/user-prompt-submit.ts b/src/hooks/claude-code-hooks/user-prompt-submit.ts index e714eb6bd..3e951f99b 100644 --- a/src/hooks/claude-code-hooks/user-prompt-submit.ts +++ b/src/hooks/claude-code-hooks/user-prompt-submit.ts @@ -4,6 +4,7 @@ import type { ClaudeHooksConfig, } from "./types" import { findMatchingHooks, log } from "../../shared" +import { isRealUserTextPart } from "../../shared/internal-initiator-marker" import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" @@ -44,10 +45,14 @@ export async function executeUserPromptSubmitHooks( return { block: false, modifiedParts, messages } } + const realUserTextParts = ctx.parts.filter(isRealUserTextPart) + if (realUserTextParts.length === 0) { + return { block: false, modifiedParts, messages } + } + // Check if hook tags are in the current user input only (not in injected context) // by checking only the text parts that were provided in this message - const userInputText = ctx.parts - .filter((p) => p.type === "text" && p.text) + const userInputText = realUserTextParts .map((p) => p.text ?? "") .join("\n")