Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/workbench/contrib/chat/test/common/chatImageExtraction.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 { URI } from '../../../../../base/common/uri.js';
9
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js';
10
import { IImageVariableEntry } from '../../common/attachments/chatVariableEntries.js';
11
import { IChatProgressResponseContent } from '../../common/model/chatModel.js';
12
import { IChatRequestViewModel, IChatResponseViewModel } from '../../common/model/chatViewModel.js';
13
import { IChatContentInlineReference, IChatToolInvocationSerialized, IToolResultOutputDetailsSerialized } from '../../common/chatService/chatService.js';
14
import { IToolResultInputOutputDetails } from '../../common/tools/languageModelToolsService.js';
15
import { extractImagesFromChatRequest, extractImagesFromChatResponse, extractImagesFromToolInvocationMessages } from '../../common/chatImageExtraction.js';
16
17
function makeToolInvocation(overrides: Partial<IChatToolInvocationSerialized> = {}): IChatToolInvocationSerialized {
18
return {
19
kind: 'toolInvocationSerialized',
20
toolCallId: 'call_1',
21
toolId: 'test-tool',
22
invocationMessage: 'Running tool',
23
originMessage: undefined,
24
pastTenseMessage: 'Ran tool',
25
isConfirmed: true,
26
isComplete: true,
27
source: undefined,
28
presentation: undefined,
29
resultDetails: undefined,
30
...overrides,
31
};
32
}
33
34
function makeInlineReference(uri: URI, name?: string): IChatContentInlineReference {
35
return {
36
kind: 'inlineReference',
37
inlineReference: uri,
38
name,
39
};
40
}
41
42
function makeResponse(items: ReadonlyArray<IChatProgressResponseContent>, opts: {
43
sessionResource?: URI;
44
requestId?: string;
45
id?: string;
46
requestMessageText?: string;
47
noMatchingRequest?: boolean;
48
} = {}): IChatResponseViewModel {
49
const sessionResource = opts.sessionResource ?? URI.parse('chat-session://test/session');
50
const requestId = opts.requestId ?? 'req-1';
51
const responseId = opts.id ?? 'resp-1';
52
const requestMessageText = opts.requestMessageText ?? 'Show me images';
53
54
return {
55
id: responseId,
56
requestId,
57
sessionResource,
58
response: { value: items },
59
session: {
60
getItems: () => opts.noMatchingRequest ? [] : [{
61
id: requestId,
62
messageText: requestMessageText,
63
message: { parts: [], text: requestMessageText },
64
}],
65
},
66
} as unknown as IChatResponseViewModel;
67
}
68
69
const fakeReadFile = (uri: URI) => Promise.resolve(VSBuffer.fromString(`data-for-${uri.path}`));
70
71
function makeRequest(variables: IChatRequestViewModel['variables'], opts: { id?: string; messageText?: string } = {}): IChatRequestViewModel {
72
return {
73
id: opts.id ?? 'req-1',
74
sessionResource: URI.parse('chat-session://test/session'),
75
dataId: 'data-1',
76
username: 'test-user',
77
message: { text: opts.messageText ?? 'Show me images', parts: [] },
78
messageText: opts.messageText ?? 'Show me images',
79
attempt: 0,
80
variables,
81
currentRenderedHeight: undefined,
82
shouldBeRemovedOnSend: undefined,
83
isComplete: true,
84
isCompleteAddedRequest: true,
85
slashCommand: undefined,
86
agentOrSlashCommandDetected: false,
87
shouldBeBlocked: undefined!,
88
timestamp: 0,
89
} as unknown as IChatRequestViewModel;
90
}
91
92
function makeImageVariableEntry(overrides: Partial<IImageVariableEntry> & Pick<IImageVariableEntry, 'value'>): IImageVariableEntry {
93
const { value, ...rest } = overrides;
94
return {
95
id: 'img-1',
96
kind: 'image',
97
name: 'cat.png',
98
value,
99
mimeType: 'image/png',
100
...rest,
101
};
102
}
103
104
suite('extractImagesFromChatResponse', () => {
105
ensureNoDisposablesAreLeakedInTestSuite();
106
107
test('returns empty images when response has no items', async () => {
108
const response = makeResponse([]);
109
const result = await extractImagesFromChatResponse(response, fakeReadFile);
110
assert.deepStrictEqual(result, {
111
id: response.sessionResource.toString() + '_' + response.id,
112
title: 'Show me images',
113
images: [],
114
});
115
});
116
117
test('uses default title when no matching request is found', async () => {
118
const response = makeResponse([], { noMatchingRequest: true });
119
const result = await extractImagesFromChatResponse(response, fakeReadFile);
120
assert.strictEqual(result.title, 'Images');
121
});
122
123
test('extracts image from tool invocation with IToolResultOutputDetails', async () => {
124
const resultDetails: IToolResultOutputDetailsSerialized = {
125
output: { type: 'data', mimeType: 'image/png', base64Data: 'AQID' },
126
};
127
const toolInvocation = makeToolInvocation({
128
toolCallId: 'call_img',
129
toolId: 'screenshot-tool',
130
pastTenseMessage: 'Took a screenshot',
131
resultDetails,
132
});
133
134
const response = makeResponse([toolInvocation]);
135
const result = await extractImagesFromChatResponse(response, fakeReadFile);
136
137
assert.strictEqual(result.images.length, 1);
138
assert.strictEqual(result.images[0].id, 'call_img_0');
139
assert.strictEqual(result.images[0].mimeType, 'image/png');
140
assert.ok(result.images[0].source.includes('screenshot-tool'));
141
assert.strictEqual(result.images[0].caption, 'Took a screenshot');
142
});
143
144
test('extracts multiple images from tool invocation with IToolResultInputOutputDetails', async () => {
145
const resultDetails: IToolResultInputOutputDetails = {
146
input: '',
147
output: [
148
{ type: 'embed', mimeType: 'image/png', value: 'AQID', isText: false },
149
{ type: 'embed', mimeType: 'text/plain', value: 'text', isText: true },
150
{ type: 'embed', mimeType: 'image/jpeg', value: 'BAUG', isText: false },
151
],
152
};
153
const toolInvocation = makeToolInvocation({
154
toolCallId: 'call_multi',
155
toolId: 'multi-tool',
156
pastTenseMessage: 'Generated images',
157
resultDetails,
158
});
159
160
const response = makeResponse([toolInvocation]);
161
const result = await extractImagesFromChatResponse(response, fakeReadFile);
162
163
assert.strictEqual(result.images.length, 2);
164
assert.strictEqual(result.images[0].id, 'call_multi_0');
165
assert.strictEqual(result.images[0].mimeType, 'image/png');
166
assert.strictEqual(result.images[1].id, 'call_multi_2');
167
assert.strictEqual(result.images[1].mimeType, 'image/jpeg');
168
});
169
170
test('skips tool invocations without image results', async () => {
171
const resultDetails: IToolResultOutputDetailsSerialized = {
172
output: { type: 'data', mimeType: 'text/plain', base64Data: 'aGVsbG8=' },
173
};
174
const toolInvocation = makeToolInvocation({ resultDetails });
175
176
const response = makeResponse([toolInvocation]);
177
const result = await extractImagesFromChatResponse(response, fakeReadFile);
178
assert.strictEqual(result.images.length, 0);
179
});
180
181
test('extracts image from inline reference URI when readFile is provided', async () => {
182
const imageUri = URI.file('/photos/cat.png');
183
const inlineRef = makeInlineReference(imageUri, 'cat.png');
184
185
const response = makeResponse([inlineRef]);
186
const result = await extractImagesFromChatResponse(response, fakeReadFile);
187
188
assert.strictEqual(result.images.length, 1);
189
assert.strictEqual(result.images[0].uri.toString(), imageUri.toString());
190
assert.strictEqual(result.images[0].name, 'cat.png');
191
assert.strictEqual(result.images[0].mimeType, 'image/png');
192
assert.strictEqual(result.images[0].source, 'File');
193
});
194
195
test('extracts image from inline reference Location', async () => {
196
const imageUri = URI.file('/photos/dog.jpg');
197
const inlineRef: IChatContentInlineReference = {
198
kind: 'inlineReference',
199
inlineReference: { uri: imageUri, range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 1 } },
200
};
201
202
const response = makeResponse([inlineRef]);
203
const result = await extractImagesFromChatResponse(response, fakeReadFile);
204
205
assert.strictEqual(result.images.length, 1);
206
assert.strictEqual(result.images[0].uri.toString(), imageUri.toString());
207
});
208
209
test('skips non-image inline references', async () => {
210
const codeUri = URI.file('/src/main.ts');
211
const inlineRef = makeInlineReference(codeUri);
212
213
const response = makeResponse([inlineRef]);
214
const result = await extractImagesFromChatResponse(response, fakeReadFile);
215
assert.strictEqual(result.images.length, 0);
216
});
217
218
test('uses filename from URI path when name is not provided', async () => {
219
const imageUri = URI.file('/assets/banner.gif');
220
const inlineRef = makeInlineReference(imageUri);
221
222
const response = makeResponse([inlineRef]);
223
const result = await extractImagesFromChatResponse(response, fakeReadFile);
224
225
assert.strictEqual(result.images.length, 1);
226
assert.strictEqual(result.images[0].name, 'banner.gif');
227
});
228
229
test('preserves interleaved order of tool and inline reference images', async () => {
230
const toolInvocation = makeToolInvocation({
231
toolCallId: 'call_first',
232
toolId: 'tool-1',
233
resultDetails: {
234
output: { type: 'data', mimeType: 'image/png', base64Data: 'AQID' },
235
} satisfies IToolResultOutputDetailsSerialized,
236
});
237
238
const inlineRef = makeInlineReference(URI.file('/middle.png'), 'middle.png');
239
240
const toolInvocation2 = makeToolInvocation({
241
toolCallId: 'call_last',
242
toolId: 'tool-2',
243
resultDetails: {
244
output: { type: 'data', mimeType: 'image/jpeg', base64Data: 'BAUG' },
245
} satisfies IToolResultOutputDetailsSerialized,
246
});
247
248
const response = makeResponse([toolInvocation, inlineRef, toolInvocation2]);
249
const result = await extractImagesFromChatResponse(response, fakeReadFile);
250
251
assert.strictEqual(result.images.length, 3);
252
assert.strictEqual(result.images[0].id, 'call_first_0');
253
assert.strictEqual(result.images[1].name, 'middle.png');
254
assert.strictEqual(result.images[2].id, 'call_last_0');
255
});
256
257
test('collection id combines sessionResource and response id', async () => {
258
const sessionResource = URI.parse('chat-session://test/my-session');
259
const response = makeResponse([], { sessionResource, id: 'response-42' });
260
const result = await extractImagesFromChatResponse(response, fakeReadFile);
261
assert.strictEqual(result.id, sessionResource.toString() + '_response-42');
262
});
263
264
test('skips inline reference when readFile fails', async () => {
265
const imageUri = URI.file('/photos/missing.png');
266
const inlineRef = makeInlineReference(imageUri, 'missing.png');
267
const failingReadFile = (_uri: URI) => Promise.reject(new Error('File not found'));
268
269
const response = makeResponse([inlineRef]);
270
const result = await extractImagesFromChatResponse(response, failingReadFile);
271
assert.strictEqual(result.images.length, 0);
272
});
273
274
test('extracts images from tool invocation message URIs', async () => {
275
const imageUri = URI.file('/screenshots/result.png');
276
const toolInvocation = makeToolInvocation({
277
toolCallId: 'call_msg',
278
toolId: 'screenshot-tool',
279
pastTenseMessage: { value: 'Took a screenshot', isTrusted: false, uris: { '0': imageUri.toJSON() } },
280
});
281
282
const response = makeResponse([toolInvocation]);
283
const result = await extractImagesFromChatResponse(response, fakeReadFile);
284
285
assert.strictEqual(result.images.length, 1);
286
assert.strictEqual(result.images[0].uri.toString(), imageUri.toString());
287
assert.strictEqual(result.images[0].name, 'result.png');
288
assert.strictEqual(result.images[0].mimeType, 'image/png');
289
assert.strictEqual(result.images[0].caption, 'Took a screenshot');
290
});
291
292
test('combines output details images and message URI images', async () => {
293
const imageUri = URI.file('/screenshots/msg-image.jpg');
294
const resultDetails: IToolResultOutputDetailsSerialized = {
295
output: { type: 'data', mimeType: 'image/png', base64Data: 'AQID' },
296
};
297
const toolInvocation = makeToolInvocation({
298
toolCallId: 'call_both',
299
toolId: 'combo-tool',
300
pastTenseMessage: { value: 'Ran combo tool', isTrusted: false, uris: { '0': imageUri.toJSON() } },
301
resultDetails,
302
});
303
304
const response = makeResponse([toolInvocation]);
305
const result = await extractImagesFromChatResponse(response, fakeReadFile);
306
307
assert.strictEqual(result.images.length, 2);
308
assert.strictEqual(result.images[0].id, 'call_both_0');
309
assert.strictEqual(result.images[1].uri.toString(), imageUri.toString());
310
});
311
});
312
313
suite('extractImagesFromToolInvocationMessages', () => {
314
ensureNoDisposablesAreLeakedInTestSuite();
315
316
test('returns empty when message is undefined', async () => {
317
const toolInvocation = makeToolInvocation({
318
pastTenseMessage: undefined,
319
invocationMessage: undefined,
320
});
321
const result = await extractImagesFromToolInvocationMessages(toolInvocation, fakeReadFile);
322
assert.deepStrictEqual(result, []);
323
});
324
325
test('returns empty when message is a string', async () => {
326
const toolInvocation = makeToolInvocation({
327
pastTenseMessage: 'some string message',
328
});
329
const result = await extractImagesFromToolInvocationMessages(toolInvocation, fakeReadFile);
330
assert.deepStrictEqual(result, []);
331
});
332
333
test('returns empty when message has no uris', async () => {
334
const toolInvocation = makeToolInvocation({
335
pastTenseMessage: { value: 'No URIs here', isTrusted: false },
336
});
337
const result = await extractImagesFromToolInvocationMessages(toolInvocation, fakeReadFile);
338
assert.deepStrictEqual(result, []);
339
});
340
341
test('returns empty when message uris are empty', async () => {
342
const toolInvocation = makeToolInvocation({
343
pastTenseMessage: { value: 'Empty URIs', isTrusted: false, uris: {} },
344
});
345
const result = await extractImagesFromToolInvocationMessages(toolInvocation, fakeReadFile);
346
assert.deepStrictEqual(result, []);
347
});
348
349
test('skips non-image URIs', async () => {
350
const toolInvocation = makeToolInvocation({
351
pastTenseMessage: { value: 'Code file', isTrusted: false, uris: { '0': URI.file('/src/main.ts').toJSON() } },
352
});
353
const result = await extractImagesFromToolInvocationMessages(toolInvocation, fakeReadFile);
354
assert.deepStrictEqual(result, []);
355
});
356
357
test('extracts image from message URI', async () => {
358
const imageUri = URI.file('/screenshots/capture.png');
359
const toolInvocation = makeToolInvocation({
360
toolCallId: 'call_uri',
361
toolId: 'screenshot-tool',
362
pastTenseMessage: { value: 'Captured screenshot', isTrusted: false, uris: { '0': imageUri.toJSON() } },
363
});
364
365
const result = await extractImagesFromToolInvocationMessages(toolInvocation, fakeReadFile);
366
367
assert.strictEqual(result.length, 1);
368
assert.strictEqual(result[0].uri.toString(), imageUri.toString());
369
assert.strictEqual(result[0].name, 'capture.png');
370
assert.strictEqual(result[0].mimeType, 'image/png');
371
assert.strictEqual(result[0].caption, 'Captured screenshot');
372
assert.ok(result[0].source.includes('screenshot-tool'));
373
});
374
375
test('extracts multiple images from message URIs', async () => {
376
const uri1 = URI.file('/img/a.png');
377
const uri2 = URI.file('/img/b.jpg');
378
const toolInvocation = makeToolInvocation({
379
pastTenseMessage: {
380
value: 'Generated images',
381
isTrusted: false,
382
uris: { '0': uri1.toJSON(), '1': uri2.toJSON() },
383
},
384
});
385
386
const result = await extractImagesFromToolInvocationMessages(toolInvocation, fakeReadFile);
387
388
assert.strictEqual(result.length, 2);
389
assert.strictEqual(result[0].mimeType, 'image/png');
390
assert.strictEqual(result[1].mimeType, 'image/jpg');
391
});
392
393
test('continues when readFile fails for one URI', async () => {
394
const goodUri = URI.file('/img/good.png');
395
const badUri = URI.file('/img/bad.png');
396
const failingReadFile = (uri: URI) => {
397
if (uri.path.includes('bad')) {
398
return Promise.reject(new Error('File not found'));
399
}
400
return Promise.resolve(VSBuffer.fromString('image-data'));
401
};
402
const toolInvocation = makeToolInvocation({
403
pastTenseMessage: {
404
value: 'Mixed results',
405
isTrusted: false,
406
uris: { '0': badUri.toJSON(), '1': goodUri.toJSON() },
407
},
408
});
409
410
const result = await extractImagesFromToolInvocationMessages(toolInvocation, failingReadFile);
411
412
assert.strictEqual(result.length, 1);
413
assert.strictEqual(result[0].uri.toString(), goodUri.toString());
414
});
415
416
test('falls back to invocationMessage when pastTenseMessage is undefined', async () => {
417
const imageUri = URI.file('/img/fallback.png');
418
const toolInvocation = makeToolInvocation({
419
pastTenseMessage: undefined,
420
invocationMessage: { value: 'Running tool', isTrusted: false, uris: { '0': imageUri.toJSON() } },
421
});
422
423
const result = await extractImagesFromToolInvocationMessages(toolInvocation, fakeReadFile);
424
425
assert.strictEqual(result.length, 1);
426
assert.strictEqual(result[0].caption, 'Running tool');
427
});
428
});
429
430
suite('extractImagesFromChatRequest', () => {
431
ensureNoDisposablesAreLeakedInTestSuite();
432
433
test('extracts image attachment from Uint8Array', () => {
434
const request = makeRequest([
435
makeImageVariableEntry({ value: new Uint8Array([1, 2, 3]) }),
436
]);
437
438
const result = extractImagesFromChatRequest(request);
439
440
assert.strictEqual(result.length, 1);
441
assert.strictEqual(result[0].name, 'cat.png');
442
assert.strictEqual(result[0].mimeType, 'image/png');
443
assert.deepStrictEqual([...result[0].data.buffer], [1, 2, 3]);
444
});
445
446
test('extracts image attachment from ArrayBuffer', () => {
447
const request = makeRequest([
448
makeImageVariableEntry({ value: new Uint8Array([4, 5, 6]).buffer }),
449
]);
450
451
const result = extractImagesFromChatRequest(request);
452
453
assert.strictEqual(result.length, 1);
454
assert.deepStrictEqual([...result[0].data.buffer], [4, 5, 6]);
455
});
456
457
test('extracts restored image attachment from plain object bytes', () => {
458
const request = makeRequest([
459
makeImageVariableEntry({ value: { 0: 7, 1: 8, 2: 9 } }),
460
]);
461
462
const result = extractImagesFromChatRequest(request);
463
464
assert.strictEqual(result.length, 1);
465
assert.deepStrictEqual([...result[0].data.buffer], [7, 8, 9]);
466
});
467
468
test('extracts restored image attachment from reordered plain object bytes', () => {
469
const request = makeRequest([
470
makeImageVariableEntry({ value: { 2: 9, 0: 7, 1: 8 } }),
471
]);
472
473
const result = extractImagesFromChatRequest(request);
474
475
assert.strictEqual(result.length, 1);
476
assert.deepStrictEqual([...result[0].data.buffer], [7, 8, 9]);
477
});
478
479
test('uses attachment resource URI when available', () => {
480
const uri = URI.file('/tmp/cat.png');
481
const request = makeRequest([
482
makeImageVariableEntry({ value: new Uint8Array([1]), references: [{ kind: 'reference', reference: uri }] }),
483
]);
484
485
const result = extractImagesFromChatRequest(request);
486
487
assert.strictEqual(result.length, 1);
488
assert.strictEqual(result[0].uri.toString(), uri.toString());
489
});
490
});
491
492