소스 검색

test(images): cover attachment projection edges

creatixchu 1 개월 전
부모
커밋
657ec56fbf

+ 6 - 8
packages/attachment/attachment-local/src/canonical.ts

@@ -77,12 +77,10 @@ export async function hasLowColourCount(pipeline: Sharp): Promise<boolean> {
   }).raw().toBuffer({ resolveWithObject: true })
   }).raw().toBuffer({ resolveWithObject: true })
   const colours = new Set<number>()
   const colours = new Set<number>()
   for (let offset = 0; offset < data.length; offset += info.channels) {
   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))
     colours.add(((red >> 3) << 15) | ((green >> 3) << 10) | ((blue >> 3) << 5) | (alpha >> 3))
     if (colours.size > LOW_COLOUR_LIMIT) return false
     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 scale = Math.min(MIN_SCALE_STEP, sizeScale)
       const nextWidth = Math.max(1, Math.floor(width * scale))
       const nextWidth = Math.max(1, Math.floor(width * scale))
       const nextHeight = Math.max(1, Math.floor(height * 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) {
   } catch (error) {
     if (error instanceof AttachmentError) throw error
     if (error instanceof AttachmentError) throw error

+ 6 - 5
packages/attachment/attachment-local/src/encoding.ts

@@ -20,16 +20,17 @@ export async function encodeFirstWithinLimit<T extends EncodedCandidate>(
   attempts: readonly (() => Promise<T>)[],
   attempts: readonly (() => Promise<T>)[],
   maxBytes: number,
   maxBytes: number,
 ): Promise<T | ExhaustedEncoding<T>> {
 ): 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()
     const candidate = await attempt()
     if (candidate.data.byteLength <= maxBytes) return candidate
     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
       smallest = candidate
     }
     }
   }
   }
-  if (smallest === undefined) throw new Error('image encoding did not execute a candidate')
   return { smallest }
   return { smallest }
 }
 }
 
 

+ 1 - 5
packages/attachment/attachment-local/src/request-image.ts

@@ -263,11 +263,7 @@ async function writeCached(path: string, data: Uint8Array): Promise<void> {
   const temporary = `${path}.${randomUUID()}.tmp`
   const temporary = `${path}.${randomUUID()}.tmp`
   try {
   try {
     await writeFile(temporary, data, { mode: 0o600, flag: 'wx' })
     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 {
   } finally {
     await rm(temporary, { force: true })
     await rm(temporary, { force: true })
   }
   }

+ 43 - 0
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.',
         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', () => {
 describe('hasLowColourCount', () => {
@@ -288,6 +322,15 @@ describe('hasLowColourCount', () => {
     await expect(hasLowColourCount(grayscaleAlpha)).resolves.toBe(true)
     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 () => {
   it('keeps an antialiased text screenshot readable on the low-colour PNG path', async () => {
     const source = new Uint8Array(await sharp(Buffer.from(`
     const source = new Uint8Array(await sharp(Buffer.from(`
       <svg width="1024" height="512" xmlns="http://www.w3.org/2000/svg">
       <svg width="1024" height="512" xmlns="http://www.w3.org/2000/svg">

+ 26 - 1
packages/attachment/attachment-local/tests/encoding.spec.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it, vi } from 'vitest'
 import { describe, expect, it, vi } from 'vitest'
 import { CompressionLimiter } from '../src/compression-limiter.ts'
 import { CompressionLimiter } from '../src/compression-limiter.ts'
-import { encodeFirstWithinLimit } from '../src/encoding.ts'
+import { encodeFirstWithinLimit, isExhaustedEncoding } from '../src/encoding.ts'
 
 
 describe('lazy image encoding', () => {
 describe('lazy image encoding', () => {
   it('does not execute fallback qualities after the first fitting candidate', async () => {
   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(second).toHaveBeenCalledTimes(1)
     expect(third).not.toHaveBeenCalled()
     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', () => {
 describe('CompressionLimiter', () => {
@@ -67,4 +80,16 @@ describe('CompressionLimiter', () => {
     await expect(failed).rejects.toThrow('synchronous setup failure')
     await expect(failed).rejects.toThrow('synchronous setup failure')
     await expect(next).resolves.toBe('next')
     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')
+  })
 })
 })

+ 24 - 0
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) => {
   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-'))
     const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-16-bit-'))
     try {
     try {

+ 47 - 0
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<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.',
+      })
+  })
+})

+ 157 - 1
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 { tmpdir } from 'node:os'
 import { join } from 'node:path'
 import { join } from 'node:path'
 import { Context } from '@deepseek-ai/cordis'
 import { Context } from '@deepseek-ai/cordis'
@@ -39,9 +39,122 @@ describe('request image dimensions', () => {
     })
     })
     expect(projected.width * projected.height).toBeLessThanOrEqual(640_000)
     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', () => {
 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 () => {
   it('derives stable square and wide previews and separates route budgets in the cache key', async () => {
     const attachments = await store()
     const attachments = await store()
     const square = (await attachments.saveImage({
     const square = (await attachments.saveImage({
@@ -99,6 +212,17 @@ describe('local request-image cache', () => {
     expect(pixel[1]).toBeGreaterThan(pixel[0] ?? 0)
     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 () => {
   it('classifies opaque PNG pixels and preserves alpha while enforcing the request budget', async () => {
     const attachments = await store()
     const attachments = await store()
     const side = 256
     const side = 256
@@ -233,4 +357,36 @@ describe('local request-image cache', () => {
     await expect(request).rejects.toBe(reason)
     await expect(request).rejects.toBe(reason)
     expect(readSignal?.reason).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)
+  })
 })
 })

+ 11 - 1
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 sharp from 'sharp'
 import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
 import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
 import type { MasterImagePolicy } from '../src/canonical.ts'
 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(() => ({
 const fsControl = vi.hoisted(() => ({
   readSignals: [] as AbortSignal[],
   readSignals: [] as AbortSignal[],
@@ -256,4 +256,14 @@ describe('local attachment store', () => {
     await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY))
     await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY))
       .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' })
       .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' })
+  })
 })
 })

+ 34 - 0
packages/attachment/attachment/tests/index.spec.ts

@@ -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 {
 function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment {
   return { data: Uint8Array.of(value), mediaType, name: `${value}.png` }
   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(store.calls).toEqual(['request:1.png', 'request:2.png'])
     expect(versions.map(version => version.master.name)).toEqual(['1.png', '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', () => {
 describe('isImageAdmissionError', () => {

+ 128 - 0
packages/fs/tool-fs/tests/read-image.spec.ts

@@ -226,6 +226,134 @@ describe('read_image_region', () => {
     expect(result.isError).toBe(true)
     expect(result.isError).toBe(true)
     expect(text(result)).toContain('not referenced by the current session')
     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', () => {
 describe('read_image happy path', () => {