Skip to content

Commit 7ff27b0

Browse files
committed
test: add content+toolCalls streaming integration coverage
Adds integration tests for the isContentWithToolCallsResponse path through both invoke-with-response-stream (Anthropic-native format) and converse-stream (Converse camelCase format). This path previously had zero integration test coverage.
1 parent 61950f0 commit 7ff27b0

1 file changed

Lines changed: 204 additions & 11 deletions

File tree

src/__tests__/bedrock-stream.test.ts

Lines changed: 204 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -227,7 +227,25 @@ const errorFixture: Fixture = {
227227
},
228228
};
229229

230-
const allFixtures: Fixture[] = [textFixture, toolFixture, errorFixture];
230+
const contentWithToolCallsFixture: Fixture = {
231+
match: { userMessage: "search-and-explain" },
232+
response: {
233+
content: "Let me look that up.",
234+
toolCalls: [
235+
{
236+
name: "web_search",
237+
arguments: '{"query":"vitest testing"}',
238+
},
239+
],
240+
},
241+
};
242+
243+
const allFixtures: Fixture[] = [
244+
textFixture,
245+
toolFixture,
246+
errorFixture,
247+
contentWithToolCallsFixture,
248+
];
231249

232250
// --- test lifecycle ---
233251

@@ -294,9 +312,7 @@ describe("POST /model/{modelId}/invoke-with-response-stream", () => {
294312
expect(stopBlock!.payload).toEqual({ type: "content_block_stop", index: 0 });
295313

296314
// message_delta/message_stop
297-
const msgDelta = frames.find(
298-
(f) => (f.payload as { type?: string }).type === "message_delta",
299-
);
315+
const msgDelta = frames.find((f) => (f.payload as { type?: string }).type === "message_delta");
300316
expect(msgDelta).toBeDefined();
301317
expect(msgDelta!.payload).toMatchObject({
302318
type: "message_delta",
@@ -344,9 +360,7 @@ describe("POST /model/{modelId}/invoke-with-response-stream", () => {
344360
.join("");
345361
expect(JSON.parse(fullJson)).toEqual({ city: "SF" });
346362

347-
const msgDelta = frames.find(
348-
(f) => (f.payload as { type?: string }).type === "message_delta",
349-
);
363+
const msgDelta = frames.find((f) => (f.payload as { type?: string }).type === "message_delta");
350364
expect(msgDelta!.payload).toMatchObject({
351365
type: "message_delta",
352366
delta: { stop_reason: "tool_use" },
@@ -511,13 +525,103 @@ describe("POST /model/{modelId}/invoke-with-response-stream (multiple tool calls
511525
expect((blockStops[1].payload as { index: number }).index).toBe(1);
512526

513527
// message_delta should indicate tool_use
514-
const msgDelta = frames.find(
515-
(f) => (f.payload as { type?: string }).type === "message_delta",
516-
);
528+
const msgDelta = frames.find((f) => (f.payload as { type?: string }).type === "message_delta");
517529
expect(msgDelta!.payload).toMatchObject({ delta: { stop_reason: "tool_use" } });
518530
});
519531
});
520532

533+
// ─── invoke-with-response-stream: content + tool calls ────────────────────
534+
535+
describe("POST /model/{modelId}/invoke-with-response-stream (content + toolCalls)", () => {
536+
const MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0";
537+
538+
it("streams text block followed by tool_use block in Anthropic-native format", async () => {
539+
instance = await createServer(allFixtures);
540+
const res = await postBinary(`${instance.url}/model/${MODEL_ID}/invoke-with-response-stream`, {
541+
anthropic_version: "bedrock-2023-05-31",
542+
max_tokens: 512,
543+
messages: [{ role: "user", content: "search-and-explain" }],
544+
});
545+
546+
expect(res.status).toBe(200);
547+
expect(res.headers["content-type"]).toBe("application/vnd.amazon.eventstream");
548+
549+
const frames = parseFrames(res.body);
550+
551+
// All frames should be "chunk" eventType (Anthropic-native wrapping)
552+
expect(frames.every((f) => f.eventType === "chunk")).toBe(true);
553+
554+
// message_start
555+
expect(frames[0].payload).toMatchObject({
556+
type: "message_start",
557+
message: { role: "assistant", model: MODEL_ID },
558+
});
559+
560+
// Text content_block_start at index 0
561+
const textBlockStart = frames.find(
562+
(f) =>
563+
(f.payload as { type?: string }).type === "content_block_start" &&
564+
(f.payload as { content_block?: { type: string } }).content_block?.type === "text",
565+
);
566+
expect(textBlockStart).toBeDefined();
567+
expect(textBlockStart!.payload).toMatchObject({
568+
type: "content_block_start",
569+
index: 0,
570+
content_block: { type: "text", text: "" },
571+
});
572+
573+
// Text deltas — collect and verify full text
574+
const textDeltas = frames.filter(
575+
(f) =>
576+
(f.payload as { type?: string }).type === "content_block_delta" &&
577+
(f.payload as { delta?: { type?: string } }).delta?.type === "text_delta",
578+
);
579+
expect(textDeltas.length).toBeGreaterThanOrEqual(1);
580+
const fullText = textDeltas
581+
.map((f) => (f.payload as { delta: { text: string } }).delta.text)
582+
.join("");
583+
expect(fullText).toBe("Let me look that up.");
584+
585+
// Tool use content_block_start at index 1
586+
const toolBlockStart = frames.find(
587+
(f) =>
588+
(f.payload as { type?: string }).type === "content_block_start" &&
589+
(f.payload as { content_block?: { type: string } }).content_block?.type === "tool_use",
590+
);
591+
expect(toolBlockStart).toBeDefined();
592+
const toolStartPayload = toolBlockStart!.payload as {
593+
index: number;
594+
content_block: { type: string; id: string; name: string; input: object };
595+
};
596+
expect(toolStartPayload.index).toBe(1);
597+
expect(toolStartPayload.content_block.name).toBe("web_search");
598+
expect(toolStartPayload.content_block.id).toBeDefined();
599+
600+
// Tool deltas — input_json_delta with partial_json
601+
const toolDeltas = frames.filter(
602+
(f) =>
603+
(f.payload as { type?: string }).type === "content_block_delta" &&
604+
(f.payload as { delta?: { type?: string } }).delta?.type === "input_json_delta",
605+
);
606+
expect(toolDeltas.length).toBeGreaterThanOrEqual(1);
607+
const fullJson = toolDeltas
608+
.map((f) => (f.payload as { delta: { partial_json: string } }).delta.partial_json)
609+
.join("");
610+
expect(JSON.parse(fullJson)).toEqual({ query: "vitest testing" });
611+
612+
// message_delta with stop_reason "tool_use"
613+
const msgDelta = frames.find((f) => (f.payload as { type?: string }).type === "message_delta");
614+
expect(msgDelta!.payload).toMatchObject({
615+
type: "message_delta",
616+
delta: { stop_reason: "tool_use" },
617+
});
618+
619+
// message_stop
620+
const msgStop = frames.find((f) => (f.payload as { type?: string }).type === "message_stop");
621+
expect(msgStop).toBeDefined();
622+
});
623+
});
624+
521625
// ─── invoke-with-response-stream: interruption ─────────────────────────────
522626

523627
describe("POST /model/{modelId}/invoke-with-response-stream (interruption)", () => {
@@ -759,6 +863,93 @@ describe("POST /model/{modelId}/converse-stream", () => {
759863
});
760864
});
761865

866+
// ─── converse-stream: content + tool calls ─────────────────────────────────
867+
868+
describe("POST /model/{modelId}/converse-stream (content + toolCalls)", () => {
869+
const MODEL_ID = "anthropic.claude-3-5-sonnet-20241022-v2:0";
870+
871+
it("streams text block followed by toolUse block in Converse camelCase format", async () => {
872+
instance = await createServer(allFixtures);
873+
const res = await postBinary(`${instance.url}/model/${MODEL_ID}/converse-stream`, {
874+
messages: [{ role: "user", content: [{ text: "search-and-explain" }] }],
875+
});
876+
877+
expect(res.status).toBe(200);
878+
expect(res.headers["content-type"]).toBe("application/vnd.amazon.eventstream");
879+
880+
const frames = parseFrames(res.body);
881+
882+
// messageStart
883+
expect(frames[0].eventType).toBe("messageStart");
884+
expect(frames[0].payload).toEqual({ messageStart: { role: "assistant" } });
885+
886+
// Text contentBlockStart
887+
const textBlockStart = frames.find(
888+
(f) =>
889+
f.eventType === "contentBlockStart" &&
890+
(f.payload as { contentBlockStart?: { start?: { type?: string } } }).contentBlockStart
891+
?.start?.type === "text",
892+
);
893+
expect(textBlockStart).toBeDefined();
894+
895+
// Text deltas
896+
const textDeltas = frames.filter(
897+
(f) =>
898+
f.eventType === "contentBlockDelta" &&
899+
(f.payload as { contentBlockDelta?: { delta?: { text?: string } } }).contentBlockDelta
900+
?.delta?.text !== undefined,
901+
);
902+
expect(textDeltas.length).toBeGreaterThanOrEqual(1);
903+
const fullText = textDeltas
904+
.map(
905+
(f) =>
906+
(f.payload as { contentBlockDelta: { delta: { text: string } } }).contentBlockDelta.delta
907+
.text,
908+
)
909+
.join("");
910+
expect(fullText).toBe("Let me look that up.");
911+
912+
// Tool use contentBlockStart
913+
const toolBlockStart = frames.find(
914+
(f) =>
915+
f.eventType === "contentBlockStart" &&
916+
(f.payload as { contentBlockStart?: { start?: { toolUse?: unknown } } }).contentBlockStart
917+
?.start?.toolUse !== undefined,
918+
);
919+
expect(toolBlockStart).toBeDefined();
920+
const toolStartPayload = toolBlockStart!.payload as {
921+
contentBlockIndex: number;
922+
contentBlockStart: {
923+
contentBlockIndex: number;
924+
start: { toolUse: { toolUseId: string; name: string } };
925+
};
926+
};
927+
expect(toolStartPayload.contentBlockStart.start.toolUse.name).toBe("web_search");
928+
expect(toolStartPayload.contentBlockStart.start.toolUse.toolUseId).toBeDefined();
929+
930+
// Tool deltas — toolUse.input
931+
const toolDeltas = frames.filter(
932+
(f) =>
933+
f.eventType === "contentBlockDelta" &&
934+
(f.payload as { contentBlockDelta?: { delta?: { toolUse?: unknown } } }).contentBlockDelta
935+
?.delta?.toolUse !== undefined,
936+
);
937+
expect(toolDeltas.length).toBeGreaterThanOrEqual(1);
938+
const fullJson = toolDeltas
939+
.map(
940+
(f) =>
941+
(f.payload as { contentBlockDelta: { delta: { toolUse: { input: string } } } })
942+
.contentBlockDelta.delta.toolUse.input,
943+
)
944+
.join("");
945+
expect(JSON.parse(fullJson)).toEqual({ query: "vitest testing" });
946+
947+
// messageStop with tool_use stop reason
948+
const msgStop = frames.find((f) => f.eventType === "messageStop");
949+
expect(msgStop!.payload).toEqual({ stopReason: "tool_use" });
950+
});
951+
});
952+
762953
// ─── converseToCompletionRequest unit tests ─────────────────────────────────
763954

764955
describe("converseToCompletionRequest", () => {
@@ -1080,7 +1271,9 @@ describe("POST /model/{modelId}/invoke-with-response-stream (empty content)", ()
10801271
expect(
10811272
frames.find((f) => (f.payload as { type?: string }).type === "content_block_stop"),
10821273
).toBeDefined();
1083-
expect(frames.find((f) => (f.payload as { type?: string }).type === "message_stop")).toBeDefined();
1274+
expect(
1275+
frames.find((f) => (f.payload as { type?: string }).type === "message_stop"),
1276+
).toBeDefined();
10841277

10851278
// Content deltas should be zero (empty string → no chunks)
10861279
const deltas = frames.filter(

0 commit comments

Comments
 (0)