Files
goodbuddy/src/main/agent/image-generation-service.test.ts
T
mesalogo dd29f309f1 feat: add save_image tool and list image MCP tools
Expose save_image across direct model, MCP gateway, DeepSeek Harness and remote Agents (written on the remote host via chunked reads), and show the image MCP tool list in settings.
2026-10-02 10:08:42 +08:00

285 lines
19 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(), workMode: 'execute' as const }
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 service = new ImageGenerationService({ database, 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, service, context, fetcher, settings, profile, events, header, message }
}
describe('Main conversation image service', () => {
it('does not let a destroyed Renderer or usage observer 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: () => { throw new Error('Usage observer failed') }, onError
})
const operation = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'observer')
expect(operation.state).toBe('completed')
expect(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 }] })
})
const operation = await h.service.bind(h.context).call({ intent: 'create', prompt: 'Blue' }, 'disk-full')
expect(operation).toMatchObject({ state: 'failed', error: 'Disk full', artifactIds: [] })
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(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 = 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 and rechecks current mode', async () => {
const h = setup()
let mode: 'ask' | 'execute' = 'execute'
const binding = h.service.bind(h.context, () => mode)
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')
mode = 'ask'
await expect(binding.call({ intent: 'create', prompt: 'Blue', modelProfileId: h.profile.id }, 'ask')).rejects.toThrow('Execute')
expect(h.fetcher).not.toHaveBeenCalled()
})
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))
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 = 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 pending
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))
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 and denies Ask', async () => {
const h = setup()
const provider = new ModelToolProvider(process.cwd())
cleanups.push(() => provider.dispose())
const context = { conversationId: h.context.conversationId, workMode: 'execute' as const, 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, { ...context, workMode: 'ask' })).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')
await expect(h.service.bind(h.context, () => 'ask').save!({ artifactId, path: join(dir, 'ask.png') })).rejects.toThrow('Execute')
const provider = new ModelToolProvider(process.cwd())
cleanups.push(() => provider.dispose())
const context = { conversationId: h.context.conversationId, workMode: 'execute' as const, 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, 'ask2.png') }, new AbortController().signal, { ...context, workMode: 'ask' })).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 [uploadId] = 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, () => 'ask').describeSave!()).toBeUndefined()
})
})