@@ -27,14 +27,15 @@ describe("isContentWithToolCallsResponse", () => {
2727 expect ( isContentWithToolCallsResponse ( r ) ) . toBe ( false ) ;
2828 } ) ;
2929
30- it ( "existing guards still work for combined response" , ( ) => {
30+ it ( "existing guards are mutually exclusive with combined response" , ( ) => {
3131 const r = {
3232 content : "Hello" ,
3333 toolCalls : [ { name : "get_weather" , arguments : "{}" } ] ,
3434 } ;
35- // Both existing guards would match — that's why we check combined first
36- expect ( isTextResponse ( r ) ) . toBe ( true ) ;
37- expect ( isToolCallResponse ( r ) ) . toBe ( true ) ;
35+ // Guards are mutually exclusive — combined response only matches isContentWithToolCallsResponse
36+ expect ( isTextResponse ( r ) ) . toBe ( false ) ;
37+ expect ( isToolCallResponse ( r ) ) . toBe ( false ) ;
38+ expect ( isContentWithToolCallsResponse ( r ) ) . toBe ( true ) ;
3839 } ) ;
3940} ) ;
4041
@@ -426,6 +427,139 @@ describe("Gemini — content + toolCalls", () => {
426427 } ) ;
427428} ) ;
428429
430+ describe ( "Gemini — multi-tool-call CWTC" , ( ) => {
431+ let mock : LLMock | null = null ;
432+
433+ afterEach ( async ( ) => {
434+ if ( mock ) {
435+ await mock . stop ( ) ;
436+ mock = null ;
437+ }
438+ } ) ;
439+
440+ it ( "Gemini non-streaming multi-tool-call CWTC" , async ( ) => {
441+ mock = new LLMock ( { port : 0 } ) ;
442+ mock . addFixture ( {
443+ match : { userMessage : "test gemini multi-tc" } ,
444+ response : {
445+ content : "Sure, let me check." ,
446+ toolCalls : [
447+ { name : "get_weather" , arguments : '{"city":"NYC"}' } ,
448+ { name : "get_time" , arguments : '{"tz":"EST"}' } ,
449+ ] ,
450+ } ,
451+ } ) ;
452+ await mock . start ( ) ;
453+
454+ const res = await fetch ( `${ mock . url } /v1beta/models/gemini-2.0-flash:generateContent` , {
455+ method : "POST" ,
456+ headers : { "Content-Type" : "application/json" } ,
457+ body : JSON . stringify ( {
458+ contents : [ { role : "user" , parts : [ { text : "test gemini multi-tc" } ] } ] ,
459+ } ) ,
460+ } ) ;
461+
462+ const body = await res . json ( ) ;
463+ const parts = body . candidates [ 0 ] . content . parts ;
464+ const fcParts = parts . filter ( ( p : { functionCall ?: unknown } ) => p . functionCall !== undefined ) ;
465+ expect ( fcParts ) . toHaveLength ( 2 ) ;
466+ expect ( fcParts [ 0 ] . functionCall . name ) . toBe ( "get_weather" ) ;
467+ expect ( fcParts [ 1 ] . functionCall . name ) . toBe ( "get_time" ) ;
468+ } ) ;
469+ } ) ;
470+
471+ describe ( "Anthropic — multi-tool-call CWTC streaming" , ( ) => {
472+ let mock : LLMock | null = null ;
473+
474+ afterEach ( async ( ) => {
475+ if ( mock ) {
476+ await mock . stop ( ) ;
477+ mock = null ;
478+ }
479+ } ) ;
480+
481+ it ( "Claude streaming multi-tool-call CWTC" , async ( ) => {
482+ mock = new LLMock ( { port : 0 } ) ;
483+ mock . addFixture ( {
484+ match : { userMessage : "test claude multi-tc" } ,
485+ response : {
486+ content : "Checking." ,
487+ toolCalls : [
488+ { name : "get_weather" , arguments : '{"city":"NYC"}' } ,
489+ { name : "get_time" , arguments : '{"tz":"EST"}' } ,
490+ ] ,
491+ } ,
492+ } ) ;
493+ await mock . start ( ) ;
494+
495+ const res = await fetch ( `${ mock . url } /v1/messages` , {
496+ method : "POST" ,
497+ headers : {
498+ "Content-Type" : "application/json" ,
499+ "x-api-key" : "test-key" ,
500+ "anthropic-version" : "2023-06-01" ,
501+ } ,
502+ body : JSON . stringify ( {
503+ model : "claude-sonnet-4-20250514" ,
504+ max_tokens : 1024 ,
505+ messages : [ { role : "user" , content : "test claude multi-tc" } ] ,
506+ stream : true ,
507+ } ) ,
508+ } ) ;
509+
510+ const events = parseAnthropicSSEEvents ( await res . text ( ) ) ;
511+ const toolBlockStarts = events . filter (
512+ ( e ) =>
513+ e . type === "content_block_start" &&
514+ ( e . content_block as { type : string } ) ?. type === "tool_use" ,
515+ ) ;
516+ expect ( toolBlockStarts ) . toHaveLength ( 2 ) ;
517+ } ) ;
518+ } ) ;
519+
520+ describe ( "OpenAI — multi-tool-call CWTC streaming indices" , ( ) => {
521+ let mock : LLMock | null = null ;
522+
523+ afterEach ( async ( ) => {
524+ if ( mock ) {
525+ await mock . stop ( ) ;
526+ mock = null ;
527+ }
528+ } ) ;
529+
530+ it ( "streams content then multiple tool calls with correct indices" , async ( ) => {
531+ mock = new LLMock ( { port : 0 } ) ;
532+ mock . addFixture ( {
533+ match : { userMessage : "test multi-tc indices" } ,
534+ response : {
535+ content : "Here." ,
536+ toolCalls : [
537+ { name : "fn_a" , arguments : '{"a":1}' } ,
538+ { name : "fn_b" , arguments : '{"b":2}' } ,
539+ ] ,
540+ } ,
541+ } ) ;
542+ await mock . start ( ) ;
543+
544+ const res = await fetch ( `${ mock . url } /v1/chat/completions` , {
545+ method : "POST" ,
546+ headers : { "Content-Type" : "application/json" , Authorization : "Bearer test" } ,
547+ body : JSON . stringify ( {
548+ model : "gpt-4o" ,
549+ messages : [ { role : "user" , content : "test multi-tc indices" } ] ,
550+ stream : true ,
551+ } ) ,
552+ } ) ;
553+
554+ const chunks = parseSSEChunks ( await res . text ( ) ) ;
555+ const toolChunks = chunks . filter ( ( c ) => c . choices ?. [ 0 ] ?. delta ?. tool_calls ) ;
556+ const indices = toolChunks . map ( ( c ) => c . choices [ 0 ] . delta . tool_calls ! [ 0 ] . index ) ;
557+ // Should have both index 0 and index 1
558+ expect ( indices ) . toContain ( 0 ) ;
559+ expect ( indices ) . toContain ( 1 ) ;
560+ } ) ;
561+ } ) ;
562+
429563import {
430564 collapseOpenAISSE ,
431565 collapseAnthropicSSE ,
0 commit comments