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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -0,0 +1,130 @@
|
|||||||
|
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -11,6 +11,65 @@ import { appendTranscriptEntry, getTranscriptPath } from "../transcript"
|
|||||||
import type { PluginConfig } from "../types"
|
import type { PluginConfig } from "../types"
|
||||||
import { isHookDisabled } from "../../../shared"
|
import { isHookDisabled } from "../../../shared"
|
||||||
|
|
||||||
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStringValue(record: Record<string, unknown>, key: string): string | undefined {
|
||||||
|
const value = record[key]
|
||||||
|
return typeof value === "string" && value.length > 0 ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNumberValue(record: Record<string, unknown>, key: string): number | undefined {
|
||||||
|
const value = record[key]
|
||||||
|
return typeof value === "number" ? value : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTranscriptToolOutput(outputText: string, metadata: unknown): Record<string, unknown> {
|
||||||
|
const compactOutput: Record<string, unknown> = { 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) {
|
export function createToolExecuteAfterHandler(ctx: PluginInput, config: PluginConfig) {
|
||||||
return async (
|
return async (
|
||||||
input: { tool: string; sessionID: string; callID: string },
|
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 cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
|
||||||
|
|
||||||
const metadata = output.metadata as Record<string, unknown> | undefined
|
|
||||||
const hasMetadata =
|
|
||||||
metadata && typeof metadata === "object" && Object.keys(metadata).length > 0
|
|
||||||
const toolOutput = hasMetadata ? metadata : { output: output.output }
|
|
||||||
|
|
||||||
appendTranscriptEntry(input.sessionID, {
|
appendTranscriptEntry(input.sessionID, {
|
||||||
type: "tool_result",
|
type: "tool_result",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
tool_name: input.tool,
|
tool_name: input.tool,
|
||||||
tool_input: cachedInput,
|
tool_input: cachedInput,
|
||||||
tool_output: toolOutput,
|
tool_output: buildTranscriptToolOutput(output.output, output.metadata),
|
||||||
})
|
})
|
||||||
|
|
||||||
if (isHookDisabled(config, "PostToolUse")) {
|
if (isHookDisabled(config, "PostToolUse")) {
|
||||||
|
|||||||
Reference in New Issue
Block a user