@@ -414,6 +414,81 @@ describe("VsCodeLmHandler", () => {
414414 ] )
415415 } )
416416
417+ it ( "discards buffered text and calls on stream failure but preserves earlier text" , async ( ) => {
418+ const streamError = new Error ( "Stream failed" )
419+ mockLanguageModelChat . sendRequest . mockResolvedValueOnce ( {
420+ stream : ( async function * ( ) {
421+ yield new vscode . LanguageModelTextPart ( "Thinking. " )
422+ yield new vscode . LanguageModelTextPart (
423+ '<function_calls><invoke name="calculator"><parameter name="operation">add</parameter></invoke></function_calls>' ,
424+ )
425+ yield new vscode . LanguageModelTextPart ( "Buffered narration." )
426+ throw streamError
427+ } ) ( ) ,
428+ } )
429+
430+ const stream = handler . createMessage ( "system" , [ { role : "user" , content : "hi" } ] , {
431+ taskId : "test-task" ,
432+ tools : salvageTools ,
433+ } )
434+
435+ await expect ( stream . next ( ) ) . resolves . toEqual ( {
436+ done : false ,
437+ value : { type : "text" , text : "Thinking. " } ,
438+ } )
439+ await expect ( stream . next ( ) ) . rejects . toBe ( streamError )
440+ await expect ( stream . next ( ) ) . resolves . toEqual ( { done : true , value : undefined } )
441+ } )
442+
443+ it ( "discards buffered text and calls when the stream is cancelled" , async ( ) => {
444+ mockLanguageModelChat . sendRequest . mockResolvedValueOnce ( {
445+ stream : ( async function * ( ) {
446+ yield new vscode . LanguageModelTextPart (
447+ '<function_calls><invoke name="calculator"><parameter name="operation">add</parameter></invoke></function_calls>' ,
448+ )
449+ yield new vscode . LanguageModelTextPart ( "Buffered narration." )
450+ throw new vscode . CancellationError ( )
451+ } ) ( ) ,
452+ } )
453+
454+ const stream = handler . createMessage ( "system" , [ { role : "user" , content : "hi" } ] , {
455+ taskId : "test-task" ,
456+ tools : salvageTools ,
457+ } )
458+
459+ await expect ( stream . next ( ) ) . rejects . toThrow (
460+ new Error ( "Zoo Code <Language Model API>: Request cancelled by user" ) ,
461+ )
462+ await expect ( stream . next ( ) ) . resolves . toEqual ( { done : true , value : undefined } )
463+ } )
464+
465+ it ( "yields a native tool call before a subsequent stream failure" , async ( ) => {
466+ const streamError = new Error ( "Stream failed after native call" )
467+ mockLanguageModelChat . sendRequest . mockResolvedValueOnce ( {
468+ stream : ( async function * ( ) {
469+ yield new vscode . LanguageModelToolCallPart ( "native-1" , "calculator" , { operation : "add" } )
470+ throw streamError
471+ } ) ( ) ,
472+ } )
473+
474+ const stream = handler . createMessage ( "system" , [ { role : "user" , content : "hi" } ] , {
475+ taskId : "test-task" ,
476+ tools : salvageTools ,
477+ } )
478+
479+ await expect ( stream . next ( ) ) . resolves . toEqual ( {
480+ done : false ,
481+ value : {
482+ type : "tool_call" ,
483+ id : "native-1" ,
484+ name : "calculator" ,
485+ arguments : '{"operation":"add"}' ,
486+ } ,
487+ } )
488+ await expect ( stream . next ( ) ) . rejects . toBe ( streamError )
489+ await expect ( stream . next ( ) ) . resolves . toEqual ( { done : true , value : undefined } )
490+ } )
491+
417492 it ( "recovers a null-only declared parameter as JSON null through createMessage" , async ( ) => {
418493 mockLanguageModelChat . sendRequest . mockResolvedValueOnce ( {
419494 stream : ( async function * ( ) {
@@ -586,20 +661,20 @@ describe("VsCodeLmHandler", () => {
586661 expect ( streamedText ) . toBe ( parts . join ( "" ) )
587662 } )
588663
589- it ( "keeps a narrated block quoted when the cap splits the stream after its close tag" , async ( ) => {
590- // Cutting the drained span at `</invoke>` hid the trailing cue, so the streaming path
591- // recovered a call the one-shot path refuses.
664+ it . each ( [ "" , "\n" ] ) ( "preserves a narrated block and later text with line ending %j" , async ( lineEnding ) => {
665+ // The trailing cue must remain visible when deciding whether the block is quoted.
592666 const block = `<function${ "_calls" } ><in${ "voke" } name="calculator"><parameter name="operation">add</parameter></in${ "voke" } >`
593667 const narration = ` is what you must never emit`
594- const chunks = await collect ( [ block + narration , "x" . repeat ( 270 * 1024 ) ] )
668+ const filler = "x" . repeat ( 270 * 1024 )
669+ const chunks = await collect ( [ block + narration + lineEnding , filler ] )
595670
596671 expect ( chunks . filter ( ( chunk ) => chunk . type === "tool_call" ) ) . toEqual ( [ ] )
597672 expect (
598- extractLeakedToolCalls (
599- block + narration ,
600- new Map ( [ [ "calculator" , salvageTools [ 0 ] . function . parameters ] ] ) ,
601- ) . calls ,
602- ) . toEqual ( [ ] )
673+ chunks
674+ . filter ( ( chunk ) => chunk . type === "text" )
675+ . map ( ( chunk ) => chunk . text )
676+ . join ( "" ) ,
677+ ) . toBe ( block + narration + lineEnding + filler )
603678 } )
604679
605680 it . each ( [
0 commit comments