mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-09-14 04:01:35 +00:00
test(images): cover attachment projection edges
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user