From 657ec56fbfc26cc03ae27e033f75f148796fd86c Mon Sep 17 00:00:00 2001 From: creatixchu Date: Thu, 20 Aug 2026 20:25:35 +0800 Subject: [PATCH] test(images): cover attachment projection edges --- .../attachment-local/src/canonical.ts | 14 +- .../attachment-local/src/encoding.ts | 11 +- .../attachment-local/src/request-image.ts | 6 +- .../attachment-local/tests/canonical.spec.ts | 43 +++++ .../attachment-local/tests/encoding.spec.ts | 27 ++- .../attachment-local/tests/index.spec.ts | 24 +++ .../tests/request-image-verification.spec.ts | 47 ++++++ .../tests/request-image.spec.ts | 158 +++++++++++++++++- .../attachment-local/tests/store.spec.ts | 12 +- .../attachment/attachment/tests/index.spec.ts | 34 ++++ packages/fs/tool-fs/tests/read-image.spec.ts | 128 ++++++++++++++ 11 files changed, 483 insertions(+), 21 deletions(-) create mode 100644 packages/attachment/attachment-local/tests/request-image-verification.spec.ts diff --git a/packages/attachment/attachment-local/src/canonical.ts b/packages/attachment/attachment-local/src/canonical.ts index c437448f4c..513e5bbcc7 100644 --- a/packages/attachment/attachment-local/src/canonical.ts +++ b/packages/attachment/attachment-local/src/canonical.ts @@ -77,12 +77,10 @@ export async function hasLowColourCount(pipeline: Sharp): Promise { }).raw().toBuffer({ resolveWithObject: true }) const colours = new Set() 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 diff --git a/packages/attachment/attachment-local/src/encoding.ts b/packages/attachment/attachment-local/src/encoding.ts index 8099046c95..963edda672 100644 --- a/packages/attachment/attachment-local/src/encoding.ts +++ b/packages/attachment/attachment-local/src/encoding.ts @@ -20,16 +20,17 @@ export async function encodeFirstWithinLimit( attempts: readonly (() => Promise)[], maxBytes: number, ): Promise> { - 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 } } diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts index f65bc97ad5..ef7c841bed 100644 --- a/packages/attachment/attachment-local/src/request-image.ts +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -263,11 +263,7 @@ async function writeCached(path: string, data: Uint8Array): Promise { 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 }) } diff --git a/packages/attachment/attachment-local/tests/canonical.spec.ts b/packages/attachment/attachment-local/tests/canonical.spec.ts index a6f489615e..8aa30511d6 100644 --- a/packages/attachment/attachment-local/tests/canonical.spec.ts +++ b/packages/attachment/attachment-local/tests/canonical.spec.ts @@ -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(` diff --git a/packages/attachment/attachment-local/tests/encoding.spec.ts b/packages/attachment/attachment-local/tests/encoding.spec.ts index c95d09c43c..d75fc9b4b3 100644 --- a/packages/attachment/attachment-local/tests/encoding.spec.ts +++ b/packages/attachment/attachment-local/tests/encoding.spec.ts @@ -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') + }) }) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 872aa5a3f7..89ada53298 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -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 { diff --git a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts new file mode 100644 index 0000000000..aae96e0b1d --- /dev/null +++ b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts @@ -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() + return { + ...actual, + async detectImage(data: Uint8Array): Promise>> { + 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.', + }) + }) +}) diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts index 3cdfadd32c..e33522c104 100644 --- a/packages/attachment/attachment-local/tests/request-image.spec.ts +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -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) + }) }) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index 97445c2f85..f0c127c174 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -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' }) + }) }) diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 3a8fa23cbe..25afbcd420 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -76,6 +76,22 @@ class RecordingStore extends AttachmentStore { } } +class UnsupportedProjectionStore extends AttachmentStore { + readonly imageLimits = LIMITS + + validateImage(): Promise { + return Promise.resolve() + } + + saveImage(): Promise { + throw new Error('not used') + } + + readImage(): Promise { + 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', () => { diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 2f67a464fd..3bf86d27f5 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -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 { return Promise.resolve() } + saveImage(): Promise { throw new Error('not used') } + readImage(): Promise { throw new Error('not used') } + override cropImage(ref: ImageAttachmentRef): Promise { + 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', () => {