Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/test/browser/chatImageCarouselService.test.ts
13406 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import assert from 'assert';
7
import { VSBuffer } from '../../../../../base/common/buffer.js';
8
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
9
import { URI } from '../../../../../base/common/uri.js';
10
import { buildCollectionArgs, buildSingleImageArgs, collectCarouselSections, findClickedImageIndex, ICarouselSection } from '../../browser/chatImageCarouselService.js';
11
import { IChatToolInvocationSerialized } from '../../common/chatService/chatService.js';
12
import { ChatResponseResource } from '../../common/model/chatModel.js';
13
import { IImageVariableEntry } from '../../common/attachments/chatVariableEntries.js';
14
import { IChatRequestViewModel, IChatResponseViewModel } from '../../common/model/chatViewModel.js';
15
import { ToolDataSource } from '../../common/tools/languageModelToolsService.js';
16
17
suite('ChatImageCarouselService helpers', () => {
18
ensureNoDisposablesAreLeakedInTestSuite();
19
20
function makeRequest(id: string, variables: IChatRequestViewModel['variables'], messageText: string = 'Request'): IChatRequestViewModel {
21
return {
22
id,
23
sessionResource: URI.parse('chat-session://test/session'),
24
dataId: `data-${id}`,
25
username: 'test-user',
26
message: { text: messageText, parts: [] },
27
messageText,
28
attempt: 0,
29
variables,
30
currentRenderedHeight: undefined,
31
shouldBeRemovedOnSend: undefined,
32
isComplete: true,
33
isCompleteAddedRequest: true,
34
slashCommand: undefined,
35
agentOrSlashCommandDetected: false,
36
shouldBeBlocked: undefined!,
37
timestamp: 0,
38
} as unknown as IChatRequestViewModel;
39
}
40
41
function makeResponse(requestId: string, id: string = 'resp-1', responseValue: IChatResponseViewModel['response']['value'] = []): IChatResponseViewModel {
42
return {
43
id,
44
requestId,
45
sessionResource: URI.parse('chat-session://test/session'),
46
response: { value: responseValue },
47
session: { getItems: () => [] },
48
setVote: () => { },
49
} as unknown as IChatResponseViewModel;
50
}
51
52
function makeImageVariableEntry(overrides: Partial<IImageVariableEntry> & Pick<IImageVariableEntry, 'value'>): IImageVariableEntry {
53
const { value, ...rest } = overrides;
54
return {
55
id: 'img-1',
56
kind: 'image',
57
name: 'cat.png',
58
value,
59
mimeType: 'image/png',
60
...rest,
61
};
62
}
63
64
function makeImage(id: string, name: string = 'img.png', mimeType: string = 'image/png'): { id: string; name: string; mimeType: string; data: Uint8Array } {
65
return { id, name, mimeType, data: new Uint8Array([1, 2, 3]) };
66
}
67
68
function makeSections(...imageCounts: number[]): ICarouselSection[] {
69
return imageCounts.map((count, sectionIdx) => ({
70
title: `Section ${sectionIdx}`,
71
images: Array.from({ length: count }, (_, imgIdx) =>
72
makeImage(URI.file(`/image_s${sectionIdx}_i${imgIdx}.png`).toString(), `image_s${sectionIdx}_i${imgIdx}.png`)
73
),
74
}));
75
}
76
77
suite('findClickedImageIndex', () => {
78
79
test('finds image by URI string match in first section', () => {
80
const sections = makeSections(3);
81
const targetUri = URI.parse(sections[0].images[1].id);
82
assert.strictEqual(findClickedImageIndex(sections, targetUri), 1);
83
});
84
85
test('finds image by URI string match in second section', () => {
86
const sections = makeSections(2, 3);
87
const targetUri = URI.parse(sections[1].images[2].id);
88
// globalOffset = 2 (first section) + 2 (third in second section) = 4
89
assert.strictEqual(findClickedImageIndex(sections, targetUri), 4);
90
});
91
92
test('returns -1 when no match found', () => {
93
const sections = makeSections(2, 2);
94
const unknownUri = URI.file('/nonexistent.png');
95
assert.strictEqual(findClickedImageIndex(sections, unknownUri), -1);
96
});
97
98
test('falls back to data buffer match', () => {
99
const sections: ICarouselSection[] = [{
100
title: 'Section',
101
images: [
102
{ id: 'custom-id-1', name: 'a.png', mimeType: 'image/png', data: new Uint8Array([10, 20]) },
103
{ id: 'custom-id-2', name: 'b.png', mimeType: 'image/png', data: new Uint8Array([30, 40]) },
104
],
105
}];
106
const unknownUri = URI.from({ scheme: 'data', path: 'b.png' });
107
assert.strictEqual(findClickedImageIndex(sections, unknownUri, new Uint8Array([30, 40])), 1);
108
});
109
110
test('prefers a later exact URI match over an earlier image with identical data', () => {
111
const firstUri = URI.parse('vscode-chat-response-resource://session/tool-call-1/0/file.png');
112
const secondUri = URI.parse('vscode-chat-response-resource://session/tool-call-2/0/file.png');
113
const identicalData = new Uint8Array([10, 20, 30]);
114
const sections: ICarouselSection[] = [
115
{
116
title: 'Earlier',
117
images: [
118
{ id: firstUri.toString(), name: 'first.png', mimeType: 'image/png', data: identicalData },
119
],
120
},
121
{
122
title: 'Later',
123
images: [
124
{ id: secondUri.toString(), name: 'second.png', mimeType: 'image/png', data: identicalData },
125
],
126
},
127
];
128
129
assert.strictEqual(findClickedImageIndex(sections, secondUri, identicalData), 1);
130
});
131
132
test('returns -1 for empty sections', () => {
133
assert.strictEqual(findClickedImageIndex([], URI.file('/x.png')), -1);
134
});
135
});
136
137
suite('buildCollectionArgs', () => {
138
139
test('uses section title when single section', () => {
140
const sections = makeSections(2);
141
const result = buildCollectionArgs(sections, 0, URI.file('/session'));
142
assert.deepStrictEqual(result, {
143
collection: {
144
id: URI.file('/session').toString() + '_carousel',
145
title: 'Section 0',
146
sections,
147
},
148
startIndex: 0,
149
});
150
});
151
152
test('uses generic title for multiple sections', () => {
153
const sections = makeSections(1, 1);
154
const result = buildCollectionArgs(sections, 1, URI.file('/session'));
155
assert.strictEqual(result.collection.title, 'Conversation Images');
156
assert.strictEqual(result.startIndex, 1);
157
});
158
159
test('falls back to default title when single section has empty title', () => {
160
const sections: ICarouselSection[] = [{
161
title: '',
162
images: [makeImage(URI.file('/img.png').toString())],
163
}];
164
const result = buildCollectionArgs(sections, 0, URI.file('/session'));
165
assert.strictEqual(result.collection.title, 'Conversation Images');
166
});
167
});
168
169
suite('buildSingleImageArgs', () => {
170
171
test('extracts name and mime from URI path', () => {
172
const uri = URI.file('/path/to/photo.jpg');
173
const data = new Uint8Array([1, 2, 3]);
174
assert.deepStrictEqual(buildSingleImageArgs(uri, data), {
175
name: 'photo.jpg',
176
mimeType: 'image/jpg',
177
data,
178
title: 'photo.jpg',
179
});
180
});
181
182
test('defaults mime to image/png for unknown extension', () => {
183
const uri = URI.file('/path/to/file.xyz');
184
const data = new Uint8Array([1]);
185
assert.strictEqual(buildSingleImageArgs(uri, data).mimeType, 'image/png');
186
});
187
});
188
189
suite('collectCarouselSections', () => {
190
191
test('collects request attachment images for pending requests', async () => {
192
const request = makeRequest('req-1', [
193
makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),
194
], 'Pending request');
195
196
const result = await collectCarouselSections([request], async () => new Uint8Array());
197
198
assert.strictEqual(result.length, 1);
199
assert.strictEqual(result[0].title, 'Pending request');
200
assert.strictEqual(result[0].images.length, 1);
201
assert.deepStrictEqual({
202
id: result[0].images[0].id,
203
name: result[0].images[0].name,
204
mimeType: result[0].images[0].mimeType,
205
data: [...result[0].images[0].data],
206
}, {
207
id: URI.from({ scheme: 'data', path: 'img-1/cat.png' }).toString(),
208
name: 'cat.png',
209
mimeType: 'image/png',
210
data: [1, 2, 3],
211
});
212
});
213
214
test('collects request attachment images restored as plain objects', async () => {
215
const request = makeRequest('req-1', [
216
makeImageVariableEntry({ value: { 0: 4, 1: 5, 2: 6 } }),
217
], 'Pending request');
218
219
const result = await collectCarouselSections([request], async () => new Uint8Array());
220
221
assert.deepStrictEqual([...result[0].images[0].data], [4, 5, 6]);
222
});
223
224
test('merges request images into matching response section', async () => {
225
const request = makeRequest('req-1', [
226
makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),
227
], 'Show me images');
228
const response = makeResponse('req-1');
229
230
const result = await collectCarouselSections([request, response], async uri => VSBuffer.fromString(`data-for-${uri.path}`).buffer);
231
232
assert.strictEqual(result.length, 1);
233
assert.strictEqual(result[0].title, 'Show me images');
234
assert.strictEqual(result[0].images.length, 1);
235
assert.strictEqual(result[0].images[0].name, 'cat.png');
236
});
237
238
test('prefers paired request message text over extracted response title', async () => {
239
const request = makeRequest('req-1', [
240
makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),
241
], 'Request title wins');
242
const response = makeResponse('req-1');
243
244
const result = await collectCarouselSections([request, response], async () => new Uint8Array());
245
246
assert.strictEqual(result.length, 1);
247
assert.strictEqual(result[0].title, 'Request title wins');
248
});
249
250
test('does not duplicate request images when response exists', async () => {
251
const request = makeRequest('req-1', [
252
makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),
253
], 'Show me images');
254
const response = makeResponse('req-1');
255
256
const result = await collectCarouselSections([request, response], async () => new Uint8Array());
257
258
assert.strictEqual(result.length, 1);
259
assert.strictEqual(result[0].images.length, 1);
260
});
261
262
test('deduplicates consecutive images with the same URI', async () => {
263
const uri = URI.file('/screenshot.png');
264
const request = makeRequest('req-1', [
265
makeImageVariableEntry({
266
value: new Uint8Array([1, 2, 3]),
267
references: [{ reference: uri, kind: 'reference' }],
268
}),
269
makeImageVariableEntry({
270
id: 'img-2',
271
value: new Uint8Array([1, 2, 3]),
272
references: [{ reference: uri, kind: 'reference' }],
273
}),
274
], 'Two same images');
275
const response = makeResponse('req-1');
276
277
const result = await collectCarouselSections([request, response], async () => new Uint8Array());
278
279
assert.strictEqual(result.length, 1);
280
assert.strictEqual(result[0].images.length, 1);
281
});
282
283
test('keeps non-consecutive images with the same URI', async () => {
284
const uri = URI.file('/screenshot.png');
285
const otherUri = URI.file('/other.png');
286
const request = makeRequest('req-1', [
287
makeImageVariableEntry({
288
value: new Uint8Array([1, 2, 3]),
289
references: [{ reference: uri, kind: 'reference' }],
290
}),
291
makeImageVariableEntry({
292
id: 'img-2',
293
name: 'other.png',
294
value: new Uint8Array([4, 5, 6]),
295
references: [{ reference: otherUri, kind: 'reference' }],
296
}),
297
makeImageVariableEntry({
298
id: 'img-3',
299
value: new Uint8Array([1, 2, 3]),
300
references: [{ reference: uri, kind: 'reference' }],
301
}),
302
], 'Non-consecutive duplicates');
303
const response = makeResponse('req-1');
304
305
const result = await collectCarouselSections([request, response], async () => new Uint8Array());
306
307
assert.strictEqual(result.length, 1);
308
assert.strictEqual(result[0].images.length, 3);
309
});
310
311
test('uses tool image URIs as carousel image ids', async () => {
312
const request = makeRequest('req-1', [], 'Request with tool output image');
313
const toolCallId = 'tool-call-1';
314
const sessionResource = URI.parse('chat-session://test/session');
315
const expectedUri = ChatResponseResource.createUri(sessionResource, toolCallId, 0, 'file.png').toString();
316
const response = makeResponse('req-1', 'resp-1', [
317
{
318
kind: 'toolInvocationSerialized',
319
toolId: 'test_tool',
320
toolCallId,
321
invocationMessage: 'Took screenshot',
322
originMessage: undefined,
323
pastTenseMessage: undefined,
324
presentation: undefined,
325
resultDetails: {
326
output: {
327
type: 'data',
328
mimeType: 'image/png',
329
base64Data: 'AQID'
330
}
331
},
332
isConfirmed: { type: 0 },
333
isComplete: true,
334
source: ToolDataSource.Internal,
335
generatedTitle: undefined,
336
isAttachedToThinking: false,
337
} as unknown as IChatToolInvocationSerialized,
338
]);
339
340
const result = await collectCarouselSections([request, response], async () => new Uint8Array());
341
342
assert.strictEqual(result.length, 1);
343
assert.strictEqual(result[0].images.length, 1);
344
assert.strictEqual(result[0].images[0].id, expectedUri);
345
});
346
347
test('image data is a plain Uint8Array usable by Blob constructor', async () => {
348
const request = makeRequest('req-1', [
349
makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),
350
], 'Screenshot request');
351
const response = makeResponse('req-1');
352
353
const result = await collectCarouselSections([request, response], async () => new Uint8Array());
354
355
assert.strictEqual(result.length, 1);
356
const data = result[0].images[0].data;
357
// data must be a Uint8Array (not VSBuffer or ArrayBuffer) so that
358
// new Blob([data]) in the carousel editor works correctly.
359
assert.ok(data instanceof Uint8Array, 'image data should be Uint8Array');
360
assert.deepStrictEqual([...data], [1, 2, 3]);
361
});
362
});
363
364
});
365
366