From 77a268f91e5c49eb01d5df47b40e867f894f7844 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 14:17:46 +0900 Subject: [PATCH 1/2] fix: skip claude hook injection for internal prompts --- .../handlers/chat-message-handler.ts | 1 + .../user-prompt-submit.test.ts | 69 ++++++++++++++++++- .../claude-code-hooks/user-prompt-submit.ts | 9 ++- 3 files changed, 76 insertions(+), 3 deletions(-) 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") From f204390b43f50146271cc5c6b50881b2db8b2153 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Wed, 27 May 2026 14:27:26 +0900 Subject: [PATCH 2/2] fix: reserve completed heading for final background wake --- ...kground-task-notification-template.test.ts | 49 ++++++++++++++++++- .../background-task-notification-template.ts | 5 +- .../task-completion-cleanup.test.ts | 2 +- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/src/features/background-agent/background-task-notification-template.test.ts b/src/features/background-agent/background-task-notification-template.test.ts index 42c5371d0..70528b66a 100644 --- a/src/features/background-agent/background-task-notification-template.test.ts +++ b/src/features/background-agent/background-task-notification-template.test.ts @@ -4,6 +4,27 @@ import { unsafeTestValue } from "../../../test-support/unsafe-test-value" describe("buildBackgroundTaskNotificationText", () => { describe("#given one task still running after a completed task notification", () => { + test("#when building the partial notification #then it does not use the final completed heading", () => { + // given + const notification = buildBackgroundTaskNotificationText({ + task: { + id: "task-1", + description: "Index repo", + status: "completed", + }, + duration: "42s", + statusText: "COMPLETED", + allComplete: false, + remainingCount: 1, + completedTasks: [], + }) + + // then + expect(notification).not.toContain("[BACKGROUND TASK COMPLETED]") + expect(notification).toContain("[BACKGROUND TASK RESULT READY]") + expect(notification).toContain("You WILL be notified when ALL complete.") + }) + test("#when building the partial notification #then it preserves the existing completed-task format", () => { // given const notification = buildBackgroundTaskNotificationText({ @@ -21,7 +42,7 @@ describe("buildBackgroundTaskNotificationText", () => { // when const expectedNotification = ` -[BACKGROUND TASK COMPLETED] +[BACKGROUND TASK RESULT READY] **ID:** \`task-1\` **Description:** Index repo **Duration:** 42s @@ -156,6 +177,32 @@ Use \`background_output(task_id="")\` to retrieve each result. }) describe("#given a completed task with retry attempt history", () => { + test("#when building the final notification #then it includes the final completed heading", () => { + // given + const notification = buildBackgroundTaskNotificationText({ + task: { + id: "task-3", + description: "Fallback task", + status: "completed", + }, + duration: "10s", + statusText: "COMPLETED", + allComplete: true, + remainingCount: 0, + completedTasks: [ + { + id: "task-3", + description: "Fallback task", + status: "completed", + }, + ], + }) + + // then + expect(notification).toContain("[BACKGROUND TASK COMPLETED]") + expect(notification).toContain("[ALL BACKGROUND TASKS COMPLETE]") + }) + test("#when building the final notification #then it renders the spec-aligned balanced attempt timeline", () => { // given const notification = buildBackgroundTaskNotificationText({ diff --git a/src/features/background-agent/background-task-notification-template.ts b/src/features/background-agent/background-task-notification-template.ts index 7c71cd4e7..44a1f0730 100644 --- a/src/features/background-agent/background-task-notification-template.ts +++ b/src/features/background-agent/background-task-notification-template.ts @@ -85,7 +85,7 @@ export function buildBackgroundTaskNotificationText(input: { const hasFailures = failedTasks.length > 0 const header = hasFailures ? `[ALL BACKGROUND TASKS FINISHED - ${failedTasks.length} FAILED]` - : "[ALL BACKGROUND TASKS COMPLETE]" + : "[BACKGROUND TASK COMPLETED]\n[ALL BACKGROUND TASKS COMPLETE]" let body = "" if (succeededText) { @@ -108,9 +108,10 @@ Use \`background_output(task_id="")\` to retrieve each result.${hasFailures } const isFailure = statusText !== "COMPLETED" + const header = isFailure ? `[BACKGROUND TASK ${statusText}]` : "[BACKGROUND TASK RESULT READY]" return ` -[BACKGROUND TASK ${statusText}] +${header} **ID:** \`${task.id}\` **Description:** ${safeDescription(task)} **Duration:** ${duration}${errorInfo} diff --git a/src/features/background-agent/task-completion-cleanup.test.ts b/src/features/background-agent/task-completion-cleanup.test.ts index 1dcb9e809..01862e196 100644 --- a/src/features/background-agent/task-completion-cleanup.test.ts +++ b/src/features/background-agent/task-completion-cleanup.test.ts @@ -390,7 +390,7 @@ describe("BackgroundManager.notifyParentSession cleanup scheduling", () => { expect(promptAsyncCalls).toHaveLength(1) expect(promptAsyncCalls[0]?.body.noReply).toBe(true) const notificationPayload = JSON.stringify(promptAsyncCalls[0]?.body.parts) - expect(notificationPayload).toContain("BACKGROUND TASK COMPLETED") + expect(notificationPayload).toContain("BACKGROUND TASK RESULT READY") expect(notificationPayload).not.toContain("ALL BACKGROUND TASKS COMPLETE") })