11import { describe , it , expect , afterEach } from "vitest" ;
22import * as http from "node:http" ;
33import { crc32 } from "node:zlib" ;
4- import type { Fixture , HandlerDefaults } from "../types.js" ;
4+ import type { Fixture } from "../types.js" ;
55import { createServer , type ServerInstance } from "../server.js" ;
66import {
77 converseToCompletionRequest ,
88 handleConverse ,
99 handleConverseStream ,
1010} from "../bedrock-converse.js" ;
1111import { Journal } from "../journal.js" ;
12- import { Logger } from "../logger .js" ;
12+ import { createMockReq , createMockRes , createDefaults } from "./helpers/mock-res .js" ;
1313
1414// --- helpers ---
1515
@@ -161,6 +161,13 @@ function postPartialBinary(
161161 const parsed = new URL ( url ) ;
162162 const chunks : Buffer [ ] = [ ] ;
163163 let aborted = false ;
164+ let resolved = false ;
165+ const safeResolve = ( value : { body : Buffer ; aborted : boolean } ) => {
166+ if ( ! resolved ) {
167+ resolved = true ;
168+ resolve ( value ) ;
169+ }
170+ } ;
164171 const req = http . request (
165172 {
166173 hostname : parsed . hostname ,
@@ -175,7 +182,7 @@ function postPartialBinary(
175182 ( res ) => {
176183 res . on ( "data" , ( c : Buffer ) => chunks . push ( c ) ) ;
177184 res . on ( "end" , ( ) => {
178- resolve ( { body : Buffer . concat ( chunks ) , aborted } ) ;
185+ safeResolve ( { body : Buffer . concat ( chunks ) , aborted } ) ;
179186 } ) ;
180187 res . on ( "error" , ( ) => {
181188 aborted = true ;
@@ -184,13 +191,13 @@ function postPartialBinary(
184191 aborted = true ;
185192 } ) ;
186193 res . on ( "close" , ( ) => {
187- resolve ( { body : Buffer . concat ( chunks ) , aborted } ) ;
194+ safeResolve ( { body : Buffer . concat ( chunks ) , aborted } ) ;
188195 } ) ;
189196 } ,
190197 ) ;
191198 req . on ( "error" , ( ) => {
192199 aborted = true ;
193- resolve ( { body : Buffer . concat ( chunks ) , aborted } ) ;
200+ safeResolve ( { body : Buffer . concat ( chunks ) , aborted } ) ;
194201 } ) ;
195202 req . write ( data ) ;
196203 req . end ( ) ;
@@ -775,7 +782,7 @@ describe("POST /model/{modelId}/converse-stream", () => {
775782 expect ( fullText ) . toBe ( "Hi there!" ) ;
776783
777784 const msgStop = frames . find ( ( f ) => f . eventType === "messageStop" ) ;
778- expect ( msgStop ! . payload ) . toEqual ( { stopReason : "end_turn" } ) ;
785+ expect ( msgStop ! . payload ) . toEqual ( { messageStop : { stopReason : "end_turn" } } ) ;
779786 } ) ;
780787
781788 it ( "returns tool call response as Event Stream" , async ( ) => {
@@ -810,7 +817,7 @@ describe("POST /model/{modelId}/converse-stream", () => {
810817 expect ( JSON . parse ( fullJson ) ) . toEqual ( { city : "SF" } ) ;
811818
812819 const msgStop = frames . find ( ( f ) => f . eventType === "messageStop" ) ;
813- expect ( msgStop ! . payload ) . toEqual ( { stopReason : "tool_use" } ) ;
820+ expect ( msgStop ! . payload ) . toEqual ( { messageStop : { stopReason : "tool_use" } } ) ;
814821 } ) ;
815822
816823 it ( "supports streaming profile (ttft/tps)" , async ( ) => {
@@ -946,7 +953,240 @@ describe("POST /model/{modelId}/converse-stream (content + toolCalls)", () => {
946953
947954 // messageStop with tool_use stop reason
948955 const msgStop = frames . find ( ( f ) => f . eventType === "messageStop" ) ;
949- expect ( msgStop ! . payload ) . toEqual ( { stopReason : "tool_use" } ) ;
956+ expect ( msgStop ! . payload ) . toEqual ( { messageStop : { stopReason : "tool_use" } } ) ;
957+ } ) ;
958+ } ) ;
959+
960+ // ─── converse-stream: contentBlockStop wrapper shape ──────────────────────────
961+
962+ describe ( "POST /model/{modelId}/converse-stream (contentBlockStop wrapper shape)" , ( ) => {
963+ const MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0" ;
964+
965+ it ( "contentBlockStop events have wrapped { contentBlockStop: { contentBlockIndex: N } } shape" , async ( ) => {
966+ instance = await createServer ( allFixtures ) ;
967+ const res = await postBinary ( `${ instance . url } /model/${ MODEL_ID } /converse-stream` , {
968+ messages : [ { role : "user" , content : [ { text : "hello" } ] } ] ,
969+ } ) ;
970+
971+ expect ( res . status ) . toBe ( 200 ) ;
972+ const frames = parseFrames ( res . body ) ;
973+
974+ const stopFrames = frames . filter ( ( f ) => f . eventType === "contentBlockStop" ) ;
975+ expect ( stopFrames . length ) . toBeGreaterThanOrEqual ( 1 ) ;
976+
977+ for ( const frame of stopFrames ) {
978+ // Must be the wrapped shape, not the flat { contentBlockIndex: N }
979+ const payload = frame . payload as { contentBlockStop : { contentBlockIndex : number } } ;
980+ expect ( payload ) . toHaveProperty ( "contentBlockStop" ) ;
981+ expect ( payload . contentBlockStop ) . toHaveProperty ( "contentBlockIndex" ) ;
982+ expect ( typeof payload . contentBlockStop . contentBlockIndex ) . toBe ( "number" ) ;
983+ // Must NOT have a top-level contentBlockIndex (that would be the flat shape)
984+ expect ( Object . keys ( payload ) ) . toEqual ( [ "contentBlockStop" ] ) ;
985+ }
986+ } ) ;
987+
988+ it ( "tool-call contentBlockStop events also have the wrapped shape" , async ( ) => {
989+ instance = await createServer ( allFixtures ) ;
990+ const res = await postBinary ( `${ instance . url } /model/${ MODEL_ID } /converse-stream` , {
991+ messages : [ { role : "user" , content : [ { text : "weather" } ] } ] ,
992+ } ) ;
993+
994+ expect ( res . status ) . toBe ( 200 ) ;
995+ const frames = parseFrames ( res . body ) ;
996+
997+ const stopFrames = frames . filter ( ( f ) => f . eventType === "contentBlockStop" ) ;
998+ expect ( stopFrames . length ) . toBeGreaterThanOrEqual ( 1 ) ;
999+
1000+ for ( const frame of stopFrames ) {
1001+ const payload = frame . payload as { contentBlockStop : { contentBlockIndex : number } } ;
1002+ expect ( payload ) . toHaveProperty ( "contentBlockStop" ) ;
1003+ expect ( payload . contentBlockStop ) . toHaveProperty ( "contentBlockIndex" ) ;
1004+ expect ( Object . keys ( payload ) ) . toEqual ( [ "contentBlockStop" ] ) ;
1005+ }
1006+ } ) ;
1007+
1008+ it ( "messageStop events have the wrapped { messageStop: { stopReason: '...' } } shape" , async ( ) => {
1009+ instance = await createServer ( allFixtures ) ;
1010+ const res = await postBinary ( `${ instance . url } /model/${ MODEL_ID } /converse-stream` , {
1011+ messages : [ { role : "user" , content : [ { text : "hello" } ] } ] ,
1012+ } ) ;
1013+
1014+ expect ( res . status ) . toBe ( 200 ) ;
1015+ const frames = parseFrames ( res . body ) ;
1016+
1017+ const msgStopFrames = frames . filter ( ( f ) => f . eventType === "messageStop" ) ;
1018+ expect ( msgStopFrames ) . toHaveLength ( 1 ) ;
1019+
1020+ const payload = msgStopFrames [ 0 ] . payload as { messageStop : { stopReason : string } } ;
1021+ expect ( payload ) . toHaveProperty ( "messageStop" ) ;
1022+ expect ( payload . messageStop ) . toHaveProperty ( "stopReason" ) ;
1023+ expect ( Object . keys ( payload ) ) . toEqual ( [ "messageStop" ] ) ;
1024+ } ) ;
1025+ } ) ;
1026+
1027+ // ─── converse-stream: contentWithToolCalls full structure ─────────────────────
1028+
1029+ describe ( "POST /model/{modelId}/converse-stream (contentWithToolCalls full structure)" , ( ) => {
1030+ const MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0" ;
1031+
1032+ it ( "verifies complete event sequence for content + tool calls" , async ( ) => {
1033+ instance = await createServer ( allFixtures ) ;
1034+ const res = await postBinary ( `${ instance . url } /model/${ MODEL_ID } /converse-stream` , {
1035+ messages : [ { role : "user" , content : [ { text : "search-and-explain" } ] } ] ,
1036+ } ) ;
1037+
1038+ expect ( res . status ) . toBe ( 200 ) ;
1039+ const frames = parseFrames ( res . body ) ;
1040+
1041+ // 1. Stream starts with messageStart (role: assistant)
1042+ expect ( frames [ 0 ] . eventType ) . toBe ( "messageStart" ) ;
1043+ expect ( frames [ 0 ] . payload ) . toEqual ( { messageStart : { role : "assistant" } } ) ;
1044+
1045+ // 2. Collect all contentBlockStart frames
1046+ const blockStarts = frames . filter ( ( f ) => f . eventType === "contentBlockStart" ) ;
1047+ expect ( blockStarts . length ) . toBe ( 2 ) ; // one text, one tool
1048+
1049+ // 3. Text content block appears before tool call block
1050+ const textBlockStartIdx = frames . findIndex (
1051+ ( f ) =>
1052+ f . eventType === "contentBlockStart" &&
1053+ ( f . payload as { contentBlockStart ?: { start ?: { type ?: string } } } ) . contentBlockStart
1054+ ?. start ?. type === "text" ,
1055+ ) ;
1056+ const toolBlockStartIdx = frames . findIndex (
1057+ ( f ) =>
1058+ f . eventType === "contentBlockStart" &&
1059+ ( f . payload as { contentBlockStart ?: { start ?: { toolUse ?: unknown } } } ) . contentBlockStart
1060+ ?. start ?. toolUse !== undefined ,
1061+ ) ;
1062+ expect ( textBlockStartIdx ) . toBeLessThan ( toolBlockStartIdx ) ;
1063+
1064+ // 4. Tool call block has contentBlockStart with toolUse (toolUseId + name)
1065+ const toolBlockStart = frames [ toolBlockStartIdx ] ;
1066+ const toolStartPayload = toolBlockStart . payload as {
1067+ contentBlockStart : {
1068+ contentBlockIndex : number ;
1069+ start : { toolUse : { toolUseId : string ; name : string } } ;
1070+ } ;
1071+ } ;
1072+ expect ( toolStartPayload . contentBlockStart . start . toolUse . name ) . toBe ( "web_search" ) ;
1073+ expect ( toolStartPayload . contentBlockStart . start . toolUse . toolUseId ) . toBeDefined ( ) ;
1074+ expect ( typeof toolStartPayload . contentBlockStart . start . toolUse . toolUseId ) . toBe ( "string" ) ;
1075+
1076+ // 5. Tool call block has contentBlockDelta chunks after its start
1077+ const toolBlockIndex = toolStartPayload . contentBlockStart . contentBlockIndex ;
1078+ const toolDeltas = frames . filter (
1079+ ( f ) =>
1080+ f . eventType === "contentBlockDelta" &&
1081+ ( f . payload as { contentBlockDelta ?: { contentBlockIndex ?: number } } ) . contentBlockDelta
1082+ ?. contentBlockIndex === toolBlockIndex ,
1083+ ) ;
1084+ expect ( toolDeltas . length ) . toBeGreaterThanOrEqual ( 1 ) ;
1085+
1086+ // 6. Tool call block has contentBlockStop
1087+ const toolBlockStop = frames . find (
1088+ ( f ) =>
1089+ f . eventType === "contentBlockStop" &&
1090+ ( f . payload as { contentBlockStop ?: { contentBlockIndex ?: number } } ) . contentBlockStop
1091+ ?. contentBlockIndex === toolBlockIndex ,
1092+ ) ;
1093+ expect ( toolBlockStop ) . toBeDefined ( ) ;
1094+ expect ( toolBlockStop ! . payload ) . toEqual ( {
1095+ contentBlockStop : { contentBlockIndex : toolBlockIndex } ,
1096+ } ) ;
1097+
1098+ // 7. Stream ends with messageStop (stopReason: tool_use) then metadata
1099+ const msgStopIdx = frames . findIndex ( ( f ) => f . eventType === "messageStop" ) ;
1100+ const metadataIdx = frames . findIndex ( ( f ) => f . eventType === "metadata" ) ;
1101+ expect ( msgStopIdx ) . toBeGreaterThan ( - 1 ) ;
1102+ expect ( metadataIdx ) . toBeGreaterThan ( - 1 ) ;
1103+ expect ( metadataIdx ) . toBe ( msgStopIdx + 1 ) ; // metadata immediately follows messageStop
1104+ expect ( metadataIdx ) . toBe ( frames . length - 1 ) ; // metadata is last frame
1105+
1106+ const msgStopPayload = frames [ msgStopIdx ] . payload as {
1107+ messageStop : { stopReason : string } ;
1108+ } ;
1109+ expect ( msgStopPayload ) . toEqual ( { messageStop : { stopReason : "tool_use" } } ) ;
1110+
1111+ // 8. contentBlockIndex values are sequential across text and tool blocks
1112+ const allBlockStarts = frames
1113+ . filter ( ( f ) => f . eventType === "contentBlockStart" )
1114+ . map (
1115+ ( f ) =>
1116+ ( f . payload as { contentBlockStart : { contentBlockIndex : number } } ) . contentBlockStart
1117+ . contentBlockIndex ,
1118+ ) ;
1119+ expect ( allBlockStarts ) . toEqual ( [ 0 , 1 ] ) ;
1120+
1121+ const allBlockStops = frames
1122+ . filter ( ( f ) => f . eventType === "contentBlockStop" )
1123+ . map (
1124+ ( f ) =>
1125+ ( f . payload as { contentBlockStop : { contentBlockIndex : number } } ) . contentBlockStop
1126+ . contentBlockIndex ,
1127+ ) ;
1128+ expect ( allBlockStops ) . toEqual ( [ 0 , 1 ] ) ;
1129+ } ) ;
1130+
1131+ it ( "verifies sequential contentBlockIndex with multiple tool calls" , async ( ) => {
1132+ const multiToolContentFixture : Fixture = {
1133+ match : { userMessage : "multi-tool-with-text" } ,
1134+ response : {
1135+ content : "I will use two tools." ,
1136+ toolCalls : [
1137+ { name : "tool_a" , arguments : '{"x":1}' } ,
1138+ { name : "tool_b" , arguments : '{"y":2}' } ,
1139+ ] ,
1140+ } ,
1141+ } ;
1142+ instance = await createServer ( [ multiToolContentFixture ] ) ;
1143+ const res = await postBinary ( `${ instance . url } /model/${ MODEL_ID } /converse-stream` , {
1144+ messages : [ { role : "user" , content : [ { text : "multi-tool-with-text" } ] } ] ,
1145+ } ) ;
1146+
1147+ expect ( res . status ) . toBe ( 200 ) ;
1148+ const frames = parseFrames ( res . body ) ;
1149+
1150+ // contentBlockIndex: 0 = text, 1 = tool_a, 2 = tool_b
1151+ const blockStarts = frames . filter ( ( f ) => f . eventType === "contentBlockStart" ) ;
1152+ expect ( blockStarts ) . toHaveLength ( 3 ) ;
1153+
1154+ const indices = blockStarts . map (
1155+ ( f ) =>
1156+ ( f . payload as { contentBlockStart : { contentBlockIndex : number } } ) . contentBlockStart
1157+ . contentBlockIndex ,
1158+ ) ;
1159+ expect ( indices ) . toEqual ( [ 0 , 1 , 2 ] ) ;
1160+
1161+ // Text block at index 0
1162+ const textStart = blockStarts [ 0 ] . payload as {
1163+ contentBlockStart : { start : { type : string } } ;
1164+ } ;
1165+ expect ( textStart . contentBlockStart . start . type ) . toBe ( "text" ) ;
1166+
1167+ // Tool blocks at indices 1 and 2
1168+ const tool1Start = blockStarts [ 1 ] . payload as {
1169+ contentBlockStart : { start : { toolUse : { name : string } } } ;
1170+ } ;
1171+ expect ( tool1Start . contentBlockStart . start . toolUse . name ) . toBe ( "tool_a" ) ;
1172+
1173+ const tool2Start = blockStarts [ 2 ] . payload as {
1174+ contentBlockStart : { start : { toolUse : { name : string } } } ;
1175+ } ;
1176+ expect ( tool2Start . contentBlockStart . start . toolUse . name ) . toBe ( "tool_b" ) ;
1177+
1178+ // contentBlockStop indices are also sequential
1179+ const blockStops = frames . filter ( ( f ) => f . eventType === "contentBlockStop" ) ;
1180+ const stopIndices = blockStops . map (
1181+ ( f ) =>
1182+ ( f . payload as { contentBlockStop : { contentBlockIndex : number } } ) . contentBlockStop
1183+ . contentBlockIndex ,
1184+ ) ;
1185+ expect ( stopIndices ) . toEqual ( [ 0 , 1 , 2 ] ) ;
1186+
1187+ // messageStop with tool_use
1188+ const msgStop = frames . find ( ( f ) => f . eventType === "messageStop" ) ;
1189+ expect ( msgStop ! . payload ) . toEqual ( { messageStop : { stopReason : "tool_use" } } ) ;
9501190 } ) ;
9511191} ) ;
9521192
@@ -1472,7 +1712,7 @@ describe("converseToCompletionRequest (edge cases)", () => {
14721712 } ,
14731713 "model" ,
14741714 ) ;
1475- expect ( result . messages [ 0 ] ) . toEqual ( { role : "assistant" , content : null } ) ;
1715+ expect ( result . messages [ 0 ] ) . toEqual ( { role : "assistant" , content : "" } ) ;
14761716 } ) ;
14771717
14781718 it ( "handles user tool result with missing text in content items (text ?? '' fallback)" , ( ) => {
@@ -1583,8 +1823,8 @@ describe("converseToCompletionRequest (edge cases)", () => {
15831823 "model" ,
15841824 ) ;
15851825 expect ( result . messages [ 0 ] . tool_calls ) . toHaveLength ( 1 ) ;
1586- // Empty text → content is null (falsy )
1587- expect ( result . messages [ 0 ] . content ) . toBeNull ( ) ;
1826+ // Empty text → content is "" (nullish coalescing preserves empty string )
1827+ expect ( result . messages [ 0 ] . content ) . toBe ( "" ) ;
15881828 } ) ;
15891829} ) ;
15901830
@@ -1723,50 +1963,6 @@ describe("POST /model/{modelId}/invoke-with-response-stream (error fixture no ex
17231963
17241964// ─── Direct handler tests for req.method/req.url fallback branches ──────────
17251965
1726- function createMockReq ( overrides : Partial < http . IncomingMessage > = { } ) : http . IncomingMessage {
1727- return {
1728- method : undefined ,
1729- url : undefined ,
1730- headers : { } ,
1731- ...overrides ,
1732- } as unknown as http . IncomingMessage ;
1733- }
1734-
1735- function createMockRes ( ) : http . ServerResponse & { _written : string ; _status : number } {
1736- const res = {
1737- _written : "" ,
1738- _status : 0 ,
1739- writableEnded : false ,
1740- statusCode : 0 ,
1741- writeHead ( status : number ) {
1742- res . _status = status ;
1743- res . statusCode = status ;
1744- } ,
1745- setHeader ( ) { } ,
1746- write ( data : string ) {
1747- res . _written += data ;
1748- return true ;
1749- } ,
1750- end ( data ?: string ) {
1751- if ( data ) res . _written += data ;
1752- res . writableEnded = true ;
1753- } ,
1754- destroy ( ) {
1755- res . writableEnded = true ;
1756- } ,
1757- } ;
1758- return res as unknown as http . ServerResponse & { _written : string ; _status : number } ;
1759- }
1760-
1761- function createDefaults ( overrides : Partial < HandlerDefaults > = { } ) : HandlerDefaults {
1762- return {
1763- latency : 0 ,
1764- chunkSize : 100 ,
1765- logger : new Logger ( "silent" ) ,
1766- ...overrides ,
1767- } ;
1768- }
1769-
17701966describe ( "handleConverse (direct handler call, method/url fallbacks)" , ( ) => {
17711967 it ( "uses fallback for text response with undefined method/url" , async ( ) => {
17721968 const fixture : Fixture = {
0 commit comments