Files
goodbuddy/src/main/agent/image-generation-service.test.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

383 lines
25 KiB
TypeScript

import { randomUUID } from 'node:crypto'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
import { AssistantDatabase } from '../assistant/assistant-database'
import { ImageGenerationService, type ImageServiceSettings, type ImageGenerationServiceOptions } from './image-generation-service'
import { imageOperationSchema, type ImageOperation } from '../../shared/image-generation-contracts'
import { KnowledgeMcpGateway } from './knowledge-mcp-gateway'
import { ModelToolProvider } from './model-tool-provider'
vi.mock('electron', () => ({ nativeImage: { createFromBuffer: () => ({ isEmpty: () => false, toPNG: () => Buffer.from(png, 'base64'), toJPEG: () => Buffer.from('jpeg-bytes') }) } }))
const png = 'iVBORw0KGgo='
const cleanups: (() => void | Promise<void>)[] = []
async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'goodbuddy-save-image-'))
cleanups.push(() => rm(dir, { recursive: true, force: true }))
return dir
}
afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() })
function setup(fetcher = vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: png }] })), observers: Pick<ImageGenerationServiceOptions, 'onOperation' | 'onUsage' | 'onError'> = {}) {
const database = new AssistantDatabase(':memory:')
database.initialize(process.cwd())
cleanups.push(() => database.close())
const context = { conversationId: randomUUID(), messageId: randomUUID(), requestId: randomUUID() }
const message = { id: context.messageId, role: 'assistant' as const, content: '', createdAt: Date.now(), state: 'complete' as const }
const header = { id: context.conversationId, title: 'Images', updatedAt: Date.now() }
database.saveLocalConversations([{ header, messages: [message] }])
const profile = { id: randomUUID(), name: 'Image A', modelName: 'image-a', protocol: 'openai-images-generations',
allowConversationInvocation: true, baseUrl: 'https://provider.test/v1', authentication: 'api-key' as const, apiKey: 'test-secret' }
const settings: ImageServiceSettings = { modelProfiles: [profile], defaultImageModelProfileId: profile.id }
const events: ImageOperation[] = []
const storage = {
getConversation: async (...args: Parameters<AssistantDatabase['getConversation']>) => database.getConversation(...args),
getArtifact: async (...args: Parameters<AssistantDatabase['getArtifact']>) => database.getArtifact(...args),
saveConversationImageOperation: async (...args: Parameters<AssistantDatabase['saveConversationImageOperation']>) => database.saveConversationImageOperation(...args),
saveConversationImageSources: async (...args: Parameters<AssistantDatabase['saveConversationImageSources']>) => database.saveConversationImageSources(...args),
markUnfinishedImageOperationsUnconfirmed: async () => database.markUnfinishedImageOperationsUnconfirmed()
}
const service = new ImageGenerationService({ database: storage, getSettings: async () => settings, fetcher,
onOperation: operation => { imageOperationSchema.parse(operation); events.push(operation); observers.onOperation?.(operation) }, onUsage: observers.onUsage, onError: observers.onError })
service.initialize()
cleanups.push(() => service.dispose())
return { database, storage, service, context, fetcher, settings, profile, events, header, message }
}
describe('Main conversation image service', () => {
it('awaits usage persistence and reports its failure to callers and shutdown', async () => {
let rejectUsage!: (error: Error) => void
const onUsage = vi.fn(() => new Promise<void>((_resolve, reject) => { rejectUsage = reject }))
const h = setup(vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: png }], usage: { input_tokens: 2, output_tokens: 3 } })), { onUsage })
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'usage')
const rejected = expect(pending).rejects.toThrow('Usage storage failed')
await vi.waitFor(() => expect(onUsage).toHaveBeenCalledOnce())
expect(h.events.at(-1)?.state).not.toBe('completed')
rejectUsage(new Error('Usage storage failed'))
await rejected
await expect(h.service.dispose()).rejects.toThrow('Usage storage failed')
cleanups.pop()
})
it('does not notify completion or finish shutdown before the image write commits', async () => {
const h = setup()
const save = h.database.saveConversationImageOperation.bind(h.database)
let commit!: () => void
vi.spyOn(h.storage, 'saveConversationImageOperation').mockImplementation((operation, image) => {
if (!image) return Promise.resolve(save(operation, image))
return new Promise(resolve => { commit = () => resolve(save(operation, image)) })
})
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'delayed-write')
await vi.waitFor(() => expect(commit).toBeTypeOf('function'))
expect(h.events.at(-1)?.state).toBe('saving')
const closed = vi.fn()
const closing = h.service.dispose().then(closed)
await Promise.resolve()
expect(closed).not.toHaveBeenCalled()
commit()
expect((await pending).state).toBe('completed')
await closing
})
it('does not let a destroyed Renderer prevent submission or overwrite completion', async () => {
const onError = vi.fn()
const h = setup(vi.fn<typeof fetch>(async () => Response.json({ data: [{ b64_json: png }], usage: { input_tokens: 2, output_tokens: 3 } })), {
onOperation: () => { throw new Error('Renderer is destroyed') },
onUsage: async () => {}, onError
})
const operation = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'observer')
expect(operation.state).toBe('completed')
expect((await h.service.getOperation(h.context.conversationId, operation.id)).state).toBe('completed')
expect(h.fetcher).toHaveBeenCalledOnce()
expect(onError).toHaveBeenCalled()
})
it('returns the known persistence failure instead of a stale running state when the database stops accepting writes', async () => {
const onError = vi.fn()
const h = setup(undefined, { onError })
h.fetcher.mockImplementation(async () => {
vi.spyOn(h.database, 'saveConversationImageOperation').mockImplementation(() => { throw new Error('Disk full') })
return Response.json({ data: [{ b64_json: png }] })
})
await expect(h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'disk-full')).rejects.toThrow('Disk full')
await expect(h.service.dispose()).rejects.toThrow('Disk full')
cleanups.pop()
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'Disk full' }))
expect(h.fetcher).toHaveBeenCalledOnce()
})
it('saves before publishing, preserves metadata against stale renderer saves, and edits durable history', async () => {
const h = setup()
const first = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue circle' }, 'first')
expect(first.state).toBe('completed')
expect(h.database.getArtifact(first.artifactIds[0]!).content).toContain(png)
h.database.saveLocalConversations([{ header: h.header, messages: [h.message] }])
expect(await h.service.getOperation(h.context.conversationId, first.id)).toEqual(first)
expect(h.database.getConversation(h.context.conversationId).messages[0]!.artifactIds).toEqual(first.artifactIds)
h.settings.defaultImageModelProfileId = randomUUID()
const second = await h.service.bind({ ...h.context, requestId: randomUUID() }).call({ intent: 'edit', prompt: 'Turn red', sourceArtifactIds: first.artifactIds }, 'edit')
expect(second.modelProfileId).toBe(first.modelProfileId)
expect(String(h.fetcher.mock.calls[1]![0])).toMatch(/images\/edits$/u)
expect((h.fetcher.mock.calls[1]![1]!.body as FormData).get('image')).toBeInstanceOf(Blob)
expect(h.events.map(event => event.state)).toEqual(['running', 'saving', 'completed', 'running', 'saving', 'completed'])
})
it('persists upload bytes and rejects cross-conversation or missing source references', async () => {
const h = setup()
const ids = await h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
h.database.saveLocalConversations([{ header: h.header, messages: [h.message] }])
expect(h.database.getConversation(h.context.conversationId).messages[0]!.imageSourceArtifactIds).toEqual(ids)
const binding = h.service.bind(h.context)
expect((await binding.call({ intent: 'edit', prompt: 'Blue', sourceArtifactIds: ids }, 'upload')).state).toBe('completed')
await expect(binding.call({ intent: 'edit', prompt: 'Blue', sourceArtifactIds: [randomUUID()] }, 'foreign')).rejects.toThrow('not available')
await expect(binding.call({ intent: 'edit', prompt: 'Blue' }, 'missing')).rejects.toThrow('requires source')
expect(h.fetcher).toHaveBeenCalledTimes(1)
})
it('rejects absent, disabled, deleted defaults without silent fallback', async () => {
const h = setup()
const binding = h.service.bind(h.context)
expect(await binding.describe()).toContain(h.profile.id)
h.profile.allowConversationInvocation = false
await expect(binding.call({ intent: 'create', prompt: 'Blue' }, 'disabled')).rejects.toThrow('unavailable')
h.profile.allowConversationInvocation = true
h.settings.defaultImageModelProfileId = randomUUID()
await expect(binding.call({ intent: 'create', prompt: 'Blue' }, 'deleted')).rejects.toThrow('unavailable')
h.settings.defaultImageModelProfileId = null
await expect(binding.call({ intent: 'create', prompt: 'Blue' }, 'absent')).rejects.toThrow('default')
expect(h.fetcher).not.toHaveBeenCalled()
expect(await binding.call({ intent: 'create', prompt: 'Blue', modelProfileId: h.profile.id }, 'enabled')).toMatchObject({ state: 'completed' })
})
it('continues after chat abort and pins the accepted profile while a replacement binding starts', async () => {
let resolve!: (response: Response) => void
const h = setup(vi.fn<typeof fetch>(() => new Promise(done => { resolve = done })))
const controller = new AbortController()
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'call', controller.signal)
await vi.waitFor(() => expect(h.fetcher).toHaveBeenCalledOnce())
const providerSignal = h.fetcher.mock.calls[0]![1]!.signal!
controller.abort()
expect((await pending).state).toBe('running')
expect(providerSignal.aborted).toBe(false)
h.profile.modelName = 'replacement'
h.profile.allowConversationInvocation = false
resolve(Response.json({ data: [{ b64_json: png }] }))
await vi.waitFor(() => expect(h.events.at(-1)?.state).toBe('completed'))
expect(h.events.at(-1)).toMatchObject({ modelName: 'image-a', modelProfileName: 'Image A' })
expect(JSON.parse(h.fetcher.mock.calls[0]![1]!.body as string).model).toBe('image-a')
await expect(h.service.bind({ ...h.context, requestId: randomUUID() }).call({ intent: 'create', prompt: 'Other' }, 'other')).rejects.toThrow('unavailable')
})
it('deduplicates one invocation, creates a new operation on manual regenerate, and retains late cancellation output', async () => {
let resolve!: (response: Response) => void
const h = setup(vi.fn<typeof fetch>(() => new Promise(done => { resolve = done })))
const binding = h.service.bind(h.context)
const input = { intent: 'create', prompt: 'Blue' }
const one = binding.call(input, 'same')
const two = binding.call(input, 'same')
await vi.waitFor(() => expect(h.events).toHaveLength(1))
await h.service.cancel(h.context.conversationId, h.events[0]!.id)
resolve(Response.json({ data: [{ b64_json: png }] }))
const completed = await one
expect(await two).toEqual(completed)
expect(completed).toMatchObject({ state: 'completed', cancellationRequested: true })
expect(h.fetcher).toHaveBeenCalledOnce()
h.fetcher.mockImplementation(async () => Response.json({ data: [{ b64_json: png }] }))
const regenerated = await h.service.regenerate(h.context, completed.id)
expect(regenerated.id).not.toBe(completed.id)
expect(h.fetcher).toHaveBeenCalledTimes(2)
})
it('never falls back to a paid generation after an edit rejection and redacts provider errors', async () => {
const h = setup(vi.fn<typeof fetch>(async () => Response.json({ error: { message: 'Image editing not supported Authorization: Bearer private-key' } }, { status: 405 })))
const ids = await h.service.persistUploads(h.context, [{ name: 'source', mediaType: 'image/png', data: png }])
const result = await h.service.bind(h.context).call({ intent: 'edit', prompt: 'Blue', sourceArtifactIds: ids }, 'edit')
expect(result.state).toBe('failed')
expect(result.error).toContain('405')
expect(result.error).not.toContain('private-key')
expect(h.fetcher).toHaveBeenCalledOnce()
})
it('does not resurrect a deleted conversation or retain its late artifact', async () => {
let resolve!: (response: Response) => void
const h = setup(vi.fn<typeof fetch>(() => new Promise(done => { resolve = done })))
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'deleted')
await vi.waitFor(() => expect(h.fetcher).toHaveBeenCalledOnce())
h.database.deleteLocalConversation(h.context.conversationId)
resolve(Response.json({ data: [{ b64_json: png }] }))
await expect(pending).rejects.toThrow()
await expect(h.service.dispose()).rejects.toThrow()
cleanups.pop()
expect(() => h.database.getConversation(h.context.conversationId)).toThrow()
expect(h.database.listArtifacts()).toHaveLength(0)
})
it('marks unfinished operations unconfirmed on initialization without issuing requests', () => {
const h = setup()
h.database.saveConversationImageOperation({ id: randomUUID(), conversationId: h.context.conversationId, messageId: h.context.messageId,
requestId: h.context.requestId, callId: 'old', modelProfileId: h.profile.id, modelName: h.profile.name,
input: { intent: 'create', prompt: 'Blue', sourceArtifactIds: [] }, state: 'running', artifactIds: [], createdAt: 1, updatedAt: 1 })
h.service.initialize()
expect(h.database.getConversation(h.context.conversationId).messages[0]!.imageOperations![0]!.state).toBe('unconfirmed')
expect(h.fetcher).not.toHaveBeenCalled()
})
it('reports provider-side uncertainty after network loss and local cancellation without retrying', async () => {
const h = setup(vi.fn<typeof fetch>(async () => { throw new TypeError('fetch failed') }))
const uncertain = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'network')
expect(uncertain.state).toBe('unconfirmed')
h.fetcher.mockImplementation((_url, init) => new Promise((_resolve, reject) => {
init!.signal!.addEventListener('abort', () => reject(init!.signal!.reason), { once: true })
}))
const pending = h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'cancel')
await vi.waitFor(() => expect(h.fetcher).toHaveBeenCalledTimes(2))
await h.service.cancel(h.context.conversationId, h.events.at(-1)!.id)
expect(await pending).toMatchObject({ state: 'stopped', cancellationRequested: true })
expect(h.fetcher).toHaveBeenCalledTimes(2)
})
it('reuses an accepted invocation across bindings and does not skip a disabled historical model', async () => {
const h = setup()
const created = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'same')
expect((await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'same')).id).toBe(created.id)
const other = { ...h.profile, id: randomUUID(), name: 'Other', modelName: 'other' }
h.settings.modelProfiles = [h.profile, other]
h.settings.defaultImageModelProfileId = other.id
h.profile.allowConversationInvocation = false
await expect(h.service.bind(h.context).call({ intent: 'edit', prompt: 'Red', sourceArtifactIds: created.artifactIds }, 'blocked-edit')).rejects.toThrow('unavailable')
const edited = await h.service.bind(h.context).call({ intent: 'edit', prompt: 'Red', sourceArtifactIds: created.artifactIds, modelProfileId: other.id }, 'explicit-edit')
expect(edited.modelProfileId).toBe(other.id)
expect(h.fetcher).toHaveBeenCalledTimes(2)
})
it('dispatches the native Model tool through its scoped binding without a mode', async () => {
const h = setup()
const provider = new ModelToolProvider(process.cwd())
cleanups.push(() => provider.dispose())
const context = { conversationId: h.context.conversationId, imageToolBinding: h.service.bind(h.context), toolCallId: 'native' }
const result = await provider.callTool('generate_image', { intent: 'create', prompt: 'Blue' }, new AbortController().signal, context)
expect(JSON.stringify(result)).toContain('completed')
expect(JSON.stringify(result)).not.toContain(png)
await expect(provider.callTool('generate_image', { intent: 'create', prompt: 'Blue' }, new AbortController().signal, { conversationId: context.conversationId })).rejects.toThrow()
})
it('round trips image-only MCP discovery/call and does not abort accepted work on capability revocation', async () => {
const h = setup()
const gateway = new KnowledgeMcpGateway({} as never)
await gateway.start()
cleanups.push(() => gateway.dispose())
const token = gateway.bindImageTool(h.service.bind(h.context), new AbortController().signal)
const client = new Client({ name: 'image-test', version: '1' })
cleanups.push(() => client.close())
await client.connect(new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { requestInit: { headers: { Authorization: `Bearer ${token}` } } }))
expect((await client.listTools()).tools.map(tool => tool.name)).toEqual(['generate_image', 'save_image'])
const result = await client.callTool({ name: 'generate_image', arguments: { intent: 'create', prompt: 'Blue' } })
expect(JSON.stringify(result)).toContain('completed')
expect(h.fetcher).toHaveBeenCalledOnce()
const operation = JSON.parse((result.content as { text: string }[])[0]!.text) as ImageOperation
const target = join(await tempDir(), 'mcp.png')
const saved = await client.callTool({ name: 'save_image', arguments: { artifactId: operation.artifactIds[0], path: target } })
expect(saved.isError).toBeFalsy()
expect((await readFile(target)).toString('base64')).toBe(png)
gateway.revoke(token)
})
it('saves conversation images to local files with conversion, overwrite protection and scoping', async () => {
const h = setup()
const created = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'save')
const artifactId = created.artifactIds[0]!
const binding = h.service.bind(h.context)
const dir = await tempDir()
const pngPath = join(dir, 'nested', 'out.png')
expect(await binding.save!({ artifactId, path: pngPath })).toMatchObject({ path: pngPath, mimeType: 'image/png' })
expect((await readFile(pngPath)).toString('base64')).toBe(png)
await expect(binding.save!({ artifactId, path: pngPath })).rejects.toThrow('already exists')
await expect(binding.save!({ artifactId, path: pngPath, overwrite: true })).resolves.toMatchObject({ path: pngPath })
const jpgPath = join(dir, 'out.jpg')
expect(await binding.save!({ artifactId, path: jpgPath })).toMatchObject({ mimeType: 'image/jpeg' })
expect((await readFile(jpgPath)).toString()).toBe('jpeg-bytes')
await expect(binding.save!({ artifactId, path: join(dir, 'out.webp') })).rejects.toThrow('WebP')
await expect(binding.save!({ artifactId, path: join(dir, 'out.gif') })).rejects.toThrow('.png')
await expect(binding.save!({ artifactId, path: 'relative.png' })).rejects.toThrow('absolute')
await expect(binding.save!({ artifactId: randomUUID(), path: join(dir, 'x.png') })).rejects.toThrow('not available')
expect((await binding.readForSave!(artifactId, 'image/png')).toString('base64')).toBe(png)
await expect(binding.readForSave!(randomUUID(), 'image/png')).rejects.toThrow('not available')
const provider = new ModelToolProvider(process.cwd())
cleanups.push(() => provider.dispose())
const context = { conversationId: h.context.conversationId, imageToolBinding: binding }
const native = await provider.callTool('save_image', { artifactId, path: join(dir, 'native.png') }, new AbortController().signal, context)
expect(JSON.stringify(native)).toContain('native.png')
await expect(provider.callTool('save_image', { artifactId, path: join(dir, 'unbound.png') }, new AbortController().signal, { conversationId: context.conversationId })).rejects.toThrow()
expect(h.fetcher).toHaveBeenCalledOnce()
})
it('offers save_image only when the conversation has images or can generate them', async () => {
const h = setup()
const binding = h.service.bind(h.context)
expect(await binding.describeSave!()).toMatch(/none yet/)
h.profile.allowConversationInvocation = false
expect(await binding.describeSave!()).toBeUndefined()
const text = h.database.createTextArtifact({ title: 'Text reply', content: 'Not an image' })
h.database.saveLocalConversations([{ header: h.header, messages: [{ ...h.message, artifactIds: [text.id, randomUUID()] }] }])
expect(await binding.describeSave!()).toBeUndefined()
const [uploadId] = await h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
// IDs are listed so the model can save images even when it cannot generate.
expect(await binding.describeSave!()).toContain(uploadId)
expect(await h.service.bind(h.context).describeSave!()).toContain(uploadId)
})
it('discovers and saves message.artifactIds-only images after reopening without image generation', async () => {
const directory = await tempDir()
const databasePath = join(directory, 'assistant.sqlite')
let database = new AssistantDatabase(databasePath)
database.initialize(directory)
cleanups.push(() => database.close())
const image = database.createImageArtifact({ title: 'Direct image reply', mimeType: 'image/png', base64: png })
const context = { conversationId: randomUUID(), messageId: randomUUID(), requestId: randomUUID() }
const message = { id: context.messageId, role: 'assistant' as const, content: 'Saved reply', createdAt: 1, state: 'complete' as const, artifactIds: [image.id] }
database.saveLocalConversations([{ header: { id: context.conversationId, title: 'Direct images', updatedAt: 1 }, messages: [message] }])
database.close()
database = new AssistantDatabase(databasePath)
database.initialize(directory)
const service = new ImageGenerationService({ database, getSettings: async () => ({ modelProfiles: [] }) })
cleanups.push(() => service.dispose())
const binding = service.bind(context)
expect(await binding.describe()).toBeUndefined()
expect(await binding.describeSave!()).toContain(image.id)
const provider = new ModelToolProvider(directory)
cleanups.push(() => provider.dispose())
const target = join(directory, 'reopened.png')
await provider.callTool('save_image', { artifactId: image.id, path: target }, new AbortController().signal,
{ conversationId: context.conversationId, imageToolBinding: binding })
expect(await readFile(target)).toEqual(Buffer.from(png, 'base64'))
expect(database.getConversation(context.conversationId).messages[0]).toMatchObject(message)
expect(database.getArtifact(image.id)).toEqual(image)
})
it('filters missing and non-image references and deduplicates before limiting recent images', async () => {
const h = setup()
const generated = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'catalog')
const [upload] = await h.service.persistUploads(h.context, [{ name: 'upload.png', mediaType: 'image/png', data: png }])
h.profile.allowConversationInvocation = false
const text = h.database.createTextArtifact({ title: 'Text', content: 'Not an image' })
const missing = randomUUID()
const images = Array.from({ length: 34 }, (_, index) => h.database.createImageArtifact({ title: `Image ${index}`, mimeType: 'image/png', base64: png }).id)
const readImages = async () => JSON.parse((await h.service.bind(h.context).describeSave!())!.split('Conversation images (oldest first): ')[1]!) as { artifactId: string; kind: string }[]
expect(await readImages()).toEqual(expect.arrayContaining([
{ artifactId: upload, kind: 'upload' }, { artifactId: generated.artifactIds[0], kind: 'generated' }
]))
h.database.saveLocalConversations([{ header: h.header, messages: [h.message,
{ ...h.message, id: randomUUID(), artifactIds: [...images, ...Array<string>(40).fill(images.at(-1)!), text.id, missing] }
] }])
const catalog = await readImages()
expect(catalog.map(image => image.artifactId)).toEqual(images.slice(-32))
expect(catalog.every(image => image.kind === 'artifact')).toBe(true)
const failure = new Error('Database read failed')
const getArtifact = vi.spyOn(h.database, 'getArtifact').mockImplementation(() => { throw failure })
await expect(h.service.bind(h.context).describeSave!()).rejects.toBe(failure)
getArtifact.mockRestore()
})
})