test(claude-code-hooks): batch 45 (4 files)

This commit is contained in:
YeonGyu-Kim
2026-05-30 19:12:07 +09:00
parent 1806ff7cee
commit 43253cb9e0
4 changed files with 85 additions and 18 deletions
+3 -1
View File
@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { normalizeSessionId } from "../../features/boulder-state"
import { log } from "../../shared/logger"
import { HOOK_NAME } from "./hook-name"
@@ -7,6 +8,7 @@ export async function isSessionInBoulderLineage(input: {
sessionID: string
boulderSessionIDs: string[]
}): Promise<boolean> {
const normalizedBoulderSessionIDs = input.boulderSessionIDs.map((sessionID) => normalizeSessionId(sessionID))
const visitedSessionIDs = new Set<string>()
let currentSessionID = input.sessionID
@@ -33,7 +35,7 @@ export async function isSessionInBoulderLineage(input: {
return false
}
if (input.boulderSessionIDs.includes(parentSessionID)) {
if (normalizedBoulderSessionIDs.includes(normalizeSessionId(parentSessionID))) {
return true
}
+16 -9
View File
@@ -5,6 +5,7 @@ import {
getPlanProgress,
getWorkForSession,
getTaskSessionState,
normalizeSessionId,
readBoulderState,
readCurrentTopLevelTask,
resolveBoulderPlanPath,
@@ -77,6 +78,7 @@ async function injectContinuation(input: {
try {
const currentBoulder = readBoulderState(input.ctx.directory)
const normalizedSessionID = normalizeSessionId(input.sessionID)
const currentPlanPath = currentBoulder
? resolveBoulderPlanPath(input.ctx.directory, currentBoulder)
: null
@@ -95,7 +97,7 @@ async function injectContinuation(input: {
const canContinueSession = await canContinueTrackedBoulderSession({
client: input.ctx.client,
sessionID: input.sessionID,
sessionOrigin: currentBoulder.session_origins?.[input.sessionID],
sessionOrigin: currentBoulder.session_origins?.[normalizedSessionID],
boulderSessionIDs: currentBoulder.session_ids,
requiredAgent: currentBoulder.agent,
})
@@ -192,7 +194,8 @@ function scheduleRetry(input: {
const currentBoulder = readBoulderState(ctx.directory)
if (!currentBoulder) return
if (!currentBoulder.session_ids?.includes(sessionID)) return
const normalizedSessionID = normalizeSessionId(sessionID)
if (!currentBoulder.session_ids?.includes(normalizedSessionID)) return
const currentProgress = getPlanProgress(resolveBoulderPlanPath(ctx.directory, currentBoulder))
if (currentProgress.isComplete) return
@@ -200,7 +203,7 @@ function scheduleRetry(input: {
const canContinueSession = await canContinueTrackedBoulderSession({
client: ctx.client,
sessionID,
sessionOrigin: currentBoulder.session_origins?.[sessionID],
sessionOrigin: currentBoulder.session_origins?.[normalizedSessionID],
boulderSessionIDs: currentBoulder.session_ids,
requiredAgent: currentBoulder.agent,
})
@@ -230,6 +233,7 @@ export async function handleAtlasSessionIdle(input: {
sessionID: string
}): Promise<void> {
const { ctx, options, getState, sessionID } = input
const normalizedSessionID = normalizeSessionId(sessionID)
const sessionState = getState(sessionID)
log(`[${HOOK_NAME}] session.idle`, { sessionID })
@@ -358,7 +362,7 @@ export async function handleAtlasSessionIdle(input: {
const canContinueSession = await canContinueTrackedBoulderSession({
client: ctx.client,
sessionID,
sessionOrigin: boulderState.session_origins?.[sessionID],
sessionOrigin: boulderState.session_origins?.[normalizedSessionID],
boulderSessionIDs: boulderState.session_ids,
requiredAgent: boulderState.agent,
})
@@ -477,7 +481,14 @@ async function canContinueTrackedBoulderSession(input: {
boulderSessionIDs: string[]
requiredAgent?: string
}): Promise<boolean> {
const ancestorSessionIDs = input.boulderSessionIDs.filter((trackedSessionID) => trackedSessionID !== input.sessionID)
const normalizedSessionID = normalizeSessionId(input.sessionID)
if (input.sessionOrigin === "direct") {
return true
}
const ancestorSessionIDs = input.boulderSessionIDs
.map((sessionID) => normalizeSessionId(sessionID))
.filter((trackedSessionID) => trackedSessionID !== normalizedSessionID)
if (ancestorSessionIDs.length === 0) {
return true
}
@@ -487,10 +498,6 @@ async function canContinueTrackedBoulderSession(input: {
sessionID: input.sessionID,
boulderSessionIDs: ancestorSessionIDs,
})
if (input.sessionOrigin === "direct") {
return true
}
if (!isTrackedDescendant) {
return false
}
@@ -1,5 +1,13 @@
import { beforeEach, describe, expect, it, mock, afterAll } from "bun:test"
type PostToolUseMockResult = {
block?: boolean
reason?: string
message?: string
warnings?: string[]
additionalContext?: string
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -8,6 +16,7 @@ const transcriptCalls: Array<[string, unknown]> = []
const appendTranscriptEntry = mock((sessionId: string, entry: unknown) => {
transcriptCalls.push([sessionId, entry])
})
let postToolUseResult: PostToolUseMockResult = { warnings: [] }
mock.module("../config", () => ({
loadClaudeHooksConfig: async () => ({}),
@@ -18,7 +27,7 @@ mock.module("../config-loader", () => ({
}))
mock.module("../post-tool-use", () => ({
executePostToolUseHooks: async () => ({ warnings: [] }),
executePostToolUseHooks: async () => postToolUseResult,
}))
mock.module("../transcript", () => ({
@@ -34,6 +43,7 @@ describe("createToolExecuteAfterHandler", () => {
beforeEach(() => {
appendTranscriptEntry.mockClear()
transcriptCalls.length = 0
postToolUseResult = { warnings: [] }
})
it("#given diff-heavy metadata #when transcript entry is appended #then it keeps concise output with compact metadata", async () => {
@@ -129,4 +139,42 @@ describe("createToolExecuteAfterHandler", () => {
expect(filediff).not.toHaveProperty("before")
expect(filediff).not.toHaveProperty("after")
})
it("#given multiline PostToolUse context on empty tool output #when output is appended #then it renders clean normalized sections", async () => {
// given
postToolUseResult = {
warnings: ["\r\nWarning line\r\n warning detail\r\n"],
additionalContext: "\r\nContext line\r\n context detail\r",
message: "\r\nMessage line\r\nmessage detail\r\n",
}
const handler = createToolExecuteAfterHandler(
{
client: {
tui: {
showToast: async () => ({}),
},
},
directory: "/repo",
} as never,
{ disabledHooks: [] }
)
const output = {
title: "tool",
output: "",
metadata: {},
}
// when
await handler({ tool: "write", sessionID: "ses_test", callID: "call_test" }, output)
// then
expect(output.output).toBe(
[
"Warning line\n warning detail",
"Context line\n context detail",
"Message line\nmessage detail",
].join("\n\n")
)
expect(output.output).not.toContain("\r")
})
})
@@ -10,6 +10,7 @@ import { getToolInput } from "../tool-input-cache"
import { appendTranscriptEntry, getTranscriptPath } from "../transcript"
import type { PluginConfig } from "../types"
import { isHookDisabled } from "../../../shared"
import { normalizeHookText, normalizeHookTextList } from "../hook-text"
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -70,6 +71,17 @@ function buildTranscriptToolOutput(outputText: string, metadata: unknown): Recor
return compactOutput
}
function appendHookSections(outputText: string, sections: readonly (string | undefined)[]): string {
const normalizedSections = normalizeHookTextList(sections)
if (normalizedSections.length === 0) {
return outputText
}
if (outputText.length === 0) {
return normalizedSections.join("\n\n")
}
return [outputText, ...normalizedSections].join("\n\n")
}
export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginConfig) {
return async (
input: { tool: string; sessionID: string; callID: string },
@@ -134,13 +146,11 @@ export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginCo
.catch(() => {})
}
if (result.warnings && result.warnings.length > 0) {
output.output = `${output.output}\n\n${result.warnings.join("\n")}`
}
if (result.message) {
output.output = `${output.output}\n\n${result.message}`
}
output.output = appendHookSections(output.output, [
...(result.warnings ?? []),
...(normalizeHookText(result.additionalContext) === undefined ? [] : [result.additionalContext]),
...(result.message === undefined ? [] : [result.message]),
])
if (result.hookName) {
ctx.client.tui