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 { 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,
+17 -7
View File
@@ -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)
}
}
}
@@ -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" })
+14 -16
View File
@@ -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<string, unknown> | 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
}
}
}
}
+18
View File
@@ -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([
+9 -5
View File
@@ -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<string, boolean>()
@@ -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
}
}
}
}
@@ -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([
"<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 () => {
// given
const dispatchSpy = spyOn(dispatchHookModule, "dispatchHook").mockResolvedValue({
@@ -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 = "<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)
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
}
}
}
}
}