@@ -107,7 +107,7 @@ import { normalizeToolSchema } from "../../../utils/json-schema"
107107import { getMcpServerTools } from "../../../core/prompts/tools/native-tools/mcp_server"
108108import type { McpHub } from "../../../services/mcp/McpHub"
109109import { clearAllMocks } from "../../../test-utils/reset"
110- import { collectStream } from "../../../test-utils/stream"
110+ import { asyncStreamFrom , collectStream } from "../../../test-utils/stream"
111111
112112const mockLanguageModelChat = {
113113 id : "test-model" ,
@@ -141,6 +141,194 @@ describe("VsCodeLmHandler", () => {
141141 handler . dispose ( )
142142 } )
143143
144+ describe ( "image input" , ( ) => {
145+ const image : Anthropic . ImageBlockParam = {
146+ type : "image" ,
147+ source : { type : "base64" , media_type : "image/png" , data : "iVBORw==" } ,
148+ }
149+ const capabilityCases = [
150+ { capabilities : { supportsImageToText : true } , expected : true } ,
151+ { capabilities : { supportsImageToText : false , imageInput : true } , expected : false } ,
152+ { capabilities : { imageInput : true } , expected : true } ,
153+ { capabilities : { imageInput : false } , expected : false } ,
154+ { capabilities : { } , expected : undefined } ,
155+ { capabilities : undefined , expected : undefined } ,
156+ ]
157+
158+ it . each ( capabilityCases ) (
159+ "uses host capabilities $capabilities for model info and listing" ,
160+ async ( { capabilities, expected } ) => {
161+ await handler . initializeClient ( )
162+ const client = { ...mockLanguageModelChat , family : vscodeLlmDefaultModelId , capabilities }
163+ handler [ "client" ] = client
164+ vi . mocked ( vscode . lm . selectChatModels ) . mockResolvedValue ( [ client ] )
165+
166+ expect ( handler . getModel ( ) . info . supportsImages ) . toBe ( expected )
167+ expect ( ( await getVsCodeLmModels ( ) ) [ 0 ] . modelInfo . supportsImages ) . toBe ( expected )
168+ } ,
169+ )
170+
171+ it . each (
172+ ( [ "message" , "tool_result" ] as const ) . flatMap ( ( path ) =>
173+ [ true , false , undefined ] . map ( ( supportsImages ) => ( { path, supportsImages } ) ) ,
174+ ) ,
175+ ) (
176+ "forwards image bytes in the $path path only when supported ($supportsImages)" ,
177+ async ( { path, supportsImages } ) => {
178+ await handler . initializeClient ( )
179+ const sendRequest = vi . fn < vscode . LanguageModelChat [ "sendRequest" ] > ( ) . mockResolvedValue ( {
180+ stream : asyncStreamFrom ( [ new vscode . LanguageModelTextPart ( "done" ) ] ) ,
181+ text : asyncStreamFrom ( [ "done" ] ) ,
182+ } )
183+ const countTokens = vi . fn < vscode . LanguageModelChat [ "countTokens" ] > ( ) . mockResolvedValue ( 37 )
184+ handler [ "client" ] = {
185+ ...mockLanguageModelChat ,
186+ ...{ capabilities : { supportsImageToText : supportsImages } } ,
187+ sendRequest,
188+ countTokens,
189+ }
190+ const messages : Anthropic . Messages . MessageParam [ ] =
191+ path === "message"
192+ ? [ { role : "user" , content : [ { type : "text" , text : "look" } , image ] } ]
193+ : [
194+ {
195+ role : "assistant" ,
196+ content : [ { type : "tool_use" , id : "image-tool" , name : "read_file" , input : { } } ] ,
197+ } ,
198+ {
199+ role : "user" ,
200+ content : [
201+ {
202+ type : "tool_result" ,
203+ tool_use_id : "image-tool" ,
204+ content : [ { type : "text" , text : "look" } , image ] ,
205+ } ,
206+ ] ,
207+ } ,
208+ ]
209+ const chunks = await collectStream ( handler . createMessage ( "system" , messages ) )
210+ const sent = sendRequest . mock . calls [ 0 ] [ 0 ]
211+ expect ( chunks ) . toContainEqual ( { type : "usage" , inputTokens : sent . length * 37 , outputTokens : 37 } )
212+ const counted = countTokens . mock . calls . map ( ( [ content ] ) => content )
213+ if ( supportsImages ) {
214+ expect ( counted ) . toEqual ( expect . arrayContaining ( sent ) )
215+ } else {
216+ expect ( counted . every ( ( content ) => typeof content === "string" ) ) . toBe ( true )
217+ }
218+ const content = sent [ sent . length - 1 ] . content
219+ const parts =
220+ path === "tool_result" && content [ 0 ] instanceof vscode . LanguageModelToolResultPart
221+ ? content [ 0 ] . content
222+ : content
223+ expect ( parts [ 0 ] ) . toMatchObject ( { value : "look" } )
224+ if ( supportsImages ) {
225+ expect ( parts [ 1 ] ) . toMatchObject ( {
226+ mimeType : "image/png" ,
227+ data : Buffer . from ( "iVBORw==" , "base64" ) ,
228+ } )
229+ } else {
230+ expect ( parts [ 1 ] ) . toBeInstanceOf ( vscode . LanguageModelTextPart )
231+ expect ( parts [ 1 ] ) . toMatchObject ( { value : expect . stringContaining ( "not supported" ) } )
232+ }
233+ } ,
234+ )
235+
236+ it . each ( [ "message" , "tool_result" ] as const ) ( "counts native image bytes in %s content" , async ( path ) => {
237+ await handler . initializeClient ( )
238+ const countTokens = vi . fn < vscode . LanguageModelChat [ "countTokens" ] > ( ) . mockResolvedValue ( 321 )
239+ handler [ "client" ] = {
240+ ...mockLanguageModelChat ,
241+ ...{ capabilities : { imageInput : true } } ,
242+ countTokens,
243+ }
244+ const content : Anthropic . Messages . ContentBlockParam [ ] =
245+ path === "message" ? [ image ] : [ { type : "tool_result" , tool_use_id : "image-tool" , content : [ image ] } ]
246+
247+ await expect ( handler . countTokens ( content ) ) . resolves . toBe ( 321 )
248+ const counted = countTokens . mock . calls [ 0 ] [ 0 ]
249+ expect ( counted ) . not . toEqual ( expect . any ( String ) )
250+ if ( typeof counted === "string" ) throw new Error ( "Expected an intact chat message" )
251+ const part = counted . content [ 0 ]
252+ const imagePart = part instanceof vscode . LanguageModelToolResultPart ? part . content [ 0 ] : part
253+ expect ( imagePart ) . toMatchObject ( { mimeType : "image/png" , data : Buffer . from ( "iVBORw==" , "base64" ) } )
254+ } )
255+
256+ const imageCapable = { imageInput : true }
257+ it . each ( [
258+ { capabilities : imageCapable , liveWindow : 100 , curatedWindow : 200 , inputTokens : 100 , accepted : true } ,
259+ { capabilities : imageCapable , liveWindow : 100 , curatedWindow : 200 , inputTokens : 101 , accepted : false } ,
260+ { capabilities : imageCapable , liveWindow : 200 , curatedWindow : 100 , inputTokens : 100 , accepted : true } ,
261+ { capabilities : imageCapable , liveWindow : 200 , curatedWindow : 100 , inputTokens : 101 , accepted : false } ,
262+ { capabilities : { } , liveWindow : 100 , curatedWindow : 200 , inputTokens : 101 , accepted : true } ,
263+ {
264+ capabilities : { supportsImageToText : false } ,
265+ liveWindow : 200 ,
266+ curatedWindow : 100 ,
267+ inputTokens : 101 ,
268+ accepted : true ,
269+ } ,
270+ ] ) ( "checks measured input against both windows only for image models: %j" , async ( fixture ) => {
271+ await handler . initializeClient ( )
272+ const userRole = vscode . LanguageModelChatMessage . User ( "" ) . role
273+ const sendRequest = vi . fn < vscode . LanguageModelChat [ "sendRequest" ] > ( ) . mockResolvedValue ( {
274+ stream : asyncStreamFrom ( [ new vscode . LanguageModelTextPart ( "done" ) ] ) ,
275+ text : asyncStreamFrom ( [ "done" ] ) ,
276+ } )
277+ handler [ "client" ] = {
278+ ...mockLanguageModelChat ,
279+ ...{ capabilities : fixture . capabilities } ,
280+ maxInputTokens : fixture . liveWindow ,
281+ sendRequest,
282+ // Image models count whole messages; text-only models count extracted strings.
283+ countTokens : vi . fn < vscode . LanguageModelChat [ "countTokens" ] > ( ) . mockImplementation ( async ( content ) => {
284+ if ( typeof content === "string" ) {
285+ return fixture . capabilities === imageCapable ? 0 : fixture . inputTokens
286+ }
287+ return content . role === userRole ? fixture . inputTokens : 0
288+ } ) ,
289+ }
290+ const windowSpy = vi . spyOn ( handler , "getCondenseContextWindow" ) . mockReturnValue ( fixture . curatedWindow )
291+ try {
292+ const request = collectStream ( handler . createMessage ( "system" , [ { role : "user" , content : [ image ] } ] ) )
293+ if ( fixture . capabilities !== imageCapable ) {
294+ await expect ( request ) . resolves . toContainEqual ( { type : "text" , text : "done" } )
295+ expect ( sendRequest ) . toHaveBeenCalledTimes ( 1 )
296+ } else if ( fixture . accepted ) {
297+ await expect ( request ) . resolves . toContainEqual ( {
298+ type : "usage" ,
299+ inputTokens : fixture . inputTokens ,
300+ outputTokens : 0 ,
301+ } )
302+ expect ( sendRequest ) . toHaveBeenCalledTimes ( 1 )
303+ } else {
304+ await expect ( request ) . rejects . toSatisfy ( checkContextWindowExceededError )
305+ expect ( sendRequest ) . not . toHaveBeenCalled ( )
306+ }
307+ } finally {
308+ windowSpy . mockRestore ( )
309+ }
310+ } )
311+
312+ it ( "does not advertise image input before a model has been selected" , async ( ) => {
313+ await handler . initializeClient ( )
314+ handler [ "client" ] = null
315+ expect ( handler . getModel ( ) . info . supportsImages ) . toBeUndefined ( )
316+ } )
317+
318+ it ( "disables image support on hosts without image parts, even when the model reports support" , async ( ) => {
319+ await handler . initializeClient ( )
320+ handler [ "client" ] = { ...mockLanguageModelChat , ...{ capabilities : { imageInput : true } } }
321+ const dataPart : { image : unknown } = Reflect . get ( vscode , "LanguageModelDataPart" )
322+ const original = dataPart . image
323+ try {
324+ dataPart . image = undefined
325+ expect ( handler . getModel ( ) . info . supportsImages ) . toBe ( false )
326+ } finally {
327+ dataPart . image = original
328+ }
329+ } )
330+ } )
331+
144332 describe ( "constructor" , ( ) => {
145333 it ( "should initialize with provided options" , ( ) => {
146334 expect ( handler ) . toBeDefined ( )
@@ -4537,11 +4725,17 @@ describe("getVsCodeLmModels", () => {
45374725
45384726 beforeEach ( ( ) => vi . clearAllMocks ( ) )
45394727
4540- it ( "returns the host's models as they are, with no added capability data " , async ( ) => {
4728+ it ( "returns serializable model metadata with host-reported image support " , async ( ) => {
45414729 const model = liveModel ( { capabilities : { supportsImageToText : true } } )
4542- vi . mocked ( vscode . lm . selectChatModels ) . mockResolvedValue ( [ model ] as never )
4543-
4544- await expect ( getVsCodeLmModels ( ) ) . resolves . toEqual ( [ model ] )
4730+ vi . mocked ( vscode . lm . selectChatModels ) . mockResolvedValue ( [ model ] )
4731+
4732+ const models = await getVsCodeLmModels ( )
4733+ expect ( models [ 0 ] ) . toMatchObject ( { id : model . id , family : model . family , modelInfo : { supportsImages : true } } )
4734+ expect ( models [ 0 ] ) . not . toHaveProperty ( "sendRequest" )
4735+ expect ( models [ 0 ] ) . not . toHaveProperty ( "countTokens" )
4736+ expect ( JSON . parse ( JSON . stringify ( models ) ) ) . toMatchObject ( [
4737+ { id : model . id , modelInfo : { supportsImages : true } } ,
4738+ ] )
45454739 } )
45464740
45474741 it ( "asks the host for every model, whatever the vendor" , async ( ) => {
0 commit comments