feat(hooks): add read-image-resizer hook
Intercepts Read tool output with image attachments and resizes to comply with Anthropic API limits (≤1568px long edge, ≤5MB). Only activates for Anthropic provider sessions and appends resize metadata (original/new resolution, token count) to tool output. 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,286 @@
|
||||
/// <reference types="bun-types" />
|
||||
|
||||
import { beforeEach, describe, expect, it, mock } from "bun:test"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
import type { ImageDimensions, ResizeResult } from "./types"
|
||||
|
||||
const mockParseImageDimensions = mock((): ImageDimensions | null => null)
|
||||
const mockCalculateTargetDimensions = mock((): ImageDimensions | null => null)
|
||||
const mockResizeImage = mock(async (): Promise<ResizeResult | null> => null)
|
||||
const mockGetSessionModel = mock((_sessionID: string) => ({
|
||||
providerID: "anthropic",
|
||||
modelID: "claude-sonnet-4-6",
|
||||
} as { providerID: string; modelID: string } | undefined))
|
||||
|
||||
mock.module("./image-dimensions", () => ({
|
||||
parseImageDimensions: mockParseImageDimensions,
|
||||
}))
|
||||
|
||||
mock.module("./image-resizer", () => ({
|
||||
calculateTargetDimensions: mockCalculateTargetDimensions,
|
||||
resizeImage: mockResizeImage,
|
||||
}))
|
||||
|
||||
mock.module("../../shared/session-model-state", () => ({
|
||||
getSessionModel: mockGetSessionModel,
|
||||
}))
|
||||
|
||||
import { createReadImageResizerHook } from "./hook"
|
||||
|
||||
type ToolOutput = {
|
||||
title: string
|
||||
output: string
|
||||
metadata: unknown
|
||||
attachments?: Array<{ mime: string; url: string; filename?: string }>
|
||||
}
|
||||
|
||||
function createMockContext(): PluginInput {
|
||||
return {
|
||||
client: {} as PluginInput["client"],
|
||||
directory: "/test",
|
||||
} as PluginInput
|
||||
}
|
||||
|
||||
function createInput(tool: string): { tool: string; sessionID: string; callID: string } {
|
||||
return {
|
||||
tool,
|
||||
sessionID: "session-1",
|
||||
callID: "call-1",
|
||||
}
|
||||
}
|
||||
|
||||
describe("createReadImageResizerHook", () => {
|
||||
beforeEach(() => {
|
||||
mockParseImageDimensions.mockReset()
|
||||
mockCalculateTargetDimensions.mockReset()
|
||||
mockResizeImage.mockReset()
|
||||
mockGetSessionModel.mockReset()
|
||||
mockGetSessionModel.mockReturnValue({ providerID: "anthropic", modelID: "claude-sonnet-4-6" })
|
||||
})
|
||||
|
||||
it("skips non-Read tools", async () => {
|
||||
//#given
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Bash"), output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original output")
|
||||
expect(mockParseImageDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("skips when provider is not anthropic", async () => {
|
||||
//#given
|
||||
mockGetSessionModel.mockReturnValue({ providerID: "openai", modelID: "gpt-5.3-codex" })
|
||||
mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 })
|
||||
mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 })
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original output")
|
||||
expect(mockParseImageDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("skips when session model is unknown", async () => {
|
||||
//#given
|
||||
mockGetSessionModel.mockReturnValue(undefined)
|
||||
mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 })
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original output")
|
||||
expect(mockParseImageDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("skips Read output with no attachments", async () => {
|
||||
//#given
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original output")
|
||||
expect(mockParseImageDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("skips non-image attachments", async () => {
|
||||
//#given
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "application/pdf", url: "data:application/pdf;base64,AAAA", filename: "file.pdf" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original output")
|
||||
expect(mockParseImageDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("skips unsupported image mime types", async () => {
|
||||
//#given
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "image/heic", url: "data:image/heic;base64,AAAA", filename: "photo.heic" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toBe("original output")
|
||||
expect(mockParseImageDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("appends within-limits metadata when image is already valid", async () => {
|
||||
//#given
|
||||
mockParseImageDimensions.mockReturnValue({ width: 800, height: 600 })
|
||||
mockCalculateTargetDimensions.mockReturnValue(null)
|
||||
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("[Image Info]")
|
||||
expect(output.output).toContain("within limits")
|
||||
expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,old")
|
||||
expect(mockResizeImage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("replaces attachment URL and appends resize metadata for oversized image", async () => {
|
||||
//#given
|
||||
mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 })
|
||||
mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 })
|
||||
mockResizeImage.mockResolvedValue({
|
||||
resizedDataUrl: "data:image/png;base64,resized",
|
||||
original: { width: 3000, height: 2000 },
|
||||
resized: { width: 1568, height: 1045 },
|
||||
})
|
||||
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "big.png" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,resized")
|
||||
expect(output.output).toContain("[Image Resize Info]")
|
||||
expect(output.output).toContain("resized")
|
||||
})
|
||||
|
||||
it("keeps original attachment URL and marks resize skipped when resize fails", async () => {
|
||||
//#given
|
||||
mockParseImageDimensions.mockReturnValue({ width: 3000, height: 2000 })
|
||||
mockCalculateTargetDimensions.mockReturnValue({ width: 1568, height: 1045 })
|
||||
mockResizeImage.mockResolvedValue(null)
|
||||
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "fail.png" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.attachments?.[0]?.url).toBe("data:image/png;base64,old")
|
||||
expect(output.output).toContain("resize skipped")
|
||||
})
|
||||
|
||||
it("appends unknown-dimensions metadata when parsing fails", async () => {
|
||||
//#given
|
||||
mockParseImageDimensions.mockReturnValue(null)
|
||||
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "corrupt.png" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("Read"), output)
|
||||
|
||||
//#then
|
||||
expect(output.output).toContain("dimensions could not be parsed")
|
||||
expect(mockCalculateTargetDimensions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("fires for lowercase read tool name", async () => {
|
||||
//#given
|
||||
mockParseImageDimensions.mockReturnValue({ width: 800, height: 600 })
|
||||
mockCalculateTargetDimensions.mockReturnValue(null)
|
||||
|
||||
const hook = createReadImageResizerHook(createMockContext())
|
||||
const output: ToolOutput = {
|
||||
title: "Read",
|
||||
output: "original output",
|
||||
metadata: {},
|
||||
attachments: [{ mime: "image/png", url: "data:image/png;base64,old", filename: "image.png" }],
|
||||
}
|
||||
|
||||
//#when
|
||||
await hook["tool.execute.after"](createInput("read"), output)
|
||||
|
||||
//#then
|
||||
expect(mockParseImageDimensions).toHaveBeenCalledTimes(1)
|
||||
expect(output.output).toContain("within limits")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user