Path: blob/main/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts
13406 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import assert from 'assert';6import { VSBuffer } from '../../../../../base/common/buffer.js';7import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';8import { URI } from '../../../../../base/common/uri.js';9import { buildCollectionArgs, buildSingleImageArgs, collectCarouselSections, findClickedImageIndex, ICarouselSection } from '../../browser/chatImageCarouselService.js';10import { IChatToolInvocationSerialized } from '../../common/chatService/chatService.js';11import { ChatResponseResource } from '../../common/model/chatModel.js';12import { IImageVariableEntry } from '../../common/attachments/chatVariableEntries.js';13import { IChatRequestViewModel, IChatResponseViewModel } from '../../common/model/chatViewModel.js';14import { ToolDataSource } from '../../common/tools/languageModelToolsService.js';1516suite('ChatImageCarouselService helpers', () => {17ensureNoDisposablesAreLeakedInTestSuite();1819function makeRequest(id: string, variables: IChatRequestViewModel['variables'], messageText: string = 'Request'): IChatRequestViewModel {20return {21id,22sessionResource: URI.parse('chat-session://test/session'),23dataId: `data-${id}`,24username: 'test-user',25message: { text: messageText, parts: [] },26messageText,27attempt: 0,28variables,29currentRenderedHeight: undefined,30shouldBeRemovedOnSend: undefined,31isComplete: true,32isCompleteAddedRequest: true,33slashCommand: undefined,34agentOrSlashCommandDetected: false,35shouldBeBlocked: undefined!,36timestamp: 0,37} as unknown as IChatRequestViewModel;38}3940function makeResponse(requestId: string, id: string = 'resp-1', responseValue: IChatResponseViewModel['response']['value'] = []): IChatResponseViewModel {41return {42id,43requestId,44sessionResource: URI.parse('chat-session://test/session'),45response: { value: responseValue },46session: { getItems: () => [] },47setVote: () => { },48} as unknown as IChatResponseViewModel;49}5051function makeImageVariableEntry(overrides: Partial<IImageVariableEntry> & Pick<IImageVariableEntry, 'value'>): IImageVariableEntry {52const { value, ...rest } = overrides;53return {54id: 'img-1',55kind: 'image',56name: 'cat.png',57value,58mimeType: 'image/png',59...rest,60};61}6263function makeImage(id: string, name: string = 'img.png', mimeType: string = 'image/png'): { id: string; name: string; mimeType: string; data: Uint8Array } {64return { id, name, mimeType, data: new Uint8Array([1, 2, 3]) };65}6667function makeSections(...imageCounts: number[]): ICarouselSection[] {68return imageCounts.map((count, sectionIdx) => ({69title: `Section ${sectionIdx}`,70images: Array.from({ length: count }, (_, imgIdx) =>71makeImage(URI.file(`/image_s${sectionIdx}_i${imgIdx}.png`).toString(), `image_s${sectionIdx}_i${imgIdx}.png`)72),73}));74}7576suite('findClickedImageIndex', () => {7778test('finds image by URI string match in first section', () => {79const sections = makeSections(3);80const targetUri = URI.parse(sections[0].images[1].id);81assert.strictEqual(findClickedImageIndex(sections, targetUri), 1);82});8384test('finds image by URI string match in second section', () => {85const sections = makeSections(2, 3);86const targetUri = URI.parse(sections[1].images[2].id);87// globalOffset = 2 (first section) + 2 (third in second section) = 488assert.strictEqual(findClickedImageIndex(sections, targetUri), 4);89});9091test('returns -1 when no match found', () => {92const sections = makeSections(2, 2);93const unknownUri = URI.file('/nonexistent.png');94assert.strictEqual(findClickedImageIndex(sections, unknownUri), -1);95});9697test('falls back to data buffer match', () => {98const sections: ICarouselSection[] = [{99title: 'Section',100images: [101{ id: 'custom-id-1', name: 'a.png', mimeType: 'image/png', data: new Uint8Array([10, 20]) },102{ id: 'custom-id-2', name: 'b.png', mimeType: 'image/png', data: new Uint8Array([30, 40]) },103],104}];105const unknownUri = URI.from({ scheme: 'data', path: 'b.png' });106assert.strictEqual(findClickedImageIndex(sections, unknownUri, new Uint8Array([30, 40])), 1);107});108109test('prefers a later exact URI match over an earlier image with identical data', () => {110const firstUri = URI.parse('vscode-chat-response-resource://session/tool-call-1/0/file.png');111const secondUri = URI.parse('vscode-chat-response-resource://session/tool-call-2/0/file.png');112const identicalData = new Uint8Array([10, 20, 30]);113const sections: ICarouselSection[] = [114{115title: 'Earlier',116images: [117{ id: firstUri.toString(), name: 'first.png', mimeType: 'image/png', data: identicalData },118],119},120{121title: 'Later',122images: [123{ id: secondUri.toString(), name: 'second.png', mimeType: 'image/png', data: identicalData },124],125},126];127128assert.strictEqual(findClickedImageIndex(sections, secondUri, identicalData), 1);129});130131test('returns -1 for empty sections', () => {132assert.strictEqual(findClickedImageIndex([], URI.file('/x.png')), -1);133});134});135136suite('buildCollectionArgs', () => {137138test('uses section title when single section', () => {139const sections = makeSections(2);140const result = buildCollectionArgs(sections, 0, URI.file('/session'));141assert.deepStrictEqual(result, {142collection: {143id: URI.file('/session').toString() + '_carousel',144title: 'Section 0',145sections,146},147startIndex: 0,148});149});150151test('uses generic title for multiple sections', () => {152const sections = makeSections(1, 1);153const result = buildCollectionArgs(sections, 1, URI.file('/session'));154assert.strictEqual(result.collection.title, 'Conversation Images');155assert.strictEqual(result.startIndex, 1);156});157158test('falls back to default title when single section has empty title', () => {159const sections: ICarouselSection[] = [{160title: '',161images: [makeImage(URI.file('/img.png').toString())],162}];163const result = buildCollectionArgs(sections, 0, URI.file('/session'));164assert.strictEqual(result.collection.title, 'Conversation Images');165});166});167168suite('buildSingleImageArgs', () => {169170test('extracts name and mime from URI path', () => {171const uri = URI.file('/path/to/photo.jpg');172const data = new Uint8Array([1, 2, 3]);173assert.deepStrictEqual(buildSingleImageArgs(uri, data), {174name: 'photo.jpg',175mimeType: 'image/jpg',176data,177title: 'photo.jpg',178});179});180181test('defaults mime to image/png for unknown extension', () => {182const uri = URI.file('/path/to/file.xyz');183const data = new Uint8Array([1]);184assert.strictEqual(buildSingleImageArgs(uri, data).mimeType, 'image/png');185});186});187188suite('collectCarouselSections', () => {189190test('collects request attachment images for pending requests', async () => {191const request = makeRequest('req-1', [192makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),193], 'Pending request');194195const result = await collectCarouselSections([request], async () => new Uint8Array());196197assert.strictEqual(result.length, 1);198assert.strictEqual(result[0].title, 'Pending request');199assert.strictEqual(result[0].images.length, 1);200assert.deepStrictEqual({201id: result[0].images[0].id,202name: result[0].images[0].name,203mimeType: result[0].images[0].mimeType,204data: [...result[0].images[0].data],205}, {206id: URI.from({ scheme: 'data', path: 'img-1/cat.png' }).toString(),207name: 'cat.png',208mimeType: 'image/png',209data: [1, 2, 3],210});211});212213test('collects request attachment images restored as plain objects', async () => {214const request = makeRequest('req-1', [215makeImageVariableEntry({ value: { 0: 4, 1: 5, 2: 6 } }),216], 'Pending request');217218const result = await collectCarouselSections([request], async () => new Uint8Array());219220assert.deepStrictEqual([...result[0].images[0].data], [4, 5, 6]);221});222223test('merges request images into matching response section', async () => {224const request = makeRequest('req-1', [225makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),226], 'Show me images');227const response = makeResponse('req-1');228229const result = await collectCarouselSections([request, response], async uri => VSBuffer.fromString(`data-for-${uri.path}`).buffer);230231assert.strictEqual(result.length, 1);232assert.strictEqual(result[0].title, 'Show me images');233assert.strictEqual(result[0].images.length, 1);234assert.strictEqual(result[0].images[0].name, 'cat.png');235});236237test('prefers paired request message text over extracted response title', async () => {238const request = makeRequest('req-1', [239makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),240], 'Request title wins');241const response = makeResponse('req-1');242243const result = await collectCarouselSections([request, response], async () => new Uint8Array());244245assert.strictEqual(result.length, 1);246assert.strictEqual(result[0].title, 'Request title wins');247});248249test('does not duplicate request images when response exists', async () => {250const request = makeRequest('req-1', [251makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),252], 'Show me images');253const response = makeResponse('req-1');254255const result = await collectCarouselSections([request, response], async () => new Uint8Array());256257assert.strictEqual(result.length, 1);258assert.strictEqual(result[0].images.length, 1);259});260261test('deduplicates consecutive images with the same URI', async () => {262const uri = URI.file('/screenshot.png');263const request = makeRequest('req-1', [264makeImageVariableEntry({265value: new Uint8Array([1, 2, 3]),266references: [{ reference: uri, kind: 'reference' }],267}),268makeImageVariableEntry({269id: 'img-2',270value: new Uint8Array([1, 2, 3]),271references: [{ reference: uri, kind: 'reference' }],272}),273], 'Two same images');274const response = makeResponse('req-1');275276const result = await collectCarouselSections([request, response], async () => new Uint8Array());277278assert.strictEqual(result.length, 1);279assert.strictEqual(result[0].images.length, 1);280});281282test('keeps non-consecutive images with the same URI', async () => {283const uri = URI.file('/screenshot.png');284const otherUri = URI.file('/other.png');285const request = makeRequest('req-1', [286makeImageVariableEntry({287value: new Uint8Array([1, 2, 3]),288references: [{ reference: uri, kind: 'reference' }],289}),290makeImageVariableEntry({291id: 'img-2',292name: 'other.png',293value: new Uint8Array([4, 5, 6]),294references: [{ reference: otherUri, kind: 'reference' }],295}),296makeImageVariableEntry({297id: 'img-3',298value: new Uint8Array([1, 2, 3]),299references: [{ reference: uri, kind: 'reference' }],300}),301], 'Non-consecutive duplicates');302const response = makeResponse('req-1');303304const result = await collectCarouselSections([request, response], async () => new Uint8Array());305306assert.strictEqual(result.length, 1);307assert.strictEqual(result[0].images.length, 3);308});309310test('uses tool image URIs as carousel image ids', async () => {311const request = makeRequest('req-1', [], 'Request with tool output image');312const toolCallId = 'tool-call-1';313const sessionResource = URI.parse('chat-session://test/session');314const expectedUri = ChatResponseResource.createUri(sessionResource, toolCallId, 0, 'file.png').toString();315const response = makeResponse('req-1', 'resp-1', [316{317kind: 'toolInvocationSerialized',318toolId: 'test_tool',319toolCallId,320invocationMessage: 'Took screenshot',321originMessage: undefined,322pastTenseMessage: undefined,323presentation: undefined,324resultDetails: {325output: {326type: 'data',327mimeType: 'image/png',328base64Data: 'AQID'329}330},331isConfirmed: { type: 0 },332isComplete: true,333source: ToolDataSource.Internal,334generatedTitle: undefined,335isAttachedToThinking: false,336} as unknown as IChatToolInvocationSerialized,337]);338339const result = await collectCarouselSections([request, response], async () => new Uint8Array());340341assert.strictEqual(result.length, 1);342assert.strictEqual(result[0].images.length, 1);343assert.strictEqual(result[0].images[0].id, expectedUri);344});345346test('image data is a plain Uint8Array usable by Blob constructor', async () => {347const request = makeRequest('req-1', [348makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),349], 'Screenshot request');350const response = makeResponse('req-1');351352const result = await collectCarouselSections([request, response], async () => new Uint8Array());353354assert.strictEqual(result.length, 1);355const data = result[0].images[0].data;356// data must be a Uint8Array (not VSBuffer or ArrayBuffer) so that357// new Blob([data]) in the carousel editor works correctly.358assert.ok(data instanceof Uint8Array, 'image data should be Uint8Array');359assert.deepStrictEqual([...data], [1, 2, 3]);360});361});362363});364365366