Skip to content

Commit 5d33d71

Browse files
committed
test: strengthen Bedrock stream assertions and add coverage
Update messageStop/contentBlockStop assertions for wrapped shapes. Add safeResolve guard in postPartialBinary. Add unit tests for contentBlockStop shape, contentWithToolCalls stream structure, and metadata event presence. Add content+toolCalls streaming integration coverage for both invoke and converse paths.
1 parent bf9fda8 commit 5d33d71

1 file changed

Lines changed: 251 additions & 55 deletions

File tree

src/__tests__/bedrock-stream.test.ts

Lines changed: 251 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
import { describe, it, expect, afterEach } from "vitest";
22
import * as http from "node:http";
33
import { crc32 } from "node:zlib";
4-
import type { Fixture, HandlerDefaults } from "../types.js";
4+
import type { Fixture } from "../types.js";
55
import { createServer, type ServerInstance } from "../server.js";
66
import {
77
converseToCompletionRequest,
88
handleConverse,
99
handleConverseStream,
1010
} from "../bedrock-converse.js";
1111
import { 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-
17701966
describe("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

Comments
 (0)