Skip to content

Commit ed1a823

Browse files
committed
test: add deep shape validation for message streams
- Add comprehensive tests for AssistantMessage shape validation - Add tests for ToolResponseMessage shape with toolCallId validation - Verify tool execution results match expected output schema - Validate message ordering (tool responses before final assistant) - Fix getNewMessagesStream to yield final assistant message after tools
1 parent 78dd5a5 commit ed1a823

File tree

2 files changed

+222
-2
lines changed

2 files changed

+222
-2
lines changed

src/lib/response-wrapper.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,17 @@ export class ResponseWrapper {
418418
} as models.ToolResponseMessage;
419419
}
420420
}
421+
422+
// If tools were executed, yield the final assistant message (if there is one)
423+
if (this.finalResponse && this.allToolExecutionRounds.length > 0) {
424+
// Check if the final response contains a message
425+
const hasMessage = this.finalResponse.output.some(
426+
(item) => "type" in item && item.type === "message"
427+
);
428+
if (hasMessage) {
429+
yield extractMessageFromResponse(this.finalResponse);
430+
}
431+
}
421432
}.call(this));
422433
}
423434

tests/e2e/callModel.test.ts

Lines changed: 211 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { beforeAll, describe, expect, it } from "vitest";
2-
import { OpenRouter } from "../../src/sdk/sdk.js";
2+
import { OpenRouter, ToolType } from "../../src/sdk/sdk.js";
33
import { Message } from "../../src/models/message.js";
4+
import { AssistantMessage } from "../../src/models/assistantmessage.js";
5+
import { ToolResponseMessage } from "../../src/models/toolresponsemessage.js";
6+
import { z } from "zod/v4";
47

