From c9402b96fcafba6de537019830270e8c9ec8e80b Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Mon, 9 Mar 2026 11:28:04 +0900 Subject: [PATCH] fix(claude-code-hooks): compact transcript tool results for diff-heavy metadata Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: Sisyphus --- .../tool-execute-after-handler.test.ts | 130 ++++++++++++++++++ .../handlers/tool-execute-after-handler.ts | 66 ++++++++- 2 files changed, 190 insertions(+), 6 deletions(-) create mode 100644 src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.test.ts 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 new file mode 100644 index 000000000..5efd27e17 --- /dev/null +++ b/src/hooks/claude-code-hooks/handlers/tool-execute-after-handler.test.ts @@ -0,0 +1,130 @@ +import { beforeEach, describe, expect, it, mock } from "bun:test" + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +const transcriptCalls: Array<[string, unknown]> = [] +const appendTranscriptEntry = mock((sessionId: string, entry: unknown) => { + transcriptCalls.push([sessionId, entry]) +}) + +mock.module("../config", () => ({ + loadClaudeHooksConfig: async () => ({}), +})) + +mock.module("../config-loader", () => ({ + loadPluginExtendedConfig: async () => ({}), +})) + +mock.module("../post-tool-use", () => ({ + executePostToolUseHooks: async () => ({ warnings: [] }), +})) + +mock.module("../transcript", () => ({ + appendTranscriptEntry, + getTranscriptPath: () => "/tmp/transcript.jsonl", +})) + +const { createToolExecuteAfterHandler } = await import("./tool-execute-after-handler") + +describe("createToolExecuteAfterHandler", () => { + beforeEach(() => { + appendTranscriptEntry.mockClear() + transcriptCalls.length = 0 + }) + + it("#given diff-heavy metadata #when transcript entry is appended #then it keeps concise output with compact metadata", async () => { + const handler = createToolExecuteAfterHandler( + { + client: { + tui: { + showToast: async () => ({}), + }, + }, + directory: "/repo", + } as never, + { disabledHooks: ["PostToolUse"] } + ) + + await handler( + { tool: "hashline_edit", sessionID: "ses_test", callID: "call_test" }, + { + title: "src/example.ts", + output: "Updated src/example.ts", + metadata: { + filePath: "src/example.ts", + path: "src/duplicate-path.ts", + file: "src/duplicate-file.ts", + sessionId: "ses_oracle", + agent: "oracle", + prompt: "very large hidden prompt", + diff: "x".repeat(5000), + noopEdits: 1, + deduplicatedEdits: 2, + firstChangedLine: 42, + filediff: { + before: "before body", + after: "after body", + additions: 3, + deletions: 4, + }, + nested: { + keep: false, + }, + }, + } + ) + + expect(appendTranscriptEntry).toHaveBeenCalledTimes(1) + + const firstCall = transcriptCalls[0] + const sessionId = firstCall?.[0] + const entry = firstCall?.[1] + expect(sessionId).toBe("ses_test") + expect(entry).toBeDefined() + if (!entry || typeof entry !== "object" || !("tool_output" in entry)) { + throw new Error("expected transcript entry with tool_output") + } + + const toolOutput = entry.tool_output + expect(toolOutput).toBeDefined() + if (!isRecord(toolOutput)) { + throw new Error("expected compact tool_output object") + } + + expect(entry).toMatchObject({ + type: "tool_result", + tool_name: "hashline_edit", + tool_input: {}, + tool_output: { + output: "Updated src/example.ts", + filePath: "src/example.ts", + sessionId: "ses_oracle", + agent: "oracle", + noopEdits: 1, + deduplicatedEdits: 2, + firstChangedLine: 42, + filediff: { + additions: 3, + deletions: 4, + }, + }, + }) + + expect(entry).toHaveProperty("timestamp") + expect(toolOutput).not.toHaveProperty("diff") + expect(toolOutput).not.toHaveProperty("path") + expect(toolOutput).not.toHaveProperty("file") + expect(toolOutput).not.toHaveProperty("prompt") + expect(toolOutput).not.toHaveProperty("nested") + + const filediff = toolOutput.filediff + expect(filediff).toBeDefined() + if (!isRecord(filediff)) { + throw new Error("expected compact filediff object") + } + expect(filediff).not.toHaveProperty("before") + expect(filediff).not.toHaveProperty("after") + }) +}) 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 4664cc505..c9adf45e8 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 @@ -11,6 +11,65 @@ import { appendTranscriptEntry, getTranscriptPath } from "../transcript" import type { PluginConfig } from "../types" import { isHookDisabled } from "../../../shared" +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value) +} + +function getStringValue(record: Record, key: string): string | undefined { + const value = record[key] + return typeof value === "string" && value.length > 0 ? value : undefined +} + +function getNumberValue(record: Record, key: string): number | undefined { + const value = record[key] + return typeof value === "number" ? value : undefined +} + +function buildTranscriptToolOutput(outputText: string, metadata: unknown): Record { + const compactOutput: Record = { output: outputText } + if (!isRecord(metadata)) { + return compactOutput + } + + const filePath = getStringValue(metadata, "filePath") + ?? getStringValue(metadata, "path") + ?? getStringValue(metadata, "file") + if (filePath) { + compactOutput.filePath = filePath + } + + const sessionId = getStringValue(metadata, "sessionId") + if (sessionId) { + compactOutput.sessionId = sessionId + } + + const agent = getStringValue(metadata, "agent") + if (agent) { + compactOutput.agent = agent + } + + for (const key of ["noopEdits", "deduplicatedEdits", "firstChangedLine"] as const) { + const value = getNumberValue(metadata, key) + if (value !== undefined) { + compactOutput[key] = value + } + } + + const filediff = metadata.filediff + if (isRecord(filediff)) { + const additions = getNumberValue(filediff, "additions") + const deletions = getNumberValue(filediff, "deletions") + if (additions !== undefined || deletions !== undefined) { + compactOutput.filediff = { + ...(additions !== undefined ? { additions } : {}), + ...(deletions !== undefined ? { deletions } : {}), + } + } + } + + return compactOutput +} + export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginConfig) { return async ( input: { tool: string; sessionID: string; callID: string }, @@ -25,17 +84,12 @@ export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginCo const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {} - const metadata = output.metadata as Record | undefined - const hasMetadata = - metadata && typeof metadata === "object" && Object.keys(metadata).length > 0 - const toolOutput = hasMetadata ? metadata : { output: output.output } - appendTranscriptEntry(input.sessionID, { type: "tool_result", timestamp: new Date().toISOString(), tool_name: input.tool, tool_input: cachedInput, - tool_output: toolOutput, + tool_output: buildTranscriptToolOutput(output.output, output.metadata), }) if (isHookDisabled(config, "PostToolUse")) {