Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/src/extension/byok/common/test/geminiMessageConverter.spec.ts
13405 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 { Raw } from '@vscode/prompt-tsx';
7
import { describe, expect, it } from 'vitest';
8
import type { LanguageModelChatMessage } from 'vscode';
9
import { CustomDataPartMimeTypes } from '../../../../platform/endpoint/common/endpointTypes';
10
import { LanguageModelChatMessageRole, LanguageModelDataPart, LanguageModelTextPart, LanguageModelToolResultPart, LanguageModelTextPart as LMText } from '../../../../vscodeTypes';
11
import { apiMessageToGeminiMessage } from '../geminiMessageConverter';
12
13
describe('GeminiMessageConverter', () => {
14
it('should convert basic user and assistant messages', () => {
15
const messages: LanguageModelChatMessage[] = [
16
{
17
role: LanguageModelChatMessageRole.User,
18
content: [new LanguageModelTextPart('Hello, how are you?')],
19
name: undefined
20
},
21
{
22
role: LanguageModelChatMessageRole.Assistant,
23
content: [new LanguageModelTextPart('I am doing well, thank you!')],
24
name: undefined
25
}
26
];
27
28
const result = apiMessageToGeminiMessage(messages);
29
30
expect(result.contents).toHaveLength(2);
31
expect(result.contents[0].role).toBe('user');
32
expect(result.contents[0].parts).toBeDefined();
33
expect(result.contents[0].parts![0].text).toBe('Hello, how are you?');
34
expect(result.contents[1].role).toBe('model');
35
expect(result.contents[1].parts).toBeDefined();
36
expect(result.contents[1].parts![0].text).toBe('I am doing well, thank you!');
37
});
38
39
it('should handle system messages as system instruction', () => {
40
const messages: LanguageModelChatMessage[] = [
41
{
42
role: LanguageModelChatMessageRole.System,
43
content: [new LanguageModelTextPart('You are a helpful assistant.')],
44
name: undefined
45
},
46
{
47
role: LanguageModelChatMessageRole.User,
48
content: [new LanguageModelTextPart('Hello!')],
49
name: undefined
50
}
51
];
52
53
const result = apiMessageToGeminiMessage(messages);
54
55
expect(result.systemInstruction).toBeDefined();
56
expect(result.systemInstruction!.parts).toBeDefined();
57
expect(result.systemInstruction!.parts![0].text).toBe('You are a helpful assistant.');
58
expect(result.contents).toHaveLength(1);
59
expect(result.contents[0].role).toBe('user');
60
});
61
62
it('should filter out empty text parts', () => {
63
const messages: LanguageModelChatMessage[] = [
64
{
65
role: LanguageModelChatMessageRole.User,
66
content: [
67
new LanguageModelTextPart(''),
68
new LanguageModelTextPart(' '),
69
new LanguageModelTextPart('Hello!')
70
],
71
name: undefined
72
}
73
];
74
75
const result = apiMessageToGeminiMessage(messages);
76
77
expect(result.contents[0].parts).toBeDefined();
78
expect(result.contents[0].parts!).toHaveLength(2); // Empty string filtered out, whitespace kept
79
expect(result.contents[0].parts![0].text).toBe(' ');
80
expect(result.contents[0].parts![1].text).toBe('Hello!');
81
});
82
83
it('should extract functionResponse parts from model message into subsequent user message and prune empty model', () => {
84
// Simulate a model message that (incorrectly) contains only a tool result part
85
const toolResult = new LanguageModelToolResultPart('myTool_12345', [new LanguageModelTextPart('{"foo":"bar"}')]);
86
const messages: LanguageModelChatMessage[] = [
87
{
88
role: LanguageModelChatMessageRole.Assistant,
89
content: [toolResult],
90
name: undefined
91
}
92
];
93
94
const { contents } = apiMessageToGeminiMessage(messages);
95
96
// The original (empty) model message should be pruned; we expect a single user message with functionResponse
97
expect(contents).toHaveLength(1);
98
expect(contents[0].role).toBe('user');
99
expect(contents[0].parts![0]).toHaveProperty('functionResponse');
100
const fr: any = contents[0].parts![0];
101
expect(fr.functionResponse.name).toBe('myTool'); // extracted from callId prefix
102
expect(fr.functionResponse.response).toEqual({ foo: 'bar' });
103
});
104
105
it('should wrap array responses in an object', () => {
106
const toolResult = new LanguageModelToolResultPart('listRepos_12345', [new LanguageModelTextPart('["repo1", "repo2"]')]);
107
const messages: LanguageModelChatMessage[] = [
108
{
109
role: LanguageModelChatMessageRole.Assistant,
110
content: [toolResult],
111
name: undefined
112
}
113
];
114
115
const result = apiMessageToGeminiMessage(messages);
116
117
expect(result.contents).toHaveLength(1);
118
expect(result.contents[0].role).toBe('user');
119
const fr: any = result.contents[0].parts![0];
120
expect(fr.functionResponse.response).toEqual({ result: ['repo1', 'repo2'] });
121
});
122
123
it('should be idempotent when called multiple times (no duplication)', () => {
124
const toolResult = new LanguageModelToolResultPart('doThing_12345', [new LMText('{"value":42}')]);
125
const messages: LanguageModelChatMessage[] = [
126
{ role: LanguageModelChatMessageRole.Assistant, content: [new LMText('Result:'), toolResult], name: undefined }
127
];
128
const first = apiMessageToGeminiMessage(messages);
129
const second = apiMessageToGeminiMessage(messages); // Re-run with same original messages
130
131
// Both runs should yield identical normalized structure (model text + user tool response) without growth
132
expect(first.contents.length).toBe(2);
133
expect(second.contents.length).toBe(2);
134
expect(first.contents[0].role).toBe('model');
135
expect(first.contents[1].role).toBe('user');
136
expect(second.contents[0].role).toBe('model');
137
expect(second.contents[1].role).toBe('user');
138
});
139
140
describe('Image handling', () => {
141
it('should handle LanguageModelDataPart as inline image data', () => {
142
const imageData = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); // PNG header
143
const imagePart = new LanguageModelDataPart(imageData, 'image/png');
144
145
const messages: LanguageModelChatMessage[] = [
146
{
147
role: LanguageModelChatMessageRole.User,
148
content: [new LanguageModelTextPart('Here is an image:'), imagePart as any],
149
name: undefined
150
}
151
];
152
153
const result = apiMessageToGeminiMessage(messages);
154
155
expect(result.contents).toHaveLength(1);
156
expect(result.contents[0].parts).toHaveLength(2);
157
expect(result.contents[0].parts![0].text).toBe('Here is an image:');
158
expect(result.contents[0].parts![1]).toHaveProperty('inlineData');
159
const inlineData: any = result.contents[0].parts![1];
160
expect(inlineData.inlineData.mimeType).toBe('image/png');
161
expect(inlineData.inlineData.data).toBe(Buffer.from(imageData).toString('base64'));
162
});
163
164
it('should filter out StatefulMarker and CacheControl data parts', () => {
165
const imageData = new Uint8Array([137, 80, 78, 71]);
166
const validImage = new LanguageModelDataPart(imageData, 'image/jpeg');
167
const statefulMarker = new LanguageModelDataPart(new Uint8Array([1, 2, 3]), CustomDataPartMimeTypes.StatefulMarker);
168
const cacheControl = new LanguageModelDataPart(new TextEncoder().encode('ephemeral'), CustomDataPartMimeTypes.CacheControl);
169
170
const messages: LanguageModelChatMessage[] = [
171
{
172
role: LanguageModelChatMessageRole.User,
173
content: [validImage as any, statefulMarker as any, cacheControl as any],
174
name: undefined
175
}
176
];
177
178
const result = apiMessageToGeminiMessage(messages);
179
180
// Should only include the valid image, not the stateful marker or cache control
181
expect(result.contents[0].parts).toHaveLength(1);
182
expect(result.contents[0].parts![0]).toHaveProperty('inlineData');
183
const inlineData: any = result.contents[0].parts![0];
184
expect(inlineData.inlineData.mimeType).toBe('image/jpeg');
185
});
186
187
it('should handle images in tool result content with text', () => {
188
const imageData = new Uint8Array([255, 216, 255, 224]); // JPEG header
189
const imagePart = new LanguageModelDataPart(imageData, 'image/jpeg');
190
const textPart = new LanguageModelTextPart('{"success": true}');
191
192
const toolResult = new LanguageModelToolResultPart('processImage_12345', [textPart, imagePart as any]);
193
const messages: LanguageModelChatMessage[] = [
194
{
195
role: LanguageModelChatMessageRole.Assistant,
196
content: [toolResult],
197
name: undefined
198
}
199
];
200
201
const result = apiMessageToGeminiMessage(messages);
202
203
// Should have a user message with function response
204
expect(result.contents).toHaveLength(1);
205
expect(result.contents[0].role).toBe('user');
206
expect(result.contents[0].parts![0]).toHaveProperty('functionResponse');
207
208
const fr: any = result.contents[0].parts![0];
209
expect(fr.functionResponse.name).toBe('processImage');
210
expect(fr.functionResponse.response.success).toBe(true);
211
expect(fr.functionResponse.response.images).toBeDefined();
212
expect(fr.functionResponse.response.images).toHaveLength(1);
213
expect(fr.functionResponse.response.images[0].mimeType).toBe('image/jpeg');
214
expect(fr.functionResponse.response.images[0].size).toBe(imageData.length);
215
});
216
217
it('should handle images in tool result content without text', () => {
218
const imageData1 = new Uint8Array([255, 216, 255, 224]); // JPEG header
219
const imageData2 = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]); // PNG header
220
const imagePart1 = new LanguageModelDataPart(imageData1, 'image/jpeg');
221
const imagePart2 = new LanguageModelDataPart(imageData2, 'image/png');
222
223
const toolResult = new LanguageModelToolResultPart('generateImages_12345', [imagePart1 as any, imagePart2 as any]);
224
const messages: LanguageModelChatMessage[] = [
225
{
226
role: LanguageModelChatMessageRole.Assistant,
227
content: [toolResult],
228
name: undefined
229
}
230
];
231
232
const result = apiMessageToGeminiMessage(messages);
233
234
expect(result.contents).toHaveLength(1);
235
expect(result.contents[0].role).toBe('user');
236
237
const fr: any = result.contents[0].parts![0];
238
expect(fr.functionResponse.name).toBe('generateImages');
239
expect(fr.functionResponse.response.images).toHaveLength(2);
240
241
// First image
242
expect(fr.functionResponse.response.images[0].mimeType).toBe('image/jpeg');
243
expect(fr.functionResponse.response.images[0].size).toBe(imageData1.length);
244
expect(fr.functionResponse.response.images[0].data).toBe(Buffer.from(imageData1).toString('base64'));
245
246
// Second image
247
expect(fr.functionResponse.response.images[1].mimeType).toBe('image/png');
248
expect(fr.functionResponse.response.images[1].size).toBe(imageData2.length);
249
expect(fr.functionResponse.response.images[1].data).toBe(Buffer.from(imageData2).toString('base64'));
250
});
251
252
it('should handle mixed text and filtered data parts in tool results', () => {
253
const validImageData = new Uint8Array([255, 216]);
254
const validImage = new LanguageModelDataPart(validImageData, 'image/jpeg');
255
const statefulMarker = new LanguageModelDataPart(new Uint8Array([1, 2, 3]), CustomDataPartMimeTypes.StatefulMarker);
256
const textPart = new LanguageModelTextPart('Result text');
257
258
const toolResult = new LanguageModelToolResultPart('mixedContent_12345', [textPart, validImage as any, statefulMarker as any]);
259
const messages: LanguageModelChatMessage[] = [
260
{
261
role: LanguageModelChatMessageRole.Assistant,
262
content: [toolResult],
263
name: undefined
264
}
265
];
266
267
const result = apiMessageToGeminiMessage(messages);
268
269
const fr: any = result.contents[0].parts![0];
270
expect(fr.functionResponse.name).toBe('mixedContent');
271
// Should include text and valid image, but not stateful marker
272
expect(fr.functionResponse.response.result).toContain('Result text');
273
expect(fr.functionResponse.response.result).toContain('[Contains 1 image(s) with types: image/jpeg]');
274
expect(fr.functionResponse.response.images).toHaveLength(1);
275
expect(fr.functionResponse.response.images[0].mimeType).toBe('image/jpeg');
276
});
277
});
278
279
describe('geminiMessagesToRawMessages', () => {
280
it('should convert function response with images to Raw format with image content parts', async () => {
281
const { geminiMessagesToRawMessages } = await import('../geminiMessageConverter');
282
283
// Simulate a Gemini Content with function response containing images
284
const contents = [{
285
role: 'user',
286
parts: [{
287
functionResponse: {
288
name: 'generateImages',
289
response: {
290
success: true,
291
images: [
292
{
293
mimeType: 'image/jpeg',
294
size: 1024,
295
data: 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='
296
},
297
{
298
mimeType: 'image/png',
299
size: 512,
300
data: '/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwODxAPDgwTExQUExMcGxsbHB8fHx8fHx8fHx//2wBDAQcHBw0MDRgQEBgaFREVGh8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx//wAARCAABAAEDAREAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k='
301
}
302
]
303
}
304
}
305
}]
306
}];
307
308
const rawMessages = geminiMessagesToRawMessages(contents);
309
310
expect(rawMessages).toHaveLength(1);
311
// Check the role - should be Raw.ChatRole.Tool enum value
312
expect(rawMessages[0].role).toBe(Raw.ChatRole.Tool);
313
314
// Type assertion for tool message
315
const toolMessage = rawMessages[0] as any;
316
expect(toolMessage.toolCallId).toBe('generateImages');
317
expect(rawMessages[0].content).toHaveLength(3); // 2 images + 1 text part
318
319
// Check first image
320
expect(rawMessages[0].content[0].type).toBe(Raw.ChatCompletionContentPartKind.Image);
321
const firstImage = rawMessages[0].content[0] as any;
322
expect(firstImage.imageUrl?.url).toBe('data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==');
323
324
// Check second image
325
expect(rawMessages[0].content[1].type).toBe(Raw.ChatCompletionContentPartKind.Image);
326
const secondImage = rawMessages[0].content[1] as any;
327
expect(secondImage.imageUrl?.url).toBe('data:image/png;base64,/9j/4AAQSkZJRgABAQEAYABgAAD/2wBDAAYEBAQFBAYFBQYJBgUGCQsIBgYICwwKCgsKCgwQDAwMDAwMEAwODxAPDgwTExQUExMcGxsbHB8fHx8fHx8fHx//2wBDAQcHBw0MDRgQEBgaFREVGh8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx8fHx//wAARCAABAAEDAREAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAv/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIRAxEAPwCdABmX/9k=');
328
329
// Check text content with cleaned response
330
expect(rawMessages[0].content[2].type).toBe(Raw.ChatCompletionContentPartKind.Text);
331
const textPart = rawMessages[0].content[2] as any;
332
const textContent = JSON.parse(textPart.text);
333
expect(textContent.success).toBe(true);
334
expect(textContent.images).toHaveLength(2);
335
expect(textContent.images[0].mimeType).toBe('image/jpeg');
336
expect(textContent.images[0].size).toBe(1024);
337
expect(textContent.images[1].mimeType).toBe('image/png');
338
expect(textContent.images[1].size).toBe(512);
339
// Should not contain raw base64 data in text content
340
expect(textContent.images[0]).not.toHaveProperty('data');
341
expect(textContent.images[1]).not.toHaveProperty('data');
342
});
343
344
it('should handle function response without images normally', async () => {
345
const { geminiMessagesToRawMessages } = await import('../geminiMessageConverter');
346
347
const contents = [{
348
role: 'user',
349
parts: [{
350
functionResponse: {
351
name: 'textFunction',
352
response: { result: 'success', value: 42 }
353
}
354
}]
355
}];
356
357
const rawMessages = geminiMessagesToRawMessages(contents);
358
359
expect(rawMessages).toHaveLength(1);
360
expect(rawMessages[0].role).toBe(Raw.ChatRole.Tool);
361
expect(rawMessages[0].content).toHaveLength(1);
362
expect(rawMessages[0].content[0].type).toBe(Raw.ChatCompletionContentPartKind.Text);
363
const textPart = rawMessages[0].content[0] as any;
364
expect(JSON.parse(textPart.text)).toEqual({ result: 'success', value: 42 });
365
});
366
});
367
});
368