Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/hooks/agent-usage-reminder/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/category-skill-reminder/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
if (!output) {
if (!output || output.output == null) {
return
}

Expand Down
53 changes: 53 additions & 0 deletions src/hooks/comment-checker/hook.null-output.test.ts
Original file line number Diff line number Diff line change
@@ -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")
})
})
1 change: 1 addition & 0 deletions src/hooks/comment-checker/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:") ||
Expand Down
1 change: 1 addition & 0 deletions src/hooks/context-window-monitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions src/hooks/delegate-task-retry/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/directory-agents-injector/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/directory-readme-injector/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
}
Expand Down
1 change: 1 addition & 0 deletions src/hooks/edit-error-recovery/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
20 changes: 20 additions & 0 deletions src/hooks/edit-error-recovery/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/interactive-bash-session/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
Expand Down
4 changes: 3 additions & 1 deletion src/hooks/rules-injector/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/hooks/task-reminder/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
34 changes: 34 additions & 0 deletions src/hooks/task-reminder/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions src/hooks/task-resume-info/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion src/hooks/tool-output-truncator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down