58
describe("callModel E2E Tests", () => {
69
let client: OpenRouter;
@@ -226,7 +229,7 @@ describe("callModel E2E Tests", () => {
226229
],
227230
});
228231

229-
const messages: Message[] = [];
232+
const messages: (AssistantMessage | ToolResponseMessage)[] = [];
230233

231234
for await (const message of response.getNewMessagesStream()) {
232235
expect(message).toBeDefined();
@@ -248,6 +251,212 @@ describe("callModel E2E Tests", () => {
248251
expect(lastText.length).toBeGreaterThanOrEqual(firstText.length);
249252
}
250253
}, 15000);
254+
255+
it("should return AssistantMessages with correct shape", async () => {
256+
const response = client.callModel({
257+
model: "meta-llama/llama-3.2-1b-instruct",
258+
input: [
259+
{
260+
role: "user",
261+
content: "Say 'hello world'.",
262+
},
263+
],
264+
});
265+
266+
const messages: (AssistantMessage | ToolResponseMessage)[] = [];
267+
268+
for await (const message of response.getNewMessagesStream()) {
269+
messages.push(message);
270+
271+
// Deep validation of AssistantMessage shape
272+
expect(message).toHaveProperty("role");
273+
expect(message).toHaveProperty("content");
274+
275+
if (message.role === "assistant") {
276+
// Validate AssistantMessage structure
277+
expect(message.role).toBe("assistant");
278+
279+
// content must be string, array, null, or undefined
280+
const contentType = typeof message.content;
281+
const isValidContent =
282+
contentType === "string" ||
283+
Array.isArray(message.content) ||
284+
message.content === null ||
285+
message.content === undefined;
286+
expect(isValidContent).toBe(true);
287+
288+
// If content is an array, each item must have a type
289+
if (Array.isArray(message.content)) {
290+
for (const item of message.content) {
291+
expect(item).toHaveProperty("type");
292+
expect(typeof item.type).toBe("string");
293+
}
294+
}
295+
296+
// If toolCalls present, validate their shape
297+
if ("toolCalls" in message && message.toolCalls) {
298+
expect(Array.isArray(message.toolCalls)).toBe(true);
299+
for (const toolCall of message.toolCalls) {
300+
expect(toolCall).toHaveProperty("id");
301+
expect(toolCall).toHaveProperty("type");
302+
expect(toolCall).toHaveProperty("function");
303+
expect(typeof toolCall.id).toBe("string");
304+
expect(typeof toolCall.type).toBe("string");
305+
expect(toolCall.function).toHaveProperty("name");
306+
expect(toolCall.function).toHaveProperty("arguments");
307+
expect(typeof toolCall.function.name).toBe("string");
308+
expect(typeof toolCall.function.arguments).toBe("string");
309+
}
310+
}
311+
}
312+
}
313+
314+
expect(messages.length).toBeGreaterThan(0);
315+
316+
// Verify last message has the complete assistant response shape
317+
const lastMessage = messages[messages.length - 1];
318+
expect(lastMessage.role).toBe("assistant");
319+
}, 15000);
320+
321+
it("should include ToolResponseMessages with correct shape when tools are executed", async () => {
322+
const response = client.callModel({
323+
model: "openai/gpt-4o-mini",
324+
input: [
325+
{
326+
role: "user",
327+
content: "What's the weather in Tokyo? Use the get_weather tool.",
328+
},
329+
],
330+
tools: [
331+
{
332+
type: ToolType.Function,
333+
function: {
334+
name: "get_weather",
335+
description: "Get weather for a location",
336+
inputSchema: z.object({
337+
location: z.string().describe("City name"),
338+
}),
339+
outputSchema: z.object({
340+
temperature: z.number(),
341+
condition: z.string(),
342+
}),
343+
execute: async (params: { location: string }) => {
344+
return {
345+
temperature: 22,
346+
condition: "Sunny",
347+
};
348+
},
349+
},
350+
},
351+
],
352+
});
353+
354+
const messages: (AssistantMessage | ToolResponseMessage)[] = [];
355+
let hasAssistantMessage = false;
356+
let hasToolResponseMessage = false;
357+
358+
for await (const message of response.getNewMessagesStream()) {
359+
messages.push(message);
360+
361+
// Validate each message has correct shape based on role
362+
expect(message).toHaveProperty("role");
363+
expect(message).toHaveProperty("content");
364+
365+
if (message.role === "assistant") {
366+
hasAssistantMessage = true;
367+
368+
// Validate AssistantMessage shape
369+
const contentType = typeof message.content;
370+
const isValidContent =
371+
contentType === "string" ||
372+
Array.isArray(message.content) ||
373+
message.content === null ||
374+
message.content === undefined;
375+
expect(isValidContent).toBe(true);
376+
} else if (message.role === "tool") {
377+
hasToolResponseMessage = true;
378+
379+
// Deep validation of ToolResponseMessage shape
380+
expect(message).toHaveProperty("toolCallId");
381+
expect(typeof (message as ToolResponseMessage).toolCallId).toBe("string");
382+
expect((message as ToolResponseMessage).toolCallId.length).toBeGreaterThan(0);
383+
384+
// content must be string or array
385+
const contentType = typeof message.content;
386+
const isValidContent =
387+
contentType === "string" ||
388+
Array.isArray(message.content);
389+
expect(isValidContent).toBe(true);
390+
391+
// If content is string, it should be parseable JSON (our tool result)
392+
if (typeof message.content === "string" && message.content.length > 0) {
393+
const parsed = JSON.parse(message.content);
394+
expect(parsed).toBeDefined();
395+
// Verify it matches our tool output schema
396+
expect(parsed).toHaveProperty("temperature");
397+
expect(parsed).toHaveProperty("condition");
398+
expect(typeof parsed.temperature).toBe("number");
399+
expect(typeof parsed.condition).toBe("string");
400+
}
401+
}
402+
}
403+
404+
expect(messages.length).toBeGreaterThan(0);
405+
// We must have tool responses since we have an executable tool
406+
expect(hasToolResponseMessage).toBe(true);
407+
408+
// If the model provided a final text response, verify proper ordering
409+
if (hasAssistantMessage) {
410+
const lastToolIndex = messages.reduce((lastIdx, m, i) =>
411+
m.role === "tool" ? i : lastIdx, -1);
412+
const lastAssistantIndex = messages.reduce((lastIdx, m, i) =>
413+
m.role === "assistant" ? i : lastIdx, -1);
414+
415+
// The final assistant message should come after tool responses
416+
if (lastToolIndex !== -1 && lastAssistantIndex !== -1) {
417+
expect(lastAssistantIndex).toBeGreaterThan(lastToolIndex);
418+
}
419+
}
420+
}, 30000);
421+
422+
it("should return messages with all required fields and correct types", async () => {
423+
const response = client.callModel({
424+
model: "meta-llama/llama-3.2-1b-instruct",
425+
input: [
426+
{
427+
role: "user",
428+
content: "Count from 1 to 3.",
429+
},
430+
],
431+
});
432+
433+
for await (const message of response.getNewMessagesStream()) {
434+
// role must be a string and one of the valid values
435+
expect(typeof message.role).toBe("string");
436+
expect(["assistant", "tool"]).toContain(message.role);
437+
438+
// content must exist (even if null)
439+
expect("content" in message).toBe(true);
440+
441+
if (message.role === "assistant") {
442+
// AssistantMessage specific validations
443+
const validContentTypes = ["string", "object", "undefined"];
444+
expect(validContentTypes).toContain(typeof message.content);
445+
446+
// If content is array, validate structure
447+
if (Array.isArray(message.content)) {
448+
expect(message.content.every(item =>
449+
typeof item === "object" && item !== null && "type" in item
450+
)).toBe(true);
451+
}
452+
} else if (message.role === "tool") {
453+
// ToolResponseMessage specific validations
454+
const toolMsg = message as ToolResponseMessage;
455+
expect(typeof toolMsg.toolCallId).toBe("string");
456+
expect(toolMsg.toolCallId.length).toBeGreaterThan(0);
457+
}
458+
}
459+
}, 15000);
251460
});
252461

253462
describe("response.reasoningStream - Streaming reasoning deltas", () => {

0 commit comments

Comments
 (0)