test(claude-code-hooks): batch 78 (8 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:13 +09:00
parent d6b8c84845
commit a6193606ad
8 changed files with 147 additions and 55 deletions
+34 -22
View File
@@ -7,6 +7,7 @@ import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "..
import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript" import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
import { normalizeHookText } from "./hook-text"
export interface PostToolUseClient { export interface PostToolUseClient {
session: { session: {
@@ -41,6 +42,10 @@ export interface PostToolUseResult {
systemMessage?: string systemMessage?: string
} }
function joinedMessages(messages: readonly string[]): string | undefined {
return messages.length > 0 ? messages.join("\n\n") : undefined
}
export async function executePostToolUseHooks( export async function executePostToolUseHooks(
ctx: PostToolUseContext, ctx: PostToolUseContext,
config: ClaudeHooksConfig | null, config: ClaudeHooksConfig | null,
@@ -106,13 +111,10 @@ export async function executePostToolUseHooks(
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.stdout) {
messages.push(result.stdout)
}
if (result.exitCode === 2) { if (result.exitCode === 2) {
if (result.stderr) { const stderr = normalizeHookText(result.stderr)
warnings.push(`[${hookName}]\n${result.stderr.trim()}`) if (stderr !== undefined) {
warnings.push(`[${hookName}]\n${stderr}`)
} }
continue continue
} }
@@ -120,59 +122,69 @@ export async function executePostToolUseHooks(
if (result.exitCode === 0 && result.stdout) { if (result.exitCode === 0 && result.stdout) {
try { try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
const additionalContext = normalizeHookText(output.hookSpecificOutput?.additionalContext)
if (output.decision === "block") { if (output.decision === "block") {
return { return {
block: true, block: true,
reason: output.reason || result.stderr, reason: normalizeHookText(output.reason) ?? normalizeHookText(result.stderr),
message: messages.join("\n"), message: joinedMessages(messages),
warnings: warnings.length > 0 ? warnings : undefined, warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime, elapsedMs: Date.now() - startTime,
hookName: firstHookName, hookName: firstHookName,
toolName: transformedToolName, toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext, additionalContext,
continue: output.continue, continue: output.continue,
stopReason: output.stopReason, stopReason: normalizeHookText(output.stopReason),
suppressOutput: output.suppressOutput, 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 { return {
block: false, block: false,
message: messages.join("\n"), message: joinedMessages(messages),
warnings: warnings.length > 0 ? warnings : undefined, warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime, elapsedMs: Date.now() - startTime,
hookName: firstHookName, hookName: firstHookName,
toolName: transformedToolName, toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext, additionalContext,
continue: output.continue, continue: output.continue,
stopReason: output.stopReason, stopReason: normalizeHookText(output.stopReason),
suppressOutput: output.suppressOutput, suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage, systemMessage: normalizeHookText(output.systemMessage),
} }
} }
} catch { } catch {
const stdout = normalizeHookText(result.stdout)
if (stdout !== undefined) {
messages.push(stdout)
}
} }
} else if (result.exitCode !== 0 && result.exitCode !== 2) { } else if (result.exitCode !== 0 && result.exitCode !== 2) {
try { try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
const additionalContext = normalizeHookText(output.hookSpecificOutput?.additionalContext)
if (output.decision === "block") { if (output.decision === "block") {
return { return {
block: true, block: true,
reason: output.reason || result.stderr, reason: normalizeHookText(output.reason) ?? normalizeHookText(result.stderr),
message: messages.join("\n"), message: joinedMessages(messages),
warnings: warnings.length > 0 ? warnings : undefined, warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime, elapsedMs: Date.now() - startTime,
hookName: firstHookName, hookName: firstHookName,
toolName: transformedToolName, toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext, additionalContext,
continue: output.continue, continue: output.continue,
stopReason: output.stopReason, stopReason: normalizeHookText(output.stopReason),
suppressOutput: output.suppressOutput, suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage, systemMessage: normalizeHookText(output.systemMessage),
} }
} }
} catch { } catch {
const stdout = normalizeHookText(result.stdout)
if (stdout !== undefined) {
messages.push(stdout)
}
} }
} }
} }
@@ -182,7 +194,7 @@ export async function executePostToolUseHooks(
return { return {
block: false, block: false,
message: messages.length > 0 ? messages.join("\n") : undefined, message: joinedMessages(messages),
warnings: warnings.length > 0 ? warnings : undefined, warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs, elapsedMs,
hookName: firstHookName, hookName: firstHookName,
+17 -7
View File
@@ -6,6 +6,7 @@ import type {
import { findMatchingHooks, log } from "../../shared" import { findMatchingHooks, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
import { normalizeHookText } from "./hook-text"
export interface PreCompactContext { export interface PreCompactContext {
sessionId: string sessionId: string
@@ -22,6 +23,13 @@ export interface PreCompactResult {
systemMessage?: string systemMessage?: string
} }
function appendContext(context: string[], value: string): void {
const normalized = normalizeHookText(value)
if (normalized !== undefined) {
context.push(normalized)
}
}
export async function executePreCompactHooks( export async function executePreCompactHooks(
ctx: PreCompactContext, ctx: PreCompactContext,
config: ClaudeHooksConfig | null, config: ClaudeHooksConfig | null,
@@ -72,9 +80,13 @@ export async function executePreCompactHooks(
const output = JSON.parse(result.stdout || "{}") as PreCompactOutput const output = JSON.parse(result.stdout || "{}") as PreCompactOutput
if (output.hookSpecificOutput?.additionalContext) { if (output.hookSpecificOutput?.additionalContext) {
collectedContext.push(...output.hookSpecificOutput.additionalContext) for (const context of output.hookSpecificOutput.additionalContext) {
appendContext(collectedContext, context)
}
} else if (output.context) { } else if (output.context) {
collectedContext.push(...output.context) for (const context of output.context) {
appendContext(collectedContext, context)
}
} }
if (output.continue === false) { if (output.continue === false) {
@@ -83,15 +95,13 @@ export async function executePreCompactHooks(
elapsedMs: Date.now() - startTime, elapsedMs: Date.now() - startTime,
hookName: firstHookName, hookName: firstHookName,
continue: output.continue, continue: output.continue,
stopReason: output.stopReason, stopReason: normalizeHookText(output.stopReason),
suppressOutput: output.suppressOutput, suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage, systemMessage: normalizeHookText(output.systemMessage),
} }
} }
} catch { } catch {
if (result.stdout.trim()) { appendContext(collectedContext, result.stdout)
collectedContext.push(result.stdout.trim())
}
} }
} }
} }
@@ -58,6 +58,22 @@ describe("executePreToolUseHooks", () => {
expect(result.reason).toBe("blocked") 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 () => { it("#given hook returns exit code 1 #when called #then returns ask", async () => {
dispatchSpy.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "needs confirmation" }) dispatchSpy.mockResolvedValue({ exitCode: 1, stdout: "", stderr: "needs confirmation" })
+14 -16
View File
@@ -7,6 +7,7 @@ import type {
import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared" import { findMatchingHooks, objectToSnakeCase, transformToolName, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
import { normalizeHookText } from "./hook-text"
export interface PreToolUseContext { export interface PreToolUseContext {
sessionId: string sessionId: string
@@ -74,7 +75,7 @@ export async function executePreToolUseHooks(
let firstHookName: string | undefined let firstHookName: string | undefined
const inputLines = buildInputLines(ctx.toolInput) const inputLines = buildInputLines(ctx.toolInput)
let accumulatedModifiedInput: Record<string, unknown> | undefined let accumulatedModifiedInput: Record<string, unknown> | undefined
let accumulatedCommonFields: { const accumulatedCommonFields: {
continue?: boolean continue?: boolean
stopReason?: string stopReason?: string
suppressOutput?: boolean suppressOutput?: boolean
@@ -99,7 +100,7 @@ export async function executePreToolUseHooks(
if (result.exitCode === 2) { if (result.exitCode === 2) {
return { return {
decision: "deny", decision: "deny",
reason: result.stderr || result.stdout || "Hook blocked the operation", reason: normalizeHookText(result.stderr) ?? normalizeHookText(result.stdout) ?? "Hook blocked the operation",
modifiedInput: accumulatedModifiedInput, modifiedInput: accumulatedModifiedInput,
elapsedMs: Date.now() - startTime, elapsedMs: Date.now() - startTime,
hookName: firstHookName, hookName: firstHookName,
@@ -112,7 +113,7 @@ export async function executePreToolUseHooks(
if (result.exitCode === 1) { if (result.exitCode === 1) {
return { return {
decision: "ask", decision: "ask",
reason: result.stderr || result.stdout, reason: normalizeHookText(result.stderr) ?? normalizeHookText(result.stdout),
modifiedInput: accumulatedModifiedInput, modifiedInput: accumulatedModifiedInput,
elapsedMs: Date.now() - startTime, elapsedMs: Date.now() - startTime,
hookName: firstHookName, hookName: firstHookName,
@@ -133,7 +134,7 @@ export async function executePreToolUseHooks(
if (output.hookSpecificOutput?.permissionDecision) { if (output.hookSpecificOutput?.permissionDecision) {
decision = output.hookSpecificOutput.permissionDecision decision = output.hookSpecificOutput.permissionDecision
reason = output.hookSpecificOutput.permissionDecisionReason reason = normalizeHookText(output.hookSpecificOutput.permissionDecisionReason)
modifiedInput = output.hookSpecificOutput.updatedInput modifiedInput = output.hookSpecificOutput.updatedInput
} else if (output.decision) { } else if (output.decision) {
// Map deprecated values: approve->allow, block->deny, ask->ask // Map deprecated values: approve->allow, block->deny, ask->ask
@@ -145,15 +146,9 @@ export async function executePreToolUseHooks(
} else if (legacyDecision === "ask") { } else if (legacyDecision === "ask") {
decision = "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") { if (decision === "deny" || decision === "ask") {
return { return {
decision, decision,
@@ -164,9 +159,9 @@ export async function executePreToolUseHooks(
toolName: transformedToolName, toolName: transformedToolName,
inputLines, inputLines,
continue: output.continue ?? accumulatedCommonFields.continue, continue: output.continue ?? accumulatedCommonFields.continue,
stopReason: output.stopReason ?? accumulatedCommonFields.stopReason, stopReason: normalizeHookText(output.stopReason) ?? accumulatedCommonFields.stopReason,
suppressOutput: output.suppressOutput ?? accumulatedCommonFields.suppressOutput, 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)) Object.assign(stdinData.tool_input, objectToSnakeCase(modifiedInput))
} }
if (output.continue !== undefined) accumulatedCommonFields.continue = output.continue 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.suppressOutput !== undefined) accumulatedCommonFields.suppressOutput = output.suppressOutput
if (output.systemMessage !== undefined) accumulatedCommonFields.systemMessage = output.systemMessage if (output.systemMessage !== undefined) accumulatedCommonFields.systemMessage = normalizeHookText(output.systemMessage)
} catch { } catch (error) {
if (!(error instanceof SyntaxError)) {
throw error
}
} }
} }
} }
+18
View File
@@ -85,6 +85,24 @@ describe("executeStopHooks", () => {
expect(result.reason).toBe("blocked reason") 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 () => { it("#given hook with decision=block #when stop hooks called #then blocks", async () => {
const ctx = createStopContext() const ctx = createStopContext()
const config = createConfig([ const config = createConfig([
+9 -5
View File
@@ -7,6 +7,7 @@ import { findMatchingHooks, log } from "../../shared"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { getTodoPath } from "./todo" import { getTodoPath } from "./todo"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
import { normalizeHookText } from "./hook-text"
// Module-level state to track stop_hook_active per session // Module-level state to track stop_hook_active per session
const stopHookActiveState = new Map<string, boolean>() const stopHookActiveState = new Map<string, boolean>()
@@ -80,7 +81,7 @@ export async function executeStopHooks(
// Check exit code first - exit code 2 means block // Check exit code first - exit code 2 means block
if (result.exitCode === 2) { 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 { return {
block: true, block: true,
reason, reason,
@@ -98,17 +99,20 @@ export async function executeStopHooks(
// Only return early if the hook explicitly blocks - non-blocking hooks // Only return early if the hook explicitly blocks - non-blocking hooks
// should not prevent subsequent hooks from executing (matches Claude Code behavior) // should not prevent subsequent hooks from executing (matches Claude Code behavior)
if (isBlock) { if (isBlock) {
const injectPrompt = output.inject_prompt ?? (output.reason || undefined) const reason = normalizeHookText(output.reason)
const injectPrompt = normalizeHookText(output.inject_prompt) ?? reason
return { return {
block: true, block: true,
reason: output.reason, reason,
stopHookActive: output.stop_hook_active, stopHookActive: output.stop_hook_active,
permissionMode: output.permission_mode, permissionMode: output.permission_mode,
injectPrompt, injectPrompt,
} }
} }
} catch { } catch (error) {
// Ignore JSON parse errors - hook may return non-JSON output if (!(error instanceof SyntaxError)) {
throw error
}
} }
} }
} }
@@ -139,6 +139,34 @@ describe("executeUserPromptSubmitHooks", () => {
expect(dispatchSpy).toHaveBeenCalledTimes(0) 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([
"<user-prompt-submit-hook>\nfirst line\n second line\nthird line\n</user-prompt-submit-hook>",
])
})
it("#given internal prompt marker only #when prompt submit runs #then hook command is not dispatched", async () => { it("#given internal prompt marker only #when prompt submit runs #then hook command is not dispatched", async () => {
// given // given
const dispatchSpy = spyOn(dispatchHookModule, "dispatchHook").mockResolvedValue({ const dispatchSpy = spyOn(dispatchHookModule, "dispatchHook").mockResolvedValue({
@@ -7,6 +7,7 @@ import { findMatchingHooks, log } from "../../shared"
import { isRealUserTextPart } from "../../shared/internal-initiator-marker" import { isRealUserTextPart } from "../../shared/internal-initiator-marker"
import { dispatchHook, getHookIdentifier } from "./dispatch-hook" import { dispatchHook, getHookIdentifier } from "./dispatch-hook"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader" import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
import { normalizeHookText } from "./hook-text"
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>" const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>" const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>"
@@ -96,7 +97,10 @@ export async function executeUserPromptSubmitHooks(
const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd) const result = await dispatchHook(hook, JSON.stringify(stdinData), ctx.cwd)
if (result.stdout) { 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)) { if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) {
messages.push(output) messages.push(output)
} else { } else {
@@ -110,14 +114,16 @@ export async function executeUserPromptSubmitHooks(
if (output.decision === "block") { if (output.decision === "block") {
return { return {
block: true, block: true,
reason: output.reason || result.stderr, reason: normalizeHookText(output.reason) ?? normalizeHookText(result.stderr),
modifiedParts, modifiedParts,
messages, messages,
} }
} }
} catch { } catch (error) {
// Ignore JSON parse errors if (!(error instanceof SyntaxError)) {
} throw error
}
}
} }
} }
} }