chore: import upstream snapshot with attribution
This commit is contained in:
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vitest/globals" />
|
||||
@@ -0,0 +1,259 @@
|
||||
describe("Anthropic Adapter - Allowlist Approach", () => {
|
||||
it("should filter out tool_result messages with no corresponding tool_use ID", () => {
|
||||
// Setup test data
|
||||
const validToolUseIds = new Set<string>(["valid-id-1", "valid-id-2"]);
|
||||
|
||||
// Messages to filter - valid and invalid ones
|
||||
const messages = [
|
||||
{ type: "text", role: "user", content: "Hello" },
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-1",
|
||||
result: "result1",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "invalid-id",
|
||||
result: "invalid",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-2",
|
||||
result: "result2",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-1",
|
||||
result: "duplicate",
|
||||
}, // Duplicate ID
|
||||
];
|
||||
|
||||
// Apply the allowlist filter approach
|
||||
const filteredMessages = [];
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool_result") {
|
||||
// Skip if no corresponding valid tool_use ID
|
||||
if (!validToolUseIds.has(message.actionExecutionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if we've already processed this ID
|
||||
if (processedIds.has(message.actionExecutionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark this ID as processed
|
||||
processedIds.add(message.actionExecutionId);
|
||||
}
|
||||
|
||||
// Include all non-tool-result messages and valid tool results
|
||||
filteredMessages.push(message);
|
||||
}
|
||||
|
||||
// Verify results
|
||||
expect(filteredMessages.length).toBe(3); // text + 2 valid tool results (no duplicates or invalid)
|
||||
|
||||
// Valid results should be included
|
||||
expect(
|
||||
filteredMessages.some(
|
||||
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
filteredMessages.some(
|
||||
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-2",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Invalid result should be excluded
|
||||
expect(
|
||||
filteredMessages.some(
|
||||
(m) => m.type === "tool_result" && m.actionExecutionId === "invalid-id",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Duplicate should be excluded
|
||||
const validId1Count = filteredMessages.filter(
|
||||
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
|
||||
).length;
|
||||
|
||||
expect(validId1Count).toBe(1);
|
||||
});
|
||||
|
||||
it("should maintain correct order of messages when filtering", () => {
|
||||
// Setup test data with specific ordering
|
||||
const validToolUseIds = new Set<string>(["tool-1", "tool-2", "tool-3"]);
|
||||
|
||||
// Messages in a specific order, with some invalid/duplicate results
|
||||
const messages = [
|
||||
{ type: "text", role: "user", content: "Initial message" },
|
||||
{ type: "text", role: "assistant", content: "I'll help with that" },
|
||||
{ type: "tool_use", id: "tool-1", name: "firstTool" },
|
||||
{ type: "tool_result", actionExecutionId: "tool-1", result: "result1" },
|
||||
{ type: "text", role: "assistant", content: "Got the first result" },
|
||||
{ type: "tool_use", id: "tool-2", name: "secondTool" },
|
||||
{ type: "tool_result", actionExecutionId: "tool-2", result: "result2" },
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "invalid-id",
|
||||
result: "invalid-result",
|
||||
},
|
||||
{ type: "tool_use", id: "tool-3", name: "thirdTool" },
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "tool-1",
|
||||
result: "duplicate-result",
|
||||
}, // Duplicate
|
||||
{ type: "tool_result", actionExecutionId: "tool-3", result: "result3" },
|
||||
{ type: "text", role: "user", content: "Final message" },
|
||||
];
|
||||
|
||||
// Apply the allowlist filter approach
|
||||
const filteredMessages = [];
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool_result") {
|
||||
// Skip if no corresponding valid tool_use ID
|
||||
if (!validToolUseIds.has(message.actionExecutionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if we've already processed this ID
|
||||
if (processedIds.has(message.actionExecutionId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark this ID as processed
|
||||
processedIds.add(message.actionExecutionId);
|
||||
}
|
||||
|
||||
// Include all non-tool-result messages and valid tool results
|
||||
filteredMessages.push(message);
|
||||
}
|
||||
|
||||
// Verify results
|
||||
expect(filteredMessages.length).toBe(10); // 12 original - 2 filtered out
|
||||
|
||||
// Check that the order is preserved
|
||||
expect(filteredMessages[0].type).toBe("text"); // Initial user message
|
||||
expect(filteredMessages[1].type).toBe("text"); // Assistant response
|
||||
expect(filteredMessages[2].type).toBe("tool_use"); // First tool
|
||||
expect(filteredMessages[3].type).toBe("tool_result"); // First result
|
||||
expect(filteredMessages[3].actionExecutionId).toBe("tool-1"); // First result
|
||||
expect(filteredMessages[4].type).toBe("text"); // Assistant comment
|
||||
expect(filteredMessages[5].type).toBe("tool_use"); // Second tool
|
||||
expect(filteredMessages[6].type).toBe("tool_result"); // Second result
|
||||
expect(filteredMessages[6].actionExecutionId).toBe("tool-2"); // Second result
|
||||
expect(filteredMessages[7].type).toBe("tool_use"); // Third tool
|
||||
expect(filteredMessages[8].type).toBe("tool_result"); // Third result
|
||||
expect(filteredMessages[8].actionExecutionId).toBe("tool-3"); // Third result
|
||||
expect(filteredMessages[9].type).toBe("text"); // Final user message
|
||||
|
||||
// Each valid tool ID should appear exactly once in the results
|
||||
const toolResultCounts = {
|
||||
"tool-1": 0,
|
||||
"tool-2": 0,
|
||||
"tool-3": 0,
|
||||
};
|
||||
|
||||
filteredMessages.forEach((message) => {
|
||||
if (
|
||||
message.type === "tool_result" &&
|
||||
message.actionExecutionId in toolResultCounts
|
||||
) {
|
||||
toolResultCounts[message.actionExecutionId]++;
|
||||
}
|
||||
});
|
||||
|
||||
expect(toolResultCounts["tool-1"]).toBe(1);
|
||||
expect(toolResultCounts["tool-2"]).toBe(1);
|
||||
expect(toolResultCounts["tool-3"]).toBe(1);
|
||||
});
|
||||
|
||||
it("should handle an empty message array", () => {
|
||||
const validToolUseIds = new Set<string>(["valid-id-1", "valid-id-2"]);
|
||||
const messages = [];
|
||||
|
||||
// Apply the filtering logic
|
||||
const filteredMessages = [];
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool_result") {
|
||||
if (
|
||||
!validToolUseIds.has(message.actionExecutionId) ||
|
||||
processedIds.has(message.actionExecutionId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
processedIds.add(message.actionExecutionId);
|
||||
}
|
||||
filteredMessages.push(message);
|
||||
}
|
||||
|
||||
expect(filteredMessages.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle edge cases with mixed message types", () => {
|
||||
// Setup with mixed message types
|
||||
const validToolUseIds = new Set<string>(["valid-id-1"]);
|
||||
|
||||
const messages = [
|
||||
{ type: "text", role: "user", content: "Hello" },
|
||||
{ type: "image", url: "https://example.com/image.jpg" }, // Non-tool message type
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-1",
|
||||
result: "result1",
|
||||
},
|
||||
{ type: "custom", data: { key: "value" } }, // Another custom type
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-1",
|
||||
result: "duplicate",
|
||||
}, // Duplicate
|
||||
{ type: "null", value: null }, // Edge case
|
||||
{ type: "undefined" }, // Edge case
|
||||
];
|
||||
|
||||
// Apply the filtering logic
|
||||
const filteredMessages = [];
|
||||
const processedIds = new Set<string>();
|
||||
|
||||
for (const message of messages) {
|
||||
if (message.type === "tool_result") {
|
||||
if (
|
||||
!validToolUseIds.has(message.actionExecutionId) ||
|
||||
processedIds.has(message.actionExecutionId)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
processedIds.add(message.actionExecutionId);
|
||||
}
|
||||
filteredMessages.push(message);
|
||||
}
|
||||
|
||||
// Should have all non-tool_result messages + 1 valid tool_result
|
||||
expect(filteredMessages.length).toBe(6); // 7 original - 1 duplicate
|
||||
|
||||
// Valid tool_result should be included exactly once
|
||||
const toolResults = filteredMessages.filter(
|
||||
(m) => m.type === "tool_result",
|
||||
);
|
||||
expect(toolResults.length).toBe(1);
|
||||
expect(toolResults[0].actionExecutionId).toBe("valid-id-1");
|
||||
|
||||
// All other message types should be preserved
|
||||
expect(filteredMessages.filter((m) => m.type === "text").length).toBe(1);
|
||||
expect(filteredMessages.filter((m) => m.type === "image").length).toBe(1);
|
||||
expect(filteredMessages.filter((m) => m.type === "custom").length).toBe(1);
|
||||
expect(filteredMessages.filter((m) => m.type === "null").length).toBe(1);
|
||||
expect(filteredMessages.filter((m) => m.type === "undefined").length).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
});
|
||||
+102
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { AnthropicProviderSettings } from "@ai-sdk/anthropic";
|
||||
import { AnthropicAdapter } from "../../../src/service-adapters/anthropic/anthropic-adapter";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
|
||||
// Keys from AnthropicProviderSettings that we forward from the Anthropic SDK client.
|
||||
type ForwardedAnthropicKeys = "baseURL" | "apiKey" | "headers" | "fetch";
|
||||
|
||||
// We don't set `name` or `generateId` — they're provider-internal concerns.
|
||||
// `authToken` is an alternative auth mechanism the adapter does not forward.
|
||||
type ControlledAnthropicKeys = "name" | "generateId" | "authToken";
|
||||
|
||||
// Compile-time exhaustiveness check: every key in AnthropicProviderSettings
|
||||
// must be accounted for. If this line errors, a new key was added.
|
||||
type _exhaustive =
|
||||
Exclude<
|
||||
keyof AnthropicProviderSettings,
|
||||
ForwardedAnthropicKeys | ControlledAnthropicKeys
|
||||
> extends never
|
||||
? true
|
||||
: {
|
||||
error: "AnthropicProviderSettings has unhandled keys";
|
||||
unhandled: Exclude<
|
||||
keyof AnthropicProviderSettings,
|
||||
ForwardedAnthropicKeys | ControlledAnthropicKeys
|
||||
>;
|
||||
};
|
||||
const _check: _exhaustive = true;
|
||||
|
||||
const { mockProviderFn, mockCreateAnthropic } = vi.hoisted(() => {
|
||||
const mockProviderFn = vi.fn().mockReturnValue({ modelId: "test-model" });
|
||||
const mockCreateAnthropic = vi.fn().mockReturnValue(mockProviderFn);
|
||||
return { mockProviderFn, mockCreateAnthropic };
|
||||
});
|
||||
|
||||
vi.mock("@ai-sdk/anthropic", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@ai-sdk/anthropic")>();
|
||||
return { ...actual, createAnthropic: mockCreateAnthropic };
|
||||
});
|
||||
|
||||
vi.mock("@anthropic-ai/sdk", () => {
|
||||
return {
|
||||
default: class MockAnthropic {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
_options: Record<string, any>;
|
||||
messages = { create: vi.fn() };
|
||||
|
||||
constructor(opts: any = {}) {
|
||||
this.baseURL = opts.baseURL ?? "https://api.anthropic.com/v1";
|
||||
this.apiKey = opts.apiKey ?? "default-key";
|
||||
this._options = {
|
||||
defaultHeaders: opts.defaultHeaders,
|
||||
fetch: opts.fetch,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("AnthropicAdapter.getLanguageModel()", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("forwards all provider-relevant options from the Anthropic SDK client", () => {
|
||||
const customFetch = vi.fn();
|
||||
const anthropic = new Anthropic({
|
||||
apiKey: "sk-ant-test",
|
||||
baseURL: "https://proxy.example.com/v1",
|
||||
defaultHeaders: { "x-custom": "value" },
|
||||
fetch: customFetch,
|
||||
});
|
||||
|
||||
const adapter = new AnthropicAdapter({
|
||||
anthropic,
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
});
|
||||
adapter.getLanguageModel();
|
||||
|
||||
expect(mockCreateAnthropic).toHaveBeenCalledOnce();
|
||||
const settings = mockCreateAnthropic.mock.calls[0][0];
|
||||
|
||||
expect(settings.baseURL).toBe("https://proxy.example.com/v1");
|
||||
expect(settings.apiKey).toBe("sk-ant-test");
|
||||
expect(settings.headers).toEqual({ "x-custom": "value" });
|
||||
expect(settings.fetch).toBe(customFetch);
|
||||
|
||||
expect(mockProviderFn).toHaveBeenCalledWith("claude-3-5-sonnet-latest");
|
||||
});
|
||||
|
||||
it("works with default Anthropic config (no custom options)", () => {
|
||||
const anthropic = new Anthropic({ apiKey: "sk-ant-default" });
|
||||
const adapter = new AnthropicAdapter({ anthropic });
|
||||
adapter.getLanguageModel();
|
||||
|
||||
const settings = mockCreateAnthropic.mock.calls[0][0];
|
||||
expect(settings.baseURL).toBe("https://api.anthropic.com/v1");
|
||||
expect(settings.apiKey).toBe("sk-ant-default");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,687 @@
|
||||
import { AnthropicAdapter } from "../../../src/service-adapters/anthropic/anthropic-adapter";
|
||||
import {
|
||||
TextMessage,
|
||||
ActionExecutionMessage,
|
||||
ResultMessage,
|
||||
Role,
|
||||
} from "../../../src/graphql/types/converted";
|
||||
|
||||
// Mock only the Anthropic SDK, not our adapter
|
||||
vi.mock("@anthropic-ai/sdk", () => {
|
||||
return {
|
||||
default: vi.fn().mockImplementation(() => ({
|
||||
messages: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock the message classes
|
||||
vi.mock("../../../src/graphql/types/converted", () => {
|
||||
class MockTextMessage {
|
||||
content: string;
|
||||
role: string;
|
||||
id: string;
|
||||
|
||||
constructor(options: { role: string; content: string }) {
|
||||
this.role = options.role;
|
||||
this.content = options.content;
|
||||
this.id = "mock-text-" + Math.random().toString(36).slice(7);
|
||||
}
|
||||
|
||||
isTextMessage() {
|
||||
return true;
|
||||
}
|
||||
isImageMessage() {
|
||||
return false;
|
||||
}
|
||||
isActionExecutionMessage() {
|
||||
return false;
|
||||
}
|
||||
isResultMessage() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class MockActionExecutionMessage {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
|
||||
constructor(params: { id: string; name: string; arguments: string }) {
|
||||
this.id = params.id;
|
||||
this.name = params.name;
|
||||
this.arguments = params.arguments;
|
||||
}
|
||||
|
||||
isTextMessage() {
|
||||
return false;
|
||||
}
|
||||
isImageMessage() {
|
||||
return false;
|
||||
}
|
||||
isActionExecutionMessage() {
|
||||
return true;
|
||||
}
|
||||
isResultMessage() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class MockResultMessage {
|
||||
actionExecutionId: string;
|
||||
result: string;
|
||||
id: string;
|
||||
|
||||
constructor(params: { actionExecutionId: string; result: string }) {
|
||||
this.actionExecutionId = params.actionExecutionId;
|
||||
this.result = params.result;
|
||||
this.id = "mock-result-" + Math.random().toString(36).slice(7);
|
||||
}
|
||||
|
||||
isTextMessage() {
|
||||
return false;
|
||||
}
|
||||
isImageMessage() {
|
||||
return false;
|
||||
}
|
||||
isActionExecutionMessage() {
|
||||
return false;
|
||||
}
|
||||
isResultMessage() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
TextMessage: MockTextMessage,
|
||||
ActionExecutionMessage: MockActionExecutionMessage,
|
||||
ResultMessage: MockResultMessage,
|
||||
Role: {
|
||||
assistant: "assistant",
|
||||
developer: "developer",
|
||||
system: "system",
|
||||
tool: "tool",
|
||||
user: "user",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("AnthropicAdapter", () => {
|
||||
let adapter: AnthropicAdapter;
|
||||
let mockEventSource: any;
|
||||
let mockAnthropicCreate: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create a mock Anthropic instance
|
||||
const mockAnthropic = {
|
||||
messages: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
// Create adapter with the mocked instance
|
||||
adapter = new AnthropicAdapter({ anthropic: mockAnthropic as any });
|
||||
|
||||
// Mock the create method to capture what's being sent
|
||||
mockAnthropicCreate = mockAnthropic.messages.create;
|
||||
|
||||
mockEventSource = {
|
||||
stream: vi.fn((callback) => {
|
||||
const mockStream = {
|
||||
sendTextMessageStart: vi.fn(),
|
||||
sendTextMessageContent: vi.fn(),
|
||||
sendTextMessageEnd: vi.fn(),
|
||||
sendActionExecutionStart: vi.fn(),
|
||||
sendActionExecutionArgs: vi.fn(),
|
||||
sendActionExecutionEnd: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
};
|
||||
callback(mockStream);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Deduplication Logic", () => {
|
||||
it("should filter out duplicate result messages", async () => {
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
const userMessage = new TextMessage({
|
||||
role: Role.user,
|
||||
content: "Set theme to orange",
|
||||
});
|
||||
|
||||
// Tool execution
|
||||
const toolExecution = new ActionExecutionMessage({
|
||||
id: "tool-123",
|
||||
name: "setThemeColor",
|
||||
arguments: '{"themeColor": "orange"}',
|
||||
});
|
||||
|
||||
// Multiple duplicate results (this was causing the infinite loop)
|
||||
const result1 = new ResultMessage({
|
||||
actionExecutionId: "tool-123",
|
||||
result: "Theme color set to orange",
|
||||
});
|
||||
const result2 = new ResultMessage({
|
||||
actionExecutionId: "tool-123",
|
||||
result: "Theme color set to orange",
|
||||
});
|
||||
const result3 = new ResultMessage({
|
||||
actionExecutionId: "tool-123",
|
||||
result: "Theme color set to orange",
|
||||
});
|
||||
|
||||
// Mock Anthropic to return empty stream (simulating the original problem)
|
||||
mockAnthropicCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
// Empty stream - no content from Anthropic
|
||||
},
|
||||
});
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
model: "claude-3-5-sonnet-latest",
|
||||
messages: [
|
||||
systemMessage,
|
||||
userMessage,
|
||||
toolExecution,
|
||||
result1,
|
||||
result2,
|
||||
result3,
|
||||
],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
// Check that only one result message was sent to Anthropic
|
||||
expect(mockAnthropicCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Set theme to orange" }],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "assistant",
|
||||
content: [
|
||||
expect.objectContaining({
|
||||
type: "tool_use",
|
||||
id: "tool-123",
|
||||
name: "setThemeColor",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
expect.objectContaining({
|
||||
role: "user",
|
||||
content: [
|
||||
expect.objectContaining({
|
||||
type: "tool_result",
|
||||
content: "Theme color set to orange",
|
||||
tool_use_id: "tool-123",
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify only 3 messages sent (user, assistant tool_use, user tool_result) - no duplicates
|
||||
const sentMessages = mockAnthropicCreate.mock.calls[0][0].messages;
|
||||
expect(sentMessages).toHaveLength(3);
|
||||
|
||||
// Count tool_result messages - should be only 1
|
||||
const toolResults = sentMessages.filter(
|
||||
(msg: any) =>
|
||||
msg.role === "user" &&
|
||||
msg.content.some((c: any) => c.type === "tool_result"),
|
||||
);
|
||||
expect(toolResults).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("should filter out invalid result messages without corresponding tool_use", async () => {
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
|
||||
// Valid tool execution
|
||||
const validTool = new ActionExecutionMessage({
|
||||
id: "valid-tool",
|
||||
name: "validAction",
|
||||
arguments: "{}",
|
||||
});
|
||||
const validResult = new ResultMessage({
|
||||
actionExecutionId: "valid-tool",
|
||||
result: "Valid result",
|
||||
});
|
||||
|
||||
// Invalid result with no corresponding tool_use
|
||||
const invalidResult = new ResultMessage({
|
||||
actionExecutionId: "nonexistent-tool",
|
||||
result: "Invalid result",
|
||||
});
|
||||
|
||||
mockAnthropicCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
});
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
messages: [systemMessage, validTool, validResult, invalidResult],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
const sentMessages = mockAnthropicCreate.mock.calls[0][0].messages;
|
||||
|
||||
// Should only include the valid tool result
|
||||
const toolResults = sentMessages.filter(
|
||||
(msg: any) =>
|
||||
msg.role === "user" &&
|
||||
msg.content.some((c: any) => c.type === "tool_result"),
|
||||
);
|
||||
expect(toolResults).toHaveLength(1);
|
||||
expect(toolResults[0].content[0].tool_use_id).toBe("valid-tool");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Fallback Response Logic", () => {
|
||||
it("should generate contextual fallback when Anthropic returns no content", async () => {
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
const userMessage = new TextMessage({
|
||||
role: Role.user,
|
||||
content: "Set theme to orange",
|
||||
});
|
||||
|
||||
const toolExecution = new ActionExecutionMessage({
|
||||
id: "tool-123",
|
||||
name: "setThemeColor",
|
||||
arguments: '{"themeColor": "orange"}',
|
||||
});
|
||||
|
||||
const toolResult = new ResultMessage({
|
||||
actionExecutionId: "tool-123",
|
||||
result: "Theme color set to orange",
|
||||
});
|
||||
|
||||
// Mock Anthropic to return empty stream
|
||||
mockAnthropicCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
// No content blocks - simulates Anthropic not responding
|
||||
},
|
||||
});
|
||||
|
||||
const mockStream = {
|
||||
sendTextMessageStart: vi.fn(),
|
||||
sendTextMessageContent: vi.fn(),
|
||||
sendTextMessageEnd: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventSource.stream.mockImplementation((callback) => {
|
||||
callback(mockStream);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
messages: [systemMessage, userMessage, toolExecution, toolResult],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
// Should generate fallback response with the tool result content
|
||||
expect(mockStream.sendTextMessageStart).toHaveBeenCalled();
|
||||
expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
|
||||
messageId: expect.any(String),
|
||||
content: "Theme color set to orange", // Uses the actual result content
|
||||
});
|
||||
expect(mockStream.sendTextMessageEnd).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should use generic fallback when no tool result content available", async () => {
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
|
||||
const toolExecution = new ActionExecutionMessage({
|
||||
id: "tool-123",
|
||||
name: "someAction",
|
||||
arguments: "{}",
|
||||
});
|
||||
|
||||
const toolResult = new ResultMessage({
|
||||
actionExecutionId: "tool-123",
|
||||
result: "", // Empty result
|
||||
});
|
||||
|
||||
mockAnthropicCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
});
|
||||
|
||||
const mockStream = {
|
||||
sendTextMessageStart: vi.fn(),
|
||||
sendTextMessageContent: vi.fn(),
|
||||
sendTextMessageEnd: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
};
|
||||
|
||||
mockEventSource.stream.mockImplementation((callback) => {
|
||||
callback(mockStream);
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
messages: [systemMessage, toolExecution, toolResult],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
// Should use generic fallback
|
||||
expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
|
||||
messageId: expect.any(String),
|
||||
content: "Task completed successfully.",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Unknown Tool Use Handling", () => {
|
||||
it("should skip unknown tool_use blocks without crashing", async () => {
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
const userMessage = new TextMessage({
|
||||
role: Role.user,
|
||||
content: "Do something",
|
||||
});
|
||||
|
||||
// Mock Anthropic to return a stream with an unknown tool_use block
|
||||
mockAnthropicCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield { type: "message_start", message: { id: "msg-1" } };
|
||||
// Unknown tool_use block — tool name not in the actions list
|
||||
yield {
|
||||
type: "content_block_start",
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "tool-unknown",
|
||||
name: "nonexistent_tool",
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "content_block_delta",
|
||||
delta: { type: "input_json_delta", partial_json: '{"query":' },
|
||||
};
|
||||
yield {
|
||||
type: "content_block_delta",
|
||||
delta: { type: "input_json_delta", partial_json: '"test"}' },
|
||||
};
|
||||
yield { type: "content_block_stop" };
|
||||
// Then a normal text block
|
||||
yield {
|
||||
type: "content_block_start",
|
||||
content_block: { type: "text" },
|
||||
};
|
||||
yield {
|
||||
type: "content_block_delta",
|
||||
delta: { type: "text_delta", text: "Here is the result." },
|
||||
};
|
||||
yield { type: "content_block_stop" };
|
||||
},
|
||||
});
|
||||
|
||||
const mockStream = {
|
||||
sendTextMessageStart: vi.fn(),
|
||||
sendTextMessageContent: vi.fn(),
|
||||
sendTextMessageEnd: vi.fn(),
|
||||
sendActionExecutionStart: vi.fn(),
|
||||
sendActionExecutionArgs: vi.fn(),
|
||||
sendActionExecutionEnd: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
};
|
||||
|
||||
let streamCallbackDone: Promise<void>;
|
||||
mockEventSource.stream.mockImplementation((callback: any) => {
|
||||
streamCallbackDone = callback(mockStream);
|
||||
});
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
messages: [systemMessage, userMessage],
|
||||
actions: [
|
||||
{
|
||||
name: "known_tool",
|
||||
description: "A known tool",
|
||||
jsonSchema: '{"type":"object","properties":{}}',
|
||||
},
|
||||
],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
// Wait for async stream processing to complete
|
||||
await streamCallbackDone!;
|
||||
|
||||
// Should NOT have sent action execution events for the unknown tool
|
||||
expect(mockStream.sendActionExecutionStart).not.toHaveBeenCalled();
|
||||
expect(mockStream.sendActionExecutionArgs).not.toHaveBeenCalled();
|
||||
expect(mockStream.sendActionExecutionEnd).not.toHaveBeenCalled();
|
||||
|
||||
// Should still process the text block normally
|
||||
expect(mockStream.sendTextMessageStart).toHaveBeenCalled();
|
||||
expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
|
||||
messageId: "msg-1",
|
||||
content: "Here is the result.",
|
||||
});
|
||||
expect(mockStream.sendTextMessageEnd).toHaveBeenCalled();
|
||||
expect(mockStream.complete).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trigger fallback when only unknown tool_use blocks are returned", async () => {
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
const userMessage = new TextMessage({
|
||||
role: Role.user,
|
||||
content: "Do something",
|
||||
});
|
||||
|
||||
const toolExecution = new ActionExecutionMessage({
|
||||
id: "tool-prev",
|
||||
name: "someAction",
|
||||
arguments: "{}",
|
||||
});
|
||||
|
||||
const toolResult = new ResultMessage({
|
||||
actionExecutionId: "tool-prev",
|
||||
result: "Previous result",
|
||||
});
|
||||
|
||||
// Mock Anthropic to return ONLY an unknown tool_use block
|
||||
mockAnthropicCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {
|
||||
yield { type: "message_start", message: { id: "msg-1" } };
|
||||
yield {
|
||||
type: "content_block_start",
|
||||
content_block: {
|
||||
type: "tool_use",
|
||||
id: "tool-unknown",
|
||||
name: "nonexistent_tool",
|
||||
},
|
||||
};
|
||||
yield {
|
||||
type: "content_block_delta",
|
||||
delta: { type: "input_json_delta", partial_json: "{}" },
|
||||
};
|
||||
yield { type: "content_block_stop" };
|
||||
},
|
||||
});
|
||||
|
||||
const mockStream = {
|
||||
sendTextMessageStart: vi.fn(),
|
||||
sendTextMessageContent: vi.fn(),
|
||||
sendTextMessageEnd: vi.fn(),
|
||||
sendActionExecutionStart: vi.fn(),
|
||||
sendActionExecutionArgs: vi.fn(),
|
||||
sendActionExecutionEnd: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
};
|
||||
|
||||
let streamCallbackDone: Promise<void>;
|
||||
mockEventSource.stream.mockImplementation((callback: any) => {
|
||||
streamCallbackDone = callback(mockStream);
|
||||
});
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
messages: [systemMessage, userMessage, toolExecution, toolResult],
|
||||
actions: [
|
||||
{
|
||||
name: "known_tool",
|
||||
description: "A known tool",
|
||||
jsonSchema: '{"type":"object","properties":{}}',
|
||||
},
|
||||
],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
// Wait for async stream processing to complete
|
||||
await streamCallbackDone!;
|
||||
|
||||
// Should NOT have sent action execution events
|
||||
expect(mockStream.sendActionExecutionStart).not.toHaveBeenCalled();
|
||||
|
||||
// Should trigger fallback since hasReceivedContent should be false
|
||||
expect(mockStream.sendTextMessageStart).toHaveBeenCalled();
|
||||
expect(mockStream.sendTextMessageContent).toHaveBeenCalledWith({
|
||||
messageId: expect.any(String),
|
||||
content: "Previous result",
|
||||
});
|
||||
expect(mockStream.sendTextMessageEnd).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AnthropicAdapter max_tokens default", () => {
|
||||
let mockAnthropicCreate: any;
|
||||
let mockEventSource: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should default max_tokens to 4096 when not specified", async () => {
|
||||
const mockAnthropic = {
|
||||
messages: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = new AnthropicAdapter({ anthropic: mockAnthropic as any });
|
||||
mockAnthropicCreate = mockAnthropic.messages.create;
|
||||
|
||||
mockAnthropicCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
});
|
||||
|
||||
mockEventSource = {
|
||||
stream: vi.fn((callback) => {
|
||||
const mockStream = {
|
||||
sendTextMessageStart: vi.fn(),
|
||||
sendTextMessageContent: vi.fn(),
|
||||
sendTextMessageEnd: vi.fn(),
|
||||
sendActionExecutionStart: vi.fn(),
|
||||
sendActionExecutionArgs: vi.fn(),
|
||||
sendActionExecutionEnd: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
};
|
||||
callback(mockStream);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
const userMessage = new TextMessage({ role: Role.user, content: "Hello" });
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
messages: [systemMessage, userMessage],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
const createCallArgs = mockAnthropicCreate.mock.calls[0][0];
|
||||
expect(createCallArgs.max_tokens).toBe(4096);
|
||||
});
|
||||
|
||||
it("should use provided maxTokens when specified", async () => {
|
||||
const mockAnthropic = {
|
||||
messages: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
const adapter = new AnthropicAdapter({ anthropic: mockAnthropic as any });
|
||||
mockAnthropicCreate = mockAnthropic.messages.create;
|
||||
|
||||
mockAnthropicCreate.mockResolvedValue({
|
||||
[Symbol.asyncIterator]: async function* () {},
|
||||
});
|
||||
|
||||
mockEventSource = {
|
||||
stream: vi.fn((callback) => {
|
||||
const mockStream = {
|
||||
sendTextMessageStart: vi.fn(),
|
||||
sendTextMessageContent: vi.fn(),
|
||||
sendTextMessageEnd: vi.fn(),
|
||||
sendActionExecutionStart: vi.fn(),
|
||||
sendActionExecutionArgs: vi.fn(),
|
||||
sendActionExecutionEnd: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
};
|
||||
callback(mockStream);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
};
|
||||
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
const userMessage = new TextMessage({ role: Role.user, content: "Hello" });
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
messages: [systemMessage, userMessage],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: { maxTokens: 8192 },
|
||||
});
|
||||
|
||||
const createCallArgs = mockAnthropicCreate.mock.calls[0][0];
|
||||
expect(createCallArgs.max_tokens).toBe(8192);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,301 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { limitMessagesToTokenCount } from "../../../src/service-adapters/anthropic/utils";
|
||||
|
||||
// Helper to build messages for testing. The token counter is length/3,
|
||||
// so we can control token counts via string length.
|
||||
|
||||
function textUser(text: string) {
|
||||
return { role: "user", content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function textAssistant(text: string) {
|
||||
return { role: "assistant", content: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
function toolUseAssistant(id: string, name = "my_tool", input = {}) {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id, name, input }],
|
||||
};
|
||||
}
|
||||
|
||||
function toolResultUser(toolUseId: string, content = "result") {
|
||||
return {
|
||||
role: "user",
|
||||
content: [{ type: "tool_result", tool_use_id: toolUseId, content }],
|
||||
};
|
||||
}
|
||||
|
||||
function mixedAssistant(blocks: any[]) {
|
||||
return { role: "assistant", content: blocks };
|
||||
}
|
||||
|
||||
function mixedUser(blocks: any[]) {
|
||||
return { role: "user", content: blocks };
|
||||
}
|
||||
|
||||
describe("limitMessagesToTokenCount - orphan handling", () => {
|
||||
// Use a high token limit so trimming doesn't kick in for these tests
|
||||
const HIGH_LIMIT = 999999;
|
||||
|
||||
it("preserves matched tool_use / tool_result pairs", () => {
|
||||
const messages = [
|
||||
textUser("hello"),
|
||||
toolUseAssistant("t1", "tool_a"),
|
||||
toolResultUser("t1", "done"),
|
||||
textAssistant("ok"),
|
||||
];
|
||||
|
||||
const result = limitMessagesToTokenCount(
|
||||
messages,
|
||||
[],
|
||||
"claude-3",
|
||||
HIGH_LIMIT,
|
||||
);
|
||||
|
||||
// All four messages should survive
|
||||
expect(result).toHaveLength(4);
|
||||
// The tool_use and tool_result should still be present
|
||||
const toolUse = result.find(
|
||||
(m: any) =>
|
||||
m.role === "assistant" &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some((b: any) => b.type === "tool_use"),
|
||||
);
|
||||
const toolResult = result.find(
|
||||
(m: any) =>
|
||||
m.role === "user" &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some((b: any) => b.type === "tool_result"),
|
||||
);
|
||||
expect(toolUse).toBeDefined();
|
||||
expect(toolResult).toBeDefined();
|
||||
});
|
||||
|
||||
it("removes orphaned tool_result when tool_use was trimmed", () => {
|
||||
// Simulate: tool_use message was removed by token trimming, leaving
|
||||
// a tool_result without a matching tool_use.
|
||||
const messages = [
|
||||
textUser("hello"),
|
||||
// no toolUseAssistant for "t1"
|
||||
toolResultUser("t1", "orphaned result"),
|
||||
textAssistant("ok"),
|
||||
];
|
||||
|
||||
const result = limitMessagesToTokenCount(
|
||||
messages,
|
||||
[],
|
||||
"claude-3",
|
||||
HIGH_LIMIT,
|
||||
);
|
||||
|
||||
// The orphaned tool_result message should be gone
|
||||
const hasToolResult = result.some(
|
||||
(m: any) =>
|
||||
m.role === "user" &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some((b: any) => b.type === "tool_result"),
|
||||
);
|
||||
expect(hasToolResult).toBe(false);
|
||||
expect(result).toHaveLength(2); // textUser + textAssistant
|
||||
});
|
||||
|
||||
it("removes orphaned tool_use when tool_result was trimmed", () => {
|
||||
// Simulate: tool_result message was removed by token trimming, leaving
|
||||
// a tool_use without a matching tool_result.
|
||||
const messages = [
|
||||
textUser("hello"),
|
||||
toolUseAssistant("t1", "tool_a"),
|
||||
// no toolResultUser for "t1"
|
||||
textAssistant("ok"),
|
||||
];
|
||||
|
||||
const result = limitMessagesToTokenCount(
|
||||
messages,
|
||||
[],
|
||||
"claude-3",
|
||||
HIGH_LIMIT,
|
||||
);
|
||||
|
||||
// The orphaned tool_use message should be gone
|
||||
const hasToolUse = result.some(
|
||||
(m: any) =>
|
||||
m.role === "assistant" &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some((b: any) => b.type === "tool_use"),
|
||||
);
|
||||
expect(hasToolUse).toBe(false);
|
||||
expect(result).toHaveLength(2); // textUser + textAssistant
|
||||
});
|
||||
|
||||
it("retains non-orphaned blocks in mixed-content messages", () => {
|
||||
// Assistant message has both a text block and an orphaned tool_use
|
||||
const messages = [
|
||||
textUser("hello"),
|
||||
mixedAssistant([
|
||||
{ type: "text", text: "thinking..." },
|
||||
{ type: "tool_use", id: "t1", name: "tool_a", input: {} },
|
||||
]),
|
||||
// no tool_result for t1
|
||||
textAssistant("done"),
|
||||
];
|
||||
|
||||
const result = limitMessagesToTokenCount(
|
||||
messages,
|
||||
[],
|
||||
"claude-3",
|
||||
HIGH_LIMIT,
|
||||
);
|
||||
|
||||
// The assistant message should survive with only the text block
|
||||
const assistantMixed = result.find(
|
||||
(m: any) =>
|
||||
m.role === "assistant" &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some(
|
||||
(b: any) => b.type === "text" && b.text === "thinking...",
|
||||
),
|
||||
);
|
||||
expect(assistantMixed).toBeDefined();
|
||||
expect(assistantMixed.content).toHaveLength(1);
|
||||
expect(assistantMixed.content[0].type).toBe("text");
|
||||
});
|
||||
|
||||
it("retains non-orphaned blocks in mixed user messages", () => {
|
||||
// User message has both a text block and an orphaned tool_result
|
||||
const messages = [
|
||||
textUser("hello"),
|
||||
mixedUser([
|
||||
{ type: "text", text: "here is context" },
|
||||
{ type: "tool_result", tool_use_id: "t_missing", content: "orphan" },
|
||||
]),
|
||||
textAssistant("ok"),
|
||||
];
|
||||
|
||||
const result = limitMessagesToTokenCount(
|
||||
messages,
|
||||
[],
|
||||
"claude-3",
|
||||
HIGH_LIMIT,
|
||||
);
|
||||
|
||||
const userMixed = result.find(
|
||||
(m: any) =>
|
||||
m.role === "user" &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some(
|
||||
(b: any) => b.type === "text" && b.text === "here is context",
|
||||
),
|
||||
);
|
||||
expect(userMixed).toBeDefined();
|
||||
expect(userMixed.content).toHaveLength(1);
|
||||
expect(userMixed.content[0].type).toBe("text");
|
||||
});
|
||||
|
||||
it("drops message entirely when all blocks are orphaned", () => {
|
||||
const messages = [
|
||||
textUser("hello"),
|
||||
mixedUser([
|
||||
{ type: "tool_result", tool_use_id: "t_a", content: "orphan a" },
|
||||
{ type: "tool_result", tool_use_id: "t_b", content: "orphan b" },
|
||||
]),
|
||||
textAssistant("ok"),
|
||||
];
|
||||
|
||||
const result = limitMessagesToTokenCount(
|
||||
messages,
|
||||
[],
|
||||
"claude-3",
|
||||
HIGH_LIMIT,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].role).toBe("user");
|
||||
expect(result[1].role).toBe("assistant");
|
||||
});
|
||||
|
||||
it("drops assistant message entirely when all tool_use blocks are orphaned", () => {
|
||||
const messages = [
|
||||
textUser("hello"),
|
||||
mixedAssistant([
|
||||
{ type: "tool_use", id: "t_x", name: "tool_x", input: {} },
|
||||
{ type: "tool_use", id: "t_y", name: "tool_y", input: {} },
|
||||
]),
|
||||
// no tool_results for either
|
||||
textAssistant("done"),
|
||||
];
|
||||
|
||||
const result = limitMessagesToTokenCount(
|
||||
messages,
|
||||
[],
|
||||
"claude-3",
|
||||
HIGH_LIMIT,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("does not mutate the original messages array or message objects", () => {
|
||||
const originalContent = [
|
||||
{ type: "text", text: "context" },
|
||||
{ type: "tool_result", tool_use_id: "t_orphan", content: "orphan" },
|
||||
];
|
||||
const userMsg = { role: "user", content: [...originalContent] };
|
||||
const messages = [textUser("hello"), userMsg, textAssistant("ok")];
|
||||
|
||||
const result = limitMessagesToTokenCount(
|
||||
messages,
|
||||
[],
|
||||
"claude-3",
|
||||
HIGH_LIMIT,
|
||||
);
|
||||
|
||||
// Original message should still have both blocks
|
||||
expect(userMsg.content).toHaveLength(2);
|
||||
expect(userMsg.content[1].type).toBe("tool_result");
|
||||
|
||||
// Original messages array should still have 3 entries
|
||||
expect(messages).toHaveLength(3);
|
||||
|
||||
// Result should have the filtered version
|
||||
const filtered = result.find(
|
||||
(m: any) =>
|
||||
m.role === "user" &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some((b: any) => b.text === "context"),
|
||||
);
|
||||
expect(filtered).toBeDefined();
|
||||
expect(filtered.content).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("handles token trimming that creates orphans via cutoff", () => {
|
||||
// Build messages where token trimming will cut off early messages,
|
||||
// leaving orphaned tool_result for a tool_use that got trimmed.
|
||||
// Each char ~0.33 tokens, so 300 chars ~ 100 tokens
|
||||
const longText = "x".repeat(300);
|
||||
|
||||
const messages = [
|
||||
toolUseAssistant("t_old"),
|
||||
toolResultUser("t_old", "old result"),
|
||||
textUser(longText),
|
||||
textAssistant(longText),
|
||||
toolUseAssistant("t_new"),
|
||||
toolResultUser("t_new", "new result"),
|
||||
];
|
||||
|
||||
// Set a limit that keeps only the last few messages, trimming t_old's tool_use
|
||||
const result = limitMessagesToTokenCount(messages, [], "claude-3", 300);
|
||||
|
||||
// t_old's tool_use should have been trimmed by the token limit,
|
||||
// and then t_old's tool_result should be cleaned up as orphaned
|
||||
const hasOldResult = result.some(
|
||||
(m: any) =>
|
||||
m.role === "user" &&
|
||||
Array.isArray(m.content) &&
|
||||
m.content.some(
|
||||
(b: any) => b.type === "tool_result" && b.tool_use_id === "t_old",
|
||||
),
|
||||
);
|
||||
expect(hasOldResult).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OpenAIProviderSettings } from "@ai-sdk/openai";
|
||||
import { GroqAdapter } from "../../../src/service-adapters/groq/groq-adapter";
|
||||
import { Groq } from "groq-sdk";
|
||||
|
||||
// Groq uses createOpenAI (OpenAI-compatible API), so we check against
|
||||
// OpenAIProviderSettings. Same exhaustiveness guard as the OpenAI test.
|
||||
type ForwardedGroqKeys = "baseURL" | "apiKey" | "headers" | "fetch";
|
||||
|
||||
// Keys we set ourselves or that don't apply to Groq.
|
||||
type ControlledGroqKeys = "name" | "organization" | "project";
|
||||
|
||||
type _exhaustive =
|
||||
Exclude<
|
||||
keyof OpenAIProviderSettings,
|
||||
ForwardedGroqKeys | ControlledGroqKeys
|
||||
> extends never
|
||||
? true
|
||||
: {
|
||||
error: "OpenAIProviderSettings has unhandled keys";
|
||||
unhandled: Exclude<
|
||||
keyof OpenAIProviderSettings,
|
||||
ForwardedGroqKeys | ControlledGroqKeys
|
||||
>;
|
||||
};
|
||||
const _check: _exhaustive = true;
|
||||
|
||||
const { mockProviderFn, mockCreateOpenAI } = vi.hoisted(() => {
|
||||
const mockProviderFn = vi.fn().mockReturnValue({ modelId: "test-model" });
|
||||
const mockCreateOpenAI = vi.fn().mockReturnValue(mockProviderFn);
|
||||
return { mockProviderFn, mockCreateOpenAI };
|
||||
});
|
||||
|
||||
vi.mock("@ai-sdk/openai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@ai-sdk/openai")>();
|
||||
return { ...actual, createOpenAI: mockCreateOpenAI };
|
||||
});
|
||||
|
||||
vi.mock("groq-sdk", () => {
|
||||
return {
|
||||
Groq: class MockGroq {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
_options: Record<string, any>;
|
||||
chat = { completions: { create: vi.fn() } };
|
||||
|
||||
constructor(opts: any = {}) {
|
||||
this.baseURL = opts.baseURL ?? "https://api.groq.com";
|
||||
this.apiKey = opts.apiKey ?? "default-key";
|
||||
this._options = {
|
||||
defaultHeaders: opts.defaultHeaders,
|
||||
fetch: opts.fetch,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("GroqAdapter.getLanguageModel()", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("forwards all provider-relevant options from the Groq SDK client", () => {
|
||||
const customFetch = vi.fn();
|
||||
const groq = new Groq({
|
||||
apiKey: "gsk-test",
|
||||
baseURL: "https://custom-groq.example.com",
|
||||
defaultHeaders: { "x-groq": "value" },
|
||||
fetch: customFetch,
|
||||
});
|
||||
|
||||
const adapter = new GroqAdapter({
|
||||
groq,
|
||||
model: "llama-3.3-70b-versatile",
|
||||
});
|
||||
adapter.getLanguageModel();
|
||||
|
||||
expect(mockCreateOpenAI).toHaveBeenCalledOnce();
|
||||
const settings = mockCreateOpenAI.mock.calls[0][0];
|
||||
|
||||
expect(settings.baseURL).toBe("https://custom-groq.example.com");
|
||||
expect(settings.apiKey).toBe("gsk-test");
|
||||
expect(settings.headers).toEqual({ "x-groq": "value" });
|
||||
expect(settings.fetch).toBe(customFetch);
|
||||
expect(settings.name).toBe("groq");
|
||||
|
||||
expect(mockProviderFn).toHaveBeenCalledWith("llama-3.3-70b-versatile");
|
||||
});
|
||||
|
||||
it("works with default Groq config (no custom options)", () => {
|
||||
const groq = new Groq({ apiKey: "gsk-default" });
|
||||
const adapter = new GroqAdapter({ groq });
|
||||
adapter.getLanguageModel();
|
||||
|
||||
const settings = mockCreateOpenAI.mock.calls[0][0];
|
||||
expect(settings.baseURL).toBe("https://api.groq.com");
|
||||
expect(settings.apiKey).toBe("gsk-default");
|
||||
expect(settings.name).toBe("groq");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,294 @@
|
||||
describe("OpenAI Adapter - Allowlist Approach", () => {
|
||||
it("should filter out tool_result messages with no corresponding tool_call ID", () => {
|
||||
// Setup test data
|
||||
const validToolCallIds = new Set<string>(["valid-id-1", "valid-id-2"]);
|
||||
|
||||
// Messages to filter - valid and invalid ones
|
||||
const messages = [
|
||||
{ type: "text", role: "user", content: "Hello" },
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-1",
|
||||
result: "result1",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "invalid-id",
|
||||
result: "invalid",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-2",
|
||||
result: "result2",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-1",
|
||||
result: "duplicate",
|
||||
}, // Duplicate ID
|
||||
];
|
||||
|
||||
// Implement the filtering logic, similar to the adapter
|
||||
const filteredMessages = messages.filter((message) => {
|
||||
if (message.type === "tool_result") {
|
||||
// Skip if there's no corresponding tool_call
|
||||
if (!validToolCallIds.has(message.actionExecutionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove this ID from valid IDs so we don't process duplicates
|
||||
validToolCallIds.delete(message.actionExecutionId);
|
||||
}
|
||||
|
||||
// Keep all non-tool-result messages
|
||||
return true;
|
||||
});
|
||||
|
||||
// Verify results
|
||||
expect(filteredMessages.length).toBe(3); // text + 2 valid tool results (no duplicates or invalid)
|
||||
|
||||
// Valid results should be included
|
||||
expect(
|
||||
filteredMessages.some(
|
||||
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
expect(
|
||||
filteredMessages.some(
|
||||
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-2",
|
||||
),
|
||||
).toBe(true);
|
||||
|
||||
// Invalid result should be excluded
|
||||
expect(
|
||||
filteredMessages.some(
|
||||
(m) => m.type === "tool_result" && m.actionExecutionId === "invalid-id",
|
||||
),
|
||||
).toBe(false);
|
||||
|
||||
// Duplicate should be excluded - we used a different approach than Anthropic
|
||||
const validId1Count = filteredMessages.filter(
|
||||
(m) => m.type === "tool_result" && m.actionExecutionId === "valid-id-1",
|
||||
).length;
|
||||
|
||||
expect(validId1Count).toBe(1);
|
||||
});
|
||||
|
||||
it("should maintain correct order of messages when filtering", () => {
|
||||
// Setup test data
|
||||
const validToolCallIds = new Set<string>(["tool-1", "tool-2", "tool-3"]);
|
||||
|
||||
// Test with a complex conversation pattern
|
||||
const messages = [
|
||||
{ type: "text", role: "user", content: "Initial message" },
|
||||
{ type: "text", role: "assistant", content: "I'll help with that" },
|
||||
{ type: "tool_call", id: "tool-1", name: "firstTool" },
|
||||
{ type: "tool_result", actionExecutionId: "tool-1", result: "result1" },
|
||||
{ type: "text", role: "assistant", content: "Got the first result" },
|
||||
{ type: "tool_call", id: "tool-2", name: "secondTool" },
|
||||
{ type: "tool_result", actionExecutionId: "tool-2", result: "result2" },
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "invalid-id",
|
||||
result: "invalid-result",
|
||||
},
|
||||
{ type: "tool_call", id: "tool-3", name: "thirdTool" },
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "tool-1",
|
||||
result: "duplicate-result",
|
||||
}, // Duplicate
|
||||
{ type: "tool_result", actionExecutionId: "tool-3", result: "result3" },
|
||||
{ type: "text", role: "user", content: "Final message" },
|
||||
];
|
||||
|
||||
// Apply OpenAI's filter approach (using filter instead of loop)
|
||||
const filteredMessages = messages.filter((message) => {
|
||||
if (message.type === "tool_result") {
|
||||
// Skip if there's no corresponding tool_call
|
||||
if (!validToolCallIds.has(message.actionExecutionId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove this ID from valid IDs so we don't process duplicates
|
||||
validToolCallIds.delete(message.actionExecutionId);
|
||||
}
|
||||
|
||||
// Keep all non-tool-result messages
|
||||
return true;
|
||||
});
|
||||
|
||||
// Verify results
|
||||
expect(filteredMessages.length).toBe(10); // 12 original - 2 filtered out
|
||||
|
||||
// Check that the message order is preserved
|
||||
expect(filteredMessages[0].type).toBe("text"); // Initial user message
|
||||
expect(filteredMessages[0].content).toBe("Initial message");
|
||||
expect(filteredMessages[1].type).toBe("text"); // Assistant response
|
||||
expect(filteredMessages[2].type).toBe("tool_call"); // First tool
|
||||
expect(filteredMessages[3].type).toBe("tool_result"); // First result
|
||||
expect(filteredMessages[3].actionExecutionId).toBe("tool-1");
|
||||
expect(filteredMessages[4].type).toBe("text"); // Assistant comment
|
||||
expect(filteredMessages[5].type).toBe("tool_call"); // Second tool
|
||||
expect(filteredMessages[6].type).toBe("tool_result"); // Second result
|
||||
expect(filteredMessages[6].actionExecutionId).toBe("tool-2");
|
||||
expect(filteredMessages[7].type).toBe("tool_call"); // Third tool
|
||||
expect(filteredMessages[8].type).toBe("tool_result"); // Third result
|
||||
expect(filteredMessages[8].actionExecutionId).toBe("tool-3");
|
||||
expect(filteredMessages[9].type).toBe("text"); // Final user message
|
||||
|
||||
// Each valid tool result should appear exactly once
|
||||
const toolResultCounts = new Map();
|
||||
filteredMessages.forEach((message) => {
|
||||
if (message.type === "tool_result") {
|
||||
const id = message.actionExecutionId;
|
||||
toolResultCounts.set(id, (toolResultCounts.get(id) || 0) + 1);
|
||||
}
|
||||
});
|
||||
|
||||
expect(toolResultCounts.size).toBe(3); // Should have 3 different tool results
|
||||
expect(toolResultCounts.get("tool-1")).toBe(1);
|
||||
expect(toolResultCounts.get("tool-2")).toBe(1);
|
||||
expect(toolResultCounts.get("tool-3")).toBe(1);
|
||||
expect(toolResultCounts.has("invalid-id")).toBe(false);
|
||||
});
|
||||
|
||||
it("should handle empty message array", () => {
|
||||
const validToolCallIds = new Set<string>(["valid-id-1", "valid-id-2"]);
|
||||
const messages = [];
|
||||
|
||||
// Apply OpenAI's filter approach
|
||||
const filteredMessages = messages.filter((message) => {
|
||||
if (message.type === "tool_result") {
|
||||
if (!validToolCallIds.has(message.actionExecutionId)) {
|
||||
return false;
|
||||
}
|
||||
validToolCallIds.delete(message.actionExecutionId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
expect(filteredMessages.length).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle edge cases with mixed message types", () => {
|
||||
// Setup test data with various message types
|
||||
const validToolCallIds = new Set<string>(["valid-id-1"]);
|
||||
|
||||
const messages = [
|
||||
{ type: "text", role: "user", content: "Hello" },
|
||||
{ type: "image", url: "https://example.com/image.jpg" }, // Non-tool message type
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-1",
|
||||
result: "result1",
|
||||
},
|
||||
{ type: "custom", data: { key: "value" } }, // Another custom type
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "valid-id-1",
|
||||
result: "duplicate",
|
||||
}, // Duplicate
|
||||
{ type: "null", value: null }, // Edge case
|
||||
{ type: "undefined" }, // Edge case
|
||||
];
|
||||
|
||||
// Apply OpenAI's filter approach
|
||||
const filteredMessages = messages.filter((message) => {
|
||||
if (message.type === "tool_result") {
|
||||
if (!validToolCallIds.has(message.actionExecutionId)) {
|
||||
return false;
|
||||
}
|
||||
validToolCallIds.delete(message.actionExecutionId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Should have all non-tool_result messages + 1 valid tool_result
|
||||
expect(filteredMessages.length).toBe(6); // 7 original - 1 duplicate
|
||||
|
||||
// Valid tool_result should be included exactly once
|
||||
const toolResults = filteredMessages.filter(
|
||||
(m) => m.type === "tool_result",
|
||||
);
|
||||
expect(toolResults.length).toBe(1);
|
||||
expect(toolResults[0].actionExecutionId).toBe("valid-id-1");
|
||||
|
||||
// All non-tool_result messages should be preserved
|
||||
expect(filteredMessages.filter((m) => m.type === "text").length).toBe(1);
|
||||
expect(filteredMessages.filter((m) => m.type === "image").length).toBe(1);
|
||||
expect(filteredMessages.filter((m) => m.type === "custom").length).toBe(1);
|
||||
expect(filteredMessages.filter((m) => m.type === "null").length).toBe(1);
|
||||
expect(filteredMessages.filter((m) => m.type === "undefined").length).toBe(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("should properly handle multiple duplicate tool results", () => {
|
||||
// Setup test data with multiple duplicates
|
||||
const validToolCallIds = new Set<string>(["tool-1", "tool-2"]);
|
||||
|
||||
const messages = [
|
||||
{ type: "text", role: "user", content: "Initial prompt" },
|
||||
{ type: "tool_call", id: "tool-1", name: "firstTool" },
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "tool-1",
|
||||
result: "first-result",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "tool-1",
|
||||
result: "duplicate-1",
|
||||
}, // Duplicate 1
|
||||
{ type: "tool_call", id: "tool-2", name: "secondTool" },
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "tool-1",
|
||||
result: "duplicate-2",
|
||||
}, // Duplicate 2
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "tool-2",
|
||||
result: "second-result",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "tool-2",
|
||||
result: "duplicate-3",
|
||||
}, // Duplicate 3
|
||||
{
|
||||
type: "tool_result",
|
||||
actionExecutionId: "tool-1",
|
||||
result: "duplicate-4",
|
||||
}, // Duplicate 4
|
||||
];
|
||||
|
||||
// Apply OpenAI's filter approach
|
||||
const filteredMessages = messages.filter((message) => {
|
||||
if (message.type === "tool_result") {
|
||||
if (!validToolCallIds.has(message.actionExecutionId)) {
|
||||
return false;
|
||||
}
|
||||
validToolCallIds.delete(message.actionExecutionId);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Should have text + tool calls + only the first occurrence of each tool result
|
||||
expect(filteredMessages.length).toBe(5);
|
||||
|
||||
// Check that only the first occurrence of each tool result is kept
|
||||
const toolResults = filteredMessages.filter(
|
||||
(m) => m.type === "tool_result",
|
||||
);
|
||||
expect(toolResults.length).toBe(2);
|
||||
|
||||
expect(toolResults[0].actionExecutionId).toBe("tool-1");
|
||||
expect(toolResults[0].result).toBe("first-result"); // First occurrence should be kept
|
||||
|
||||
expect(toolResults[1].actionExecutionId).toBe("tool-2");
|
||||
expect(toolResults[1].result).toBe("second-result"); // First occurrence should be kept
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import type { OpenAIProviderSettings } from "@ai-sdk/openai";
|
||||
import { OpenAIAdapter } from "../../../src/service-adapters/openai/openai-adapter";
|
||||
import OpenAI from "openai";
|
||||
|
||||
// Keys from OpenAIProviderSettings that we forward from the OpenAI SDK client.
|
||||
// If @ai-sdk/openai adds new keys, the type assertion below will fail at
|
||||
// compile time, forcing us to decide whether to forward them.
|
||||
type ForwardedOpenAIKeys =
|
||||
| "baseURL"
|
||||
| "apiKey"
|
||||
| "organization"
|
||||
| "project"
|
||||
| "headers"
|
||||
| "fetch";
|
||||
|
||||
// We set `name` ourselves (not forwarded from the SDK client).
|
||||
type ControlledOpenAIKeys = "name";
|
||||
|
||||
// Compile-time exhaustiveness check: every key in OpenAIProviderSettings must
|
||||
// be accounted for in either ForwardedOpenAIKeys or ControlledOpenAIKeys.
|
||||
// If this line errors, a new key was added to OpenAIProviderSettings that
|
||||
// needs to be handled.
|
||||
type _exhaustive =
|
||||
Exclude<
|
||||
keyof OpenAIProviderSettings,
|
||||
ForwardedOpenAIKeys | ControlledOpenAIKeys
|
||||
> extends never
|
||||
? true
|
||||
: {
|
||||
error: "OpenAIProviderSettings has unhandled keys";
|
||||
unhandled: Exclude<
|
||||
keyof OpenAIProviderSettings,
|
||||
ForwardedOpenAIKeys | ControlledOpenAIKeys
|
||||
>;
|
||||
};
|
||||
const _check: _exhaustive = true;
|
||||
|
||||
const { mockProviderFn, mockCreateOpenAI } = vi.hoisted(() => {
|
||||
const mockProviderFn = vi.fn().mockReturnValue({ modelId: "test-model" });
|
||||
const mockCreateOpenAI = vi.fn().mockReturnValue(mockProviderFn);
|
||||
return { mockProviderFn, mockCreateOpenAI };
|
||||
});
|
||||
|
||||
vi.mock("@ai-sdk/openai", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@ai-sdk/openai")>();
|
||||
return { ...actual, createOpenAI: mockCreateOpenAI };
|
||||
});
|
||||
|
||||
vi.mock("openai", () => {
|
||||
return {
|
||||
default: class MockOpenAI {
|
||||
baseURL: string;
|
||||
apiKey: string;
|
||||
organization: string | null;
|
||||
project: string | null;
|
||||
_options: Record<string, any>;
|
||||
beta = { chat: { completions: { stream: vi.fn() } } };
|
||||
|
||||
constructor(opts: any = {}) {
|
||||
this.baseURL = opts.baseURL ?? "https://api.openai.com/v1";
|
||||
this.apiKey = opts.apiKey ?? "default-key";
|
||||
this.organization = opts.organization ?? null;
|
||||
this.project = opts.project ?? null;
|
||||
this._options = {
|
||||
defaultHeaders: opts.defaultHeaders,
|
||||
defaultQuery: opts.defaultQuery,
|
||||
fetch: opts.fetch,
|
||||
...opts,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe("OpenAIAdapter.getLanguageModel()", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("forwards all provider-relevant options from the OpenAI SDK client", () => {
|
||||
const customFetch = vi.fn();
|
||||
const openai = new OpenAI({
|
||||
apiKey: "azure-key",
|
||||
baseURL: "https://myinstance.openai.azure.com/openai/deployments/gpt-4o",
|
||||
organization: "org-123",
|
||||
project: "proj-456",
|
||||
defaultHeaders: { "api-key": "azure-key" },
|
||||
defaultQuery: { "api-version": "2024-04-01-preview" },
|
||||
fetch: customFetch,
|
||||
});
|
||||
|
||||
const adapter = new OpenAIAdapter({ openai, model: "gpt-4o" });
|
||||
adapter.getLanguageModel();
|
||||
|
||||
expect(mockCreateOpenAI).toHaveBeenCalledOnce();
|
||||
const settings = mockCreateOpenAI.mock.calls[0][0];
|
||||
|
||||
expect(settings.baseURL).toBe(
|
||||
"https://myinstance.openai.azure.com/openai/deployments/gpt-4o",
|
||||
);
|
||||
expect(settings.apiKey).toBe("azure-key");
|
||||
expect(settings.organization).toBe("org-123");
|
||||
expect(settings.project).toBe("proj-456");
|
||||
expect(settings.headers).toEqual({ "api-key": "azure-key" });
|
||||
expect(settings.fetch).toBe(customFetch);
|
||||
|
||||
expect(mockProviderFn).toHaveBeenCalledWith("gpt-4o");
|
||||
});
|
||||
|
||||
it("works with default OpenAI config (no custom options)", () => {
|
||||
const openai = new OpenAI({ apiKey: "sk-test" });
|
||||
const adapter = new OpenAIAdapter({ openai });
|
||||
adapter.getLanguageModel();
|
||||
|
||||
const settings = mockCreateOpenAI.mock.calls[0][0];
|
||||
expect(settings.baseURL).toBe("https://api.openai.com/v1");
|
||||
expect(settings.apiKey).toBe("sk-test");
|
||||
expect(settings.organization).toBeUndefined();
|
||||
expect(settings.project).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,317 @@
|
||||
// Mock the modules first
|
||||
vi.mock("openai", () => {
|
||||
function MockOpenAI() {}
|
||||
return { default: MockOpenAI };
|
||||
});
|
||||
|
||||
// Mock the OpenAIAdapter class to avoid the "new OpenAI()" issue
|
||||
vi.mock("../../../src/service-adapters/openai/openai-adapter", () => {
|
||||
class MockOpenAIAdapter {
|
||||
_openai: any;
|
||||
model: string = "gpt-4o";
|
||||
keepSystemRole: boolean = false;
|
||||
disableParallelToolCalls: boolean = false;
|
||||
|
||||
constructor() {
|
||||
this._openai = {
|
||||
beta: {
|
||||
chat: {
|
||||
completions: {
|
||||
stream: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
get openai() {
|
||||
return this._openai;
|
||||
}
|
||||
|
||||
async process(request: any) {
|
||||
// Mock implementation that calls our event source but doesn't do the actual processing
|
||||
request.eventSource.stream(async (stream: any) => {
|
||||
stream.complete();
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
return { threadId: request.threadId || "mock-thread-id" };
|
||||
}
|
||||
}
|
||||
|
||||
return { OpenAIAdapter: MockOpenAIAdapter };
|
||||
});
|
||||
|
||||
// Mock the Message classes since they use TypeGraphQL decorators
|
||||
vi.mock("../../../src/graphql/types/converted", () => {
|
||||
// Create minimal implementations of the message classes
|
||||
class MockTextMessage {
|
||||
content: string;
|
||||
role: string;
|
||||
id: string;
|
||||
|
||||
constructor(options: { role: string; content: string }) {
|
||||
this.role = options.role;
|
||||
this.content = options.content;
|
||||
this.id = "mock-text-" + Math.random().toString(36).slice(7);
|
||||
}
|
||||
|
||||
isTextMessage() {
|
||||
return true;
|
||||
}
|
||||
isImageMessage() {
|
||||
return false;
|
||||
}
|
||||
isActionExecutionMessage() {
|
||||
return false;
|
||||
}
|
||||
isResultMessage() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class MockActionExecutionMessage {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
|
||||
constructor(params: { id: string; name: string; arguments: string }) {
|
||||
this.id = params.id;
|
||||
this.name = params.name;
|
||||
this.arguments = params.arguments;
|
||||
}
|
||||
|
||||
isTextMessage() {
|
||||
return false;
|
||||
}
|
||||
isImageMessage() {
|
||||
return false;
|
||||
}
|
||||
isActionExecutionMessage() {
|
||||
return true;
|
||||
}
|
||||
isResultMessage() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class MockResultMessage {
|
||||
actionExecutionId: string;
|
||||
result: string;
|
||||
id: string;
|
||||
|
||||
constructor(params: { actionExecutionId: string; result: string }) {
|
||||
this.actionExecutionId = params.actionExecutionId;
|
||||
this.result = params.result;
|
||||
this.id = "mock-result-" + Math.random().toString(36).slice(7);
|
||||
}
|
||||
|
||||
isTextMessage() {
|
||||
return false;
|
||||
}
|
||||
isImageMessage() {
|
||||
return false;
|
||||
}
|
||||
isActionExecutionMessage() {
|
||||
return false;
|
||||
}
|
||||
isResultMessage() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
TextMessage: MockTextMessage,
|
||||
ActionExecutionMessage: MockActionExecutionMessage,
|
||||
ResultMessage: MockResultMessage,
|
||||
Role: {
|
||||
assistant: "assistant",
|
||||
developer: "developer",
|
||||
system: "system",
|
||||
tool: "tool",
|
||||
user: "user",
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
// Now import the modules (vi.mock is hoisted above imports, so these get the mocked versions)
|
||||
import { OpenAIAdapter } from "../../../src/service-adapters/openai/openai-adapter";
|
||||
import {
|
||||
TextMessage,
|
||||
ActionExecutionMessage,
|
||||
ResultMessage,
|
||||
Role,
|
||||
} from "../../../src/graphql/types/converted";
|
||||
|
||||
describe("OpenAIAdapter", () => {
|
||||
let adapter: OpenAIAdapter;
|
||||
let mockEventSource: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
adapter = new OpenAIAdapter();
|
||||
mockEventSource = {
|
||||
stream: vi.fn((callback) => {
|
||||
const mockStream = {
|
||||
sendTextMessageStart: vi.fn(),
|
||||
sendTextMessageContent: vi.fn(),
|
||||
sendTextMessageEnd: vi.fn(),
|
||||
sendActionExecutionStart: vi.fn(),
|
||||
sendActionExecutionArgs: vi.fn(),
|
||||
sendActionExecutionEnd: vi.fn(),
|
||||
complete: vi.fn(),
|
||||
};
|
||||
callback(mockStream);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
describe("Tool ID handling", () => {
|
||||
it("should filter out tool_result messages that don't have corresponding tool_call IDs", async () => {
|
||||
// Create messages including one valid pair and one invalid tool_result
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
const userMessage = new TextMessage({
|
||||
role: Role.user,
|
||||
content: "User message",
|
||||
});
|
||||
|
||||
// Valid tool execution message
|
||||
const validToolExecution = new ActionExecutionMessage({
|
||||
id: "valid-tool-id",
|
||||
name: "validTool",
|
||||
arguments: '{"arg":"value"}',
|
||||
});
|
||||
|
||||
// Valid result for the above tool
|
||||
const validToolResult = new ResultMessage({
|
||||
actionExecutionId: "valid-tool-id",
|
||||
result: '{"result":"success"}',
|
||||
});
|
||||
|
||||
// Invalid tool result with no corresponding tool execution
|
||||
const invalidToolResult = new ResultMessage({
|
||||
actionExecutionId: "invalid-tool-id",
|
||||
result: '{"result":"failure"}',
|
||||
});
|
||||
|
||||
// Spy on the process method to test it's called properly
|
||||
const processSpy = vi.spyOn(adapter, "process");
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
systemMessage,
|
||||
userMessage,
|
||||
validToolExecution,
|
||||
validToolResult,
|
||||
invalidToolResult,
|
||||
],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
// Verify the process method was called
|
||||
expect(processSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify the stream function was called
|
||||
expect(mockEventSource.stream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle duplicate tool IDs by only using each once", async () => {
|
||||
// Create messages including duplicate tool results for the same ID
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
|
||||
// Valid tool execution message
|
||||
const toolExecution = new ActionExecutionMessage({
|
||||
id: "tool-id-1",
|
||||
name: "someTool",
|
||||
arguments: '{"arg":"value"}',
|
||||
});
|
||||
|
||||
// Two results for the same tool ID
|
||||
const firstToolResult = new ResultMessage({
|
||||
actionExecutionId: "tool-id-1",
|
||||
result: '{"result":"first"}',
|
||||
});
|
||||
|
||||
const duplicateToolResult = new ResultMessage({
|
||||
actionExecutionId: "tool-id-1",
|
||||
result: '{"result":"duplicate"}',
|
||||
});
|
||||
|
||||
// Spy on the process method to test it's called properly
|
||||
const processSpy = vi.spyOn(adapter, "process");
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
model: "gpt-4o",
|
||||
messages: [
|
||||
systemMessage,
|
||||
toolExecution,
|
||||
firstToolResult,
|
||||
duplicateToolResult,
|
||||
],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
// Verify the process method was called
|
||||
expect(processSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify the stream function was called
|
||||
expect(mockEventSource.stream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should call the stream method on eventSource", async () => {
|
||||
// Create messages
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
const userMessage = new TextMessage({
|
||||
role: Role.user,
|
||||
content: "User message",
|
||||
});
|
||||
|
||||
await adapter.process({
|
||||
threadId: "test-thread",
|
||||
model: "gpt-4o",
|
||||
messages: [systemMessage, userMessage],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
// Verify the stream function was called
|
||||
expect(mockEventSource.stream).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should return the provided threadId", async () => {
|
||||
// Create a message
|
||||
const systemMessage = new TextMessage({
|
||||
role: Role.system,
|
||||
content: "System message",
|
||||
});
|
||||
|
||||
const result = await adapter.process({
|
||||
threadId: "test-thread",
|
||||
model: "gpt-4o",
|
||||
messages: [systemMessage],
|
||||
actions: [],
|
||||
eventSource: mockEventSource,
|
||||
forwardedParameters: {},
|
||||
});
|
||||
|
||||
expect(result.threadId).toBe("test-thread");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { getSdkClientOptions } from "../../../src/service-adapters/shared/sdk-client-utils";
|
||||
|
||||
describe("getSdkClientOptions()", () => {
|
||||
it("extracts defaultHeaders and fetch from _options", () => {
|
||||
const customFetch = vi.fn();
|
||||
const client = {
|
||||
_options: {
|
||||
defaultHeaders: { "x-api-key": "secret" },
|
||||
fetch: customFetch,
|
||||
},
|
||||
};
|
||||
|
||||
const result = getSdkClientOptions(client);
|
||||
expect(result.defaultHeaders).toEqual({ "x-api-key": "secret" });
|
||||
expect(result.fetch).toBe(customFetch);
|
||||
});
|
||||
|
||||
it("returns empty object when _options is missing", () => {
|
||||
const client = { baseURL: "https://api.example.com" };
|
||||
const result = getSdkClientOptions(client);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object when _options is null", () => {
|
||||
const client = { _options: null };
|
||||
const result = getSdkClientOptions(client);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
|
||||
it("returns empty object when _options is a primitive", () => {
|
||||
const client = { _options: "not-an-object" };
|
||||
const result = getSdkClientOptions(client);
|
||||
expect(result).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
// Import reflect-metadata to support TypeGraphQL
|
||||
import "reflect-metadata";
|
||||
|
||||
import { vi } from "vitest";
|
||||
|
||||
// Suppress console output during tests
|
||||
vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "vitest/globals"],
|
||||
"esModuleInterop": true,
|
||||
"noEmit": true,
|
||||
"allowJs": true
|
||||
},
|
||||
"include": ["./**/*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user