From a6193606ad4b522407efbe4ec73fb5b02e960401 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:13 +0900 Subject: [PATCH] test(claude-code-hooks): batch 78 (8 files) --- src/hooks/claude-code-hooks/post-tool-use.ts | 56 +++++++++++-------- src/hooks/claude-code-hooks/pre-compact.ts | 24 +++++--- .../claude-code-hooks/pre-tool-use.test.ts | 16 ++++++ src/hooks/claude-code-hooks/pre-tool-use.ts | 30 +++++----- src/hooks/claude-code-hooks/stop.test.ts | 18 ++++++ src/hooks/claude-code-hooks/stop.ts | 14 +++-- .../user-prompt-submit.test.ts | 28 ++++++++++ .../claude-code-hooks/user-prompt-submit.ts | 16 ++++-- 8 files changed, 147 insertions(+), 55 deletions(-) diff --git a/src/hooks/claude-code-hooks/post-tool-use.ts b/src/hooks/claude-code-hooks/post-tool-use.ts index 3ba1f7208..fefc8d616 100644 --- a/src/hooks/claude-code-hooks/post-tool-use.ts +++ b/src/hooks/claude-code-hooks/post-tool-use.ts @@ -7,6 +7,7 @@ import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from ".. import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" +import { normalizeHookText } from "./hook-text" export interface PostToolUseClient { session: { @@ -41,6 +42,10 @@ export interface PostToolUseResult { systemMessage?: string } +function joinedMessages(messages: readonly string[]): string | undefined { + return messages.length > 0 ? messages.join("\n\n") : undefined +} + export async function executePostToolUseHooks( ctx: PostToolUseContext, config: ClaudeHooksConfig | null, @@ -106,13 +111,10 @@ export async function executePostToolUseHooks( const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) - if (result.stdout) { - messages.push(result.stdout) - } - if (result.exitCode === 2) { - if (result.stderr) { - warnings.push(`[${hookName}]\n${result.stderr.trim()}`) + const stderr = normalizeHookText(result.stderr) + if (stderr !== undefined) { + warnings.push(`[${hookName}]\n${stderr}`) } continue } @@ -120,59 +122,69 @@ export async function executePostToolUseHooks( if (result.exitCode === 0 && result.stdout) { try { const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput + const additionalContext = normalizeHookText(output.hookSpecificOutput?.additionalContext) if (output.decision === "block") { return { block: true, - reason: output.reason || result.stderr, - message: messages.join("\n"), + reason: normalizeHookText(output.reason) ?? normalizeHookText(result.stderr), + message: joinedMessages(messages), warnings: warnings.length > 0 ? warnings : undefined, elapsedMs: Date.now() - startTime, hookName: firstHookName, toolName: transformedToolName, - additionalContext: output.hookSpecificOutput?.additionalContext, + additionalContext, continue: output.continue, - stopReason: output.stopReason, + stopReason: normalizeHookText(output.stopReason), suppressOutput: output.suppressOutput, - systemMessage: output.systemMessage, + systemMessage: normalizeHookText(output.systemMessage), } } - if (output.hookSpecificOutput?.additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) { + if (additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) { return { block: false, - message: messages.join("\n"), + message: joinedMessages(messages), warnings: warnings.length > 0 ? warnings : undefined, elapsedMs: Date.now() - startTime, hookName: firstHookName, toolName: transformedToolName, - additionalContext: output.hookSpecificOutput?.additionalContext, + additionalContext, continue: output.continue, - stopReason: output.stopReason, + stopReason: normalizeHookText(output.stopReason), suppressOutput: output.suppressOutput, - systemMessage: output.systemMessage, + systemMessage: normalizeHookText(output.systemMessage), } } } catch { + const stdout = normalizeHookText(result.stdout) + if (stdout !== undefined) { + messages.push(stdout) + } } } else if (result.exitCode !== 0 && result.exitCode !== 2) { try { const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput + const additionalContext = normalizeHookText(output.hookSpecificOutput?.additionalContext) if (output.decision === "block") { return { block: true, - reason: output.reason || result.stderr, - message: messages.join("\n"), + reason: normalizeHookText(output.reason) ?? normalizeHookText(result.stderr), + message: joinedMessages(messages), warnings: warnings.length > 0 ? warnings : undefined, elapsedMs: Date.now() - startTime, hookName: firstHookName, toolName: transformedToolName, - additionalContext: output.hookSpecificOutput?.additionalContext, + additionalContext, continue: output.continue, - stopReason: output.stopReason, + stopReason: normalizeHookText(output.stopReason), suppressOutput: output.suppressOutput, - systemMessage: output.systemMessage, + systemMessage: normalizeHookText(output.systemMessage), } } } catch { + const stdout = normalizeHookText(result.stdout) + if (stdout !== undefined) { + messages.push(stdout) + } } } } @@ -182,7 +194,7 @@ export async function executePostToolUseHooks( return { block: false, - message: messages.length > 0 ? messages.join("\n") : undefined, + message: joinedMessages(messages), warnings: warnings.length > 0 ? warnings : undefined, elapsedMs, hookName: firstHookName, diff --git a/src/hooks/claude-code-hooks/pre-compact.ts b/src/hooks/claude-code-hooks/pre-compact.ts index a3aa01b62..5ee8cc1a4 100644 --- a/src/hooks/claude-code-hooks/pre-compact.ts +++ b/src/hooks/claude-code-hooks/pre-compact.ts @@ -6,6 +6,7 @@ import type { import { findMatchingHooks, log } from "../../shared" import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" +import { normalizeHookText } from "./hook-text" export interface PreCompactContext { sessionId: string @@ -22,6 +23,13 @@ export interface PreCompactResult { systemMessage?: string } +function appendContext(context: string[], value: string): void { + const normalized = normalizeHookText(value) + if (normalized !== undefined) { + context.push(normalized) + } +} + export async function executePreCompactHooks( ctx: PreCompactContext, config: ClaudeHooksConfig | null, @@ -72,9 +80,13 @@ export async function executePreCompactHooks( const output = JSON.parse(result.stdout || "{}") as PreCompactOutput if (output.hookSpecificOutput?.additionalContext) { - collectedContext.push(...output.hookSpecificOutput.additionalContext) + for (const context of output.hookSpecificOutput.additionalContext) { + appendContext(collectedContext, context) + } } else if (output.context) { - collectedContext.push(...output.context) + for (const context of output.context) { + appendContext(collectedContext, context) + } } if (output.continue === false) { @@ -83,15 +95,13 @@ export async function executePreCompactHooks( elapsedMs: Date.now() - startTime, hookName: firstHookName, continue: output.continue, - stopReason: output.stopReason, + stopReason: normalizeHookText(output.stopReason), suppressOutput: output.suppressOutput, - systemMessage: output.systemMessage, + systemMessage: normalizeHookText(output.systemMessage), } } } catch { - if (result.stdout.trim()) { - collectedContext.push(result.stdout.trim()) - } + appendContext(collectedContext, result.stdout) } } } diff --git a/src/hooks/claude-code-hooks/pre-tool-use.test.ts b/src/hooks/claude-code-hooks/pre-tool-use.test.ts index 13770a47f..5f720c826 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.test.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.test.ts @@ -58,6 +58,22 @@ describe("executePreToolUseHooks", () => { expect(result.reason).toBe("blocked") }) + it("#given hook deny reason with CRLF and bare CR #when called #then returns normalized reason", async () => { + dispatchSpy.mockResolvedValue({ + exitCode: 2, + stdout: "", + stderr: "\r\nblocked line\r\n detail\rfinal line\r\n", + }) + + const config = createConfig([ + { matcher: "Write", hooks: [{ type: "command", command: "echo deny" }] }, + ]) + const result = await executePreToolUseHooks(createContext(), config) + + expect(result.decision).toBe("deny") + expect(result.reason).toBe("blocked line\n detail\nfinal line") + }) + it("#given hook returns exit code 1 #when called #then returns ask", async () => { dispatchSpy.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "needs confirmation" }) diff --git a/src/hooks/claude-code-hooks/pre-tool-use.ts b/src/hooks/claude-code-hooks/pre-tool-use.ts index a6d03182a..4228cb3a3 100644 --- a/src/hooks/claude-code-hooks/pre-tool-use.ts +++ b/src/hooks/claude-code-hooks/pre-tool-use.ts @@ -7,6 +7,7 @@ import type { import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared" import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" +import { normalizeHookText } from "./hook-text" export interface PreToolUseContext { sessionId: string @@ -74,7 +75,7 @@ export async function executePreToolUseHooks( let firstHookName: string | undefined const inputLines = buildInputLines(ctx.toolInput) let accumulatedModifiedInput: Record | undefined - let accumulatedCommonFields: { + const accumulatedCommonFields: { continue?: boolean stopReason?: string suppressOutput?: boolean @@ -99,7 +100,7 @@ export async function executePreToolUseHooks( if (result.exitCode === 2) { return { decision: "deny", - reason: result.stderr || result.stdout || "Hook blocked the operation", + reason: normalizeHookText(result.stderr) ?? normalizeHookText(result.stdout) ?? "Hook blocked the operation", modifiedInput: accumulatedModifiedInput, elapsedMs: Date.now() - startTime, hookName: firstHookName, @@ -112,7 +113,7 @@ export async function executePreToolUseHooks( if (result.exitCode === 1) { return { decision: "ask", - reason: result.stderr || result.stdout, + reason: normalizeHookText(result.stderr) ?? normalizeHookText(result.stdout), modifiedInput: accumulatedModifiedInput, elapsedMs: Date.now() - startTime, hookName: firstHookName, @@ -133,7 +134,7 @@ export async function executePreToolUseHooks( if (output.hookSpecificOutput?.permissionDecision) { decision = output.hookSpecificOutput.permissionDecision - reason = output.hookSpecificOutput.permissionDecisionReason + reason = normalizeHookText(output.hookSpecificOutput.permissionDecisionReason) modifiedInput = output.hookSpecificOutput.updatedInput } else if (output.decision) { // Map deprecated values: approve->allow, block->deny, ask->ask @@ -145,15 +146,9 @@ export async function executePreToolUseHooks( } else if (legacyDecision === "ask") { decision = "ask" } - reason = output.reason + reason = normalizeHookText(output.reason) } - // Return if decision is set OR if any common fields are set (fallback to allow) - const hasCommonFields = output.continue !== undefined || - output.stopReason !== undefined || - output.suppressOutput !== undefined || - output.systemMessage !== undefined - if (decision === "deny" || decision === "ask") { return { decision, @@ -164,9 +159,9 @@ export async function executePreToolUseHooks( toolName: transformedToolName, inputLines, continue: output.continue ?? accumulatedCommonFields.continue, - stopReason: output.stopReason ?? accumulatedCommonFields.stopReason, + stopReason: normalizeHookText(output.stopReason) ?? accumulatedCommonFields.stopReason, suppressOutput: output.suppressOutput ?? accumulatedCommonFields.suppressOutput, - systemMessage: output.systemMessage ?? accumulatedCommonFields.systemMessage, + systemMessage: normalizeHookText(output.systemMessage) ?? accumulatedCommonFields.systemMessage, } } @@ -176,10 +171,13 @@ export async function executePreToolUseHooks( Object.assign(stdinData.tool_input, objectToSnakeCase(modifiedInput)) } if (output.continue !== undefined) accumulatedCommonFields.continue = output.continue - if (output.stopReason !== undefined) accumulatedCommonFields.stopReason = output.stopReason + if (output.stopReason !== undefined) accumulatedCommonFields.stopReason = normalizeHookText(output.stopReason) if (output.suppressOutput !== undefined) accumulatedCommonFields.suppressOutput = output.suppressOutput - if (output.systemMessage !== undefined) accumulatedCommonFields.systemMessage = output.systemMessage - } catch { + if (output.systemMessage !== undefined) accumulatedCommonFields.systemMessage = normalizeHookText(output.systemMessage) + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error + } } } } diff --git a/src/hooks/claude-code-hooks/stop.test.ts b/src/hooks/claude-code-hooks/stop.test.ts index 92ac03936..8fa28405e 100644 --- a/src/hooks/claude-code-hooks/stop.test.ts +++ b/src/hooks/claude-code-hooks/stop.test.ts @@ -85,6 +85,24 @@ describe("executeStopHooks", () => { expect(result.reason).toBe("blocked reason") }) + it("#given hook with CRLF block reason #when stop hooks called #then blocks with normalized reason and prompt", async () => { + const ctx = createStopContext() + const config = createConfig([ + { matcher: "*", hooks: [{ type: "command", command: "exit 2" }] }, + ]) + mockDispatchHook.mockResolvedValueOnce({ + exitCode: 2, + stdout: "", + stderr: "\r\nblocked reason\r\n detail\rfinal line\r\n", + }) + + const result = await executeStopHooks(ctx, config) + + expect(result.block).toBe(true) + expect(result.reason).toBe("blocked reason\n detail\nfinal line") + expect(result.injectPrompt).toBe("blocked reason\n detail\nfinal line") + }) + it("#given hook with decision=block #when stop hooks called #then blocks", async () => { const ctx = createStopContext() const config = createConfig([ diff --git a/src/hooks/claude-code-hooks/stop.ts b/src/hooks/claude-code-hooks/stop.ts index 5b4423eb8..897a8a5aa 100644 --- a/src/hooks/claude-code-hooks/stop.ts +++ b/src/hooks/claude-code-hooks/stop.ts @@ -7,6 +7,7 @@ import { findMatchingHooks, log } from "../../shared" import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { getTodoPath } from "./todo" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" +import { normalizeHookText } from "./hook-text" // Module-level state to track stop_hook_active per session const stopHookActiveState = new Map() @@ -80,7 +81,7 @@ export async function executeStopHooks( // Check exit code first - exit code 2 means block if (result.exitCode === 2) { - const reason = result.stderr || result.stdout || "Blocked by stop hook" + const reason = normalizeHookText(result.stderr) ?? normalizeHookText(result.stdout) ?? "Blocked by stop hook" return { block: true, reason, @@ -98,17 +99,20 @@ export async function executeStopHooks( // Only return early if the hook explicitly blocks - non-blocking hooks // should not prevent subsequent hooks from executing (matches Claude Code behavior) if (isBlock) { - const injectPrompt = output.inject_prompt ?? (output.reason || undefined) + const reason = normalizeHookText(output.reason) + const injectPrompt = normalizeHookText(output.inject_prompt) ?? reason return { block: true, - reason: output.reason, + reason, stopHookActive: output.stop_hook_active, permissionMode: output.permission_mode, injectPrompt, } } - } catch { - // Ignore JSON parse errors - hook may return non-JSON output + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error + } } } } 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 a99411720..c434ad25b 100644 --- a/src/hooks/claude-code-hooks/user-prompt-submit.test.ts +++ b/src/hooks/claude-code-hooks/user-prompt-submit.test.ts @@ -139,6 +139,34 @@ describe("executeUserPromptSubmitHooks", () => { expect(dispatchSpy).toHaveBeenCalledTimes(0) }) + it("#given hook stdout with CRLF and bare CR #when prompt submit runs #then injected hook context is normalized", async () => { + // given + spyOn(dispatchHookModule, "dispatchHook").mockResolvedValue({ + exitCode: 0, + stdout: "\r\nfirst line\r\n second line\rthird line\r\n", + stderr: "", + }) + const ctx: UserPromptSubmitContext = { + sessionId: "test-session-newlines", + prompt: "hello", + parts: [{ type: "text", text: "hello" }], + cwd: "/tmp", + } + const config = { + UserPromptSubmit: [ + { matcher: "*", hooks: [{ type: "command" as const, command: "echo hook" }] }, + ], + } + + // when + const result = await executeUserPromptSubmitHooks(ctx, config) + + // then + expect(result.messages).toEqual([ + "\nfirst line\n second line\nthird line\n", + ]) + }) + it("#given internal prompt marker only #when prompt submit runs #then hook command is not dispatched", async () => { // given const dispatchSpy = spyOn(dispatchHookModule, "dispatchHook").mockResolvedValue({ diff --git a/src/hooks/claude-code-hooks/user-prompt-submit.ts b/src/hooks/claude-code-hooks/user-prompt-submit.ts index 3e951f99b..1a4d422d3 100644 --- a/src/hooks/claude-code-hooks/user-prompt-submit.ts +++ b/src/hooks/claude-code-hooks/user-prompt-submit.ts @@ -7,6 +7,7 @@ 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" +import { normalizeHookText } from "./hook-text" const USER_PROMPT_SUBMIT_TAG_OPEN = "" const USER_PROMPT_SUBMIT_TAG_CLOSE = "" @@ -96,7 +97,10 @@ export async function executeUserPromptSubmitHooks( const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) if (result.stdout) { - const output = result.stdout.trim() + const output = normalizeHookText(result.stdout) + if (output === undefined) { + continue + } if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) { messages.push(output) } else { @@ -110,14 +114,16 @@ export async function executeUserPromptSubmitHooks( if (output.decision === "block") { return { block: true, - reason: output.reason || result.stderr, + reason: normalizeHookText(output.reason) ?? normalizeHookText(result.stderr), modifiedParts, messages, } } - } catch { - // Ignore JSON parse errors - } + } catch (error) { + if (!(error instanceof SyntaxError)) { + throw error + } + } } } }