From 43253cb9e06fdb64bcc04680612d79d1c4d86136 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Sat, 30 May 2026 19:12:07 +0900 Subject: [PATCH] test(claude-code-hooks): batch 45 (4 files) --- src/hooks/atlas/boulder-session-lineage.ts | 4 +- src/hooks/atlas/idle-event.ts | 25 ++++++---- .../tool-execute-after-handler.test.ts | 50 ++++++++++++++++++- .../handlers/tool-execute-after-handler.ts | 24 ++++++--- 4 files changed, 85 insertions(+), 18 deletions(-) diff --git a/src/hooks/atlas/boulder-session-lineage.ts b/src/hooks/atlas/boulder-session-lineage.ts index 0868f3bb8..c123c634e 100644 --- a/src/hooks/atlas/boulder-session-lineage.ts +++ b/src/hooks/atlas/boulder-session-lineage.ts @@ -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 { + const normalizedBoulderSessionIDs = input.boulderSessionIDs.map((sessionID) => normalizeSessionId(sessionID)) const visitedSessionIDs = new Set() 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 } diff --git a/src/hooks/atlas/idle-event.ts b/src/hooks/atlas/idle-event.ts index 93b5685de..839e9d3d5 100644 --- a/src/hooks/atlas/idle-event.ts +++ b/src/hooks/atlas/idle-event.ts @@ -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 { 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 { - 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 } diff --git a/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.test.ts b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.test.ts index e6877cfd4..100f5a3bc 100644 --- a/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.test.ts +++ b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.test.ts @@ -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 { 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") + }) }) 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 fa37ea8b7..a155ed640 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 @@ -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 { 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