test(images): cover attachment projection edges

This commit is contained in:
creatixchu
2026-08-21 15:06:10 +08:00
parent 48a58b9090
commit 657ec56fbf
11 changed files with 483 additions and 21 deletions
@@ -77,12 +77,10 @@ export async function hasLowColourCount(pipeline: Sharp): Promise<boolean> {
}).raw().toBuffer({ resolveWithObject: true })
const colours = new Set<number>()
for (let offset = 0; offset < data.length; offset += info.channels) {
const red = data[offset] ?? 0
const green = info.channels < 3 ? red : data[offset + 1] ?? red
const blue = info.channels < 3 ? red : data[offset + 2] ?? red
const alpha = info.channels === 2
? data[offset + 1] ?? 255
: info.channels === 4 ? data[offset + 3] ?? 255 : 255
const red = data.readUInt8(offset)
const green = data.readUInt8(offset + 1)
const blue = data.readUInt8(offset + 2)
const alpha = info.channels === 4 ? data.readUInt8(offset + 3) : 255
colours.add(((red >> 3) << 15) | ((green >> 3) << 10) | ((blue >> 3) << 5) | (alpha >> 3))
if (colours.size > LOW_COLOUR_LIMIT) return false
}
@@ -183,8 +181,8 @@ export async function prepareMasterImage(
const scale = Math.min(MIN_SCALE_STEP, sizeScale)
const nextWidth = Math.max(1, Math.floor(width * scale))
const nextHeight = Math.max(1, Math.floor(height * scale))
width = nextWidth === width && width > 1 ? width - 1 : nextWidth
height = nextHeight === height && height > 1 ? height - 1 : nextHeight
width = nextWidth
height = nextHeight
}
} catch (error) {
if (error instanceof AttachmentError) throw error
@@ -20,16 +20,17 @@ export async function encodeFirstWithinLimit<T extends EncodedCandidate>(
attempts: readonly (() => Promise<T>)[],
maxBytes: number,
): Promise<T | ExhaustedEncoding<T>> {
if (attempts.length === 0) throw new Error('image encoding requires at least one candidate')
let smallest: T | undefined
for (const attempt of attempts) {
const [first, ...remaining] = attempts
if (first === undefined) throw new Error('image encoding requires at least one candidate')
let smallest = await first()
if (smallest.data.byteLength <= maxBytes) return smallest
for (const attempt of remaining) {
const candidate = await attempt()
if (candidate.data.byteLength <= maxBytes) return candidate
if (smallest === undefined || candidate.data.byteLength < smallest.data.byteLength) {
if (candidate.data.byteLength < smallest.data.byteLength) {
smallest = candidate
}
}
if (smallest === undefined) throw new Error('image encoding did not execute a candidate')
return { smallest }
}
@@ -263,11 +263,7 @@ async function writeCached(path: string, data: Uint8Array): Promise<void> {
const temporary = `${path}.${randomUUID()}.tmp`
try {
await writeFile(temporary, data, { mode: 0o600, flag: 'wx' })
try {
await rename(temporary, path)
} catch (error: unknown) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'EEXIST') throw error
}
await rename(temporary, path)
} finally {
await rm(temporary, { force: true })
}
@@ -230,6 +230,40 @@ describe('prepareMasterImage', () => {
message: 'The 16-bit PNG could not be converted to the canonical 8-bit sRGB form.',
})
})
it.each([
['float PNG', { mediaType: 'image/png', depth: 'float' }],
['uchar JPEG', { mediaType: 'image/jpeg', depth: 'uchar' }],
] as const)('describes a failed %s conversion without exposing the encoder error', async (source, fields) => {
const detected = {
...fields,
width: 5000,
height: 5000,
animated: false,
carriesMetadata: false,
space: 'srgb',
hasAlpha: false,
} as const
await expect(prepareMasterImage(Uint8Array.of(1, 2, 3), detected, POLICY))
.rejects.toMatchObject({
code: 'ATTACHMENT_WRITE_FAILED',
message: `The ${source} could not be converted to the canonical 8-bit sRGB form.`,
})
})
it('rejects a converted master whose verified alpha metadata disagrees with the source facts', async () => {
const data = await flatImage(8, 8, 'png', true)
const detected = await detectImage(data)
await expect(prepareMasterImage(data, { ...detected, hasAlpha: false }, {
maxDimension: 4,
maxBytes: POLICY.maxBytes,
})).rejects.toMatchObject({
code: 'ATTACHMENT_WRITE_FAILED',
message: 'Canonical image conversion did not produce a single-frame 8-bit sRGB image with matching metadata.',
})
})
})
describe('hasLowColourCount', () => {
@@ -288,6 +322,15 @@ describe('hasLowColourCount', () => {
await expect(hasLowColourCount(grayscaleAlpha)).resolves.toBe(true)
})
it('reads one-channel grayscale samples as equal RGB values', async () => {
const pixels = new Uint8Array(128 * 16)
for (let index = 0; index < pixels.length; index += 1) pixels[index] = index & 0xff
await expect(hasLowColourCount(sharp(pixels, {
raw: { width: 128, height: 16, channels: 1 },
}))).resolves.toBe(true)
})
it('keeps an antialiased text screenshot readable on the low-colour PNG path', async () => {
const source = new Uint8Array(await sharp(Buffer.from(`
<svg width="1024" height="512" xmlns="http://www.w3.org/2000/svg">
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { CompressionLimiter } from '../src/compression-limiter.ts'
import { encodeFirstWithinLimit } from '../src/encoding.ts'
import { encodeFirstWithinLimit, isExhaustedEncoding } from '../src/encoding.ts'
describe('lazy image encoding', () => {
it('does not execute fallback qualities after the first fitting candidate', async () => {
@@ -22,6 +22,19 @@ describe('lazy image encoding', () => {
expect(second).toHaveBeenCalledTimes(1)
expect(third).not.toHaveBeenCalled()
})
it('rejects an empty candidate list and reports the smallest exhausted candidate', async () => {
await expect(encodeFirstWithinLimit([], 8)).rejects.toThrow('requires at least one candidate')
const result = await encodeFirstWithinLimit([
() => Promise.resolve({ data: new Uint8Array(12), quality: 85 }),
() => Promise.resolve({ data: new Uint8Array(9), quality: 80 }),
() => Promise.resolve({ data: new Uint8Array(10), quality: 75 }),
], 8)
expect(isExhaustedEncoding(result)).toBe(true)
expect(result).toMatchObject({ smallest: { quality: 80 } })
expect(isExhaustedEncoding({ data: new Uint8Array(1) })).toBe(false)
})
})
describe('CompressionLimiter', () => {
@@ -67,4 +80,16 @@ describe('CompressionLimiter', () => {
await expect(failed).rejects.toThrow('synchronous setup failure')
await expect(next).resolves.toBe('next')
})
it('normalizes a non-Error rejection and releases its slot', async () => {
const limiter = new CompressionLimiter(1)
const failed = limiter.run(() => Promise.reject('native failure'))
const next = limiter.run(() => Promise.resolve('next'))
await expect(failed).rejects.toMatchObject({
message: 'Image compression task rejected with a non-Error value.',
cause: 'native failure',
})
await expect(next).resolves.toBe('next')
})
})
@@ -58,6 +58,30 @@ describe('local attachment service', () => {
}
})
it('commits a fully prepared image batch in input order', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-success-'))
try {
const service = new LocalAttachmentStore(new Context(), { dshHome })
const first = new Uint8Array(await sharp({
create: { width: 2, height: 1, channels: 3, background: { r: 1, g: 2, b: 3 } },
}).png().toBuffer())
const second = new Uint8Array(await sharp({
create: { width: 1, height: 2, channels: 3, background: { r: 4, g: 5, b: 6 } },
}).png().toBuffer())
const refs = await service.saveImages([
{ data: first, mediaType: 'image/png', name: 'first.png' },
{ data: second, mediaType: 'image/png', name: 'second.png' },
])
expect(refs.map(ref => ref.name)).toEqual(['first.png', 'second.png'])
await expect(Promise.all(refs.map(ref => service.readImage(ref))))
.resolves.toHaveLength(2)
} finally {
await rm(dshHome, { recursive: true, force: true })
}
})
it.each([3, 4] as const)('admits a 16-bit %s-channel PNG as an 8-bit master object', async (channels) => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-16-bit-'))
try {
@@ -0,0 +1,47 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
import sharp from 'sharp'
import { afterEach, describe, expect, it, vi } from 'vitest'
const control = vi.hoisted(() => ({ mismatch: false }))
vi.mock('../src/image.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/image.ts')>()
return {
...actual,
async detectImage(data: Uint8Array): Promise<Awaited<ReturnType<typeof actual.detectImage>>> {
const detected = await actual.detectImage(data)
return control.mismatch ? { ...detected, width: detected.width + 1 } : detected
},
}
})
import LocalAttachmentStore from '../src/index.ts'
const homes: string[] = []
afterEach(async () => {
control.mismatch = false
await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true })))
})
describe('request image verification', () => {
it('rejects an encoded request whose decoded facts disagree with the encoder result', async () => {
const dshHome = await mkdtemp(join(tmpdir(), 'dsh-request-verification-'))
homes.push(dshHome)
const attachments = new LocalAttachmentStore(new Context(), { dshHome })
const source = new Uint8Array(await sharp({
create: { width: 64, height: 32, channels: 3, background: { r: 12, g: 34, b: 56 } },
}).png().toBuffer())
const master = (await attachments.saveImage({ data: source, mediaType: 'image/png' })).ref
control.mismatch = true
await expect(attachments.readImageRequest(master, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }))
.rejects.toMatchObject({
code: 'ATTACHMENT_WRITE_FAILED',
message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.',
})
})
})
@@ -1,4 +1,4 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Context } from '@deepseek-ai/cordis'
@@ -39,9 +39,122 @@ describe('request image dimensions', () => {
})
expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
})
it('projects a portrait within the same total-pixel budget', () => {
const projected = requestImageDimensions(2160, 3840, 640_000)
expect(projected).toEqual({ width: 600, height: 1066 })
expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
})
it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => {
expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 })
})
it('rejects invalid preview dimensions, origins, sizes, and bounds', () => {
expect(() => previewCropToMaster(0, 10, {
previewWidth: 10, previewHeight: 10, x: 0, y: 0, width: 1, height: 1,
})).toThrow('Master image width must be a positive integer')
expect(() => previewCropToMaster(10, 10, {
previewWidth: 0, previewHeight: 10, x: 0, y: 0, width: 1, height: 1,
})).toThrow('Preview width must be a positive integer')
expect(() => previewCropToMaster(10, 10, {
previewWidth: 10, previewHeight: 10, x: -1, y: 0, width: 1, height: 1,
})).toThrow('Preview crop origin must use non-negative integer pixels')
expect(() => previewCropToMaster(10, 10, {
previewWidth: 10, previewHeight: 10, x: 0, y: 0, width: 0, height: 1,
})).toThrow('Preview crop width must be a positive integer')
expect(() => previewCropToMaster(10, 10, {
previewWidth: 10, previewHeight: 10, x: 9, y: 0, width: 2, height: 1,
})).toThrow('Preview crop extends beyond the image shown to the model')
})
})
describe('local request-image cache', () => {
it('passes through an in-budget master and reads a request batch in input order', async () => {
const attachments = await store()
const first = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref
const second = (await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' })).ref
const firstMaster = await attachments.readImage(first)
const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 }
const request = await attachments.readImageRequest(first, policy)
const batch = await attachments.readImageRequests([first, second], policy)
expect(request.data).toEqual(firstMaster.data)
expect(batch.map(value => value.master.attachmentId)).toEqual([first.attachmentId, second.attachmentId])
})
it('rejects invalid request policies and master crop bounds', async () => {
const attachments = await store()
const master = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref
await expect(attachments.readImageRequest(master, { maxPixels: 0, maxBytes: 100 }))
.rejects.toThrow('Image request maxPixels must be a positive integer')
await expect(attachments.readImageRequest(master, { maxPixels: 100, maxBytes: 0 }))
.rejects.toThrow('Image request maxBytes must be a positive integer')
await expect(attachments.readImageRequest(master, {
maxPixels: 100, maxBytes: 100, crop: { x: -1, y: 0, width: 1, height: 1 },
})).rejects.toThrow('Image crop origin must use non-negative integer pixels')
await expect(attachments.readImageRequest(master, {
maxPixels: 100, maxBytes: 100, crop: { x: 0, y: 0, width: 0, height: 1 },
})).rejects.toThrow('Image crop width must be a positive integer')
await expect(attachments.readImageRequest(master, {
maxPixels: 100, maxBytes: 100, crop: { x: 7, y: 0, width: 2, height: 1 },
})).rejects.toThrow('Image crop extends beyond the stored master image')
})
it('refuses a one-pixel request that cannot meet the encoded-byte budget', async () => {
const attachments = await store()
const master = (await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' })).ref
await expect(attachments.readImageRequest(master, { maxPixels: 1, maxBytes: 1 }))
.rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' })
})
it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => {
const attachments = await store()
const master = (await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' })).ref
const policy = { maxPixels: 16 * 16, maxBytes: 4_096 }
const initial = await attachments.readImageRequest(master, policy)
const hash = String(initial.variantId).slice('sha256:'.length)
const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash)
const noisyPixels = new Uint8Array(64 * 64 * 3)
let state = 0x2545f491
for (let index = 0; index < noisyPixels.length; index += 1) {
state ^= state << 13
state ^= state >>> 17
state ^= state << 5
noisyPixels[index] = state & 0xff
}
const oversized = new Uint8Array(await sharp(noisyPixels, {
raw: { width: 64, height: 64, channels: 3 },
}).png().toBuffer())
const depth16 = new Uint8Array(await sharp({
create: { width: 16, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } },
}).toColourspace('rgb16').png().toBuffer())
const cmyk = new Uint8Array(await sharp({
create: { width: 16, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } },
}).toColourspace('cmyk').jpeg().toBuffer())
const tooWide = await image(23, 11)
const unexpectedAlpha = new Uint8Array(await sharp({
create: { width: 16, height: 8, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 0.5 } },
}).png().toBuffer())
for (const invalid of [
oversized,
depth16,
cmyk,
tooWide,
unexpectedAlpha,
Uint8Array.of(1, 2, 3),
]) {
await writeFile(path, invalid)
const regenerated = await attachments.readImageRequest(master, policy)
expect(regenerated.data).toEqual(initial.data)
}
})
it('derives stable square and wide previews and separates route budgets in the cache key', async () => {
const attachments = await store()
const square = (await attachments.saveImage({
@@ -99,6 +212,17 @@ describe('local request-image cache', () => {
expect(pixel[1]).toBeGreaterThan(pixel[0] ?? 0)
})
it('names a crop from an unnamed attachment id', async () => {
const attachments = await store()
const master = (await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' })).ref
const cropped = await attachments.cropImage(master, {
previewWidth: 8, previewHeight: 4, x: 0, y: 0, width: 4, height: 4,
})
expect(cropped.ref.name).toMatch(/^sha256:[0-9a-f]{8}-crop\.(?:png|webp|jpg)$/u)
})
it('classifies opaque PNG pixels and preserves alpha while enforcing the request budget', async () => {
const attachments = await store()
const side = 256
@@ -233,4 +357,36 @@ describe('local request-image cache', () => {
await expect(request).rejects.toBe(reason)
expect(readSignal?.reason).toBe(reason)
})
it('normalizes a non-Error cancellation and replaces an aborted shared transform', async () => {
const attachments = await store()
const master = (await attachments.saveImage({
data: await image(2048, 1024), mediaType: 'image/png', name: 'replace.png',
})).ref
const actualRead = attachments.readImage.bind(attachments)
let calls = 0
vi.spyOn(attachments, 'readImage').mockImplementation((ref, signal) => {
calls += 1
if (calls === 1) {
return new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => reject(signal.reason), { once: true })
})
}
return actualRead(ref, signal)
})
const controller = new AbortController()
const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 }
const cancelled = attachments.readImageRequest(master, policy, controller.signal)
await vi.waitFor(() => expect(calls).toBe(1))
controller.abort('cancelled')
const replacement = attachments.readImageRequest(master, policy)
await expect(cancelled).rejects.toMatchObject({
message: 'Attachment request cancelled with a non-Error reason.',
cause: 'cancelled',
})
await expect(replacement).resolves.toMatchObject({ width: 1130, height: 565 })
expect(calls).toBe(2)
})
})
@@ -8,7 +8,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
import sharp from 'sharp'
import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
import type { MasterImagePolicy } from '../src/canonical.ts'
import { readImageFile, saveImageFile } from '../src/store.ts'
import { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile } from '../src/store.ts'
const fsControl = vi.hoisted(() => ({
readSignals: [] as AbortSignal[],
@@ -256,4 +256,14 @@ describe('local attachment store', () => {
await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY))
.rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
})
it('rejects prepared bytes that no longer match their content-addressed reference', async () => {
const storageRoot = await root()
const prepared = await prepareImageFile({ data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)
await expect(commitPreparedImageFile(storageRoot, {
...prepared,
data: Uint8Array.of(...prepared.data, 0),
})).rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' })
})
})
@@ -76,6 +76,22 @@ class RecordingStore extends AttachmentStore {
}
}
class UnsupportedProjectionStore extends AttachmentStore {
readonly imageLimits = LIMITS
validateImage(): Promise<void> {
return Promise.resolve()
}
saveImage(): Promise<SavedImageAttachment> {
throw new Error('not used')
}
readImage(): Promise<StoredImageAttachment> {
throw new Error('not used')
}
}
function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment {
return { data: Uint8Array.of(value), mediaType, name: `${value}.png` }
}
@@ -134,6 +150,24 @@ describe('AttachmentStore.readImageRequests', () => {
expect(store.calls).toEqual(['request:1.png', 'request:2.png'])
expect(versions.map(version => version.master.name)).toEqual(['1.png', '2.png'])
})
it('reports unsupported request projection and crop operations, preserving cancellation', async () => {
const store = new UnsupportedProjectionStore(new Context())
const ref = (await new RecordingStore(new Context()).saveImage(image(1))).ref
await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }))
.rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' })
await expect(store.cropImage(ref, {
previewWidth: 1, previewHeight: 1, x: 0, y: 0, width: 1, height: 1,
})).rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' })
const controller = new AbortController()
const reason = new Error('cancel unsupported projection')
controller.abort(reason)
expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason)
expect(() => store.cropImage(ref, {
previewWidth: 1, previewHeight: 1, x: 0, y: 0, width: 1, height: 1,
}, controller.signal)).toThrow(reason)
})
})
describe('isImageAdmissionError', () => {
@@ -226,6 +226,134 @@ describe('read_image_region', () => {
expect(result.isError).toBe(true)
expect(text(result)).toContain('not referenced by the current session')
})
it('finds images nested in tool results after skipping a non-matching nested result', async () => {
const ctx = await setup()
const source = await ctx.attachments.saveImage({ data: PNG_3X3, mediaType: 'image/png' })
const history = [createUserMessage({
content: [
{ type: 'tool-result', toolCallId: CallId('unrelated'), content: [{ type: 'text', text: 'none' }] },
{ type: 'tool-result', toolCallId: CallId('nested'), content: [{ type: 'image', attachment: source.ref }] },
],
source: { kind: 'plugin', plugin: 'test' },
})]
const result = await call(ctx, 'read_image_region', {
attachment_id: source.ref.attachmentId,
preview_width: 3,
preview_height: 3,
x: 0,
y: 0,
width: 1,
height: 1,
}, agentOn('vision-model', 'visual', history))
expect(result.isError).toBe(false)
})
it('rejects a missing session, empty id, and invalid coordinate arguments', async () => {
const ctx = await setup()
const base = {
attachment_id: `sha256:${'f'.repeat(64)}`,
preview_width: 1,
preview_height: 1,
x: 0,
y: 0,
width: 1,
height: 1,
}
const noSession = await call(ctx, 'read_image_region', base)
expect(text(noSession)).toContain('requires an active agent session')
const empty = await call(ctx, 'read_image_region', { ...base, attachment_id: ' ' }, agentOn('vision-model'))
expect(text(empty)).toContain('attachment_id must be a non-empty string')
const source = await ctx.attachments.saveImage({ data: PNG_1X1, mediaType: 'image/png' })
const history = [createUserMessage({
content: [{ type: 'image', attachment: source.ref }],
source: { kind: 'plugin', plugin: 'test' },
})]
const agent = agentOn('vision-model', 'visual', history)
for (const [field, value, expected] of [
['preview_width', 0, 'preview_width must be a positive integer'],
['preview_height', 0, 'preview_height must be a positive integer'],
['x', -1, 'x must be a non-negative integer'],
['y', -1, 'y must be a non-negative integer'],
['width', 0, 'width must be a positive integer'],
['height', 0, 'height must be a positive integer'],
] as const) {
const result = await call(ctx, 'read_image_region', {
...base,
attachment_id: source.ref.attachmentId,
[field]: value,
}, agent)
expect(text(result)).toContain(expected)
}
})
it('projects optional crop metadata from a provider result', async () => {
class CropMetadataStore extends AttachmentStore {
readonly imageLimits: ImageAttachmentLimits = {
maxImageBytes: 1024,
maxImagesPerMessage: 1,
maxMessageImageBytes: 1024,
maxImagePixels: 100,
maxImageDimension: 100,
mediaTypes: ['image/png'],
}
validateImage(): Promise<void> { return Promise.resolve() }
saveImage(): Promise<SavedImageAttachment> { throw new Error('not used') }
readImage(): Promise<StoredImageAttachment> { throw new Error('not used') }
override cropImage(ref: ImageAttachmentRef): Promise<SavedImageAttachment> {
return Promise.resolve({
ref: { ...ref, sourceWidth: 2, sourceHeight: 2 },
source: { mediaType: ref.mediaType, bytes: ref.bytes, width: 2, height: 2 },
})
}
}
const ctx = await setup({ attachments: false })
await ctx.plugin(CropMetadataStore)
const ref: ImageAttachmentRef = {
attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`),
mediaType: 'image/png', bytes: 1, width: 1, height: 1,
}
const history = [createUserMessage({
content: [{ type: 'image', attachment: ref }],
source: { kind: 'plugin', plugin: 'test' },
})]
const result = await call(ctx, 'read_image_region', {
attachment_id: ref.attachmentId,
preview_width: 1,
preview_height: 1,
x: 0,
y: 0,
width: 1,
height: 1,
}, agentOn('vision-model', 'visual', history))
expect(result.content[1]).toMatchObject({
type: 'image',
attachment: { sourceWidth: 2, sourceHeight: 2 },
})
expect(result.content[1]).not.toHaveProperty('attachment.name')
})
it('declares a generic read presentation for image-region calls', async () => {
const ctx = await setup()
expect(ctx.tools.get('read_image_region')?.presentCall?.({
attachment_id: 'sha256:abc',
preview_width: 1,
preview_height: 1,
x: 0,
y: 0,
width: 1,
height: 1,
}))
.toEqual({ card: 'generic', title: 'Read image region sha256:abc', kind: 'read' })
})
})
describe('read_image happy path', () => {