diff --git a/src/hooks/agent-usage-reminder/hook.ts b/src/hooks/agent-usage-reminder/hook.ts index bc7f3243fd..27fe70b88b 100644 --- a/src/hooks/agent-usage-reminder/hook.ts +++ b/src/hooks/agent-usage-reminder/hook.ts @@ -77,6 +77,7 @@ export function createAgentUsageReminderHook(_ctx: PluginInput) { return; } + if (output.output == null) return; output.output += REMINDER_MESSAGE; state.reminderCount++; state.updatedAt = Date.now(); diff --git a/src/hooks/category-skill-reminder/hook.ts b/src/hooks/category-skill-reminder/hook.ts index b15715cda0..30969d03af 100644 --- a/src/hooks/category-skill-reminder/hook.ts +++ b/src/hooks/category-skill-reminder/hook.ts @@ -105,7 +105,7 @@ export function createCategorySkillReminderHook( state.toolCallCount++ - if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) { + if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown && output.output != null) { output.output += reminderMessage state.reminderShown = true log("[category-skill-reminder] Reminder injected", { diff --git a/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts index 0dc934ea22..2291e9a1d4 100644 --- a/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts +++ b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.ts @@ -16,7 +16,7 @@ export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginCo input: { tool: string; sessionID: string; callID: string }, output: { title: string; output: string; metadata: unknown } | undefined, ): Promise => { - if (!output) { + if (!output || output.output == null) { return } diff --git a/src/hooks/comment-checker/hook.null-output.test.ts b/src/hooks/comment-checker/hook.null-output.test.ts new file mode 100644 index 0000000000..f6698974f4 --- /dev/null +++ b/src/hooks/comment-checker/hook.null-output.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect, mock } from "bun:test" + +mock.module("./cli-runner", () => ({ + initializeCommentCheckerCli: () => {}, + getCommentCheckerCliPathPromise: () => Promise.resolve("/tmp/fake-comment-checker"), + isCliPathUsable: () => true, + processWithCli: async () => {}, + processApplyPatchEditsWithCli: async () => {}, +})) + +const { createCommentCheckerHooks } = await import("./hook") + +describe("comment-checker null output guard", () => { + it("does not throw when output.output is undefined", async () => { + const hooks = createCommentCheckerHooks() + const input = { tool: "Edit", sessionID: "ses_test", callID: "call_test" } + const output = { title: "Edit", output: undefined as unknown as string, metadata: {} } + + await hooks["tool.execute.after"](input, output) + + expect(output.output).toBeUndefined() + }) + + it("does not throw when output.output is null", async () => { + const hooks = createCommentCheckerHooks() + const input = { tool: "Edit", sessionID: "ses_test", callID: "call_test" } + const output = { title: "Edit", output: null as unknown as string, metadata: {} } + + await hooks["tool.execute.after"](input, output) + + expect(output.output).toBeNull() + }) + + it("still processes valid string output", async () => { + const hooks = createCommentCheckerHooks() + const input = { tool: "Edit", sessionID: "ses_test", callID: "call_test" } + const output = { title: "Edit", output: "File edited successfully", metadata: {} } + + await hooks["tool.execute.after"](input, output) + + expect(typeof output.output).toBe("string") + }) + + it("skips tool failure output without crashing", async () => { + const hooks = createCommentCheckerHooks() + const input = { tool: "Edit", sessionID: "ses_test", callID: "call_test" } + const output = { title: "Edit", output: "Error: something went wrong", metadata: {} } + + await hooks["tool.execute.after"](input, output) + + expect(output.output).toBe("Error: something went wrong") + }) +}) diff --git a/src/hooks/comment-checker/hook.ts b/src/hooks/comment-checker/hook.ts index 8989376434..bc410c92c2 100644 --- a/src/hooks/comment-checker/hook.ts +++ b/src/hooks/comment-checker/hook.ts @@ -92,6 +92,7 @@ export function createCommentCheckerHooks(config?: CommentCheckerConfig) { const toolLower = input.tool.toLowerCase() // Only skip if the output indicates a tool execution failure + if (output.output == null) return const outputLower = output.output.toLowerCase() const isToolFailure = outputLower.includes("error:") || diff --git a/src/hooks/context-window-monitor.ts b/src/hooks/context-window-monitor.ts index 3b92191146..116395ebf9 100644 --- a/src/hooks/context-window-monitor.ts +++ b/src/hooks/context-window-monitor.ts @@ -38,6 +38,7 @@ export function createContextWindowMonitorHook(ctx: PluginInput) { output: { title: string; output: string; metadata: unknown } ) => { const { sessionID } = input + if (output.output == null) return if (remindedSessions.has(sessionID)) return diff --git a/src/hooks/delegate-task-retry/hook.ts b/src/hooks/delegate-task-retry/hook.ts index 915da323d8..7339a56800 100644 --- a/src/hooks/delegate-task-retry/hook.ts +++ b/src/hooks/delegate-task-retry/hook.ts @@ -10,6 +10,7 @@ export function createDelegateTaskRetryHook(_ctx: PluginInput) { output: { title: string; output: string; metadata: unknown } ) => { if (input.tool.toLowerCase() !== "task") return + if (output.output == null) return const errorInfo = detectDelegateTaskError(output.output) if (errorInfo) { diff --git a/src/hooks/directory-agents-injector/injector.ts b/src/hooks/directory-agents-injector/injector.ts index dc6ca0810d..1b48cfd24c 100644 --- a/src/hooks/directory-agents-injector/injector.ts +++ b/src/hooks/directory-agents-injector/injector.ts @@ -46,7 +46,9 @@ export async function processFilePathForAgentsInjection(input: { const truncationNotice = truncated ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${agentsPath}]` : ""; - input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`; + if (input.output.output != null) { + input.output.output += `\n\n[Directory Context: ${agentsPath}]\n${result}${truncationNotice}`; + } cache.add(agentsDir); } catch {} } diff --git a/src/hooks/directory-readme-injector/injector.ts b/src/hooks/directory-readme-injector/injector.ts index 082165844b..1f18eb2396 100644 --- a/src/hooks/directory-readme-injector/injector.ts +++ b/src/hooks/directory-readme-injector/injector.ts @@ -46,7 +46,9 @@ export async function processFilePathForReadmeInjection(input: { const truncationNotice = truncated ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${readmePath}]` : ""; - input.output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`; + if (input.output.output != null) { + input.output.output += `\n\n[Project README: ${readmePath}]\n${result}${truncationNotice}`; + } cache.add(readmeDir); } catch {} } diff --git a/src/hooks/edit-error-recovery/hook.ts b/src/hooks/edit-error-recovery/hook.ts index 84ac9e9dcd..8e14b0ef33 100644 --- a/src/hooks/edit-error-recovery/hook.ts +++ b/src/hooks/edit-error-recovery/hook.ts @@ -44,6 +44,7 @@ export function createEditErrorRecoveryHook(_ctx: PluginInput) { ) => { if (input.tool.toLowerCase() !== "edit") return + if (output.output == null) return const outputLower = output.output.toLowerCase() const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) => outputLower.includes(pattern.toLowerCase()) diff --git a/src/hooks/edit-error-recovery/index.test.ts b/src/hooks/edit-error-recovery/index.test.ts index bafe9311e9..f8becabbcf 100644 --- a/src/hooks/edit-error-recovery/index.test.ts +++ b/src/hooks/edit-error-recovery/index.test.ts @@ -88,6 +88,26 @@ describe("createEditErrorRecoveryHook", () => { }) }) + describe("#given output.output is nullish", () => { + it("#then should not throw when output.output is undefined", async () => { + const input = createInput("Edit") + const output = { title: "Edit", output: undefined as unknown as string, metadata: {} } + + await hook["tool.execute.after"](input, output) + + expect(output.output).toBeUndefined() + }) + + it("#then should not throw when output.output is null", async () => { + const input = createInput("Edit") + const output = { title: "Edit", output: null as unknown as string, metadata: {} } + + await hook["tool.execute.after"](input, output) + + expect(output.output).toBeNull() + }) + }) + describe("#given Edit tool with successful output", () => { describe("#when no error in output", () => { it("#then should not modify output", async () => { diff --git a/src/hooks/interactive-bash-session/hook.ts b/src/hooks/interactive-bash-session/hook.ts index 1d45a6b0a9..47c54a3f5c 100644 --- a/src/hooks/interactive-bash-session/hook.ts +++ b/src/hooks/interactive-bash-session/hook.ts @@ -92,7 +92,7 @@ export function createInteractiveBashSessionHook(ctx: PluginInput) { } const isSessionOperation = isNewSession || isKillSession || isKillServer; - if (isSessionOperation) { + if (isSessionOperation && output.output != null) { const reminder = buildSessionReminderMessage( Array.from(state.tmuxSessions), ); diff --git a/src/hooks/rules-injector/injector.ts b/src/hooks/rules-injector/injector.ts index 9ba0324bab..3f09983d2f 100644 --- a/src/hooks/rules-injector/injector.ts +++ b/src/hooks/rules-injector/injector.ts @@ -116,7 +116,9 @@ export function createRuleInjectionProcessor(deps: { const truncationNotice = truncated ? `\n\n[Note: Content was truncated to save context window space. For full context, please read the file directly: ${rule.relativePath}]` : ""; - output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`; + if (output.output != null) { + output.output += `\n\n[Rule: ${rule.relativePath}]\n[Match: ${rule.matchReason}]\n${result}${truncationNotice}`; + } } saveInjectedRules(sessionID, cache); diff --git a/src/hooks/task-reminder/hook.ts b/src/hooks/task-reminder/hook.ts index 4e795018d0..6f0c5a5649 100644 --- a/src/hooks/task-reminder/hook.ts +++ b/src/hooks/task-reminder/hook.ts @@ -38,7 +38,7 @@ export function createTaskReminderHook(_ctx: PluginInput) { const currentCount = sessionCounters.get(sessionID) ?? 0 const newCount = currentCount + 1 - if (newCount >= TURN_THRESHOLD) { + if (newCount >= TURN_THRESHOLD && output.output != null) { output.output += REMINDER_MESSAGE sessionCounters.set(sessionID, 0) } else { diff --git a/src/hooks/task-reminder/index.test.ts b/src/hooks/task-reminder/index.test.ts index db43ac5892..389c40efc1 100644 --- a/src/hooks/task-reminder/index.test.ts +++ b/src/hooks/task-reminder/index.test.ts @@ -123,6 +123,40 @@ describe("TaskReminderHook", () => { expect(output2.output).not.toContain("task tools haven't been used") }) + test("does not throw when output.output is nullish", async () => { + //#given + const sessionID = "test-session" + const output = { output: undefined as unknown as string } + + //#when - run enough turns to trigger reminder + for (let i = 0; i < 10; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID, callID: `call-${i}` }, + output + ) + } + + //#then - should not throw, output remains undefined + expect(output.output).toBeUndefined() + }) + + test("does not throw when output.output is null", async () => { + //#given + const sessionID = "test-session-null" + const output = { output: null as unknown as string } + + //#when + for (let i = 0; i < 10; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID, callID: `call-${i}` }, + output + ) + } + + //#then + expect(output.output).toBeNull() + }) + test("cleans up counters on session.deleted", async () => { //#given const sessionID = "test-session" diff --git a/src/hooks/task-resume-info/hook.ts b/src/hooks/task-resume-info/hook.ts index f5c0c5185b..6d1ecadbdf 100644 --- a/src/hooks/task-resume-info/hook.ts +++ b/src/hooks/task-resume-info/hook.ts @@ -21,6 +21,7 @@ export function createTaskResumeInfoHook() { output: { title: string; output: string; metadata: unknown } ) => { if (!TARGET_TOOLS.includes(input.tool)) return + if (output.output == null) return if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return if (output.output.includes("\nto continue:")) return diff --git a/src/hooks/tool-output-truncator.ts b/src/hooks/tool-output-truncator.ts index 8f8c300dae..a6d4dc66ec 100644 --- a/src/hooks/tool-output-truncator.ts +++ b/src/hooks/tool-output-truncator.ts @@ -39,7 +39,7 @@ export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOu output: { title: string; output: string; metadata: unknown } ) => { if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return - if (typeof output.output !== 'string') return + if (output.output == null) return try { const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS